774 lines
25 KiB
JavaScript
774 lines
25 KiB
JavaScript
/**
|
||
* 镭射卡批量预览 — Canvas 合成(人物清晰 + 强镭射叠层)
|
||
* 物理衍射方程参考: Alan Zucconi CD-ROM Shader
|
||
* https://www.alanzucconi.com/2017/07/15/cd-rom-shader-1/
|
||
*/
|
||
|
||
import { LASER_BATCH_EXPORT_SIZE, PRESET_VARIANTS } from './laserPresets.js'
|
||
import { resolveGratingConfig } from './laserGrating.js'
|
||
|
||
const EXPORT_W = LASER_BATCH_EXPORT_SIZE.w
|
||
const EXPORT_H = LASER_BATCH_EXPORT_SIZE.h
|
||
|
||
// ============================================================
|
||
// spectral_zucconi6 — Alan Zucconi 优化版 (JS版)
|
||
// 将可见光波长(400-700nm)映射到RGB
|
||
// ============================================================
|
||
function bump3y(x, yoffset) {
|
||
const y = [1 - x[0] * x[0], 1 - x[1] * x[1], 1 - x[2] * x[2]]
|
||
return [
|
||
Math.max(0, Math.min(1, y[0] - yoffset[0])),
|
||
Math.max(0, Math.min(1, y[1] - yoffset[1])),
|
||
Math.max(0, Math.min(1, y[2] - yoffset[2])),
|
||
]
|
||
}
|
||
|
||
function spectral_zucconi6(w) {
|
||
const x = Math.max(0, Math.min(1, (w - 400) / 300))
|
||
const c1 = [3.54585104, 2.93225262, 2.41593945]
|
||
const x1 = [0.69549072, 0.49228336, 0.2769988]
|
||
const y1 = [0.02312639, 0.15225084, 0.52607955]
|
||
const c2 = [3.9030714, 3.21182957, 3.96587128]
|
||
const x2 = [0.11748627, 0.86755042, 0.6607786]
|
||
const y2 = [0.8489713, 0.88445281, 0.73949448]
|
||
const b1 = bump3y([c1[0] * (x - x1[0]), c1[1] * (x - x1[1]), c1[2] * (x - x1[2])], y1)
|
||
const b2 = bump3y([c2[0] * (x - x2[0]), c2[1] * (x - x2[1]), c2[2] * (x - x2[2])], y2)
|
||
return [b1[0] + b2[0], b1[1] + b2[1], b1[2] + b2[2]]
|
||
}
|
||
|
||
const BLOB_PALETTES = {
|
||
dream: [
|
||
{ x: 0.1, y: 0.14, c0: 'rgba(186,104,255,0.55)', c1: 'rgba(186,104,255,0)' },
|
||
{ x: 0.9, y: 0.18, c0: 'rgba(255,105,180,0.48)', c1: 'rgba(255,105,180,0)' },
|
||
{ x: 0.14, y: 0.86, c0: 'rgba(0,229,255,0.42)', c1: 'rgba(0,229,255,0)' },
|
||
{ x: 0.82, y: 0.78, c0: 'rgba(255,224,140,0.38)', c1: 'rgba(255,224,140,0)' },
|
||
],
|
||
classic: [
|
||
{ x: 0.48, y: 0.1, c0: 'rgba(255,80,200,0.48)', c1: 'rgba(255,80,200,0)' },
|
||
{ x: 0.08, y: 0.58, c0: 'rgba(0,200,255,0.46)', c1: 'rgba(0,200,255,0)' },
|
||
{ x: 0.92, y: 0.76, c0: 'rgba(180,255,120,0.4)', c1: 'rgba(180,255,120,0)' },
|
||
],
|
||
ice: [
|
||
{ x: 0.18, y: 0.22, c0: 'rgba(100,180,255,0.52)', c1: 'rgba(100,180,255,0)' },
|
||
{ x: 0.88, y: 0.32, c0: 'rgba(160,140,255,0.44)', c1: 'rgba(160,140,255,0)' },
|
||
{ x: 0.5, y: 0.9, c0: 'rgba(200,240,255,0.4)', c1: 'rgba(200,240,255,0)' },
|
||
],
|
||
sunset: [
|
||
{ x: 0.12, y: 0.28, c0: 'rgba(255,150,120,0.5)', c1: 'rgba(255,150,120,0)' },
|
||
{ x: 0.9, y: 0.24, c0: 'rgba(255,200,140,0.46)', c1: 'rgba(255,200,140,0)' },
|
||
{ x: 0.52, y: 0.88, c0: 'rgba(255,100,160,0.42)', c1: 'rgba(255,100,160,0)' },
|
||
],
|
||
holoFull: [
|
||
{ x: 0.12, y: 0.16, c0: 'rgba(255,80,180,0.5)', c1: 'rgba(255,80,180,0)' },
|
||
{ x: 0.9, y: 0.22, c0: 'rgba(255,180,80,0.46)', c1: 'rgba(255,180,80,0)' },
|
||
{ x: 0.1, y: 0.78, c0: 'rgba(0,220,255,0.48)', c1: 'rgba(0,220,255,0)' },
|
||
{ x: 0.88, y: 0.82, c0: 'rgba(160,100,255,0.5)', c1: 'rgba(160,100,255,0)' },
|
||
{ x: 0.5, y: 0.5, c0: 'rgba(255,255,255,0.2)', c1: 'rgba(255,255,255,0)' },
|
||
],
|
||
}
|
||
|
||
const MARBLE_PALETTES = {
|
||
dream: [
|
||
{ x: 0.16, y: 0.26, rgb: [200, 170, 255] },
|
||
{ x: 0.84, y: 0.22, rgb: [255, 150, 210] },
|
||
{ x: 0.48, y: 0.88, rgb: [120, 210, 255] },
|
||
],
|
||
classic: [
|
||
{ x: 0.14, y: 0.2, rgb: [255, 100, 200] },
|
||
{ x: 0.86, y: 0.26, rgb: [200, 100, 255] },
|
||
{ x: 0.5, y: 0.86, rgb: [80, 220, 255] },
|
||
],
|
||
ice: [
|
||
{ x: 0.18, y: 0.24, rgb: [170, 210, 255] },
|
||
{ x: 0.82, y: 0.2, rgb: [110, 160, 255] },
|
||
{ x: 0.5, y: 0.88, rgb: [210, 235, 255] },
|
||
],
|
||
sunset: [
|
||
{ x: 0.14, y: 0.28, rgb: [255, 190, 160] },
|
||
{ x: 0.86, y: 0.24, rgb: [255, 130, 150] },
|
||
{ x: 0.5, y: 0.88, rgb: [255, 220, 180] },
|
||
],
|
||
holoFull: [
|
||
{ x: 0.12, y: 0.18, rgb: [255, 100, 200] },
|
||
{ x: 0.88, y: 0.2, rgb: [255, 180, 100] },
|
||
{ x: 0.1, y: 0.78, rgb: [80, 230, 255] },
|
||
{ x: 0.88, y: 0.8, rgb: [160, 100, 255] },
|
||
],
|
||
}
|
||
|
||
function getImageInfo(src) {
|
||
return new Promise((resolve, reject) => {
|
||
uni.getImageInfo({
|
||
src,
|
||
success: resolve,
|
||
fail: (e) => reject(e || new Error('读取图片失败')),
|
||
})
|
||
})
|
||
}
|
||
|
||
function computeCover(iw, ih, cw, ch) {
|
||
const w = Number(iw) || 1
|
||
const h = Number(ih) || 1
|
||
const scale = Math.max(cw / w, ch / h)
|
||
const dw = w * scale
|
||
const dh = h * scale
|
||
return { dx: (cw - dw) / 2, dy: (ch - dh) / 2, dw, dh }
|
||
}
|
||
|
||
function canvasDraw(ctx) {
|
||
return new Promise((resolve) => {
|
||
ctx.draw(false, () => setTimeout(resolve, 120))
|
||
})
|
||
}
|
||
|
||
function canvasToTemp(canvasId, w, h) {
|
||
return new Promise((resolve, reject) => {
|
||
uni.canvasToTempFilePath({
|
||
canvasId,
|
||
width: w,
|
||
height: h,
|
||
destWidth: w * 2,
|
||
destHeight: h * 2,
|
||
fileType: 'jpg',
|
||
quality: 0.96,
|
||
success: (res) => resolve(res.tempFilePath),
|
||
fail: (e) => reject(e || new Error('导出失败')),
|
||
})
|
||
})
|
||
}
|
||
|
||
function clipRoundRect(ctx, cw, ch) {
|
||
const r = Math.min(32, cw * 0.065, ch * 0.048)
|
||
ctx.beginPath()
|
||
ctx.moveTo(r, 0)
|
||
ctx.lineTo(cw - r, 0)
|
||
ctx.arc(cw - r, r, r, -Math.PI / 2, 0, false)
|
||
ctx.lineTo(cw, ch - r)
|
||
ctx.arc(cw - r, ch - r, r, 0, Math.PI / 2, false)
|
||
ctx.lineTo(r, ch)
|
||
ctx.arc(r, ch - r, r, Math.PI / 2, Math.PI, false)
|
||
ctx.lineTo(0, r)
|
||
ctx.arc(r, r, r, Math.PI, Math.PI * 1.5, false)
|
||
ctx.closePath()
|
||
ctx.clip()
|
||
}
|
||
|
||
function setBlend(ctx, mode) {
|
||
if (typeof ctx.setGlobalCompositeOperation === 'function') {
|
||
ctx.setGlobalCompositeOperation(mode)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* uni-app 旧版 canvas(App/小程序)无 createRadialGradient,需兼容 createCircularGradient 或线性渐变
|
||
*/
|
||
function createRadialGradientCompat(ctx, cx, cy, innerR, outerR) {
|
||
const ox = Number(cx) || 0
|
||
const oy = Number(cy) || 0
|
||
const r0 = Math.max(0, Number(innerR) || 0)
|
||
const r1 = Math.max(r0 + 0.5, Number(outerR) || 1)
|
||
if (typeof ctx.createRadialGradient === 'function') {
|
||
return ctx.createRadialGradient(ox, oy, r0, ox, oy, r1)
|
||
}
|
||
if (typeof ctx.createCircularGradient === 'function') {
|
||
return ctx.createCircularGradient(ox, oy, r1)
|
||
}
|
||
return ctx.createLinearGradient(ox - r1, oy - r1, ox + r1, oy + r1)
|
||
}
|
||
|
||
function hslToRgba(h, s, l, a) {
|
||
const hh = ((Number(h) % 360) + 360) % 360
|
||
const ss = Math.max(0, Math.min(100, s)) / 100
|
||
const ll = Math.max(0, Math.min(100, l)) / 100
|
||
const c = (1 - Math.abs(2 * ll - 1)) * ss
|
||
const x = c * (1 - Math.abs(((hh / 60) % 2) - 1))
|
||
const m = ll - c / 2
|
||
let rp = 0
|
||
let gp = 0
|
||
let bp = 0
|
||
if (hh < 60) {
|
||
rp = c
|
||
gp = x
|
||
} else if (hh < 120) {
|
||
rp = x
|
||
gp = c
|
||
} else if (hh < 180) {
|
||
gp = c
|
||
bp = x
|
||
} else if (hh < 240) {
|
||
gp = x
|
||
bp = c
|
||
} else if (hh < 300) {
|
||
rp = x
|
||
bp = c
|
||
} else {
|
||
rp = c
|
||
bp = x
|
||
}
|
||
const r = Math.round(255 * (rp + m))
|
||
const g = Math.round(255 * (gp + m))
|
||
const b = Math.round(255 * (bp + m))
|
||
const aa = Math.max(0, Math.min(1, a))
|
||
return `rgba(${r},${g},${b},${aa.toFixed(3)})`
|
||
}
|
||
|
||
function linearGradientThroughPivot(ctx, cw, ch, pivotPctX, pivotPctY, deg, stops) {
|
||
const rad = (deg * Math.PI) / 180
|
||
const len = Math.sqrt(cw * cw + ch * ch) / 2
|
||
const px = (Math.max(0, Math.min(100, pivotPctX)) / 100) * cw
|
||
const py = (Math.max(0, Math.min(100, pivotPctY)) / 100) * ch
|
||
const x0 = px - Math.cos(rad) * len
|
||
const y0 = py - Math.sin(rad) * len
|
||
const x1 = px + Math.cos(rad) * len
|
||
const y1 = py + Math.sin(rad) * len
|
||
const g = ctx.createLinearGradient(x0, y0, x1, y1)
|
||
stops.forEach(([t, c]) => g.addColorStop(t, c))
|
||
return g
|
||
}
|
||
|
||
function drawSolidLaserBackdrop(ctx, cw, ch, preset) {
|
||
// 已弃用:保留空实现以兼容旧调用
|
||
void ctx; void cw; void ch; void preset
|
||
}
|
||
|
||
function drawHslRainbowBase(ctx, cw, ch, preset, timeMs) {
|
||
// 已弃用:保留空实现以兼容旧调用
|
||
void ctx; void cw; void ch; void preset; void timeMs
|
||
}
|
||
|
||
function drawMarbleFlow(ctx, cw, ch, preset) {
|
||
// 已弃用:保留空实现以兼容旧调用
|
||
void ctx; void cw; void ch; void preset
|
||
}
|
||
|
||
function drawSolidPastelBackdrop(ctx, cw, ch, backdropTone) {
|
||
ctx.setFillStyle(backdropTone)
|
||
ctx.fillRect(0, 0, cw, ch)
|
||
}
|
||
|
||
/**
|
||
* 真实镭射卡:单条对角色带扫过(与 WebGL shader 物理一致)
|
||
* 物理:色带沿光栅法线方向扫过;色带内部按 wavelength = 400-700nm 渐变
|
||
*
|
||
* @param {CanvasRenderingContext2D} ctx
|
||
* @param {number} cw canvas width
|
||
* @param {number} ch canvas height
|
||
* @param {object} preset laser preset
|
||
* @param {number} variantIndex variant index (0-4)
|
||
* @param {number} timeMs current time in ms (for animation phase)
|
||
* @param {number} viewAngleRad 视角角度(弧度),固定视角用于静态导出
|
||
*/
|
||
function drawDiffractionStripes(ctx, cw, ch, preset, variantIndex, timeMs, viewAngleRad = 0.3) {
|
||
const cfg = resolveGratingConfig(preset, variantIndex)
|
||
const intensity = Math.max(0, Math.min(0.6, Number(cfg.stripeIntensity ?? 0.32)))
|
||
if (intensity < 0.05) return
|
||
|
||
const deg = cfg.sheenBandAngle ?? cfg.stripeAngle ?? 135
|
||
const rad = (deg * Math.PI) / 180
|
||
const normalX = -Math.sin(rad)
|
||
const normalY = Math.cos(rad)
|
||
const tangentX = Math.cos(rad)
|
||
const tangentY = Math.sin(rad)
|
||
|
||
// 色带沿法线方向扫过的速度(远慢于旧版,避免"飞过去")
|
||
const flow = ((timeMs || 0) * 0.00018 * Math.max(0.15, cfg.flowSpeed)) % 1
|
||
// 色带中心 0.5 在卡面投影位置(摆动 -0.3 ~ 0.3)
|
||
const bandCenter = 0.0 + Math.sin(flow * Math.PI * 2) * 0.18
|
||
// 色带宽度(占对角线比例)
|
||
const bandHalfWidth = 0.30
|
||
|
||
// 沿法线方向以 sub-pixel 步长采样
|
||
const stepSize = 2
|
||
const diag = Math.sqrt(cw * cw + ch * ch)
|
||
const halfDiag = diag / 2
|
||
const range = diag * 0.55
|
||
|
||
// 把屏幕空间投影到法线/切线坐标
|
||
// p(uv) = (cw/2, ch/2) + normal * (projNormal) + tangent * (projTangent)
|
||
// 循环 p: 对每个垂直于法线的扫描线,沿切线方向绘制
|
||
ctx.save()
|
||
setBlend(ctx, 'screen')
|
||
|
||
for (let n = -range; n <= range; n += stepSize) {
|
||
// 当前扫描线在切线方向上对应卡上的 y 位置
|
||
const projNormal = n
|
||
const phaseAlong = (projNormal / diag + 0.5) + flow * 0.6 // 0-1
|
||
const dist = Math.abs(phaseAlong - 0.5 - bandCenter)
|
||
if (dist > bandHalfWidth + 0.04) continue
|
||
|
||
// 色带高斯形
|
||
const bandShape = Math.exp(-(dist * dist) / (bandHalfWidth * bandHalfWidth * 0.18))
|
||
|
||
// 沿切线方向整条色带:以这条线为"基准",从一端到另一端
|
||
// 色带在切线方向上颜色是均匀的(同一扫描线相同色相)
|
||
// 但 view 视角决定色相偏移
|
||
const hueOffset = (Math.sin(viewAngleRad * 0.7) * 0.05 + 0) // 微小偏移
|
||
// 波长从红(400nm端)→紫(700nm端),沿投影位置 gradient
|
||
// projNormal = -range → wavelength = 700(紫)
|
||
// projNormal = +range → wavelength = 400(红)
|
||
const wavelength = 400 + ((n + range) / (range * 2)) * 300
|
||
const wClamped = Math.max(400, Math.min(700, wavelength))
|
||
const [r, g, b] = spectral_zucconi6(wClamped)
|
||
|
||
const a = bandShape * intensity
|
||
if (a < 0.01) continue
|
||
|
||
// 沿切线方向画一条横向色带(很窄)
|
||
// 转换: 屏幕坐标 (cx + tangent * t + normal * projNormal) for t in [-halfDiag, halfDiag]
|
||
// 用 fillRect 近似:画一条横向像素条
|
||
const x0 = cw / 2 + tangentX * (-halfDiag) + normalX * n
|
||
const y0 = ch / 2 + tangentY * (-halfDiag) + normalY * n
|
||
const x1 = cw / 2 + tangentX * (halfDiag) + normalX * n
|
||
const y1 = ch / 2 + tangentY * (halfDiag) + normalY * n
|
||
|
||
const wLen = Math.sqrt((x1 - x0) * (x1 - x0) + (y1 - y0) * (y1 - y0))
|
||
const ang = Math.atan2(y1 - y0, x1 - x0) * 180 / Math.PI
|
||
|
||
ctx.save()
|
||
ctx.translate(x0, y0)
|
||
ctx.rotate((ang * Math.PI) / 180)
|
||
|
||
// 沿这条线做一个极轻的 hue gradient(让色带略宽一点显得不机械)
|
||
const grad = ctx.createLinearGradient(0, -stepSize * 0.6, 0, stepSize * 0.6)
|
||
grad.addColorStop(0, `rgba(${Math.round(r * 255)},${Math.round(g * 255)},${Math.round(b * 255)},0)`)
|
||
grad.addColorStop(0.5, `rgba(${Math.round(r * 255)},${Math.round(g * 255)},${Math.round(b * 255)},${a.toFixed(3)})`)
|
||
grad.addColorStop(1, `rgba(${Math.round(r * 255)},${Math.round(g * 255)},${Math.round(b * 255)},0)`)
|
||
ctx.setFillStyle(grad)
|
||
ctx.fillRect(0, 0, wLen, stepSize * 1.2)
|
||
|
||
ctx.restore()
|
||
|
||
// 抑制未使用警告
|
||
void hueOffset
|
||
}
|
||
|
||
ctx.restore()
|
||
ctx.setGlobalAlpha(1)
|
||
setBlend(ctx, 'source-over')
|
||
}
|
||
|
||
function drawOilSweep(ctx, cw, ch, preset, timeMs, intensityMult = 1) {
|
||
// 已弃用:保留空实现以兼容旧调用
|
||
void ctx; void cw; void ch; void preset; void timeMs; void intensityMult
|
||
}
|
||
|
||
function drawHueFlowBeams(ctx, cw, ch, preset, timeMs) {
|
||
// 已弃用:保留空实现以兼容旧调用
|
||
void ctx; void cw; void ch; void preset; void timeMs
|
||
}
|
||
|
||
function drawDensePixelNoise(ctx, cw, ch, preset, seed) {
|
||
// 已弃用:保留空实现以兼容旧调用
|
||
void ctx; void cw; void ch; void preset; void seed
|
||
}
|
||
|
||
function drawFilmGrain(ctx, cw, ch, seed, maxDots = 90) {
|
||
let s = seed + 17
|
||
const rnd = () => {
|
||
s = (s * 1103515245 + 12345) & 0x7fffffff
|
||
return s / 0x7fffffff
|
||
}
|
||
const n = Math.max(0, Math.floor(maxDots))
|
||
for (let i = 0; i < n; i++) {
|
||
const x = rnd() * cw
|
||
const y = rnd() * ch
|
||
ctx.setGlobalAlpha(0.08 + rnd() * 0.12)
|
||
ctx.setFillStyle('#ffffff')
|
||
ctx.beginPath()
|
||
ctx.arc(x, y, rnd() * 1.5 + 0.3, 0, Math.PI * 2)
|
||
ctx.fill()
|
||
}
|
||
ctx.setGlobalAlpha(1)
|
||
}
|
||
|
||
/** 磨砂哑光层:细颗粒 + 轻雾面(静态导出快照) */
|
||
function drawFrostMatte(ctx, cw, ch, preset, seed, intensityMult = 1) {
|
||
const frost = ((Number(preset.frostIntensity) || 55) / 100) * Math.max(0.1, Math.min(1.5, Number(intensityMult) || 1))
|
||
if (frost < 0.04) return
|
||
|
||
let s = seed + 911
|
||
const rnd = () => {
|
||
s = (s * 9301 + 49297) % 233280
|
||
return s / 233280
|
||
}
|
||
|
||
const n = Math.floor(3500 + frost * 7500)
|
||
ctx.save()
|
||
setBlend(ctx, 'soft-light')
|
||
ctx.setGlobalAlpha(0.35 + frost * 0.25)
|
||
for (let k = 0; k < n; k++) {
|
||
const x = Math.floor(rnd() * cw)
|
||
const y = Math.floor(rnd() * ch)
|
||
const v = 190 + Math.floor(rnd() * 65)
|
||
const a = 0.022 + rnd() * 0.055
|
||
ctx.setFillStyle(`rgba(${v},${v},${Math.min(255, v + 6)},${a.toFixed(3)})`)
|
||
ctx.fillRect(x, y, 1, 1)
|
||
}
|
||
ctx.restore()
|
||
|
||
ctx.save()
|
||
setBlend(ctx, 'soft-light')
|
||
ctx.setGlobalAlpha(0.04 + frost * 0.07)
|
||
const g = ctx.createLinearGradient(0, 0, cw, ch)
|
||
g.addColorStop(0, 'rgba(255,255,255,0.14)')
|
||
g.addColorStop(0.45, 'rgba(240,245,255,0.04)')
|
||
g.addColorStop(1, 'rgba(255,255,255,0.11)')
|
||
ctx.setFillStyle(g)
|
||
ctx.fillRect(0, 0, cw, ch)
|
||
ctx.restore()
|
||
|
||
setBlend(ctx, 'source-over')
|
||
ctx.setGlobalAlpha(1)
|
||
}
|
||
|
||
/** 沿人像轮廓画一圈彩虹光晕(镭射卡最显眼的视觉信号)
|
||
* 不使用 ctx.ellipse(部分平台如微信小程序不支持),改用 arc + 直线段
|
||
* 用 cover 矩形外偏 + 圆角近似人像轮廓
|
||
*/
|
||
function drawPersonEdgeHalo(ctx, cw, ch, layout, seed) {
|
||
if (!layout) return
|
||
const lx = Math.floor(layout.dx)
|
||
const ly = Math.floor(layout.dy)
|
||
const lw = Math.floor(layout.dw)
|
||
const lh = Math.floor(layout.dh)
|
||
// 略大于人像框的圆角矩形
|
||
const pad = 6
|
||
const x0 = lx - pad
|
||
const y0 = ly - pad
|
||
const w0 = lw + pad * 2
|
||
const h0 = lh + pad * 2
|
||
const r = Math.min(w0, h0) * 0.20
|
||
const haloW = 5 // 光晕半径(每颗彩色小圆点)
|
||
|
||
const segs = 72
|
||
let s = seed + 41
|
||
const rnd = () => {
|
||
s = (s * 1103515245 + 12345) & 0x7fffffff
|
||
return s / 0x7fffffff
|
||
}
|
||
|
||
ctx.save()
|
||
setBlend(ctx, 'screen')
|
||
|
||
// 沿圆角矩形轮廓生成 72 颗彩虹小圆点
|
||
for (let i = 0; i < segs; i++) {
|
||
const t = i / segs
|
||
const hue = ((t * 360) + rnd() * 12) % 360
|
||
const a = 0.32
|
||
ctx.setGlobalAlpha(a)
|
||
ctx.setFillStyle(hslToRgba(hue, 88, 62, a))
|
||
const [px, py] = pointOnRoundedRect(x0, y0, w0, h0, r, t)
|
||
ctx.beginPath()
|
||
ctx.arc(px, py, haloW, 0, Math.PI * 2)
|
||
ctx.fill()
|
||
}
|
||
|
||
// 在四角加 4 颗稍大的彩色亮光(强化"镭射"信号)
|
||
const corners = [
|
||
[x0 + r, y0 + r],
|
||
[x0 + w0 - r, y0 + r],
|
||
[x0 + w0 - r, y0 + h0 - r],
|
||
[x0 + r, y0 + h0 - r],
|
||
]
|
||
corners.forEach(([cx, cy], i) => {
|
||
const hue = (i * 90 + rnd() * 30) % 360
|
||
const a = 0.45
|
||
ctx.setGlobalAlpha(a)
|
||
ctx.setFillStyle(hslToRgba(hue, 90, 65, a))
|
||
ctx.beginPath()
|
||
ctx.arc(cx, cy, haloW * 1.4, 0, Math.PI * 2)
|
||
ctx.fill()
|
||
})
|
||
|
||
ctx.restore()
|
||
ctx.setGlobalAlpha(1)
|
||
}
|
||
|
||
/** 圆角矩形上 t∈[0,1] 处的轮廓点(不依赖 ctx.ellipse) */
|
||
function pointOnRoundedRect(x, y, w, h, r, t) {
|
||
const lineTop = w
|
||
const lineRight = h
|
||
const lineBot = w
|
||
const lineLeft = h
|
||
const corner = (Math.PI * r) / 2
|
||
const lens = [lineTop, corner, lineRight, corner, lineBot, corner, lineLeft, corner]
|
||
const total = lens.reduce((a, b) => a + b, 0)
|
||
const d = t * total
|
||
let acc = 0
|
||
for (let i = 0; i < lens.length; i++) {
|
||
const segLen = lens[i]
|
||
if (acc + segLen >= d) {
|
||
const local = d - acc
|
||
const isLine = i % 2 === 0
|
||
if (isLine) {
|
||
// 直线段:4 个方向(顺时针)
|
||
if (i === 0) return [x + r + local, y] // 上边 →
|
||
if (i === 2) return [x + w, y + r + local] // 右边 ↓
|
||
if (i === 4) return [x + w - r - local, y + h] // 下边 ←
|
||
return [x, y + h - r - local] // 左边 ↑
|
||
} else {
|
||
// 圆角段:4 个角
|
||
const cornerIdx = (i - 1) / 2 // 0=tr 1=br 2=bl 3=tl
|
||
const [ccx, ccy, a0] = cornerInfo(x, y, w, h, r, cornerIdx)
|
||
const a = a0 + (local / segLen) * (Math.PI / 2)
|
||
return [ccx + Math.cos(a) * r, ccy + Math.sin(a) * r]
|
||
}
|
||
}
|
||
acc += segLen
|
||
}
|
||
return [x + r, y]
|
||
}
|
||
|
||
function cornerInfo(x, y, w, h, r, idx) {
|
||
// idx: 0=tr 1=br 2=bl 3=tl
|
||
if (idx === 0) return [x + w - r, y + r, 0] // 右上:起点 0°
|
||
if (idx === 1) return [x + w - r, y + h - r, Math.PI / 2] // 右下
|
||
if (idx === 2) return [x + r, y + h - r, Math.PI] // 左下
|
||
return [x + r, y + r, -Math.PI / 2] // 左上
|
||
}
|
||
|
||
/** 银粉星点散点层(高频亮点,模拟金属膜细反光)*/
|
||
function drawSilverSparkle(ctx, cw, ch, seed, densityMult = 1) {
|
||
let s = seed + 2003
|
||
const rnd = () => {
|
||
s = (s * 1103515245 + 12345) & 0x7fffffff
|
||
return s / 0x7fffffff
|
||
}
|
||
|
||
const layerA = Math.floor(160 * densityMult) // 密集细点
|
||
const layerB = Math.floor(40 * densityMult) // 中等亮点
|
||
|
||
ctx.save()
|
||
setBlend(ctx, 'screen')
|
||
|
||
// Layer A:8x8 像素 1 颗细点
|
||
for (let k = 0; k < layerA; k++) {
|
||
const x = rnd() * cw
|
||
const y = rnd() * ch
|
||
const r = 0.4 + rnd() * 0.6
|
||
const a = 0.35 + rnd() * 0.25
|
||
ctx.setGlobalAlpha(a)
|
||
ctx.setFillStyle(`rgba(245,248,255,${a.toFixed(3)})`)
|
||
ctx.beginPath()
|
||
ctx.arc(x, y, r, 0, Math.PI * 2)
|
||
ctx.fill()
|
||
}
|
||
|
||
// Layer B:16x16 像素 1 颗,偶尔彩色
|
||
for (let k = 0; k < layerB; k++) {
|
||
const x = rnd() * cw
|
||
const y = rnd() * ch
|
||
const r = 0.7 + rnd() * 1.3
|
||
const isColored = rnd() > 0.65
|
||
const a = 0.55 + rnd() * 0.30
|
||
ctx.setGlobalAlpha(a)
|
||
if (isColored) {
|
||
const hue = rnd() * 360
|
||
ctx.setFillStyle(hslToRgba(hue, 85, 70, a))
|
||
} else {
|
||
ctx.setFillStyle(`rgba(255,255,255,${a.toFixed(3)})`)
|
||
}
|
||
ctx.beginPath()
|
||
ctx.arc(x, y, r, 0, Math.PI * 2)
|
||
ctx.fill()
|
||
// 偶有十字星
|
||
if (rnd() > 0.7) {
|
||
ctx.setGlobalAlpha(a * 0.7)
|
||
ctx.fillRect(x - r * 3.5, y - r * 0.4, r * 7, r * 0.8)
|
||
ctx.fillRect(x - r * 0.4, y - r * 3.5, r * 0.8, r * 7)
|
||
}
|
||
}
|
||
|
||
ctx.restore()
|
||
ctx.setGlobalAlpha(1)
|
||
}
|
||
|
||
/** 金属反光梯度:在底色上叠一条柔和的对角高光(让白底变银底)*/
|
||
function drawMetallicSheen(ctx, cw, ch, cfg) {
|
||
const deg = (cfg.sheenBandAngle ?? cfg.stripeAngle ?? 135) + 90 // 与色带垂直
|
||
const rad = (deg * Math.PI) / 180
|
||
const len = Math.sqrt(cw * cw + ch * ch)
|
||
const cx = cw / 2
|
||
const cy = ch / 2
|
||
const x0 = cx - Math.cos(rad) * len / 2
|
||
const y0 = cy - Math.sin(rad) * len / 2
|
||
const x1 = cx + Math.cos(rad) * len / 2
|
||
const y1 = cy + Math.sin(rad) * len / 2
|
||
const g = ctx.createLinearGradient(x0, y0, x1, y1)
|
||
g.addColorStop(0.30, 'rgba(255,255,255,0)')
|
||
g.addColorStop(0.50, 'rgba(220,230,240,0.22)')
|
||
g.addColorStop(0.70, 'rgba(255,255,255,0)')
|
||
ctx.save()
|
||
setBlend(ctx, 'screen')
|
||
ctx.setGlobalAlpha(1)
|
||
ctx.setFillStyle(g)
|
||
ctx.fillRect(0, 0, cw, ch)
|
||
ctx.restore()
|
||
setBlend(ctx, 'source-over')
|
||
ctx.setGlobalAlpha(1)
|
||
}
|
||
|
||
/** 人物边缘镭射闪光:物理衍射彩虹星,沿人像中心环形区域分布(静态版) */
|
||
function drawSparkleEdge(ctx, cw, ch, imagePath, layout, seed, intensity) {
|
||
const amount = Math.max(0, Math.min(1, Number(intensity) || 0))
|
||
if (amount < 0.01 || !layout) return
|
||
|
||
let s = seed + 777
|
||
const rnd = () => {
|
||
s = (s * 1103515245 + 12345) & 0x7fffffff
|
||
return s / 0x7fffffff
|
||
}
|
||
|
||
const lx = Math.floor(layout.dx)
|
||
const ly = Math.floor(layout.dy)
|
||
const lw = Math.floor(layout.dw)
|
||
const lh = Math.floor(layout.dh)
|
||
const cx = lx + lw / 2
|
||
const cy = ly + lh / 2
|
||
|
||
ctx.save()
|
||
setBlend(ctx, 'screen')
|
||
|
||
const r = Math.max(lw, lh) * 0.55
|
||
// 镭射彩虹:模拟衍射角分布,HSL 360° 映射到彩虹光谱
|
||
const n = Math.floor(100 + amount * 180)
|
||
for (let i = 0; i < n; i++) {
|
||
const angle = rnd() * Math.PI * 2
|
||
const dist = r * (0.40 + rnd() * 0.95)
|
||
const px = cx + Math.cos(angle) * dist
|
||
const py = cy + Math.sin(angle) * dist
|
||
|
||
if (px < -20 || px > cw + 20 || py < -20 || py > ch + 20) continue
|
||
|
||
// 镭射彩虹色:基于角度映射到衍射角度 → 对应可见光波长
|
||
// 衍射角越大,波长越短(红→紫),这里用 angle 映射 hue
|
||
const diffractionHue = ((angle * 180 / Math.PI) + 360) % 360
|
||
const hue = diffractionHue
|
||
const sat = 85 + rnd() * 12
|
||
const light = 72 + rnd() * 10
|
||
|
||
const outerR = 2.0 + rnd() * 5.0
|
||
const innerR = outerR * (0.35 + rnd() * 0.25)
|
||
const a = (0.4 + rnd() * 0.6) * amount
|
||
const starType = Math.floor(rnd() * 3)
|
||
|
||
ctx.setGlobalAlpha(a)
|
||
ctx.setFillStyle(hslToRgba(hue, sat, light, a))
|
||
|
||
if (starType === 0) {
|
||
// 空心圆环(衍射圈)
|
||
ctx.beginPath()
|
||
ctx.arc(px, py, outerR, 0, Math.PI * 2)
|
||
ctx.arc(px, py, innerR, 0, Math.PI * 2, true)
|
||
ctx.fill()
|
||
} else if (starType === 1) {
|
||
// 空心十字(镭射十字星)
|
||
ctx.fillRect(px - outerR, py - outerR * 0.18, outerR * 2, outerR * 0.36)
|
||
ctx.fillRect(px - outerR * 0.18, py - outerR, outerR * 0.36, outerR * 2)
|
||
ctx.setFillStyle(hslToRgba((hue + 40) % 360, sat, light * 0.9, a * 0.85))
|
||
ctx.beginPath()
|
||
ctx.arc(px, py, innerR * 1.6, 0, Math.PI * 2)
|
||
ctx.fill()
|
||
} else {
|
||
// 空心菱形(衍射棱镜形)
|
||
ctx.beginPath()
|
||
ctx.moveTo(px, py - outerR)
|
||
ctx.lineTo(px + outerR * 0.65, py)
|
||
ctx.lineTo(px, py + outerR)
|
||
ctx.lineTo(px - outerR * 0.65, py)
|
||
ctx.closePath()
|
||
ctx.fill()
|
||
ctx.setFillStyle(hslToRgba((hue + 60) % 360, sat, light * 0.9, a * 0.80))
|
||
ctx.beginPath()
|
||
ctx.moveTo(px, py - innerR)
|
||
ctx.lineTo(px + innerR * 0.65, py)
|
||
ctx.lineTo(px, py + innerR)
|
||
ctx.lineTo(px - innerR * 0.65, py)
|
||
ctx.closePath()
|
||
ctx.fill()
|
||
}
|
||
}
|
||
|
||
ctx.restore()
|
||
ctx.setGlobalAlpha(1)
|
||
}
|
||
|
||
/** grating 模式(真实镭射卡):单色带 + 极轻磨砂 + 极弱人物边缘闪粉 */
|
||
function drawGratingFinish(ctx, cw, ch, preset, timeMs, variantIndex) {
|
||
drawDiffractionStripes(ctx, cw, ch, preset, variantIndex, timeMs, 0.3)
|
||
drawFilmGrain(ctx, cw, ch, variantIndex * 313, 8)
|
||
drawFrostMatte(ctx, cw, ch, preset, variantIndex * 1201 + 77, 0.18)
|
||
}
|
||
|
||
function drawRichLaserFinish(ctx, cw, ch, preset, timeMs, variantIndex) {
|
||
// 与 grating 模式一致:单色带 + 极轻磨砂
|
||
drawGratingFinish(ctx, cw, ch, preset, timeMs, variantIndex)
|
||
}
|
||
|
||
async function drawLaserVariantComposite(ctx, cw, ch, imagePath, layout, preset, variantIndex) {
|
||
const timeMs = Date.now() + variantIndex * 1379
|
||
const cfg = resolveGratingConfig(preset, variantIndex)
|
||
|
||
ctx.save()
|
||
clipRoundRect(ctx, cw, ch)
|
||
|
||
// Layer 1: 浅色纯色底
|
||
drawSolidPastelBackdrop(ctx, cw, ch, cfg.backdropTone)
|
||
|
||
// Layer 1.5: 金属反光梯度(让白底变银底)
|
||
drawMetallicSheen(ctx, cw, ch, cfg)
|
||
|
||
// Layer 2: 抠图人像 PNG(透明底,source-over)
|
||
setBlend(ctx, 'source-over')
|
||
ctx.setGlobalAlpha(1)
|
||
try {
|
||
ctx.drawImage(imagePath, layout.dx, layout.dy, layout.dw, layout.dh)
|
||
} catch (e) {
|
||
/* noop */
|
||
}
|
||
|
||
// Layer 3: 整卡光栅 + 轻磨砂
|
||
if (preset.backdropMode === 'rich') {
|
||
drawRichLaserFinish(ctx, cw, ch, preset, timeMs, variantIndex)
|
||
} else {
|
||
drawGratingFinish(ctx, cw, ch, preset, timeMs, variantIndex)
|
||
}
|
||
|
||
// Layer 4: 人像边缘彩虹光晕(镭射最显眼的视觉信号)
|
||
drawPersonEdgeHalo(ctx, cw, ch, layout, variantIndex * 1301 + 31)
|
||
|
||
// Layer 5: 银粉星点散点层(金属膜细反光)
|
||
drawSilverSparkle(ctx, cw, ch, variantIndex * 2087 + 991, 1.0)
|
||
|
||
// Layer 6: 人物边缘闪粉(极弱 —— 实体卡只是淡淡光点,不抢人物)
|
||
drawSparkleEdge(ctx, cw, ch, null, layout, variantIndex * 519, 0.18)
|
||
|
||
ctx.setGlobalAlpha(1)
|
||
setBlend(ctx, 'source-over')
|
||
ctx.restore()
|
||
}
|
||
|
||
export async function generateLaserVariantBatch(imagePath, canvasId = 'laserBatchCanvas') {
|
||
const src = String(imagePath || '').trim()
|
||
if (!src) {
|
||
throw new Error('缺少上传图片')
|
||
}
|
||
const info = await getImageInfo(src)
|
||
const layout = computeCover(info.width, info.height, EXPORT_W, EXPORT_H)
|
||
const ctx = uni.createCanvasContext(canvasId)
|
||
const paths = []
|
||
|
||
for (let i = 0; i < PRESET_VARIANTS.length; i++) {
|
||
const cfg = resolveGratingConfig(PRESET_VARIANTS[i], i)
|
||
console.log(`[laserBatch] variant ${i} backdrop=${cfg.backdropTone} angle=${cfg.stripeAngle} density=${cfg.stripeDensity}`)
|
||
await drawLaserVariantComposite(ctx, EXPORT_W, EXPORT_H, src, layout, PRESET_VARIANTS[i], i)
|
||
await canvasDraw(ctx)
|
||
paths.push(await canvasToTemp(canvasId, EXPORT_W, EXPORT_H))
|
||
}
|
||
|
||
return paths
|
||
}
|
||
|
||
export const LASER_BATCH_CANVAS_SIZE = LASER_BATCH_EXPORT_SIZE
|
||
export { PRESET_VARIANTS } from './laserPresets.js'
|