From a4836605e76d6e54ca5bf9fee10944811ebac97f Mon Sep 17 00:00:00 2001 From: zerosaturation Date: Mon, 6 Jul 2026 18:48:09 +0800 Subject: [PATCH] =?UTF-8?q?refactor(craft-tilt):=20=E5=88=A0=E9=99=A4?= =?UTF-8?q?=E7=A1=AC=E4=BB=B6=E4=BC=A0=E6=84=9F=E5=99=A8=E6=A8=A1=E5=9D=97?= =?UTF-8?q?=20+=20=E5=90=8C=E6=AD=A5=E6=B8=85=E7=90=86=203=20=E4=B8=AA?= =?UTF-8?q?=E5=85=B6=E4=BB=96=E9=A1=B5=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 删 useLenticularStudioTilt.js (28KB, 894 行) - 删 useOneEuroFilter.js (75 行) - useLenticularCraftTiltPreview 移除对二者的引用与 3 个旧 API(scheduleTiltStart/stopTiltPreview/gyroSourceLabel) - 同步清理 3 个其他消费方页面(discover/generation-result, castlove/success, asset-detail): - 删 3 个旧 destructure 键 - 模板 :gyro-source 改静态 'simulation' + 删 :skip-built-in-touch - 删 12 处旧 API 调用 - 保留手动模拟入口 simulateTilt / simulateTiltFromNormalized / simulateTiltReset(Task 1) - ★ 3 个页面用触摸事件替代硬件传感器(未加 slider,后续可补) Co-Authored-By: Claude --- .../useLenticularCraftTiltPreview.js | 51 +- .../composables/useLenticularStudioTilt.js | 840 ------------------ frontend/composables/useOneEuroFilter.js | 75 -- frontend/pages/asset-detail/asset-detail.vue | 14 +- frontend/pages/castlove/success.vue | 8 +- frontend/pages/discover/generation-result.vue | 23 +- 6 files changed, 6 insertions(+), 1005 deletions(-) delete mode 100644 frontend/composables/useLenticularStudioTilt.js delete mode 100644 frontend/composables/useOneEuroFilter.js diff --git a/frontend/composables/useLenticularCraftTiltPreview.js b/frontend/composables/useLenticularCraftTiltPreview.js index 096f65a..62b57ca 100644 --- a/frontend/composables/useLenticularCraftTiltPreview.js +++ b/frontend/composables/useLenticularCraftTiltPreview.js @@ -1,16 +1,12 @@ /** - * 铸爱光栅预览(lenticular-result / asset-detail 等):物理参数 + 陀螺仪倾斜 → 离散档位映射 + * 铸爱光栅预览(lenticular-result / asset-detail 等):手动倾斜入口 → 离散档位映射 * * 引擎是连续叠化引擎(gamma -1..+1 → 层组叠化),但上层做硬切: - * 1. 陀螺仪传感器 → filtered (dx, dy) 2D 向量 + * 1. 手动入口(滑块 / 触摸)→ 归一化 -1..+1 → 度数 * 2. (dx, dy) → 2D 幅值 + 方向 → 离散 layerIdx(硬阈值 + 滞回 + 冷却) * 3. layerIdx → gamma ±1/0 → engine + snapSimulatedTilt 重置平滑 - * - * 传感器数据处理见 useLenticularStudioTilt.js(跳变拒绝 / 快慢通道 / 符号滞回)。 */ -import { ref, nextTick } from 'vue' import { useLenticularPreview } from '@/composables/useLenticularPreview.js' -import { useLenticularStudioTilt } from '@/composables/useLenticularStudioTilt.js' // 手动模拟倾斜:归一化 → 度数 转换系数(spec §3.5) const NORMALIZED_TO_DEG = 45 @@ -48,7 +44,6 @@ const dlog = (...args) => { * @param {import('vue').Ref|import('vue').Ref} layersRef */ export function useLenticularCraftTiltPreview(layersRef) { - const gyroSourceLabel = ref('simulation') const { physics, layerTransforms, simulate, relax, snapSimulatedTilt } = useLenticularPreview(layersRef) Object.assign(physics, FLOW_PHYSICS) @@ -176,45 +171,6 @@ export function useLenticularCraftTiltPreview(layersRef) { simulate(0, 0) } - const { start: startTilt, stop: stopTilt } = useLenticularStudioTilt({ - simulate, - simulateFromSignedDegrees, - gyroSourceLabel, - onTiltDriverFallback: () => { - lastLayerIdx = -1 - dirSign = 0 - dirOppositeStreak = 0 - }, - }) - - let entryTiltTimer = null - - function cancelScheduledTiltStart() { - if (entryTiltTimer != null) { - clearTimeout(entryTiltTimer) - entryTiltTimer = null - } - } - - function scheduleTiltStart() { - dlog('[scheduleTiltStart] called') - cancelScheduledTiltStart() - nextTick(() => { - relax(0.86) - lockPreviewStill() - dlog('[scheduleTiltStart] timer set, delay:', LENTICULAR_TILT_START_DELAY_MS) - entryTiltTimer = setTimeout(() => { - entryTiltTimer = null - startTilt() - }, LENTICULAR_TILT_START_DELAY_MS) - }) - } - - function stopTiltPreview() { - cancelScheduledTiltStart() - stopTilt() - } - // ==================================================================== // 手动模拟倾斜入口(spec §3.1) // ==================================================================== @@ -253,9 +209,6 @@ export function useLenticularCraftTiltPreview(layersRef) { layerTransforms, simulate, relax, - gyroSourceLabel, - scheduleTiltStart, - stopTiltPreview, lockPreviewStill, // 新增:手动模拟入口(spec §3.1) simulateTilt, diff --git a/frontend/composables/useLenticularStudioTilt.js b/frontend/composables/useLenticularStudioTilt.js deleted file mode 100644 index 744ae99..0000000 --- a/frontend/composables/useLenticularStudioTilt.js +++ /dev/null @@ -1,840 +0,0 @@ -/** - * 光栅卡倾斜驱动(铸爱预览共用) - * - * 多路径混合(按优先级): - * - App Android:Native.js → SensorManager(加速度计低通滤波 → 重力方向 → 绝对倾角) - * - App iOS:Native.js → CMMotionManager.deviceMotion.attitude(系统级传感器融合) - * - H5:DeviceOrientationEvent(Chrome/Safari/Firefox 通用) - * - 小程序:降级为模拟(无陀螺仪 API) - * - * ⚠️ uni-app 内置 uni.startGyroscope / uni.onGyroscopeChange 在 Android/iOS - * 原生层未实现桥接——文档里写着但底层没接通。实测所有 3.x 版本 App 打包后调用均 - * 静默失败(无报错、无日志、无回调)。官方跳过陀螺仪是因为 iOS CMMotionManager - * 和 Android SensorManager 在生命周期、权限模型、回调线程上差异太大。 - * 结论:App 端必须用 Native.js 或原生插件接入。 - * - * 平台接入要点: - * - Android:BODY_SENSORS 权限(Android 12+ 强制,仅 ACCESS_FINE_LOCATION 不够) - * - iOS:NSMotionUsageDescription(Info.plist,否则 CMMotionManager 直接失败) - * - 不能在 onLoad 阶段调用传感器——须等 plus.ready 后 - * - 务必检查传感器硬件:getDefaultSensor(TYPE_GYROSCOPE) 返回 null = 不支持 - * - * DeviceOrientationEvent: - * - gamma: 左右倾斜 -90°~+90°(平放=0) - * - beta: 前后倾斜 -180°~+180°(竖直=0) - * - iOS 13+ Safari 需 requestPermission(用户手势触发) - * - * 关键设计: - * 1. 跳变拒绝:abs(raw-prev) > 15° → clamp,掐掉位移加速度伪影 - * 2. 快通道 α=0.25:约 0.2s 到 63%,离散切换更跟手 - * 3. 慢通道 α=0.08 + 冷启动基线=0:自适应用户"休息位" - * 4. dx/dy = fast - slow → 上层 2D 挡位映射 - * 5. 跳过前 5 帧预热帧(传感器初始化噪声) - */ - -import { createOneEuroFilter, nowMs } from '@/composables/useOneEuroFilter.js' - -const JUMP_REJECT_DEG = 15 -const SKIP_WARMUP_FRAMES = 5 -const FAST_ALPHA = 0.25 -const SLOW_ALPHA = 0.08 - -/** - * @param {object} opts - * @param {(x: number, y: number) => void} opts.simulate - * @param {(dx: number, dy: number) => void} [opts.simulateFromSignedDegrees] - * @param {import('vue').Ref} opts.gyroSourceLabel - * @param {() => void} [opts.onTiltDriverFallback] - */ -export function useLenticularStudioTilt(opts) { - const { simulate, simulateFromSignedDegrees, gyroSourceLabel, onTiltDriverFallback } = opts - - // ==================================================================== - // 通用状态 - // ==================================================================== - let tiltGen = 0 - - // 跳变拒绝:上一帧 raw,NaN 表示"无条件接受第一帧" - let prevRawX = NaN, prevRawY = NaN - // 预热跳过计数器 - let warmupSkip = 0 - - // 快通道:平滑跟踪(α=0.25 跟手 + 抑制噪声) - let fastX = NaN, fastY = NaN - // 慢通道:缓慢追踪"休息位"(α=0.08 自动重居中,冷启动基线=0) - let slowX = NaN, slowY = NaN - - // 日志节流 - let lastLogSecond = 0 - - // ==================================================================== - // Native.js 路径状态 - // ==================================================================== - // #ifdef APP-PLUS - let nativeCleanup = null // () => void - let nativeStartTimer = null // setTimeout ID - // #endif - - // ==================================================================== - // DeviceOrientation 路径状态 - // ==================================================================== - let orientationHandler = null - - // ——— 通用复位 ——— - - function resetState() { - prevRawX = NaN; prevRawY = NaN - warmupSkip = 0 - fastX = NaN; fastY = NaN - slowX = NaN; slowY = NaN - } - - // ——— 跳变拒绝 ——— - - /** - * 单帧 raw 变化超过 JUMP_REJECT_DEG 则 clamp,掐掉位移加速度伪影。 - * 真实倾斜不太可能超过 ±15°/帧,大跳变必定是设备平移加速度的伪信号。 - */ - function clampJump(rawX, rawY) { - let cx = rawX, cy = rawY - if (Number.isFinite(prevRawX)) { - if (Math.abs(rawX - prevRawX) > JUMP_REJECT_DEG) { - cx = prevRawX + Math.sign(rawX - prevRawX) * JUMP_REJECT_DEG - } - if (Math.abs(rawY - prevRawY) > JUMP_REJECT_DEG) { - cy = prevRawY + Math.sign(rawY - prevRawY) * JUMP_REJECT_DEG - } - } - prevRawX = rawX; prevRawY = rawY - return { cx, cy } - } - - // ——— 快慢双通道 ——— - - function updateFast(rawX, rawY) { - if (!Number.isFinite(fastX)) { fastX = rawX; fastY = rawY; return } - fastX += (rawX - fastX) * FAST_ALPHA - fastY += (rawY - fastY) * FAST_ALPHA - } - - function updateSlow(rawX, rawY) { - if (!Number.isFinite(slowX)) { - // 冷启动基线=0:让第1帧 dx 立即有值(设备已倾斜时不会白板) - slowX = 0; slowY = 0 - return - } - slowX += (rawX - slowX) * SLOW_ALPHA - slowY += (rawY - slowY) * SLOW_ALPHA - } - - // ——— 统一输出 ——— - - function applyDelta(dx, dy) { - // 节流日志(每秒最多一条) - if (Math.floor(Date.now() / 1000) !== lastLogSecond) { - lastLogSecond = Math.floor(Date.now() / 1000) - console.log('[StudioTilt] dx:', dx.toFixed(1), 'dy:', dy.toFixed(1), - 'src:', gyroSourceLabel.value) - } - - if (typeof simulateFromSignedDegrees === 'function') { - simulateFromSignedDegrees(dx, dy) - } else { - simulate(0, 0) - } - } - - // ==================================================================== - // Native.js 倾斜帧入口(Android & iOS 共用) - // 参数:(rollDeg, pitchDeg) — 绝对倾角,单位为度 - // rollDeg: 左右倾斜(>0 右侧低),映射到 dx - // pitchDeg: 前后倾斜(>0 后端低),映射到 dy - // ==================================================================== - - function handleNativeTiltFrame(rollDeg, pitchDeg) { - if (warmupSkip < SKIP_WARMUP_FRAMES) { - warmupSkip++ - return - } - - const { cx, cy } = clampJump(rollDeg, pitchDeg) - updateFast(cx, cy) - updateSlow(cx, cy) - - const dx = fastX - slowX - const dy = fastY - slowY - - applyDelta(dx, dy) - } - - // ==================================================================== - // 路径 A-1: Native.js Android - // 用加速度计 + 低通滤波提取重力方向 → 绝对 roll/pitch(度) - // 避免直接积分陀螺仪(TYPE_GYROSCOPE 输出角速度 rad/s,积分漂移严重) - // ==================================================================== - - // #ifdef APP-PLUS - - // ================================================================ - // 插件路径 (Android):imengyu-UniAndroidGyro-GyroModule - // 插件内部完成 Java float[] → JS number 转换,绕开 Native.js 限制 - // ================================================================ - - const NATIVE_PLUGIN_ID = 'imengyu-UniAndroidGyro-GyroModule' - const POLL_INTERVAL_MS = 33 - const RESTART_DELAY_MS = 600 - const STARTGYRO_MAX_RETRIES = 3 - const STARTGYRO_BASE_RETRY_MS = 400 - - let gyroModule = null - let gyroPollGen = 0 - let pollTimer = null - let startTimer = null - - function tryRequireGyroModule() { - try { - if (typeof uni !== 'undefined' && typeof uni.requireNativePlugin === 'function') { - const mod = uni.requireNativePlugin(NATIVE_PLUGIN_ID) - if (mod && typeof mod.startGyro === 'function' && typeof mod.stopGyro === 'function') { - return mod - } - } - } catch (e) { - console.warn('[useLenticularStudioTilt] requireNativePlugin failed', e) - } - return null - } - - function stopPoll() { - if (pollTimer != null) { - try { clearInterval(pollTimer) } catch (_) {} - pollTimer = null - } - if (startTimer != null) { - try { clearTimeout(startTimer) } catch (_) {} - startTimer = null - } - } - - function handleGyroValue(res) { - const rawX = Number(res.y || 0) // roll → dx(左右) - const rawY = Number(res.x || 0) // pitch → dy(前后) - - if (warmupSkip < SKIP_WARMUP_FRAMES) { - warmupSkip++ - return - } - - const { cx, cy } = clampJump(rawX, rawY) - updateFast(cx, cy) - updateSlow(cx, cy) - - const dx = fastX - slowX - const dy = fastY - slowY - - applyDelta(dx, dy) - } - - function pollOnce(myGen) { - if (myGen !== gyroPollGen || !gyroModule) return - try { - gyroModule.getGyroValue((res) => { - if (myGen !== gyroPollGen || !gyroModule || !res) return - gyroSourceLabel.value = 'gyroscope' - handleGyroValue(res) - }) - } catch (_) {} - } - - function tryStartGyro(myGen, retry) { - if (myGen !== gyroPollGen || !gyroModule) return - - if (retry > 0) { - console.log('[useLenticularStudioTilt] startGyro retry #', retry) - } - - gyroModule.startGyro({ interval: 'ui' }, (res) => { - if (myGen !== gyroPollGen || !gyroModule) return - - if (!res || !res.success) { - const errMsg = (res && res.errMsg) || '' - if (retry < STARTGYRO_MAX_RETRIES && /running/i.test(errMsg)) { - const delay = STARTGYRO_BASE_RETRY_MS * (retry + 1) - console.warn('[useLenticularStudioTilt] startGyro busy, retry in', delay, 'ms') - startTimer = setTimeout(() => { - startTimer = null - tryStartGyro(myGen, retry + 1) - }, delay) - return - } - console.warn('[useLenticularStudioTilt] startGyro failed:', res) - onNativeFallback() - return - } - - console.log('[useLenticularStudioTilt] plugin gyro ok, polling') - gyroSourceLabel.value = 'gyroscope' - warmupSkip = 0 - pollOnce(myGen) - pollTimer = setInterval(() => pollOnce(myGen), POLL_INTERVAL_MS) - }) - } - - function stopGyroPlugin() { - gyroPollGen++ - stopPoll() - if (gyroModule && typeof gyroModule.stopGyro === 'function') { - try { gyroModule.stopGyro(() => {}) } catch (_) {} - } - gyroModule = null - } - - // ================================================================ - // 原生传感器 (iOS / Android 备用) - // ================================================================ - - /** - * @param {(rollDeg: number, pitchDeg: number) => void} onFrame - * @returns {{ cleanup: () => void } | null} - */ - function createAndroidTiltSensor(onFrame) { - try { - if (typeof plus === 'undefined' || !plus.android) { - console.warn('[useLenticularStudioTilt] plus.android not available') - return null - } - - const Context = plus.android.importClass('android.content.Context') - const SensorManager = plus.android.importClass('android.hardware.SensorManager') - const Sensor = plus.android.importClass('android.hardware.Sensor') - - const activity = plus.android.runtimeMainActivity() - const sm = activity.getSystemService(Context.SENSOR_SERVICE) - - // 先检查陀螺仪硬件是否存在(作为诊断日志,实际用加速度计) - const gyroSensor = sm.getDefaultSensor(Sensor.TYPE_GYROSCOPE) - if (!gyroSensor) { - console.warn('[useLenticularStudioTilt] device has no gyroscope hardware — ' + - 'using accelerometer gravity fallback') - } else { - console.log('[useLenticularStudioTilt] gyroscope hardware detected; using accelerometer gravity for absolute tilt') - } - - // 检查加速度计 - const gravitySensor = sm.getDefaultSensor(Sensor.TYPE_GRAVITY) - const accelSensorRaw = sm.getDefaultSensor(Sensor.TYPE_ACCELEROMETER) - console.log('[useLenticularStudioTilt] Android sensor support: ' + - 'gyro=' + (gyroSensor ? 'YES' : 'NO') + ', ' + - 'gravity=' + (gravitySensor ? 'YES' : 'NO') + ', ' + - 'accel=' + (accelSensorRaw ? 'YES' : 'NO')) - - // TYPE_GRAVITY 直接输出重力分量,优先使用;不支持则用 TYPE_ACCELEROMETER + LPF - let accelSensor = gravitySensor - const useGravitySensor = !!accelSensor - if (!accelSensor) { - accelSensor = accelSensorRaw - console.log('[useLenticularStudioTilt] TYPE_GRAVITY unavailable, using TYPE_ACCELEROMETER + LPF') - } else { - console.log('[useLenticularStudioTilt] using TYPE_GRAVITY sensor (no LPF needed)') - } - if (!accelSensor) { - console.warn('[useLenticularStudioTilt] no accelerometer available — tilt NOT supported on this device') - return null - } - - // 低通滤波状态(仅 TYPE_ACCELEROMETER 需要) - const LPF_ALPHA = 0.85 - let gx = 0, gy = 0, gz = 0 - let gravityReady = useGravitySensor // TYPE_GRAVITY 不需要 LPF - - let sensorCallbackCount = 0 - let ArrayReflect = null - - const listener = plus.android.implements('android.hardware.SensorEventListener', { - onSensorChanged: function(event) { - sensorCallbackCount++ - try { - // ——— 诊断:探明 event 对象的实际结构 ——— - if (sensorCallbackCount <= 3) { - // 枚举 event 上的所有 key - let keys = [] - try { - for (let k in event) { - try { keys.push(k + ':' + typeof event[k]) } catch (_) { keys.push(k + ':err') } - } - } catch (_) {} - console.log('[useLenticularStudioTilt] onSensorChanged #' + sensorCallbackCount + - ' typeof=' + typeof event + - ' keys=[' + (keys.length ? keys.join(', ') : '(none)') + ']') - - // 尝试 plusGetAttribute 方式(项目既有模式) - try { - const v = event.plusGetAttribute('values') - console.log('[useLenticularStudioTilt] plusGetAttribute("values"):', typeof v, v) - } catch (e1) { - console.log('[useLenticularStudioTilt] plusGetAttribute("values") threw:', e1) - } - - // 尝试 plus.android.getAttribute - try { - const v = plus.android.getAttribute(event, 'values') - console.log('[useLenticularStudioTilt] getAttribute(event,"values"):', typeof v, v) - } catch (e2) { - console.log('[useLenticularStudioTilt] getAttribute(event,"values") threw:', e2) - } - } - - // ——— 获取 float[] 数据 ——— - // 已验证: - // ✅ plusGetAttribute('values') → Java float[] - // ✅ Array.get(arr, i) → Java Float 对象(之前 r0 type=object 是成功的) - // ❌ Array.getFloat(arr, i) → null - // ❌ Number(Float) → 0 - // ❌ Float.floatValue 不是 JS function - // 取 float[] 数据 — 内联调用,不经过函数传参(Native.js 传参丢引用) - let v0, v1, v2 - - let rawValues = null - try { rawValues = event.plusGetAttribute('values') } catch (_) {} - if (!rawValues) { - try { rawValues = plus.android.getAttribute(event, 'values') } catch (_) {} - } - if (!rawValues) return - - if (!ArrayReflect || typeof ArrayReflect.get !== "function") { - try { - ArrayReflect = plus.android.importClass("java.lang.reflect.Array") - } catch (_) {} - } - - // 内联 Array.get()(已验证可用),拿 Float → invoke floatValue 拆箱 - if (sensorCallbackCount <= 2) { console.log('[useLenticularStudioTilt] pre-get: ArrayReflect=' + typeof ArrayReflect + ' hasGet=' + (ArrayReflect && typeof ArrayReflect.get === 'function') + ' rv=' + typeof rawValues + ' rvNull=' + (rawValues == null)) } - const f0 = ArrayReflect ? ArrayReflect.get(rawValues, 0) : null - const f1 = ArrayReflect ? ArrayReflect.get(rawValues, 1) : null - const f2 = ArrayReflect ? ArrayReflect.get(rawValues, 2) : null - - if (sensorCallbackCount <= 2) { - console.log('[useLenticularStudioTilt] Array.get results: f0=' + typeof f0 + - ' null=' + (f0 == null) + ' f1 null=' + (f1 == null) + ' f2 null=' + (f2 == null)) - } - - function unboxFloat(f) { - if (f == null) return NaN - try { - const v = plus.android.invoke(f, 'floatValue') - if (typeof v === 'number') return v - } catch (_) {} - try { - const s = plus.android.invoke(f, 'toString') - if (s) return parseFloat(String(s)) - } catch (_) {} - return NaN - } - - v0 = unboxFloat(f0) - v1 = unboxFloat(f1) - v2 = unboxFloat(f2) - - if (typeof v0 !== 'number' || isNaN(v0)) { - if (sensorCallbackCount <= 3) { - console.warn('[useLenticularStudioTilt] onSensorChanged: failed to read values #' + sensorCallbackCount) - } - return - } - - // 第一次拿到值时打印 - if (sensorCallbackCount <= 5) { - console.log('[useLenticularStudioTilt] sensor #' + sensorCallbackCount + - ': [' + v0.toFixed(3) + ', ' + v1.toFixed(3) + ', ' + v2.toFixed(3) + ']') - } - - let rx, ry, rz - - if (useGravitySensor) { - // TYPE_GRAVITY 直接输出重力分量 - rx = v0 - ry = v1 - rz = v2 - } else { - // TYPE_ACCELEROMETER:低通滤波提取重力分量 - gx = LPF_ALPHA * gx + (1 - LPF_ALPHA) * v0 - gy = LPF_ALPHA * gy + (1 - LPF_ALPHA) * v1 - gz = LPF_ALPHA * gz + (1 - LPF_ALPHA) * v2 - - if (!gravityReady) { - // 等重力滤波器收敛后再输出 - const mag = Math.sqrt(gx * gx + gy * gy + gz * gz) - if (mag > 8 && mag < 12) gravityReady = true - return - } - rx = gx; ry = gy; rz = gz - } - - // 重力方向 → roll/pitch(度),坐标系对齐 DeviceOrientation - // roll: 绕 z 轴旋转(左右倾斜) - // pitch: 绕 x 轴旋转(前后倾斜) - const roll = Math.atan2(rx, rz) * (180 / Math.PI) - const pitch = Math.atan2(ry, Math.sqrt(rx * rx + rz * rz)) * (180 / Math.PI) - - onFrame(roll, pitch) - } catch (e) { - if (sensorCallbackCount <= 3) { - console.warn('[useLenticularStudioTilt] onSensorChanged error:', e, JSON.stringify(e)) - } - } - }, - onAccuracyChanged: function(sensor, accuracy) {} - }) - - // SENSOR_DELAY_GAME = 20,000μs (50Hz) — 响应快,够跟手 - sm.registerListener(listener, accelSensor, SensorManager.SENSOR_DELAY_GAME) - - return { - cleanup: function() { - sm.unregisterListener(listener) - } - } - } catch (e) { - console.warn('[useLenticularStudioTilt] Android tilt sensor init failed:', e) - return null - } - } - - // ==================================================================== - // 路径 A-2: Native.js iOS - // CMMotionManager.deviceMotion.attitude → roll/pitch(度) - // 系统级传感器融合,无漂移,比手动积分陀螺仪可靠得多 - // ==================================================================== - - /** - * @param {(rollDeg: number, pitchDeg: number) => void} onFrame - * @returns {{ cleanup: () => void } | null} - */ - function createIOSTiltSensor(onFrame) { - try { - if (typeof plus === 'undefined' || !plus.ios) { - console.warn('[useLenticularStudioTilt] plus.ios not available') - return null - } - - const CMMotionManager = plus.ios.importClass('CMMotionManager') - const NSOperationQueue = plus.ios.importClass('NSOperationQueue') - - const mm = CMMotionManager.alloc().init() - - if (!mm || !mm.isDeviceMotionAvailable || !mm.isDeviceMotionAvailable()) { - console.warn('[useLenticularStudioTilt] CMMotionManager deviceMotion NOT available — tilt NOT supported on this device') - if (mm) { mm.release() } - return null - } - - // 检查各传感器硬件可用性(诊断用) - const gyroOk = mm.isGyroAvailable && mm.isGyroAvailable() - const accelOk = mm.isAccelerometerAvailable && mm.isAccelerometerAvailable() - const magnetoOk = mm.isMagnetometerAvailable && mm.isMagnetometerAvailable() - console.log('[useLenticularStudioTilt] iOS sensor support: ' + - 'deviceMotion=YES, ' + - 'gyro=' + (gyroOk ? 'YES' : 'NO') + ', ' + - 'accel=' + (accelOk ? 'YES' : 'NO') + ', ' + - 'magnetometer=' + (magnetoOk ? 'YES' : 'NO')) - if (!gyroOk) { - console.warn('[useLenticularStudioTilt] iOS gyro unavailable; deviceMotion will fall back to accel+compass fusion') - } - - // 30Hz 采样(适合倾斜交互,低于游戏需求但省电) - mm.setDeviceMotionUpdateInterval(1.0 / 30.0) - - const queue = NSOperationQueue.mainQueue() - - // 用 block 接收 motion 数据 - // CMDeviceMotionHandler = void (^)(CMDeviceMotion * _Nullable motion, NSError * _Nullable error) - const handler = plus.ios.implements('CMDeviceMotionHandler', { - invoke: function(motion, error) { - try { - if (!motion || error) return - const attitude = motion.attitude() - if (!attitude) return - // attitude.roll / pitch 是弧度,转度 - const roll = attitude.roll() * (180 / Math.PI) - const pitch = attitude.pitch() * (180 / Math.PI) - onFrame(roll, pitch) - } catch (_) {} - } - }) - - mm.startDeviceMotionUpdatesToQueueWithHandler(queue, handler) - - return { - cleanup: function() { - mm.stopDeviceMotionUpdates() - mm.release() - } - } - } catch (e) { - console.warn('[useLenticularStudioTilt] iOS tilt sensor init failed:', e) - return null - } - } - - /** - * 等待 plus 环境就绪后执行回调。 - * 在 Vue mounted 阶段调用时 plus 通常已就绪,但加一层防护。 - * @param {(ready: boolean) => void} cb - */ - function onPlusReady(cb) { - try { - if (typeof plus !== 'undefined') { - cb(true) - return - } - // 尚未就绪:挂载 plusready 事件 - function handler() { - document.removeEventListener('plusready', handler) - cb(true) - } - document.addEventListener('plusready', handler, false) - } catch (_) { - cb(false) - } - } - - function startNativeApp(myGen) { - onPlusReady(function(ready) { - if (!ready) { - console.warn('[useLenticularStudioTilt] plus not ready') - onNativeFallback() - return - } - - // Android:uni.onAccelerometerChange(uni 原生桥接,不走 WebView 事件, - // 也不走 Native.js float[] 读取,uni 内部处理数据转换,直接返 JS number)。 - if (plus.os && plus.os.name === 'Android') { - uni.startAccelerometer({ interval: 'game' }) - let accelWarmup = 0 - // ——— Android-accel 路径专用:One-Euro Filter(速度自适应低通)——— - // 必须在闭包内新建,不能放在 handleNativeTiltFrame - // (handleNativeTiltFrame 被 iOS CMMotionManager 共用) - const oneEuroRoll = createOneEuroFilter({ mincutoff: 0.2, beta: 0.05, dcutoff: 1.0 }) - const oneEuroPitch = createOneEuroFilter({ mincutoff: 0.2, beta: 0.05, dcutoff: 1.0 }) - - let accelCb = function(res) { - if (myGen !== tiltGen) return - // 跳过前几帧初始化噪声 - if (accelWarmup < SKIP_WARMUP_FRAMES) { accelWarmup++; return } - - const rollRaw = Math.atan2(res.x, res.z) * (180 / Math.PI) - const pitchRaw = Math.atan2(res.y, Math.sqrt(res.x*res.x + res.z*res.z)) * (180 / Math.PI) - - // One-Euro 平滑(解决静止抖动 + 减小动态延迟) - const t = nowMs() - const roll = oneEuroRoll.filter(rollRaw, t) - const pitch = oneEuroPitch.filter(pitchRaw, t) - - gyroSourceLabel.value = 'accelerometer' - handleNativeTiltFrame(roll, pitch) - } - uni.onAccelerometerChange(accelCb) - nativeCleanup = function() { - uni.offAccelerometerChange(accelCb) - try { uni.stopAccelerometer() } catch (_) {} - } - console.log('[useLenticularStudioTilt] uni.onAccelerometerChange started with One-Euro Filter') - return - } - - // iOS:CMMotionManager.attitude - if (plus.os && plus.os.name === 'iOS') { - const result = createIOSTiltSensor(function(roll, pitch) { - if (myGen !== tiltGen) return - gyroSourceLabel.value = 'native-ios' - handleNativeTiltFrame(roll, pitch) - }) - if (result) { - nativeCleanup = result.cleanup - console.log('[useLenticularStudioTilt] Native.js iOS tilt started') - return - } - } - - // Native.js 不可用 → 降级 - console.warn('[useLenticularStudioTilt] Native.js tilt init failed for ' + - (plus.os ? plus.os.name : 'unknown') + ' — falling back') - onNativeFallback() - }) - } - - function stopNativeApp() { - stopGyroPlugin() - if (nativeStartTimer != null) { - try { clearTimeout(nativeStartTimer) } catch (_) {} - nativeStartTimer = null - } - if (nativeCleanup) { - try { nativeCleanup() } catch (_) {} - nativeCleanup = null - } - } - - // #endif - - /** - * Native.js 路径降级回调:转到 DeviceOrientation 或模拟 - */ - function onNativeFallback() { - // #ifdef APP-PLUS - nativeCleanup = null - // #endif - - // 尝试 DeviceOrientationEvent - console.log('[useLenticularStudioTilt] Native.js unavailable, trying DeviceOrientationEvent...') - if (startOrientation()) return - - // 全部不可用 → 模拟 - console.warn('[useLenticularStudioTilt] all tilt paths exhausted — falling back to simulation') - gyroSourceLabel.value = 'simulation' - simulate(0, 0) - if (typeof onTiltDriverFallback === 'function') { - try { onTiltDriverFallback() } catch (_) {} - } - } - - // ==================================================================== - // 路径 B: DeviceOrientationEvent (H5 / iOS App WKWebView 降级) - // ==================================================================== - - function handleDeviceOrientation(e) { - if (e.gamma == null) return - - // gamma: 左右倾斜(roll),beta: 前后倾斜(pitch) - const rawX = e.gamma || 0 // roll → dx(左右) - const rawY = e.beta || 0 // pitch → dy(前后) - - if (warmupSkip < SKIP_WARMUP_FRAMES) { - warmupSkip++ - return - } - - const { cx, cy } = clampJump(rawX, rawY) - updateFast(cx, cy) - updateSlow(cx, cy) - - const dx = fastX - slowX - const dy = fastY - slowY - - applyDelta(dx, dy) - } - - function startOrientationListener() { - gyroSourceLabel.value = 'deviceorientation' - warmupSkip = 0 - orientationHandler = handleDeviceOrientation - window.addEventListener('deviceorientation', orientationHandler, true) - console.log('[useLenticularStudioTilt] deviceorientation listener started') - } - - function stopOrientationListener() { - if (orientationHandler) { - window.removeEventListener('deviceorientation', orientationHandler, true) - orientationHandler = null - } - } - - /** - * 尝试启动 DeviceOrientationEvent。 - * @returns {boolean} true=已启动或正在请求权限;false=不可用 - */ - function startOrientation() { - if (typeof DeviceOrientationEvent === 'undefined') { - console.warn('[useLenticularStudioTilt] DeviceOrientationEvent not supported') - return false - } - - // iOS 13+ Safari 需要用户手势触发 requestPermission - if (typeof DeviceOrientationEvent.requestPermission === 'function') { - gyroSourceLabel.value = 'deviceorientation-requesting' - DeviceOrientationEvent.requestPermission() - .then(state => { - if (state === 'granted') { - startOrientationListener() - } else { - console.warn('[useLenticularStudioTilt] DeviceOrientation permission denied') - gyroSourceLabel.value = 'simulation' - simulate(0, 0) - if (typeof onTiltDriverFallback === 'function') { - try { onTiltDriverFallback() } catch (_) {} - } - } - }) - .catch(err => { - console.warn('[useLenticularStudioTilt] DeviceOrientation permission error:', err) - gyroSourceLabel.value = 'simulation' - simulate(0, 0) - if (typeof onTiltDriverFallback === 'function') { - try { onTiltDriverFallback() } catch (_) {} - } - }) - return true - } - - // Android / 旧 iOS:直接启动 listener - startOrientationListener() - return true - } - - // ==================================================================== - // 公开 API - // ==================================================================== - - /** - * 启动传感器。优先级: - * 1. Native.js (App Android/iOS) — 直接桥接原生 API,最可靠 - * 2. DeviceOrientationEvent (H5 / iOS App WKWebView) - * 3. 降级模拟 - */ - function start() { - console.log('[useLenticularStudioTilt] start()') - stop() - tiltGen++ - resetState() - lastLogSecond = 0 - - // #ifdef APP-PLUS - // App 端:优先 Native.js - startNativeApp(tiltGen) - return - // #endif - - // #ifndef APP-PLUS - // H5 / 小程序:尝试 DeviceOrientationEvent - if (startOrientation()) return - - // 全部不可用 → 模拟 - gyroSourceLabel.value = 'simulation' - simulate(0, 0) - if (typeof onTiltDriverFallback === 'function') { - try { onTiltDriverFallback() } catch (_) {} - } - // #endif - } - - function stop() { - console.log('[useLenticularStudioTilt] stop()') - tiltGen++ - - // #ifdef APP-PLUS - stopNativeApp() - // #endif - - stopOrientationListener() - - gyroSourceLabel.value = 'simulation' - resetState() - } - - function recalibrate() { - resetState() - simulate(0, 0) - } - - return { start, stop, recalibrate } -} diff --git a/frontend/composables/useOneEuroFilter.js b/frontend/composables/useOneEuroFilter.js deleted file mode 100644 index 943eb56..0000000 --- a/frontend/composables/useOneEuroFilter.js +++ /dev/null @@ -1,75 +0,0 @@ -/** - * One-Euro Filter(速度自适应低通滤波器) - * 参考:Casiez et al., "1€ Filter: A Simple Speed-based Low-pass Filter for Noisy Input in Interactive Systems", CHI 2012 - * https://cristal.univ-lille.fr/~casiez/1euro/ - * - * 用法:每个轴一个实例 - * const f = createOneEuroFilter({ mincutoff: 0.2, beta: 0.05, dcutoff: 1.0 }) - * const out = f.filter(x, performance.now()) - * f.reset() - * - * @param {object} [opts] - * @param {number} [opts.mincutoff=0.2] - Hz,静止截止频率 - * @param {number} [opts.beta=0.05] - 速度系数(越大快速运动越跟手) - * @param {number} [opts.dcutoff=1.0] - Hz,速度估计的截止频率 - */ -export function createOneEuroFilter({ mincutoff = 0.2, beta = 0.05, dcutoff = 1.0 } = {}) { - let xPrev = null // 上次 FILTERED 输出(与 Casiez 论文一致) - let dxPrev = 0 // 平滑后的速度 - let tPrev = null // 上次时间戳 - - function smoothingFactor(cutoff, dt) { - const r = 2 * Math.PI * cutoff * dt - return r / (r + 1) - } - - function filter(x, t) { - // 首帧:直接接受,建立基线 - if (xPrev == null || tPrev == null) { - xPrev = x - dxPrev = 0 - tPrev = t - return x - } - - const dt = Math.max((t - tPrev) / 1000, 1e-6) // ms → s - - // 1. 速度差分(用 filtered xPrev,与 Casiez 一致) - const dx = (x - xPrev) / dt - - // 2. 平滑速度 - const aD = smoothingFactor(dcutoff, dt) - const edx = aD * dx + (1 - aD) * dxPrev - - // 3. 自适应截止频率 - const cutoff = mincutoff + beta * Math.abs(edx) - - // 4. 平滑位置 - const a = smoothingFactor(cutoff, dt) - const result = a * x + (1 - a) * xPrev - - xPrev = result - dxPrev = edx - tPrev = t - return result - } - - function reset() { - xPrev = null - dxPrev = 0 - tPrev = null - } - - return { filter, reset } -} - -/** - * 取单调递增的高精度时间戳(毫秒)。 - * 优先 performance.now()(sub-ms 精度),回退 Date.now()(WebView 上 ~15ms 精度)。 - */ -export function nowMs() { - if (typeof performance !== 'undefined' && typeof performance.now === 'function') { - return performance.now() - } - return Date.now() -} \ No newline at end of file diff --git a/frontend/pages/asset-detail/asset-detail.vue b/frontend/pages/asset-detail/asset-detail.vue index 07a25e7..d5c7d6c 100644 --- a/frontend/pages/asset-detail/asset-detail.vue +++ b/frontend/pages/asset-detail/asset-detail.vue @@ -47,7 +47,7 @@ @@ -274,9 +274,6 @@ const lenticularLayers = ref([]); const { layerTransforms, simulate, - gyroSourceLabel, - scheduleTiltStart, - stopTiltPreview, } = useLenticularCraftTiltPreview(lenticularLayers); const isLenticularAsset = computed(() => { @@ -646,7 +643,6 @@ const loadData = async () => { ? buildLenticularLayersTwo(bgUrl, subjectUrl) : buildLenticularLayers(subjectUrl) isLenticularDetail.value = true - scheduleTiltStart() } }).catch(err => { console.error('加载封面图片失败:', err); @@ -750,7 +746,6 @@ const loadCraftConfirm = () => { if (raw) { const p = typeof raw === 'string' ? JSON.parse(raw) : raw; lenticularLayers.value = buildLenticularLayersTwo(p.bgPath || '', p.subjectPath || ''); - scheduleTiltStart(); } } } catch (e) { @@ -871,12 +866,6 @@ onShow(() => { }); onHide(() => { - if (craftConfirmMode.value && isCraftLenticular.value) { - stopTiltPreview(); - } - if (isLenticularDetail.value) { - stopTiltPreview() - } stopLikePolling(); }); @@ -888,7 +877,6 @@ onUnload(() => { onUnmounted(() => { if (countdownTimer) clearInterval(countdownTimer); - stopTiltPreview(); stopLikePolling(); uni.$off('assetLikeChanged', handleAssetLikeChangedExternal); }); diff --git a/frontend/pages/castlove/success.vue b/frontend/pages/castlove/success.vue index 646b3e0..d8d4d49 100644 --- a/frontend/pages/castlove/success.vue +++ b/frontend/pages/castlove/success.vue @@ -27,7 +27,7 @@ @@ -107,9 +107,6 @@ const lenticularLayers = ref([]) const { layerTransforms, simulate, - gyroSourceLabel, - scheduleTiltStart, - stopTiltPreview, } = useLenticularCraftTiltPreview(lenticularLayers); // NftCard 样式 @@ -190,7 +187,6 @@ onMounted(async () => { if ((asset.is_lenticular || data.is_lenticular) && bgImageUrl && imageUrl) { isLenticular.value = true lenticularLayers.value = buildLenticularLayersTwo(bgImageUrl, imageUrl) - scheduleTiltStart() } } else { // API 返回异常,使用本地数据 @@ -239,7 +235,6 @@ function applyLocalNftData(data) { if (data.is_lenticular && data.image && data.bg_image) { isLenticular.value = true lenticularLayers.value = buildLenticularLayersTwo(data.bg_image, data.image) - scheduleTiltStart() } } @@ -272,7 +267,6 @@ const handleViewDetails = () => { }; onUnload(() => { - stopTiltPreview() try { uni.removeStorageSync('temp_nft_data'); } catch (e) { diff --git a/frontend/pages/discover/generation-result.vue b/frontend/pages/discover/generation-result.vue index d73577b..22fe879 100644 --- a/frontend/pages/discover/generation-result.vue +++ b/frontend/pages/discover/generation-result.vue @@ -33,8 +33,7 @@ class="lenticular-preview" :layers="lenticularLayers" :transforms="layerTransforms" - :gyro-source="gyroSourceLabel" - :skip-built-in-touch="true" + gyro-source="simulation" tilt-hint-text="晃动查看" :shimmer-mid-opacity="0.16" @simulate="simulate" @@ -91,8 +90,7 @@