Skip to content

Commit 48e453e

Browse files
committed
feat(motion): 落时间码回跳与掠过缩略
1 parent 0bdad6d commit 48e453e

8 files changed

Lines changed: 851 additions & 8 deletions
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
// @vitest-environment jsdom
2+
/**
3+
* SessionTriTrackView.thumbs.test.tsx — #5 的**掠过条**端到端判据(批 6 波 C · T31)。
4+
*
5+
* @ai-context 与两个既有件的分工:`SessionTriTrackView.test.tsx`(T1–T5,批 5)与
6+
* `SessionTriTrackView.rail.test.tsx`(T25 的 I1–I5)**一字未改**;本件只加 T31 新增的面 ——
7+
* ① 三档帧数(1/3/6)与「只取 `thumb/` 级缩略图」;② 帧的取材 = 目标邻域(**单一真源**
8+
* `screensFor.screensAround`,不是「最前几屏」);③ 稀疏降级(`image_ref` 全空 / 槽未接线 ⇒
9+
* **整条不渲染**,而不是占位或拉伸时间轴);④ 解析不出 URL 的那一帧同样不占位。
10+
* @ai-context 仪器边界:`imageUrl` 用**注入的 spy**(视图侧零 `@tauri-apps` —— 本件不 mock Tauri,
11+
* 也没有真 `convertFileSrc` 可 mock);载荷只在 DOM 与 spy 的实参上。⚠️ 视图从 T31 起把 `imageUrl`
12+
* 当**可选**槽收(既有夹具单参渲染)⇒ 缺它时不出条,这是 T4② 那条「视图零 Tauri」的形态之一。
13+
* @ai-context 诚实边界:jsdom **不排版**(尺寸/位置不可判)⇒ 只判 `src`、帧数与结构事实;
14+
* **「掠过几帧」的观感未测**(本件不声称)。
15+
* 副作用:只挂 React 树 + 读写 `localStorage` 的档位记忆(`beforeEach` 清空)。不写盘、不发请求。
16+
*/
17+
import { cleanup, render } from "@testing-library/react";
18+
import type { Mock } from "vitest";
19+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
20+
import type { SessionDetail } from "../../types/session";
21+
import { MOTION_INTENSITY_KEY } from "../../motion/intensity";
22+
import SessionTriTrackView from "./SessionTriTrackView";
23+
24+
const URL_OF = (ref: string): string => `asset://injected/${ref}`;
25+
const STRIP = '[data-testid="session-timerail-thumbs"]';
26+
const RAIL = '[data-testid="session-timerail"]';
27+
const PLAYHEAD = '[data-testid="session-timerail-playhead"]';
28+
29+
/** 五屏(10s…50s),每屏都有归档图;屏区间互不重叠 ⇒ 目标落在哪一屏可归因 */
30+
function screensOf(refOf: (ms: number) => string | null): SessionDetail["screens"] {
31+
return [10, 20, 30, 40, 50].map((s) => {
32+
const ms = s * 1_000;
33+
return {
34+
session_id: 1042,
35+
screen_id: s,
36+
first_seen_ms: ms,
37+
last_seen_ms: ms + 900,
38+
title: null,
39+
body: [],
40+
labels: [],
41+
image_ref: refOf(ms),
42+
structure: [],
43+
};
44+
});
45+
}
46+
function detailOf(screens: SessionDetail["screens"]): SessionDetail {
47+
return {
48+
session: { id: 1042, title: "构图与调色", source_window: "Chrome", started_at: 1_700_000_000_000, ended_at: 1_700_003_800_000, status: "finished", kind: null },
49+
segments: [{ id: 9001, session_id: 1042, start_ms: 0, end_ms: 60_000, text: "开场:构图三要素", source: "subtitle", confidence: 0.9 }],
50+
screens,
51+
ocr_blocks: [],
52+
};
53+
}
54+
const FULL: SessionDetail["screens"] = screensOf((ms) => `full/${ms}.webp`);
55+
56+
interface Mounted {
57+
readonly container: HTMLElement;
58+
readonly imageUrl: Mock;
59+
}
60+
/** 挂载**真实视图**(端到端:视图 → TimeRail → 掠过条 → `screensFor`) */
61+
function mount(opts: { readonly screens?: SessionDetail["screens"]; readonly playheadMs?: number | null; readonly wired?: boolean } = {}): Mounted {
62+
const imageUrl = vi.fn((ref: string | null) => (ref === null ? null : URL_OF(ref)));
63+
const view = render(
64+
<SessionTriTrackView
65+
detail={detailOf(opts.screens ?? FULL)}
66+
playheadMs={opts.playheadMs === undefined ? 35_000 : opts.playheadMs}
67+
{...(opts.wired === false ? {} : { imageUrl })}
68+
/>,
69+
);
70+
return { container: view.container, imageUrl };
71+
}
72+
function q(container: HTMLElement, sel: string): HTMLElement {
73+
const el = container.querySelector(sel);
74+
if (el === null) throw new Error(`没有渲染出 ${sel}`);
75+
return el as HTMLElement;
76+
}
77+
const srcs = (container: HTMLElement): string[] => [...container.querySelectorAll(`${STRIP} img`)].map((el) => el.getAttribute("src") ?? "");
78+
79+
beforeEach(() => {
80+
localStorage.clear();
81+
});
82+
afterEach(cleanup);
83+
84+
describe("T1 三档帧数 + 只取 `thumb/` 级缩略图(`full/` 是原图,图集可达 115 MB 级)", () => {
85+
it("standard ⇒ 恰 3 帧、rich ⇒ 恰 6(屏数不足 6 ⇒ 有多少给多少)、eco ⇒ 恰 1;每帧 `src` 都含 `thumb/`(逐序)", () => {
86+
const expectRefs: Readonly<Record<string, readonly string[]>> = {
87+
eco: ["thumb/50000.webp"],
88+
standard: ["thumb/30000.webp", "thumb/40000.webp", "thumb/50000.webp"],
89+
rich: ["thumb/10000.webp", "thumb/20000.webp", "thumb/30000.webp", "thumb/40000.webp", "thumb/50000.webp"],
90+
};
91+
const counts: number[] = [];
92+
for (const tier of ["eco", "standard", "rich"] as const) {
93+
localStorage.setItem(MOTION_INTENSITY_KEY, tier);
94+
const { container, imageUrl } = mount({ playheadMs: 50_000 });
95+
// `imageUrl` 在 render 里调用(同 `SessionCardFlowView`)⇒ 每次重渲都会再调一次,故去重后对拍
96+
const asked = [...new Set(imageUrl.mock.calls.map((c) => c[0] as string))];
97+
expect(asked, `${tier}:配图槽只许收到 thumb/ 级 ref(逐序)`).toEqual(expectRefs[tier]);
98+
const shown = srcs(container);
99+
counts.push(shown.length);
100+
expect(shown, `${tier}:` + "`src` 逐序 = 注入槽的返回(URL 不在本层拼)").toEqual(expectRefs[tier].map(URL_OF));
101+
expect(shown.filter((s) => !s.includes("thumb/")), `${tier}:出现非 thumb/ 的帧 ⇒ 掠过会去拉原图`).toEqual([]);
102+
expect(q(container, STRIP).dataset.frames, `${tier}:data-frames 与 DOM 一致`).toBe(String(shown.length));
103+
cleanup();
104+
}
105+
expect(counts, "三档帧数逐序(1/3/6;rich 受屏数上限约束 ⇒ 5)").toEqual([1, 3, 5]);
106+
});
107+
});
108+
109+
describe("T2 帧的取材 = 目标邻域(单一真源),不是「最前几屏」", () => {
110+
it("目标 35s(第 3 屏区间后)⇒ 以第 3 屏结尾的最近 3 屏;目标 5s(早于所有屏)⇒ 只有首屏", () => {
111+
const near = mount({ playheadMs: 35_000 });
112+
expect(srcs(near.container), "35s 的目标邻域 = 10/20/30s 三屏(`screensAround` 的窗口口径)").toEqual([
113+
URL_OF("thumb/10000.webp"),
114+
URL_OF("thumb/20000.webp"),
115+
URL_OF("thumb/30000.webp"),
116+
]);
117+
expect(q(near.container, STRIP).dataset.frames, "`data-frames` = 实际渲染的帧数(与 DOM 一致)").toBe("3");
118+
cleanup();
119+
const early = mount({ playheadMs: 5_000 });
120+
expect(srcs(early.container), "早于所有屏 ⇒ 锚取最小 first_seen_ms 的那一屏(不是空、也不是全部)").toEqual([URL_OF("thumb/10000.webp")]);
121+
});
122+
});
123+
124+
describe("T3 稀疏降级:`image_ref` 全空 ⇒ **不出**掠过条(不占位、不拉伸时间轴)", () => {
125+
it("五屏全无归档图 ⇒ 无条、无 img;但时间轨、播放头与刻度照旧(回跳的落位不受影响)", () => {
126+
const { container, imageUrl } = mount({ screens: screensOf(() => null) });
127+
expect(container.querySelector(STRIP), "没有可用帧却渲染了条(占位/拉伸都会在这里露馅)").toBeNull();
128+
expect(imageUrl.mock.calls.length, "`image_ref === null` 不该调用配图槽").toBe(0);
129+
expect(q(container, RAIL), "降级把时间轨带走了").not.toBeNull();
130+
expect(q(container, PLAYHEAD).dataset.ms, "播放头照旧落位(掠过条只是配图面)").toBe("35000");
131+
expect(q(container, PLAYHEAD).style.transform, "落终态:位移写出 0(不是不写)").toContain("translate");
132+
expect([...container.querySelectorAll("[data-tick-ms]")].length, "刻度尺被带走了").toBeGreaterThan(1);
133+
cleanup();
134+
const none = mount({ screens: [] });
135+
expect(none.container.querySelector(STRIP), "空 screens ⇒ 同样不出条").toBeNull();
136+
});
137+
});
138+
139+
describe("T4 槽未接线(视图从 T31 起把 `imageUrl` 当可选槽收)⇒ 不出条且不抛", () => {
140+
it("不传 `imageUrl` ⇒ 三条轨、时间轨、播放头都在,掠过条缺席(既有夹具的单参渲染形态)", () => {
141+
const { container, imageUrl } = mount({ wired: false });
142+
expect(container.querySelector(STRIP), "缺槽却渲染了条").toBeNull();
143+
expect(imageUrl.mock.calls.length, "缺槽时不该有配图解析").toBe(0);
144+
expect([...container.querySelectorAll("[data-track]")].map((el) => el.children.length)).toEqual([1, 5, 0]);
145+
expect(q(container, PLAYHEAD)).not.toBeNull();
146+
});
147+
});
148+
149+
describe("T5 解析不出 URL 的那一帧不占位(`imageUrl` 返回 `null`)", () => {
150+
it("第 2 屏的 ref 解析失败 ⇒ 帧数 3→2 且逐序少了它(不渲染 `src=null` 的空图)", () => {
151+
const screens = screensOf((ms) => (ms === 30_000 ? "full/gone.webp" : `full/${ms}.webp`));
152+
const imageUrl = vi.fn((ref: string | null) => (ref === "thumb/gone.webp" ? null : ref === null ? null : URL_OF(ref)));
153+
const { container } = render(<SessionTriTrackView detail={detailOf(screens)} playheadMs={35_000} imageUrl={imageUrl} />);
154+
expect(srcs(container), "解析不出的一帧被跳过(不是渲染空 src)").toEqual([URL_OF("thumb/10000.webp"), URL_OF("thumb/20000.webp")]);
155+
expect(q(container, STRIP).dataset.frames).toBe("2");
156+
expect(srcs(container).filter((s) => s === "" || s === "null"), "出现空 src").toEqual([]);
157+
});
158+
});

‎app/src/views/session/SessionTriTrackView.tsx‎

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -68,8 +68,10 @@ import { useTriTrackAlign } from "./useTriTrackAlign";
6868
// 轨名类型(`data-track` 锚的唯一字面量来源)由编排层持有 ⇒ 两处不各写一份字面量
6969
import type { TrackKey, TriTrackAlign } from "./useTriTrackAlign";
7070

71-
/** 视图的数据面:`detail`(三轨)+ T25 的三个**只读**注入槽(音频引用 / 播放头 / 上行通道) */
72-
type Slot = Pick<SessionViewSlot, "detail" | "audio" | "playheadMs" | "onSeekMs">;
71+
/** 视图的数据面:`detail`(三轨)+ T25 的三个**只读**注入槽(音频 / 播放头 / 上行通道)+ T31 的
72+
* `imageUrl`(掠过条的配图槽)。🔴 `imageUrl` **可选**:既有夹具(T1–T5)按 `detail` 单参渲染 ⇒
73+
* 缺它时**不出掠过条**(稀疏降级),而不是把这条链拉红(`SessionViewSlot` 里它是必填)。 */
74+
type Slot = Pick<SessionViewSlot, "detail" | "audio" | "playheadMs" | "onSeekMs"> & { readonly imageUrl?: SessionViewSlot["imageUrl"] };
7375

7476
/** 三轨的数据面 = 槽里的 `detail` 一项(C14②:其余槽位与三轨无关 ⇒ 三轨仍不可能取数) */
7577
type Detail = Slot["detail"];
@@ -241,7 +243,7 @@ function attachOf(align: TriTrackAlign, track: TrackKey): ((el: HTMLElement | nu
241243
* 渲染会话三轨对齐视图 + 时间轨(T25)+#1 对齐动效(T27)。数据全来自 props(`detail` + 三个注入槽)
242244
* ⇒ 本件不取数、**不自持播放头状态**(受控:点击只上报,位置随 `playheadMs` 回来)。
243245
*/
244-
export default function SessionTriTrackView({ detail, audio, playheadMs, onSeekMs }: Slot): ReactElement {
246+
export default function SessionTriTrackView({ detail, audio, playheadMs, onSeekMs, imageUrl }: Slot): ReactElement {
245247
const lanes = lanesOf(detail);
246248
const empty = lanes.every((lane) => lane.items.length === 0);
247249
// 🔴 hook 在**空态提前返回之前**调用(否则两次渲染的 hook 数不同 ⇒ React 抛错);空态下两条轨
@@ -268,8 +270,9 @@ export default function SessionTriTrackView({ detail, audio, playheadMs, onSeekM
268270

269271
return (
270272
<div style={ROOT_STYLE} data-testid="session-tritrack-view">
271-
{/* R5.5 #5 的承载面:共享时间轴尺 + 播放头 + `<audio>` 属性契约 + 降级提示行 */}
272-
<TimeRail totalMs={totalMsOf(detail, audio)} playheadMs={playheadMs ?? null} audio={audio} onSeekMs={onSeekMs} />
273+
{/* R5.5 #5 的承载面:共享时间轴尺 + 播放头 + `<audio>` 属性契约 + 降级提示行;T31 起同址接上
274+
**回跳编排**与**掠过条**(`screens` 与配图槽在此一转即达,视图自身零副作用) */}
275+
<TimeRail totalMs={totalMsOf(detail, audio)} playheadMs={playheadMs ?? null} audio={audio} onSeekMs={onSeekMs} screens={detail.screens} imageUrl={imageUrl} />
273276
{/* `data-tone="instrument"` = #1 的基调登记(R3.4 逐字「每个动效落点显式声明基调」) */}
274277
<div style={TRACKS_STYLE} data-tone="instrument">
275278
{lanes.map((lane) => (
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
/**
2+
* @ai-context ThumbStrip.tsx — #5 的**掠过条**(规格 §8.6 第 5 行「掠过几帧缩略」· 批 6 波 C · T31)。
3+
*
4+
* @ai-context 依赖方向(规格 §7.1 + `views/architecture.guard.test.ts` 的 A3③ 整目录硬边界):本件属
5+
* `views/**` ⇒ **零 `@tauri-apps`**(含 `import type`)。配图 URL 一律经容器注入的 `imageUrl(ref)`
6+
* 解析(`null` = 解析不出 ⇒ 那一帧**不渲染**,不做第二个 `convertFileSrc`)。
7+
* @ai-context 取材口径的唯一真源 = `screensFor.screensAround`(本件**不自己算**「最近几帧」);相对路径
8+
* 还要过 `thumbRefOf`(`full/` → `thumb/`)—— 掠过条只许取 320px 级缩略图,`full/` 是原图。
9+
* @ai-context **诚实边界(不得越界声称)**:① 「掠过几帧」是**翻图**不是视频抽帧 —— 帧率由屏数决定
10+
* (PB1 逐字:`full/<ms>.webp` + `thumb/` 双层约定);② **观感未测**(jsdom 不排版);③ `screens`
11+
* 稀疏(或 `image_ref` 全空 / `imageUrl` 槽未接线)⇒ **整条不渲染**(不占位、不拉伸时间轴)。
12+
* 副作用:无(纯展示;一次 `<img>` 的惰性加载由浏览器负责,`loading="lazy"`)。无 I/O、无订阅。
13+
* 边界:① `targetMs === null` ⇒ 不渲染(没有目标就没有「掠过」);② ref 由调用方(回跳编排)持有
14+
* —— 本件只把它挂到根上,`transform` 的写入全在 `usePlayheadJump`(本件不碰 GSAP)。
15+
*/
16+
import type { CSSProperties, ReactElement, RefObject } from "react";
17+
import type { SessionViewSlot } from "../registry";
18+
import { screensAround, thumbRefOf } from "./screensFor";
19+
20+
/** 布局样式(**只许布局**,ADR-033 §4):横排、间距与高度下限由条自己定(尺寸不靠父级) */
21+
const STRIP: CSSProperties = { display: "flex", alignItems: "center", gap: 6, minHeight: 27 };
22+
/** 单帧:16:9 的小图(320px 级缩略图的展示尺寸;不是原图) */
23+
const THUMB: CSSProperties = { display: "block", width: 48, height: 27, objectFit: "cover" };
24+
25+
interface Props {
26+
/** 屏数据面(槽里的 `detail.screens`;只读);`undefined` = 宿主未接线 ⇒ 不出条 */
27+
readonly screens?: SessionViewSlot["detail"]["screens"];
28+
/** 配图 URL 槽(容器注入);`undefined` = 宿主未接线 ⇒ 不出条 */
29+
readonly imageUrl?: SessionViewSlot["imageUrl"];
30+
/** 目标位置(毫秒,会话轴);`null` = 无目标 ⇒ 不出条 */
31+
readonly targetMs: number | null;
32+
/** 掠过帧数(真源 `screensFor.framesFor`;调用方按档位/reduced-motion 派生后传入) */
33+
readonly frames: number;
34+
/** 掠过条容器的登记口(回跳编排把 ≤8px 的位移写到这里) */
35+
readonly stripRef: RefObject<HTMLDivElement | null>;
36+
}
37+
38+
/** 掠过条:目标邻域的最近 `frames` 帧缩略图(稀疏 ⇒ `null`)。 */
39+
export default function ThumbStrip({ screens, imageUrl, targetMs, frames, stripRef }: Props): ReactElement | null {
40+
if (targetMs === null || imageUrl === undefined || screens === undefined) return null;
41+
const shots: { readonly key: number; readonly src: string }[] = [];
42+
for (const screen of screensAround(screens, targetMs, frames)) {
43+
if (screen.image_ref === null) continue;
44+
const src = imageUrl(thumbRefOf(screen.image_ref));
45+
if (src !== null) shots.push({ key: screen.first_seen_ms, src }); // `src === null` ⇒ 那一帧不占位
46+
}
47+
if (shots.length === 0) return null; // 稀疏降级:不显示缩略(**不**把少数的屏摊开充数)
48+
return (
49+
<div ref={stripRef} style={STRIP} data-testid="session-timerail-thumbs" data-frames={shots.length}>
50+
{/* 装饰帧:语义由轨上的刻度与时间码承载 ⇒ `alt=""`(不给屏幕阅读器复读一遍) */}
51+
{shots.map((shot, i) => (
52+
<img key={shot.key} src={shot.src} alt="" loading="lazy" style={THUMB} data-thumb-index={i} />
53+
))}
54+
</div>
55+
);
56+
}

0 commit comments

Comments
 (0)