3337 lines
112 KiB
Vue
3337 lines
112 KiB
Vue
<!--
|
||
与独立工程 Laser-Card 同源:风格预设、beamEffects、默认滑杆与 pages/laser-card-studio/laser-card-studio.vue 保持一致;改参数时请双端同步。
|
||
铸爱 create 单图流程:底部「生成」→ 写入 castlove_laser_entry_payload → 本页 onLoad 读入预览。
|
||
目标端:Android / iOS(App-Plus,Vue 页面 + WebView 渲染)。
|
||
pages.json 示例:
|
||
{ "path": "pages/laser-card-studio/laser-card-studio", "style": { "navigationStyle": "custom", "backgroundColor": "#0a0a0a" } }
|
||
App 端说明:
|
||
- 顶栏/底栏使用 statusBarHeight、safeAreaInsets.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_BUCKET(STS 仅签发临时密钥,不写 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'
|
||
}
|
||
],
|
||
/** 粉丝主控:整体镭射强弱 0–100(参考实物以满铺彩虹为主,默认抬高) */
|
||
laserStrength: 72,
|
||
/** 光柱/光束亮度与宽度 20–140(默认偏柔,避免一道斜光柱厄住整张卡) */
|
||
beamIntensity: 48,
|
||
angle: 52,
|
||
/** 光束枢轴在卡面内的位置 0–100(相对预览小卡,非强制中心) */
|
||
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)'
|
||
}
|
||
],
|
||
/** 颗粒密度 20–100(默认更细更密,贴合实物闪粉) */
|
||
grainDensity: 88,
|
||
/** 彩虹细纹密度 0–100:越高条纹越细越密 */
|
||
diffractionDensity: 60,
|
||
/** 彩虹细纹角度偏移 -90~90:相对光束角度的偏移,默认 90 = 垂直于光束方向 */
|
||
diffractionAngleOffset: 90,
|
||
/** 椭圆抠图遮罩半径(相对预览宽/高 %)—— 用于把人物从镭射背景中分离 */
|
||
subjectMaskRadiusX: 46,
|
||
subjectMaskRadiusY: 60,
|
||
subjectMaskSoftness: 44,
|
||
/** 人物椭圆内压低幻彩(模拟白墨挡底),0–100 */
|
||
subjectLaserSuppress: 40,
|
||
/** 叠在照片上的幻彩罩层(模拟光油/膜上反光),0–100;参考卡正面人物仍有膜感 */
|
||
portraitVeilStrength: 52,
|
||
/** 大理石/揉箔感中频纹理强度 0–100 */
|
||
marbleFlowStrength: 52,
|
||
/** 磨砂雾面 0–100,抬升颗粒有效密度 */
|
||
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 | ai:ai 时使用 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%),任意枢轴旋转时仍能盖住圆角预览区;
|
||
* 卡面枢轴 0–100 → 层内 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>
|