From 4124ce52ed87d654637c664bef3e7c27bd01aa55 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/6] 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 c48578d4dde8757001717157981ac19bd4fcb9bb 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/6] 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 75eed157d05ce20e6d84ac2fe31d5b0d64462076 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/6] 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}`; From 4ac9b4d62647c49cd943e1a1e67f2851361ff427 Mon Sep 17 00:00:00 2001 From: sunyuchenyaobo <261746743+sunyuchenyaobo@users.noreply.github.com> Date: Wed, 2 Sep 2026 15:12:34 +0800 Subject: [PATCH 4/6] feat(document): map plain-text transcript edits to words --- .../ai-edition/document/transcribe.test.ts | 21 +- .../ai-edition/document/transcript.test.ts | 233 ++++++++++++++++- src/lib/ai-edition/document/transcript.ts | 244 ++++++++++++++++-- 3 files changed, 481 insertions(+), 17 deletions(-) diff --git a/src/lib/ai-edition/document/transcribe.test.ts b/src/lib/ai-edition/document/transcribe.test.ts index 034abf212..c2247770c 100644 --- a/src/lib/ai-edition/document/transcribe.test.ts +++ b/src/lib/ai-edition/document/transcribe.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from "vitest"; import { type AxcutDocument, axcutSchemaVersion } from "../schema"; -import { transcribeAsset } from "./transcribe"; +import { transcribeAsset, withTranscript } from "./transcribe"; vi.mock("@/components/video-editor/projectPersistence", () => ({ toFileUrl: (path: string) => `file://${path}`, @@ -120,3 +120,22 @@ describe("transcribeAsset language handling", () => { expect(t.language).toBe("auto"); }); }); + +describe("withTranscript", () => { + it("updates transcripts[] and the primary asset's legacy top-level transcript together", () => { + const doc = makeDoc(); + const transcript = { + assetId: "asset_1", + language: "en", + segments: [], + words: [], + }; + + const next = withTranscript(doc, transcript); + + expect(next.transcripts).toEqual([transcript]); + expect(next.transcript).toBe(transcript); + expect(doc.transcripts).toEqual([]); + expect(doc.transcript).toBeNull(); + }); +}); diff --git a/src/lib/ai-edition/document/transcript.test.ts b/src/lib/ai-edition/document/transcript.test.ts index a1c872ffb..3052d437c 100644 --- a/src/lib/ai-edition/document/transcript.test.ts +++ b/src/lib/ai-edition/document/transcript.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from "vitest"; import type { AxcutTranscript } from "../schema"; -import { setWordText } from "./transcript"; +import { + replaceTranscriptText, + replaceTranscriptTextRange, + setWordText, + transcriptTextForWords, +} from "./transcript"; function fixture(language = "en"): AxcutTranscript { return { @@ -288,3 +293,229 @@ describe("setWordText", () => { ); }); }); + +describe("replaceTranscriptTextRange", () => { + it("inserts and replaces within one word", () => { + const inserted = replaceTranscriptTextRange( + fixture(), + ["word_1", "word_2", "word_3"], + 3, + 3, + " really", + ); + expect(inserted.words.find((word) => word.id === "word_2")?.text).toBe("u reallyse"); + expect(transcriptTextForWords(inserted, ["word_1", "word_2", "word_3"])).toBe( + "I u reallyse OpenScreen", + ); + + const replaced = replaceTranscriptTextRange( + fixture(), + ["word_1", "word_2", "word_3"], + 3, + 5, + "sed", + ); + expect(replaced.words.find((word) => word.id === "word_2")?.text).toBe("used"); + expect(replaced.segments[0].text).toBe("I used OpenScreen"); + }); + + it.each([ + { label: "backward", start: 1, end: 2 }, + { label: "forward", start: 1, end: 2 }, + ])("deletes one non-BMP code point $label without splitting it", ({ start, end }) => { + const transcript = transcriptForTokens("en", ["A😀B", "tail"]); + const result = replaceTranscriptTextRange(transcript, ["word_1", "word_2"], start, end, ""); + + expect(result.words.find((word) => word.id === "word_1")?.text).toBe("AB"); + expect(transcriptTextForWords(result, ["word_1", "word_2"])).toBe("AB tail"); + }); + + it("replaces across words, empties later affected rows, and leaves unaffected rows alone", () => { + const transcript = transcriptForTokens("en", ["Hello", "brave", "world", "today"]); + const untouched = transcript.words.find((word) => word.id === "word_4"); + const snapshot = structuredClone(transcript); + const result = replaceTranscriptTextRange( + transcript, + ["word_1", "word_2", "word_3", "word_4"], + 2, + 17, + "llo", + ); + + expect(result.words.find((word) => word.id === "word_1")?.text).toBe("Hello"); + expect(result.words.find((word) => word.id === "word_2")?.text).toBe(""); + expect(result.words.find((word) => word.id === "word_3")?.text).toBe(""); + expect(result.words.find((word) => word.id === "word_4")).toBe(untouched); + // The chain of gaps across the emptied rows is all zero (one spoken + // phrase), so no break is manufactured: a full-sentence merge reads as + // one line again. + expect(transcriptTextForWords(result, ["word_1", "word_2", "word_3", "word_4"])).toBe( + "Hello today", + ); + expect(transcript).toEqual(snapshot); + }); + + it("attaches an insertion at a word boundary to the preceding real word", () => { + const result = replaceTranscriptTextRange( + transcriptForTokens("en", ["Hello", "world"]), + ["word_1", "word_2"], + 5, + 5, + "!", + ); + + expect(result.words.find((word) => word.id === "word_1")?.text).toBe("Hello!"); + expect(result.words.find((word) => word.id === "word_2")?.text).toBe("world"); + }); + + it("rebuilds every segment touched by a cross-segment replacement", () => { + const transcript = fixture(); + // Offsets are code points into the projection starting at "use": 33 ends + // after "segment" (the two projected break chars shift it by 2 vs flat). + const result = replaceTranscriptTextRange( + transcript, + ["word_2", "word_3", "word_4", "word_5"], + 1, + 33, + "crossed", + ); + + expect(result.segments[0].text).toBe("I ucrossed"); + expect(result.segments[1].text).toBe(""); + }); + + it.each(["missing_word", "silence_1"])("rejects non-document word ID %s", (wordId) => { + expect(() => replaceTranscriptTextRange(fixture(), ["word_1", wordId], 0, 0, "x")).toThrowError( + wordId, + ); + }); + + it("derives the minimal code-point range when replacing the full visible text", () => { + const transcript = transcriptForTokens("en", ["one", "two", "three"]); + const result = replaceTranscriptText( + transcript, + ["word_1", "word_2", "word_3"], + "one TWO three", + ); + + expect(result.words.find((word) => word.id === "word_1")?.text).toBe("one"); + expect(result.words.find((word) => word.id === "word_2")?.text).toBe("TWO"); + expect(result.words.find((word) => word.id === "word_3")?.text).toBe("three"); + }); + + it("projects Chinese compactly while keeping mixed Latin boundaries readable", () => { + const transcript = transcriptForTokens("auto", ["我", "使用", "Claude", "Code", "剪视频"]); + + expect( + transcriptTextForWords(transcript, ["word_1", "word_2", "word_3", "word_4", "word_5"]), + ).toBe("我使用 Claude Code 剪视频"); + }); + + it("replaces mixed CJK/Latin text using offsets from the natural projection", () => { + const transcript = transcriptForTokens("zh", ["我", "使用", "克劳德扣", "剪视频"]); + const wordIds = ["word_1", "word_2", "word_3", "word_4"]; + const result = replaceTranscriptText(transcript, wordIds, "我使用 Claude Code 剪视频"); + + expect(transcriptTextForWords(result, wordIds)).toBe("我使用 Claude Code 剪视频"); + expect(result.words.find((word) => word.id === "word_1")?.text).toBe("我"); + expect(result.words.find((word) => word.id === "word_4")?.text).toBe("剪视频"); + expect(result.segments[0].text).toBe("我使用 Claude Code 剪视频"); + }); + + it("normalizes pasted line breaks to spaces", () => { + const transcript = transcriptForTokens("en", ["one", "two"]); + const result = replaceTranscriptText(transcript, ["word_1", "word_2"], "one pasted\ntext two"); + + expect(transcriptTextForWords(result, ["word_1", "word_2"])).toBe("one pasted text two"); + }); +}); + +describe("text-mode paragraph projection", () => { + /** Contiguous first paragraph, then a real speech pause, then a second one. */ + function paragraphFixture(): AxcutTranscript { + return { + assetId: "asset_para", + language: "auto", + segments: [ + { + id: "segment_para", + kind: "speech", + startSec: 0, + endSec: 5, + text: "大家好世界 AA BB", + wordIds: ["word_1", "word_2", "word_3", "word_4"], + }, + ], + words: [ + { id: "word_1", segmentId: "segment_para", startSec: 0, endSec: 1, text: "大家好" }, + // Gap 0 — an ASR fragment of the same phrase, reassembles inline. + { id: "word_2", segmentId: "segment_para", startSec: 1, endSec: 2, text: "世界" }, + // Real pause between takes — starts a new paragraph. + { id: "word_3", segmentId: "segment_para", startSec: 3, endSec: 4, text: "AA" }, + { id: "word_4", segmentId: "segment_para", startSec: 4, endSec: 5, text: "BB" }, + ], + }; + } + + const paraWordIds = ["word_1", "word_2", "word_3", "word_4"]; + + it("breaks paragraphs at speech pauses and reassembles gap-0 ASR fragments", () => { + expect(transcriptTextForWords(paragraphFixture(), paraWordIds)).toBe("大家好世界\n\nAA BB"); + }); + + it("maps an edit spanning the paragraph break onto the correct rows", () => { + // "大家好世界⏎⏎AA BB" — code-point offsets 5-8: "界" is overwritten by + // "X", the two break chars are consumed, "A" becomes " Y". + const result = replaceTranscriptTextRange(paragraphFixture(), paraWordIds, 5, 8, "X Y"); + + expect(result.words.find((word) => word.id === "word_2")?.text).toBe("世界X YA"); + expect(result.words.find((word) => word.id === "word_3")?.text).toBe(""); + expect(result.words.find((word) => word.id === "word_4")?.text).toBe("BB"); + // The break itself is never stored: the next projection re-derives it + // from the unchanged timings. + expect(transcriptTextForWords(result, paraWordIds)).toBe("大家好世界X YA\n\nBB"); + }); + + it("replaces text with the breaks intact and leaves the other paragraph's rows alone", () => { + const transcript = paragraphFixture(); + const snapshot = structuredClone(transcript); + const result = replaceTranscriptText(transcript, paraWordIds, "大家好世界\n\nAA CC"); + + expect(result.words.find((word) => word.id === "word_4")?.text).toBe("CC"); + expect(result.words.find((word) => word.id === "word_1")?.text).toBe( + snapshot.words.find((word) => word.id === "word_1")?.text, + ); + expect(transcriptTextForWords(result, paraWordIds)).toBe("大家好世界\n\nAA CC"); + }); + + it("canonicalizes lone newlines to spaces but keeps \\n\\n paragraph breaks", () => { + const transcript = paragraphFixture(); + + // A lone hand-typed newline is a soft wrap: it lands as a trailing space + // in the row before it (rows are stored single-line); the "世界" merge + // goes to the row holding the text before the wrap point. + const softWrap = replaceTranscriptText(transcript, paraWordIds, "大家好\n世界\n\nAA BB"); + expect(softWrap.words.find((word) => word.id === "word_1")?.text).toBe("大家好 "); + // CRLF pastes from Windows editors collapse onto the same projection + // unchanged — a pure no-op. + expect(replaceTranscriptText(transcript, paraWordIds, "大家好世界\r\n\r\nAA BB")).toBe( + transcript, + ); + }); + + it("merging across a break: the pause survives upstream and re-projects after the emptied row", () => { + const transcript = paragraphFixture(); + const result = replaceTranscriptText(transcript, paraWordIds, "大家好世界AA BB"); + + // Rows are stored single-line — the merge lands in the row before the + // old break, the emptied row keeps its timing, nothing is lost: + expect(result.words.find((word) => word.id === "word_2")?.text).toBe("世界AA"); + expect(result.words.find((word) => word.id === "word_3")?.text).toBe(""); + expect(result.words.find((word) => word.id === "word_4")?.text).toBe("BB"); + // The real 1s pause now sits INSIDE the merged row ("世界|AA"), which a + // between-rows break cannot express. Known approximation: the break + // re-projects right after the emptied row, keeping paragraph two alive + // rather than silently absorbing it. + expect(transcriptTextForWords(result, paraWordIds)).toBe("大家好世界AA\n\nBB"); + }); +}); diff --git a/src/lib/ai-edition/document/transcript.ts b/src/lib/ai-edition/document/transcript.ts index f64588f9e..e482d1975 100644 --- a/src/lib/ai-edition/document/transcript.ts +++ b/src/lib/ai-edition/document/transcript.ts @@ -1,4 +1,4 @@ -import type { AxcutTranscript } from "../schema"; +import type { AxcutTranscript, AxcutWord } from "../schema"; const CJK_EDGE = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}]/u; const CLOSING_PUNCTUATION = /^[,.;:!?%。,、;:!?…))\]}>》」』】〕]/u; @@ -10,22 +10,20 @@ const OPENING_PUNCTUATION = /[([<{《「『【〔(]$/u; // 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 separatorBefore(joined: string, token: string): "" | " " { + if (joined.length === 0) return ""; + if (CLOSING_PUNCTUATION.test(token) || OPENING_PUNCTUATION.test(joined)) return ""; + 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 ""; + return " "; +} + function joinSegmentText(texts: string[]): string { const tokens = texts.map((text) => text.trim()).filter((text) => text.length > 0); - 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) ?? ""; - // 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}`; - }, ""); + return tokens.reduce((joined, token) => joined + separatorBefore(joined, token) + token, ""); } export function setWordText( @@ -76,3 +74,219 @@ export function setWordText( return { ...transcript, words, segments }; } + +interface EditableWordPosition { + wordIndex: number; + textOffset: number; +} + +/** + * Paragraph breaks in text mode come from the recording itself: consecutive + * rows whose timing shows a real pause (>= PARAGRAPH_BREAK_GAP_SEC apart) start + * a new paragraph. Rows the ASR split mid-phrase (gap 0, e.g. "Personal" / "AI" + * / "Counselor") stay joined, so those fragments reassemble into one line. + * Breaks are a projection of timings, never persisted: editing text rewrites + * row texts, and the next projection re-derives breaks from the same timings. + */ +const PARAGRAPH_BREAK_GAP_SEC = 0.05; +const PARAGRAPH_BREAK = "\n\n"; + +interface ProjectedWord { + wordIndex: number; + text: string; + start: number; + end: number; +} + +function orderedTranscriptWords(transcript: AxcutTranscript, wordIds: readonly string[]) { + const wordsById = new Map(transcript.words.map((word) => [word.id, word])); + const seen = new Set(); + return wordIds.map((wordId) => { + if (seen.has(wordId)) { + throw new Error(`Transcript word "${wordId}" appears more than once in the edit range`); + } + seen.add(wordId); + const word = wordsById.get(wordId); + if (!word) { + throw new Error(`Cannot edit missing transcript word "${wordId}"`); + } + return word; + }); +} + +function projectTranscriptWords(transcript: AxcutTranscript, wordIds: readonly string[]) { + const words = orderedTranscriptWords(transcript, wordIds); + const paragraphs: { rows: { wordIndex: number; text: string; separator: string }[] }[] = []; + let previousWord: AxcutWord | null = null; + let previousHasText = false; + // Smallest gap in the chain of pairs whose LEFT row still carries text. + // Empty rows (text merged elsewhere by an edit) fold their incoming gap in + // — a pause before an emptied row is a real recording pause — but their own + // outgoing boundary is skipped: it borders displaced text, not the audio, + // so vacated rows neither manufacture phantom pauses ("Hello [brave world] + // today" stays one paragraph) nor dilute the real pause that survives + // upstream of them. + let chainMinGap = Number.POSITIVE_INFINITY; + for (const [wordIndex, word] of words.entries()) { + const token = word.text.trim(); + const gap = previousWord ? word.startSec - previousWord.endSec : Number.POSITIVE_INFINITY; + if (token) { + if (previousWord && previousHasText) { + chainMinGap = Math.min(chainMinGap, gap); + } + const lastParagraph = paragraphs.at(-1); + if (!lastParagraph || chainMinGap >= PARAGRAPH_BREAK_GAP_SEC) { + paragraphs.push({ rows: [{ wordIndex, text: token, separator: "" }] }); + } else { + const paragraphText = lastParagraph.rows.map((row) => row.separator + row.text).join(""); + lastParagraph.rows.push({ + wordIndex, + text: token, + separator: separatorBefore(paragraphText, token), + }); + } + chainMinGap = Number.POSITIVE_INFINITY; + } else if (previousWord && previousHasText) { + chainMinGap = Math.min(chainMinGap, gap); + } + previousWord = word; + previousHasText = token.length > 0; + } + + const projected: ProjectedWord[] = []; + let text = ""; + for (const [paragraphIndex, paragraph] of paragraphs.entries()) { + if (paragraphIndex > 0) text += PARAGRAPH_BREAK; + for (const row of paragraph.rows) { + // The separator is emitted here, into the global string, but is NOT + // part of row.text: offsets must index the word's trimmed text, which + // is what edits are applied against below. + text += row.separator; + const start = [...text].length; + text += row.text; + projected.push({ wordIndex: row.wordIndex, text: row.text, start, end: [...text].length }); + } + } + return { words, projected, text }; +} + +/** + * Builds the natural plain-text projection used by transcript text mode. It uses + * the same punctuation/CJK separators as segment.text, so Chinese stays compact + * and mixed Latin text remains readable. Empty persisted rows contribute nothing. + */ +export function transcriptTextForWords( + transcript: AxcutTranscript, + wordIds: readonly string[], +): string { + return projectTranscriptWords(transcript, wordIds).text; +} + +function locateEditablePosition( + projected: readonly ProjectedWord[], + wordCount: number, + offset: number, +): EditableWordPosition { + if (projected.length === 0) return { wordIndex: 0, textOffset: 0 }; + let previous: ProjectedWord | null = null; + for (const entry of projected) { + if (offset < entry.start) { + return previous + ? { wordIndex: previous.wordIndex, textOffset: [...previous.text].length } + : { wordIndex: entry.wordIndex, textOffset: 0 }; + } + if (offset <= entry.end) { + return { wordIndex: entry.wordIndex, textOffset: offset - entry.start }; + } + previous = entry; + } + const last = projected.at(-1); + if (!last) return { wordIndex: Math.max(0, wordCount - 1), textOffset: 0 }; + return { wordIndex: last.wordIndex, textOffset: [...last.text].length }; +} + +/** + * Replaces a code-point range in one clip's ordered real-word projection without + * creating word rows or changing timings. For a multi-word edit, the first affected + * row receives prefix + replacement + suffix and later affected rows are emptied. + */ +export function replaceTranscriptTextRange( + transcript: AxcutTranscript, + wordIds: readonly string[], + startOffset: number, + endOffset: number, + replacement: string, +): AxcutTranscript { + const projection = projectTranscriptWords(transcript, wordIds); + const { words } = projection; + if (words.length === 0) { + throw new Error("Cannot edit an empty transcript word range"); + } + const textLength = [...projection.text].length; + if ( + !Number.isInteger(startOffset) || + !Number.isInteger(endOffset) || + startOffset < 0 || + endOffset < startOffset || + endOffset > textLength + ) { + throw new Error(`Invalid transcript text range ${startOffset}-${endOffset}`); + } + + const start = locateEditablePosition(projection.projected, words.length, startOffset); + const end = locateEditablePosition(projection.projected, words.length, endOffset); + const firstText = [...words[start.wordIndex].text.trim()]; + const lastText = [...words[end.wordIndex].text.trim()]; + const normalizedReplacement = replacement.replace(/[\r\n]+/g, " "); + const mergedText = + firstText.slice(0, start.textOffset).join("") + + normalizedReplacement + + lastText.slice(end.textOffset).join(""); + + let result = setWordText(transcript, words[start.wordIndex].id, mergedText); + for (let index = start.wordIndex + 1; index <= end.wordIndex; index += 1) { + result = setWordText(result, words[index].id, ""); + } + return result; +} + +/** Applies a final plain-text value by deriving its smallest code-point edit. */ +export function replaceTranscriptText( + transcript: AxcutTranscript, + wordIds: readonly string[], + text: string, +): AxcutTranscript { + // The projection emits "\n\n" paragraph breaks from word timings, and the + // editor hands the text back with those breaks intact. Canonicalize incoming + // newlines to the same convention — lone breaks become spaces, "\n\n" runs + // survive — so the diff below runs in projection coordinates and untouched + // break characters match one-for-one in the common prefix/suffix. Anything a + // break slips into the replacement, replaceTranscriptTextRange flattens back + // to a space: rows are stored single-line and the next projection re-derives + // the visible breaks from the same timings. + const normalizedText = text.replace(/\r\n?/g, "\n").replace(/(? Date: Wed, 2 Sep 2026 15:12:50 +0800 Subject: [PATCH 5/6] feat(editor): edit transcript text in the transcript pane --- src/components/ai-edition/NewEditorShell.tsx | 30 + src/components/ai-edition/RightPanes.tsx | 495 ++++++++++- .../TranscriptPane.textEdit.test.tsx | 796 ++++++++++++++++++ src/i18n/locales/ar/settings.json | 5 + src/i18n/locales/en/settings.json | 5 + src/i18n/locales/es/settings.json | 5 + src/i18n/locales/fr/settings.json | 5 + src/i18n/locales/it/settings.json | 5 + src/i18n/locales/ja-JP/settings.json | 5 + src/i18n/locales/ko-KR/settings.json | 5 + src/i18n/locales/pt-BR/settings.json | 5 + src/i18n/locales/ru/settings.json | 5 + src/i18n/locales/tr/settings.json | 5 + src/i18n/locales/vi/settings.json | 5 + src/i18n/locales/zh-CN/settings.json | 5 + src/i18n/locales/zh-TW/settings.json | 5 + .../store/documentWriteAudit.test.ts | 2 + 17 files changed, 1368 insertions(+), 20 deletions(-) create mode 100644 src/components/ai-edition/TranscriptPane.textEdit.test.tsx diff --git a/src/components/ai-edition/NewEditorShell.tsx b/src/components/ai-edition/NewEditorShell.tsx index b371d100c..e03ba0208 100644 --- a/src/components/ai-edition/NewEditorShell.tsx +++ b/src/components/ai-edition/NewEditorShell.tsx @@ -13,6 +13,8 @@ import { applyProbedDuration, replaceTimeline as replaceTimelineOp, } from "@/lib/ai-edition/document/timeline"; +import { withTranscript } from "@/lib/ai-edition/document/transcribe"; +import { replaceTranscriptText } from "@/lib/ai-edition/document/transcript"; import { isModalOpen } from "@/lib/ai-edition/modalGuard"; import { type AxcutClip, documentSchema } from "@/lib/ai-edition/schema"; import { useProjectStore } from "@/lib/ai-edition/store/projectStore"; @@ -604,6 +606,33 @@ export function NewEditorShell() { [applyTimelineOp], ); + // Transcript text edits are whole-document read/modify/writes, so they share the + // timeline queue rather than racing it on a second debounce queue. Read the document + // INSIDE the chain: rapid edits then see the transcript saved by the preceding edit. + const handleEditTranscriptText = useCallback( + (assetId: string, wordIds: readonly string[], text: string): Promise => + enqueueTimelineWrite(async () => { + const doc = useProjectStore.getState().document; + if (!doc) return false; + const transcript = + doc.transcripts.find((entry) => entry.assetId === assetId) ?? + (doc.transcript?.assetId === assetId ? doc.transcript : null); + if (!transcript) return false; + try { + const nextTranscript = replaceTranscriptText(transcript, wordIds, text); + if (nextTranscript === transcript) return true; + return saveDocument(withTranscript(doc, nextTranscript), { history: true }); + } catch (error) { + console.error("[transcript] failed to edit text:", error); + toast.error("Could not edit transcript text", { + description: error instanceof Error ? error.message : String(error), + }); + return false; + } + }), + [enqueueTimelineWrite, saveDocument], + ); + const handleSelectProject = useCallback( async (id: string) => { try { @@ -1151,6 +1180,7 @@ export function NewEditorShell() { onSeek: handleSeek, onAddTrimRange: handleAddTrimRange, onRemoveTrimRange: handleRemoveTrimRange, + onEditTranscriptText: handleEditTranscriptText, onTranscribe: handleTranscribe, canTranscribe: hasAsset, isTranscribing: transcriptGate.state === "pending", diff --git a/src/components/ai-edition/RightPanes.tsx b/src/components/ai-edition/RightPanes.tsx index 2e15f7b12..e549814e7 100644 --- a/src/components/ai-edition/RightPanes.tsx +++ b/src/components/ai-edition/RightPanes.tsx @@ -13,12 +13,15 @@ import { Layout as LayoutIcon, Loader2, MousePointerClick, + Pencil, + Scissors, Sliders, Trash2, } from "lucide-react"; import { type ChangeEvent, + type CompositionEvent, type CSSProperties, type FormEvent, memo, @@ -39,6 +42,7 @@ import GradientEditor, { type GradientEditorState } from "@/components/ui/gradie import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { useI18n, useScopedT } from "@/contexts/I18nContext"; import { collectNativeFormats } from "@/lib/ai-edition/document/outputFormat"; +import { transcriptTextForWords } from "@/lib/ai-edition/document/transcript"; import type { AxcutAsset, AxcutClip, @@ -672,6 +676,14 @@ export interface TrimTarget { clipId: string; } +export type TranscriptPaneMode = "cut" | "text"; + +export type TranscriptTextSave = ( + assetId: string, + wordIds: readonly string[], + text: string, +) => Promise | boolean; + // ─── Transcript ──────────────────────────────────────────────────── // Aggregated transcript view: one contentEditable region per clip on the // timeline, in timeline order. Each word is rendered as a ` void; onAddTrimRange: (target: TrimTarget, startSec: number, endSec: number, reason: string) => void; onRemoveTrimRange: (trimId: string) => void; + onEditTranscriptText?: TranscriptTextSave; onTranscribe: () => void; canTranscribe: boolean; isTranscribing: boolean; @@ -731,6 +745,7 @@ export function TranscriptPane({ blocked?: { reason: TranscriptGateReason; message?: string }; }) { const ts = useScopedT("settings"); + const [mode, setMode] = useState("cut"); // Subscribed here, not passed down: the playhead is rewritten every animation // frame during playback, and reading it in NewEditorShell re-rendered the whole // editor (timeline included) once per frame — see NativePlaybackSync there. Only @@ -822,16 +837,91 @@ export function TranscriptPane({

{ts("transcript.title")}

+
+ {( + [ + { + value: "text" as const, + label: ts("transcript.textEditMode"), + help: ts("transcript.textEditHelp"), + icon:
+

+ {ts(mode === "text" ? "transcript.textEditHelp" : "transcript.cutVideoHelp")} +

{sections.map((section, idx) => ( ))}
@@ -854,19 +944,23 @@ export function TranscriptPane({ const TranscriptClipBlock = memo(function TranscriptClipBlock({ index, section, + mode, busy, cueWordId, onSeek, onAddTrimRange, onRemoveTrimRange, + onEditTranscriptText, }: { index: number; section: ClipSection; + mode: TranscriptPaneMode; busy: boolean; cueWordId: string | null; onSeek: (sec: number) => void; onAddTrimRange: (target: TrimTarget, startSec: number, endSec: number, reason: string) => void; onRemoveTrimRange: (trimId: string) => void; + onEditTranscriptText?: TranscriptTextSave; }) { const ts = useScopedT("settings"); const { clip, asset, words } = section; @@ -884,6 +978,216 @@ const TranscriptClipBlock = memo(function TranscriptClipBlock({ const editorRef = useRef(null); const pendingCaretWordIdRef = useRef(null); + const textSaveTimerRef = useRef | null>(null); + const pendingTextRef = useRef(null); + const textEditRevisionRef = useRef(0); + const textSaveRetryCountRef = useRef(0); + // A successful save updates the transcript projection before its promise + // necessarily settles. Keep those expected projections separate from real + // external replacements (regen/undo), so an older save landing cannot make + // us discard newer text that is still pending. + const expectedSavedTextsRef = useRef(new Set()); + // An IME composition mutates the DOM through events this component must not + // react to (see handleInput/handleBeforeInput); it is tracked here, not in + // state, so composition never triggers a re-render mid-candidate. + const isComposingRef = useRef(false); + const busyRef = useRef(busy); + const modeRef = useRef(mode); + const assetIdRef = useRef(clip.assetId); + const onEditTranscriptTextRef = useRef(onEditTranscriptText); + const realWords = useMemo(() => words.filter((cw) => !isSilenceWord(cw.word)), [words]); + const realWordIds = useMemo(() => realWords.map((cw) => cw.word.id), [realWords]); + const realWordIdsRef = useRef(realWordIds); + const editableText = useMemo( + () => (section.transcript ? transcriptTextForWords(section.transcript, realWordIds) : ""), + [section.transcript, realWordIds], + ); + const committedTextRef = useRef(editableText); + modeRef.current = mode; + committedTextRef.current = editableText; + busyRef.current = busy; + assetIdRef.current = clip.assetId; + realWordIdsRef.current = realWordIds; + onEditTranscriptTextRef.current = onEditTranscriptText; + + // Commit the pending typing burst to the document. A transcription regen + // holds the asset read-only for the whole run (busy), during which the + // flush polls until it clears instead of dropping the burst. All inputs + // come from refs, so this callback never goes stale and the debounce + // below can be armed from effects too. + const flushPendingTextSave = useCallback((delayMs: number) => { + if (textSaveTimerRef.current) { + // Normal debounce calls keep the existing timer. A zero-delay flush + // means the editor is about to stop owning the visible text (mode + // switch), so replace the debounce with an immediate save instead of + // merely waiting for its original deadline. + if (delayMs > 0) return; + clearTimeout(textSaveTimerRef.current); + textSaveTimerRef.current = null; + } + textSaveTimerRef.current = setTimeout(() => { + textSaveTimerRef.current = null; + const pendingText = pendingTextRef.current; + if (pendingText === null) return; + if (busyRef.current) { + flushPendingTextSave(250); + return; + } + const save = onEditTranscriptTextRef.current; + if (!save) return; + // The user may have retyped back to the committed projection + // (undo-by-hand) since arming the timer: a save would be a no-op + // round-trip — skip it and clear the burst. + if (pendingText === committedTextRef.current) { + pendingTextRef.current = null; + return; + } + const revision = textEditRevisionRef.current; + expectedSavedTextsRef.current.add(pendingText); + const finish = (saved: boolean) => { + if (!saved || revision === textEditRevisionRef.current) { + expectedSavedTextsRef.current.delete(pendingText); + } + if (revision !== textEditRevisionRef.current) return; + const editor = editorRef.current; + if (saved) { + pendingTextRef.current = null; + return; + } + if (modeRef.current !== "text") return; + if (editor && globalThis.document.activeElement === editor) { + // The user is typing in this editor: reverting the DOM would + // steal the caret (and truncate a live IME composition), and + // wiping the pending burst would silently drop the edit. + // Keep both and retry once after a backoff; every fresh + // keystroke resets the retry budget, so a failing disk + // cannot turn into an infinite retry loop — and a later + // keystroke, mode switch, or unmount flush re-attempts it. + if (textSaveRetryCountRef.current < 1) { + textSaveRetryCountRef.current += 1; + flushPendingTextSave(1000); + } + return; + } + pendingTextRef.current = null; + if (editor) { + editor.textContent = committedTextRef.current; + } + }; + void Promise.resolve(save(assetIdRef.current, realWordIdsRef.current, pendingText)).then( + (saved) => finish(saved === true), + () => finish(false), + ); + }, delayMs); + }, []); + + const scheduleTextSave = useCallback( + (text: string) => { + // Busy does NOT gate this: a transcription run makes the editor + // read-only, but text typed just before the run started stays the + // pending burst, and flushPendingTextSave polls until the run + // clears before saving it — instead of dropping it. + if (modeRef.current !== "text" || !onEditTranscriptTextRef.current) return; + pendingTextRef.current = text; + textEditRevisionRef.current += 1; + textSaveRetryCountRef.current = 0; + if (textSaveTimerRef.current) { + clearTimeout(textSaveTimerRef.current); + textSaveTimerRef.current = null; + } + flushPendingTextSave(250); + }, + [flushPendingTextSave], + ); + + // Flush an unsaved burst on unmount (pane closed, project switched, clip + // removed): the debounce timer dies with the component and would take the + // last 250ms of edits with it. A duplicate of an in-flight save is + // harmless — replaceTranscriptText no-ops when the text already matches. + useEffect( + () => () => { + if (textSaveTimerRef.current) clearTimeout(textSaveTimerRef.current); + const pendingText = pendingTextRef.current; + const save = onEditTranscriptTextRef.current; + if (pendingText !== null && save && !busyRef.current) { + void Promise.resolve(save(assetIdRef.current, realWordIdsRef.current, pendingText)).then( + () => undefined, + () => undefined, + ); + } + }, + [], + ); + + const prevModeRef = useRef(mode); + useLayoutEffect(() => { + const previous = prevModeRef.current; + prevModeRef.current = mode; + if (previous === "text" && mode !== "text" && pendingTextRef.current !== null) { + // Leaving text mode with unsaved text: flush now — the cut-mode + // render replaces the editor's content, so the DOM copy of that + // text is about to disappear. + flushPendingTextSave(0); + } + // In cut mode React owns the children (word spans); any bare text node + // left here was written imperatively by the text-mode writer below and + // is invisible to React's diff. Strip it so the word stream is the only + // content the cut-mode word resolution walks. + const editor = editorRef.current; + if (mode !== "text" && editor) { + for (const node of [...editor.childNodes]) { + if (node.nodeType === globalThis.Node.TEXT_NODE && node.nodeValue) { + node.remove(); + } + } + } + }, [mode, flushPendingTextSave]); + + // Keep the editor's DOM text in sync with the projection WITHOUT React + // children: in text mode React renders no children here, so this effect is + // the only writer. A React-managed text child would be rewritten by the + // re-render after every debounced save, resetting the caret mid-typing + // session. Rewrites preserve the caret when the editor has focus. + const prevEditableTextRef = useRef(editableText); + useLayoutEffect(() => { + const previousEditableText = prevEditableTextRef.current; + prevEditableTextRef.current = editableText; + const editor = editorRef.current; + if (mode !== "text" || !editor) return; + // A live IME composition owns the DOM text until compositionend; + // rewriting mid-composition destroys the candidate string. The save + // that follows compositionend re-derives the projection and lands the + // sync then. + if (isComposingRef.current) return; + const isExpectedSavedProjection = expectedSavedTextsRef.current.delete(editableText); + if (pendingTextRef.current !== null && previousEditableText !== editableText) { + if (isExpectedSavedProjection && pendingTextRef.current !== editableText) { + // An older save landed while the user kept typing. Its projection + // belongs to us, not to an external replacement: keep both the + // newer pending value and the editor DOM until their own save lands. + return; + } + // The projection changed while an edit was still pending: the + // transcript was replaced underneath it (transcription regen, undo, + // a second clip's save of the same asset). The pending text no + // longer maps onto the new word rows — drop it rather than + // replaying it over the replacement. + textEditRevisionRef.current += 1; + pendingTextRef.current = null; + } else if ( + pendingTextRef.current !== null && + pendingTextRef.current === editableText && + editor.textContent !== editableText + ) { + // Pending text matches the committed projection (typing was undone + // by hand) — nothing to save; just restore the view. + textEditRevisionRef.current += 1; + pendingTextRef.current = null; + } + if (editor.textContent !== editableText) { + writeEditorTextPreservingCaret(editor, editableText); + } + }, [editableText, mode]); // auto-scroll the cue word into view as the playback head // moves. The right pane has ONE scroll container (paneBody, which @@ -1005,16 +1309,91 @@ const TranscriptClipBlock = memo(function TranscriptClipBlock({ const handleKeyDown = useCallback( (event: ReactKeyboardEvent) => { + if (mode === "text") return; if (event.key !== "Backspace" && event.key !== "Delete") return; event.preventDefault(); cutNativeSelection(event.key === "Backspace" ? "backward" : "forward"); }, - [cutNativeSelection], + [cutNativeSelection, mode], + ); + + const handleInput = useCallback( + (event: FormEvent) => { + if (modeRef.current !== "text") return; + // While an IME composition is live, intermediate input events fire + // for every candidate / romanisation step. Saving those would write + // pinyin fragments into the word rows; the compositionend handler + // below schedules the single save of the final text instead. + if (isComposingRef.current) return; + scheduleTextSave(event.currentTarget.textContent ?? ""); + }, + [scheduleTextSave], + ); + + const handleBlur = useCallback(() => { + if (modeRef.current === "text" && pendingTextRef.current !== null) { + flushPendingTextSave(0); + } + }, [flushPendingTextSave]); + + const handleCompositionStart = useCallback(() => { + isComposingRef.current = true; + }, []); + + const handleCompositionEnd = useCallback( + (event: CompositionEvent) => { + isComposingRef.current = false; + // compositionend is followed by a final input event on some + // engines; both carry the same complete text, and scheduleTextSave + // coalesces by value, so scheduling here is idempotent with it. + if (modeRef.current !== "text") return; + scheduleTextSave(event.currentTarget.textContent ?? ""); + }, + [scheduleTextSave], + ); + + const insertPlainText = useCallback( + (text: string) => { + const editor = editorRef.current; + const selection = globalThis.getSelection(); + if (!editor || !selection || selection.rangeCount === 0) return; + const range = selection.getRangeAt(0); + if ( + !editor.contains(range.commonAncestorContainer) && + range.commonAncestorContainer !== editor + ) { + return; + } + range.deleteContents(); + const textNode = globalThis.document.createTextNode(text); + range.insertNode(textNode); + range.setStartAfter(textNode); + range.collapse(true); + selection.removeAllRanges(); + selection.addRange(range); + editor.normalize(); + scheduleTextSave(editor.textContent ?? ""); + }, + [scheduleTextSave], ); const handleBeforeInput = useCallback( (event: FormEvent) => { const inputEvent = event.nativeEvent as InputEvent; + if (mode === "text") { + // A transcription run holds the editor read-only at the DOM + // level (contentEditable={false}), so no inserts arrive here + // while busy. The paragraph rewrite still runs for the editable + // text mode, and the pending-save flush waits out the run. + if ( + inputEvent.inputType === "insertParagraph" || + inputEvent.inputType === "insertLineBreak" + ) { + event.preventDefault(); + insertPlainText(" "); + } + return; + } if (inputEvent.inputType.startsWith("delete")) { event.preventDefault(); cutNativeSelection( @@ -1037,16 +1416,23 @@ const TranscriptClipBlock = memo(function TranscriptClipBlock({ event.preventDefault(); } }, - [cutNativeSelection], + [cutNativeSelection, insertPlainText, mode], ); - const handlePaste = useCallback((event: ReactClipboardEvent) => { - event.preventDefault(); - }, []); + const handlePaste = useCallback( + (event: ReactClipboardEvent) => { + event.preventDefault(); + if (modeRef.current !== "text") return; + const plainText = event.clipboardData.getData("text/plain").replace(/[\r\n]+/g, " "); + insertPlainText(plainText); + }, + [insertPlainText], + ); const handlePointerUp = useCallback( (event: ReactPointerEvent) => { if (event.button !== 0) return; + if (mode === "text") return; // a click on the trim-pill button (bin) bubbles up here // before the button's onClick fires. Skip those — the bin's own // handler is responsible for restoring the skip range. @@ -1081,7 +1467,7 @@ const TranscriptClipBlock = memo(function TranscriptClipBlock({ // single clip at the head of the timeline, where the two coordinates coincide. onSeek(clip.timelineStartSec + (cw.word.startSec - clip.sourceStartSec)); }, - [onSeek, words, clip.timelineStartSec, clip.sourceStartSec], + [onSeek, words, clip.timelineStartSec, clip.sourceStartSec, mode], ); return ( @@ -1159,7 +1545,7 @@ const TranscriptClipBlock = memo(function TranscriptClipBlock({
) : null} - {words.length === 0 ? ( + {(mode === "text" ? realWords.length : words.length) === 0 ? (

- {words.map((cw) => ( - - ))} + {/* Text mode renders NO React children: the DOM text is owned + by the projection-writer effect above. A React-rendered text + child would be rewritten by the re-render after every + debounced save, resetting the caret mid-typing session (and + truncating a live IME composition). */} + {mode === "text" + ? null + : words.map((cw) => ( + + ))} )} @@ -1536,6 +1937,60 @@ function restoreCaretBeforeWord(editor: HTMLElement | null, wordId: string): voi selection?.addRange(range); } +/** + * Rewrites the text-mode editor's DOM to a new projection while keeping the + * caret where the user left it. Text mode has no React children — this helper + * is the effect that keeps the DOM in sync with `transcriptTextForWords()`. + * + * A caret inside the editor is (text node, character offset). When the + * projection changes, both endpoints are located in the OLD text, mapped onto + * the NEW text, and re-established — so a save arriving mid-typing session + * does not throw the cursor to the start of the block. Mapping is by plain + * index: the projection changes here are single-row text rewrites (the saved + * edit), so index-based mapping is exact; for a full transcript replacement + * any mapping is a guess anyway, and clamping keeps the caret inside bounds. + * A live IME composition is never touched (composition text lives in the + * DOM only until compositionend, and rewriting mid-composition breaks it). + */ +function writeEditorTextPreservingCaret(editor: HTMLElement, text: string): void { + const selection = globalThis.getSelection(); + const hasCaret = + editor.contains(globalThis.document.activeElement) && + selection !== null && + selection.rangeCount > 0 && + editor.contains(selection.getRangeAt(0).commonAncestorContainer); + // Capture the caret offsets BEFORE mutating the DOM: assigning + // textContent detaches the selection's text node, which collapses the + // live Range to (editor, 0) — offsets read after the write are always 0. + let startOffset = 0; + let endOffset = 0; + let collapsed = true; + if (hasCaret) { + const range = selection.getRangeAt(0); + collapsed = range.collapsed; + startOffset = clampRangeOffset(range.startContainer, range.startOffset); + endOffset = clampRangeOffset(range.endContainer, range.endOffset); + } + const oldText = editor.textContent ?? ""; + if (oldText === text) return; + editor.textContent = text; + if (!hasCaret) return; + const nextRange = globalThis.document.createRange(); + const textNode = editor.firstChild; + if (!textNode) return; + // The projection differences that reach this helper are single-row text + // rewrites, so a plain index mapping of the caret keeps it where the user + // left it; clamping handles a shrunk text safely. + nextRange.setStart(textNode, Math.min(startOffset, text.length)); + if (collapsed) { + nextRange.collapse(true); + } else { + nextRange.setEnd(textNode, Math.min(endOffset, text.length)); + } + selection.removeAllRanges(); + selection.addRange(nextRange); +} + // Re-export AxcutWord type so the helpers above can be typed without // pulling the schema into the helpers block. export type { AxcutWord }; diff --git a/src/components/ai-edition/TranscriptPane.textEdit.test.tsx b/src/components/ai-edition/TranscriptPane.textEdit.test.tsx new file mode 100644 index 000000000..cb0b07490 --- /dev/null +++ b/src/components/ai-edition/TranscriptPane.textEdit.test.tsx @@ -0,0 +1,796 @@ +// @vitest-environment jsdom + +import "@testing-library/jest-dom"; +import { act, cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { useState } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { I18nProvider } from "@/contexts/I18nContext"; +import { replaceTranscriptText } from "@/lib/ai-edition/document/transcript"; +import type { + AxcutAsset, + AxcutClip, + AxcutTranscript, + AxcutTrimRange, +} from "@/lib/ai-edition/schema"; +import { useProjectStore } from "@/lib/ai-edition/store/projectStore"; +import { TranscriptPane, type TrimTarget } from "./RightPanes"; + +vi.mock("@/native/client", () => ({ nativeBridgeClient: { aiEdition: {} } })); +vi.mock("sonner", () => ({ toast: { error: vi.fn() } })); + +const ASSET: AxcutAsset = { + id: "asset_1", + kind: "video", + label: "recording.mp4", + originalPath: "/rec.mp4", + durationSec: 8, + cameraTrack: null, +}; + +const CLIP: AxcutClip = { + id: "clip_1", + assetId: "asset_1", + sourceStartSec: 0, + sourceEndSec: 8, + timelineStartSec: 0, + timelineEndSec: 8, + wordRefs: [], + origin: "user", + reason: "", +}; + +const SECOND_CLIP: AxcutClip = { + ...CLIP, + id: "clip_2", + timelineStartSec: 8, + timelineEndSec: 16, +}; + +const TRANSCRIPT: AxcutTranscript = { + assetId: "asset_1", + language: "en", + segments: [ + { + id: "segment_1", + kind: "speech", + startSec: 0, + endSec: 8, + text: "Hello brave world", + wordIds: ["w1", "w2", "w3"], + }, + ], + words: [ + { id: "w1", segmentId: "segment_1", startSec: 0, endSec: 1, text: "Hello" }, + // The 1-4s gap creates a synthetic silence chip in cut mode. + { id: "w2", segmentId: "segment_1", startSec: 4, endSec: 5, text: "brave" }, + { id: "w3", segmentId: "segment_1", startSec: 5, endSec: 6, text: "world" }, + ], +}; + +const TRIMMED_W2: AxcutTrimRange = { + id: "trim_w2", + assetId: "asset_1", + clipId: "clip_1", + startSec: 4, + endSec: 5, + origin: "user", + reason: "", +}; + +type TextSave = ( + assetId: string, + wordIds: readonly string[], + text: string, +) => Promise | boolean; + +function paneElement({ + clips = [CLIP], + transcript = TRANSCRIPT, + trimRanges = [TRIMMED_W2], + busyAssetIds = [], + onSeek = vi.fn(), + onAddTrimRange = vi.fn(), + onEditTranscriptText = vi.fn(async () => true), +}: { + clips?: AxcutClip[]; + transcript?: AxcutTranscript; + trimRanges?: AxcutTrimRange[]; + busyAssetIds?: string[]; + onSeek?: ReturnType; + onAddTrimRange?: ReturnType; + onEditTranscriptText?: TextSave; +} = {}) { + return ( + + void} + onAddTrimRange={ + onAddTrimRange as ( + target: TrimTarget, + startSec: number, + endSec: number, + reason: string, + ) => void + } + onRemoveTrimRange={vi.fn()} + onEditTranscriptText={onEditTranscriptText} + onTranscribe={vi.fn()} + canTranscribe + isTranscribing={false} + /> + + ); +} + +function renderPane({ + clips = [CLIP], + transcript = TRANSCRIPT, + trimRanges = [TRIMMED_W2], + busyAssetIds = [], + onSeek = vi.fn(), + onAddTrimRange = vi.fn(), + onEditTranscriptText = vi.fn(async () => true), +}: { + clips?: AxcutClip[]; + transcript?: AxcutTranscript; + trimRanges?: AxcutTrimRange[]; + busyAssetIds?: string[]; + onSeek?: ReturnType; + onAddTrimRange?: ReturnType; + onEditTranscriptText?: TextSave; +} = {}) { + return { + ...render( + paneElement({ + clips, + transcript, + trimRanges, + busyAssetIds, + onSeek, + onAddTrimRange, + onEditTranscriptText, + }), + ), + onSeek, + onAddTrimRange, + onEditTranscriptText, + }; +} + +function selectText(node: Text, start: number, end: number) { + const range = document.createRange(); + range.setStart(node, start); + range.setEnd(node, end); + const selection = window.getSelection(); + selection?.removeAllRanges(); + selection?.addRange(range); +} + +async function flushTextEdit() { + await act(async () => { + vi.advanceTimersByTime(300); + await Promise.resolve(); + }); +} + +beforeEach(() => { + vi.useFakeTimers(); + useProjectStore.setState({ currentTimeSec: 0 }); +}); + +afterEach(() => { + cleanup(); + window.getSelection()?.removeAllRanges(); + vi.useRealTimers(); + vi.clearAllMocks(); +}); + +describe("TranscriptPane text editing mode", () => { + it("defaults to cut mode and preserves cut rendering and Backspace semantics", () => { + const onAddTrimRange = vi.fn(); + const { container } = renderPane({ onAddTrimRange }); + expect(screen.getByRole("button", { name: "Cut selected video" })).toHaveAttribute( + "aria-pressed", + "true", + ); + expect(screen.getByRole("button", { name: "Transcript text" })).toHaveAttribute( + "aria-pressed", + "false", + ); + expect(container.querySelector('[data-silence="true"]')).toBeInTheDocument(); + expect(container.querySelector('[data-word-id="clip_1:w2"]')).toHaveStyle({ + textDecoration: "line-through", + }); + + const editor = screen.getByRole("textbox"); + const firstWord = container.querySelector('[data-word-id="clip_1:w1"]'); + selectText(firstWord?.firstChild as Text, 1, 1); + fireEvent.keyDown(editor, { key: "Backspace" }); + expect(onAddTrimRange).toHaveBeenCalled(); + }); + + it("switches to a plain editable projection and restores cut affordances on return", () => { + const { container } = renderPane(); + fireEvent.click(screen.getByRole("button", { name: "Transcript text" })); + + const editor = screen.getByRole("textbox"); + expect(editor).toHaveAttribute("contenteditable", "true"); + expect(editor).toHaveAttribute("aria-readonly", "false"); + expect(editor).toHaveTextContent("Hello brave world"); + expect(container.querySelector('[data-silence="true"]')).not.toBeInTheDocument(); + expect(container.querySelector("[data-skip-id]")).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "Cut selected video" })); + expect(container.querySelector('[data-silence="true"]')).toBeInTheDocument(); + expect(container.querySelector('[data-word-id="clip_1:w2"]')).toHaveStyle({ + textDecoration: "line-through", + }); + }); + + it("renders Chinese compactly and mixed Latin text with readable boundaries", () => { + const transcript: AxcutTranscript = { + assetId: "asset_1", + language: "auto", + segments: [ + { + id: "segment_zh", + kind: "speech", + startSec: 0, + endSec: 5, + text: "我使用 Claude Code 剪视频", + wordIds: ["zh1", "zh2", "zh3", "zh4", "zh5"], + }, + ], + words: [ + { id: "zh1", segmentId: "segment_zh", startSec: 0, endSec: 1, text: "我" }, + { id: "zh2", segmentId: "segment_zh", startSec: 1, endSec: 2, text: "使用" }, + { id: "zh3", segmentId: "segment_zh", startSec: 2, endSec: 3, text: "Claude" }, + { id: "zh4", segmentId: "segment_zh", startSec: 3, endSec: 4, text: "Code" }, + { id: "zh5", segmentId: "segment_zh", startSec: 4, endSec: 5, text: "剪视频" }, + ], + }; + renderPane({ transcript, trimRanges: [] }); + fireEvent.click(screen.getByRole("button", { name: "Transcript text" })); + + expect(screen.getByRole("textbox")).toHaveTextContent("我使用 Claude Code 剪视频"); + }); + + it("coalesces a typing burst into one bare-word text save and never trims video", async () => { + const onEditTranscriptText = vi.fn(async () => true); + const onAddTrimRange = vi.fn(); + renderPane({ onEditTranscriptText, onAddTrimRange }); + fireEvent.click(screen.getByRole("button", { name: "Transcript text" })); + const editor = screen.getByRole("textbox"); + + editor.textContent = "Hello brave worlds"; + fireEvent.input(editor, { inputType: "insertText", data: "s" }); + editor.textContent = "Hello brave worlds!"; + fireEvent.input(editor, { inputType: "insertText", data: "!" }); + expect(onEditTranscriptText).not.toHaveBeenCalled(); + await flushTextEdit(); + + expect(onEditTranscriptText).toHaveBeenCalledTimes(1); + expect(onEditTranscriptText).toHaveBeenCalledWith( + "asset_1", + ["w1", "w2", "w3"], + "Hello brave worlds!", + ); + expect(onAddTrimRange).not.toHaveBeenCalled(); + }); + + it("debounces a typing burst from the most recent input", async () => { + const onEditTranscriptText = vi.fn(async () => true); + renderPane({ onEditTranscriptText }); + fireEvent.click(screen.getByRole("button", { name: "Transcript text" })); + const editor = screen.getByRole("textbox"); + + editor.textContent = "Hello brave world!"; + fireEvent.input(editor, { inputType: "insertText", data: "!" }); + await act(async () => { + vi.advanceTimersByTime(200); + }); + editor.textContent = "Hello brave world!!"; + fireEvent.input(editor, { inputType: "insertText", data: "!" }); + await act(async () => { + vi.advanceTimersByTime(60); + await Promise.resolve(); + }); + + // Only 60 ms passed after the newest input, so the 250 ms debounce + // must still be armed rather than saving on the first input's clock. + expect(onEditTranscriptText).not.toHaveBeenCalled(); + await act(async () => { + vi.advanceTimersByTime(200); + await Promise.resolve(); + }); + expect(onEditTranscriptText).toHaveBeenCalledTimes(1); + expect(onEditTranscriptText).toHaveBeenCalledWith( + "asset_1", + ["w1", "w2", "w3"], + "Hello brave world!!", + ); + }); + + it("preserves newer typing when an earlier save projection lands", async () => { + let releaseFirst: (() => void) | undefined; + const onEditTranscriptText = vi.fn( + () => + new Promise((resolve) => { + releaseFirst = () => resolve(true); + }), + ); + const view = render(paneElement({ onEditTranscriptText })); + fireEvent.click(screen.getByRole("button", { name: "Transcript text" })); + const editor = screen.getByRole("textbox"); + + editor.textContent = "Hello brave world!"; + fireEvent.input(editor, { inputType: "insertText", data: "!" }); + await act(async () => { + vi.advanceTimersByTime(300); + await Promise.resolve(); + }); + expect(onEditTranscriptText).toHaveBeenCalledTimes(1); + + // Keep typing while the first disk write is still in flight. + editor.textContent = "Hello brave world!!"; + fireEvent.input(editor, { inputType: "insertText", data: "!" }); + + // The first save lands in the store and re-renders the projection + // before its promise resolves to the editor. + const firstSaved = replaceTranscriptText(TRANSCRIPT, ["w1", "w2", "w3"], "Hello brave world!"); + view.rerender(paneElement({ transcript: firstSaved, onEditTranscriptText })); + await act(async () => { + releaseFirst?.(); + await Promise.resolve(); + vi.advanceTimersByTime(300); + await Promise.resolve(); + }); + + expect(onEditTranscriptText).toHaveBeenCalledTimes(2); + expect(onEditTranscriptText).toHaveBeenLastCalledWith( + "asset_1", + ["w1", "w2", "w3"], + "Hello brave world!!", + ); + }); + + it("replaces a selection spanning words with normalized plain-text paste", async () => { + const onEditTranscriptText = vi.fn(async () => true); + renderPane({ onEditTranscriptText }); + fireEvent.click(screen.getByRole("button", { name: "Transcript text" })); + const editor = screen.getByRole("textbox"); + const textNode = editor.firstChild as Text; + selectText(textNode, 3, 12); + + fireEvent.paste(editor, { + clipboardData: { getData: (type: string) => (type === "text/plain" ? "pasted\ntext" : "") }, + }); + // The fixture's 1-4s speech pause projects as a "\n\n" break inside the + // selection (offsets 3-12 cover "lo\n\nbrave"), so the space before "world" + // survives the paste. + expect(editor).toHaveTextContent("Helpasted text world"); + await flushTextEdit(); + expect(onEditTranscriptText).toHaveBeenCalledWith( + "asset_1", + ["w1", "w2", "w3"], + "Helpasted text world", + ); + }); + + it("lets Backspace/Delete edit text without seeking or cutting", async () => { + const onEditTranscriptText = vi.fn(async () => true); + const onAddTrimRange = vi.fn(); + const onSeek = vi.fn(); + renderPane({ onEditTranscriptText, onAddTrimRange, onSeek }); + fireEvent.click(screen.getByRole("button", { name: "Transcript text" })); + const editor = screen.getByRole("textbox"); + selectText(editor.firstChild as Text, 5, 5); + + fireEvent.pointerUp(editor, { button: 0 }); + fireEvent.keyDown(editor, { key: "Backspace" }); + editor.textContent = "Hell brave world"; + fireEvent.input(editor, { inputType: "deleteContentBackward" }); + fireEvent.keyDown(editor, { key: "Delete" }); + await flushTextEdit(); + + expect(onSeek).not.toHaveBeenCalled(); + expect(onAddTrimRange).not.toHaveBeenCalled(); + expect(onEditTranscriptText).toHaveBeenCalledWith( + "asset_1", + ["w1", "w2", "w3"], + "Hell brave world", + ); + }); + + it("is read-only only for the asset currently being transcribed", () => { + const first = renderPane({ busyAssetIds: ["asset_1"] }); + fireEvent.click(screen.getByRole("button", { name: "Transcript text" })); + expect(screen.getByRole("textbox")).toHaveAttribute("contenteditable", "false"); + expect(screen.getByRole("textbox")).toHaveAttribute("aria-busy", "true"); + first.unmount(); + + renderPane({ busyAssetIds: ["asset_other"] }); + fireEvent.click(screen.getByRole("button", { name: "Transcript text" })); + expect(screen.getByRole("textbox")).toHaveAttribute("contenteditable", "true"); + expect(screen.getByRole("textbox")).toHaveAttribute("aria-busy", "false"); + }); + + it("updates both clip projections when shared transcript state is saved", async () => { + function Harness() { + const [transcript, setTranscript] = useState(TRANSCRIPT); + return ( + + { + setTranscript((current) => replaceTranscriptText(current, wordIds, text)); + return true; + }} + onTranscribe={vi.fn()} + canTranscribe + isTranscribing={false} + /> + + ); + } + + render(); + fireEvent.click(screen.getByRole("button", { name: "Transcript text" })); + const editors = screen.getAllByRole("textbox"); + editors[0].textContent = "Hello shared world"; + fireEvent.input(editors[0], { inputType: "insertText" }); + await flushTextEdit(); + // The fixture's 1-4s pause projects as a break: "Hello shared | world" is + // two paragraphs, and both clip editors render the same projection. + expect(screen.getAllByRole("textbox").map((editor) => editor.textContent)).toEqual([ + "Hello shared\n\nworld", + "Hello shared\n\nworld", + ]); + }); + + it("restores the last committed text when persistence reports failure", async () => { + const onEditTranscriptText = vi.fn(async () => false); + renderPane({ onEditTranscriptText }); + fireEvent.click(screen.getByRole("button", { name: "Transcript text" })); + const editor = screen.getByRole("textbox"); + editor.textContent = "unsaved text"; + fireEvent.input(editor, { inputType: "insertText" }); + + await flushTextEdit(); + + expect(onEditTranscriptText).toHaveBeenCalled(); + expect(editor).toHaveTextContent("Hello brave world"); + }); + + it("does not save when switching modes without editing", async () => { + const onEditTranscriptText = vi.fn(async () => true); + renderPane({ onEditTranscriptText }); + fireEvent.click(screen.getByRole("button", { name: "Transcript text" })); + fireEvent.click(screen.getByRole("button", { name: "Cut selected video" })); + await flushTextEdit(); + expect(onEditTranscriptText).not.toHaveBeenCalled(); + }); + + it("ignores intermediate IME composition input and saves once at compositionend", async () => { + const onEditTranscriptText = vi.fn(async () => true); + renderPane({ onEditTranscriptText }); + fireEvent.click(screen.getByRole("button", { name: "Transcript text" })); + const editor = screen.getByRole("textbox"); + + fireEvent.compositionStart(editor); + // Pinyin fragments arrive as input events mid-composition. + editor.textContent = "Hello brave worldn"; + fireEvent.input(editor, { inputType: "insertCompositionText", data: "n" }); + editor.textContent = "Hello brave worldni"; + fireEvent.input(editor, { inputType: "insertCompositionText", data: "i" }); + fireEvent.compositionEnd(editor, { data: "你" }); + editor.textContent = "Hello brave world你好"; + fireEvent.input(editor, { inputType: "insertCompositionText", data: "你" }); + await flushTextEdit(); + + expect(onEditTranscriptText).toHaveBeenCalledTimes(1); + expect(onEditTranscriptText).toHaveBeenCalledWith( + "asset_1", + ["w1", "w2", "w3"], + "Hello brave world你好", + ); + }); + + it("keeps the caret in place when a replaced transcript rewrites the DOM", async () => { + const replaced: AxcutTranscript = { + assetId: "asset_1", + language: "en", + segments: [ + { + id: "segment_new", + kind: "speech", + startSec: 0, + endSec: 3, + text: "Bonjour le monde", + wordIds: ["n1", "n2", "n3"], + }, + ], + words: [ + { id: "n1", segmentId: "segment_new", startSec: 0, endSec: 1, text: "Bonjour" }, + { id: "n2", segmentId: "segment_new", startSec: 1, endSec: 2, text: "le" }, + { id: "n3", segmentId: "segment_new", startSec: 2, endSec: 3, text: "monde" }, + ], + }; + const view = render(paneElement({ onEditTranscriptText: vi.fn(async () => true) })); + fireEvent.click(screen.getByRole("button", { name: "Transcript text" })); + const editor = screen.getByRole("textbox"); + editor.focus(); + const textNode = editor.firstChild as Text; + selectText(textNode, 6, 6); // caret after "Hello " + expect(editor.textContent).toBe("Hello\n\nbrave world"); + + // A transcription regen lands a new transcript while the user is + // focused in the editor: the DOM is rewritten to the new projection, + // and the caret must stay at its (clamped) offset instead of jumping + // to the start. + view.rerender(paneElement({ transcript: replaced })); + const selection = window.getSelection(); + expect(editor.textContent).toBe("Bonjour le monde"); + expect(selection?.rangeCount).toBeGreaterThan(0); + expect(editor.contains(selection?.anchorNode ?? null)).toBe(true); + expect(selection?.anchorOffset).toBe(6); + }); + + it("does not rewrite the DOM mid-composition even when the projection changes", async () => { + const replaced: AxcutTranscript = { + assetId: "asset_1", + language: "en", + segments: [ + { + id: "segment_new", + kind: "speech", + startSec: 0, + endSec: 3, + text: "Bonjour le monde", + wordIds: ["n1", "n2", "n3"], + }, + ], + words: [ + { id: "n1", segmentId: "segment_new", startSec: 0, endSec: 1, text: "Bonjour" }, + { id: "n2", segmentId: "segment_new", startSec: 1, endSec: 2, text: "le" }, + { id: "n3", segmentId: "segment_new", startSec: 2, endSec: 3, text: "monde" }, + ], + }; + const view = render(paneElement({ onEditTranscriptText: vi.fn(async () => true) })); + fireEvent.click(screen.getByRole("button", { name: "Transcript text" })); + const editor = screen.getByRole("textbox"); + editor.focus(); + fireEvent.compositionStart(editor); + editor.textContent = "Hello brave world中"; + fireEvent.input(editor, { inputType: "insertCompositionText", data: "中" }); + + // A projection update lands while composition is live: the DOM must be + // left to the IME until compositionend. + view.rerender(paneElement({ transcript: replaced })); + expect(editor.textContent).toBe("Hello brave world中"); + + fireEvent.compositionEnd(editor, { data: "中国" }); + // compositionend clears the guard; the next projection CHANGE (this + // rerender with a further transcript) converges the DOM onto it. + const afterComposition: AxcutTranscript = { + ...replaced, + words: [ + { id: "n1", segmentId: "segment_new", startSec: 0, endSec: 1, text: "Au" }, + { id: "n2", segmentId: "segment_new", startSec: 1, endSec: 2, text: "revoir" }, + ], + segments: [ + { + id: "segment_new", + kind: "speech", + startSec: 0, + endSec: 2, + text: "Au revoir", + wordIds: ["n1", "n2"], + }, + ], + }; + view.rerender(paneElement({ transcript: afterComposition })); + expect(editor.textContent).toBe("Au revoir"); + }); + + it("polls a pending save through a transcription run instead of dropping it", async () => { + const onEditTranscriptText = vi.fn(async () => true); + const view = render(paneElement({ onEditTranscriptText })); + fireEvent.click(screen.getByRole("button", { name: "Transcript text" })); + const editor = screen.getByRole("textbox"); + editor.textContent = "Hello brave world!"; + fireEvent.input(editor, { inputType: "insertText", data: "!" }); + + // The transcription run starts BEFORE the debounce fires: the editor + // goes read-only and the save must wait it out. + view.rerender(paneElement({ onEditTranscriptText, busyAssetIds: ["asset_1"] })); + await act(async () => { + vi.advanceTimersByTime(400); + await Promise.resolve(); + }); + expect(onEditTranscriptText).not.toHaveBeenCalled(); + + // The run finishes; the next poll commits the burst. + view.rerender(paneElement({ onEditTranscriptText })); + await act(async () => { + vi.advanceTimersByTime(400); + await Promise.resolve(); + }); + expect(onEditTranscriptText).toHaveBeenCalledWith( + "asset_1", + ["w1", "w2", "w3"], + "Hello brave world!", + ); + }); + + it("drops a stale pending burst when the transcript is replaced underneath it", async () => { + const replaced: AxcutTranscript = { + assetId: "asset_1", + language: "en", + segments: [ + { + id: "segment_new", + kind: "speech", + startSec: 0, + endSec: 3, + text: "Brand new words", + wordIds: ["n1", "n2", "n3"], + }, + ], + words: [ + { id: "n1", segmentId: "segment_new", startSec: 0, endSec: 1, text: "Brand" }, + { id: "n2", segmentId: "segment_new", startSec: 1, endSec: 2, text: "new" }, + { id: "n3", segmentId: "segment_new", startSec: 2, endSec: 3, text: "words" }, + ], + }; + const onEditTranscriptText = vi.fn(async () => true); + const view = render(paneElement({ onEditTranscriptText })); + fireEvent.click(screen.getByRole("button", { name: "Transcript text" })); + const editor = screen.getByRole("textbox"); + editor.textContent = "Hello brave world!"; + fireEvent.input(editor, { inputType: "insertText", data: "!" }); + + // The transcript is replaced wholesale (regen / undo) before the + // debounce fires: the burst was typed against the OLD word rows and + // must be dropped, not replayed over the replacement. + view.rerender(paneElement({ onEditTranscriptText, transcript: replaced })); + await flushTextEdit(); + expect(onEditTranscriptText).not.toHaveBeenCalled(); + expect(editor.textContent).toBe("Brand new words"); + }); + + it("keeps the pending burst when a save fails and the editor has focus, retrying once", async () => { + const onEditTranscriptText = vi.fn().mockResolvedValueOnce(false).mockResolvedValueOnce(true); + renderPane({ onEditTranscriptText }); + fireEvent.click(screen.getByRole("button", { name: "Transcript text" })); + const editor = screen.getByRole("textbox"); + editor.focus(); + editor.textContent = "Hello brave world!"; + fireEvent.input(editor, { inputType: "insertText", data: "!" }); + + await act(async () => { + vi.advanceTimersByTime(300); + await Promise.resolve(); + }); + // First attempt failed: the focused editor keeps its text and the + // pending burst for the retry. + expect(editor.textContent).toBe("Hello brave world!"); + expect(onEditTranscriptText).toHaveBeenCalledTimes(1); + + await act(async () => { + vi.advanceTimersByTime(1100); + await Promise.resolve(); + }); + expect(onEditTranscriptText).toHaveBeenCalledTimes(2); + expect(onEditTranscriptText).toHaveBeenLastCalledWith( + "asset_1", + ["w1", "w2", "w3"], + "Hello brave world!", + ); + }); + + it("reverts an unfocused editor when its save fails", async () => { + const onEditTranscriptText = vi.fn(async () => false); + renderPane({ onEditTranscriptText }); + fireEvent.click(screen.getByRole("button", { name: "Transcript text" })); + const editor = screen.getByRole("textbox"); + // No focus(): the user has moved on to another control. + editor.textContent = "unsaved text"; + fireEvent.input(editor, { inputType: "insertText" }); + await flushTextEdit(); + + // Reverted to the committed projection — text mode's own rendering of + // it, paragraph break included. + expect(editor.textContent).toBe("Hello\n\nbrave world"); + }); + + it("flushes an unsaved burst on unmount", async () => { + const onEditTranscriptText = vi.fn(async () => true); + const { unmount } = renderPane({ onEditTranscriptText }); + fireEvent.click(screen.getByRole("button", { name: "Transcript text" })); + const editor = screen.getByRole("textbox"); + editor.textContent = "Hello brave world!"; + fireEvent.input(editor, { inputType: "insertText", data: "!" }); + // Unmount BEFORE the 250ms debounce fires. + unmount(); + await act(async () => { + await Promise.resolve(); + }); + + expect(onEditTranscriptText).toHaveBeenCalledWith( + "asset_1", + ["w1", "w2", "w3"], + "Hello brave world!", + ); + }); + + it("flushes a pending burst immediately when leaving text mode", async () => { + const onEditTranscriptText = vi.fn(async () => true); + renderPane({ onEditTranscriptText }); + fireEvent.click(screen.getByRole("button", { name: "Transcript text" })); + const editor = screen.getByRole("textbox"); + editor.textContent = "Hello brave world!"; + fireEvent.input(editor, { inputType: "insertText", data: "!" }); + fireEvent.click(screen.getByRole("button", { name: "Cut selected video" })); + await act(async () => { + vi.advanceTimersByTime(0); + await Promise.resolve(); + }); + + expect(onEditTranscriptText).toHaveBeenCalledWith( + "asset_1", + ["w1", "w2", "w3"], + "Hello brave world!", + ); + }); + + it("flushes a pending burst immediately when the editor loses focus", async () => { + const onEditTranscriptText = vi.fn(async () => true); + renderPane({ onEditTranscriptText }); + fireEvent.click(screen.getByRole("button", { name: "Transcript text" })); + const editor = screen.getByRole("textbox"); + editor.focus(); + editor.textContent = "Hello brave world!"; + fireEvent.input(editor, { inputType: "insertText", data: "!" }); + fireEvent.blur(editor); + await act(async () => { + vi.advanceTimersByTime(0); + await Promise.resolve(); + }); + + expect(onEditTranscriptText).toHaveBeenCalledWith( + "asset_1", + ["w1", "w2", "w3"], + "Hello brave world!", + ); + }); + + it("clears the pending burst when the user undoes their own typing", async () => { + const onEditTranscriptText = vi.fn(async () => true); + renderPane({ onEditTranscriptText, trimRanges: [] }); + fireEvent.click(screen.getByRole("button", { name: "Transcript text" })); + const editor = screen.getByRole("textbox"); + editor.textContent = "Hello brave world!"; + fireEvent.input(editor, { inputType: "insertText", data: "!" }); + // Undo by hand before the debounce fires: the pending text now equals + // the committed projection ("Hello\n\nbrave world"), so nothing is + // saved and the view is restored to the projection's own rendering. + editor.textContent = "Hello\n\nbrave world"; + fireEvent.input(editor, { inputType: "deleteContentBackward" }); + await flushTextEdit(); + expect(onEditTranscriptText).not.toHaveBeenCalled(); + expect(editor.textContent).toBe("Hello\n\nbrave world"); + }); +}); diff --git a/src/i18n/locales/ar/settings.json b/src/i18n/locales/ar/settings.json index 5fc1ac58c..6bc230627 100644 --- a/src/i18n/locales/ar/settings.json +++ b/src/i18n/locales/ar/settings.json @@ -263,6 +263,11 @@ "help": "مساعدة" }, "transcript": { + "modeLabel": "وضع التفاعل مع النص المفرغ", + "textEditMode": "تحرير نص الترجمة", + "cutVideoMode": "قص الفيديو المحدد", + "textEditHelp": "عدّل نص التفريغ فقط من دون تغيير المخطط الزمني للفيديو.", + "cutVideoHelp": "حدد نصًا أو فترة صمت لقص الجزء المقابل من الفيديو.", "title": "النص الحالي", "help": "نص مجمّع لكل المقاطع على المخطط الزمني. اضغط Backspace / Delete على كلمة أو تحديد لتمييزه كمتخطّى (بالأحمر). مرّر المؤشر فوق المقطع الأحمر لاستعادته.", "noClips": "لا توجد مقاطع بعد", diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json index b291460d7..3d0c898e3 100644 --- a/src/i18n/locales/en/settings.json +++ b/src/i18n/locales/en/settings.json @@ -269,6 +269,11 @@ "help": "Help" }, "transcript": { + "modeLabel": "Transcript interaction mode", + "textEditMode": "Transcript text", + "cutVideoMode": "Cut selected video", + "textEditHelp": "Edit transcript text without changing the video timeline.", + "cutVideoHelp": "Select transcript words or pauses to cut them from the video.", "title": "Current transcription", "help": "Aggregated transcript of every clip on the timeline. Backspace / Delete a word or selection to mark it as skipped (red). Hover a red span to restore it.", "noClips": "No clips yet", diff --git a/src/i18n/locales/es/settings.json b/src/i18n/locales/es/settings.json index bb9ee27a2..cc7afa0d5 100644 --- a/src/i18n/locales/es/settings.json +++ b/src/i18n/locales/es/settings.json @@ -263,6 +263,11 @@ "help": "Ayuda" }, "transcript": { + "modeLabel": "Modo de interacción de la transcripción", + "textEditMode": "Editar texto de subtítulos", + "cutVideoMode": "Cortar vídeo seleccionado", + "textEditHelp": "Edita solo el texto de la transcripción sin cambiar la línea de tiempo del vídeo.", + "cutVideoHelp": "Selecciona texto o una pausa para cortar el fragmento de vídeo correspondiente.", "title": "Transcripción actual", "help": "Transcripción agregada de todos los clips de la línea de tiempo. Retroceso / Supr sobre una palabra o selección la marca como omitida (en rojo). Pasa el cursor sobre un fragmento rojo para restaurarlo.", "noClips": "Aún no hay clips", diff --git a/src/i18n/locales/fr/settings.json b/src/i18n/locales/fr/settings.json index 23dd573f0..09bdf35a3 100644 --- a/src/i18n/locales/fr/settings.json +++ b/src/i18n/locales/fr/settings.json @@ -263,6 +263,11 @@ "help": "Aide" }, "transcript": { + "modeLabel": "Mode d’interaction de la transcription", + "textEditMode": "Modifier le texte", + "cutVideoMode": "Couper la vidéo sélectionnée", + "textEditHelp": "Modifiez uniquement le texte de la transcription sans changer la timeline vidéo.", + "cutVideoHelp": "Sélectionnez du texte ou un silence pour couper la portion vidéo correspondante.", "title": "Transcription actuelle", "help": "Transcription agrégée de tous les clips de la timeline. Retour arrière / Suppr sur un mot ou une sélection le marque comme ignoré (en rouge). Survolez un passage rouge pour le restaurer.", "noClips": "Aucun clip pour l'instant", diff --git a/src/i18n/locales/it/settings.json b/src/i18n/locales/it/settings.json index e10828765..c7bd89079 100644 --- a/src/i18n/locales/it/settings.json +++ b/src/i18n/locales/it/settings.json @@ -263,6 +263,11 @@ "help": "Aiuto" }, "transcript": { + "modeLabel": "Modalità di interazione della trascrizione", + "textEditMode": "Modifica testo sottotitoli", + "cutVideoMode": "Taglia video selezionato", + "textEditHelp": "Modifica solo il testo della trascrizione senza cambiare la timeline video.", + "cutVideoHelp": "Seleziona testo o una pausa per tagliare la parte di video corrispondente.", "title": "Trascrizione corrente", "help": "Trascrizione aggregata di tutti i clip sulla timeline. Backspace / Canc su una parola o selezione la segna come saltata (in rosso). Passa sopra un tratto rosso per ripristinarlo.", "noClips": "Ancora nessun clip", diff --git a/src/i18n/locales/ja-JP/settings.json b/src/i18n/locales/ja-JP/settings.json index ead358d48..ef320175b 100644 --- a/src/i18n/locales/ja-JP/settings.json +++ b/src/i18n/locales/ja-JP/settings.json @@ -263,6 +263,11 @@ "help": "ヘルプ" }, "transcript": { + "modeLabel": "文字起こし操作モード", + "textEditMode": "字幕テキスト編集", + "cutVideoMode": "選択範囲の映像をカット", + "textEditHelp": "映像のタイムラインを変えずに字幕テキストだけを編集します。", + "cutVideoHelp": "字幕または無音区間を選択し、対応する映像をカットします。", "title": "現在の文字起こし", "help": "タイムライン上のすべてのクリップをまとめた文字起こしです。単語や選択範囲を Backspace / Delete でスキップ(赤色)にできます。赤い部分にカーソルを合わせると元に戻せます。", "noClips": "クリップがまだありません", diff --git a/src/i18n/locales/ko-KR/settings.json b/src/i18n/locales/ko-KR/settings.json index 631189192..5b59e4d3c 100644 --- a/src/i18n/locales/ko-KR/settings.json +++ b/src/i18n/locales/ko-KR/settings.json @@ -263,6 +263,11 @@ "help": "도움말" }, "transcript": { + "modeLabel": "자막 상호작용 모드", + "textEditMode": "자막 텍스트 편집", + "cutVideoMode": "선택 구간 영상 자르기", + "textEditHelp": "영상 타임라인은 변경하지 않고 자막 텍스트만 편집합니다.", + "cutVideoHelp": "자막이나 무음 구간을 선택해 해당 영상 구간을 자릅니다.", "title": "현재 전사", "help": "타임라인의 모든 클립을 합친 전사입니다. 단어나 선택 영역에서 Backspace / Delete를 누르면 건너뛴 것으로 표시됩니다(빨간색). 빨간 부분에 마우스를 올리면 복원할 수 있습니다.", "noClips": "아직 클립이 없습니다", diff --git a/src/i18n/locales/pt-BR/settings.json b/src/i18n/locales/pt-BR/settings.json index de22724be..29e0e0829 100644 --- a/src/i18n/locales/pt-BR/settings.json +++ b/src/i18n/locales/pt-BR/settings.json @@ -263,6 +263,11 @@ "help": "Ajuda" }, "transcript": { + "modeLabel": "Modo de interação da transcrição", + "textEditMode": "Editar texto da legenda", + "cutVideoMode": "Cortar vídeo selecionado", + "textEditHelp": "Edite apenas o texto da transcrição sem alterar a linha do tempo do vídeo.", + "cutVideoHelp": "Selecione texto ou uma pausa para cortar o trecho correspondente do vídeo.", "title": "Transcrição atual", "help": "Transcrição agregada de todos os clipes da linha do tempo. Backspace / Delete em uma palavra ou seleção marca como ignorada (em vermelho). Passe o mouse sobre um trecho vermelho para restaurá-lo.", "noClips": "Nenhum clipe ainda", diff --git a/src/i18n/locales/ru/settings.json b/src/i18n/locales/ru/settings.json index ca193c6d8..d97f3c452 100644 --- a/src/i18n/locales/ru/settings.json +++ b/src/i18n/locales/ru/settings.json @@ -263,6 +263,11 @@ "help": "Справка" }, "transcript": { + "modeLabel": "Режим работы с расшифровкой", + "textEditMode": "Редактировать текст субтитров", + "cutVideoMode": "Вырезать выбранное видео", + "textEditHelp": "Редактируйте только текст расшифровки, не меняя видеомонтаж.", + "cutVideoHelp": "Выберите текст или паузу, чтобы вырезать соответствующий фрагмент видео.", "title": "Текущая расшифровка", "help": "Сводная расшифровка всех клипов на таймлайне. Backspace / Delete по слову или выделению помечает его как пропущенное (красным). Наведите курсор на красный фрагмент, чтобы вернуть его.", "noClips": "Клипов пока нет", diff --git a/src/i18n/locales/tr/settings.json b/src/i18n/locales/tr/settings.json index 485666d42..0720be97d 100644 --- a/src/i18n/locales/tr/settings.json +++ b/src/i18n/locales/tr/settings.json @@ -263,6 +263,11 @@ "help": "Yardım" }, "transcript": { + "modeLabel": "Transkript etkileşim modu", + "textEditMode": "Altyazı metnini düzenle", + "cutVideoMode": "Seçili videoyu kes", + "textEditHelp": "Video zaman çizelgesini değiştirmeden yalnızca transkript metnini düzenleyin.", + "cutVideoHelp": "İlgili video bölümünü kesmek için metni veya bir duraklamayı seçin.", "title": "Geçerli döküm", "help": "Zaman çizelgesindeki tüm kliplerin birleşik dökümü. Bir kelimede veya seçimde Backspace / Delete tuşuna basmak onu atlanmış (kırmızı) olarak işaretler. Kırmızı bölümün üzerine gelerek geri alabilirsiniz.", "noClips": "Henüz klip yok", diff --git a/src/i18n/locales/vi/settings.json b/src/i18n/locales/vi/settings.json index a2af00397..9d8291138 100644 --- a/src/i18n/locales/vi/settings.json +++ b/src/i18n/locales/vi/settings.json @@ -263,6 +263,11 @@ "help": "Trợ giúp" }, "transcript": { + "modeLabel": "Chế độ tương tác bản chép lời", + "textEditMode": "Chỉnh sửa chữ phụ đề", + "cutVideoMode": "Cắt video đã chọn", + "textEditHelp": "Chỉ sửa văn bản bản chép lời mà không thay đổi dòng thời gian video.", + "cutVideoHelp": "Chọn văn bản hoặc khoảng lặng để cắt đoạn video tương ứng.", "title": "Bản chép lời hiện tại", "help": "Bản chép lời gộp của mọi clip trên dòng thời gian. Nhấn Backspace / Delete trên một từ hoặc vùng chọn để đánh dấu là bỏ qua (màu đỏ). Di chuột lên đoạn màu đỏ để khôi phục.", "noClips": "Chưa có clip nào", diff --git a/src/i18n/locales/zh-CN/settings.json b/src/i18n/locales/zh-CN/settings.json index bd13392a1..9e3191dcd 100644 --- a/src/i18n/locales/zh-CN/settings.json +++ b/src/i18n/locales/zh-CN/settings.json @@ -263,6 +263,11 @@ "help": "帮助" }, "transcript": { + "modeLabel": "字幕交互模式", + "textEditMode": "字幕文字编辑", + "cutVideoMode": "剪掉选区对应视频", + "textEditHelp": "只修改字幕文字,不改变视频时间线。可直接输入、粘贴或删除文字。", + "cutVideoHelp": "选中字幕文字或停顿,将对应片段从视频中剪掉。", "title": "当前转录", "help": "时间轴上所有片段的合并转录。对某个词或选区按退格 / Delete 会将其标记为跳过(红色)。将鼠标悬停在红色片段上可恢复。", "noClips": "暂无片段", diff --git a/src/i18n/locales/zh-TW/settings.json b/src/i18n/locales/zh-TW/settings.json index f9328155d..c452b1b13 100644 --- a/src/i18n/locales/zh-TW/settings.json +++ b/src/i18n/locales/zh-TW/settings.json @@ -264,6 +264,11 @@ "help": "說明" }, "transcript": { + "modeLabel": "字幕互動模式", + "textEditMode": "字幕文字編輯", + "cutVideoMode": "剪掉選取範圍對應影片", + "textEditHelp": "只修改字幕文字,不改變影片時間軸。可直接輸入、貼上或刪除文字。", + "cutVideoHelp": "選取字幕文字或停頓,將對應片段從影片中剪掉。", "title": "目前的逐字稿", "help": "時間軸上所有片段的合併逐字稿。對某個字或選取範圍按 Backspace / Delete 會標記為略過(紅色)。將滑鼠移到紅色片段上即可還原。", "noClips": "尚無片段", diff --git a/src/lib/ai-edition/store/documentWriteAudit.test.ts b/src/lib/ai-edition/store/documentWriteAudit.test.ts index 6a76e8013..386b225df 100644 --- a/src/lib/ai-edition/store/documentWriteAudit.test.ts +++ b/src/lib/ai-edition/store/documentWriteAudit.test.ts @@ -123,6 +123,8 @@ const DECLARED: WritePath[] = [ w("src/components/ai-edition/NewEditorShell.tsx", "NewEditorShell", "save", "automatic"), // "Save" on the unsaved-changes prompt. w("src/components/ai-edition/NewEditorShell.tsx", "handleConfirmUnsaved", "save", "gesture"), + // A transcript text edit committed after the user's short typing burst. + w("src/components/ai-edition/NewEditorShell.tsx", "handleEditTranscriptText", "save", "gesture"), // The probed duration folded into the document when the