topfans/frontend/utils/laser-card/laserBatchExport.js
2026-05-16 02:42:32 +08:00

580 lines
17 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.

/**
* 镭射卡批量预览 — 合成逻辑对齐 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 旧版 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) {
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 }