From 509c9ae7f5cf41d1fba9b24750d42de2b4c16788 Mon Sep 17 00:00:00 2001 From: sunyuchenyaobo <261746743+sunyuchenyaobo@users.noreply.github.com> Date: Sun, 30 Aug 2026 21:07:30 +0800 Subject: [PATCH 1/3] feat(document): add immutable transcript word edits --- .../ai-edition/document/transcript.test.ts | 263 ++++++++++++++++++ src/lib/ai-edition/document/transcript.ts | 68 +++++ 2 files changed, 331 insertions(+) create mode 100644 src/lib/ai-edition/document/transcript.test.ts create mode 100644 src/lib/ai-edition/document/transcript.ts diff --git a/src/lib/ai-edition/document/transcript.test.ts b/src/lib/ai-edition/document/transcript.test.ts new file mode 100644 index 000000000..1678374bc --- /dev/null +++ b/src/lib/ai-edition/document/transcript.test.ts @@ -0,0 +1,263 @@ +import { describe, expect, it } from "vitest"; +import type { AxcutTranscript } from "../schema"; +import { setWordText } from "./transcript"; + +function fixture(language = "en"): AxcutTranscript { + return { + assetId: "asset_1", + language, + sourceDslPath: "transcript.dsl", + sourceJsonPath: "transcript.json", + segments: [ + { + id: "segment_1", + kind: "speech", + startSec: 1, + endSec: 4, + text: "I use OpenScreen", + wordIds: ["word_1", "word_2", "word_3"], + }, + { + id: "segment_2", + kind: "speech", + startSec: 5, + endSec: 6, + text: "Untouched segment", + wordIds: ["word_4", "word_5"], + }, + ], + // Deliberately shuffled: segment.wordIds, not this array, defines segment order. + words: [ + { id: "word_3", segmentId: "segment_1", startSec: 3, endSec: 4, text: "OpenScreen" }, + { id: "word_1", segmentId: "segment_1", startSec: 1, endSec: 2, text: "I" }, + { id: "word_5", segmentId: "segment_2", startSec: 5.5, endSec: 6, text: "segment" }, + { id: "word_2", segmentId: "segment_1", startSec: 2, endSec: 3, text: "use" }, + { id: "word_4", segmentId: "segment_2", startSec: 5, endSec: 5.5, text: "Untouched" }, + ], + }; +} + +function transcriptForTokens(language: string, tokens: string[]): AxcutTranscript { + const wordIds = tokens.map((_, index) => `word_${index + 1}`); + return { + assetId: "asset_tokens", + language, + segments: [ + { + id: "segment_tokens", + kind: "speech", + startSec: 0, + endSec: tokens.length, + text: tokens.join(" "), + wordIds, + }, + { + id: "segment_other", + kind: "speech", + startSec: 20, + endSec: 21, + text: "other", + wordIds: ["word_other"], + }, + ], + words: [ + ...tokens.map((text, index) => ({ + id: wordIds[index], + segmentId: "segment_tokens", + startSec: index, + endSec: index + 1, + text, + })), + { + id: "word_other", + segmentId: "segment_other", + startSec: 20, + endSec: 21, + text: "other", + }, + ], + }; +} + +describe("setWordText", () => { + it("immutably updates the exact word and rebuilds only its owning segment", () => { + const transcript = fixture(); + const originalSnapshot = structuredClone(transcript); + const originalTarget = transcript.words.find((word) => word.id === "word_2"); + const originalOtherWord = transcript.words.find((word) => word.id === "word_4"); + const originalOtherSegment = transcript.segments[1]; + + const result = setWordText(transcript, "word_2", "prefer"); + + expect(result).not.toBe(transcript); + expect(result.words.map((word) => word.id)).toEqual(transcript.words.map((word) => word.id)); + expect(result.segments.map((segment) => segment.id)).toEqual( + transcript.segments.map((segment) => segment.id), + ); + expect(result.words.find((word) => word.id === "word_2")).toEqual({ + ...originalTarget, + text: "prefer", + }); + expect(result.segments[0]).toEqual({ + ...transcript.segments[0], + text: "I prefer OpenScreen", + }); + for (const originalWord of transcript.words) { + if (originalWord.id !== "word_2") { + expect(result.words.find((word) => word.id === originalWord.id)).toBe(originalWord); + } + } + expect(result.words.find((word) => word.id === "word_4")).toBe(originalOtherWord); + expect(result.segments[1]).toBe(originalOtherSegment); + expect(result.assetId).toBe("asset_1"); + expect(result.language).toBe("en"); + expect(result.sourceDslPath).toBe("transcript.dsl"); + expect(result.sourceJsonPath).toBe("transcript.json"); + expect(transcript).toEqual(originalSnapshot); + }); + + it("uses segment.wordIds order even when transcript.words is shuffled", () => { + const result = setWordText(fixture(), "word_3", "Studio"); + + expect(result.segments[0].text).toBe("I use Studio"); + expect(result.words.map((word) => word.id)).toEqual([ + "word_3", + "word_1", + "word_5", + "word_2", + "word_4", + ]); + }); + + it("joins English words with one space", () => { + const result = setWordText( + transcriptForTokens("en", ["I", "use", "OpenScreen"]), + "word_2", + "prefer", + ); + + expect(result.segments[0].text).toBe("I prefer OpenScreen"); + }); + + it("preserves the passed word text exactly while trimming its segment contribution", () => { + const result = setWordText(fixture(), "word_2", " prefer "); + + expect(result.words.find((word) => word.id === "word_2")?.text).toBe(" prefer "); + expect(result.segments[0].text).toBe("I prefer OpenScreen"); + }); + + it.each([ + "zh", + "zh-CN", + "zh-TW", + "ZH-cn", + ])("does not add artificial spaces between adjacent Chinese content for %s", (language) => { + const result = setWordText(transcriptForTokens(language, ["你", "好", "世界"]), "word_2", "们"); + + expect(result.segments[0].text).toBe("你们世界"); + }); + + it.each([ + "ja", + "ja-JP", + "JA-jp", + ])("does not add artificial spaces between adjacent Japanese content for %s", (language) => { + const result = setWordText( + transcriptForTokens(language, ["私", "は", "テスト", "です"]), + "word_3", + "開発者", + ); + + expect(result.segments[0].text).toBe("私は開発者です"); + }); + + it("does not add a space after Chinese closing punctuation between CJK tokens", () => { + const result = setWordText(transcriptForTokens("zh-CN", ["你好,", "世"]), "word_2", "世界"); + + expect(result.segments[0].text).toBe("你好,世界"); + }); + + it("does not add a space after Japanese closing punctuation between CJK tokens", () => { + const result = setWordText( + transcriptForTokens("ja-JP", ["これは。", "試験"]), + "word_2", + "テスト", + ); + + expect(result.segments[0].text).toBe("これは。テスト"); + }); + + it("keeps readable boundaries in mixed CJK and Latin content", () => { + const result = setWordText( + transcriptForTokens("zh-CN", ["我们用", "GitHub", "Action", "部署"]), + "word_3", + "Actions", + ); + + expect(result.segments[0].text).toBe("我们用 GitHub Actions 部署"); + }); + + it("does not put spaces before common closing punctuation", () => { + const result = setWordText( + transcriptForTokens("en", ["Hello", ",", "world", "?"]), + "word_4", + "!", + ); + + expect(result.segments[0].text).toBe("Hello, world!"); + }); + + it("does not put spaces immediately after common opening punctuation", () => { + const result = setWordText(transcriptForTokens("en", ["(", "hello", ")"]), "word_2", "world"); + + expect(result.segments[0].text).toBe("(world)"); + }); + + it.each([ + { tokens: ["I", "use", "OpenScreen"], targetId: "word_2", expected: "I OpenScreen" }, + { tokens: ["I", "use", "OpenScreen"], targetId: "word_1", expected: "use OpenScreen" }, + { tokens: ["I", "use", "OpenScreen"], targetId: "word_3", expected: "I use" }, + ])("keeps the emptied word but creates no duplicate or edge whitespace", ({ + tokens, + targetId, + expected, + }) => { + const result = setWordText(transcriptForTokens("en", tokens), targetId, ""); + + expect(result.words.find((word) => word.id === targetId)?.text).toBe(""); + expect(result.segments[0].text).toBe(expected); + }); + + it.each(["missing_word", "silence_1"])("rejects non-document word ID %s", (wordId) => { + expect(() => setWordText(fixture(), wordId, "replacement")).toThrowError(wordId); + }); + + it("rejects a target whose owning segment is missing", () => { + const transcript = fixture(); + const target = transcript.words.find((word) => word.id === "word_2"); + if (!target) throw new Error("fixture target missing"); + target.segmentId = "segment_missing"; + + expect(() => setWordText(transcript, "word_2", "replacement")).toThrowError( + /word_2.*segment_missing|segment_missing.*word_2/, + ); + }); + + it("rejects an owning segment that references a missing word", () => { + const transcript = fixture(); + transcript.segments[0].wordIds.splice(1, 0, "word_missing"); + + expect(() => setWordText(transcript, "word_2", "replacement")).toThrowError( + /segment_1.*word_missing|word_missing.*segment_1/, + ); + }); + + it("rejects an owning segment that omits the target word", () => { + const transcript = fixture(); + transcript.segments[0].wordIds = ["word_1", "word_3"]; + + expect(() => setWordText(transcript, "word_2", "replacement")).toThrowError( + /segment_1.*word_2|word_2.*segment_1/, + ); + }); +}); diff --git a/src/lib/ai-edition/document/transcript.ts b/src/lib/ai-edition/document/transcript.ts new file mode 100644 index 000000000..45e538d88 --- /dev/null +++ b/src/lib/ai-edition/document/transcript.ts @@ -0,0 +1,68 @@ +import type { AxcutTranscript } from "../schema"; + +const CJK_EDGE = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}]/u; +const CLOSING_PUNCTUATION = /^[,.;:!?%。,、;:!?…))\]}>》」』】〕]/u; +const TRAILING_CLOSING_PUNCTUATION = /[,.;:!?%。,、;:!?…))\]}>》」』】〕]+$/u; +const OPENING_PUNCTUATION = /[([<{《「『【〔(]$/u; + +function joinSegmentText(language: string, texts: string[]): string { + const tokens = texts.map((text) => text.trim()).filter((text) => text.length > 0); + const primaryLanguage = language.split("-")[0].toLowerCase(); + const compactCjk = primaryLanguage === "zh" || primaryLanguage === "ja"; + + return tokens.reduce((joined, token) => { + if (joined.length === 0) return token; + if (CLOSING_PUNCTUATION.test(token) || OPENING_PUNCTUATION.test(joined)) { + return joined + token; + } + const leftContentEdge = joined.replace(TRAILING_CLOSING_PUNCTUATION, "").at(-1) ?? ""; + if (compactCjk && CJK_EDGE.test(leftContentEdge) && CJK_EDGE.test(token[0] ?? "")) { + return joined + token; + } + return `${joined} ${token}`; + }, ""); +} + +export function setWordText( + transcript: AxcutTranscript, + wordId: string, + text: string, +): AxcutTranscript { + const targetWord = transcript.words.find((word) => word.id === wordId); + if (!targetWord) { + throw new Error(`Cannot set text for missing transcript word "${wordId}"`); + } + + const owningSegment = transcript.segments.find((segment) => segment.id === targetWord.segmentId); + if (!owningSegment) { + throw new Error( + `Transcript word "${wordId}" references missing segment "${targetWord.segmentId}"`, + ); + } + if (!owningSegment.wordIds.includes(wordId)) { + throw new Error(`Segment "${owningSegment.id}" does not reference target word "${wordId}"`); + } + + const wordsById = new Map(transcript.words.map((word) => [word.id, word])); + for (const referencedWordId of owningSegment.wordIds) { + if (!wordsById.has(referencedWordId)) { + throw new Error( + `Segment "${owningSegment.id}" references missing word "${referencedWordId}"`, + ); + } + } + + const words = transcript.words.map((word) => (word.id === wordId ? { ...word, text } : word)); + const updatedWordsById = new Map(words.map((word) => [word.id, word])); + const segmentText = joinSegmentText( + transcript.language, + owningSegment.wordIds.map( + (referencedWordId) => updatedWordsById.get(referencedWordId)?.text ?? "", + ), + ); + const segments = transcript.segments.map((segment) => + segment.id === owningSegment.id ? { ...segment, text: segmentText } : segment, + ); + + return { ...transcript, words, segments }; +} From 7461e2e42ba496853864b23629f108635f4a7afe Mon Sep 17 00:00:00 2001 From: sunyuchenyaobo <261746743+sunyuchenyaobo@users.noreply.github.com> Date: Sun, 30 Aug 2026 21:51:35 +0800 Subject: [PATCH 2/3] fix(document): validate referenced word ownership and join CJK independently of language tag --- .../ai-edition/document/transcript.test.ts | 11 ++++++++++ src/lib/ai-edition/document/transcript.ts | 21 ++++++++++++------- 2 files changed, 25 insertions(+), 7 deletions(-) diff --git a/src/lib/ai-edition/document/transcript.test.ts b/src/lib/ai-edition/document/transcript.test.ts index 1678374bc..f9ed108a7 100644 --- a/src/lib/ai-edition/document/transcript.test.ts +++ b/src/lib/ai-edition/document/transcript.test.ts @@ -151,6 +151,8 @@ describe("setWordText", () => { "zh-CN", "zh-TW", "ZH-cn", + "auto", + "yue", ])("does not add artificial spaces between adjacent Chinese content for %s", (language) => { const result = setWordText(transcriptForTokens(language, ["你", "好", "世界"]), "word_2", "们"); @@ -252,6 +254,15 @@ describe("setWordText", () => { ); }); + it("rejects an owning segment that references a word owned by another segment", () => { + const transcript = fixture(); + transcript.segments[0].wordIds.push("word_4"); + + expect(() => setWordText(transcript, "word_2", "replacement")).toThrowError( + /segment_1.*word_4.*segment_2/, + ); + }); + it("rejects an owning segment that omits the target word", () => { const transcript = fixture(); transcript.segments[0].wordIds = ["word_1", "word_3"]; diff --git a/src/lib/ai-edition/document/transcript.ts b/src/lib/ai-edition/document/transcript.ts index 45e538d88..7d516dde1 100644 --- a/src/lib/ai-edition/document/transcript.ts +++ b/src/lib/ai-edition/document/transcript.ts @@ -5,18 +5,20 @@ const CLOSING_PUNCTUATION = /^[,.;:!?%。,、;:!?…))\]}>》」』 const TRAILING_CLOSING_PUNCTUATION = /[,.;:!?%。,、;:!?…))\]}>》」』】〕]+$/u; const OPENING_PUNCTUATION = /[([<{《「『【〔(]$/u; -function joinSegmentText(language: string, texts: string[]): string { +// The CJK-compaction rule is deliberately LANGUAGE-AGNOSTIC: two adjacent Han / +// Hiragana / Katakana characters never carry a space between them in any script +// that uses them. Gating it on the `language` tag would corrupt transcripts whose +// stored tag is "auto" (a real persisted value — see transcribe.ts's language +// fallback) or "yue": the join would inject ASCII spaces between Chinese runs. +function joinSegmentText(texts: string[]): string { const tokens = texts.map((text) => text.trim()).filter((text) => text.length > 0); - const primaryLanguage = language.split("-")[0].toLowerCase(); - const compactCjk = primaryLanguage === "zh" || primaryLanguage === "ja"; - return tokens.reduce((joined, token) => { if (joined.length === 0) return token; if (CLOSING_PUNCTUATION.test(token) || OPENING_PUNCTUATION.test(joined)) { return joined + token; } const leftContentEdge = joined.replace(TRAILING_CLOSING_PUNCTUATION, "").at(-1) ?? ""; - if (compactCjk && CJK_EDGE.test(leftContentEdge) && CJK_EDGE.test(token[0] ?? "")) { + if (CJK_EDGE.test(leftContentEdge) && CJK_EDGE.test(token[0] ?? "")) { return joined + token; } return `${joined} ${token}`; @@ -45,17 +47,22 @@ export function setWordText( const wordsById = new Map(transcript.words.map((word) => [word.id, word])); for (const referencedWordId of owningSegment.wordIds) { - if (!wordsById.has(referencedWordId)) { + const referencedWord = wordsById.get(referencedWordId); + if (!referencedWord) { throw new Error( `Segment "${owningSegment.id}" references missing word "${referencedWordId}"`, ); } + if (referencedWord.segmentId !== owningSegment.id) { + throw new Error( + `Segment "${owningSegment.id}" references word "${referencedWordId}" which belongs to segment "${referencedWord.segmentId}"`, + ); + } } const words = transcript.words.map((word) => (word.id === wordId ? { ...word, text } : word)); const updatedWordsById = new Map(words.map((word) => [word.id, word])); const segmentText = joinSegmentText( - transcript.language, owningSegment.wordIds.map( (referencedWordId) => updatedWordsById.get(referencedWordId)?.text ?? "", ), From e84e55db04255b4365f4fd6b95b6ce92e30b7d60 Mon Sep 17 00:00:00 2001 From: sunyuchenyaobo <261746743+sunyuchenyaobo@users.noreply.github.com> Date: Sun, 30 Aug 2026 22:01:29 +0800 Subject: [PATCH 3/3] fix(document): read CJK segment edges by code point for non-BMP Han --- src/lib/ai-edition/document/transcript.test.ts | 16 ++++++++++++++++ src/lib/ai-edition/document/transcript.ts | 7 +++++-- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/src/lib/ai-edition/document/transcript.test.ts b/src/lib/ai-edition/document/transcript.test.ts index f9ed108a7..a1c872ffb 100644 --- a/src/lib/ai-edition/document/transcript.test.ts +++ b/src/lib/ai-edition/document/transcript.test.ts @@ -159,6 +159,22 @@ describe("setWordText", () => { expect(result.segments[0].text).toBe("你们世界"); }); + it("does not add a space after a non-BMP Han word (edge read by code point)", () => { + const result = setWordText(transcriptForTokens("zh", ["\u{20000}", "好"]), "word_2", "世界"); + + expect(result.segments[0].text).toBe("\u{20000}世界"); + }); + + it("does not add a space before a token starting with a non-BMP Han character", () => { + const result = setWordText( + transcriptForTokens("zh", ["好", "\u{20000}"]), + "word_2", + "\u{20000}", + ); + + expect(result.segments[0].text).toBe("好\u{20000}"); + }); + it.each([ "ja", "ja-JP", diff --git a/src/lib/ai-edition/document/transcript.ts b/src/lib/ai-edition/document/transcript.ts index 7d516dde1..f64588f9e 100644 --- a/src/lib/ai-edition/document/transcript.ts +++ b/src/lib/ai-edition/document/transcript.ts @@ -17,8 +17,11 @@ function joinSegmentText(texts: string[]): string { if (CLOSING_PUNCTUATION.test(token) || OPENING_PUNCTUATION.test(joined)) { return joined + token; } - const leftContentEdge = joined.replace(TRAILING_CLOSING_PUNCTUATION, "").at(-1) ?? ""; - if (CJK_EDGE.test(leftContentEdge) && CJK_EDGE.test(token[0] ?? "")) { + const leftContentEdge = [...joined.replace(TRAILING_CLOSING_PUNCTUATION, "")].at(-1) ?? ""; + // Spread reads the edges by CODE POINT: `.at(-1)` / `[0]` would return half a + // surrogate pair, so a non-BMP Han edge (e.g. U+20000) would miss CJK_EDGE + // and receive an ASCII space. + if (CJK_EDGE.test(leftContentEdge) && CJK_EDGE.test([...token][0] ?? "")) { return joined + token; } return `${joined} ${token}`;