topfans/frontend/pages/castlove/laser-card-studio.vue

3337 lines
112 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 变量导致刘海屏错位
- 预览mesh + 流体箔 + 高光脊 + 衍射纹 + 旋转幻彩/光束 + 颗粒 + 磨砂 + 扫光人物椭圆内压下层幻彩人像膜层叠在照片上soft-light最上层人物图
- 导出合成离屏 canvas柔光底 + 主幻彩渐变 + 流体底纹 + 满铺衍射纹 + 光柱 + 闪粉 + 磨砂 + 人物椭圆内压镭射multiply+ 顶层扫光与预览同参
- iOS WKWebView / Android System WebView mix-blend-mode 支持较好若个别机型异常可去掉各 preview-layer 内联 style mixBlendMode
- 预览参照实体小卡样例柔光色块mesh+ 斜向光束 + 细颗粒 + 扫光高光粉丝参数收敛为风格预设 + 调校
- 顶栏与预览区 position: sticky向下滑动调校时预览贴在导航下方便于实时对比效果
- 保存先合成图片写入系统相册再询问是否将参数写入本地我的预设下次进入可套用
- 主体增强可选边缘压暗镭射换底换底为椭圆柔边遮罩 + 底层镭射 AI 抠图枢轴宜对准人物中心并调范围磨砂强化抬升颗粒与微雾面
-->
<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" @tap="onPreviewWrapTap">
<block v-if="previewSrc">
<view class="preview-layer preview-mesh" :style="meshOverlayStyle" />
<view class="preview-layer preview-marble-flow" :style="marbleFlowOverlayStyle" />
<view class="preview-layer preview-foil-spec" :style="foilSpecOverlayStyle" />
<view class="preview-layer preview-diffraction" :style="diffractionOverlayStyle" />
<view class="preview-layer preview-beam-rotate preview-beam-rotate--stack-main" :style="beamRotateWrapStyle">
<view
class="preview-irid-wrap"
:class="{
'preview-irid-wrap--rainbow': presetAnimMode === 'hue',
'preview-irid-wrap--metal': presetAnimMode === 'metal'
}"
:style="iridWrapDynamicStyle"
>
<view class="preview-irid-core" :style="iridCoreStyle" />
</view>
<view class="preview-rays" :style="raysOverlayStyle" />
</view>
<view class="preview-layer preview-grain" :style="grainOverlayStyle" />
<view class="preview-layer preview-matte-frost" :style="matteFrostStyle" />
<view class="preview-layer preview-beam-rotate preview-beam-rotate--stack-sheen" :style="beamRotateWrapStyle">
<view class="preview-sheen-wrap" :style="sheenWrapStyle">
<view class="preview-sheen-core" :style="sheenCoreStyle" />
</view>
</view>
<view class="preview-layer preview-holo-suppress" :style="subjectHoloSuppressStyle" />
<view class="preview-img-mask-wrap" :style="subjectWrapStyle">
<image
v-if="segmentationMode === 'ai' && aiCutoutSrc"
class="preview-img-subject"
:src="aiCutoutSrc"
mode="aspectFill"
@touchstart="onPreviewTouchStart"
@touchmove="onPreviewTouchMove"
@touchend="onPreviewTouchEnd"
/>
<image
v-else
class="preview-img-subject"
:src="previewSrc"
mode="aspectFill"
@touchstart="onPreviewTouchStart"
@touchmove="onPreviewTouchMove"
@touchend="onPreviewTouchEnd"
/>
<view
class="preview-subject-holo-veil"
:class="{
'preview-subject-holo-veil--rainbow': presetAnimMode === 'hue',
'preview-subject-holo-veil--metal': presetAnimMode === 'metal'
}"
:style="subjectHoloVeilStyle"
/>
</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">{{ previewHintText }}</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>
<!-- 本地保存的参数预设(再次进入可读) -->
<view v-if="paramPresets.length" class="preset-memory">
<text class="preset-memory-title">我的预设</text>
<text class="preset-memory-hint">点击套用 · 长按删除</text>
<scroll-view class="preset-memory-scroll" scroll-x show-scrollbar="false">
<view
v-for="p in paramPresets"
:key="p.id"
class="preset-memory-chip"
@tap="applyParamPreset(p)"
@longpress.stop="onPresetLongPress(p)"
>
<text class="preset-memory-name">{{ p.name }}</text>
<text class="preset-memory-meta">{{ presetChipMeta(p) }}</text>
</view>
</scroll-view>
</view>
<!-- 当前效果速览 -->
<view class="summary-bar">
<text class="summary-bar-title">当前效果</text>
<scroll-view class="summary-scroll" scroll-x show-scrollbar="false">
<view
class="summary-chip"
:class="{ on: activeTab === 'style' }"
@tap="activeTab = 'style'"
>
<text class="summary-chip-k">风格</text>
<text class="summary-chip-v">{{ currentPreset.name }}</text>
</view>
<view
class="summary-chip"
:class="{ on: activeTab === 'tune' }"
@tap="activeTab = 'tune'"
>
<text class="summary-chip-k">调校</text>
<text class="summary-chip-v">{{ tuneSummary }}</text>
</view>
</scroll-view>
</view>
<view class="param-panel">
<view class="panel-head">
<view class="panel-head-text">
<text class="panel-title">{{ activePanelMeta.title }}</text>
<text class="panel-desc">{{ activePanelMeta.desc }}</text>
</view>
<view class="panel-tag">
<text class="panel-tag-text">{{ activePanelMeta.tag }}</text>
</view>
</view>
<!-- 风格:参考大卡 / 小卡 / 样例渐变 -->
<view v-show="activeTab === 'style'" class="panel-body">
<view class="preset-grid">
<view
v-for="p in stylePresets"
:key="p.id"
class="preset-card"
:class="{ active: stylePresetId === p.id }"
@tap="stylePresetId = p.id"
>
<view class="preset-strip" :style="{ background: p.strip }" />
<text class="preset-name">{{ p.name }}</text>
<text class="preset-desc">{{ p.desc }}</text>
</view>
</view>
</view>
<!-- 调校:仅保留粉丝必调项 -->
<view v-show="activeTab === 'tune'" class="panel-body panel-body--tight">
<view class="slider-block">
<view class="slider-row">
<text class="slider-label">镭射强度</text>
<text class="value-pill">{{ laserStrength }}%</text>
</view>
<text class="slider-sub">整体幻彩与光束的强弱,人像建议约 45%65%</text>
<slider
class="slider"
:value="laserStrength"
min="0"
max="100"
activeColor="#66FFFF"
backgroundColor="#2a2a2a"
block-color="#66FFFF"
@changing="onLaserStrengthChange"
@change="onLaserStrengthChange"
/>
<view class="slider-scale">
<text class="scale-edge">淡</text>
<text class="scale-edge">浓</text>
</view>
<view class="slider-row slider-row--tight">
<text class="slider-label-sub">流体底纹</text>
<text class="value-pill value-pill--mini">{{ marbleFlowStrength }}%</text>
</view>
<text class="slider-tip-inline">叠加大理石/揉箔感的中频纹理,亮带更随型(略增绘制开销)</text>
<slider
class="slider"
:value="marbleFlowStrength"
min="0"
max="100"
activeColor="#FF99FF"
backgroundColor="#2a2a2a"
block-color="#FF99FF"
@changing="onMarbleFlowStrengthChange"
@change="onMarbleFlowStrengthChange"
/>
</view>
<view class="slider-block">
<view class="slider-row">
<text class="slider-label">光柱强度</text>
<text class="value-pill">{{ beamIntensity }}%</text>
</view>
<text class="slider-sub">斜射光柱的亮度与宽度,值越高光柱越粗越耀眼</text>
<slider
class="slider"
:value="beamIntensity"
min="20"
max="140"
activeColor="#FF99FF"
backgroundColor="#2a2a2a"
block-color="#FF99FF"
@changing="onBeamIntensityChange"
@change="onBeamIntensityChange"
/>
<view class="slider-scale">
<text class="scale-edge">柔</text>
<text class="scale-edge">烈</text>
</view>
</view>
<view class="slider-block">
<view class="slider-row">
<text class="slider-label">智能抠图</text>
<text class="value-pill value-pill--pink">云端</text>
</view>
<text class="slider-sub">方案一:在 config/segmentApi.js 配置 ALIYUN_STS_URL + OSS_BUCKETSTS 仅签发临时密钥,不写 AK。方案二自建代理 SEGMENT_API_BASE。云端方案为稳定读图上传前不再本地压缩体积略大。</text>
<view class="segment-actions">
<view class="segment-btn segment-btn--primary" @tap="onSmartSegment">
<text class="segment-btn-text">智能抠图</text>
</view>
<view
v-if="segmentationMode === 'ai' && aiCutoutSrc"
class="segment-btn segment-btn--ghost"
@tap="onClearAiCutout"
>
<text class="segment-btn-text">恢复椭圆</text>
</view>
</view>
</view>
<view class="slider-block" :class="{ 'slider-block--muted': segmentationMode === 'ai' && aiCutoutSrc }">
<view class="slider-row">
<text class="slider-label">人物范围</text>
<text class="value-pill value-pill--pink">椭圆抠图</text>
</view>
<text class="slider-sub">用椭圆把人物从背景中抠出,置于镭射光效之上(拖动预览定位中心;椭圆为近似形状,背景过于杂乱时边缘会可见){{ (segmentationMode === 'ai' && aiCutoutSrc) ? ' · 当前为智能抠图,点上方「恢复椭圆」后可调下列滑块。' : '' }}</text>
<view class="slider-row slider-row--tight">
<text class="slider-label-sub">主体横向范围</text>
<text class="value-pill value-pill--mini">{{ subjectMaskRadiusX }}%</text>
</view>
<slider
class="slider"
:value="subjectMaskRadiusX"
min="22"
max="58"
activeColor="#66FFFF"
backgroundColor="#2a2a2a"
block-color="#66FFFF"
@changing="onSubjectMaskXChange"
@change="onSubjectMaskXChange"
/>
<view class="slider-row slider-row--tight">
<text class="slider-label-sub">主体纵向范围</text>
<text class="value-pill value-pill--mini">{{ subjectMaskRadiusY }}%</text>
</view>
<slider
class="slider"
:value="subjectMaskRadiusY"
min="28"
max="72"
activeColor="#66FFFF"
backgroundColor="#2a2a2a"
block-color="#66FFFF"
@changing="onSubjectMaskYChange"
@change="onSubjectMaskYChange"
/>
<view class="slider-row slider-row--tight">
<text class="slider-label-sub">边缘柔和</text>
<text class="value-pill value-pill--mini">{{ subjectMaskSoftness }}%</text>
</view>
<slider
class="slider"
:value="subjectMaskSoftness"
min="12"
max="92"
activeColor="#66FFFF"
backgroundColor="#2a2a2a"
block-color="#66FFFF"
@changing="onSubjectSoftnessChange"
@change="onSubjectSoftnessChange"
/>
<view class="slider-row slider-row--tight">
<text class="slider-label-sub">人物区压镭射</text>
<text class="value-pill value-pill--mini">{{ subjectLaserSuppress }}%</text>
</view>
<text class="slider-tip-inline">只作用于人物图下方的幻彩:压低边缘透闪,不会让人脸变亮(与「人像膜层」分开调)</text>
<slider
class="slider"
:value="subjectLaserSuppress"
min="0"
max="100"
activeColor="#66FFFF"
backgroundColor="#2a2a2a"
block-color="#66FFFF"
@changing="onSubjectLaserSuppressChange"
@change="onSubjectLaserSuppressChange"
/>
<view class="slider-row slider-row--tight">
<text class="slider-label-sub">人像膜层幻彩</text>
<text class="value-pill value-pill--mini">{{ portraitVeilStrength }}%</text>
</view>
<text class="slider-tip-inline">叠在照片最上层soft-light模拟参考卡里人物区域仍有光油/薄膜幻彩;想接近参考图请调高此项</text>
<slider
class="slider"
:value="portraitVeilStrength"
min="0"
max="100"
activeColor="#FF99FF"
backgroundColor="#2a2a2a"
block-color="#FF99FF"
@changing="onPortraitVeilStrengthChange"
@change="onPortraitVeilStrengthChange"
/>
<view class="slider-row slider-row--tight">
<text class="slider-label-sub">磨砂强化</text>
<text class="value-pill value-pill--mini">{{ matteStrength }}%</text>
</view>
<text class="slider-tip-inline">叠加雾面颗粒与微纹理,更接近实体磨砂卡(会抬高闪粉有效密度)</text>
<slider
class="slider"
:value="matteStrength"
min="0"
max="100"
activeColor="#66FFFF"
backgroundColor="#2a2a2a"
block-color="#66FFFF"
@changing="onMatteStrengthChange"
@change="onMatteStrengthChange"
/>
</view>
<view class="slider-block">
<view class="slider-row">
<text class="slider-label">光束角度</text>
<text class="value-pill value-pill--pink">{{ angle }}°</text>
</view>
<text class="slider-sub">绕预览区拖定的枢轴旋转;下方可一键常用角度,再滑杆微调</text>
<slider
class="slider"
:value="angle"
min="0"
max="360"
activeColor="#66FFFF"
backgroundColor="#2a2a2a"
block-color="#66FFFF"
@changing="onAngleChange"
@change="onAngleChange"
/>
<view class="slider-scale">
<text class="scale-edge">0°</text>
<text class="scale-edge">360°</text>
</view>
<scroll-view class="angle-quick-scroll" scroll-x show-scrollbar="false">
<view
v-for="d in angleQuickDegrees"
:key="d"
class="angle-quick-chip"
:class="{ active: angle === d }"
@tap="setAngleQuick(d)"
>
<text class="angle-quick-text">{{ d }}°</text>
</view>
</scroll-view>
<view class="angle-fine-row">
<view class="angle-fine-btn" @tap="nudgeAngle(-15)">
<text class="angle-fine-text">15°</text>
</view>
<view class="angle-fine-btn" @tap="nudgeAngle(-5)">
<text class="angle-fine-text">5°</text>
</view>
<view class="angle-fine-btn" @tap="nudgeAngle(5)">
<text class="angle-fine-text">+5°</text>
</view>
<view class="angle-fine-btn" @tap="nudgeAngle(15)">
<text class="angle-fine-text">+15°</text>
</view>
</view>
</view>
<view class="slider-block">
<view class="slider-row">
<text class="slider-label">光束光效</text>
<text class="value-pill value-pill--pink">{{ currentBeamEffect.name }}</text>
</view>
<text class="slider-sub">主光束与扫光可切换炫彩,不仅限于白光</text>
<scroll-view class="beam-effect-scroll" scroll-x show-scrollbar="false">
<view
v-for="b in beamEffects"
:key="b.id"
class="beam-effect-chip"
:class="{ active: beamEffectId === b.id }"
@tap="beamEffectId = b.id"
>
<view class="beam-effect-swatch" :style="{ background: b.swatch }" />
<text class="beam-effect-name">{{ b.name }}</text>
</view>
</scroll-view>
</view>
<view class="slider-block">
<view class="slider-row">
<text class="slider-label">彩虹细纹</text>
<text class="value-pill">{{ diffractionDensity }}%</text>
</view>
<text class="slider-sub">满铺斜向彩虹条纹的密度,越高条纹越细越密</text>
<slider
class="slider"
:value="diffractionDensity"
min="0"
max="100"
activeColor="#66FFFF"
backgroundColor="#2a2a2a"
block-color="#66FFFF"
@changing="onDiffractionDensityChange"
@change="onDiffractionDensityChange"
/>
<view class="slider-scale">
<text class="scale-edge">稀</text>
<text class="scale-edge">密</text>
</view>
<view class="slider-row slider-row--tight">
<text class="slider-label-sub">条纹角度偏移</text>
<text class="value-pill value-pill--mini">{{ diffractionAngleOffset }}°</text>
</view>
<text class="slider-tip-inline">相对光束角度的偏移90° 表示与光线方向垂直(最像衍射纹)</text>
<slider
class="slider"
:value="diffractionAngleOffset"
min="-90"
max="90"
activeColor="#66FFFF"
backgroundColor="#2a2a2a"
block-color="#66FFFF"
@changing="onDiffractionAngleOffsetChange"
@change="onDiffractionAngleOffsetChange"
/>
</view>
<view class="slider-block">
<view class="slider-row">
<text class="slider-label">闪粉细腻度</text>
<text class="value-pill">{{ grainDensity }}%</text>
</view>
<text class="slider-sub">越高颗粒越密;开启「磨砂强化」后等效细腻度约 {{ grainDensityEffective }}%</text>
<slider
class="slider"
:value="grainDensity"
min="20"
max="100"
activeColor="#66FFFF"
backgroundColor="#2a2a2a"
block-color="#66FFFF"
@changing="onGrainDensityChange"
@change="onGrainDensityChange"
/>
<view class="slider-scale">
<text class="scale-edge">粗</text>
<text class="scale-edge">细</text>
</view>
</view>
<text class="group-label spaced">展示预览(可选)</text>
<view class="slider-block">
<view class="slider-row">
<text class="slider-label">动效速度</text>
<text class="value-pill">{{ previewAnimSpeed }}%</text>
</view>
<text class="slider-sub">现场给粉丝看时可略加快</text>
<slider
class="slider"
:value="previewAnimSpeed"
min="40"
max="220"
activeColor="#66FFFF"
backgroundColor="#2a2a2a"
block-color="#66FFFF"
@changing="onAnimSpeedChange"
@change="onAnimSpeedChange"
/>
<view class="slider-scale">
<text class="scale-edge">慢</text>
<text class="scale-edge">快</text>
</view>
</view>
<view class="slider-block">
<view class="slider-row">
<text class="slider-label">色彩鲜艳度</text>
<text class="value-pill">{{ previewVivid }}%</text>
</view>
<text class="slider-sub">控制预览里彩虹/偏色的饱和度感</text>
<slider
class="slider"
:value="previewVivid"
min="70"
max="135"
activeColor="#66FFFF"
backgroundColor="#2a2a2a"
block-color="#66FFFF"
@changing="onVividChange"
@change="onVividChange"
/>
<view class="slider-scale">
<text class="scale-edge">淡</text>
<text class="scale-edge">艳</text>
</view>
</view>
</view>
</view>
<!-- 底栏 Tab -->
<view class="tabbar" :style="{ paddingBottom: safeAreaBottom + 'px' }">
<view
v-for="t in tabs"
:key="t.key"
class="tab-item"
:class="{ active: activeTab === t.key }"
@tap="activeTab = t.key"
>
<view class="tab-indicator" />
<text class="tab-icon">{{ t.icon }}</text>
<text class="tab-text">{{ t.label }}</text>
</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'
const PARAM_PRESET_STORAGE_KEY = 'laser_card_param_presets_v1'
export default {
name: 'LaserCardStudio',
data() {
return {
statusBarHeight: 24,
safeAreaBottom: 0,
previewSrc: '',
activeTab: 'style',
tabs: [
{ key: 'style', label: '风格', icon: '◆' },
{ key: 'tune', label: '调校', icon: '◎' }
],
/** 参考样例:大卡幻彩 / 高饱和彩虹 / 冷色冰霜 / 暖粉日落 / 珍珠银 */
stylePresetId: 'dream',
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'
}
],
/** 粉丝主控:整体镭射强弱 0100参考实物以满铺彩虹为主默认抬高 */
laserStrength: 72,
/** 光柱/光束亮度与宽度 20140默认偏柔避免一道斜光柱厄住整张卡 */
beamIntensity: 48,
angle: 52,
/** 光束枢轴在卡面内的位置 0100相对预览小卡非强制中心 */
beamPivotX: 50,
beamPivotY: 50,
beamEffectId: 'white',
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: 88,
/** 彩虹细纹密度 0100越高条纹越细越密 */
diffractionDensity: 60,
/** 彩虹细纹角度偏移 -90~90相对光束角度的偏移默认 90 = 垂直于光束方向 */
diffractionAngleOffset: 90,
/** 椭圆抠图遮罩半径(相对预览宽/高 %)—— 用于把人物从镭射背景中分离 */
subjectMaskRadiusX: 46,
subjectMaskRadiusY: 60,
subjectMaskSoftness: 44,
/** 人物椭圆内压低幻彩模拟白墨挡底0100 */
subjectLaserSuppress: 40,
/** 叠在照片上的幻彩罩层(模拟光油/膜上反光0100参考卡正面人物仍有膜感 */
portraitVeilStrength: 52,
/** 大理石/揉箔感中频纹理强度 0100 */
marbleFlowStrength: 52,
/** 磨砂雾面 0100抬升颗粒有效密度 */
matteStrength: 45,
previewAnimSpeed: 100,
previewVivid: 100,
_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
}
},
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'
}
}
},
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()
let img = ''
if (b64) {
img = b64.startsWith('data:') ? b64 : `data:image/jpeg;base64,${b64}`
} else if (path) {
img = path
}
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()
},
methods: {
/**
* 仅用于预览:参考「液态丝绸 / 揉箔 / 高光脊」叠层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() {
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 = uni.createCanvasContext('laserExportCanvas', this)
const iw = img.width
const ih = img.height
const scale = Math.max(cw / iw, ch / ih)
const dw = iw * scale
const dh = ih * scale
const dx = (cw - dw) / 2
const dy = (ch - dh) / 2
ctx.setFillStyle('#141018')
ctx.fillRect(0, 0, cw, ch)
this._drawSolidLaserBackdrop(ctx, cw, ch)
this._drawLaserLayers(ctx, cw, ch)
this._exportDrawSubjectLaserSuppress(ctx, cw, ch)
try {
this._clipSubjectEllipse(ctx, cw, ch)
ctx.drawImage(path, dx, dy, dw, dh)
} catch (e) {
ctx.beginPath()
ctx.rect(0, 0, cw, ch)
ctx.clip()
ctx.drawImage(path, dx, dy, dw, dh)
}
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('screen')
}
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)
ctx.setFillStyle(g)
ctx.fillRect(0, 0, cw, ch)
} catch (err) {
ctx.setGlobalAlpha(0.82 * strength * vivid)
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
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()
},
/**
* 导出用:满铺斜向彩虹衍射纹(与预览 diffractionOverlayStyle 同源参数screen 混合。
*/
_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.11 + strength * 0.16) * vivid
const hiA = (0.15 + strength * 0.2) * vivid
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('screen')
}
ctx.setGlobalAlpha(Math.min(0.92, 0.26 + strength * 0.48 + density * 0.16))
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.55 + ms * 0.35)
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')
}
},
/**
* 导出用沿枢轴附近的一条高光带静态截取一帧「光油扫光」screen 混合。
*/
_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.22 + strength * 0.18 + bi * 0.12) * 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('screen')
}
ctx.setGlobalAlpha(0.38 + bi * 0.28)
ctx.setFillStyle(g)
ctx.fillRect(0, 0, cw, ch)
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('screen')
}
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.55 * strength * vivid)
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)
if (typeof ctx.setGlobalCompositeOperation === 'function') {
ctx.setGlobalCompositeOperation('screen')
}
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(0.85 * 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)
const n = Math.floor(400 + grain * 500)
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.08 + rnd() * 0.2).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.16 + ms * 0.36)
const n2 = Math.floor(240 + ms * 500)
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._exportDrawFineNoiseMatte(ctx, cw, ch, rnd)
this._exportDrawOilSweep(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)
this.$nextTick(() => {
this._askSaveParamsAfterAlbum()
})
},
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);
}
.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>