<!--
  与独立工程 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>
