topfans/frontend/components/laser/LaserPreviewCanvas.vue
Lenticular Studio Agent af7908e72e feat: 接入微达API中转站,重构镭射卡生图流程
- 替换中转站从 xbcl.link 到 weda.cc
- prompt 模板改为镭射卡全图生成(去掉 6 层合成/抠图依赖)
- 4 路并发调用 + 原图展示 = 5 张 variant
- 前端提示词中译英支持
- 全局 Vue errorHandler
- WebSocket 鉴权失败跳登录
- 删除已弃用的 laserCompositor 微服务

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-23 22:43:49 +08:00

361 lines
8.7 KiB
Vue
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.

<template>
<view class="laser-preview-root">
<canvas
v-if="webglSupport"
id="laserPreviewWebgl"
type="webgl"
class="laser-preview-canvas"
:disable-scroll="true"
:style="{ width: exportCssW + 'px', height: exportCssH + 'px' }"
:width="exportCssW"
:height="exportCssH"
/>
<view v-else class="laser-preview-fallback">
<image
v-if="variantPath || fallbackPath"
class="laser-preview-fallback-img"
:src="variantPath || fallbackPath"
mode="aspectFill"
/>
<text v-else class="laser-preview-fallback-text">无预览</text>
<!-- 兜底时也保留镭射扫光感(CSS 动画, LaserVariantPyramid 一致) -->
<view v-if="variantPath || fallbackPath" class="laser-preview-fallback-shimmer" />
<view v-if="variantPath || fallbackPath" class="laser-preview-fallback-edge" />
</view>
</view>
</template>
<script setup>
import { getCurrentInstance, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { createLaserPreviewWebgl, loadTextureImage, BEAM_STYLE_HUE } from '@/utils/laser-card/laserPreviewWebgl.js'
import { resolveGratingConfig } from '@/utils/laser-card/laserGrating.js'
const props = defineProps({
/** 抠图 PNG用于闪粉 alpha 计算) */
cutoutPath: { type: String, default: '' },
/** 已生成的激光卡图WebGL 直接渲染这张) */
variantPath: { type: String, default: '' },
/** WebGL 不可用时的兜底图 */
fallbackPath: { type: String, default: '' },
preset: { type: Object, default: () => ({}) },
/** 当前选中的卡索引(用于 resolveGratingConfig 匹配导出参数) */
variantIndex: { type: Number, default: 0 },
paused: { type: Boolean, default: false },
})
const exportCssW = 360
const exportCssH = 480
const webglSupport = ref(true)
const pageProxy = getCurrentInstance()?.proxy
let _canvasNode = null
let _webglR = null
let _photoRect = null
let _rafId = null
let _engineReady = false
let _startTime = 0
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 scheduleFrame(cb) {
if (typeof requestAnimationFrame === 'function') return requestAnimationFrame(cb)
return setTimeout(cb, 33)
}
function cancelFrame(id) {
if (id == null) return
try {
if (typeof cancelAnimationFrame === 'function') cancelAnimationFrame(id)
else clearTimeout(id)
} catch {
/* noop */
}
}
const scheduleLoop = () => {
cancelFrame(_rafId)
if (_startTime === 0) _startTime = performance.now()
const loop = () => {
if (!_engineReady) {
_rafId = scheduleFrame(loop)
return
}
if (!props.paused && _webglR && _photoRect) {
const elapsed = performance.now() - _startTime
renderFrame(elapsed)
}
_rafId = scheduleFrame(loop)
}
_rafId = scheduleFrame(loop)
}
function stopLoop() {
cancelFrame(_rafId)
_rafId = null
}
function _resetEngine() {
_engineReady = false
_photoRect = null
_startTime = 0
if (_webglR) {
try { _webglR.destroy() } catch { /* noop */ }
_webglR = null
}
}
async function _initWebglEngine() {
if (_webglR) return true
try {
return await new Promise((resolve) => {
const q = pageProxy ? uni.createSelectorQuery().in(pageProxy) : uni.createSelectorQuery()
q.select('#laserPreviewWebgl')
.fields({ node: true, size: true })
.exec((res) => {
const row = res && res[0]
if (!row || !row.node) {
resolve(false)
return
}
_canvasNode = row.node
// 像素比:与 LaserCardStudio 保持一致的上限范围
let pr = 1
try {
const sys = uni.getSystemInfoSync()
pr = Math.min(2.5, Math.max(1, Number(sys.pixelRatio) || 1))
} catch {
pr = 1
}
const bw = Math.max(2, Math.floor(exportCssW * pr))
const bh = Math.max(2, Math.floor(exportCssH * pr))
try {
_canvasNode.width = bw
_canvasNode.height = bh
} catch {
resolve(false)
return
}
const gl =
_canvasNode.getContext('webgl', {
alpha: true,
antialias: true,
preserveDrawingBuffer: false,
stencil: false,
depth: false,
}) || _canvasNode.getContext('experimental-webgl')
if (!gl) {
webglSupport.value = false
resolve(false)
return
}
try {
_webglR = createLaserPreviewWebgl(gl, { width: exportCssW, height: exportCssH })
resolve(true)
} catch (e3) {
console.warn('[LaserPreviewCanvas] createLaserPreviewWebgl failed', e3)
webglSupport.value = false
resolve(false)
}
})
})
} catch (e) {
console.warn('[LaserPreviewCanvas] init webgl failed', e)
webglSupport.value = false
return false
}
}
async function _uploadTexturesAndRects() {
if (!_webglR || !_canvasNode) return
// cutoutPath 用于闪粉 alpha 计算(抠图 PNG 是透明底的)
const cutoutSrc = String(props.cutoutPath || '').trim()
if (!cutoutSrc) return
try {
const imgInfo = await new Promise((resolve) => {
uni.getImageInfo({ src: cutoutSrc, success: resolve, fail: () => resolve(null) })
})
if (imgInfo) {
_photoRect = computeCover(imgInfo.width, imgInfo.height, exportCssW, exportCssH)
}
} catch { /* noop */ }
try {
const photoImg = await loadTextureImage(cutoutSrc, _canvasNode)
_webglR.uploadPhoto(photoImg)
} catch {
_webglR.uploadPhoto(null)
}
_engineReady = true
if (_photoRect) {
_startTime = performance.now()
renderFrame(0)
}
}
function renderFrame(timeMs) {
if (!_webglR || !_photoRect) return
const preset = props.preset || {}
const gratingConfig = resolveGratingConfig(preset, props.variantIndex)
const beamEffectId = String(preset?.beam || 'prism')
const styleHue = typeof BEAM_STYLE_HUE[beamEffectId] === 'number' ? BEAM_STYLE_HUE[beamEffectId] : BEAM_STYLE_HUE.prism
const frostIntensity = Number(preset?.frostIntensity) || 18
const flowSpeed = Number(preset?.flowSpeed || 1)
const cw = exportCssW
const ch = exportCssH
const cornerRadiusPx = Math.min(32, cw * 0.065, ch * 0.048)
// 根据 _photoRect抠图 PNG 的 cover layout计算人物中心 UV 和边缘环形参数
const pr = _photoRect
const personEdgeX = (pr.dx + pr.dw / 2) / cw
const personEdgeY = (pr.dy + pr.dh / 2) / ch
const personEdgeInner = Math.min(pr.dw, pr.dh) / cw * 0.40
const personEdgeOuter = Math.min(pr.dw, pr.dh) / cw * 0.72
_webglR.render(timeMs, {
photoRect: _photoRect,
gratingConfig,
cornerRadiusPx,
styleHue,
animSpeed: 1,
frostIntensity,
flowSpeed,
personEdgeX,
personEdgeY,
personEdgeInner,
personEdgeOuter,
canvasW: cw,
canvasH: ch,
})
}
watch(
() => [props.cutoutPath, props.variantPath, props.preset, props.paused],
async ([cutout, variant, preset, paused]) => {
if (!cutout && !variant) return
if (!webglSupport.value) return
if (!preset) return
_resetEngine()
stopLoop()
const ok = await _initWebglEngine()
if (!ok) return
await _uploadTexturesAndRects()
if (!paused) scheduleLoop()
},
{ immediate: true, deep: true }
)
onMounted(async () => {
// 初次挂载尝试初始化;后续由 watch 处理重新渲染
if (!props.paused) scheduleLoop()
})
onBeforeUnmount(() => {
stopLoop()
try {
_webglR?.destroy?.()
} catch {
/* noop */
}
_webglR = null
_canvasNode = null
})
</script>
<style scoped>
.laser-preview-root {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.laser-preview-canvas {
border-radius: 24rpx;
}
.laser-preview-fallback {
position: relative;
width: 360px;
height: 480px;
border-radius: 24rpx;
overflow: hidden;
background: rgba(255, 255, 255, 0.06);
display: flex;
align-items: center;
justify-content: center;
}
.laser-preview-fallback-img {
width: 100%;
height: 100%;
}
.laser-preview-fallback-text {
color: rgba(255, 255, 255, 0.7);
font-size: 28rpx;
}
/* 兜底场景下的镭射扫光(WebGL 不可用时仍能看到动态光感) */
.laser-preview-fallback-shimmer {
position: absolute;
inset: 0;
border-radius: 24rpx;
background: linear-gradient(
115deg,
transparent 32%,
rgba(255, 255, 255, 0.18) 46%,
rgba(255, 255, 255, 0.32) 50%,
rgba(255, 255, 255, 0.18) 54%,
transparent 68%
);
background-size: 220% 220%;
background-position: -120% -120%;
animation: laser-preview-shimmer-sweep 8s ease-in-out infinite;
mix-blend-mode: screen;
pointer-events: none;
}
/* 兜底场景下的彩虹描边 */
.laser-preview-fallback-edge {
position: absolute;
inset: 0;
border-radius: 24rpx;
pointer-events: none;
box-shadow:
inset 0 0 8rpx rgba(200, 220, 240, 0.22),
inset 0 0 18rpx rgba(220, 230, 240, 0.12);
mix-blend-mode: screen;
}
@keyframes laser-preview-shimmer-sweep {
0% {
background-position: -120% -120%;
}
55% {
background-position: -120% -120%;
}
100% {
background-position: 220% 220%;
}
}
</style>