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

597 lines
15 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<view class="physics-page">
<view class="top-bar">
<view class="top-bar-btn" @tap="goBack">
<text class="nav-glyph"></text>
</view>
<view class="top-bar-title">
<text class="top-bar-title-text">光栅卡工作室</text>
<text class="top-bar-subtitle">倾斜手机 · 重力感应预览</text>
</view>
<view class="top-bar-actions">
<view class="top-bar-chip" @tap.stop="onRecalibrate">
<text class="top-bar-chip-text">校零</text>
</view>
<view class="top-bar-btn" @tap="onMore">
<text class="nav-glyph"></text>
</view>
</view>
</view>
<view class="canvas-wrap">
<view class="canvas-bg" />
<LenticularCard
class="preview-stack"
:layers="layers"
:transforms="layerTransforms"
:gyro-source="gyroSourceLabel"
:skip-built-in-touch="true"
:tilt-hint-text="''"
:shimmer-mid-opacity="0.16"
@simulate="simulate"
/>
</view>
<view class="tool-dock">
<view class="dock-rim" />
<view class="layer-strip">
<view class="layer-strip-header">
<text class="layer-strip-title">图层素材</text>
<text class="layer-strip-hint">点击替换 · 长按重置</text>
</view>
<view class="layer-slots">
<view
v-for="layer in layers"
:key="layer.id"
class="layer-slot"
:class="{ 'layer-slot--filled': !!layer.src }"
@tap="pickImage(layer.id)"
@longpress="resetLayer(layer.id)"
>
<image v-if="layer.src" class="layer-slot-img" :src="layer.src" mode="aspectFill" />
<view v-else class="layer-slot-placeholder" :style="placeholderStyle(layer)">
<text class="layer-slot-icon"></text>
</view>
<view class="layer-slot-meta">
<text class="layer-slot-name">{{ layer.label }}</text>
<text class="layer-slot-tag">{{ layer.src ? '已上传' : '点击上传' }}</text>
</view>
<view v-if="layer.src" class="layer-slot-badge" @tap.stop="resetLayer(layer.id)">
<text class="layer-slot-badge-icon">×</text>
</view>
</view>
</view>
</view>
</view>
</view>
</template>
<script>
export default {
inheritAttrs: false,
};
</script>
<script setup>
import { ref, onMounted, onUnmounted, nextTick } from 'vue'
import LenticularCard from '@/components/lenticular/LenticularCard.vue'
import { useLenticularPreview } from '@/composables/useLenticularPreview.js'
import { useLenticularStudioTilt } from '@/composables/useLenticularStudioTilt.js'
import {
buildLenticularLayersTwo,
LENTICULAR_STUDIO_STORAGE_KEY,
} from '@/utils/castloveMintForm.js'
const layers = ref([])
const gyroSourceLabel = ref('simulation')
const { physics, layerTransforms, simulate, relax, snapSimulatedTilt } = useLenticularPreview(layers)
/** 相对进入时基准的倾角标量(度),每满一档换一层;越大需掰得越狠才换图,体感更慢 */
const STUDIO_DISCRETE_STEP_DEG = 13
/** 档位施密特迟滞(度),减轻在档位边界来回跳;换向经过水平附近时略加大更稳 */
const DISCRETE_STEP_HYST_DEG = 5
/**
* 倾角幅度一阶低通的时间常数ms对称跟随进档/退档节奏一致,避免「抬手慢、压手快」的忽快忽慢。
* dt 按实际回调间隔估算,与陀螺轮询/加速度计频率解耦。
*/
const STUDIO_TILT_MAG_TAU_MS = 150
/** 平滑后倾角幅度最大爬升速率(度/秒),抑制某一侧翻转时 raw 陡增导致的瞬间跳档 */
const STUDIO_TILT_MAG_MAX_RISE_DPS = 82
/** 回落可略快,减档仍跟手 */
const STUDIO_TILT_MAG_MAX_FALL_DPS = 168
/**
* 有符号倾角相对水平远离的速率阈值(度/秒);超过则认为该帧在「快速掰离水平」,
* 再收紧爬升限幅(典型:从下往上翻时 |atan2| 一侧梯度更大)。
*/
const STUDIO_TILT_AWAY_FROM_LEVEL_DPS = 72
/** 触发「快速远离水平」时,爬升限幅乘数(越小越匀) */
const STUDIO_TILT_RISE_TIGHTEN = 0.42
/** 进入页后延迟开启倾斜监听ms避免首帧/基线采集/大图解码时画面跟着抖 */
const STUDIO_TILT_START_DELAY_MS = 560
const discreteStableStep = ref(0)
/** 与离散档位同步平滑的倾角幅度(度) */
let studioTiltMagEma = 0
/** 上次幅度采样时间戳,用于按真实 dt 做指数平滑 */
let lastStudioTiltMagSampleAt = 0
/** 上一帧有符号倾角(度),仅加速度计路径传入;用于检测快速远离水平 */
let lastStudioSignedDegHint = null
function resetStudioTiltMagFilter() {
studioTiltMagEma = 0
lastStudioTiltMagSampleAt = 0
lastStudioSignedDegHint = null
}
/**
* 将相对基准的倾角标量(度,非负)映射为 LenticularEngine 的 gamma。
* @param {number} tiltMagDeg 相对校零的倾角幅度(度,非负)
* @param {number} [signedDegHint] 有符号倾角(度),仅加速度计路径传入;用于识别快速远离水平的一侧并收紧爬升
*/
function simulateFromSignedDegrees(tiltMagDeg, signedDegHint) {
const ls = layers.value || []
const n = Math.max(1, ls.length)
const raw = Math.abs(Number(tiltMagDeg) || 0)
const now = Date.now()
let dt = 33
if (lastStudioTiltMagSampleAt > 0) {
dt = now - lastStudioTiltMagSampleAt
}
lastStudioTiltMagSampleAt = now
dt = Math.max(16, Math.min(100, dt))
const alpha = 1 - Math.exp(-dt / STUDIO_TILT_MAG_TAU_MS)
let nextEma = studioTiltMagEma + (raw - studioTiltMagEma) * alpha
const sec = dt * 0.001
let maxRise = STUDIO_TILT_MAG_MAX_RISE_DPS * sec
const maxFall = STUDIO_TILT_MAG_MAX_FALL_DPS * sec
if (Number.isFinite(signedDegHint)) {
if (lastStudioSignedDegHint != null && sec > 1e-6) {
const dAbsSignedDt =
(Math.abs(signedDegHint) - Math.abs(lastStudioSignedDegHint)) / sec
if (dAbsSignedDt > STUDIO_TILT_AWAY_FROM_LEVEL_DPS) {
maxRise *= STUDIO_TILT_RISE_TIGHTEN
}
}
lastStudioSignedDegHint = signedDegHint
} else {
lastStudioSignedDegHint = null
}
let dMag = nextEma - studioTiltMagEma
if (dMag > 0) {
dMag = Math.min(dMag, maxRise)
} else {
dMag = Math.max(dMag, -maxFall)
}
studioTiltMagEma += dMag
const absDeg = studioTiltMagEma
const STEP = STUDIO_DISCRETE_STEP_DEG
const MARGIN = DISCRETE_STEP_HYST_DEG
const stepBefore = discreteStableStep.value
let s = stepBefore
while (absDeg >= (s + 1) * STEP + MARGIN) s++
while (s > 0 && absDeg <= s * STEP - MARGIN) s--
discreteStableStep.value = s
const idx = s % n
const sens = physics.tiltSensitivity / 100
const mul = 0.44 + sens * 0.52
const u = (idx + 0.5) / n
const gPick = Math.max(-1, Math.min(1, 2 * u - 1))
const gamma = Math.max(-1, Math.min(1, gPick / mul))
simulate(gamma, 0)
/* 不在此 snap保留引擎内 displayGamma 渐近angleStability / transitionSmoothness
* 切换档位时 u 连续扫过叠化带,才能看到一张渐隐、另一张渐显。 */
}
const { start: startTilt, stop: stopTilt, recalibrate: recalibrateTilt } = useLenticularStudioTilt({
simulate,
simulateFromSignedDegrees,
gyroSourceLabel,
useStudioAccelDirect: true,
onTiltDriverFallback: () => {
discreteStableStep.value = 0
resetStudioTiltMagFilter()
},
})
/** 首屏与换素材后:档位与 EMA 归零,并写入与 0° 档位一致的 gamma传感器未开时保持静止 */
function lockStudioPreviewStill() {
discreteStableStep.value = 0
resetStudioTiltMagFilter()
simulateFromSignedDegrees(0)
snapSimulatedTilt({ resetLayerSmoothing: true })
}
let entryTiltTimer = null
function goBack() {
uni.navigateBack({
delta: 1,
fail: () => {},
})
}
function onMore() {
uni.showActionSheet({
itemList: ['恢复为进入工作室时的图片'],
success: (res) => {
if (res.tapIndex === 0) {
const raw = uni.getStorageSync(LENTICULAR_STUDIO_STORAGE_KEY)
if (!raw) return
try {
const p = typeof raw === 'string' ? JSON.parse(raw) : raw
layers.value = buildLenticularLayersTwo(p.bgPath || '', p.subjectPath || '')
discreteStableStep.value = 0
resetStudioTiltMagFilter()
nextTick(() => relax(0.86))
uni.showToast({ title: '已恢复', icon: 'none' })
} catch (e) {
console.error(e)
}
}
},
})
}
function placeholderStyle(layer) {
if (layer.background) {
return { background: layer.background }
}
return { background: 'rgba(6,14,32,0.55)' }
}
function pickImage(layerId) {
uni.chooseImage({
count: 1,
sizeType: ['compressed'],
sourceType: ['album', 'camera'],
success: (res) => {
const path = res.tempFilePaths && res.tempFilePaths[0]
if (!path) return
const idx = layers.value.findIndex((l) => l.id === layerId)
if (idx < 0) return
const cur = layers.value[idx]
const next = { ...cur, src: path, background: undefined }
if (layerId === 'base') next.dots = undefined
const copy = [...layers.value]
copy[idx] = next
layers.value = copy
nextTick(() => relax(0.86))
},
fail: (err) => {
const msg = (err && err.errMsg) || ''
if (!msg.includes('cancel')) {
uni.showToast({ title: '选择图片失败', icon: 'none' })
}
},
})
}
function resetLayer(layerId) {
const fresh = buildLenticularLayersTwo('', '')
const defLayer = fresh.find((l) => l.id === layerId)
const idx = layers.value.findIndex((l) => l.id === layerId)
if (idx < 0 || !defLayer) return
const copy = [...layers.value]
copy[idx] = { ...defLayer }
layers.value = copy
nextTick(() => relax(0.86))
}
function onRecalibrate() {
discreteStableStep.value = 0
resetStudioTiltMagFilter()
recalibrateTilt()
nextTick(() => {
simulateFromSignedDegrees(0)
snapSimulatedTilt({ resetLayerSmoothing: true })
})
uni.showToast({ title: '已重置水平基准', icon: 'none' })
}
onMounted(() => {
try {
const raw = uni.getStorageSync(LENTICULAR_STUDIO_STORAGE_KEY)
if (!raw) {
uni.showToast({ title: '缺少素材数据,请返回上一步', icon: 'none' })
setTimeout(() => goBack(), 1600)
return
}
const p = typeof raw === 'string' ? JSON.parse(raw) : raw
layers.value = buildLenticularLayersTwo(p.bgPath || '', p.subjectPath || '')
/* 光栅感:略抬残影/ghost叠化带宽略加宽配合非 snap 的 gamma 渐近,换图时渐隐渐显更明显 */
Object.assign(physics, {
/* 略提高:让 smoothedSensor → displayGamma 在档位间多走几帧,叠化更明显 */
angleStability: 52,
transitionSmoothness: 40,
tiltSensitivity: 96,
sensorDeadzoneStrength: 0,
parallaxDepth: 18,
lenticularAnchorFloor: 0.1,
lenticularNonDominantResidualMin: 0.092,
lenticularPrevLayerGhostMin: 0.098,
lenticularBlendBaseScale: 1.1,
})
} catch (e) {
console.error('[lenticular-studio] load payload', e)
uni.showToast({ title: '数据无效', icon: 'none' })
setTimeout(() => goBack(), 1600)
return
}
nextTick(() => {
lockStudioPreviewStill()
if (entryTiltTimer != null) {
clearTimeout(entryTiltTimer)
entryTiltTimer = null
}
entryTiltTimer = setTimeout(() => {
entryTiltTimer = null
startTilt()
}, STUDIO_TILT_START_DELAY_MS)
})
})
onUnmounted(() => {
if (entryTiltTimer != null) {
clearTimeout(entryTiltTimer)
entryTiltTimer = null
}
stopTilt()
})
</script>
<style>
.nav-glyph {
font-size: 22px;
line-height: 1;
font-weight: 600;
color: inherit;
}
</style>
<style scoped>
.physics-page {
width: 100vw;
height: 100vh;
display: flex;
flex-direction: column;
background: #0b1326;
color: #dae2fd;
overflow: hidden;
position: relative;
}
.top-bar {
position: relative;
z-index: 50;
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 16px;
height: 56px;
padding-top: calc(env(safe-area-inset-top) + 4px);
background: rgba(11, 19, 38, 0.88);
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.top-bar-actions {
display: flex;
align-items: center;
gap: 4px;
}
.top-bar-chip {
padding: 6px 12px;
border-radius: 999px;
background: rgba(45, 52, 73, 0.75);
border: 1px solid rgba(255, 255, 255, 0.12);
}
.top-bar-chip-text {
font-size: 12px;
font-weight: 600;
color: #ddb7ff;
}
.top-bar-btn {
width: 44px;
height: 44px;
display: flex;
align-items: center;
justify-content: center;
color: #ddb7ff;
}
.top-bar-title {
display: flex;
flex-direction: column;
align-items: center;
}
.top-bar-title-text {
font-size: 18px;
font-weight: 600;
color: #dae2fd;
}
.top-bar-subtitle {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.16em;
color: #4cd7f6;
margin-top: 2px;
}
.canvas-wrap {
flex: 1;
min-height: 0;
position: relative;
display: flex;
align-items: center;
justify-content: center;
padding: 8px 10px 4px;
z-index: 10;
}
.preview-stack {
position: relative;
z-index: 1;
width: 100%;
height: 100%;
min-height: 0;
display: flex;
align-items: center;
justify-content: center;
contain: layout paint;
transform: translateZ(0);
}
.canvas-bg {
position: absolute;
inset: 0;
background:
radial-gradient(ellipse at 50% 35%, rgba(132, 43, 210, 0.18), transparent 55%),
radial-gradient(ellipse at 50% 85%, rgba(76, 215, 246, 0.1), transparent 60%);
pointer-events: none;
}
.tool-dock {
position: relative;
z-index: 20;
margin: 0 10px calc(10px + env(safe-area-inset-bottom));
max-width: 460px;
align-self: center;
width: calc(100% - 20px);
background: rgba(34, 42, 61, 0.88);
border: 1px solid rgba(77, 67, 84, 0.45);
border-radius: 20px;
padding: 12px 14px;
display: flex;
flex-direction: column;
gap: 10px;
box-shadow: 0 18px 50px rgba(0, 0, 0, 0.4);
overflow: hidden;
}
.dock-rim {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 1px;
background: linear-gradient(to right, transparent, rgba(221, 183, 255, 0.45), transparent);
}
.layer-strip {
display: flex;
flex-direction: column;
gap: 8px;
}
.layer-strip-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 2px;
}
.layer-strip-title {
font-size: 13px;
font-weight: 600;
color: #dae2fd;
}
.layer-strip-hint {
font-size: 11px;
color: rgba(207, 194, 214, 0.6);
}
.layer-slots {
display: flex;
gap: 8px;
}
.layer-slot {
flex: 1;
position: relative;
display: flex;
flex-direction: column;
border-radius: 12px;
overflow: hidden;
background: rgba(6, 14, 32, 0.55);
border: 1px solid rgba(255, 255, 255, 0.08);
}
.layer-slot--filled {
border-color: rgba(76, 215, 246, 0.5);
box-shadow: 0 0 14px rgba(76, 215, 246, 0.15);
}
.layer-slot-img {
width: 100%;
height: 56px;
display: block;
}
.layer-slot-placeholder {
width: 100%;
height: 56px;
display: flex;
align-items: center;
justify-content: center;
background-size: cover;
background-position: center;
}
.layer-slot-icon {
font-size: 22px;
color: rgba(221, 183, 255, 0.85);
}
.layer-slot-meta {
display: flex;
flex-direction: column;
padding: 6px 8px 8px;
gap: 2px;
background: rgba(6, 14, 32, 0.55);
}
.layer-slot-name {
font-size: 12px;
font-weight: 600;
color: #dae2fd;
}
.layer-slot-tag {
font-size: 10px;
color: rgba(207, 194, 214, 0.7);
}
.layer-slot--filled .layer-slot-tag {
color: #4cd7f6;
}
.layer-slot-badge {
position: absolute;
top: 4px;
right: 4px;
width: 22px;
height: 22px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: rgba(0, 0, 0, 0.55);
border: 1px solid rgba(255, 255, 255, 0.18);
}
.layer-slot-badge-icon {
font-size: 14px;
color: #dae2fd;
}
</style>