/** * 镭射卡批量预览 — 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'