Add SRT subtitle import to the captions panel

[Modified from 0bd641b4102c827ef8d218ada099229f73396077, 2026-03-28 08:46:23 -0400]

Parse .srt files into chunks and insert them as text elements on a new text track using the existing caption styling defaults. Displays a warning when malformed cues are skipped during import.
This commit is contained in:
kcfancher 2026-04-02 06:17:58 -04:00
parent 3516bd69f4
commit 63fa3821fa
3 changed files with 313 additions and 94 deletions

View File

@ -7,42 +7,41 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useState, useRef } from "react";
import { extractTimelineAudio } from "@/lib/media/mediabunny";
import { useEditor } from "@/hooks/use-editor";
import { useState, useRef } from "react";
import { extractTimelineAudio } from "@/lib/media/mediabunny";
import { useEditor } from "@/hooks/use-editor";
import {
DEFAULT_TRANSCRIPTION_SAMPLE_RATE,
TRANSCRIPTION_LANGUAGES,
} from "@/constants/transcription-constants";
import type {
TranscriptionLanguage,
TranscriptionProgress,
} from "@/lib/transcription/types";
import { transcriptionService } from "@/services/transcription/service";
import { decodeAudioToFloat32 } from "@/lib/media/audio";
import { buildCaptionChunks } from "@/lib/transcription/caption";
import { Spinner } from "@/components/ui/spinner";
import {
Section,
SectionContent,
SectionField,
SectionFields,
} from "@/components/section";
import { DEFAULTS } from "@/lib/timeline/defaults";
import {
AddTrackCommand,
BatchCommand,
InsertElementCommand,
} from "@/lib/commands";
export function Captions() {
const [selectedLanguage, setSelectedLanguage] =
useState<TranscriptionLanguage>("auto");
const [isProcessing, setIsProcessing] = useState(false);
const [processingStep, setProcessingStep] = useState("");
const [error, setError] = useState<string | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
const editor = useEditor();
import type {
CaptionChunk,
TranscriptionLanguage,
TranscriptionProgress,
} from "@/lib/transcription/types";
import { transcriptionService } from "@/services/transcription/service";
import { decodeAudioToFloat32 } from "@/lib/media/audio";
import { buildCaptionChunks } from "@/lib/transcription/caption";
import { insertCaptionChunksAsTextTrack } from "@/lib/subtitles/insert";
import { parseSrt } from "@/lib/subtitles/srt";
import { Spinner } from "@/components/ui/spinner";
import {
Section,
SectionContent,
SectionField,
SectionFields,
} from "@/components/section";
export function Captions() {
const [selectedLanguage, setSelectedLanguage] =
useState<TranscriptionLanguage>("auto");
const [isProcessing, setIsProcessing] = useState(false);
const [processingStep, setProcessingStep] = useState("");
const [error, setError] = useState<string | null>(null);
const [warning, setWarning] = useState<string | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const editor = useEditor();
const handleProgress = (progress: TranscriptionProgress) => {
if (progress.status === "loading-model") {
@ -53,10 +52,11 @@ export function Captions() {
};
const handleGenerateTranscript = async () => {
try {
setIsProcessing(true);
setError(null);
setProcessingStep("Extracting audio...");
try {
setIsProcessing(true);
setError(null);
setWarning(null);
setProcessingStep("Extracting audio...");
const audioBlob = await extractTimelineAudio({
tracks: editor.timeline.getTracks(),
@ -75,34 +75,11 @@ export function Captions() {
language: selectedLanguage === "auto" ? undefined : selectedLanguage,
onProgress: handleProgress,
});
setProcessingStep("Generating captions...");
const captionChunks = buildCaptionChunks({ segments: result.segments });
const addTrackCommand = new AddTrackCommand("text", 0);
const insertCommands = captionChunks.map(
(caption, i) =>
new InsertElementCommand({
placement: {
mode: "explicit",
trackId: addTrackCommand.getTrackId(),
},
element: {
...DEFAULTS.text.element,
name: `Caption ${i + 1}`,
content: caption.text,
duration: caption.duration,
startTime: caption.startTime,
fontSize: 65,
fontWeight: "bold",
},
}),
);
editor.command.execute({
command: new BatchCommand([addTrackCommand, ...insertCommands]),
});
} catch (error) {
setProcessingStep("Generating captions...");
const captionChunks = buildCaptionChunks({ segments: result.segments });
insertCaptionChunks({ captions: captionChunks });
} catch (error) {
console.error("Transcription failed:", error);
setError(
error instanceof Error ? error.message : "An unexpected error occurred",
@ -110,8 +87,78 @@ export function Captions() {
} finally {
setIsProcessing(false);
setProcessingStep("");
}
};
}
};
const insertCaptionChunks = ({
captions,
}: {
captions: CaptionChunk[];
}) => {
const trackId = insertCaptionChunksAsTextTrack({
editor,
captions,
});
if (!trackId) {
throw new Error("No captions were generated");
}
};
const handleImportClick = () => {
fileInputRef.current?.click();
};
const handleImportFile = async ({
file,
}: {
file: File;
}) => {
try {
setIsProcessing(true);
setError(null);
setWarning(null);
setProcessingStep("Reading subtitle file...");
const input = await file.text();
const result = parseSrt({ input });
if (result.captions.length === 0) {
throw new Error("No valid subtitle cues were found in the .srt file");
}
setProcessingStep("Importing subtitles...");
insertCaptionChunks({ captions: result.captions });
if (result.skippedCueCount > 0) {
setWarning(
`Imported ${result.captions.length} subtitle cue(s) and skipped ${result.skippedCueCount} malformed cue(s).`,
);
}
} catch (error) {
console.error("Subtitle import failed:", error);
setError(
error instanceof Error ? error.message : "An unexpected error occurred",
);
} finally {
setIsProcessing(false);
setProcessingStep("");
}
};
const handleFileChange = async ({
event,
}: {
event: React.ChangeEvent<HTMLInputElement>;
}) => {
const file = event.target.files?.[0];
if (event.target) {
event.target.value = "";
}
if (!file) return;
await handleImportFile({ file });
};
const handleLanguageChange = ({ value }: { value: string }) => {
if (value === "auto") {
@ -126,14 +173,21 @@ export function Captions() {
setSelectedLanguage(matchedLanguage.code);
};
return (
<PanelView
title="Captions"
contentClassName="px-0 flex flex-col h-full"
ref={containerRef}
>
<Section showTopBorder={false} showBottomBorder={false} className="flex-1">
<SectionContent className="flex flex-col gap-4 h-full pt-1">
return (
<PanelView
title="Captions"
contentClassName="px-0 flex flex-col h-full"
ref={containerRef}
>
<input
ref={fileInputRef}
type="file"
accept=".srt"
className="hidden"
onChange={(event) => void handleFileChange({ event })}
/>
<Section showTopBorder={false} showBottomBorder={false} className="flex-1">
<SectionContent className="flex flex-col gap-4 h-full pt-1">
<SectionFields>
<SectionField label="Language">
<Select
@ -155,25 +209,40 @@ export function Captions() {
</SectionField>
</SectionFields>
{error && (
<div className="bg-destructive/10 border-destructive/20 rounded-md border p-3">
<p className="text-destructive text-sm">{error}</p>
</div>
)}
</SectionContent>
</Section>
<Section showBottomBorder={false} showTopBorder={false}>
<SectionContent>
<Button
className="w-full"
onClick={handleGenerateTranscript}
disabled={isProcessing}
>
{isProcessing && <Spinner className="mr-1" />}
{isProcessing ? processingStep : "Generate transcript"}
</Button>
</SectionContent>
</Section>
</PanelView>
{error && (
<div className="bg-destructive/10 border-destructive/20 rounded-md border p-3">
<p className="text-destructive text-sm">{error}</p>
</div>
)}
{warning && (
<div className="rounded-md border border-amber-500/20 bg-amber-500/10 p-3">
<p className="text-sm text-amber-700">{warning}</p>
</div>
)}
</SectionContent>
</Section>
<Section showBottomBorder={false} showTopBorder={false}>
<SectionContent>
<div className="flex gap-2">
<Button
className="flex-1"
onClick={handleGenerateTranscript}
disabled={isProcessing}
>
{isProcessing && <Spinner className="mr-1" />}
{isProcessing ? processingStep : "Generate transcript"}
</Button>
<Button
variant="outline"
className="flex-1"
onClick={handleImportClick}
disabled={isProcessing}
>
Import .srt
</Button>
</div>
</SectionContent>
</Section>
</PanelView>
);
}

View File

@ -0,0 +1,38 @@
import type { EditorCore } from "@/core";
import { DEFAULTS } from "@/lib/timeline/defaults";
import type { CaptionChunk } from "@/lib/transcription/types";
export function insertCaptionChunksAsTextTrack({
editor,
captions,
}: {
editor: EditorCore;
captions: CaptionChunk[];
}): string | null {
if (captions.length === 0) {
return null;
}
const trackId = editor.timeline.addTrack({
type: "text",
index: 0,
});
for (let i = 0; i < captions.length; i++) {
const caption = captions[i];
editor.timeline.insertElement({
placement: { mode: "explicit", trackId },
element: {
...DEFAULTS.text.element,
name: `Caption ${i + 1}`,
content: caption.text,
duration: caption.duration,
startTime: caption.startTime,
fontSize: 65,
fontWeight: "bold",
},
});
}
return trackId;
}

View File

@ -0,0 +1,112 @@
import type { CaptionChunk } from "@/lib/transcription/types";
const TIMESTAMP_SEPARATOR = /\s*-->\s*/;
const TIMESTAMP_PATTERN =
/^(\d{2}:\d{2}:\d{2}[,.]\d{1,3})\s*-->\s*(\d{2}:\d{2}:\d{2}[,.]\d{1,3})/;
export interface ParseSrtResult {
captions: CaptionChunk[];
skippedCueCount: number;
}
export function parseSrt({
input,
}: {
input: string;
}): ParseSrtResult {
const normalized = input.replace(/\r\n?/g, "\n").trim();
if (!normalized) {
return {
captions: [],
skippedCueCount: 0,
};
}
const blocks = normalized.split(/\n{2,}/);
const cues: CaptionChunk[] = [];
let skippedCueCount = 0;
for (const block of blocks) {
const lines = block
.split("\n")
.map((line) => line.trim())
.filter((line) => line.length > 0);
if (lines.length < 2) {
skippedCueCount += 1;
continue;
}
const timestampIndex = TIMESTAMP_SEPARATOR.test(lines[0]) ? 0 : 1;
const timestampLine = lines[timestampIndex];
if (!timestampLine || !TIMESTAMP_PATTERN.test(timestampLine)) {
skippedCueCount += 1;
continue;
}
const textLines = lines.slice(timestampIndex + 1);
const text = textLines.join("\n").trim();
if (!text) {
skippedCueCount += 1;
continue;
}
const [rawStart, rawEnd] = timestampLine.split(TIMESTAMP_SEPARATOR);
if (!rawStart || !rawEnd) {
skippedCueCount += 1;
continue;
}
const startTime = parseSrtTimestamp({ input: rawStart });
const endTime = parseSrtTimestamp({ input: rawEnd });
const duration = endTime - startTime;
if (
!Number.isFinite(startTime) ||
!Number.isFinite(endTime) ||
duration <= 0
) {
skippedCueCount += 1;
continue;
}
cues.push({
text,
startTime,
duration,
});
}
return {
captions: cues,
skippedCueCount,
};
}
function parseSrtTimestamp({
input,
}: {
input: string;
}): number {
const normalized = input.trim().replace(",", ".");
const match = normalized.match(/^(\d{2}):(\d{2}):(\d{2})\.(\d{1,3})$/);
if (!match) {
return Number.NaN;
}
const [, hours, minutes, seconds, milliseconds] = match;
const parsedHours = Number.parseInt(hours, 10);
const parsedMinutes = Number.parseInt(minutes, 10);
const parsedSeconds = Number.parseInt(seconds, 10);
const parsedMilliseconds = Number.parseInt(
milliseconds.padEnd(3, "0"),
10,
);
return (
parsedHours * 3600 +
parsedMinutes * 60 +
parsedSeconds +
parsedMilliseconds / 1000
);
}