/** * 镭射卡批量预览 — 合成逻辑对齐 laser-card-studio 导出链(人物清晰 + 强镭射叠层) */ const EXPORT_W = 450 const EXPORT_H = 600 const PRESET_VARIANTS = [ { style: 'dream', angle: 36, beam: 'prism', backdrop: 'liquidBlue', laserStrength: 78, diffractionDensity: 68, marbleFlow: 52, beamIntensity: 92, grainDensity: 82, }, { style: 'classic', angle: 24, beam: 'aurora', backdrop: 'liquidLavender', laserStrength: 85, diffractionDensity: 74, marbleFlow: 38, beamIntensity: 96, grainDensity: 76, }, { style: 'holoFull', angle: 58, beam: 'neon', backdrop: 'liquidPearl', laserStrength: 90, diffractionDensity: 80, marbleFlow: 58, beamIntensity: 100, grainDensity: 88, }, { style: 'ice', angle: 46, beam: 'white', backdrop: 'liquidBlue', laserStrength: 72, diffractionDensity: 60, marbleFlow: 44, beamIntensity: 88, grainDensity: 74, }, { style: 'sunset', angle: 30, beam: 'sunset', backdrop: 'liquidLavender', laserStrength: 82, diffractionDensity: 66, marbleFlow: 50, beamIntensity: 94, grainDensity: 84, }, ] const BACKDROP_MAP = { liquidBlue: '/static/castlove/laser-bg/laser-bg-1.png', liquidLavender: '/static/castlove/laser-bg/laser-bg-2.png', liquidPearl: '/static/castlove/laser-bg/laser-bg-3.png', } 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] }, ], } const backdropLayoutCache = {} 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 } } async function getBackdropLayout(key, cw, ch) { const cacheKey = `${key}_${cw}_${ch}` if (backdropLayoutCache[cacheKey]) { return backdropLayoutCache[cacheKey] } const bd = BACKDROP_MAP[key] || BACKDROP_MAP.liquidBlue const info = await getImageInfo(bd) const lay = computeCover(info.width, info.height, cw, ch) backdropLayoutCache[cacheKey] = { path: bd, lay } return backdropLayoutCache[cacheKey] } 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) { const strength = preset.laserStrength / 100 const vivid = 1.1 const list = BLOB_PALETTES[preset.style] || BLOB_PALETTES.dream const R = Math.max(cw, ch) setBlend(ctx, 'soft-light') list.forEach((b) => { try { const bx = b.x * cw const by = b.y * ch const br = R * 0.62 const g = createRadialGradientCompat(ctx, bx, by, 0, br) g.addColorStop(0, b.c0) g.addColorStop(1, b.c1) ctx.setGlobalAlpha(0.82 * strength * vivid * 0.42) ctx.setFillStyle(g) ctx.fillRect(0, 0, cw, ch) } catch (e) { ctx.setGlobalAlpha(0.35 * strength) ctx.setFillStyle(b.c0) ctx.fillRect(0, 0, cw, ch) } }) ctx.setGlobalAlpha(1) setBlend(ctx, 'source-over') } function drawHslRainbowBase(ctx, cw, ch, preset, timeMs) { const strength = preset.laserStrength / 100 const vivid = 1.1 const hueShift = ((timeMs * 0.055) % 360 + 360) % 360 const g = ctx.createLinearGradient(0, 0, cw * 1.05, ch * 0.92) const stops = 12 for (let i = 0; i <= stops; i++) { const p = i / stops const h = (hueShift + p * 360) % 360 const alpha = (0.052 + strength * 0.1) * vivid * (0.55 + p * 0.22) * 0.65 g.addColorStop(p, hslToRgba(h, 88, 54, alpha)) } setBlend(ctx, 'soft-light') ctx.setGlobalAlpha(1) ctx.setFillStyle(g) ctx.fillRect(0, 0, cw, ch) setBlend(ctx, 'source-over') } function drawMarbleFlow(ctx, cw, ch, preset) { const mf = preset.marbleFlow / 100 if (mf < 0.04) return const strength = preset.laserStrength / 100 const pts = MARBLE_PALETTES[preset.style] || MARBLE_PALETTES.dream const phase = (preset.angle * Math.PI) / 180 const Rmax = Math.max(cw, ch) * 0.48 const baseA = (0.1 + mf * 0.38) * strength * 0.85 ctx.save() setBlend(ctx, 'soft-light') pts.forEach((p, i) => { const gx = p.x * cw + Math.cos(phase * 0.35) * 10 * (i % 2) const gy = p.y * ch + Math.sin(phase * 0.3) * 8 * ((i + 1) % 2) const rad = Rmax * (0.28 + (i % 3) * 0.08) const g = createRadialGradientCompat(ctx, gx, gy, 0, rad) const [r, gg, b] = p.rgb const a0 = Math.min(0.75, baseA * 0.95) g.addColorStop(0, `rgba(${r},${gg},${b},${a0.toFixed(3)})`) g.addColorStop(0.55, `rgba(${r},${gg},${b},${(baseA * 0.5).toFixed(3)})`) g.addColorStop(1, `rgba(${r},${gg},${b},0)`) ctx.setGlobalAlpha(1) ctx.setFillStyle(g) ctx.fillRect(0, 0, cw, ch) }) ctx.restore() setBlend(ctx, 'source-over') } function drawDiffractionStripes(ctx, cw, ch, preset) { const density = preset.diffractionDensity / 100 if (density < 0.02) return const strength = preset.laserStrength / 100 const deg = preset.angle + 90 const rad = (deg * Math.PI) / 180 const stripeW = Math.max(1.1, (3.2 - density * 2.4) * 0.42) const span = Math.sqrt(cw * cw + ch * ch) * 1.2 const baseA = (0.08 + strength * 0.12) * 0.75 const hiA = (0.1 + strength * 0.14) * 0.75 const prefix = [ 'rgba(255,60,180,', 'rgba(255,170,60,', 'rgba(180,255,80,', 'rgba(0,220,255,', 'rgba(120,90,255,', 'rgba(255,80,200,', ] const alphas = [baseA, baseA, hiA, hiA, baseA, baseA * 0.85] ctx.save() ctx.translate(cw / 2, ch / 2) ctx.rotate(rad) setBlend(ctx, 'overlay') ctx.setGlobalAlpha(Math.min(0.42, (0.28 + strength * 0.55 + density * 0.2) * 0.52)) const count = Math.ceil((span * 2) / stripeW) for (let i = -count; i <= count; i++) { const pos = i * stripeW const ci = ((i % 6) + 6) % 6 ctx.setFillStyle(prefix[ci] + alphas[ci].toFixed(3) + ')') ctx.fillRect(pos - span, -span, stripeW * 0.92, span * 2) } ctx.restore() ctx.setGlobalAlpha(1) setBlend(ctx, 'source-over') } function drawOilSweep(ctx, cw, ch, preset, timeMs) { const strength = preset.laserStrength / 100 const bi = preset.beamIntensity / 100 const pvx = 0.5 * cw const pvy = 0.48 * ch const deg = preset.angle const rad = (deg * Math.PI) / 180 const perp = rad + Math.PI / 2 const len = Math.sqrt(cw * cw + ch * ch) * 0.55 const phase = ((timeMs % 8000) / 8000 - 0.5) * len * 0.55 * 2 const cx = pvx + Math.cos(perp) * phase const cy = pvy + Math.sin(perp) * phase const x0 = cx - Math.cos(rad) * len const y0 = cy - Math.sin(rad) * len const x1 = cx + Math.cos(rad) * len const y1 = cy + Math.sin(rad) * len const g = ctx.createLinearGradient(x0, y0, x1, y1) const peak = (0.2 + strength * 0.16 + bi * 0.12) * 1.1 const be = preset.beam let cHi = `rgba(255,255,255,${Math.min(0.9, peak).toFixed(3)})` let cMid = `rgba(220,240,255,${Math.min(0.6, peak * 0.72).toFixed(3)})` if (be === 'prism') { cHi = `rgba(255,240,200,${Math.min(0.88, peak).toFixed(3)})` cMid = `rgba(200,80,255,${Math.min(0.55, peak * 0.65).toFixed(3)})` } else if (be === 'aurora') { cHi = `rgba(200,255,250,${Math.min(0.85, peak).toFixed(3)})` cMid = `rgba(140,80,255,${Math.min(0.52, peak * 0.62).toFixed(3)})` } else if (be === 'sunset') { cHi = `rgba(255,230,180,${Math.min(0.88, peak).toFixed(3)})` cMid = `rgba(255,120,120,${Math.min(0.54, peak * 0.64).toFixed(3)})` } else if (be === 'neon') { cHi = `rgba(0,255,255,${Math.min(0.82, peak).toFixed(3)})` cMid = `rgba(255,80,200,${Math.min(0.52, peak * 0.62).toFixed(3)})` } g.addColorStop(0, 'rgba(255,255,255,0)') g.addColorStop(0.38, 'rgba(255,255,255,0)') g.addColorStop(0.48, cMid) g.addColorStop(0.5, cHi) g.addColorStop(0.52, cMid) g.addColorStop(0.62, 'rgba(255,255,255,0)') g.addColorStop(1, 'rgba(255,255,255,0)') setBlend(ctx, 'soft-light') ctx.setGlobalAlpha((0.42 + bi * 0.32) * 0.62) ctx.setFillStyle(g) ctx.fillRect(0, 0, cw, ch) ctx.setGlobalAlpha(1) setBlend(ctx, 'source-over') } function drawHueFlowBeams(ctx, cw, ch, preset, timeMs) { const strength = preset.laserStrength / 100 const flow = ((timeMs * 0.00012) % 1) * 360 const layers = [ { off: 0, span: 260 }, { off: 58, span: 200 }, { off: -44, span: 220 }, ] ctx.save() setBlend(ctx, 'soft-light') layers.forEach((layer, idx) => { const deg = preset.angle + layer.off + Math.sin((timeMs + idx * 900) * 0.0017) * 14 const h0 = (flow + idx * 72) % 360 const stops = [] for (let i = 0; i <= 7; i++) { const u = i / 7 const h = (h0 + u * layer.span) % 360 const a = (0.03 + strength * 0.065) * (0.5 + 0.5 * Math.sin(u * Math.PI)) * 0.7 stops.push([u, hslToRgba(h, 90, 58, a)]) } const g = linearGradientThroughPivot(ctx, cw, ch, 50, 48, deg, stops) ctx.setGlobalAlpha(0.28 + idx * 0.04) ctx.setFillStyle(g) ctx.fillRect(0, 0, cw, ch) }) ctx.restore() ctx.setGlobalAlpha(1) setBlend(ctx, 'source-over') } function drawDensePixelNoise(ctx, cw, ch, preset, seed) { const strength = preset.laserStrength / 100 const grain = preset.grainDensity / 100 const n = Math.floor(1800 + grain * 3500 + strength * 1500) let s = seed const rnd = () => { s = (s * 9301 + 49297) % 233280 return s / 233280 } ctx.save() setBlend(ctx, 'overlay') ctx.setGlobalAlpha(0.5) for (let k = 0; k < n; k++) { const x = Math.floor(rnd() * cw) const y = Math.floor(rnd() * ch) const lit = rnd() > 0.5 const v = lit ? 255 : 198 + Math.floor(rnd() * 40) const a = 0.028 + rnd() * 0.08 ctx.setFillStyle(`rgba(${v},${v},${Math.min(255, v + 8)},${a.toFixed(3)})`) ctx.fillRect(x, y, 1, 1) } ctx.restore() ctx.setGlobalAlpha(1) setBlend(ctx, 'source-over') } function drawFilmGrain(ctx, cw, ch, seed) { let s = seed + 17 const rnd = () => { s = (s * 1103515245 + 12345) & 0x7fffffff return s / 0x7fffffff } for (let i = 0; i < 90; 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) } async function drawBackdropImage(ctx, bdPath, lay) { try { ctx.drawImage(bdPath, lay.dx, lay.dy, lay.dw, lay.dh) } catch (e) { /* noop */ } } async function drawLaserVariantComposite(ctx, cw, ch, imagePath, layout, preset, variantIndex) { const timeMs = Date.now() + variantIndex * 1379 const { path: bdPath, lay: bdLay } = await getBackdropLayout(preset.backdrop, cw, ch) ctx.setFillStyle('#141018') ctx.fillRect(0, 0, cw, ch) ctx.save() clipRoundRect(ctx, cw, ch) await drawBackdropImage(ctx, bdPath, bdLay) try { ctx.drawImage(imagePath, layout.dx, layout.dy, layout.dw, layout.dh) } catch (e) { /* noop */ } ctx.save() setBlend(ctx, 'overlay') ctx.setGlobalAlpha(0.48) await drawBackdropImage(ctx, bdPath, bdLay) ctx.restore() ctx.save() setBlend(ctx, 'screen') ctx.setGlobalAlpha(0.28) await drawBackdropImage(ctx, bdPath, bdLay) ctx.restore() drawSolidLaserBackdrop(ctx, cw, ch, preset) drawHslRainbowBase(ctx, cw, ch, preset, timeMs) drawMarbleFlow(ctx, cw, ch, preset) drawHueFlowBeams(ctx, cw, ch, preset, timeMs) drawDiffractionStripes(ctx, cw, ch, preset) drawOilSweep(ctx, cw, ch, preset, timeMs) drawDensePixelNoise(ctx, cw, ch, preset, variantIndex * 7919 + 42) drawFilmGrain(ctx, cw, ch, variantIndex * 313) 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++) { 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 = { w: EXPORT_W, h: EXPORT_H }