Skip to content

Commit 2766dc0

Browse files
committed
fix: validate translated transcript speaker annotations
1 parent 239aa5c commit 2766dc0

3 files changed

Lines changed: 111 additions & 18 deletions

File tree

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { describe, expect, it } from "vitest";
2+
import { isValidTranscriptTranslation } from "@/lib/transcript-vtt";
3+
4+
const source =
5+
"WEBVTT\n\n1\n00:00:00.125 --> 00:00:01.500\n<v Speaker A>Hello there.</v>\n\n2\n00:00:01.600 --> 00:00:03.000\n<v Speaker B>Good morning.</v>\n";
6+
const translated = source
7+
.replace("Hello there.", "Merhaba.")
8+
.replace("Good morning.", "Günaydın.");
9+
10+
describe("translation structure validation", () => {
11+
it("accepts translated speech with unchanged voices and timings", () => {
12+
expect(isValidTranscriptTranslation(source, translated)).toBe(true);
13+
expect(
14+
isValidTranscriptTranslation(source, translated.replace(/\n/g, "\r\n")),
15+
).toBe(true);
16+
expect(
17+
isValidTranscriptTranslation(
18+
source,
19+
translated.replace("Merhaba.", "Merhaba\narkadaşım."),
20+
),
21+
).toBe(true);
22+
});
23+
it.each([
24+
translated.replace("<v Speaker A>", ""),
25+
translated.replace("<v Speaker A>", "<v Speaker B>"),
26+
translated.replace("<v Speaker A>", "<v Konuşmacı A>"),
27+
translated.replace("</v>", ""),
28+
translated.replace("Merhaba.", "Merhaba.<v Speaker B>Ek söz.</v>"),
29+
translated.replace("00:00:00.125", "00:00:00.000"),
30+
translated.replace("\n2\n", "\n3\n"),
31+
translated.split("\n\n2")[0] ?? "",
32+
translated.replace("Merhaba.", ""),
33+
`Here is the WEBVTT:\n${translated}`,
34+
`${translated}\nExtra explanation`,
35+
])("rejects changed metadata or malformed cues before caching", (value) => {
36+
expect(isValidTranscriptTranslation(source, value)).toBe(false);
37+
});
38+
it("accepts legacy captions but rejects invented speakers", () => {
39+
const legacy = source.replace(/<[^>]*>/g, "");
40+
expect(
41+
isValidTranscriptTranslation(legacy, legacy.replace("Hello", "Merhaba")),
42+
).toBe(true);
43+
expect(isValidTranscriptTranslation(legacy, translated)).toBe(false);
44+
});
45+
});

apps/web/actions/videos/translate-transcript.ts

Lines changed: 24 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { runWithAiProviders } from "@/lib/ai/run";
1212
import { isRateLimited, RATE_LIMIT_IDS } from "@/lib/rate-limit";
1313
import * as EffectRuntime from "@/lib/server";
1414
import { runPromise } from "@/lib/server";
15+
import { isValidTranscriptTranslation } from "@/lib/transcript-vtt";
1516
import { decodeStorageVideo } from "@/lib/video-storage";
1617
import {
1718
type LanguageCode,
@@ -73,6 +74,19 @@ export async function translateTranscript(
7374

7475
const { video } = query[0];
7576

77+
const originalVtt = await Effect.gen(function* () {
78+
const [bucket] = yield* Storage.getAccessForVideo(
79+
decodeStorageVideo(video),
80+
);
81+
return yield* bucket.getObject(
82+
`${video.ownerId}/${videoId}/transcription.vtt`,
83+
);
84+
}).pipe(runPromise);
85+
86+
if (Option.isNone(originalVtt)) {
87+
return { success: false, message: "Original transcript not found" };
88+
}
89+
7690
const translatedKey = `${video.ownerId}/${videoId}/transcription.${targetLanguage}.vtt`;
7791

7892
try {
@@ -83,7 +97,10 @@ export async function translateTranscript(
8397
return yield* bucket.getObject(translatedKey);
8498
}).pipe(runPromise);
8599

86-
if (Option.isSome(existingTranslation)) {
100+
if (
101+
Option.isSome(existingTranslation) &&
102+
isValidTranscriptTranslation(originalVtt.value, existingTranslation.value)
103+
) {
87104
return {
88105
success: true,
89106
translatedVtt: existingTranslation.value,
@@ -94,19 +111,6 @@ export async function translateTranscript(
94111
console.debug("[translateTranscript] No cached translation found:", e);
95112
}
96113

97-
const originalVtt = await Effect.gen(function* () {
98-
const [bucket] = yield* Storage.getAccessForVideo(
99-
decodeStorageVideo(video),
100-
);
101-
return yield* bucket.getObject(
102-
`${video.ownerId}/${videoId}/transcription.vtt`,
103-
);
104-
}).pipe(runPromise);
105-
106-
if (Option.isNone(originalVtt)) {
107-
return { success: false, message: "Original transcript not found" };
108-
}
109-
110114
const translatedVtt = await translateVttContent(
111115
originalVtt.value,
112116
targetLanguage,
@@ -167,10 +171,12 @@ ${vttContent}`;
167171
...(selection.supportsTemperature ? { temperature: 0.3 } : {}),
168172
});
169173

170-
// Validate inside the provider loop so a fulfilled response that
171-
// dropped the WEBVTT header falls through to the next provider.
172-
if (!response.text.includes("WEBVTT")) {
173-
throw new Error("translation response did not contain WEBVTT");
174+
// Validate inside the provider loop so malformed translations try the
175+
// next provider without poisoning the cached captions.
176+
if (!isValidTranscriptTranslation(vttContent, response.text)) {
177+
throw new Error(
178+
"translation changed cue structure or speaker annotations",
179+
);
174180
}
175181

176182
return response.text.trim();

apps/web/lib/transcript-vtt.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,3 +244,45 @@ export const parseVTT = (vttContent: string): TranscriptEntry[] => {
244244
const sortedEntries = entries.sort((a, b) => a.startTime - b.startTime);
245245
return sortedEntries;
246246
};
247+
248+
function getTranslationCueStructure(content: string) {
249+
const blocks = content
250+
.trim()
251+
.replace(/\r\n?/g, "\n")
252+
.split(/\n[\t ]*\n/);
253+
if (blocks.shift() !== "WEBVTT" || blocks.length === 0) return null;
254+
const cues: { id: string; timing: string; voices: string[] }[] = [];
255+
for (const block of blocks) {
256+
const [id, timing, ...payload] = block.split("\n");
257+
if (
258+
!id ||
259+
!/^\d+$/.test(id) ||
260+
!timing ||
261+
!/^\d{2,}:\d{2}:\d{2}\.\d{3} --> \d{2,}:\d{2}:\d{2}\.\d{3}(?:[ \t].*)?$/.test(
262+
timing,
263+
)
264+
)
265+
return null;
266+
const text = payload.join("\n");
267+
if (!parseVttCueText(text).text) return null;
268+
cues.push({
269+
id,
270+
timing,
271+
voices: text.match(/<\/?v(?:[.\s][^>]*)?>/g) ?? [],
272+
});
273+
}
274+
return cues;
275+
}
276+
277+
export function isValidTranscriptTranslation(
278+
source: string,
279+
translated: string,
280+
): boolean {
281+
const sourceCues = getTranslationCueStructure(source);
282+
const translatedCues = getTranslationCueStructure(translated);
283+
return (
284+
sourceCues !== null &&
285+
translatedCues !== null &&
286+
JSON.stringify(sourceCues) === JSON.stringify(translatedCues)
287+
);
288+
}

0 commit comments

Comments
 (0)