Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions components/ExportDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ export default function ExportDialog() {
const [textFormat, setTextFormat] = useState<TranscriptFormat>("txt");
const [includeTimestamps, setIncludeTimestamps] = useState(false);
const [shortCues, setShortCues] = useState(true);
const [speakerLabels, setSpeakerLabels] = useState(true);
const [timelineFormat, setTimelineFormat] =
useState<TimelineExportFormat>("resolve");
const [timelineFrameRate, setTimelineFrameRate] =
Expand Down Expand Up @@ -273,6 +274,7 @@ export default function ExportDialog() {

const textSupportsTimestamps = DOC_FORMATS.has(textFormat);
const textSupportsShortCues = SUBTITLE_FORMATS.has(textFormat);
const textSupportsSpeakerLabels = textFormat !== "json";

const exportText = useCallback(() => {
// Read live store state at click time so a rename / replace-in-project
Expand All @@ -287,13 +289,15 @@ export default function ExportDialog() {
speakers: speakersFromWords(s.words, s.speakers),
...(textSupportsTimestamps ? { timestamps: includeTimestamps } : {}),
...(textSupportsShortCues ? { shortCues } : {}),
...(textSupportsSpeakerLabels ? { speakerLabels } : {}),
});
setError(null);
trackEvent("export_completed", {
kind: "transcript",
format: textFormat,
...(textSupportsTimestamps ? { timestamps: includeTimestamps } : {}),
...(textSupportsShortCues ? { shortCues } : {}),
...(textSupportsSpeakerLabels ? { speakerLabels } : {}),
});
} catch (err) {
setError(err instanceof Error ? err.message : en["error.export"]);
Expand All @@ -302,8 +306,10 @@ export default function ExportDialog() {
textFormat,
textSupportsTimestamps,
textSupportsShortCues,
textSupportsSpeakerLabels,
includeTimestamps,
shortCues,
speakerLabels,
baseName,
]);

Expand Down Expand Up @@ -514,6 +520,17 @@ export default function ExportDialog() {
<span>{t("export.includeTimestamps")}</span>
</label>
)}
{textSupportsSpeakerLabels && (
<label className="flex cursor-pointer items-center gap-2.5 rounded-lg bg-zinc-50 px-3 py-2.5 text-sm text-zinc-700 dark:bg-zinc-800/60 dark:text-zinc-200">
<input
type="checkbox"
checked={speakerLabels}
onChange={(e) => setSpeakerLabels(e.target.checked)}
className="h-3.5 w-3.5 rounded border-zinc-300 text-zinc-900 focus:ring-zinc-400 dark:border-zinc-600 dark:bg-zinc-900"
/>
<span>{t("export.speakerLabels")}</span>
</label>
)}
{textSupportsShortCues && (
<label className="flex cursor-pointer items-center gap-2.5 rounded-lg bg-zinc-50 px-3 py-2.5 text-sm text-zinc-700 dark:bg-zinc-800/60 dark:text-zinc-200">
<input
Expand Down
1 change: 1 addition & 0 deletions lib/i18n/messages/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@ export const de: Record<MessageKey, string> = {
"export.statCuts": "Schnitte",
"export.statEdited": "Bearbeitet",
"export.includeTimestamps": "Zeitstempel einbeziehen",
"export.speakerLabels": "Sprechernamen einbeziehen",
"export.shortCues": "Kurze Untertitel (≤5 s)",
"export.transcriptHelp": "Text mit Sprecherlabels und entfernten Schnitten.",
"export.transcriptHelpTimestamps":
Expand Down
1 change: 1 addition & 0 deletions lib/i18n/messages/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,7 @@ export const en = {
"export.statCuts": "Cuts",
"export.statEdited": "Edited",
"export.includeTimestamps": "Include timestamps",
"export.speakerLabels": "Include speaker names",
"export.shortCues": "Short cues (≤5s)",
"export.transcriptHelp": "Speaker-labeled text with cuts removed.",
"export.transcriptHelpTimestamps":
Expand Down
1 change: 1 addition & 0 deletions lib/i18n/messages/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@ export const es: Record<MessageKey, string> = {
"export.statCuts": "Cortes",
"export.statEdited": "Editado",
"export.includeTimestamps": "Incluir marcas de tiempo",
"export.speakerLabels": "Incluir nombres de hablantes",
"export.shortCues": "Subtítulos cortos (≤5 s)",
"export.transcriptHelp": "Texto con etiquetas de hablante y cortes eliminados.",
"export.transcriptHelpTimestamps":
Expand Down
1 change: 1 addition & 0 deletions lib/i18n/messages/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@ export const fr: Record<MessageKey, string> = {
"export.statCuts": "Coupes",
"export.statEdited": "Monté",
"export.includeTimestamps": "Inclure les horodatages",
"export.speakerLabels": "Inclure les noms des locuteurs",
"export.shortCues": "Sous-titres courts (≤5 s)",
"export.transcriptHelp": "Texte avec étiquettes de locuteur et coupes retirées.",
"export.transcriptHelpTimestamps":
Expand Down
1 change: 1 addition & 0 deletions lib/i18n/messages/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@ export const ja: Record<MessageKey, string> = {
"export.statCuts": "カット",
"export.statEdited": "編集後",
"export.includeTimestamps": "タイムスタンプを含める",
"export.speakerLabels": "話者名を含める",
"export.shortCues": "短い字幕(≤5秒)",
"export.transcriptHelp": "話者ラベル付きテキスト。カット部分は除外されます。",
"export.transcriptHelpTimestamps":
Expand Down
1 change: 1 addition & 0 deletions lib/i18n/messages/ko.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@ export const ko: Record<MessageKey, string> = {
"export.statCuts": "컷",
"export.statEdited": "편집본",
"export.includeTimestamps": "타임스탬프 포함",
"export.speakerLabels": "화자 이름 포함",
"export.shortCues": "짧은 자막 (≤5초)",
"export.transcriptHelp": "화자 라벨이 포함된 텍스트이며 컷한 내용은 제거됩니다.",
"export.transcriptHelpTimestamps":
Expand Down
1 change: 1 addition & 0 deletions lib/i18n/messages/pt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@ export const pt: Record<MessageKey, string> = {
"export.statCuts": "Cortes",
"export.statEdited": "Editado",
"export.includeTimestamps": "Incluir marcas de tempo",
"export.speakerLabels": "Incluir nomes dos falantes",
"export.shortCues": "Legendas curtas (≤5 s)",
"export.transcriptHelp": "Texto com rótulos de falante e cortes removidos.",
"export.transcriptHelpTimestamps":
Expand Down
1 change: 1 addition & 0 deletions lib/i18n/messages/zh-CN.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ export const zhCN: Record<MessageKey, string> = {
"export.statCuts": "剪除",
"export.statEdited": "成片",
"export.includeTimestamps": "包含时间戳",
"export.speakerLabels": "包含说话人姓名",
"export.shortCues": "短字幕(≤5 秒)",
"export.transcriptHelp": "带说话人标签的文本,已移除剪除内容。",
"export.transcriptHelpTimestamps":
Expand Down
1 change: 1 addition & 0 deletions lib/i18n/messages/zh-TW.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@ export const zhTW: Record<MessageKey, string> = {
"export.statCuts": "剪除",
"export.statEdited": "成片",
"export.includeTimestamps": "包含時間戳",
"export.speakerLabels": "包含說話人姓名",
"export.shortCues": "短字幕(≤5 秒)",
"export.transcriptHelp": "帶有說話人標籤的文字,已移除剪除內容。",
"export.transcriptHelpTimestamps":
Expand Down
70 changes: 54 additions & 16 deletions lib/serializeTranscript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,12 @@ export interface SerializeOptions {
* on speaker changes and pause gaps (legacy long cues).
*/
shortCues?: boolean;
/**
* When true (default for SRT/VTT/TXT/MD/DOCX/PDF), prefix cues / turns with
* speaker names (`Speaker 1: …`, VTT `<v>`, document headings). When false,
* export dialogue only. JSON always keeps speaker ids/names for re-import.
*/
speakerLabels?: boolean;
}

interface Cue {
Expand Down Expand Up @@ -108,10 +114,11 @@ export function serializeTranscript(
throw new Error(en["error.noWords"]);
}
const shortCues = options.shortCues !== false;
const speakerLabels = options.speakerLabels !== false;
const cues = wordsToCues(prepared, shortCues);
return format === "vtt"
? serializeVtt(cues, speakers, shortCues)
: serializeSrt(cues, speakers, shortCues);
? serializeVtt(cues, speakers, shortCues, speakerLabels)
: serializeSrt(cues, speakers, shortCues, speakerLabels);
}

/** Serialize to DOCX or PDF (binary). */
Expand All @@ -122,8 +129,11 @@ export function serializeTranscriptBinary(
): Uint8Array {
const speakers = speakersFromWords(words, options.speakers ?? []);
const turns = buildDocumentTurns(words, { ...options, speakers });
if (format === "docx") return serializeDocx(turns, speakers, options.timestamps);
return serializePdf(turns, speakers, options.timestamps);
const speakerLabels = options.speakerLabels !== false;
if (format === "docx") {
return serializeDocx(turns, speakers, options.timestamps, speakerLabels);
}
return serializePdf(turns, speakers, options.timestamps, speakerLabels);
}

/** Trigger a browser download of the serialized transcript / subtitles. */
Expand Down Expand Up @@ -198,11 +208,17 @@ function serializeDocument(
const speakers = speakersFromWords(words, options.speakers ?? []);
const turns = buildDocumentTurns(words, { ...options, speakers });
const withTs = Boolean(options.timestamps);
const withLabels = options.speakerLabels !== false;

if (format === "txt") {
return (
turns
.map((turn) => {
if (!withLabels) {
return withTs
? `[${formatTranscriptTimestamp(turn.start)}] ${turn.text}`
: turn.text;
}
const label = speakerLabel(speakers, turn.speaker);
const prefix = withTs
? `[${formatTranscriptTimestamp(turn.start)}] ${label}`
Expand All @@ -216,6 +232,11 @@ function serializeDocument(
return (
turns
.map((turn) => {
if (!withLabels) {
return withTs
? `**[${formatTranscriptTimestamp(turn.start)}]**\n\n${turn.text}`
: turn.text;
}
const label = speakerLabel(speakers, turn.speaker);
const heading = withTs
? `**[${formatTranscriptTimestamp(turn.start)}] ${label}**`
Expand All @@ -237,10 +258,20 @@ function escapeXml(text: string): string {
function serializeDocx(
turns: DocumentTurn[],
speakers: SpeakerInfo[],
timestamps?: boolean
timestamps?: boolean,
speakerLabels = true
): Uint8Array {
const paragraphs = turns
.map((turn) => {
const body = `<w:p><w:pPr><w:spacing w:after="240"/></w:pPr><w:r><w:t xml:space="preserve">${escapeXml(turn.text)}</w:t></w:r></w:p>`;
if (!speakerLabels) {
if (!timestamps) return body;
const heading = `[${formatTranscriptTimestamp(turn.start)}]`;
return [
`<w:p><w:pPr><w:spacing w:after="80"/></w:pPr><w:r><w:rPr><w:b/></w:rPr><w:t xml:space="preserve">${escapeXml(heading)}</w:t></w:r></w:p>`,
body,
].join("");
}
const label = speakerLabel(speakers, turn.speaker);
const heading = timestamps
? `[${formatTranscriptTimestamp(turn.start)}] ${label}`
Expand All @@ -249,7 +280,7 @@ function serializeDocx(
// Speaker heading (bold)
`<w:p><w:pPr><w:spacing w:after="80"/></w:pPr><w:r><w:rPr><w:b/></w:rPr><w:t xml:space="preserve">${escapeXml(heading)}</w:t></w:r></w:p>`,
// Body
`<w:p><w:pPr><w:spacing w:after="240"/></w:pPr><w:r><w:t xml:space="preserve">${escapeXml(turn.text)}</w:t></w:r></w:p>`,
body,
].join("");
})
.join("");
Expand Down Expand Up @@ -447,7 +478,8 @@ function wrapCueLines(text: string, maxChars: number): string[] {
function serializeSrt(
cues: Cue[],
speakers: SpeakerInfo[],
shortCues: boolean
shortCues: boolean,
speakerLabels: boolean
): string {
return (
cues
Expand All @@ -456,7 +488,7 @@ function serializeSrt(
String(i + 1),
`${formatSrtTimestamp(cue.start)} --> ${formatSrtTimestamp(cue.end)}`,
];
if (cue.speaker >= 0) {
if (speakerLabels && cue.speaker >= 0) {
lines.push(
formatCueBody(
cue.text,
Expand All @@ -476,7 +508,8 @@ function serializeSrt(
function serializeVtt(
cues: Cue[],
speakers: SpeakerInfo[],
shortCues: boolean
shortCues: boolean,
speakerLabels: boolean
): string {
const body = cues
.map((cue) => {
Expand All @@ -485,7 +518,7 @@ function serializeVtt(
? wrapCueLines(cue.text, CUE_LINE_CHARS).join("\n")
: cue.text;
const text =
cue.speaker >= 0
speakerLabels && cue.speaker >= 0
? `<v ${speakerLabel(speakers, cue.speaker)}>${dialogue}`
: dialogue;
return `${timing}\n${text}`;
Expand Down Expand Up @@ -604,7 +637,8 @@ function wrapPdfLine(text: string, maxChars: number): string[] {
function serializePdf(
turns: DocumentTurn[],
speakers: SpeakerInfo[],
timestamps?: boolean
timestamps?: boolean,
speakerLabels = true
): Uint8Array {
const pageWidth = 612;
const pageHeight = 792;
Expand All @@ -617,11 +651,15 @@ function serializePdf(
type PdfLine = { text: string; bold: boolean };
const allLines: PdfLine[] = [];
for (const turn of turns) {
const label = speakerLabel(speakers, turn.speaker);
const heading = timestamps
? `[${formatTranscriptTimestamp(turn.start)}] ${label}`
: label;
allLines.push({ text: heading, bold: true });
if (speakerLabels || timestamps) {
const label = speakerLabel(speakers, turn.speaker);
const heading = !speakerLabels
? `[${formatTranscriptTimestamp(turn.start)}]`
: timestamps
? `[${formatTranscriptTimestamp(turn.start)}] ${label}`
: label;
allLines.push({ text: heading, bold: true });
}
for (const line of wrapPdfLine(turn.text, maxChars)) {
allLines.push({ text: line, bold: false });
}
Expand Down
41 changes: 41 additions & 0 deletions tests/serialize-transcript-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -435,4 +435,45 @@ const sample: Word[] = [
console.log("max cue chars: ok");
}

{
// speakerLabels: false omits names from captions and documents.
const srt = serializeTranscript(sample, "srt", {
duration: 10,
speakerLabels: false,
});
assert(srt.includes("Hello world"), `srt text\n${srt}`);
assert(srt.includes("How are you"), `srt text 2\n${srt}`);
assert(!/Speaker \d+:/.test(srt), `srt should omit speaker prefix\n${srt}`);

const vtt = serializeTranscript(sample, "vtt", {
duration: 10,
speakerLabels: false,
});
assert(vtt.includes("Hello world"), `vtt text\n${vtt}`);
assert(!vtt.includes("<v "), `vtt should omit voice tags\n${vtt}`);

const txt = serializeTranscript(sample, "txt", {
duration: 10,
speakerLabels: false,
});
assert(txt.includes("Hello world"), `txt text\n${txt}`);
assert(!/Speaker \d+:/.test(txt), `txt should omit labels\n${txt}`);

const md = serializeTranscript(sample, "md", {
duration: 10,
speakerLabels: false,
timestamps: true,
});
assert(md.includes("Hello world"), `md text\n${md}`);
assert(md.includes("**[0:01]**"), `md keeps timestamps without names\n${md}`);
assert(!md.includes("Speaker"), `md should omit speaker names\n${md}`);

// JSON always keeps speakers for re-import regardless of the toggle.
const json = serializeTranscript(sample, "json", { speakerLabels: false });
const data = JSON.parse(json);
assert(data.speakers?.[0]?.name === "Speaker 1", "json keeps speaker names");
assert(data.words[0].speaker === 0, "json keeps speaker ids");
console.log("speakerLabels off: ok");
}

console.log("ALL SERIALIZE TRANSCRIPT TESTS PASSED");
Loading