From e9498350fdc70e7d8d37b5c3b8145550a4c5856b Mon Sep 17 00:00:00 2001 From: Ackii Date: Sat, 19 Sep 2026 16:19:49 +0800 Subject: [PATCH 1/7] =?UTF-8?q?perf(web):=20=E5=A4=A7=E5=B1=8F=E4=B8=BB?= =?UTF-8?q?=E7=BA=BF=E7=A8=8B=E8=B4=9F=E8=BD=BD=E5=86=8D=E9=99=8D=E7=BA=A6?= =?UTF-8?q?=E4=B8=80=E5=8D=8A=E2=80=94=E2=80=94=E9=BD=BF=E5=AD=94=E6=94=B9?= =?UTF-8?q?=E7=94=BB=E5=B8=83=E3=80=81=E5=8D=A1=E7=89=87=E4=B8=A4=E7=BA=A7?= =?UTF-8?q?=E5=8F=98=E6=8D=A2=E3=80=81iframe=20=E6=A7=BD=E4=BD=8D=E6=B1=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 线上反馈「电脑端大屏功耗很大、所有设备打开都卡;01/05 样式卡内有一块区域闪烁」。 实测定位到三处,逐个改掉: - 齿孔每帧拼 SVG 字符串再 innerHTML 覆盖(约 900 个节点重建)→ 改画布重绘。 A/B 实测这一项独占约 24 个百分点(整体 40.8% → 关掉齿孔 9.5%) - 卡片每帧写整条 matrix3d,导致每张卡逐帧重新栅格化(闪烁与功耗同源)→ 拆两层: 外层每帧只写 translate3d(合成器平移,不重栅格化),内层形变仅在局部缩放变化 超过 0.5% 时重写。实测 10 秒内层重写 109 次(改前约 9250 次) - 没有截图的卡全部走 iframe(线上 27 个同时活着,每个生成页自己的 JS/动画都在跑) → 新增模块级槽位池 lib/frameSlots.ts,上限 16,按优先级抢占 + 冷却; 离开预载区不再立刻卸载。实测 30 秒零重载、存活数恒定,主线程开销与不限档持平 顺带修掉两个既有问题: - 「减少动效」环境下相位恒不变,整帧被永久跳过,而卡片元素是 Vue 补丁后才交给 渲染循环的 → 新卡拿不到 transform,大屏整墙空白(现加脏标记强制补渲染一帧) - 移除卡片内图片的 loading="lazy":3D 变换层里的惰性加载会与变换互相干扰 主线程(32 张卡):有截图路径 33.7% → 约 17%;无截图路径 16 个 iframe ≈ 18–20%。 画面与改前逐帧一致(页面零变化)。 --- apps/web/src/components/FilmCard.vue | 8 - apps/web/src/components/FilmWallDesktop.vue | 225 +++++++++++++++++--- apps/web/src/components/LazyFrame.vue | 47 +++- apps/web/src/lib/frameSlots.ts | 148 +++++++++++++ 4 files changed, 377 insertions(+), 51 deletions(-) create mode 100644 apps/web/src/lib/frameSlots.ts diff --git a/apps/web/src/components/FilmCard.vue b/apps/web/src/components/FilmCard.vue index 1f492f7..c174d2e 100644 --- a/apps/web/src/components/FilmCard.vue +++ b/apps/web/src/components/FilmCard.vue @@ -112,7 +112,6 @@ const emBase = (props.w / 146) * 12.5; v-if="frameImg" :src="frameImg" class="h-full w-full object-cover object-top" - loading="lazy" decoding="async" alt="" /> @@ -151,7 +150,6 @@ const emBase = (props.w / 146) * 12.5; v-if="frameImg" :src="frameImg" class="h-full w-full object-cover object-top" - loading="lazy" decoding="async" alt="" /> @@ -303,7 +301,6 @@ const emBase = (props.w / 146) * 12.5; v-if="frameImg" :src="frameImg" class="h-full w-full object-cover object-top" - loading="lazy" decoding="async" alt="" /> @@ -370,7 +367,6 @@ const emBase = (props.w / 146) * 12.5; v-if="frameImg" :src="frameImg" class="h-full w-full object-cover object-top" - loading="lazy" decoding="async" alt="" /> @@ -438,7 +434,6 @@ const emBase = (props.w / 146) * 12.5; v-if="frameImg" :src="frameImg" class="h-full w-full object-cover object-top" - loading="lazy" decoding="async" alt="" /> @@ -510,7 +505,6 @@ const emBase = (props.w / 146) * 12.5; v-if="frameImg" :src="frameImg" class="h-full w-full object-cover object-top" - loading="lazy" decoding="async" alt="" /> @@ -625,7 +619,6 @@ const emBase = (props.w / 146) * 12.5; v-if="frameImg" :src="frameImg" class="h-full w-full object-cover object-top" - loading="lazy" decoding="async" alt="" /> @@ -671,7 +664,6 @@ const emBase = (props.w / 146) * 12.5; v-if="frameImg" :src="frameImg" class="h-full w-full object-cover object-top" - loading="lazy" decoding="async" alt="" /> diff --git a/apps/web/src/components/FilmWallDesktop.vue b/apps/web/src/components/FilmWallDesktop.vue index a38be7b..ebea9ee 100644 --- a/apps/web/src/components/FilmWallDesktop.vue +++ b/apps/web/src/components/FilmWallDesktop.vue @@ -5,8 +5,11 @@ import { t } from "@/i18n"; import type { WallItem } from "@/lib/wall"; import { A0, + HW, + PERF, SPEED, STRIDE, + Y, RIBBON_A, RIBBON_B, atLs, @@ -15,7 +18,6 @@ import { footDesk, ghostMarkup, headDesk, - holesAt, homography, lRange, matrix3d, @@ -26,8 +28,13 @@ import { /** * B 方案桌面大屏:双胶卷透视滚墙。 * 上带左近右远(卡右→左走),下带左远右近(卡左→右走)——每条带朝 - * 自己的近端走,两条带互为反向。带体/齿孔走 SVG;卡片是 HTML, - * 用单应(matrix3d)贴到带面上,rAF 每帧只更新 transform 与齿孔。 + * 自己的近端走,两条带互为反向。带体走 SVG;齿孔走画布; + * 卡片是 HTML,两级变换贴到带面上(外层每帧只改位移、内层形变低频更新)。 + * + * 性能约束(2026-09-19 上线后实测):主线程开销必须压低—— + * 齿孔每帧重建 SVG 字符串要吃掉约 24% 单核,已改画布重绘; + * 卡片逐帧写整条 matrix3d 会让每层重新栅格化(掉帧/闪烁), + * 已拆成「外层位移 + 内层形变」两级。 */ const props = defineProps<{ items: WallItem[]; demoCount: number }>(); @@ -60,16 +67,44 @@ const bandASvg = bandA.body; const wrap = ref(null); const stage = ref(null); -const holesA = ref(null); -const holesB = ref(null); +const holesA = ref(null); +const holesB = ref(null); const slotsA = ref([]); const slotsB = ref([]); -const elsA = new Map(); -const elsB = new Map(); -function bindEl(map: Map, n: number, el: unknown) { - if (el) map.set(n, el as HTMLElement); +/** 卡片两级结构:outer 每帧改位移;inner 只在形变超阈值时重写 */ +interface El { + outer: HTMLElement; + inner: HTMLElement | null; + /** 上次写入内层时的局部缩放,用于形变阈值判定 */ + sc?: number; +} +const elsA = new Map(); +const elsB = new Map(); + +function slot(map: Map, n: number): El { + const cur = map.get(n); + if (cur) return cur; + const rec: El = { outer: null as unknown as HTMLElement, inner: null }; + map.set(n, rec); + return rec; +} +/** + * 脏标记:Vue 补丁后才把真实元素交给渲染循环(bindOuter/bindInner), + * 此时相位可能没变——必须强制补渲染一帧,否则新卡的 transform 永远写不上。 + * 「减少动效」下相位恒定,不补这一帧就是整墙空白(2026-09-19 实测复现)。 + */ +let dirty = false; +function bindOuter(map: Map, n: number, el: unknown) { + if (el) slot(map, n).outer = el as HTMLElement; else map.delete(n); + dirty = true; +} +function bindInner(map: Map, n: number, el: unknown) { + const rec = map.get(n); + if (rec && el) rec.inner = el as HTMLElement; + else if (rec) rec.inner = null; + dirty = true; } const itemAt = (n: number): WallItem | undefined => { @@ -108,6 +143,87 @@ const FRAME_MS = 33; let lastRenderAt = 0; /** 上次渲染的相位:未变化则跳过整帧(悬停/减少动效时白算归零) */ let renderedPhase = -1; +/** 画布像素比(DPR × 舞台缩放,封顶 2),保齿孔清晰又不过度分配 */ +const hpx = ref(1); + +/** 针孔:把 homography 平移到以卡片中心为原点(内层用) */ +function centered(h: number[], cx: number, cy: number): number[] { + return [ + h[0] - cx * h[6], + h[1] - cx * h[7], + h[2] - cx, + h[3] - cy * h[6], + h[4] - cy * h[7], + h[5] - cy, + h[6], + h[7], + ]; +} + +/** + * 形变是否需要重算:局部缩放相对变化 > 0.5% 才重写内层 matrix3d。 + * 卡片位移(外层)是每帧精确的,形变滞后<1px,肉眼不可辨; + * 换来的是内层图层不逐帧重新栅格化。 + */ +function needsReshape(rec: El, h: number[]): boolean { + const sc = Math.hypot(h[0], h[3]); + if (rec.sc === undefined) { + rec.sc = sc; + return true; + } + if (Math.abs(sc - rec.sc) / (rec.sc || 1) < 0.005) return false; + rec.sc = sc; + return true; +} + +let ctxA: CanvasRenderingContext2D | null = null; +let ctxB: CanvasRenderingContext2D | null = null; + +/** + * 齿孔:整块画布重绘。 + * 旧实现每帧拼 SVG 字符串再 innerHTML 覆盖(约 900 个节点重建), + * 实测占单核约 24%——画布只做「清屏 + 画小方块」,无 DOM、无样式重算。 + */ +function drawHoles( + cv: HTMLCanvasElement | null, + ctx: CanvasRenderingContext2D | null, + band: Band, + phase: number, +) { + if (!cv || !ctx) return; + const k = hpx.value; + ctx.setTransform(1, 0, 0, 1, 0, 0); + ctx.clearRect(0, 0, cv.width, cv.height); + ctx.fillStyle = Y; + const P0 = PERF * 0.58; + const rounded = typeof ctx.roundRect === "function"; + for ( + let n = Math.ceil((26 - P0 - phase) / PERF); + P0 + n * PERF + phase <= band.Lmax - 26; + n++ + ) { + const l = P0 + n * PERF + phase; + const p = atLs(band.P, l); + if (p.x < -160 || p.x > W + 160) continue; + const ang = Math.atan2(p.ty, p.tx); + const cos = Math.cos(ang); + const sin = Math.sin(ang); + const sk = k * p.k; + const off = (HW - 13) * p.k; + ctx.globalAlpha = 0.42 + 0.58 * norm(band, p.k); + for (const sx of [1, -1]) { + const cx = p.x + p.nx * off * sx; + const cy = p.y + p.ny * off * sx; + ctx.setTransform(cos * sk, sin * sk, -sin * sk, cos * sk, cx * k, cy * k); + ctx.beginPath(); + if (rounded) ctx.roundRect(-8, -5.5, 16, 11, 2.4); + else ctx.rect(-8, -5.5, 16, 11); + ctx.fill(); + } + } + ctx.setTransform(1, 0, 0, 1, 0, 0); + ctx.globalAlpha = 1; +} function renderBand( band: Band, @@ -115,8 +231,9 @@ function renderBand( dir: number, phase: number, slots: { value: number[] }, - els: Map, - holesEl: SVGElement | null, + els: Map, + cv: HTMLCanvasElement | null, + ctx: CanvasRenderingContext2D | null, ) { const ph = dir * phase; const nMin = Math.ceil((rng[0] - A0 - ph) / STRIDE); @@ -131,10 +248,18 @@ function renderBand( ) { slots.value = list; } - for (const [n, el] of els) { + for (const [n, rec] of els) { + const el = rec.outer; const l = A0 + n * STRIDE + ph; const h = homography(cardQuad(band, l)); - el.style.transform = matrix3d(h, 146, 234); + // 卡片中心在屏幕上的位置:外层只做纯位移(合成器可平移,不重新栅格化) + const den = h[6] * 0.5 + h[7] * 0.5 + 1; + const cx = (h[0] * 0.5 + h[1] * 0.5 + h[2]) / den; + const cy = (h[3] * 0.5 + h[4] * 0.5 + h[5]) / den; + el.style.transform = `translate3d(${cx.toFixed(2)}px,${cy.toFixed(2)}px,0)`; + if (rec.inner && needsReshape(rec, h)) { + rec.inner.style.transform = matrix3d(centered(h, cx, cy), 146, 234); + } const p = atLs(band.P, l); const veil = band.floor + (band.dimMax - band.floor) * (1 - norm(band, p.k)); @@ -144,26 +269,26 @@ function renderBand( el.dataset.v = vs; el.style.setProperty("--veil", vs); } - el.style.visibility = "visible"; + if (el.style.visibility !== "visible") el.style.visibility = "visible"; } - if (holesEl) holesEl.innerHTML = holesAt(band, ph); + drawHoles(cv, ctx, band, ph); } function frame(now: number) { raf = requestAnimationFrame(frame); - // 整体 30fps 门控:单应求解、transform/veil 赋值、齿孔 innerHTML - // (每帧最大的 JS+DOM+光栅开销)全部减半 + // 整体 30fps 门控:单应求解、transform/veil 赋值、齿孔绘制全部减半 if (now - lastRenderAt < FRAME_MS) return; const dt = Math.min(0.1, (now - last) / 1000); last = now; lastRenderAt = now; if (!paused && !reduced) elapsed += dt; const phase = elapsed * SPEED; - // 相位未变(悬停/减少动效/首帧后)→ 画面无变化,整帧跳过 - if (phase === renderedPhase) return; + // 相位未变且没有待补的新卡片(悬停/减少动效)→ 画面无变化,整帧跳过 + if (phase === renderedPhase && !dirty) return; + dirty = false; renderedPhase = phase; - renderBand(bandB, rngB, 1, phase, slotsB, elsB, holesB.value); - renderBand(bandA, rngA, -1, phase, slotsA, elsA, holesA.value); + renderBand(bandB, rngB, 1, phase, slotsB, elsB, holesB.value, ctxB); + renderBand(bandA, rngA, -1, phase, slotsA, elsA, holesA.value, ctxA); } // ---------- 缩放(设计画布 cover 铺满视口) ---------- @@ -171,6 +296,17 @@ function fit() { if (!stage.value || !wrap.value) return; const s = Math.max(wrap.value.clientWidth / W, wrap.value.clientHeight / H); stage.value.style.transform = `scale(${s})`; + // 齿孔画布按实际显示尺寸分配像素,封顶 2x(再高清也看不出,只烧显存) + const want = Math.min(2, Math.max(1, s * (devicePixelRatio || 1))); + if (Math.abs(want - hpx.value) > 0.05) { + hpx.value = want; + for (const cv of [holesA.value, holesB.value]) { + if (!cv) continue; + cv.width = Math.round(W * want); + cv.height = Math.round(H * want); + } + renderedPhase = -1; // 重绘一帧 + } } function onEnter() { @@ -182,6 +318,8 @@ function onLeave() { onMounted(() => { fit(); + ctxA = holesA.value?.getContext("2d") ?? null; + ctxB = holesB.value?.getContext("2d") ?? null; addEventListener("resize", fit); document.addEventListener("visibilitychange", onVis); raf = requestAnimationFrame(frame); @@ -224,12 +362,12 @@ function onVis() { :viewBox="`0 0 ${W} ${H}`" v-html="underSvg" /> -
- +
+ +
-
- +
+ +
import { ref, onMounted, onUnmounted } from "vue"; +import { + centerDistance, + register, + release, + unregister, + want, + type FrameSlot, +} from "@/lib/frameSlots"; /** - * 懒加载 iframe:进入视口(含预载边距)才挂载内容,远离视口自动卸载, - * 让滚动墙可以承载任意数量的页面而内存有界。 - * 真实页面传 src;演示页传 srcdoc。 + * 懒加载 iframe:进入视口(含预载边距)才挂载内容。 + * + * 2026-09-19 上线后实测(桌面大屏):无截图的卡片全部走 iframe, + * 27 个同时活着 → 内存与功耗失控;且「进入挂载 / 离开卸载」在滚动墙里 + * 反复抖动,卡片页面区一闪一闪。 + * 现在:全站最多 MAX_LIVE 个(见 lib/frameSlots.ts),优先给最靠近屏幕 + * 中心的卡片;离开预载区只标记不再需要、不立刻卸载,只有别处排队等不到 + * 槽位时才被回收(LRU),所以内容不会一闪一闪,回来也不重新加载。 + * 未拿到槽位的卡片显示静态占位,不来回挂载。 + * + * 真实活动应优先给卡片配截图(服务端 puppeteer),本组件是兜底路径。 */ defineProps<{ src?: string; @@ -15,24 +31,39 @@ defineProps<{ const host = ref(null); const live = ref(false); let io: IntersectionObserver | null = null; +let slot: FrameSlot | null = null; onMounted(() => { - if (!host.value) return; + const el = host.value; + if (!el) return; + slot = { + want: false, + live: false, + pri: () => centerDistance(el), + set: (v: boolean) => (live.value = v), + }; + register(slot); io = new IntersectionObserver( (entries) => { - for (const e of entries) live.value = e.isIntersecting; + const hit = entries[entries.length - 1]?.isIntersecting ?? false; + if (!slot) return; + if (hit) want(slot); + else release(slot); }, // 提前后各半个视口预载,滚动到之前已就绪 { rootMargin: "50% 50% 50% 50%", threshold: 0 }, ); - io.observe(host.value); + io.observe(el); +}); +onUnmounted(() => { + io?.disconnect(); + if (slot) unregister(slot); }); -onUnmounted(() => io?.disconnect());
- +
` 里的顶层变量是「每个组件实例一份」, + * 写在组件里做全局上限根本不起作用(实测 27 个 iframe 照旧同时挂载)。 + * 全站共享的配额必须放在普通模块里,组件只注册/注销。 + */ + +/** 同时存活的 iframe 上限。 + * + * 上限不省主线程(实测上限 8 / 16 / 不限,主线程都是 ~17–18%),它省的是每张卡 + * 背后那个生成页自己的 JS、动画与渲染进程——线上 27 个页面各自活着,才是一台 + * 机器「打开就卡、功耗大」的根源。所以上限是功耗闸门,不是速度优化。 + * + * 取 16:板面卡片渲染高度 101–464px,最显眼的前 16 张(≥171px)都能拿到内容, + * 剩下的是远端小卡,占位几乎看不出。 + */ +export const MAX_LIVE = 16; + +export interface FrameSlot { + /** 当前是否在预载区内(想上屏) */ + want: boolean; + /** 是否已占用槽位 */ + live: boolean; + /** 优先级:越小越优先(距屏幕中心越近) */ + pri: () => number; + /** 通知组件切到真实内容 */ + set: (v: boolean) => void; + /** 拿到槽位的时间:用于抢占冷却,避免两张卡来回抢 */ + since?: number; +} + +const slots = new Set(); + +/** 抢占有意义的优先级差(占视口高度的比例)。 + * 方向是单向的——只有「明显更优先」的卡能把差的换下去,被换下者不会再抢回来, + * 所以阈值小也不会来回抖;但每次抢占 = 一次整页加载,别设成 0。 */ +const SWAP_MARGIN = 0.05; +/** 抢占冷却(ms):刚拿到的槽位短时间内不许被抢 */ +const SWAP_COOLDOWN = 2000; + +function liveCount(): number { + let n = 0; + for (const s of slots) if (s.live) n++; + return n; +} + +function take(s: FrameSlot): void { + s.live = true; + s.since = performance.now(); + s.set(true); +} + +/** + * 发槽位:有空槽就给等待中最优先的卡;池满时,只有当某张持有卡的优先级 + * 明显差于排队卡(SWAP_MARGIN)且已过冷却(SWAP_COOLDOWN)才换人。 + * + * 为什么不能只回收「want=false」的卡:预载区(±50% 视口)几乎覆盖整块板面, + * 大部分卡都处于 want 状态,于是先注册的 16 张会永久占坑,画面中心的大卡 + * 反而一直是占位。带阈值的抢占既能纠正优先级,又不会来回抖。 + * + * 好处:内容在没人抢的时候一直留着——卡片离开预载区不再立刻卸载, + * 回来也不用重新加载(省掉一次整页加载 + 页面区闪一下)。 + */ +export function promote(): void { + const waiting = [...slots] + .filter((s) => s.want && !s.live) + .sort((a, b) => a.pri() - b.pri()); + if (!waiting.length) return; + let used = liveCount(); + for (const s of waiting) { + if (used < MAX_LIVE) { + take(s); + used++; + continue; + } + const now = performance.now(); + const mine = s.pri(); + // 池满:找优先级最差、且冷却已过的持有卡 + const worst = [...slots] + .filter( + (h) => + h.live && + h !== s && + now - (h.since ?? 0) > SWAP_COOLDOWN && + h.pri() > mine + SWAP_MARGIN, + ) + .sort((a, b) => b.pri() - a.pri())[0]; + if (!worst) break; // 持有者都不比我差 → 排队等 + worst.live = false; + worst.set(false); + take(s); + } + rebalance(); +} + +/** + * 有人在排队就每隔一会儿重算一次:卡片在动,优先级一直在变, + * 只靠 IntersectionObserver 事件触发会漏掉「优先级悄悄反转」的情况。 + * 没有排队者时不留定时器,开销为零。 + */ +let timer = 0; +function rebalance(): void { + if (timer) return; + if (!slots.size) return; + let waiting = false; + for (const s of slots) + if (s.want && !s.live) { + waiting = true; + break; + } + if (!waiting) return; + timer = window.setTimeout(() => { + timer = 0; + promote(); + }, 1200); +} + +export function register(s: FrameSlot): void { + slots.add(s); + promote(); +} + +export function unregister(s: FrameSlot): void { + slots.delete(s); + promote(); +} + +/** 进入预载区:排队要槽位 */ +export function want(s: FrameSlot): void { + s.want = true; + promote(); +} + +/** 离开预载区:只标记「不再需要」,槽位先留着(内容继续显示); + * 真正被回收发生在别处排队等不到槽位时(见 promote)——避免频繁卸载重载 */ +export function release(s: FrameSlot): void { + s.want = false; + promote(); +} + +/** 距视口中心的归一化距离(越小越优先;已移出画面的给最大值) */ +export function centerDistance(el: HTMLElement): number { + const r = el.getBoundingClientRect(); + if (r.width === 0 && r.height === 0) return 1e6; + const vh = window.innerHeight || 1; + return Math.abs(r.top + r.height / 2 - vh / 2) / vh; +} From 68a606ceda6b950d537425279d145c1b54f9bb2b Mon Sep 17 00:00:00 2001 From: Ackii Date: Sat, 19 Sep 2026 20:28:17 +0800 Subject: [PATCH 2/7] =?UTF-8?q?fix(web):=20=E8=A1=A5=E5=9B=9E=E4=B8=A4?= =?UTF-8?q?=E7=BA=A7=E5=8F=98=E6=8D=A2=E7=9A=84=E5=86=85=E5=B1=82=E5=8D=8A?= =?UTF-8?q?=E5=8D=A1=E5=81=8F=E7=A7=BB=E2=80=94=E2=80=94=E4=BF=AE=E6=95=B4?= =?UTF-8?q?=E5=A2=99=E5=B7=A6=E4=B8=8A=E5=81=8F=E4=B8=8E=E5=8D=A1=E7=89=87?= =?UTF-8?q?=E4=B8=8D=E8=B4=B4=E8=83=B6=E5=B8=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit e949835 把卡片单应拆成「外层 translate3d + 内层 matrix3d」时,内层自带 left:-73/top:-117(卡心落在外层原点),归位却按卡心算,整层卡片向左上 偏了半张卡:近端大卡浮在胶带上方、上带整体抬高约 117px 扎进标题区。 单应改按「卡心 − 内层偏移」归位,DOM 实测与计算值逐张相等。 --- apps/web/src/components/FilmWallDesktop.vue | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/FilmWallDesktop.vue b/apps/web/src/components/FilmWallDesktop.vue index ebea9ee..cbca20f 100644 --- a/apps/web/src/components/FilmWallDesktop.vue +++ b/apps/web/src/components/FilmWallDesktop.vue @@ -5,6 +5,8 @@ import { t } from "@/i18n"; import type { WallItem } from "@/lib/wall"; import { A0, + CARD_H, + CARD_W, HW, PERF, SPEED, @@ -258,7 +260,14 @@ function renderBand( const cy = (h[3] * 0.5 + h[4] * 0.5 + h[5]) / den; el.style.transform = `translate3d(${cx.toFixed(2)}px,${cy.toFixed(2)}px,0)`; if (rec.inner && needsReshape(rec, h)) { - rec.inner.style.transform = matrix3d(centered(h, cx, cy), 146, 234); + // 内层相对外层原点自带 (-CARD_W/2, -CARD_H/2) 偏移(让卡心落在外层原点上), + // 所以单应要按「卡心 − 这个偏移」归位。写成 centered(h, cx, cy) 会让整张卡 + // 往左上偏半张卡(e949835 引入,2026-09-19 线上截图比对时发现)。 + rec.inner.style.transform = matrix3d( + centered(h, cx - CARD_W / 2, cy - CARD_H / 2), + CARD_W, + CARD_H, + ); } const p = atLs(band.P, l); const veil = From 18dbd64a6a4bfa93e78caa2c4bac0c1ff9d86fcc Mon Sep 17 00:00:00 2001 From: Ackii Date: Sat, 19 Sep 2026 20:56:54 +0800 Subject: [PATCH 3/7] =?UTF-8?q?feat(web):=20=E6=B5=8F=E8=A7=88=E5=99=A8?= =?UTF-8?q?=E7=AB=AF=E8=87=AA=E5=8A=A8=E6=88=AA=E5=9B=BE=E2=80=94=E2=80=94?= =?UTF-8?q?=E5=A4=A7=E5=B1=8F=E5=85=A8=E9=83=A8=E8=B5=B0=20=EF=BC=8Ci?= =?UTF-8?q?frame=20=E4=B8=8E=20LOADING=20=E5=9F=BA=E6=9C=AC=E6=B6=88?= =?UTF-8?q?=E5=A4=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 大屏此前每张真实卡都要开一个 iframe 实时渲染(活动机未装 puppeteer, hasScreenshot 恒 0),是滚动卡顿与功耗的主要来源。改为在用户自己的浏览器里 拍一张截图存进服务端: - /start done 步挂 ShotCapture:取产物 HTML → 隐藏 iframe(srcdoc) 渲染 → html-to-image 截 (420×760@2x,与服务端快门约定一致)→ POST - 服务端新增 POST /api/tasks/:id/screenshot:收 PNG 落盘 shot.png 并置 screenshot=1;只许首次写入(防覆盖),校验 PNG 魔数,路由级 raw 放宽到 6mb - FilmCard 的 frameImg/frameSrc 改 computed:卡片实例按槽位复用,截图上传后 15s 内轮询翻牌即可热切换 iframe → (原来是 setup 期常量,永远翻不过去) - html-to-image 以 vendor 单文件引入(13KB,MIT,prettier/eslint 已 ignore), 部署机零新增依赖;任何一步失败都静默放弃,大屏自动回退 iframe 实测(本地全链路 + mock 生成):发布完成后浏览器自动上传 840×1520 PNG, 大屏 hasScreenshot 翻位、该卡 img 直出;截图内容与页面一致。 --- .prettierignore | 1 + apps/server/src/routes/tasks.ts | 43 ++++++- apps/server/src/services/screenshot.ts | 7 +- apps/web/src/components/FilmCard.vue | 14 ++- apps/web/src/components/home/ShotCapture.vue | 122 +++++++++++++++++++ apps/web/src/lib/vendor/html-to-image.d.ts | 15 +++ apps/web/src/lib/vendor/html-to-image.js | 5 + apps/web/src/views/HomeView.vue | 8 ++ eslint.config.ts | 1 + 9 files changed, 209 insertions(+), 7 deletions(-) create mode 100644 apps/web/src/components/home/ShotCapture.vue create mode 100644 apps/web/src/lib/vendor/html-to-image.d.ts create mode 100644 apps/web/src/lib/vendor/html-to-image.js diff --git a/.prettierignore b/.prettierignore index 05a17fa..618645e 100644 --- a/.prettierignore +++ b/.prettierignore @@ -2,3 +2,4 @@ pnpm-lock.yaml **/dist/ data/ **/*.tsbuildinfo +**/vendor/** diff --git a/apps/server/src/routes/tasks.ts b/apps/server/src/routes/tasks.ts index 5e4efd4..5504b35 100644 --- a/apps/server/src/routes/tasks.ts +++ b/apps/server/src/routes/tasks.ts @@ -1,4 +1,4 @@ -import { Router, type Request, type Response } from "express"; +import express, { Router, type Request, type Response } from "express"; import fs from "node:fs"; import path from "node:path"; import { config } from "../config.js"; @@ -6,6 +6,7 @@ import { tasks, reservations } from "../db.js"; import { allow } from "../services/ratelimit.js"; import { queue } from "../services/queue.js"; import { shotPath } from "../services/screenshot.js"; +import { taskLog } from "../util/logger.js"; import { pickStyle } from "../services/styleHint/index.js"; import { fullDomain } from "../util/domain.js"; import { newTaskId, isValidDeviceId } from "../util/ids.js"; @@ -172,6 +173,46 @@ tasksRouter.get("/:id/screenshot", (req: Request, res: Response) => { res.type("png").send(fs.readFileSync(file)); }); +/** + * 收浏览器端拍的产物截图(/start done 步的 ShotCapture 组件用 html-to-image + * 截 420×760@2x 后原样 POST 过来;puppeteer 未装时这是截图的唯一来源)。 + * 收的是 PNG 二进制而非 JSON——全局 express.json 只放 64kb,路由级 raw 单独放宽。 + * 只许首次写入:已有截图的任务拒绝,防止任意客户端覆盖别人的图。 + */ +tasksRouter.post( + "/:id/screenshot", + express.raw({ type: "image/png", limit: "6mb" }), + (req: Request, res: Response) => { + const t = tasks.get(req.params.id); + if (!t || !TERMINAL.has(t.status)) { + res.status(404).json({ error: "任务不存在或未完成" }); + return; + } + if (t.screenshot) { + res.status(409).json({ error: "截图已存在" }); + return; + } + const buf = req.body as Buffer; + const isPng = + Buffer.isBuffer(buf) && + buf.length > 8 && + buf[0] === 0x89 && + buf[1] === 0x50 && + buf[2] === 0x4e && + buf[3] === 0x47; + if (!isPng) { + res.status(400).json({ error: "仅接受 PNG" }); + return; + } + const file = shotPath(t.id); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, buf); + tasks.update({ id: t.id, screenshot: 1 }); + taskLog(t.id, `浏览器端截图已上传(${Math.round(buf.length / 1024)}KB)`); + res.json({ ok: true }); + }, +); + /** 凭证数据 */ tasksRouter.get("/:id/certificate", (req: Request, res: Response) => { const t = tasks.get(req.params.id); diff --git a/apps/server/src/services/screenshot.ts b/apps/server/src/services/screenshot.ts index 9cd8f00..e69bc33 100644 --- a/apps/server/src/services/screenshot.ts +++ b/apps/server/src/services/screenshot.ts @@ -5,9 +5,12 @@ import { taskLog } from "../util/logger.js"; /** * 大屏截图:发布成功后对线上 URL 截全页 PNG。 - * 运行时按需加载 puppeteer(可选依赖,未安装则大屏自动降级为 iframe 实时预览): + * + * 主路径已改为**浏览器端截图**:/start done 步的 ShotCapture 组件用 + * html-to-image 截产物页后 POST 到 routes/tasks.ts 的 `POST /:id/screenshot`, + * 部署机零额外依赖。本文件是可选的服务端补充(未装 puppeteer 时直接跳过): * pnpm --filter @words2site/server add puppeteer - * 部署机首次运行会下载 Chromium,活动前务必预热。 + * 装了需预热(首次下载 Chromium),可作为没有前端参与时的兜底。 */ export function shotPath(taskId: string): string { diff --git a/apps/web/src/components/FilmCard.vue b/apps/web/src/components/FilmCard.vue index c174d2e..b516e39 100644 --- a/apps/web/src/components/FilmCard.vue +++ b/apps/web/src/components/FilmCard.vue @@ -1,4 +1,5 @@ + + diff --git a/apps/web/src/lib/vendor/html-to-image.d.ts b/apps/web/src/lib/vendor/html-to-image.d.ts new file mode 100644 index 0000000..76428ca --- /dev/null +++ b/apps/web/src/lib/vendor/html-to-image.d.ts @@ -0,0 +1,15 @@ +/** + * html-to-image 1.11.13 的类型声明(vendored 单文件,只声明本仓库用到的 API)。 + * 上游完整类型见 https://github.com/bubkooo/html-to-image + */ +declare module "@/lib/vendor/html-to-image.js" { + export interface Options { + width?: number; + height?: number; + pixelRatio?: number; + backgroundColor?: string | null; + filter?: (domNode: HTMLElement) => boolean; + style?: Partial; + } + export function toPng(node: HTMLElement, options?: Options): Promise; +} diff --git a/apps/web/src/lib/vendor/html-to-image.js b/apps/web/src/lib/vendor/html-to-image.js new file mode 100644 index 0000000..e4e937d --- /dev/null +++ b/apps/web/src/lib/vendor/html-to-image.js @@ -0,0 +1,5 @@ +/*! html-to-image 1.11.13 — MIT License (c) 2019 bubkooo + * https://github.com/bubkooo/html-to-image + * Vendored as a single ESM bundle (no install step needed on deploy machines). */ +function U(t,e){if(t.match(/^[a-z]+:\/\//i))return t;if(t.match(/^\/\//))return window.location.protocol+t;if(t.match(/^[a-z]+:/i))return t;let r=document.implementation.createHTMLDocument(),n=r.createElement("base"),o=r.createElement("a");return r.head.appendChild(n),r.body.appendChild(o),e&&(n.href=e),o.href=t,o.href}var v=(()=>{let t=0,e=()=>`0000${(Math.random()*36**4<<0).toString(36)}`.slice(-4);return()=>(t+=1,`u${e()}${t}`)})();function f(t){let e=[];for(let r=0,n=t.length;ru||t.height>u)&&(t.width>u&&t.height>u?t.width>t.height?(t.height*=u/t.width,t.width=u):(t.width*=u/t.height,t.height=u):t.width>u?(t.height*=u/t.width,t.width=u):(t.width*=u/t.height,t.height=u))}function H(t,e={}){return t.toBlob?new Promise(r=>{t.toBlob(r,e.type?e.type:"image/png",e.quality?e.quality:1)}):new Promise(r=>{let n=window.atob(t.toDataURL(e.type?e.type:void 0,e.quality?e.quality:void 0).split(",")[1]),o=n.length,i=new Uint8Array(o);for(let a=0;a{let n=new Image;n.onload=()=>{n.decode().then(()=>{requestAnimationFrame(()=>e(n))})},n.onerror=r,n.crossOrigin="anonymous",n.decoding="async",n.src=t})}async function et(t){return Promise.resolve().then(()=>new XMLSerializer().serializeToString(t)).then(encodeURIComponent).then(e=>`data:image/svg+xml;charset=utf-8,${e}`)}async function V(t,e,r){let n="http://www.w3.org/2000/svg",o=document.createElementNS(n,"svg"),i=document.createElementNS(n,"foreignObject");return o.setAttribute("width",`${e}`),o.setAttribute("height",`${r}`),o.setAttribute("viewBox",`0 0 ${e} ${r}`),i.setAttribute("width","100%"),i.setAttribute("height","100%"),i.setAttribute("x","0"),i.setAttribute("y","0"),i.setAttribute("externalResourcesRequired","true"),o.appendChild(i),i.appendChild(t),et(o)}var l=(t,e)=>{if(t instanceof e)return!0;let r=Object.getPrototypeOf(t);return r===null?!1:r.constructor.name===e.name||l(r,e)};function rt(t){let e=t.getPropertyValue("content");return`${t.cssText} content: '${e.replace(/'|"/g,"")}';`}function nt(t,e){return E(e).map(r=>{let n=t.getPropertyValue(r),o=t.getPropertyPriority(r);return`${r}: ${n}${o?" !important":""};`}).join(" ")}function it(t,e,r,n){let o=`.${t}:${e}`,i=r.cssText?rt(r):nt(r,n);return document.createTextNode(`${o}{${i}}`)}function O(t,e,r,n){let o=window.getComputedStyle(t,r),i=o.getPropertyValue("content");if(i===""||i==="none")return;let a=v();try{e.className=`${e.className} ${a}`}catch{return}let s=document.createElement("style");s.appendChild(it(a,r,o,n)),e.appendChild(s)}function W(t,e,r){O(t,e,":before",r),O(t,e,":after",r)}var _="application/font-woff",q="image/jpeg",ot={woff:_,woff2:_,ttf:"application/font-truetype",eot:"application/vnd.ms-fontobject",png:"image/png",jpg:q,jpeg:q,gif:"image/gif",tiff:"image/tiff",svg:"image/svg+xml",webp:"image/webp"};function at(t){let e=/\.([^./]*?)$/g.exec(t);return e?e[1]:""}function y(t){let e=at(t).toLowerCase();return ot[e]||""}function st(t){return t.split(/,/)[1]}function x(t){return t.search(/^(data:)/)!==-1}function $(t,e){return`data:${e};base64,${t}`}async function F(t,e,r){let n=await fetch(t,e);if(n.status===404)throw new Error(`Resource "${n.url}" not found`);let o=await n.blob();return new Promise((i,a)=>{let s=new FileReader;s.onerror=a,s.onloadend=()=>{try{i(r({res:n,result:s.result}))}catch(c){a(c)}},s.readAsDataURL(o)})}var L={};function ct(t,e,r){let n=t.replace(/\?.*/,"");return r&&(n=t),/ttf|otf|eot|woff2?/i.test(n)&&(n=n.replace(/.*\//,"")),e?`[${e}]${n}`:n}async function p(t,e,r){let n=ct(t,e,r.includeQueryParams);if(L[n]!=null)return L[n];r.cacheBust&&(t+=(/\?/.test(t)?"&":"?")+new Date().getTime());let o;try{let i=await F(t,r.fetchRequestInit,({res:a,result:s})=>(e||(e=a.headers.get("Content-Type")||""),st(s)));o=$(i,e)}catch(i){o=r.imagePlaceholder||"";let a=`Failed to fetch resource: ${t}`;i&&(a=typeof i=="string"?i:i.message),a&&console.warn(a)}return L[n]=o,o}async function lt(t){let e=t.toDataURL();return e==="data:,"?t.cloneNode(!1):d(e)}async function ut(t,e){if(t.currentSrc){let i=document.createElement("canvas"),a=i.getContext("2d");i.width=t.clientWidth,i.height=t.clientHeight,a?.drawImage(t,0,0,i.width,i.height);let s=i.toDataURL();return d(s)}let r=t.poster,n=y(r),o=await p(r,n,e);return d(o)}async function ft(t,e){var r;try{if(!((r=t?.contentDocument)===null||r===void 0)&&r.body)return await b(t.contentDocument.body,e,!0)}catch{}return t.cloneNode(!1)}async function mt(t,e){return l(t,HTMLCanvasElement)?lt(t):l(t,HTMLVideoElement)?ut(t,e):l(t,HTMLIFrameElement)?ft(t,e):t.cloneNode(B(t))}var ht=t=>t.tagName!=null&&t.tagName.toUpperCase()==="SLOT",B=t=>t.tagName!=null&&t.tagName.toUpperCase()==="SVG";async function gt(t,e,r){var n,o;if(B(e))return e;let i=[];return ht(t)&&t.assignedNodes?i=f(t.assignedNodes()):l(t,HTMLIFrameElement)&&(!((n=t.contentDocument)===null||n===void 0)&&n.body)?i=f(t.contentDocument.body.childNodes):i=f(((o=t.shadowRoot)!==null&&o!==void 0?o:t).childNodes),i.length===0||l(t,HTMLVideoElement)||await i.reduce((a,s)=>a.then(()=>b(s,r)).then(c=>{c&&e.appendChild(c)}),Promise.resolve()),e}function dt(t,e,r){let n=e.style;if(!n)return;let o=window.getComputedStyle(t);o.cssText?(n.cssText=o.cssText,n.transformOrigin=o.transformOrigin):E(r).forEach(i=>{let a=o.getPropertyValue(i);i==="font-size"&&a.endsWith("px")&&(a=`${Math.floor(parseFloat(a.substring(0,a.length-2)))-.1}px`),l(t,HTMLIFrameElement)&&i==="display"&&a==="inline"&&(a="block"),i==="d"&&e.getAttribute("d")&&(a=`path(${e.getAttribute("d")})`),n.setProperty(i,a,o.getPropertyPriority(i))})}function yt(t,e){l(t,HTMLTextAreaElement)&&(e.innerHTML=t.value),l(t,HTMLInputElement)&&e.setAttribute("value",t.value)}function pt(t,e){if(l(t,HTMLSelectElement)){let n=Array.from(e.children).find(o=>t.value===o.getAttribute("value"));n&&n.setAttribute("selected","")}}function wt(t,e,r){return l(e,Element)&&(dt(t,e,r),W(t,e,r),yt(t,e),pt(t,e)),e}async function xt(t,e){let r=t.querySelectorAll?t.querySelectorAll("use"):[];if(r.length===0)return t;let n={};for(let i=0;imt(n,e)).then(n=>gt(t,n,e)).then(n=>wt(t,n,e)).then(n=>xt(n,e))}var j=/url\((['"]?)([^'"]+?)\1\)/g,bt=/url\([^)]+\)\s*format\((["']?)([^"']+)\1\)/g,St=/src:\s*(?:url\([^)]+\)\s*format\([^)]+\)[,;]\s*)+/g;function Et(t){let e=t.replace(/([.*+?^${}()|\[\]\/\\])/g,"\\$1");return new RegExp(`(url\\(['"]?)(${e})(['"]?\\))`,"g")}function Rt(t){let e=[];return t.replace(j,(r,n,o)=>(e.push(o),r)),e.filter(r=>!x(r))}async function Ct(t,e,r,n,o){try{let i=r?U(e,r):e,a=y(e),s;if(o){let c=await o(i);s=$(c,a)}else s=await p(i,a,n);return t.replace(Et(e),`$1${s}$3`)}catch{}return t}function Pt(t,{preferredFontFormat:e}){return e?t.replace(St,r=>{for(;;){let[n,,o]=bt.exec(r)||[];if(!o)return"";if(o===e)return`src: ${n};`}}):t}function I(t){return t.search(j)!==-1}async function C(t,e,r){if(!I(t))return t;let n=Pt(t,r);return Rt(n).reduce((i,a)=>i.then(s=>Ct(s,a,e,r)),Promise.resolve(n))}async function w(t,e,r){var n;let o=(n=e.style)===null||n===void 0?void 0:n.getPropertyValue(t);if(o){let i=await C(o,null,r);return e.style.setProperty(t,i,e.style.getPropertyPriority(t)),!0}return!1}async function Tt(t,e){await w("background",t,e)||await w("background-image",t,e),await w("mask",t,e)||await w("-webkit-mask",t,e)||await w("mask-image",t,e)||await w("-webkit-mask-image",t,e)}async function Lt(t,e){let r=l(t,HTMLImageElement);if(!(r&&!x(t.src))&&!(l(t,SVGImageElement)&&!x(t.href.baseVal)))return;let n=r?t.src:t.href.baseVal,o=await p(n,y(n),e);await new Promise((i,a)=>{t.onload=i,t.onerror=e.onImageErrorHandler?(...c)=>{try{i(e.onImageErrorHandler(...c))}catch(m){a(m)}}:a;let s=t;s.decode&&(s.decode=i),s.loading==="lazy"&&(s.loading="eager"),r?(t.srcset="",t.src=o):t.href.baseVal=o})}async function $t(t,e){let n=f(t.childNodes).map(o=>D(o,e));await Promise.all(n).then(()=>t)}async function D(t,e){l(t,Element)&&(await Tt(t,e),await Lt(t,e),await $t(t,e))}function z(t,e){let{style:r}=t;e.backgroundColor&&(r.backgroundColor=e.backgroundColor),e.width&&(r.width=`${e.width}px`),e.height&&(r.height=`${e.height}px`);let n=e.style;return n!=null&&Object.keys(n).forEach(o=>{r[o]=n[o]}),t}var G={};async function X(t){let e=G[t];if(e!=null)return e;let n=await(await fetch(t)).text();return e={url:t,cssText:n},G[t]=e,e}async function J(t,e){let r=t.cssText,n=/url\(["']?([^"')]+)["']?\)/g,i=(r.match(/url\([^)]+\)/g)||[]).map(async a=>{let s=a.replace(n,"$1");return s.startsWith("https://")||(s=new URL(s,t.url).href),F(s,e.fetchRequestInit,({result:c})=>(r=r.replace(a,`url(${c})`),[a,c]))});return Promise.all(i).then(()=>r)}function K(t){if(t==null)return[];let e=[],r=/(\/\*[\s\S]*?\*\/)/gi,n=t.replace(r,""),o=new RegExp("((@.*?keyframes [\\s\\S]*?){([\\s\\S]*?}\\s*?)})","gi");for(;;){let c=o.exec(n);if(c===null)break;e.push(c[0])}n=n.replace(o,"");let i=/@import[\s\S]*?url\([^)]*\)[\s\S]*?;/gi,a="((\\s*?(?:\\/\\*[\\s\\S]*?\\*\\/)?\\s*?@media[\\s\\S]*?){([\\s\\S]*?)}\\s*?})|(([\\s\\S]*?){([\\s\\S]*?)})",s=new RegExp(a,"gi");for(;;){let c=i.exec(n);if(c===null){if(c=s.exec(n),c===null)break;i.lastIndex=s.lastIndex}else s.lastIndex=i.lastIndex;e.push(c[0])}return e}async function Ft(t,e){let r=[],n=[];return t.forEach(o=>{if("cssRules"in o)try{f(o.cssRules||[]).forEach((i,a)=>{if(i.type===CSSRule.IMPORT_RULE){let s=a+1,c=i.href,m=X(c).then(h=>J(h,e)).then(h=>K(h).forEach(T=>{try{o.insertRule(T,T.startsWith("@import")?s+=1:o.cssRules.length)}catch(Z){console.error("Error inserting rule from remote css",{rule:T,error:Z})}})).catch(h=>{console.error("Error loading remote css",h.toString())});n.push(m)}})}catch(i){let a=t.find(s=>s.href==null)||document.styleSheets[0];o.href!=null&&n.push(X(o.href).then(s=>J(s,e)).then(s=>K(s).forEach(c=>{a.insertRule(c,a.cssRules.length)})).catch(s=>{console.error("Error loading remote stylesheet",s)})),console.error("Error inlining remote css file",i)}}),Promise.all(n).then(()=>(t.forEach(o=>{if("cssRules"in o)try{f(o.cssRules||[]).forEach(i=>{r.push(i)})}catch(i){console.error(`Error while reading CSS rules from ${o.href}`,i)}}),r))}function It(t){return t.filter(e=>e.type===CSSRule.FONT_FACE_RULE).filter(e=>I(e.style.getPropertyValue("src")))}async function Dt(t,e){if(t.ownerDocument==null)throw new Error("Provided element is not within a Document");let r=f(t.ownerDocument.styleSheets),n=await Ft(r,e);return It(n)}function Q(t){return t.trim().replace(/["']/g,"")}function At(t){let e=new Set;function r(n){(n.style.fontFamily||getComputedStyle(n).fontFamily).split(",").forEach(i=>{e.add(Q(i))}),Array.from(n.children).forEach(i=>{i instanceof HTMLElement&&r(i)})}return r(t),e}async function A(t,e){let r=await Dt(t,e),n=At(t);return(await Promise.all(r.filter(i=>n.has(Q(i.style.fontFamily))).map(i=>{let a=i.parentStyleSheet?i.parentStyleSheet.href:null;return C(i.cssText,a,e)}))).join(` +`)}async function Y(t,e){let r=e.fontEmbedCSS!=null?e.fontEmbedCSS:e.skipFonts?null:await A(t,e);if(r){let n=document.createElement("style"),o=document.createTextNode(r);n.appendChild(o),t.firstChild?t.insertBefore(n,t.firstChild):t.appendChild(n)}}async function Ut(t,e={}){let{width:r,height:n}=R(t,e),o=await b(t,e,!0);return await Y(o,e),await D(o,e),z(o,e),await V(o,r,n)}async function P(t,e={}){let{width:r,height:n}=R(t,e),o=await Ut(t,e),i=await d(o),a=document.createElement("canvas"),s=a.getContext("2d"),c=e.pixelRatio||k(),m=e.canvasWidth||r,h=e.canvasHeight||n;return a.width=m*c,a.height=h*c,e.skipAutoScale||M(a),a.style.width=`${m}`,a.style.height=`${h}`,e.backgroundColor&&(s.fillStyle=e.backgroundColor,s.fillRect(0,0,a.width,a.height)),s.drawImage(i,0,0,a.width,a.height),a}async function le(t,e={}){let{width:r,height:n}=R(t,e);return(await P(t,e)).getContext("2d").getImageData(0,0,r,n).data}async function ue(t,e={}){return(await P(t,e)).toDataURL()}async function fe(t,e={}){return(await P(t,e)).toDataURL("image/jpeg",e.quality||1)}async function me(t,e={}){let r=await P(t,e);return await H(r)}async function he(t,e={}){return A(t,e)}export{he as getFontEmbedCSS,me as toBlob,P as toCanvas,fe as toJpeg,le as toPixelData,ue as toPng,Ut as toSvg}; diff --git a/apps/web/src/views/HomeView.vue b/apps/web/src/views/HomeView.vue index 4efda8a..421a30e 100644 --- a/apps/web/src/views/HomeView.vue +++ b/apps/web/src/views/HomeView.vue @@ -10,6 +10,7 @@ import IntroStep from "@/components/home/IntroStep.vue"; import FormStep from "@/components/home/FormStep.vue"; import WaitingStep from "@/components/home/WaitingStep.vue"; import DoneStep from "@/components/home/DoneStep.vue"; +import ShotCapture from "@/components/home/ShotCapture.vue"; /** * /start 壳 + 四步状态机:intro → form → waiting → done。 @@ -176,6 +177,13 @@ function restart() {

Presented by CPU

+ + +
diff --git a/eslint.config.ts b/eslint.config.ts index 8c5875c..0a5d184 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -14,6 +14,7 @@ export default tseslint.config( "**/node_modules/**", "**/*.tsbuildinfo", "pnpm-lock.yaml", + "**/vendor/**", ], }, eslint.configs.recommended, From 436564ae849c3a9aa4850abc6c189cfa3b1ace63 Mon Sep 17 00:00:00 2001 From: Robert He Date: Sat, 19 Sep 2026 21:52:56 +0800 Subject: [PATCH 4/7] feat: render demo cards as svg shots via img --- apps/web/src/components/FilmCard.vue | 28 +-- apps/web/src/lib/demoPages.ts | 279 ++++++++++++++++++++++++++- apps/web/tsconfig.tsbuildinfo | 2 +- 3 files changed, 288 insertions(+), 21 deletions(-) diff --git a/apps/web/src/components/FilmCard.vue b/apps/web/src/components/FilmCard.vue index b516e39..5759496 100644 --- a/apps/web/src/components/FilmCard.vue +++ b/apps/web/src/components/FilmCard.vue @@ -1,7 +1,7 @@