topfans/frontend/pages/castlove/laser-card-studio.vue
2026-05-16 02:42:32 +08:00

3664 lines
119 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.

<!--
与独立工程 Laser-Card 同源风格预设beamEffects默认滑杆与 pages/laser-card-studio/laser-card-studio.vue 保持一致改参数时请双端同步
铸爱 create 单图流程底部生成 写入 castlove_laser_entry_payload 本页 onLoad 读入预览
目标端Android / iOSApp-PlusVue 页面 + WebView 渲染
pages.json 示例
{ "path": "pages/laser-card-studio/laser-card-studio", "style": { "navigationStyle": "custom", "backgroundColor": "#0a0a0a" } }
App 端说明
- 顶栏/底栏使用 statusBarHeightsafeAreaInsets.bottom避免仅靠 CSS 变量导致刘海屏错位
- 预览优先 WebGL Pass 全息着色FBM + 视角色相 + 色散闪点失败则 2D Canvas + requestAnimationFrame导出仍为 2D 离屏 Canvas
- iOS WKWebView / Android System WebView2D 分支用 globalCompositeOperation本页已移除 DOM 叠层预览
- 顶栏与预览区 position: sticky便于长屏下仍可操作顶栏
- 内置强全息默认参数无调校 UI保存写入相册或铸爱下单不再弹窗询问保存调校参数
-->
<template>
<view class="page">
<!-- 顶栏sticky滚动时始终可操作 -->
<view class="header header--sticky" :style="{ paddingTop: statusBarHeight + 'px' }">
<text class="header-btn" @tap="onCancel">取消</text>
<text class="header-title">镭射工坊</text>
<text class="header-btn" @tap="onSave">保存</text>
</view>
<!-- 预览区sticky下滑调参数时贴在顶栏下方 -->
<view class="preview-sticky" :style="{ top: stickyHeaderTotalPx + 'px' }">
<view class="preview-wrap">
<block v-if="previewSrc">
<view class="preview-canvas-stack" :style="{ width: exportCssW + 'px', height: exportCssH + 'px' }">
<canvas
v-show="!webglPreviewReady"
canvas-id="laserPreviewCanvas"
class="preview-canvas preview-canvas--2d"
:style="{ width: exportCssW + 'px', height: exportCssH + 'px' }"
:width="exportCssW"
:height="exportCssH"
:disable-scroll="true"
/>
<canvas
v-show="webglPreviewReady"
id="laserPreviewWebgl"
type="webgl"
class="preview-canvas preview-canvas--webgl"
:style="{ width: exportCssW + 'px', height: exportCssH + 'px' }"
:width="exportCssW"
:height="exportCssH"
:disable-scroll="true"
/>
</view>
<view class="preview-fab" @tap.stop="onPreviewTap">
<text class="preview-fab-text">换图</text>
</view>
<view class="preview-replace-hint">
<text class="preview-replace-text">WebGL 全息预览 · 保存仍为高清 2D 导出</text>
</view>
</block>
<view v-else class="preview-placeholder">
<view class="placeholder-icon-wrap">
<text class="placeholder-plus">+</text>
</view>
<text class="placeholder-title">拍照或上传</text>
<text class="placeholder-sub">支持相册选择或相机拍摄</text>
</view>
</view>
</view>
<!-- 内置镭射底纹:置于人物后方,与 Canvas 合成顺序一致 -->
<view v-if="previewSrc" class="laser-backdrop-panel">
<text class="laser-backdrop-title">镭射底纹(人物后方)</text>
<scroll-view scroll-x class="laser-backdrop-scroll" :show-scrollbar="false">
<view class="laser-backdrop-row">
<view
v-for="opt in laserBackdropOptions"
:key="opt.id"
class="laser-backdrop-item"
:class="{ 'laser-backdrop-item--active': laserBackdropId === opt.id }"
@tap="onPickLaserBackdrop(opt.id)"
>
<view v-if="opt.id === 'none'" class="laser-backdrop-none">
<text class="laser-backdrop-none-text">无</text>
</view>
<image v-else :src="opt.src" class="laser-backdrop-thumb" mode="aspectFill" />
<text class="laser-backdrop-name">{{ opt.label }}</text>
</view>
</view>
</scroll-view>
</view>
<!-- 离屏 canvas合成后导出到相册与预览同逻辑圆角 + 照片 + 叠层镭射 -->
<canvas
canvas-id="laserExportCanvas"
class="export-canvas"
:style="{ width: exportCssW + 'px', height: exportCssH + 'px' }"
:width="exportCssW"
:height="exportCssH"
:disable-scroll="true"
/>
</view>
</template>
<script>
import { segmentPortraitToLocal } from '@/utils/laser-card/segmentationCloud.js'
import { humanizeIfBareOssUploadErr } from '@/utils/laser-card/aliyunPortraitUni.js'
import { submitCastloveAfterLaserExport } from '@/utils/castloveAfterLaserMint.js'
import { CASTLOVE_LASER_ENTRY_KEY } from '@/utils/castloveMintForm.js'
import {
createLaserPreviewWebgl,
loadTextureImage,
BEAM_STYLE_HUE
} from '@/utils/laser-card/laserPreviewWebgl.js'
const PARAM_PRESET_STORAGE_KEY = 'laser_card_param_presets_v1'
export default {
name: 'LaserCardStudio',
data() {
return {
statusBarHeight: 24,
safeAreaBottom: 0,
previewSrc: '',
/** 仅保留供 computed 使用(调校 UI 已移除) */
activeTab: 'style',
/** 全幅 Canvas 预览getImageInfo 后缓存 cover 布局,避免每帧重算 */
_previewLayout: null,
_previewRafId: null,
/** 内置静态镭射底图 cover 布局(置于人物图之下) */
_backdropLayout: null,
/** none | 三款内置流体底纹 */
laserBackdropId: 'none',
laserBackdropOptions: [
{ id: 'none', label: '无底图', src: '' },
{ id: 'liquidBlue', label: '青粉流体', src: '/static/castlove/laser-bg/laser-bg-1.png' },
{ id: 'liquidLavender', label: '紫青幻彩', src: '/static/castlove/laser-bg/laser-bg-2.png' },
{ id: 'liquidPearl', label: '缎面流光', src: '/static/castlove/laser-bg/laser-bg-3.png' }
],
stylePresetId: 'holoFull',
stylePresets: [
{
id: 'dream',
name: '梦幻棱镜',
desc: '紫粉青柔雾 + 斜向光束,偏横版大卡',
tag: '梦幻',
strip: 'linear-gradient(125deg,#7c4dff 0%,#ff6ec7 35%,#00e5ff 70%,#fff59d 100%)',
animMode: 'hue'
},
{
id: 'classic',
name: '经典彩虹',
desc: '高对比色带,接近强镭射小卡',
tag: '彩虹',
strip: 'linear-gradient(90deg,#ff0080,#7928ca,#0070f3,#00dfd8,#b8ff6a)',
animMode: 'hue'
},
{
id: 'ice',
name: '冰霜蓝紫',
desc: '冷色雾面 + 薄荷高光',
tag: '冰霜',
strip: 'linear-gradient(135deg,#1a237e,#5c6bc0,#4dd0e1,#e1f5fe)',
animMode: 'hue'
},
{
id: 'sunset',
name: '日落蜜桃',
desc: '桃粉与暖金,偏手持柔光小卡',
tag: '蜜桃',
strip: 'linear-gradient(135deg,#ffccbc,#ff8a65,#ff6b9d,#ffe082)',
animMode: 'hue'
},
{
id: 'pearl',
name: '珍珠流光',
desc: '银白偏光 + 慢扫金属感',
tag: '珍珠',
strip: 'linear-gradient(135deg,#eceff1,#b0bec5,#ffffff,#cfd8dc)',
animMode: 'metal'
},
{
id: 'holoFull',
name: '全息满铺',
desc: '满铺细密六彩衍射纹 + 高密度闪粉,最接近实物镭射卡',
tag: '全息',
strip: 'linear-gradient(120deg,#ff0080,#ff8a00,#fff200,#33ff66,#00d0ff,#a056ff,#ff0080)',
animMode: 'hue'
}
],
/** 以下为内置参数(无调校 UI叠在照片上时平衡「人像清晰」与「表面幻彩」 */
laserStrength: 65,
/** 光柱/光束亮度与宽度 20140 */
beamIntensity: 84,
angle: 50,
/** 光束枢轴在卡面内的位置 0100相对预览小卡非强制中心 */
beamPivotX: 50,
beamPivotY: 50,
beamEffectId: 'prism',
beamEffects: [
{
id: 'white',
name: '纯净白光',
swatch: 'linear-gradient(90deg,#ffffff,#e8feff,#ffffff)'
},
{
id: 'prism',
name: '棱镜虹彩',
swatch: 'linear-gradient(90deg,#ff6ec7,#7c4dff,#00e5ff,#fff59d)'
},
{
id: 'aurora',
name: '极光紫青',
swatch: 'linear-gradient(90deg,#ce93ff,#00ffd9,#7e57c2)'
},
{
id: 'sunset',
name: '日落暖金',
swatch: 'linear-gradient(90deg,#ffcc80,#ff6b6b,#ffe082)'
},
{
id: 'neon',
name: '电音霓虹',
swatch: 'linear-gradient(90deg,#ff00aa,#00f0ff,#ffee58)'
}
],
/** 颗粒密度 20100 */
grainDensity: 84,
/** 彩虹细纹密度 0100满铺叠图时略低避免条纹糊脸 */
diffractionDensity: 55,
/** 彩虹细纹角度偏移 -90~90相对光束角度的偏移默认 90 = 垂直于光束方向 */
diffractionAngleOffset: 90,
/** 椭圆抠图遮罩半径(相对预览宽/高 %)—— 用于把人物从镭射背景中分离 */
subjectMaskRadiusX: 46,
subjectMaskRadiusY: 60,
subjectMaskSoftness: 44,
/** 人物椭圆内压低幻彩:全幅膜时关闭 */
subjectLaserSuppress: 0,
/** 叠在照片上的幻彩罩层:改由 Canvas 叠层统一处理 */
portraitVeilStrength: 0,
/** 大理石/揉箔感中频纹理强度 0100叠图略降避免整张发糊 */
marbleFlowStrength: 42,
/** 磨砂雾面 0100 */
matteStrength: 46,
previewAnimSpeed: 128,
previewVivid: 106,
_previewRect: null,
/** 顶栏占用总高度(px),用于预览区 sticky 的 top */
stickyHeaderTotalPx: 96,
/** 导出 canvas 逻辑尺寸px再按 exportScale 放大导出 */
exportCssW: 360,
exportCssH: 480,
exportScale: 3,
_saving: false,
/** ellipse | aiai 时使用 aiCutoutSrc 透明人物图叠在镭射上 */
segmentationMode: 'ellipse',
aiCutoutSrc: '',
_segmenting: false,
/** 本地持久化的参数预设列表 */
paramPresets: [],
/** 常用光束角度快捷(度) */
angleQuickDegrees: [0, 15, 30, 45, 60, 90, 120, 135, 150, 180, 210, 225, 240, 270, 300, 315, 330],
/** 铸爱单图入口create 写入,保存后走下单 */
castloveEntry: null,
/** WebGL 预览:成功前仍走 2D 回退 */
webglPreviewReady: false,
_webglR: null,
_webglCanvasNode: null,
_webglBooting: false,
/** 递增以作废进行中的 WebGL 异步启动 */
laserWebglSession: 0
}
},
computed: {
currentPreset() {
return this.stylePresets.find((p) => p.id === this.stylePresetId) || this.stylePresets[0]
},
presetAnimMode() {
return this.currentPreset.animMode || 'hue'
},
currentBeamEffect() {
return this.beamEffects.find((b) => b.id === this.beamEffectId) || this.beamEffects[0]
},
/** 闪粉 + 磨砂强化后的等效细腻度(用于预览与导出) */
grainDensityEffective() {
const base = Number(this.grainDensity) || 78
const ms = Math.max(0, Math.min(100, Number(this.matteStrength) || 0))
return Math.min(100, Math.round(base * (0.62 + 0.55 * (ms / 100))))
},
tuneSummary() {
const tag = this.currentBeamEffect.name.slice(0, 4)
return `${this.laserStrength}% · 膜${this.portraitVeilStrength}% · 流体${this.marbleFlowStrength}% · 压${this.subjectLaserSuppress}% · ${this.angle}° · ${tag}`
},
previewHintText() {
if (this.segmentationMode === 'ai' && this.aiCutoutSrc) {
return '拖动定位视觉中心 ·「换图」上传照片 ·「恢复椭圆」后可调人物范围'
}
return '拖动定位人物中心 · 在下方「人物范围」里调椭圆大小让人物贴合 ·「换图」上传照片'
},
/** 智能抠图结果已是透明 PNG 时不套椭圆 mask */
subjectWrapStyle() {
if (this.segmentationMode === 'ai' && this.aiCutoutSrc) {
return {}
}
return this.previewImageExtraStyle
},
/**
* 椭圆 mask 套在外层 view 上(不直接套在 image避免 App 端 image+mask 合成把透明区渲成黑块挡住下层镭射。
*/
previewImageExtraStyle() {
const cx = Math.max(0, Math.min(100, Number(this.beamPivotX) || 50))
const cy = Math.max(0, Math.min(100, Number(this.beamPivotY) || 50))
const rx = Math.max(18, Math.min(62, Number(this.subjectMaskRadiusX) || 46))
const ry = Math.max(22, Math.min(78, Number(this.subjectMaskRadiusY) || 60))
const soft = Math.max(10, Math.min(95, Number(this.subjectMaskSoftness) || 44))
const inner = Math.round(Math.max(32, 74 - soft * 0.32))
const outer = Math.min(99, inner + 10 + Math.round(soft * 0.24))
const mask = `radial-gradient(ellipse ${rx}% ${ry}% at ${cx}% ${cy}%, #000 0%, #000 ${inner}%, transparent ${outer}%)`
return {
maskImage: mask,
WebkitMaskImage: mask,
maskSize: '100% 100%',
WebkitMaskSize: '100% 100%',
maskRepeat: 'no-repeat',
WebkitMaskRepeat: 'no-repeat',
maskComposite: 'source-over',
WebkitMaskComposite: 'source-over'
}
},
/** 与人物椭圆对齐multiply 压暗下层幻彩,减轻边缘透闪 */
subjectHoloSuppressStyle() {
const sup = Math.max(0, Math.min(100, Number(this.subjectLaserSuppress) || 0))
if (sup < 2) {
return { opacity: '0', pointerEvents: 'none' }
}
const cx = Math.max(0, Math.min(100, Number(this.beamPivotX) || 50))
const cy = Math.max(0, Math.min(100, Number(this.beamPivotY) || 50))
const rx = Math.max(18, Math.min(62, Number(this.subjectMaskRadiusX) || 46))
const ry = Math.max(22, Math.min(78, Number(this.subjectMaskRadiusY) || 60))
const soft = Math.max(10, Math.min(95, Number(this.subjectMaskSoftness) || 44))
const inner = Math.round(Math.max(32, 74 - soft * 0.32))
const outer = Math.min(99, inner + 10 + Math.round(soft * 0.24))
const t = sup / 100
const aCenter = (0.16 + t * 0.5).toFixed(3)
const aMid = (0.09 + t * 0.3).toFixed(3)
const bg = `radial-gradient(ellipse ${rx}% ${ry}% at ${cx}% ${cy}%, rgba(26,22,34,${aCenter}) 0%, rgba(18,16,28,${aMid}) ${inner}%, rgba(0,0,0,0) ${outer}%)`
return {
opacity: '1',
background: bg,
mixBlendMode: 'multiply'
}
},
/** 中频流体/揉箔纹理(预览):接近参考稿的液态褶皱纹 + 高光脊 + 微褶,强度见「流体底纹」滑杆 */
marbleFlowOverlayStyle() {
const mf = Math.max(0, Math.min(100, Number(this.marbleFlowStrength) || 0)) / 100
if (mf < 0.02) {
return { opacity: '0', pointerEvents: 'none' }
}
const s = this.portraitStrength
const vivid = Math.max(0.72, Math.min(1.35, (Number(this.previewVivid) || 100) / 100))
const pid = this.stylePresetId
const ang = Number(this.angle) || 0
const bg = this._previewLiquidFoilBackground(mf, s, vivid, pid, ang)
return {
opacity: String(Math.min(0.96, 0.34 + mf * 0.5)),
background: bg,
mixBlendMode: 'soft-light'
}
},
/** 预览:窄条高光 + 热点overlay叠在流体层之上、衍射之下 */
foilSpecOverlayStyle() {
const mf = Math.max(0, Math.min(100, Number(this.marbleFlowStrength) || 0)) / 100
if (mf < 0.08) {
return { opacity: '0', pointerEvents: 'none' }
}
const s = this.portraitStrength
const k = mf * (0.45 + s * 0.55)
const a = (b) => Math.min(0.38, Math.max(0, b * k)).toFixed(3)
const ang = Number(this.angle) || 0
const bg = [
`linear-gradient(${(ang + 51).toFixed(1)}deg, transparent 42%, rgba(255,255,255,${a(0.26)}) 49.8%, transparent 57%)`,
`linear-gradient(${(ang - 56).toFixed(1)}deg, transparent 46%, rgba(255,255,255,${a(0.19)}) 50.2%, transparent 54%)`,
`radial-gradient(ellipse 22% 15% at 48% 30%, rgba(255,255,255,${a(0.36)}) 0%, transparent 68%)`,
`radial-gradient(ellipse 18% 14% at 74% 64%, rgba(255,255,255,${a(0.22)}) 0%, transparent 72%)`
].join(', ')
return {
opacity: String(Math.min(0.58, 0.2 + mf * 0.42)),
background: bg,
mixBlendMode: 'overlay'
}
},
matteFrostStyle() {
const ms = Math.max(0, Math.min(100, Number(this.matteStrength) || 0))
if (ms < 4) {
return { opacity: '0', pointerEvents: 'none' }
}
const o = (0.1 + (ms / 100) * 0.42).toFixed(3)
const a1 = (0.028 + (ms / 100) * 0.06).toFixed(3)
return {
opacity: String(Math.min(0.95, 0.18 + ms * 0.0065)),
background: `repeating-linear-gradient(108deg, rgba(255,255,255,${a1}) 0 1px, transparent 1px 5px), repeating-linear-gradient(-19deg, rgba(255,255,255,${(Number(a1) * 0.85).toFixed(3)}) 0 1px, transparent 1px 4px), radial-gradient(ellipse 140% 120% at 50% 40%, rgba(255,255,255,${o}) 0%, transparent 55%)`,
mixBlendMode: 'soft-light'
}
},
/**
* 与光束旋转层对齐:层为 250%×250% 且居中left/top -75%),任意枢轴旋转时仍能盖住圆角预览区;
* 卡面枢轴 0100 → 层内 transform-origin 百分比 (75+px)/2.5
*/
beamRotateWrapStyle() {
const px = Math.max(0, Math.min(100, Number(this.beamPivotX) || 50))
const py = Math.max(0, Math.min(100, Number(this.beamPivotY) || 50))
const ox = (75 + px) / 2.5
const oy = (75 + py) / 2.5
return {
transformOrigin: `${ox}% ${oy}%`,
transform: `rotate(${this.angle}deg)`
}
},
presetChipMeta() {
return (p) => {
const sp = this.stylePresets.find((x) => x.id === p.stylePresetId)
return sp ? sp.tag : p.stylePresetId
}
},
activePanelMeta() {
if (this.activeTab === 'style') {
return {
title: '镭射风格',
desc: '一键套用样例卡常见的配色与光感,再进调校细调',
tag: this.currentPreset.tag
}
}
return {
title: '效果调校',
desc: '强度、角度、颗粒为必会项;下方两项仅影响预览动效',
tag: this.tuneSummary
}
},
portraitStrength() {
const t = this.laserStrength / 100
return Math.min(1, 0.08 + t * 0.9)
},
meshBackground() {
switch (this.stylePresetId) {
case 'dream':
return `radial-gradient(ellipse 38% 32% at 56% 44%, rgba(255,255,255,0.42) 0%, rgba(255,255,255,0.06) 28%, transparent 58%),
radial-gradient(ellipse 92% 72% at 10% 16%, rgba(186,104,255,0.58), transparent 54%),
radial-gradient(ellipse 78% 62% at 90% 20%, rgba(255,105,180,0.48), transparent 52%),
radial-gradient(ellipse 72% 58% at 14% 86%, rgba(0,229,255,0.42), transparent 50%),
radial-gradient(ellipse 58% 52% at 80% 74%, rgba(255,224,140,0.4), transparent 48%)`
case 'classic':
return `radial-gradient(ellipse 36% 30% at 52% 48%, rgba(255,255,255,0.38) 0%, rgba(255,255,255,0.05) 30%, transparent 56%),
radial-gradient(ellipse 88% 68% at 48% 8%, rgba(255,80,200,0.42), transparent 46%),
radial-gradient(ellipse 82% 72% at 6% 58%, rgba(0,200,255,0.45), transparent 50%),
radial-gradient(ellipse 74% 58% at 94% 78%, rgba(180,255,120,0.38), transparent 46%)`
case 'ice':
return `radial-gradient(ellipse 40% 34% at 48% 40%, rgba(255,255,255,0.48) 0%, rgba(230,248,255,0.08) 32%, transparent 60%),
radial-gradient(ellipse 90% 70% at 18% 22%, rgba(100,180,255,0.52), transparent 52%),
radial-gradient(ellipse 78% 60% at 88% 32%, rgba(160,140,255,0.44), transparent 50%),
radial-gradient(ellipse 68% 62% at 48% 90%, rgba(200,240,255,0.4), transparent 48%)`
case 'sunset':
return `radial-gradient(ellipse 42% 36% at 50% 42%, rgba(255,255,255,0.4) 0%, rgba(255,240,230,0.08) 30%, transparent 58%),
radial-gradient(ellipse 88% 65% at 12% 28%, rgba(255,150,120,0.5), transparent 50%),
radial-gradient(ellipse 74% 60% at 92% 22%, rgba(255,200,140,0.46), transparent 48%),
radial-gradient(ellipse 72% 68% at 52% 88%, rgba(255,100,160,0.4), transparent 48%)`
case 'pearl':
return `radial-gradient(ellipse 44% 36% at 50% 38%, rgba(255,255,255,0.55) 0%, rgba(245,248,252,0.12) 35%, transparent 62%),
radial-gradient(ellipse 82% 62% at 22% 28%, rgba(255,255,255,0.52), transparent 50%),
radial-gradient(ellipse 78% 68% at 82% 38%, rgba(210,225,245,0.42), transparent 50%),
radial-gradient(ellipse 70% 58% at 50% 82%, rgba(180,200,255,0.36), transparent 46%)`
case 'holoFull':
default:
return `radial-gradient(ellipse 36% 30% at 54% 46%, rgba(255,255,255,0.4) 0%, rgba(255,255,255,0.06) 26%, transparent 55%),
radial-gradient(ellipse 86% 70% at 12% 16%, rgba(255,80,180,0.5), transparent 52%),
radial-gradient(ellipse 78% 64% at 92% 22%, rgba(255,180,80,0.46), transparent 50%),
radial-gradient(ellipse 74% 58% at 8% 78%, rgba(0,220,255,0.48), transparent 50%),
radial-gradient(ellipse 76% 62% at 88% 82%, rgba(160,100,255,0.5), transparent 50%),
radial-gradient(ellipse 60% 50% at 50% 50%, rgba(255,255,255,0.18), transparent 60%)`
}
},
meshOverlayStyle() {
const s = this.portraitStrength
return {
opacity: String(Math.min(0.98, 0.38 + s * 0.52)),
background: this.meshBackground,
mixBlendMode: 'screen'
}
},
/**
* 满铺细密斜向彩虹条纹diffraction lines——参考实物镭射卡的核心特征
* 整张卡面铺满细密、密集、规整重复的彩虹条纹,光线越强越鲜明。
* 角度 = 光束角度 + 用户可调偏移(默认 +90°即垂直于光线方向
* 密度由 diffractionDensity 控制,对每个色带 px 位置做线性缩放u 越小越密)。
*/
diffractionOverlayStyle() {
const s = this.portraitStrength
const vivid = Math.max(0.7, Math.min(1.4, (Number(this.previewVivid) || 100) / 100))
const density = Math.max(0, Math.min(100, Number(this.diffractionDensity) || 60)) / 100
const angleOff = Math.max(-90, Math.min(90, Number(this.diffractionAngleOffset) || 90))
const a = (Number(this.angle) || 52) + angleOff
const baseA = (0.13 + s * 0.18) * vivid
const hiA = (0.18 + s * 0.22) * vivid
const opacity = Math.min(0.92, 0.28 + s * 0.5 + density * 0.18)
const u = (3.2 - density * 2.4).toFixed(2)
const stripe = `repeating-linear-gradient(${a}deg,
rgba(255, 60, 180, ${baseA.toFixed(3)}) 0px,
rgba(255, 170, 60, ${baseA.toFixed(3)}) ${(Number(u) * 2).toFixed(2)}px,
rgba(180, 255, 80, ${hiA.toFixed(3)}) ${(Number(u) * 4).toFixed(2)}px,
rgba(0, 220, 255, ${hiA.toFixed(3)}) ${(Number(u) * 6).toFixed(2)}px,
rgba(120, 90, 255, ${baseA.toFixed(3)}) ${(Number(u) * 8).toFixed(2)}px,
rgba(255, 80, 200, ${baseA.toFixed(3)}) ${(Number(u) * 10).toFixed(2)}px,
transparent ${(Number(u) * 11).toFixed(2)}px,
transparent ${(Number(u) * 14).toFixed(2)}px)`
const macroStep = (Number(u) * 28).toFixed(2)
const macro = `repeating-linear-gradient(${a + 4}deg,
rgba(255,255,255, ${(baseA * 0.55).toFixed(3)}) 0px,
rgba(255,255,255, 0) ${(Number(u) * 3).toFixed(2)}px,
rgba(255,255,255, 0) ${macroStep}px)`
return {
opacity: String(opacity),
background: `${stripe}, ${macro}`,
mixBlendMode: 'screen'
}
},
/* 角度由外层 preview-beam-rotate 承担,此处为局部 0°朝上色带 */
iridGradient() {
switch (this.stylePresetId) {
case 'dream':
return `linear-gradient(0deg, rgba(255,230,255,0.5) 0%, rgba(160,110,255,0.55) 16%, rgba(60,200,255,0.52) 38%, rgba(255,130,200,0.48) 60%, rgba(255,248,200,0.46) 82%, rgba(190,170,255,0.44) 100%)`
case 'classic':
return `linear-gradient(0deg, rgba(255,60,180,0.58) 0%, rgba(110,60,255,0.52) 22%, rgba(0,210,255,0.55) 45%, rgba(255,240,100,0.5) 68%, rgba(255,100,200,0.54) 100%)`
case 'ice':
return `linear-gradient(0deg, rgba(210,235,255,0.52) 0%, rgba(80,150,255,0.54) 32%, rgba(130,110,230,0.5) 58%, rgba(190,230,255,0.48) 100%)`
case 'sunset':
return `linear-gradient(0deg, rgba(255,225,190,0.54) 0%, rgba(255,130,110,0.5) 24%, rgba(255,90,160,0.48) 52%, rgba(255,215,150,0.52) 100%)`
case 'pearl':
return `linear-gradient(0deg, rgba(255,255,255,0.58) 0%, rgba(220,232,248,0.46) 34%, rgba(170,200,255,0.42) 62%, rgba(255,252,248,0.5) 100%)`
case 'holoFull':
return `linear-gradient(0deg, rgba(255,60,180,0.62) 0%, rgba(255,170,60,0.58) 18%, rgba(180,255,80,0.55) 36%, rgba(0,220,255,0.6) 54%, rgba(120,90,255,0.58) 72%, rgba(255,80,200,0.6) 90%, rgba(255,255,255,0.5) 100%)`
default:
return `linear-gradient(0deg, rgba(255,200,255,0.5) 0%, rgba(120,190,255,0.48) 100%)`
}
},
iridBlendMode() {
return this.stylePresetId === 'pearl' ? 'overlay' : 'screen'
},
iridCoreStyle() {
const o = Math.min(1, 0.42 + this.portraitStrength * 0.82)
return {
width: '100%',
height: '100%',
opacity: String(o),
background: this.iridGradient,
mixBlendMode: this.iridBlendMode
}
},
raysOverlayStyle() {
const s = this.portraitStrength
const bi = Math.max(0, Math.min(100, Number(this.beamIntensity) || 48)) / 100
const e = this.beamEffectId
const vivid = Math.max(0.7, Math.min(1.35, (this.previewVivid || 100) / 100))
const sat = (1.1 * vivid).toFixed(2)
const beamOpacity = Math.min(0.7, 0.08 + s * 0.32 + bi * 0.3)
const beamWidth = (0.12 + bi * 0.2).toFixed(3)
const beamLength = 0.88
const beamStart = ((1 - beamLength) / 2).toFixed(3)
const beamEnd = ((1 + beamLength) / 2).toFixed(3)
const beamCenter = 0.5
const bandWidth = (0.08 + bi * 0.12).toFixed(3)
const midT = (beamCenter - Number(bandWidth) / 2).toFixed(3)
const hiT = (beamCenter + Number(bandWidth) / 2).toFixed(3)
let c0, c1, c2, c3, c4
if (e === 'prism') {
c0 = 'rgba(255,80,200,0)'
c1 = `rgba(255,80,200,${(0.45 * sat).toFixed(2)})`
c2 = `rgba(160,80,255,${(0.7 * sat).toFixed(2)})`
c3 = `rgba(0,220,255,${(0.85 * sat).toFixed(2)})`
c4 = 'rgba(255,240,100,0)'
} else if (e === 'aurora') {
c0 = 'rgba(100,255,220,0)'
c1 = `rgba(100,255,220,${(0.45 * sat).toFixed(2)})`
c2 = `rgba(140,80,255,${(0.7 * sat).toFixed(2)})`
c3 = `rgba(200,80,255,${(0.85 * sat).toFixed(2)})`
c4 = 'rgba(255,120,200,0)'
} else if (e === 'sunset') {
c0 = 'rgba(255,160,80,0)'
c1 = `rgba(255,160,80,${(0.45 * sat).toFixed(2)})`
c2 = `rgba(255,80,140,${(0.7 * sat).toFixed(2)})`
c3 = `rgba(255,120,220,${(0.85 * sat).toFixed(2)})`
c4 = 'rgba(255,220,160,0)'
} else if (e === 'neon') {
c0 = 'rgba(0,200,255,0)'
c1 = `rgba(0,200,255,${(0.45 * sat).toFixed(2)})`
c2 = `rgba(255,0,180,${(0.7 * sat).toFixed(2)})`
c3 = `rgba(255,240,80,${(0.85 * sat).toFixed(2)})`
c4 = 'rgba(120,255,180,0)'
} else {
c0 = 'rgba(200,240,255,0)'
c1 = `rgba(200,240,255,${(0.42 * sat).toFixed(2)})`
c2 = `rgba(255,255,255,${(0.72 * sat).toFixed(2)})`
c3 = `rgba(180,220,255,${(0.8 * sat).toFixed(2)})`
c4 = 'rgba(140,200,255,0)'
}
return {
opacity: String(beamOpacity),
background: `linear-gradient(135deg,
${c0} ${(Number(beamStart) * 100).toFixed(1)}%,
${c1} ${((Number(beamStart) + 0.04) * 100).toFixed(1)}%,
${c2} 50%,
${c3} ${((Number(beamEnd) - 0.04) * 100).toFixed(1)}%,
${c4} ${(Number(beamEnd) * 100).toFixed(1)}%),
linear-gradient(180deg,
transparent ${(Number(midT) * 100).toFixed(1)}%,
${c2} ${(Number(midT) * 100).toFixed(1)}%,
rgba(255,255,255,${(0.5 * sat).toFixed(2)}) 50%,
transparent ${(Number(hiT) * 100).toFixed(1)}%)`,
backgroundSize: '200% 200%, 100% 100%',
backgroundPosition: 'top left, center',
backgroundRepeat: 'no-repeat, no-repeat',
mixBlendMode: 'screen'
}
},
grainPattern() {
const d = (Number(this.grainDensityEffective) || 70) / 100
const a1 = (0.06 + d * 0.09).toFixed(3)
const a2 = (0.045 + d * 0.07).toFixed(3)
const fine = `repeating-linear-gradient(-16deg, rgba(255,255,255,${a1}) 0 1px, transparent 1px ${Math.max(3, Math.round(5 - d * 2))}px), repeating-linear-gradient(74deg, rgba(255,255,255,${a2}) 0 1px, transparent 1px ${Math.max(4, Math.round(6 - d * 2))}px)`
const sp = (0.22 + d * 0.32).toFixed(2)
const sp2 = (Number(sp) * 0.8).toFixed(2)
const sp3 = (Number(sp) * 0.65).toFixed(2)
const specks = [
// 主白色亮点(高密度)
[8, 11, 'rgba(255,255,255,' + sp + ')'],
[22, 7, 'rgba(255,255,255,' + sp2 + ')'],
[37, 16, 'rgba(200,230,255,' + sp + ')'],
[54, 9, 'rgba(255,255,255,' + sp + ')'],
[68, 5, 'rgba(255,240,200,' + sp2 + ')'],
[83, 14, 'rgba(255,255,255,' + sp + ')'],
[94, 22, 'rgba(180,255,255,' + sp2 + ')'],
[13, 28, 'rgba(255,220,255,' + sp2 + ')'],
[29, 34, 'rgba(255,255,255,' + sp + ')'],
[46, 23, 'rgba(220,255,240,' + sp3 + ')'],
[61, 32, 'rgba(255,255,255,' + sp2 + ')'],
[77, 27, 'rgba(255,255,255,' + sp + ')'],
[88, 36, 'rgba(180,210,255,' + sp2 + ')'],
[6, 45, 'rgba(255,255,255,' + sp + ')'],
[20, 52, 'rgba(255,240,180,' + sp3 + ')'],
[34, 48, 'rgba(255,255,255,' + sp + ')'],
[49, 56, 'rgba(200,255,255,' + sp2 + ')'],
[63, 50, 'rgba(255,255,255,' + sp + ')'],
[78, 58, 'rgba(255,220,255,' + sp2 + ')'],
[92, 53, 'rgba(255,255,255,' + sp + ')'],
[11, 67, 'rgba(255,255,255,' + sp2 + ')'],
[26, 72, 'rgba(200,230,255,' + sp + ')'],
[42, 64, 'rgba(255,255,255,' + sp + ')'],
[58, 76, 'rgba(255,240,200,' + sp2 + ')'],
[73, 69, 'rgba(255,255,255,' + sp + ')'],
[86, 78, 'rgba(180,255,255,' + sp2 + ')'],
[4, 84, 'rgba(255,255,255,' + sp3 + ')'],
[19, 91, 'rgba(255,255,255,' + sp + ')'],
[33, 88, 'rgba(255,220,255,' + sp2 + ')'],
[50, 95, 'rgba(255,255,255,' + sp + ')'],
[67, 86, 'rgba(220,255,240,' + sp3 + ')'],
[82, 93, 'rgba(255,255,255,' + sp + ')'],
[96, 88, 'rgba(255,240,180,' + sp2 + ')']
]
const speck = specks
.map(([x, y, c]) => `radial-gradient(circle at ${x}% ${y}%, ${c} 0 1px, transparent 2px)`)
.join(', ')
return `${fine}, ${speck}`
},
grainOverlayStyle() {
const o = Math.min(1, 0.32 + this.portraitStrength * 0.68)
return {
opacity: String(o),
background: this.grainPattern,
mixBlendMode: 'overlay'
}
},
sheenCoreStyle() {
const s = this.portraitStrength
const bi = Math.max(0, Math.min(100, Number(this.beamIntensity) || 48)) / 100
const o = Math.min(0.6, (0.08 + s * 0.28 + bi * 0.2) * 0.9)
const e = this.beamEffectId
const beamW = (0.1 + bi * 0.18).toFixed(3)
const vivid = Math.max(0.7, Math.min(1.35, (this.previewVivid || 100) / 100))
const sat = (1.05 * vivid).toFixed(2)
let c1, c2
if (e === 'prism') {
c1 = `rgba(255,220,255,${(0.92 * sat).toFixed(2)})`
c2 = `rgba(200,80,255,${(0.5 * sat).toFixed(2)})`
} else if (e === 'aurora') {
c1 = `rgba(200,160,255,${(0.92 * sat).toFixed(2)})`
c2 = `rgba(0,240,220,${(0.5 * sat).toFixed(2)})`
} else if (e === 'sunset') {
c1 = `rgba(255,220,160,${(0.92 * sat).toFixed(2)})`
c2 = `rgba(255,120,100,${(0.5 * sat).toFixed(2)})`
} else if (e === 'neon') {
c1 = `rgba(0,255,255,${(0.88 * sat).toFixed(2)})`
c2 = `rgba(255,80,200,${(0.5 * sat).toFixed(2)})`
} else {
c1 = `rgba(255,255,255,${(0.9 * sat).toFixed(2)})`
c2 = `rgba(200,230,255,${(0.48 * sat).toFixed(2)})`
}
return {
width: '100%',
height: '100%',
opacity: String(o),
background: `linear-gradient(135deg,
transparent ${((0.5 - Number(beamW)) * 100).toFixed(1)}%,
${c1} ${((0.5 - Number(beamW) * 0.3) * 100).toFixed(1)}%,
${c2} 50%,
${c1} ${((0.5 + Number(beamW) * 0.3) * 100).toFixed(1)}%,
transparent ${((0.5 + Number(beamW)) * 100).toFixed(1)}%)`,
mixBlendMode: 'screen'
}
},
previewAnimSpeedFactor() {
const sp = Number(this.previewAnimSpeed) || 100
return Math.max(0.35, Math.min(2.8, sp / 100))
},
iridWrapDynamicStyle() {
const vivid = Math.max(0.65, Math.min(1.45, (Number(this.previewVivid) || 100) / 100))
const sat = (1.14 * vivid).toFixed(3)
const brLo = (1.02 + (vivid - 1) * 0.11).toFixed(3)
const brHi = (1.08 + (vivid - 1) * 0.15).toFixed(3)
const f = this.previewAnimSpeedFactor
const style = {
'--holo-sat': sat,
'--holo-bright-lo': brLo,
'--holo-bright-hi': brHi
}
if (this.presetAnimMode === 'hue') {
style.animationDuration = `${(9.5 / f).toFixed(2)}s`
} else if (this.presetAnimMode === 'metal') {
style.animationDuration = `${(3.4 / f).toFixed(2)}s`
}
return style
},
sheenWrapStyle() {
const f = this.previewAnimSpeedFactor
return {
animationDuration: `${(2.6 / f).toFixed(2)}s`
}
},
/**
* 叠在人物图之上conic + 扫光 + 与底层同系 irid 渐变hue 动画与 irid 一致,模拟参考卡正面「膜上」幻彩。
*/
subjectHoloVeilStyle() {
const v = Math.max(0, Math.min(100, Number(this.portraitVeilStrength) || 0)) / 100
if (v < 0.03) {
return { display: 'none', pointerEvents: 'none' }
}
const vivid = Math.max(0.65, Math.min(1.45, (Number(this.previewVivid) || 100) / 100))
const f = this.previewAnimSpeedFactor
const ang = Number(this.angle) || 0
const pid = this.stylePresetId
const ls = Math.max(0, Math.min(1, this.laserStrength / 100))
const sub = Math.max(0, Math.min(1, Number(this.subjectLaserSuppress) / 100 || 0))
const pidBoost = pid === 'pearl' ? 0.82 : pid === 'ice' ? 1.1 : 1
const mix = (0.82 - sub * 0.25) * (0.38 + v * 0.62) * (0.5 + ls * 0.5) * pidBoost
const op = Math.min(0.52, 0.14 + mix * 0.48)
const w = (0.04 + v * 0.14 * vivid).toFixed(3)
const ir = { ...this.iridWrapDynamicStyle }
if (this.presetAnimMode === 'hue') {
ir.animationDuration = `${(10.8 / f).toFixed(2)}s`
} else if (this.presetAnimMode === 'metal') {
ir.animationDuration = `${(3.9 / f).toFixed(2)}s`
}
const core = this.iridGradient
const conic = `conic-gradient(from ${(ang * 0.7 + 18).toFixed(1)}deg at 46% 42%, rgba(255,255,255,${(Number(w) * 0.85).toFixed(3)}) 0deg, transparent 56deg, rgba(210,170,255,${(Number(w) * 1.35).toFixed(3)}) 118deg, rgba(110,220,255,${(Number(w) * 1.15).toFixed(3)}) 198deg, transparent 278deg)`
const sweep = `linear-gradient(${(ang + 58).toFixed(1)}deg, transparent 32%, rgba(255,255,255,${(0.06 + v * 0.16).toFixed(3)}) 49.5%, transparent 66%)`
return {
...ir,
display: 'block',
position: 'absolute',
left: '0',
top: '0',
right: '0',
bottom: '0',
zIndex: '2',
pointerEvents: 'none',
borderRadius: 'inherit',
opacity: String(op),
background: `${conic}, ${sweep}, ${core}`,
backgroundSize: '100% 100%',
mixBlendMode: 'soft-light',
transform: 'scale(1.12)',
transformOrigin: 'center center'
}
}
},
watch: {
previewSrc(src) {
this._previewLayout = null
this._stopPreviewAnimation()
this._destroyWebglPreview()
if (src) {
this.$nextTick(() => {
this._refreshPreviewLayoutFromSrc()
})
} else {
this._backdropLayout = null
}
},
laserBackdropId() {
this._refreshBackdropLayout()
}
},
onLoad() {
try {
const raw = uni.getStorageSync(CASTLOVE_LASER_ENTRY_KEY)
if (raw) {
this.castloveEntry = typeof raw === 'string' ? JSON.parse(raw) : raw
const ent = this.castloveEntry
const b64 = ent && String(ent.uploadedImageBase64 || '').trim()
const path = ent && String(ent.uploadedImage || '').trim()
/** App/小程序getImageInfo + canvas drawImage 对超长 dataURL 常失败,优先本地临时路径 */
let img = ''
if (path) {
img = path
} else if (b64) {
img = b64.startsWith('data:') ? b64 : `data:image/jpeg;base64,${b64}`
}
if (img) {
this.previewSrc = img
}
}
} catch (e) {
console.error('[LaserCardStudio] castlove entry', e)
}
try {
const sys = uni.getSystemInfoSync()
this.statusBarHeight = sys.statusBarHeight || 24
this.safeAreaBottom = sys.safeAreaInsets?.bottom || 0
const barBody = typeof uni.upx2px === 'function' ? uni.upx2px(92) : 46
this.stickyHeaderTotalPx = this.statusBarHeight + barBody
} catch (e) {
/* 非 App 或异常时使用默认值 */
this.stickyHeaderTotalPx = (this.statusBarHeight || 24) + 48
}
this.loadParamPresets()
},
onUnload() {
this._stopPreviewAnimation()
this._destroyWebglPreview()
},
methods: {
_computeCoverLayout(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 }
},
/** 可写沙盒根目录App/小程序H5 常为空 */
_getUserDataPathForWrite() {
try {
if (typeof uni !== 'undefined' && uni.env && uni.env.USER_DATA_PATH) {
return uni.env.USER_DATA_PATH
}
} catch (e) {
/* noop */
}
try {
if (typeof wx !== 'undefined' && wx.env && wx.env.USER_DATA_PATH) {
return wx.env.USER_DATA_PATH
}
} catch (e2) {
/* noop */
}
return ''
},
/**
* data:image/*;base64,... 在真机 getImageInfo 易失败:落盘为固定临时文件后返回本地路径。
* 已是 http(s)/本地路径则原样返回。
*/
_materializeDataUrlForCanvas(src) {
return new Promise((resolve) => {
if (!src || typeof src !== 'string') {
resolve('')
return
}
if (!src.startsWith('data:image/')) {
resolve(src)
return
}
const fs = typeof uni.getFileSystemManager === 'function' ? uni.getFileSystemManager() : null
const root = this._getUserDataPathForWrite()
if (!fs || !root) {
resolve(src)
return
}
const comma = src.indexOf(',')
if (comma < 12) {
resolve('')
return
}
const head = src.slice(0, comma).toLowerCase()
const raw = src.slice(comma + 1)
const ext = head.indexOf('png') !== -1 ? 'png' : 'jpg'
const fp = `${root}/castlove_laser_preview_work.${ext}`
fs.writeFile({
filePath: fp,
data: raw,
encoding: 'base64',
success: () => resolve(fp),
fail: (err) => {
console.warn('[LaserCardStudio] materialize dataUrl fail', err)
resolve(src)
}
})
})
},
_resolveLaserBackdropPath() {
const opt = this.laserBackdropOptions.find((o) => o.id === this.laserBackdropId)
return opt && opt.src ? opt.src : ''
},
_refreshBackdropLayout() {
const cw = this.exportCssW
const ch = this.exportCssH
const p = this._resolveLaserBackdropPath()
if (!p) {
this._backdropLayout = null
return
}
uni.getImageInfo({
src: p,
success: (img) => {
this._backdropLayout = this._computeCoverLayout(img.width, img.height, cw, ch)
this._reloadWebglBackdropTextureOnly()
},
fail: (err) => {
console.warn('[LaserCardStudio] backdrop getImageInfo', err)
this._backdropLayout = null
}
})
},
onPickLaserBackdrop(id) {
if (this.laserBackdropId === id) {
return
}
this.laserBackdropId = id
},
/**
* 圆角 clip 内合成:
* - 有内置镭射底图:底图铺底 → 人物原图(清晰载体)→ 同一张纹理以 overlay + screen 叠在人物上(豆包思路,控制雾感)→ 极轻颗粒。
* - 无底图:保留原算法镭射链(略偏强时可再调 data
*/
_drawSparseFilmGrain(ctx, cw, ch) {
const rnd = Math.random
const n = Math.floor(480 + rnd() * 360)
ctx.save()
if (typeof ctx.setGlobalCompositeOperation === 'function') {
ctx.setGlobalCompositeOperation('overlay')
}
ctx.setGlobalAlpha(0.14)
for (let i = 0; i < n; i++) {
const x = rnd() * cw
const y = rnd() * ch
const a = (0.028 + rnd() * 0.055).toFixed(3)
ctx.setFillStyle(`rgba(255,255,255,${a})`)
ctx.fillRect(x, y, 1, 1)
}
ctx.restore()
ctx.setGlobalAlpha(1)
if (typeof ctx.setGlobalCompositeOperation === 'function') {
ctx.setGlobalCompositeOperation('source-over')
}
},
_drawLaserCardComposite(ctx, cw, ch, imagePath, layout, timeMs) {
const { dx, dy, dw, dh } = layout
ctx.setFillStyle('#141018')
ctx.fillRect(0, 0, cw, ch)
ctx.save()
this._clipExportCardRoundRect(ctx, cw, ch)
const bdPath = this._resolveLaserBackdropPath()
const bdLay = this._backdropLayout
const hasBuiltInBackdrop = !!(bdPath && bdLay)
if (hasBuiltInBackdrop) {
try {
ctx.drawImage(bdPath, bdLay.dx, bdLay.dy, bdLay.dw, bdLay.dh)
} catch (e) {
/* 底图失败则仅靠人物 */
}
}
if (typeof ctx.setGlobalCompositeOperation === 'function') {
ctx.setGlobalCompositeOperation('source-over')
}
ctx.setGlobalAlpha(1)
try {
ctx.drawImage(imagePath, dx, dy, dw, dh)
} catch (e) {
/* drawImage 失败时仍铺镭射底,避免白屏 */
}
if (hasBuiltInBackdrop) {
try {
ctx.save()
if (typeof ctx.setGlobalCompositeOperation === 'function') {
ctx.setGlobalCompositeOperation('overlay')
}
ctx.setGlobalAlpha(0.44)
ctx.drawImage(bdPath, bdLay.dx, bdLay.dy, bdLay.dw, bdLay.dh)
ctx.restore()
ctx.save()
if (typeof ctx.setGlobalCompositeOperation === 'function') {
ctx.setGlobalCompositeOperation('screen')
}
ctx.setGlobalAlpha(0.22)
ctx.drawImage(bdPath, bdLay.dx, bdLay.dy, bdLay.dw, bdLay.dh)
ctx.restore()
} catch (e) {
/* 纹理叠层失败则仅保留人物 */
}
this._drawSparseFilmGrain(ctx, cw, ch)
} else {
this._exportDrawHslRainbowBase(ctx, cw, ch, timeMs)
this._drawSolidLaserBackdrop(ctx, cw, ch)
this._drawLaserLayers(ctx, cw, ch, undefined, timeMs)
}
ctx.restore()
},
/**
* 旧版 canvas 上下文Vue 3 页面若传入 `this`Proxy部分端无法绑定到画布表现为整幅透明/全黑。
* 页面级 canvas 使用单参数即可Vue 2 仍传实例以兼容旧端。
*/
_createLaserCanvasContext(canvasId) {
// #ifdef VUE3
return uni.createCanvasContext(canvasId)
// #endif
// #ifndef VUE3
return uni.createCanvasContext(canvasId, this)
// #endif
},
_destroyWebglPreview() {
this.laserWebglSession++
this.webglPreviewReady = false
this._webglBooting = false
if (this._webglR) {
try {
this._webglR.destroy()
} catch (e) {
/* noop */
}
this._webglR = null
}
this._webglCanvasNode = null
},
/**
* 查询 type=webgl 画布、按 dpr 设物理像素、编译着色器。
* @returns {Promise<boolean>}
*/
_initWebglPreviewEngine() {
return new Promise((resolve) => {
if (this._webglR) {
try {
this._webglR.destroy()
} catch (e) {
/* noop */
}
this._webglR = null
}
try {
const q = uni.createSelectorQuery().in(this)
q.select('#laserPreviewWebgl')
.fields({ node: true, size: true })
.exec((res) => {
const row = res && res[0]
if (!row || !row.node) {
resolve(false)
return
}
const canvas = row.node
this._webglCanvasNode = canvas
let pr = 1
try {
const sys = uni.getSystemInfoSync()
pr = Math.min(2.5, Math.max(1, Number(sys.pixelRatio) || 1))
} catch (e1) {
pr = 1
}
const bw = Math.max(2, Math.floor(this.exportCssW * pr))
const bh = Math.max(2, Math.floor(this.exportCssH * pr))
try {
canvas.width = bw
canvas.height = bh
} catch (e2) {
resolve(false)
return
}
const gl =
canvas.getContext('webgl', {
alpha: true,
antialias: true,
preserveDrawingBuffer: false,
stencil: false,
depth: false
}) || canvas.getContext('experimental-webgl')
if (!gl) {
resolve(false)
return
}
try {
this._webglR = createLaserPreviewWebgl(gl, {
width: this.exportCssW,
height: this.exportCssH
})
resolve(true)
} catch (e3) {
console.warn('[LaserCardStudio] WebGL program', e3)
resolve(false)
}
})
} catch (e) {
resolve(false)
}
})
},
async _loadWebglTextures() {
if (!this._webglR || !this.previewSrc || !this._webglCanvasNode) {
return
}
const photoImg = await loadTextureImage(this.previewSrc, this._webglCanvasNode)
this._webglR.uploadPhoto(photoImg)
const bp = this._resolveLaserBackdropPath()
if (bp) {
try {
const bi = await loadTextureImage(bp, this._webglCanvasNode)
this._webglR.uploadBackdrop(bi)
} catch (e) {
this._webglR.uploadBackdrop(null)
}
} else {
this._webglR.uploadBackdrop(null)
}
},
async _reloadWebglBackdropTextureOnly() {
if (!this._webglR || !this._webglCanvasNode) {
return
}
const sess = this.laserWebglSession
const bp = this._resolveLaserBackdropPath()
if (!bp) {
this._webglR.uploadBackdrop(null)
return
}
try {
const bi = await loadTextureImage(bp, this._webglCanvasNode)
if (sess !== this.laserWebglSession) {
return
}
this._webglR.uploadBackdrop(bi)
} catch (e) {
if (sess !== this.laserWebglSession) {
return
}
this._webglR.uploadBackdrop(null)
}
},
async _bootWebglPreview() {
if (this._webglBooting || !this.previewSrc || !this._previewLayout) {
return
}
this._webglBooting = true
const sess = this.laserWebglSession
try {
const ok = await this._initWebglPreviewEngine()
if (sess !== this.laserWebglSession) {
return
}
if (!ok) {
this.webglPreviewReady = false
return
}
await this._loadWebglTextures()
if (sess !== this.laserWebglSession) {
return
}
if (!this._webglR || !this._webglR.hasPhoto()) {
this.webglPreviewReady = false
return
}
this.webglPreviewReady = true
} catch (e) {
console.warn('[LaserCardStudio] WebGL boot', e)
this.webglPreviewReady = false
} finally {
this._webglBooting = false
}
},
_renderWebglPreviewFrame(t) {
if (!this.webglPreviewReady || !this._webglR || !this._previewLayout) {
return
}
const timeMs = typeof t === 'number' ? t : Date.now()
const vivid = Math.max(0.72, Math.min(1.35, (Number(this.previewVivid) || 100) / 100))
const strength = Math.max(0.42, Math.min(1.12, (Number(this.laserStrength) || 65) / 100))
const animSpeed = Math.max(0.35, Math.min(1.8, (Number(this.previewAnimSpeed) || 100) / 100))
const styleHue =
typeof BEAM_STYLE_HUE[this.beamEffectId] === 'number'
? BEAM_STYLE_HUE[this.beamEffectId]
: BEAM_STYLE_HUE.prism
const cw = this.exportCssW
const ch = this.exportCssH
const hasBd = !!(this._backdropLayout && this._resolveLaserBackdropPath())
this._webglR.render(timeMs, {
photoRect: this._previewLayout,
backRect: this._backdropLayout,
hasBackdrop: hasBd,
cornerRadiusPx: Math.min(32, cw * 0.065, ch * 0.048),
vivid,
strength,
styleHue,
animSpeed,
canvasW: cw,
canvasH: ch
})
},
_schedulePreviewFrame(cb) {
if (typeof requestAnimationFrame === 'function') {
return requestAnimationFrame(cb)
}
return setTimeout(cb, 33)
},
_cancelPreviewFrame(id) {
if (id == null) {
return
}
try {
clearTimeout(id)
} catch (e) {
/* noop */
}
try {
if (typeof cancelAnimationFrame === 'function') {
cancelAnimationFrame(id)
}
} catch (e) {
/* noop */
}
},
_stopPreviewAnimation() {
if (this._previewRafId != null) {
this._cancelPreviewFrame(this._previewRafId)
this._previewRafId = null
}
},
_refreshPreviewLayoutFromSrc() {
const src = this.previewSrc
if (!src) {
this._previewLayout = null
this._stopPreviewAnimation()
return
}
this._materializeDataUrlForCanvas(src).then((resolved) => {
if (!resolved) {
this._previewLayout = null
this._stopPreviewAnimation()
uni.showToast({ title: '无法加载预览图', icon: 'none' })
return
}
if (resolved !== src) {
this.previewSrc = resolved
return
}
uni.getImageInfo({
src: resolved,
success: (img) => {
this._previewLayout = this._computeCoverLayout(img.width, img.height, this.exportCssW, this.exportCssH)
this.$nextTick(() => {
setTimeout(() => {
this._refreshBackdropLayout()
this._startPreviewAnimation()
this._bootWebglPreview()
}, 48)
})
},
fail: (err) => {
console.warn('[LaserCardStudio] getImageInfo fail', err)
this._previewLayout = null
this._stopPreviewAnimation()
uni.showToast({ title: '无法加载预览图', icon: 'none' })
}
})
})
},
_startPreviewAnimation() {
this._stopPreviewAnimation()
const loop = () => {
if (!this.previewSrc || !this._previewLayout) {
this._previewRafId = null
return
}
if (this.webglPreviewReady && this._webglR) {
this._renderWebglPreviewFrame(Date.now())
} else {
this._renderLaserPreviewFrame()
}
this._previewRafId = this._schedulePreviewFrame(loop)
}
this._previewRafId = this._schedulePreviewFrame(loop)
},
_renderLaserPreviewFrame() {
const cw = this.exportCssW
const ch = this.exportCssH
const layout = this._previewLayout
const path = this.previewSrc
if (!layout || !path) {
return
}
const ctx = this._createLaserCanvasContext('laserPreviewCanvas')
const t = Date.now()
this._drawLaserCardComposite(ctx, cw, ch, path, layout, t)
ctx.draw(false)
},
/**
* 仅用于预览:参考「液态丝绸 / 揉箔 / 高光脊」叠层conic + 大椭圆褶 + 窄条高光 + 微褶)。
*/
_previewLiquidFoilBackground(mf, s, vivid, pid, angDeg) {
const ang = Number(angDeg) || 0
const pearlMul = pid === 'pearl' ? 0.58 : 1
const k = mf * (0.34 + s * 0.66) * vivid * pearlMul
const A = (b) => Math.min(0.5, Math.max(0, b * k)).toFixed(3)
const R = (r, g, b, a) => `rgba(${r},${g},${b},${a})`
const palettes = {
dream: {
deep: [52, 22, 78],
mid: [150, 88, 210],
hi: [255, 220, 245],
acc: [72, 200, 255],
spec: [255, 255, 255]
},
classic: {
deep: [90, 15, 95],
mid: [255, 55, 170],
hi: [255, 250, 160],
acc: [0, 200, 255],
spec: [255, 255, 255]
},
ice: {
deep: [18, 48, 92],
mid: [72, 140, 230],
hi: [230, 246, 255],
acc: [160, 210, 255],
spec: [255, 255, 255]
},
sunset: {
deep: [115, 38, 52],
mid: [255, 130, 110],
hi: [255, 230, 200],
acc: [255, 185, 165],
spec: [255, 252, 248]
},
pearl: {
deep: [158, 168, 188],
mid: [218, 226, 240],
hi: [255, 255, 255],
acc: [200, 218, 238],
spec: [255, 255, 255]
},
holoFull: {
deep: [55, 18, 88],
mid: [255, 75, 175],
hi: [210, 255, 130],
acc: [0, 215, 255],
spec: [255, 255, 255]
}
}
const c = palettes[pid] || palettes.dream
const d = c.deep
const m = c.mid
const h = c.hi
const x = c.acc
const sp = c.spec
const c1 = (ang * 0.52 + 8).toFixed(1)
const c2 = (118 - ang * 0.44).toFixed(1)
const parts = [
`conic-gradient(from ${c1}deg at 36% 42%, ${R(d[0], d[1], d[2], A(0.055))} 0deg, transparent 52deg, ${R(
m[0],
m[1],
m[2],
A(0.12)
)} 102deg, ${R(x[0], x[1], x[2], A(0.09))} 168deg, transparent 228deg, ${R(h[0], h[1], h[2], A(0.07))} 312deg, transparent 360deg)`,
`conic-gradient(from ${c2}deg at 66% 56%, transparent 0deg, ${R(x[0], x[1], x[2], A(0.06))} 68deg, transparent 128deg, ${R(
m[0],
m[1],
m[2],
A(0.1)
)} 198deg, ${R(h[0], h[1], h[2], A(0.065))} 276deg, transparent 360deg)`,
`radial-gradient(ellipse 135% 100% at 26% 34%, ${R(d[0], d[1], d[2], A(0.17))} 0%, ${R(m[0], m[1], m[2], A(0.12))} 20%, ${R(
h[0],
h[1],
h[2],
A(0.07)
)} 40%, transparent 72%)`,
`radial-gradient(ellipse 118% 92% at 76% 66%, ${R(d[0], d[1], d[2], A(0.13))} 0%, ${R(x[0], x[1], x[2], A(0.1))} 34%, transparent 70%)`,
`radial-gradient(ellipse 88% 70% at 50% 50%, ${R(m[0], m[1], m[2], A(0.06))} 0%, transparent 58%)`,
`linear-gradient(${(ang + 39).toFixed(1)}deg, transparent 0% 39%, ${R(sp[0], sp[1], sp[2], A(0.2))} 49.5%, transparent 50.8% 100%)`,
`linear-gradient(${(ang - 31).toFixed(1)}deg, transparent 0% 42%, ${R(h[0], h[1], h[2], A(0.12))} 50%, transparent 58% 100%)`,
`linear-gradient(${(ang + 108).toFixed(1)}deg, transparent 0% 34%, ${R(sp[0], sp[1], sp[2], A(0.11))} 50.2%, transparent 66% 100%)`,
`repeating-linear-gradient(${(20 + ang * 0.07).toFixed(1)}deg, rgba(255,255,255,${A(0.055)}) 0 0.5px, transparent 0.5px 5px)`,
`repeating-linear-gradient(${(-68 - ang * 0.05).toFixed(1)}deg, rgba(0,0,0,${A(0.028)}) 0 0.5px, transparent 0.5px 7px)`,
`radial-gradient(ellipse 32% 22% at 54% 26%, ${R(sp[0], sp[1], sp[2], A(0.24))} 0%, transparent 62%)`,
`radial-gradient(ellipse 26% 20% at 16% 74%, ${R(sp[0], sp[1], sp[2], A(0.16))} 0%, transparent 68%)`
]
return parts.join(', ')
},
loadParamPresets() {
try {
const raw = uni.getStorageSync(PARAM_PRESET_STORAGE_KEY)
if (raw && Array.isArray(raw)) {
this.paramPresets = raw
}
} catch (e) {
this.paramPresets = []
}
},
persistParamPresets() {
try {
uni.setStorageSync(PARAM_PRESET_STORAGE_KEY, this.paramPresets)
} catch (e) {
uni.showToast({ title: '本地保存失败', icon: 'none' })
}
},
commitParamPreset(name) {
const snap = {
id: `p_${Date.now()}_${Math.floor(Math.random() * 1000)}`,
name: (name && String(name).trim().slice(0, 24)) || `预设${this.paramPresets.length + 1}`,
savedAt: Date.now(),
stylePresetId: this.stylePresetId,
laserStrength: this.laserStrength,
angle: this.angle,
beamPivotX: this.beamPivotX,
beamPivotY: this.beamPivotY,
beamEffectId: this.beamEffectId,
grainDensity: this.grainDensity,
subjectMaskRadiusX: this.subjectMaskRadiusX,
subjectMaskRadiusY: this.subjectMaskRadiusY,
subjectMaskSoftness: this.subjectMaskSoftness,
subjectLaserSuppress: this.subjectLaserSuppress,
portraitVeilStrength: this.portraitVeilStrength,
marbleFlowStrength: this.marbleFlowStrength,
matteStrength: this.matteStrength,
beamIntensity: this.beamIntensity,
diffractionDensity: this.diffractionDensity,
diffractionAngleOffset: this.diffractionAngleOffset,
previewAnimSpeed: this.previewAnimSpeed,
previewVivid: this.previewVivid,
previewSrc: this.previewSrc || ''
}
this.paramPresets = [snap, ...this.paramPresets].slice(0, 40)
this.persistParamPresets()
uni.showToast({ title: '参数已保存到应用', icon: 'success' })
},
_askSaveParamsAfterAlbum() {
uni.showModal({
title: '保存参数',
content: '是否将当前调校参数保存到应用?下次进入可在「我的预设」中一键套用。',
confirmText: '保存参数',
cancelText: '暂不',
success: (res) => {
if (!res.confirm) {
return
}
const def = `预设${this.paramPresets.length + 1}`
uni.showModal({
title: '预设名称',
editable: true,
placeholderText: '便于识别',
content: def,
success: (r2) => {
if (!r2.confirm) {
return
}
let name = def
if (typeof r2.content === 'string' && r2.content.trim()) {
name = r2.content.trim().slice(0, 24)
}
this.commitParamPreset(name)
}
})
}
})
},
applyParamPreset(p) {
if (!p) {
return
}
this.onClearAiCutout()
this.stylePresetId = p.stylePresetId || 'dream'
this.laserStrength = typeof p.laserStrength === 'number' ? p.laserStrength : 56
this.angle = typeof p.angle === 'number' ? p.angle : 52
this.beamPivotX = typeof p.beamPivotX === 'number' ? p.beamPivotX : 50
this.beamPivotY = typeof p.beamPivotY === 'number' ? p.beamPivotY : 50
const bid = typeof p.beamEffectId === 'string' ? p.beamEffectId : 'white'
this.beamEffectId = this.beamEffects.some((b) => b.id === bid) ? bid : 'white'
this.grainDensity = typeof p.grainDensity === 'number' ? p.grainDensity : 78
this.subjectMaskRadiusX = typeof p.subjectMaskRadiusX === 'number' ? p.subjectMaskRadiusX : 46
this.subjectMaskRadiusY = typeof p.subjectMaskRadiusY === 'number' ? p.subjectMaskRadiusY : 60
this.subjectMaskSoftness = typeof p.subjectMaskSoftness === 'number' ? p.subjectMaskSoftness : 44
this.subjectLaserSuppress =
typeof p.subjectLaserSuppress === 'number' ? p.subjectLaserSuppress : 40
this.portraitVeilStrength =
typeof p.portraitVeilStrength === 'number' ? p.portraitVeilStrength : 52
this.marbleFlowStrength =
typeof p.marbleFlowStrength === 'number' ? p.marbleFlowStrength : 52
this.matteStrength = typeof p.matteStrength === 'number' ? p.matteStrength : 45
this.beamIntensity = typeof p.beamIntensity === 'number' ? p.beamIntensity : 48
this.diffractionDensity = typeof p.diffractionDensity === 'number' ? p.diffractionDensity : 60
this.diffractionAngleOffset = typeof p.diffractionAngleOffset === 'number' ? p.diffractionAngleOffset : 90
this.previewAnimSpeed = typeof p.previewAnimSpeed === 'number' ? p.previewAnimSpeed : 100
this.previewVivid = typeof p.previewVivid === 'number' ? p.previewVivid : 100
if (p.previewSrc) {
uni.getImageInfo({
src: p.previewSrc,
success: () => {
this.previewSrc = p.previewSrc
uni.showToast({ title: '已套用预设', icon: 'none' })
},
fail: () => {
uni.showToast({ title: '参数已套用,原图失效请重新选图', icon: 'none' })
}
})
} else {
uni.showToast({ title: '已套用预设', icon: 'none' })
}
},
onPresetLongPress(p) {
uni.showModal({
title: '删除预设',
content: `确定删除「${p.name}」?`,
success: (res) => {
if (res.confirm) {
this.paramPresets = this.paramPresets.filter((x) => x.id !== p.id)
this.persistParamPresets()
uni.showToast({ title: '已删除', icon: 'none' })
}
}
})
},
onCancel() {
this._stopPreviewAnimation()
try {
uni.removeStorageSync(CASTLOVE_LASER_ENTRY_KEY)
} catch (e) {
/* noop */
}
uni.navigateBack({ fail: () => {} })
},
onSave() {
if (!this.previewSrc) {
uni.showToast({ title: '请先拍照或上传图片', icon: 'none' })
return
}
if (this._saving) {
return
}
this._saving = true
uni.showLoading({ title: '正在生成…', mask: true })
const cw = this.exportCssW
const ch = this.exportCssH
const path = this.previewSrc
uni.getImageInfo({
src: path,
success: (img) => {
try {
const ctx = this._createLaserCanvasContext('laserExportCanvas')
const layout = this._computeCoverLayout(img.width, img.height, cw, ch)
const exportT = Date.now()
this._drawLaserCardComposite(ctx, cw, ch, path, layout, exportT)
ctx.draw(false, () => {
setTimeout(() => {
uni.canvasToTempFilePath(
{
canvasId: 'laserExportCanvas',
fileType: 'jpg',
quality: 0.92,
width: cw,
height: ch,
destWidth: Math.round(cw * this.exportScale),
destHeight: Math.round(ch * this.exportScale),
success: (res) => {
if (this.castloveEntry) {
this._finishCastloveExport(res.tempFilePath)
} else {
this._writeImageToPhotosAlbum(res.tempFilePath)
}
},
fail: (e) => {
this._saving = false
uni.hideLoading()
uni.showToast({ title: e.errMsg || '导出失败', icon: 'none' })
}
},
this
)
}, 120)
})
} catch (err) {
this._saving = false
uni.hideLoading()
uni.showToast({ title: '合成失败', icon: 'none' })
}
},
fail: () => {
this._saving = false
uni.hideLoading()
uni.showToast({ title: '无法读取图片', icon: 'none' })
}
})
},
_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
},
_drawSolidLaserBackdrop(ctx, cw, ch) {
const strength = Math.max(0.22, Math.min(1, this.laserStrength / 100))
const vivid = Math.max(0.82, Math.min(1.28, (this.previewVivid || 100) / 100))
const pid = this.stylePresetId
const R = Math.max(cw, ch)
const blobs = {
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)' }
],
pearl: [
{ x: 0.22, y: 0.28, c0: 'rgba(255,255,255,0.5)', c1: 'rgba(255,255,255,0)' },
{ x: 0.82, y: 0.38, c0: 'rgba(210,225,245,0.42)', c1: 'rgba(210,225,245,0)' },
{ x: 0.5, y: 0.82, c0: 'rgba(180,200,255,0.36)', c1: 'rgba(180,200,255,0)' }
]
}
const list = blobs[pid] || blobs.dream
if (typeof ctx.setGlobalCompositeOperation === 'function') {
ctx.setGlobalCompositeOperation('soft-light')
}
list.forEach((b) => {
try {
const g = ctx.createRadialGradient(b.x * cw, b.y * ch, 0, b.x * cw, b.y * ch, R * 0.62)
g.addColorStop(0, b.c0)
g.addColorStop(1, b.c1)
ctx.setGlobalAlpha(0.82 * strength * vivid * 0.38)
ctx.setFillStyle(g)
ctx.fillRect(0, 0, cw, ch)
} catch (err) {
ctx.setGlobalAlpha(0.82 * strength * vivid * 0.38)
ctx.setFillStyle(b.c0)
ctx.fillRect(0, 0, cw, ch)
}
})
ctx.setGlobalAlpha(1)
if (typeof ctx.setGlobalCompositeOperation === 'function') {
ctx.setGlobalCompositeOperation('source-over')
}
},
/**
* 导出用:与预览 marbleFlowOverlayStyle 同参的中频流体/揉箔叠层soft-light
*/
_exportDrawMarbleFlow(ctx, cw, ch) {
const mf = Math.max(0, Math.min(100, Number(this.marbleFlowStrength) || 0)) / 100
if (mf < 0.04) {
return
}
const strength = Math.max(0.15, Math.min(1, this.laserStrength / 100))
const vivid = Math.max(0.75, Math.min(1.35, (this.previewVivid || 100) / 100))
const pid = this.stylePresetId
const phase = ((Number(this.angle) || 0) * Math.PI) / 180
const 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] },
{ x: 0.5, y: 0.48, rgb: [255, 240, 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] }
],
pearl: [
{ x: 0.22, y: 0.28, rgb: [245, 248, 255] },
{ x: 0.8, y: 0.34, rgb: [210, 225, 245] },
{ x: 0.5, y: 0.82, rgb: [230, 238, 255] }
],
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 pts = palettes[pid] || palettes.dream
const Rmax = Math.max(cw, ch) * 0.46
const baseA = (0.1 + mf * 0.34) * strength * vivid * 0.72
ctx.save()
if (typeof ctx.setGlobalCompositeOperation === 'function') {
ctx.setGlobalCompositeOperation('soft-light')
}
pts.forEach((p, i) => {
const ox = Math.cos(phase * 0.35) * 10 * (i % 2)
const oy = Math.sin(phase * 0.3) * 8 * ((i + 1) % 2)
const gx = p.x * cw + ox
const gy = p.y * ch + oy
const rad = Rmax * (0.24 + (i % 4) * 0.07)
let g
try {
g = ctx.createRadialGradient(gx, gy, 0, gx, gy, rad)
} catch (e) {
return
}
const [r, gg, b] = p.rgb
const a0 = Math.min(0.72, baseA * 0.95)
const a1 = Math.min(0.5, baseA * 0.52)
g.addColorStop(0, `rgba(${r},${gg},${b},${a0.toFixed(3)})`)
g.addColorStop(0.52, `rgba(${r},${gg},${b},${a1.toFixed(3)})`)
g.addColorStop(1, `rgba(${r},${gg},${b},0)`)
ctx.setGlobalAlpha(1)
ctx.setFillStyle(g)
ctx.fillRect(0, 0, cw, ch)
})
ctx.restore()
if (typeof ctx.setGlobalCompositeOperation === 'function') {
ctx.setGlobalCompositeOperation('source-over')
}
},
/**
* 导出用:人物椭圆内 multiply 压暗幻彩(与预览 subjectHoloSuppressStyle 一致)。
*/
_exportDrawSubjectLaserSuppress(ctx, cw, ch) {
const sup = Math.max(0, Math.min(100, Number(this.subjectLaserSuppress) || 0)) / 100
if (sup < 0.02) {
return
}
const cx = (Math.max(0, Math.min(100, Number(this.beamPivotX) || 50)) / 100) * cw
const cy = (Math.max(0, Math.min(100, Number(this.beamPivotY) || 50)) / 100) * ch
const rx = (Math.max(18, Math.min(62, Number(this.subjectMaskRadiusX) || 46)) / 100) * cw
const ry = (Math.max(22, Math.min(78, Number(this.subjectMaskRadiusY) || 60)) / 100) * ch
if (rx < 1 || ry < 1) {
return
}
ctx.save()
if (typeof ctx.setGlobalCompositeOperation === 'function') {
ctx.setGlobalCompositeOperation('multiply')
}
ctx.translate(cx, cy)
ctx.scale(rx, ry)
ctx.beginPath()
ctx.arc(0, 0, 1, 0, Math.PI * 2)
ctx.clip()
const g = ctx.createRadialGradient(0, 0, 0, 0, 0, 1)
const a0 = 0.12 + sup * 0.48
const a1 = 0.07 + sup * 0.28
g.addColorStop(0, `rgba(26,22,34,${a0.toFixed(3)})`)
g.addColorStop(0.58, `rgba(18,16,28,${a1.toFixed(3)})`)
g.addColorStop(1, 'rgba(0,0,0,0)')
ctx.setFillStyle(g)
ctx.setGlobalAlpha(1)
ctx.fillRect(-1, -1, 2, 2)
ctx.restore()
if (typeof ctx.setGlobalCompositeOperation === 'function') {
ctx.setGlobalCompositeOperation('source-over')
}
},
_clipSubjectEllipse(ctx, cw, ch) {
const cx = (Math.max(0, Math.min(100, Number(this.beamPivotX) || 50)) / 100) * cw
const cy = (Math.max(0, Math.min(100, Number(this.beamPivotY) || 50)) / 100) * ch
const rx = (Math.max(18, Math.min(62, Number(this.subjectMaskRadiusX) || 46)) / 100) * cw
const ry = (Math.max(22, Math.min(78, Number(this.subjectMaskRadiusY) || 60)) / 100) * ch
if (rx < 1 || ry < 1) {
return
}
ctx.beginPath()
if (typeof ctx.ellipse === 'function') {
ctx.ellipse(cx, cy, rx, ry, 0, 0, Math.PI * 2)
} else {
ctx.save()
ctx.translate(cx, cy)
ctx.scale(rx / ry, 1)
ctx.arc(0, 0, ry, 0, Math.PI * 2)
ctx.restore()
ctx.beginPath()
ctx.ellipse(cx, cy, rx, ry, 0, 0, Math.PI * 2)
}
ctx.clip()
},
/**
* 导出用:满铺斜向彩虹衍射纹;叠在照片上时用 overlay + 压低 alpha避免粗条纹挡脸。
*/
_exportDrawDiffractionStripes(ctx, cw, ch) {
const density = Math.max(0, Math.min(100, Number(this.diffractionDensity) || 0)) / 100
if (density < 0.02) {
return
}
const strength = Math.max(0.15, Math.min(1, this.laserStrength / 100))
const vivid = Math.max(0.75, Math.min(1.35, (this.previewVivid || 100) / 100))
const angleOff = Math.max(-90, Math.min(90, Number(this.diffractionAngleOffset) || 90))
const deg = (Number(this.angle) || 52) + angleOff
const rad = (deg * Math.PI) / 180
const u = 3.2 - density * 2.4
const stripeW = Math.max(1.1, u * 0.42)
const span = Math.sqrt(cw * cw + ch * ch) * 1.2
const baseA = (0.07 + strength * 0.09) * vivid * 0.62
const hiA = (0.09 + strength * 0.11) * vivid * 0.62
const alphas = [
baseA,
baseA,
hiA,
hiA,
baseA,
baseA * 0.85
]
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,'
]
ctx.save()
ctx.translate(cw / 2, ch / 2)
ctx.rotate(rad)
if (typeof ctx.setGlobalCompositeOperation === 'function') {
ctx.setGlobalCompositeOperation('overlay')
}
ctx.setGlobalAlpha(Math.min(0.33, (0.26 + strength * 0.48 + density * 0.16) * 0.44))
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.9, span * 2)
}
ctx.restore()
ctx.setGlobalAlpha(1)
if (typeof ctx.setGlobalCompositeOperation === 'function') {
ctx.setGlobalCompositeOperation('source-over')
}
},
/**
* 导出用:极细噪点叠在镭射之上,模拟磨砂/雾面(对应工艺光油下的细微漫反射)。
*/
_exportDrawFineNoiseMatte(ctx, cw, ch, rndFn) {
const ms = Math.max(0, Math.min(100, Number(this.matteStrength) || 0)) / 100
if (ms < 0.03) {
return
}
const rnd = typeof rndFn === 'function' ? rndFn : Math.random
const n = Math.floor(900 + ms * 2200)
const alphaLo = 0.018 + ms * 0.045
const alphaHi = 0.04 + ms * 0.07
if (typeof ctx.setGlobalCompositeOperation === 'function') {
ctx.setGlobalCompositeOperation('soft-light')
}
ctx.setGlobalAlpha(0.36 + ms * 0.26)
for (let k = 0; k < n; k++) {
const x = rnd() * cw
const y = rnd() * ch
const s = rnd() * 0.85 + 0.35
const a = alphaLo + rnd() * (alphaHi - alphaLo)
ctx.setFillStyle(`rgba(252,253,255,${a.toFixed(4)})`)
ctx.fillRect(x, y, s, s)
}
ctx.setGlobalAlpha(1)
if (typeof ctx.setGlobalCompositeOperation === 'function') {
ctx.setGlobalCompositeOperation('source-over')
}
},
/**
* 导出用:沿枢轴附近的一条高光带;叠图时用 soft-light + 较低 alpha避免一道亮带盖满人物。
*/
_exportDrawOilSweep(ctx, cw, ch, timeMs) {
const strength = Math.max(0.12, Math.min(1, this.laserStrength / 100))
const bi = Math.max(20, Math.min(140, Number(this.beamIntensity) || 48)) / 100
const vivid = Math.max(0.75, Math.min(1.35, (this.previewVivid || 100) / 100))
const pvx = (Math.max(0, Math.min(100, Number(this.beamPivotX) || 50)) / 100) * cw
const pvy = (Math.max(0, Math.min(100, Number(this.beamPivotY) || 50)) / 100) * ch
const deg = Number(this.angle) || 52
const rad = (deg * Math.PI) / 180
const perp = rad + Math.PI / 2
const len = Math.sqrt(cw * cw + ch * ch) * 0.55
const clock = typeof timeMs === 'number' && !Number.isNaN(timeMs) ? timeMs : Date.now()
const phase = ((clock % 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
let g
try {
g = ctx.createLinearGradient(x0, y0, x1, y1)
} catch (e) {
return
}
const be = this.beamEffectId
const peak = (0.17 + strength * 0.14 + bi * 0.09) * vivid
let cHi = `rgba(255,255,255,${Math.min(0.85, peak).toFixed(3)})`
let cMid = `rgba(220,240,255,${Math.min(0.55, peak * 0.72).toFixed(3)})`
if (be === 'prism') {
cHi = `rgba(255,240,200,${Math.min(0.82, peak).toFixed(3)})`
cMid = `rgba(200,80,255,${Math.min(0.5, peak * 0.65).toFixed(3)})`
} else if (be === 'aurora') {
cHi = `rgba(200,255,250,${Math.min(0.8, peak).toFixed(3)})`
cMid = `rgba(140,80,255,${Math.min(0.48, peak * 0.62).toFixed(3)})`
} else if (be === 'sunset') {
cHi = `rgba(255,230,180,${Math.min(0.82, peak).toFixed(3)})`
cMid = `rgba(255,120,120,${Math.min(0.5, peak * 0.64).toFixed(3)})`
} else if (be === 'neon') {
cHi = `rgba(0,255,255,${Math.min(0.78, peak).toFixed(3)})`
cMid = `rgba(255,80,200,${Math.min(0.48, 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)')
if (typeof ctx.setGlobalCompositeOperation === 'function') {
ctx.setGlobalCompositeOperation('soft-light')
}
ctx.setGlobalAlpha((0.38 + bi * 0.28) * 0.56)
ctx.setFillStyle(g)
ctx.fillRect(0, 0, cw, ch)
ctx.setGlobalAlpha(1)
if (typeof ctx.setGlobalCompositeOperation === 'function') {
ctx.setGlobalCompositeOperation('source-over')
}
},
/** HSL → rgba 字符串,供导出 Canvas 彩虹基底 / 流光(纯原生,无第三方滤镜) */
_hslToRgba(h, s, l, a) {
const hh = ((Number(h) % 360) + 360) % 360
const ss = Math.max(0, Math.min(100, Number(s))) / 100
const ll = Math.max(0, Math.min(100, Number(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 = typeof a === 'number' ? Math.max(0, Math.min(1, a)) : 1
return `rgba(${r},${g},${b},${aa.toFixed(3)})`
},
/** 导出整卡圆角裁剪(与实体小卡一致) */
_clipExportCardRoundRect(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()
},
/**
* HSL 色相循环彩虹基底:叠在照片上时用 soft-light + 较低 alpha避免多层 screen 把人像「漂没」。
*/
_exportDrawHslRainbowBase(ctx, cw, ch, timeMs) {
const strength = Math.max(0.15, Math.min(1, this.laserStrength / 100))
const vivid = Math.max(0.75, Math.min(1.35, (this.previewVivid || 100) / 100))
const speed = Math.max(0.35, Math.min(1.8, (Number(this.previewAnimSpeed) || 100) / 100))
const t = typeof timeMs === 'number' && !Number.isNaN(timeMs) ? timeMs : Date.now()
const hueShift = ((t * 0.055 * speed) % 360 + 360) % 360
let g
try {
g = ctx.createLinearGradient(0, 0, cw * 1.05, ch * 0.92)
} catch (e) {
return
}
const stops = 12
for (let i = 0; i <= stops; i++) {
const p = i / stops
const h = (hueShift + p * 360) % 360
const sat = 82 + vivid * 11
const light = 52 + (i % 4) * 2.8
const alpha =
(0.052 + strength * 0.082) * (0.78 + vivid * 0.08) * (0.55 + p * 0.22) * 0.6
g.addColorStop(p, this._hslToRgba(h, sat, light, alpha))
}
if (typeof ctx.setGlobalCompositeOperation === 'function') {
ctx.setGlobalCompositeOperation('soft-light')
}
ctx.setGlobalAlpha(1)
ctx.setFillStyle(g)
ctx.fillRect(0, 0, cw, ch)
if (typeof ctx.setGlobalCompositeOperation === 'function') {
ctx.setGlobalCompositeOperation('source-over')
}
},
/** 多道斜向 HSL 渐变 + 时间相位,模拟「动态流光」一帧快照 */
_exportDrawHueFlowBeams(ctx, cw, ch, timeMs) {
const strength = Math.max(0.15, Math.min(1, this.laserStrength / 100))
const vivid = Math.max(0.75, Math.min(1.35, (this.previewVivid || 100) / 100))
const speed = Math.max(0.35, Math.min(1.8, (Number(this.previewAnimSpeed) || 100) / 100))
const t = typeof timeMs === 'number' && !Number.isNaN(timeMs) ? timeMs : Date.now()
const flow = ((t * 0.00012 * speed) % 1) * 360
const baseDeg = Number(this.angle) || 52
const pvx = Math.max(0, Math.min(100, Number(this.beamPivotX) || 50))
const pvy = Math.max(0, Math.min(100, Number(this.beamPivotY) || 50))
const layers = [
{ off: 0, span: 260 },
{ off: 58, span: 200 },
{ off: -44, span: 220 }
]
ctx.save()
if (typeof ctx.setGlobalCompositeOperation === 'function') {
ctx.setGlobalCompositeOperation('soft-light')
}
layers.forEach((layer, idx) => {
const deg = baseDeg + layer.off + Math.sin((t + idx * 900) * 0.0017) * 14
const h0 = (flow + idx * 72) % 360
const stops = []
const seg = 7
for (let i = 0; i <= seg; i++) {
const u = i / seg
const h = (h0 + u * layer.span) % 360
const a = (0.026 + strength * 0.055) * vivid * (0.5 + 0.5 * Math.sin(u * Math.PI)) * 0.62
stops.push([u, this._hslToRgba(h, 88, 58, a)])
}
let g
try {
g = this._linearGradientThroughPivot(ctx, cw, ch, pvx, pvy, deg, stops)
} catch (e) {
return
}
ctx.setGlobalAlpha((0.22 + idx * 0.035) * 0.98)
ctx.setFillStyle(g)
ctx.fillRect(0, 0, cw, ch)
})
ctx.restore()
ctx.setGlobalAlpha(1)
if (typeof ctx.setGlobalCompositeOperation === 'function') {
ctx.setGlobalCompositeOperation('source-over')
}
},
/** 像素级 1px 随机噪点overlay强化镭射磨砂颗粒 */
_exportDrawDensePixelNoise(ctx, cw, ch, rndFn, timeMs) {
const rnd = typeof rndFn === 'function' ? rndFn : Math.random
const strength = Math.max(0.15, Math.min(1, this.laserStrength / 100))
const grain = (this.grainDensityEffective || 70) / 100
const n = Math.floor((2200 + grain * 4200 + strength * 1800) * 0.56)
const seed = ((typeof timeMs === 'number' ? timeMs : Date.now()) % 131071) / 131071
ctx.save()
if (typeof ctx.setGlobalCompositeOperation === 'function') {
ctx.setGlobalCompositeOperation('overlay')
}
ctx.setGlobalAlpha(0.45)
for (let i = 0; i < n; i++) {
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.022 + rnd() * 0.072 + seed * 0.028
ctx.setFillStyle(`rgba(${v},${v},${Math.min(255, v + 8)},${a.toFixed(3)})`)
ctx.fillRect(x, y, 1, 1)
}
ctx.restore()
ctx.setGlobalAlpha(1)
if (typeof ctx.setGlobalCompositeOperation === 'function') {
ctx.setGlobalCompositeOperation('source-over')
}
},
/** 宽柔高光soft-light模拟镭射金属反光层 */
_exportDrawMetalReflectionOverlay(ctx, cw, ch, timeMs) {
const t = typeof timeMs === 'number' && !Number.isNaN(timeMs) ? timeMs : Date.now()
const deg = 32 + ((t * 0.018) % 28)
let g
try {
g = this._linearGradientThroughPivot(ctx, cw, ch, 28, 22, deg, [
[0, 'rgba(255,255,255,0)'],
[0.32, 'rgba(255,255,255,0.05)'],
[0.5, 'rgba(255,248,255,0.2)'],
[0.66, 'rgba(240,250,255,0.09)'],
[1, 'rgba(255,255,255,0)']
])
} catch (e) {
return
}
ctx.save()
if (typeof ctx.setGlobalCompositeOperation === 'function') {
ctx.setGlobalCompositeOperation('soft-light')
}
ctx.setGlobalAlpha(0.28)
ctx.setFillStyle(g)
ctx.fillRect(0, 0, cw, ch)
ctx.restore()
ctx.setGlobalAlpha(1)
if (typeof ctx.setGlobalCompositeOperation === 'function') {
ctx.setGlobalCompositeOperation('source-over')
}
},
_drawLaserLayers(ctx, cw, ch, rndFn, oilTimeMs) {
const rnd = typeof rndFn === 'function' ? rndFn : Math.random
const strength = Math.max(0.15, Math.min(1, this.laserStrength / 100))
const vivid = Math.max(0.75, Math.min(1.35, (this.previewVivid || 100) / 100))
const grain = (this.grainDensityEffective || 70) / 100
const a = this.angle
const pvx = this.beamPivotX
const pvy = this.beamPivotY
const pid = this.stylePresetId
const be = this.beamEffectId
if (typeof ctx.setGlobalCompositeOperation === 'function') {
ctx.setGlobalCompositeOperation('soft-light')
}
const meshStops = {
dream: [
[0, 'rgba(186,104,255,0.42)'],
[0.45, 'rgba(255,105,180,0.28)'],
[1, 'rgba(0,229,255,0.22)']
],
classic: [
[0, 'rgba(255,60,180,0.4)'],
[0.5, 'rgba(0,210,255,0.38)'],
[1, 'rgba(255,240,100,0.32)']
],
ice: [
[0, 'rgba(100,180,255,0.4)'],
[0.55, 'rgba(160,140,255,0.32)'],
[1, 'rgba(200,240,255,0.26)']
],
sunset: [
[0, 'rgba(255,150,120,0.38)'],
[0.5, 'rgba(255,200,140,0.34)'],
[1, 'rgba(255,100,160,0.3)']
],
pearl: [
[0, 'rgba(255,255,255,0.45)'],
[0.5, 'rgba(210,225,245,0.28)'],
[1, 'rgba(180,200,255,0.26)']
],
holoFull: [
[0, 'rgba(255,60,180,0.48)'],
[0.22, 'rgba(255,170,60,0.44)'],
[0.45, 'rgba(0,220,255,0.46)'],
[0.68, 'rgba(120,90,255,0.44)'],
[1, 'rgba(255,80,200,0.42)']
]
}
const stops = meshStops[pid] || meshStops.dream
ctx.setGlobalAlpha(0.38 * strength * vivid * 0.42)
try {
ctx.setFillStyle(this._linearGradientThroughPivot(ctx, cw, ch, pvx, pvy, a, stops))
} catch (err) {
ctx.setFillStyle('rgba(180,100,255,0.45)')
}
ctx.fillRect(0, 0, cw, ch)
this._exportDrawMarbleFlow(ctx, cw, ch)
this._exportDrawDiffractionStripes(ctx, cw, ch)
this._exportDrawHueFlowBeams(ctx, cw, ch, oilTimeMs)
if (typeof ctx.setGlobalCompositeOperation === 'function') {
ctx.setGlobalCompositeOperation('soft-light')
}
const rayA = a + 90
let r0 = 'rgba(255,255,255,0)'
const bi2 = Math.max(20, Math.min(140, Number(this.beamIntensity) || 86)) / 100
const rMidA = (0.12 + strength * 0.28) * (0.55 + bi2 * 0.52)
const rHiA = (0.06 + strength * 0.12) * (0.55 + bi2 * 0.52)
let rMid = `rgba(255,255,255,${rMidA.toFixed(3)})`
let rHi = `rgba(220,255,255,${rHiA.toFixed(3)})`
if (be === 'prism') {
rMid = `rgba(200,60,255,${(rMidA * 0.95).toFixed(3)})`
rHi = `rgba(255,240,160,${(rHiA * 1.08).toFixed(3)})`
} else if (be === 'aurora') {
rMid = `rgba(160,80,255,${(rMidA * 0.98).toFixed(3)})`
rHi = `rgba(0,255,220,${(rHiA * 1.1).toFixed(3)})`
} else if (be === 'sunset') {
rMid = `rgba(255,100,80,${(rMidA * 0.95).toFixed(3)})`
rHi = `rgba(255,230,140,${(rHiA * 1.08).toFixed(3)})`
} else if (be === 'neon') {
rMid = `rgba(0,240,255,${(rMidA * 0.98).toFixed(3)})`
rHi = `rgba(255,255,80,${(rHiA * 1.1).toFixed(3)})`
}
const coreW = 0.32 + bi2 * 0.24
const gRay = this._linearGradientThroughPivot(ctx, cw, ch, pvx, pvy, rayA, [
[0, r0],
[(0.5 - coreW).toFixed(3), r0],
[0.48, rMid],
[0.52, rHi],
[(0.5 + coreW).toFixed(3), r0],
[1, r0]
])
ctx.setGlobalAlpha(Math.min(0.38, 0.3 * strength))
ctx.setFillStyle(gRay)
ctx.fillRect(0, 0, cw, ch)
if (typeof ctx.setGlobalCompositeOperation === 'function') {
ctx.setGlobalCompositeOperation('overlay')
}
ctx.setGlobalAlpha((0.35 + grain * 0.35) * 0.62)
const n = Math.floor((400 + grain * 500) * 0.72)
for (let i = 0; i < n; i++) {
const x = rnd() * cw
const y = rnd() * ch
const s = rnd() * 1.8 + 0.3
ctx.setFillStyle(`rgba(255,255,255,${(0.045 + rnd() * 0.12).toFixed(3)})`)
ctx.fillRect(x, y, s, s)
}
const ms = Math.max(0, Math.min(100, Number(this.matteStrength) || 0)) / 100
if (ms > 0.04) {
if (typeof ctx.setGlobalCompositeOperation === 'function') {
ctx.setGlobalCompositeOperation('soft-light')
}
ctx.setGlobalAlpha(0.14 + ms * 0.26)
const n2 = Math.floor((240 + ms * 500) * 0.72)
for (let j = 0; j < n2; j++) {
const x = rnd() * cw
const y = rnd() * ch
const s = rnd() * 1.1 + 0.2
ctx.setFillStyle(`rgba(250,252,255,${(0.035 + rnd() * 0.1).toFixed(3)})`)
ctx.fillRect(x, y, s, s)
}
}
this._exportDrawDensePixelNoise(ctx, cw, ch, rnd, oilTimeMs)
this._exportDrawFineNoiseMatte(ctx, cw, ch, rnd)
this._exportDrawOilSweep(ctx, cw, ch, oilTimeMs)
this._exportDrawMetalReflectionOverlay(ctx, cw, ch, oilTimeMs)
if (typeof ctx.setGlobalCompositeOperation === 'function') {
ctx.setGlobalCompositeOperation('source-over')
}
ctx.setGlobalAlpha(1)
},
_finishCastloveExport(tempFilePath) {
const entry = this.castloveEntry
const fail = (msg) => {
this._saving = false
uni.hideLoading()
uni.showToast({ title: msg || '提交失败', icon: 'none' })
}
const trySubmit = (dataUrl) => {
uni.showLoading({ title: '上传并创建订单…', mask: true })
submitCastloveAfterLaserExport(dataUrl, entry)
.then(() => {
this._saving = false
uni.hideLoading()
try {
uni.removeStorageSync(CASTLOVE_LASER_ENTRY_KEY)
} catch (e) {
/* noop */
}
uni.redirectTo({ url: '/pages/castlove/success' })
})
.catch((err) => {
fail(err.message || String(err))
})
}
// #ifdef H5
if (typeof fetch !== 'undefined' && tempFilePath && String(tempFilePath).indexOf('blob:') === 0) {
fetch(tempFilePath)
.then((r) => r.blob())
.then(
(blob) =>
new Promise((resolve, reject) => {
const fr = new FileReader()
fr.onload = () => resolve(fr.result)
fr.onerror = () => reject(new Error('读取导出图失败'))
fr.readAsDataURL(blob)
})
)
.then((dataUrl) => trySubmit(dataUrl))
.catch(() => fail('读取导出图失败'))
return
}
// #endif
try {
uni.getFileSystemManager().readFile({
filePath: tempFilePath,
encoding: 'base64',
success: (r) => {
trySubmit('data:image/jpeg;base64,' + r.data)
},
fail: () => fail('读取导出文件失败')
})
} catch (e) {
fail('读取导出文件失败')
}
},
_writeImageToPhotosAlbum(filePath) {
uni.saveImageToPhotosAlbum({
filePath,
success: () => {
this._saving = false
uni.hideLoading()
uni.showToast({ title: '已保存到相册', icon: 'success' })
const payload = {
previewSrc: this.previewSrc,
savedPath: filePath,
stylePresetId: this.stylePresetId,
stylePresetName: this.currentPreset.name,
laserStrength: this.laserStrength,
angle: this.angle,
beamPivotX: this.beamPivotX,
beamPivotY: this.beamPivotY,
beamEffectId: this.beamEffectId,
grainDensity: this.grainDensity,
subjectMaskRadiusX: this.subjectMaskRadiusX,
subjectMaskRadiusY: this.subjectMaskRadiusY,
subjectMaskSoftness: this.subjectMaskSoftness,
subjectLaserSuppress: this.subjectLaserSuppress,
portraitVeilStrength: this.portraitVeilStrength,
marbleFlowStrength: this.marbleFlowStrength,
matteStrength: this.matteStrength,
beamIntensity: this.beamIntensity,
previewAnimSpeed: this.previewAnimSpeed,
previewVivid: this.previewVivid
}
this.$emit('saved', payload)
},
fail: (err) => {
this._saving = false
uni.hideLoading()
const msg = err.errMsg || ''
if (msg.includes('auth deny') || msg.includes('authorize')) {
uni.showModal({
title: '需要相册权限',
content: '请在系统设置中允许本应用写入相册,以便保存图片。',
showCancel: false
})
return
}
uni.showToast({ title: '保存失败,请检查相册权限', icon: 'none' })
}
})
},
onPreviewWrapTap() {
if (!this.previewSrc) {
this.onPreviewTap()
}
},
onPreviewTouchStart() {
if (!this.previewSrc) {
return
}
const query = uni.createSelectorQuery().in(this)
query
.select('.preview-wrap')
.boundingClientRect((rect) => {
this._previewRect = rect
})
.exec()
},
_touchPointXY(t) {
if (!t) {
return null
}
const x =
typeof t.clientX === 'number'
? t.clientX
: typeof t.pageX === 'number'
? t.pageX
: typeof t.x === 'number'
? t.x
: null
const y =
typeof t.clientY === 'number'
? t.clientY
: typeof t.pageY === 'number'
? t.pageY
: typeof t.y === 'number'
? t.y
: null
if (x == null || y == null) {
return null
}
return { x, y }
},
onPreviewTouchMove(e) {
if (!this.previewSrc || !this._previewRect) {
return
}
const t = (e.touches && e.touches[0]) || (e.changedTouches && e.changedTouches[0])
const pt = this._touchPointXY(t)
if (!pt) {
return
}
const rect = this._previewRect
const u = ((pt.x - rect.left) / rect.width) * 100
const v = ((pt.y - rect.top) / rect.height) * 100
this.beamPivotX = Math.round(Math.max(0, Math.min(100, u)))
this.beamPivotY = Math.round(Math.max(0, Math.min(100, v)))
},
onPreviewTouchEnd() {
this._previewRect = null
},
onPreviewTap() {
uni.showActionSheet({
itemList: ['拍照', '从相册选择'],
success: (res) => {
if (res.tapIndex === 0) {
this.pickImage(['camera'])
} else if (res.tapIndex === 1) {
this.pickImage(['album'])
}
}
})
},
pickImage(sourceType) {
uni.chooseImage({
count: 1,
/** 优先原图:仅 compressed 时部分机型临时路径 Bitmap 易「加载失败」 */
sizeType: ['original', 'compressed'],
sourceType,
success: (res) => {
const path = res.tempFilePaths && res.tempFilePaths[0]
if (path) {
this.onClearAiCutout()
this.previewSrc = path
}
},
fail: (err) => {
const msg = err.errMsg || ''
if (msg.includes('cancel') || msg.includes('取消')) {
return
}
uni.showToast({ title: '选取图片失败', icon: 'none' })
}
})
},
onClearAiCutout() {
this.segmentationMode = 'ellipse'
this.aiCutoutSrc = ''
},
async onSmartSegment() {
if (!this.previewSrc) {
uni.showToast({ title: '请先拍照或上传图片', icon: 'none' })
return
}
if (this._segmenting) {
return
}
this._segmenting = true
let timedOut = false
const watchdog = setTimeout(() => {
timedOut = true
this._segmenting = false
uni.hideLoading()
uni.showToast({
title: '识别超时,请检查网络与 segmentApi 配置',
icon: 'none',
duration: 4000
})
console.error('[智能抠图] 超过2分钟未完成已强制结束 loading')
}, 120000)
uni.showLoading({ title: '正在识别…', mask: true })
try {
const { localPath } = await segmentPortraitToLocal(this.previewSrc)
if (timedOut) {
return
}
this.aiCutoutSrc = localPath
this.segmentationMode = 'ai'
uni.showToast({ title: '抠图完成', icon: 'none' })
} catch (e) {
if (timedOut) {
return
}
const msg = humanizeIfBareOssUploadErr((e && e.message) || '抠图失败')
// 真机调试时请看 HBuilderX 底部「控制台」Toast 最多约一行,配置说明用弹窗展示全文
console.error('[智能抠图]', e)
const needModal =
msg.includes('segmentApi') ||
msg.includes('IMM_') ||
msg.includes('IVPD') ||
msg.includes('OSS ') ||
msg.includes('域名白名单') ||
msg.includes('manifest') ||
msg.length > 36
if (needModal) {
uni.showModal({ title: '智能抠图', content: msg, showCancel: false })
} else {
uni.showToast({ title: msg.length > 40 ? `${msg.slice(0, 40)}` : msg, icon: 'none' })
}
} finally {
if (watchdog) {
clearTimeout(watchdog)
}
if (!timedOut) {
this._segmenting = false
uni.hideLoading()
}
}
},
onLaserStrengthChange(e) {
this.laserStrength = Number(e.detail.value)
},
onGrainDensityChange(e) {
this.grainDensity = Number(e.detail.value)
},
onAngleChange(e) {
this.angle = Math.round(Number(e.detail.value)) % 360
if (this.angle < 0) {
this.angle += 360
}
},
setAngleQuick(d) {
this.angle = ((Math.round(Number(d)) % 360) + 360) % 360
},
nudgeAngle(delta) {
this.angle = Math.round(this.angle + delta)
this.angle = ((this.angle % 360) + 360) % 360
},
onAnimSpeedChange(e) {
this.previewAnimSpeed = Number(e.detail.value)
},
onVividChange(e) {
this.previewVivid = Number(e.detail.value)
},
onSubjectMaskXChange(e) {
this.subjectMaskRadiusX = Number(e.detail.value)
},
onSubjectMaskYChange(e) {
this.subjectMaskRadiusY = Number(e.detail.value)
},
onSubjectSoftnessChange(e) {
this.subjectMaskSoftness = Number(e.detail.value)
},
onMatteStrengthChange(e) {
this.matteStrength = Number(e.detail.value)
},
onSubjectLaserSuppressChange(e) {
this.subjectLaserSuppress = Math.max(0, Math.min(100, Number(e.detail.value)))
},
onPortraitVeilStrengthChange(e) {
this.portraitVeilStrength = Math.max(0, Math.min(100, Number(e.detail.value)))
},
onMarbleFlowStrengthChange(e) {
this.marbleFlowStrength = Math.max(0, Math.min(100, Number(e.detail.value)))
},
onBeamIntensityChange(e) {
this.beamIntensity = Number(e.detail.value)
},
onDiffractionDensityChange(e) {
this.diffractionDensity = Math.max(0, Math.min(100, Number(e.detail.value)))
},
onDiffractionAngleOffsetChange(e) {
this.diffractionAngleOffset = Math.max(-90, Math.min(90, Math.round(Number(e.detail.value))))
}
}
}
</script>
<style scoped>
.page {
min-height: 100vh;
background: #0a0a0a;
padding-bottom: 200rpx;
box-sizing: border-box;
}
.export-canvas {
position: fixed;
left: -4000px;
top: 0;
z-index: -1;
pointer-events: none;
}
.header {
flex-direction: row;
display: flex;
align-items: center;
justify-content: space-between;
padding: 16rpx 28rpx 8rpx;
}
.header--sticky {
position: -webkit-sticky;
position: sticky;
top: 0;
z-index: 200;
background-color: #0a0a0a;
box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.35);
}
.preview-sticky {
position: -webkit-sticky;
position: sticky;
z-index: 150;
background-color: #0a0a0a;
padding-bottom: 8rpx;
}
.header-title {
flex: 1;
min-width: 0;
color: #ffffff;
font-size: 34rpx;
font-weight: 600;
text-align: center;
padding: 0 12rpx;
}
.header-btn {
color: #ffffff;
font-size: 28rpx;
padding: 8rpx 12rpx;
}
.preview-wrap {
margin: 12rpx 32rpx 0;
border-radius: 28rpx;
overflow: hidden;
position: relative;
height: 720rpx;
background: #121212;
border: 2rpx solid rgba(255, 255, 255, 0.1);
box-shadow:
inset 0 0 0 1rpx rgba(255, 255, 255, 0.04),
0 12rpx 40rpx rgba(0, 0, 0, 0.45);
display: flex;
align-items: center;
justify-content: center;
}
.preview-canvas-stack {
position: relative;
flex-shrink: 0;
margin: 0 auto;
}
.preview-canvas--2d,
.preview-canvas--webgl {
display: block;
}
.preview-canvas--webgl {
position: absolute;
left: 0;
top: 0;
pointer-events: none;
}
.preview-canvas {
display: block;
flex-shrink: 0;
}
.laser-backdrop-panel {
margin: 16rpx 32rpx 0;
padding: 0 0 8rpx;
}
.laser-backdrop-title {
display: block;
color: #9a9a9a;
font-size: 24rpx;
margin-bottom: 12rpx;
letter-spacing: 1rpx;
}
.laser-backdrop-scroll {
width: 100%;
white-space: nowrap;
}
.laser-backdrop-row {
flex-direction: row;
display: inline-flex;
align-items: flex-end;
padding-bottom: 4rpx;
}
.laser-backdrop-item {
flex-direction: column;
display: inline-flex;
align-items: center;
width: 132rpx;
flex-shrink: 0;
margin-right: 16rpx;
}
.laser-backdrop-item:last-child {
margin-right: 0;
}
.laser-backdrop-item--active .laser-backdrop-thumb,
.laser-backdrop-item--active .laser-backdrop-none {
border-color: rgba(102, 255, 255, 0.85);
box-shadow: 0 0 0 2rpx rgba(102, 255, 255, 0.35);
}
.laser-backdrop-thumb,
.laser-backdrop-none {
width: 120rpx;
height: 120rpx;
border-radius: 16rpx;
border: 2rpx solid rgba(255, 255, 255, 0.12);
background: #1a1a1a;
overflow: hidden;
}
.laser-backdrop-none {
flex-direction: row;
display: flex;
align-items: center;
justify-content: center;
}
.laser-backdrop-none-text {
color: #666666;
font-size: 28rpx;
}
.laser-backdrop-name {
margin-top: 8rpx;
font-size: 22rpx;
color: #b0b0b0;
text-align: center;
max-width: 132rpx;
overflow: hidden;
}
.laser-backdrop-item--active .laser-backdrop-name {
color: #66ffff;
}
.preview-img-mask-wrap {
position: relative;
z-index: 10;
width: 100%;
height: 100%;
border-radius: 28rpx;
overflow: hidden;
pointer-events: auto;
}
.preview-img-subject {
display: block;
position: relative;
z-index: 1;
width: 100%;
height: 100%;
border-radius: 28rpx;
}
.preview-subject-holo-veil {
position: absolute;
left: 0;
top: 0;
right: 0;
bottom: 0;
z-index: 2;
pointer-events: none;
border-radius: inherit;
}
.preview-subject-holo-veil--rainbow {
animation-name: holo-rainbow;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
.preview-subject-holo-veil--metal {
animation-name: holo-metal-shimmer;
animation-timing-function: ease-in-out;
animation-iteration-count: infinite;
animation-direction: alternate;
}
.preview-matte-frost {
z-index: 6;
}
.preview-marble-flow {
z-index: 3;
}
.preview-foil-spec {
z-index: 3;
}
.preview-holo-suppress {
z-index: 8;
}
.preview-layer {
position: absolute;
left: -10%;
top: -10%;
width: 120%;
height: 120%;
pointer-events: none;
}
.preview-mesh {
z-index: 3;
}
.preview-diffraction {
z-index: 3;
}
/* 光束旋转层:显著大于预览区,避免枢轴偏移 + 大角度时四角露底 */
.preview-layer.preview-beam-rotate {
left: -75%;
top: -75%;
width: 250%;
height: 250%;
}
.preview-beam-rotate {
pointer-events: none;
}
.preview-beam-rotate--stack-main {
z-index: 4;
}
.preview-beam-rotate--stack-sheen {
z-index: 7;
}
.preview-beam-rotate .preview-irid-wrap {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
z-index: 2;
transform: scale(1.34);
transform-origin: center center;
}
.preview-beam-rotate .preview-rays {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
z-index: 3;
}
.preview-grain {
z-index: 5;
}
.preview-irid-wrap--rainbow {
animation-name: holo-rainbow;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
.preview-irid-wrap--metal {
animation-name: holo-metal-shimmer;
animation-timing-function: ease-in-out;
animation-iteration-count: infinite;
animation-direction: alternate;
}
.preview-irid-core {
display: block;
}
.preview-beam-rotate--stack-sheen .preview-sheen-wrap {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
}
.preview-sheen-wrap {
animation-name: sheen-breathe;
animation-timing-function: ease-in-out;
animation-iteration-count: infinite;
}
.preview-sheen-core {
display: block;
}
@keyframes holo-rainbow {
from {
filter: hue-rotate(0deg) saturate(var(--holo-sat, 1.3)) brightness(var(--holo-bright-lo, 1.04));
}
to {
filter: hue-rotate(360deg) saturate(calc(var(--holo-sat, 1.3) * 1.1))
brightness(var(--holo-bright-hi, 1.12));
}
}
@keyframes holo-metal-shimmer {
from {
filter: hue-rotate(-24deg) saturate(calc(var(--holo-sat, 1.28) * 0.96))
brightness(var(--holo-bright-lo, 1.05));
}
to {
filter: hue-rotate(36deg) saturate(calc(var(--holo-sat, 1.28) * 1.12)) brightness(var(--holo-bright-hi, 1.16));
}
}
@keyframes sheen-breathe {
0%,
100% {
filter: brightness(1);
}
50% {
filter: brightness(1.14);
}
}
.preview-placeholder {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40rpx;
box-sizing: border-box;
}
.placeholder-icon-wrap {
width: 120rpx;
height: 120rpx;
border-radius: 60rpx;
border: 3rpx solid #4fd1c5;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 28rpx;
background: rgba(79, 209, 197, 0.08);
}
.placeholder-plus {
color: #4fd1c5;
font-size: 64rpx;
font-weight: 300;
line-height: 1;
}
.placeholder-title {
color: #e8e8e8;
font-size: 32rpx;
margin-bottom: 12rpx;
}
.placeholder-sub {
color: #888888;
font-size: 24rpx;
}
.preview-fab {
position: absolute;
top: 16rpx;
right: 16rpx;
z-index: 20;
padding: 12rpx 22rpx;
border-radius: 999rpx;
background: rgba(0, 0, 0, 0.55);
border: 1rpx solid rgba(102, 255, 255, 0.45);
}
.preview-fab-text {
color: #66ffff;
font-size: 24rpx;
}
.preview-replace-hint {
position: absolute;
left: 0;
right: 0;
bottom: 0;
z-index: 20;
padding: 16rpx 20rpx;
padding-right: 120rpx;
background: linear-gradient(180deg, transparent, rgba(0, 0, 0, 0.65));
pointer-events: none;
}
.preview-replace-text {
color: #cccccc;
font-size: 22rpx;
text-align: center;
display: block;
}
.preset-memory {
margin: 16rpx 32rpx 0;
padding: 16rpx 18rpx;
border-radius: 18rpx;
background: #141414;
border: 1rpx solid #2a2a2a;
}
.preset-memory-title {
display: block;
color: #9a9a9a;
font-size: 22rpx;
letter-spacing: 2rpx;
margin-bottom: 4rpx;
}
.preset-memory-hint {
display: block;
color: #5a5a5a;
font-size: 18rpx;
margin-bottom: 12rpx;
}
.preset-memory-scroll {
white-space: nowrap;
width: 100%;
}
.preset-memory-chip {
display: inline-flex;
flex-direction: column;
align-items: flex-start;
padding: 12rpx 20rpx;
margin-right: 12rpx;
border-radius: 14rpx;
background: #1c1c1c;
border: 1rpx solid #333333;
max-width: 280rpx;
box-sizing: border-box;
}
.preset-memory-name {
color: #f0f0f0;
font-size: 24rpx;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 240rpx;
}
.preset-memory-meta {
color: #6a9e9a;
font-size: 20rpx;
margin-top: 4rpx;
}
.summary-bar {
margin: 20rpx 0 0;
padding: 0 32rpx;
}
.summary-bar-title {
display: block;
color: #7a7a7a;
font-size: 20rpx;
letter-spacing: 3rpx;
margin-bottom: 12rpx;
}
.summary-scroll {
white-space: nowrap;
width: 100%;
}
.summary-chip {
display: inline-flex;
flex-direction: column;
align-items: flex-start;
padding: 14rpx 22rpx;
margin-right: 14rpx;
border-radius: 16rpx;
background: #151515;
border: 1rpx solid #2a2a2a;
max-width: 240rpx;
box-sizing: border-box;
}
.summary-chip.on {
border-color: rgba(102, 255, 255, 0.55);
background: rgba(102, 255, 255, 0.06);
}
.summary-chip-k {
color: #888888;
font-size: 20rpx;
margin-bottom: 6rpx;
}
.summary-chip-v {
color: #eeeeee;
font-size: 24rpx;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 200rpx;
}
.param-panel {
margin: 20rpx 32rpx 0;
padding: 24rpx 24rpx 28rpx;
border-radius: 24rpx;
background: #121212;
border: 1rpx solid #252525;
box-sizing: border-box;
}
.panel-head {
display: flex;
flex-direction: row;
align-items: flex-start;
justify-content: space-between;
padding-bottom: 20rpx;
margin-bottom: 8rpx;
border-bottom: 1rpx solid #242424;
}
.panel-head-text {
flex: 1;
padding-right: 16rpx;
}
.panel-title {
display: block;
color: #ffffff;
font-size: 30rpx;
font-weight: 600;
margin-bottom: 8rpx;
}
.panel-desc {
display: block;
color: #8a8a8a;
font-size: 22rpx;
line-height: 1.45;
}
.panel-tag {
max-width: 42%;
padding: 10rpx 16rpx;
border-radius: 12rpx;
background: #1e1e1e;
border: 1rpx solid #333333;
align-self: center;
}
.panel-tag-text {
color: #66ffff;
font-size: 22rpx;
text-align: right;
word-break: break-all;
}
.panel-body {
padding-top: 8rpx;
}
.panel-body--tight {
padding-top: 4rpx;
}
.preset-grid {
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 16rpx;
}
.preset-card {
width: calc(50% - 8rpx);
box-sizing: border-box;
padding: 16rpx 14rpx 18rpx;
border-radius: 18rpx;
background: #181818;
border: 2rpx solid #2c2c2c;
}
.preset-card.active {
border-color: rgba(79, 209, 197, 0.75);
box-shadow: 0 0 20rpx rgba(79, 209, 197, 0.2);
}
.preset-strip {
height: 56rpx;
border-radius: 12rpx;
margin-bottom: 12rpx;
}
.preset-name {
display: block;
color: #f0f0f0;
font-size: 26rpx;
font-weight: 600;
margin-bottom: 6rpx;
}
.preset-desc {
display: block;
color: #7a7a7a;
font-size: 20rpx;
line-height: 1.35;
}
.group-label {
display: block;
color: #9a9a9a;
font-size: 22rpx;
letter-spacing: 2rpx;
margin-bottom: 14rpx;
}
.group-label.spaced {
margin-top: 28rpx;
}
.chip-scroll {
white-space: nowrap;
width: 100%;
}
.chip {
display: inline-flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-width: 120rpx;
padding: 16rpx 20rpx;
margin-right: 16rpx;
border-radius: 20rpx;
background: #1c1c1c;
border: 2rpx solid #2a2a2a;
box-sizing: border-box;
}
.chip.active {
border-color: #66ffff;
box-shadow: 0 0 16rpx rgba(102, 255, 255, 0.35);
}
.chip-icon {
color: #ffffff;
font-size: 36rpx;
margin-bottom: 6rpx;
}
.chip-text {
color: #cccccc;
font-size: 22rpx;
}
.chip-text.alone {
margin-top: 0;
}
.palette-row {
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 20rpx;
}
.swatch {
width: 30%;
min-width: 200rpx;
background: #1a1a1a;
border-radius: 20rpx;
padding: 16rpx;
border: 2rpx solid #2a2a2a;
box-sizing: border-box;
}
.swatch.mini {
display: inline-flex;
flex-direction: column;
width: 160rpx;
min-width: 160rpx;
margin-right: 16rpx;
vertical-align: top;
}
.swatch.active {
border-color: #66ffff;
box-shadow: 0 0 12rpx rgba(102, 255, 255, 0.3);
}
.swatch-fill {
height: 88rpx;
border-radius: 14rpx;
margin-bottom: 10rpx;
}
.swatch-name {
color: #cfcfcf;
font-size: 22rpx;
text-align: center;
}
.slider-block {
margin-top: 20rpx;
padding: 20rpx 18rpx;
border-radius: 18rpx;
background: #161616;
border: 1rpx solid #242424;
}
.slider-block:first-child {
margin-top: 0;
}
.slider-block--muted {
opacity: 0.55;
pointer-events: none;
}
.segment-actions {
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 16rpx;
margin-top: 16rpx;
}
.segment-btn {
padding: 16rpx 28rpx;
border-radius: 999rpx;
}
.segment-btn--primary {
background: linear-gradient(120deg, #00c4a0, #0077bb);
}
.segment-btn--ghost {
background: transparent;
border: 1rpx solid rgba(102, 255, 255, 0.45);
}
.segment-btn-text {
color: #ffffff;
font-size: 26rpx;
}
.slider-row {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
margin-bottom: 8rpx;
}
.slider-label {
color: #e8e8e8;
font-size: 28rpx;
}
.value-pill {
color: #0a0a0a;
background: #66ffff;
font-size: 24rpx;
font-weight: 600;
padding: 6rpx 18rpx;
border-radius: 999rpx;
}
.value-pill--pink {
background: #ff99cc;
}
.value-pill--mini {
font-size: 22rpx;
padding: 4rpx 14rpx;
}
.slider-row--tight {
margin-top: 16rpx;
margin-bottom: 4rpx;
}
.slider-label-sub {
color: #c4c4c4;
font-size: 26rpx;
}
.slider-tip-inline {
display: block;
color: #6a8a86;
font-size: 20rpx;
margin-bottom: 8rpx;
line-height: 1.4;
}
/* 光柱强烈时的强调色光晕 */
.preview-rays {
filter: saturate(calc(1 + (var(--ray-sat, 0)) * 0.4));
}
.slider-sub {
display: block;
color: #777777;
font-size: 22rpx;
margin-bottom: 8rpx;
}
.slider {
margin: 0;
}
.slider-scale {
display: flex;
flex-direction: row;
justify-content: space-between;
margin-top: 8rpx;
}
.scale-edge {
color: #5c5c5c;
font-size: 20rpx;
}
.beam-effect-scroll {
white-space: nowrap;
width: 100%;
margin-top: 12rpx;
}
.beam-effect-chip {
display: inline-flex;
flex-direction: column;
align-items: center;
width: 132rpx;
margin-right: 14rpx;
padding: 12rpx 10rpx;
border-radius: 16rpx;
background: #1a1a1a;
border: 2rpx solid #2c2c2c;
box-sizing: border-box;
vertical-align: top;
}
.beam-effect-chip.active {
border-color: rgba(255, 153, 204, 0.85);
box-shadow: 0 0 14rpx rgba(255, 153, 204, 0.25);
}
.beam-effect-swatch {
width: 100%;
height: 40rpx;
border-radius: 10rpx;
margin-bottom: 8rpx;
}
.beam-effect-name {
color: #c8c8c8;
font-size: 20rpx;
text-align: center;
line-height: 1.25;
}
.angle-quick-scroll {
white-space: nowrap;
width: 100%;
margin-top: 14rpx;
}
.angle-quick-chip {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 72rpx;
padding: 10rpx 16rpx;
margin-right: 10rpx;
border-radius: 999rpx;
background: #1e1e1e;
border: 2rpx solid #333333;
box-sizing: border-box;
}
.angle-quick-chip.active {
border-color: rgba(255, 153, 204, 0.9);
background: rgba(255, 153, 204, 0.12);
}
.angle-quick-text {
color: #d0d0d0;
font-size: 22rpx;
}
.angle-quick-chip.active .angle-quick-text {
color: #ffccdd;
font-weight: 600;
}
.angle-fine-row {
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 12rpx;
margin-top: 14rpx;
}
.angle-fine-btn {
flex: 1;
min-width: 140rpx;
padding: 14rpx 12rpx;
border-radius: 14rpx;
background: #1a1a1a;
border: 1rpx solid #2e2e2e;
align-items: center;
justify-content: center;
display: flex;
}
.angle-fine-text {
color: #9a9a9a;
font-size: 24rpx;
}
.slider-tip {
display: block;
color: #6a9e9a;
font-size: 20rpx;
margin-top: 10rpx;
line-height: 1.4;
}
.picker-field {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
padding: 24rpx 28rpx;
border-radius: 20rpx;
background: #1c1c1c;
border: 2rpx solid #2a2a2a;
}
.picker-left {
display: flex;
flex-direction: column;
flex: 1;
padding-right: 16rpx;
}
.picker-text {
color: #ffffff;
font-size: 28rpx;
}
.picker-hint {
color: #6f6f6f;
font-size: 22rpx;
margin-top: 8rpx;
}
.picker-arrow {
color: #888888;
font-size: 24rpx;
}
.tabbar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
display: flex;
flex-direction: row;
align-items: stretch;
justify-content: space-around;
background: #0f0f0f;
border-top: 1rpx solid #222222;
padding-top: 8rpx;
z-index: 100;
}
.tab-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
padding: 12rpx 0 18rpx;
position: relative;
}
.tab-indicator {
width: 48rpx;
height: 6rpx;
border-radius: 6rpx;
background: transparent;
margin-bottom: 6rpx;
}
.tab-item.active .tab-indicator {
background: #66ffff;
box-shadow: 0 0 12rpx rgba(102, 255, 255, 0.8);
}
.tab-icon {
font-size: 32rpx;
color: #666666;
margin-bottom: 4rpx;
}
.tab-text {
font-size: 22rpx;
color: #777777;
}
.tab-item.active .tab-icon,
.tab-item.active .tab-text {
color: #66ffff;
}
</style>