topfans/frontend/utils/laser-card/laserBatchExport.js
2026-06-03 22:19:22 +08:00

774 lines
25 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 镭射卡批量预览 — 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 旧版 canvasApp/小程序)无 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 A8x8 像素 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 B16x16 像素 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'