Skip to content

Commit 5d54f1a

Browse files
committed
feat(components): 采集期转写行挂上逐段显影
1 parent 747cd8c commit 5d54f1a

2 files changed

Lines changed: 151 additions & 17 deletions

File tree

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
// @vitest-environment jsdom
2+
/**
3+
* TranscriptRow.test.tsx — 采集期转写行渲染 + **逐段显影**(#2)在采集期的落点判据(批 7 T20)。
4+
*
5+
* @ai-context 本文件守两件事(各自能独立变红):
6+
* ① **行渲染形态**(拆件守恒):定稿行的时间码/来源色点/正文、未沉淀行的「按句读一对多 + 尾段 …」
7+
* —— 这是 `LiveTranscriptStream.tsx` 拆件后**新增的**回归面(拆前该文件无同名测试)。
8+
* ② **显影的落点锚点**(§C9.16 的 7b 半 · R8.5 结构锚点优先于时间判据):逐行一个容器(`ref`)+
9+
* `data-seg-id`(`SEGMENT_SELECTOR`)+ `data-tone="instrument"`(R3.4)+ `data-reveal-epoch`,
10+
* 且**起始态真的被物化进 DOM**(R50.1:手写 `style.transform` 无效,唯一正解是出口的零时长 tween)。
11+
* @ai-context 为什么还要一条**源码级**判据:DOM 侧证明不了「每行一个 `ref`」这个接线本身(`ref` 不落
12+
* DOM)。故附一条先剥注释的结构探针:本件必须**恰好一处** `ref={box}` 与**一处** `data-seg-id`
13+
* (多一处 = 有人又加了一条并行的显影通道;少一处 = 接线被删)。带**负控**(不存在的串 0 命中)。
14+
* @ai-context 仪器局限:jsdom **不做布局、不 paint** ⇒ 判的是「锚点与起始态存在」,**不是**观感/帧率
15+
* (像素与手感归批 8,见 `## 诚实边界`)。`eco` / reduced-motion 下的「零在场 tween」由
16+
* `useRevealChoreography.test.tsx` 自己钉,本件不复制第二份 matchMedia 分支。
17+
* 副作用:挂/卸真实 DOM、触发一次动态 import(GSAP 出口)、读写档位记忆(localStorage)。
18+
*/
19+
import { readFileSync } from "node:fs";
20+
import { dirname, join } from "node:path";
21+
import { fileURLToPath } from "node:url";
22+
import { cleanup, render, waitFor } from "@testing-library/react";
23+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
24+
import { stripComments } from "../ui/primitives/sliceScan";
25+
import { fmtMs } from "../utils/fmt";
26+
import { PendingRows, TranscriptRow } from "./TranscriptRow";
27+
import type { PendingLine, TranscriptLine } from "./TranscriptRow";
28+
29+
const HERE = dirname(fileURLToPath(import.meta.url));
30+
/** 先剥注释再扫(先例 `sliceScan.stripComments`)⇒ 文件头注释里提到 `data-seg-id` 不产生幻影命中 */
31+
const SRC = stripComments(readFileSync(join(HERE, "TranscriptRow.tsx"), "utf8"));
32+
const countOf = (needle: string): number => SRC.split(needle).length - 1;
33+
34+
const LINE: TranscriptLine = { id: 7, time: 65_000, source: "subtitle", text: "这一句是定稿的字幕行" };
35+
const ASR_LINE: TranscriptLine = { id: 8, time: 66_000, source: "asr", text: "这一句是定稿的语音行" };
36+
const PENDING: readonly PendingLine[] = [
37+
{ id: 1, time: 70_000, text: "已定稿待沉淀", committed: true },
38+
{ id: 2, time: 0, text: "第一句。第二句还没说完", committed: false },
39+
];
40+
41+
const rowOf = (id: number): HTMLElement | null => document.querySelector<HTMLElement>(`[data-seg-id="live-${id}"]`);
42+
43+
beforeEach(() => localStorage.clear());
44+
afterEach(cleanup);
45+
46+
describe("T20-B① · 行渲染形态(拆件后新增的回归面)", () => {
47+
it("定稿行:时间码经 `fmtMs` · 来源色点带 title 与来源色 · 正文逐字", () => {
48+
const { container } = render(<TranscriptRow line={LINE} fmtTime={fmtMs} />);
49+
expect(container.textContent, "时间码必须是 `fmtMs` 的输出(不许就地另写一份格式化)").toContain(fmtMs(65_000));
50+
expect(container.textContent, "正文逐字").toContain(LINE.text);
51+
const dot = container.querySelector<HTMLElement>('[title="字幕"]');
52+
expect(dot, "来源色点的 title 是「字幕」/「语音」二选一").not.toBeNull();
53+
expect(dot?.style.background, "字幕色点 = 品牌青(拆前逐字)").toBe("rgb(13, 148, 136)");
54+
});
55+
56+
it("语音行:色点 title 与色值随来源切换(与字幕行**可区分**)", () => {
57+
const { container } = render(<TranscriptRow line={ASR_LINE} fmtTime={fmtMs} />);
58+
const dot = container.querySelector<HTMLElement>('[title="语音"]');
59+
expect(dot, "语音色点的 title").not.toBeNull();
60+
expect(dot?.style.background, "语音色点 = 弱化灰(拆前逐字)").toBe("rgb(156, 163, 175)");
61+
});
62+
63+
it("未沉淀行:已定稿 1 行 + 识别中按句读**一对多**展开且尾段加 …(拆前行为逐字)", () => {
64+
const { container } = render(<PendingRows partials={PENDING} fmtTime={fmtMs} elapsedMs={71_000} />);
65+
const text = container.textContent ?? "";
66+
expect(text, "已定稿待沉淀行原样出").toContain("已定稿待沉淀");
67+
expect(text, "识别中按句读切分:第一句(带句读)").toContain("第一句。");
68+
expect(text, "尾段(无句读)加 … —— 它是「仍在识别」的唯一可见信号").toContain("第二句还没说完…");
69+
expect(text, "首行带的是 elapsedMs 的实时时钟,不是行时间").toContain(fmtMs(71_000));
70+
expect(container.querySelectorAll('[title="识别中"]'), "识别中按句读**一对多**:两句 ⇒ 两个识别中色点(拆前形态)").toHaveLength(2);
71+
});
72+
});
73+
74+
describe("T20-B② · 逐段显影在采集期的落点锚点(§C9.16 的 7b 半)", () => {
75+
it("逐行结构锚点:`data-seg-id` 在**基调容器**上、容器是行本体的父节点(= 逐行一个 ref)", () => {
76+
const { container } = render(<TranscriptRow line={LINE} fmtTime={fmtMs} />);
77+
const row = rowOf(7);
78+
expect(row, "`data-seg-id=live-<id>` 缺 ⇒ `SEGMENT_SELECTOR` 取不到这一行").not.toBeNull();
79+
expect(container.querySelectorAll("[data-seg-id]"), "一行恰一个段锚点").toHaveLength(1);
80+
expect(row?.getAttribute("data-tone"), "R3.4:每个动效落点显式声明基调(采集面 = 精密仪器)").toBe("instrument");
81+
expect(row?.getAttribute("data-reveal-epoch"), "R8.5 的结构锚点:持有的世代号在 DOM 上").toBe("0");
82+
expect(row?.parentElement?.className, "行本体挂在**容器**里(容器 = 被动画的那个块;行本体是它的子节点)").toBe("");
83+
expect(row?.parentElement?.parentElement, "容器再往上是渲染宿主").toBe(container);
84+
});
85+
86+
it("起始态被物化进 DOM:出口的零时长 tween 真写了 `transform`(R50.1:手写 style 无效)", async () => {
87+
render(<TranscriptRow line={LINE} fmtTime={fmtMs} />);
88+
await waitFor(
89+
() => expect(rowOf(7)?.style.transform ?? "", "显影的起点没有落进 DOM ⇒ 出口没被接上").not.toBe(""),
90+
{ timeout: 5000 },
91+
);
92+
});
93+
94+
it("源码级结构探针(先剥注释):`ref={box}` 与 `data-seg-id` **各恰一处** + 负控 0 命中", () => {
95+
expect(countOf("ref={box}"), "逐行一个容器 `ref`(多一处 = 并行的第二条显影通道)").toBe(1);
96+
expect(countOf("data-seg-id"), "段锚点恰一处").toBe(1);
97+
expect(countOf("useRevealChoreography(box"), "逐行调用显影编排(整列表一个容器会旧行重播)").toBe(1);
98+
expect(countOf("zzzNoSuchAnchorZzz"), "负控:不存在的串必须 0 命中(否则上面的计数不可信)").toBe(0);
99+
});
100+
});

‎app/src/components/TranscriptRow.tsx‎

Lines changed: 51 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,10 @@
1414
* 副作用:无(纯函数式渲染)。边界:`partials` 里 `committed === false` 的行按句读**一对多**展开
1515
* (一行文本出多行),故本件返回的是**数组**而非单元素(`<>…</>` 不产生 DOM 节点)。
1616
*/
17+
import { useMemo, useRef } from "react";
1718
import type { ReactElement } from "react";
19+
import { useRevealChoreography } from "./session-detail/useRevealChoreography";
20+
import type { RevealSegment } from "./session-detail/useRevealChoreography";
1821
import { Text } from "../ui/primitives";
1922

2023
/** 定稿转写行(字幕或语音) */
@@ -68,25 +71,56 @@ function hasText(seg: string): boolean {
6871
return seg.trim().length > 0 && !/^[。!?!?…\s]+$/.test(seg);
6972
}
7073

71-
/** 一条**定稿**转写行(时间码 · 来源色点 · 正文)—— 形态与拆前逐字相同 */
74+
/**
75+
* 一条**定稿**转写行(时间码 · 来源色点 · 正文)—— 形态与拆前逐字相同。
76+
*
77+
* @ai-context 批 7 T20 的 7b 半(§C9.16):本件是「逐段显影」(#2,`useRevealChoreography`)在
78+
* **采集期**的落点(课后落点 = `session-detail/SessionRawView.tsx`,批 6 T28)。两处**同一支动效**:
79+
* ① 这一行是**容器**(`ref`),② 行本体带 `data-seg-id`(`SEGMENT_SELECTOR` 的锚点),
80+
* ③ `data-tone="instrument"`(R3.4 逐字「每个动效落点显式声明基调」—— 采集面属**精密仪器**族)。
81+
* @ai-context 🔴 **为什么逐行调用 hook、而不是整列表一个容器**:转写行是**流式追加**的 ——
82+
* 整列表一个容器时,每到达一行都会让 `segments` 数组换新 ⇒ `delays` 换新 ⇒ 效果重跑 ⇒
83+
* **全部旧行一起重播**(每分钟几十次跳动)。逐行调用后:段数组逐行 memo 化(只认本行的
84+
* `text` / `time`)⇒ **只有新挂载的那一行显影一次**,旧行的依赖一个字不变、不重播。
85+
* @ai-context 时间轴投影:直播行**没有 `end_ms`**(到达即定稿)⇒ 传退化段(`start_ms === end_ms`),
86+
* `charRate` 走 `len / 1` 的已登记退化口径(见 `useRevealChoreography.ts` 文件头)。
87+
* 位移只由 hook 经出口写(本件**不自持**起始态、不写 `style.transform`)。
88+
* @ai-context 边界:`eco` / reduced-motion ⇒ 出口直接落终态、零在场 tween(hook 内部决定,本件不复制
89+
* 第二份 `matchMedia` 分支);**未沉淀行(`PendingRows`)不挂显影** —— 它们的文本每来一个 partial
90+
* 就整体换新,挂上去等于「每次识别都重播」,那不是显影而是抖动。⚠️ 逐行观感/真实帧率**本批未测**
91+
* (jsdom 无排版、无 paint;像素与手感归批 8)。
92+
*/
7293
export function TranscriptRow({ line, fmtTime }: { line: TranscriptLine; fmtTime: (ms: number) => string }): ReactElement {
94+
const box = useRef<HTMLDivElement | null>(null);
95+
const segs = useMemo<RevealSegment[]>(
96+
() => [{ text: line.text, start_ms: line.time, end_ms: line.time }],
97+
[line.text, line.time],
98+
);
99+
const reveal = useRevealChoreography(box, segs);
73100
return (
74-
<div style={{ display: "flex", gap: 8, alignItems: "baseline", fontSize: 13, lineHeight: 1.6 }}>
75-
<Text tone="ink-3" style={{ fontSize: 11, width: 44, flexShrink: 0, fontVariantNumeric: "tabular-nums" }}>
76-
{fmtTime(line.time)}
77-
</Text>
78-
<span
79-
title={line.source === "subtitle" ? "字幕" : "语音"}
80-
style={{
81-
width: 8,
82-
height: 8,
83-
borderRadius: 4,
84-
flexShrink: 0,
85-
alignSelf: "center",
86-
background: line.source === "subtitle" ? "#0d9488" : "#9ca3af",
87-
}}
88-
/>
89-
<span style={{ color: line.source === "subtitle" ? "#0f766e" : "#374151" }}>{line.text}</span>
101+
<div ref={box}>
102+
<div
103+
data-seg-id={`live-${line.id}`}
104+
data-tone="instrument"
105+
data-reveal-epoch={reveal.revealEpoch}
106+
style={{ display: "flex", gap: 8, alignItems: "baseline", fontSize: 13, lineHeight: 1.6 }}
107+
>
108+
<Text tone="ink-3" style={{ fontSize: 11, width: 44, flexShrink: 0, fontVariantNumeric: "tabular-nums" }}>
109+
{fmtTime(line.time)}
110+
</Text>
111+
<span
112+
title={line.source === "subtitle" ? "字幕" : "语音"}
113+
style={{
114+
width: 8,
115+
height: 8,
116+
borderRadius: 4,
117+
flexShrink: 0,
118+
alignSelf: "center",
119+
background: line.source === "subtitle" ? "#0d9488" : "#9ca3af",
120+
}}
121+
/>
122+
<span style={{ color: line.source === "subtitle" ? "#0f766e" : "#374151" }}>{line.text}</span>
123+
</div>
90124
</div>
91125
);
92126
}

0 commit comments

Comments
 (0)