summaryrefslogtreecommitdiff
path: root/apps/web/src/components/editor/panels/assets/views
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src/components/editor/panels/assets/views')
-rw-r--r--apps/web/src/components/editor/panels/assets/views/captions.tsx461
1 files changed, 282 insertions, 179 deletions
diff --git a/apps/web/src/components/editor/panels/assets/views/captions.tsx b/apps/web/src/components/editor/panels/assets/views/captions.tsx
index c89e587b..488875a8 100644
--- a/apps/web/src/components/editor/panels/assets/views/captions.tsx
+++ b/apps/web/src/components/editor/panels/assets/views/captions.tsx
@@ -1,179 +1,282 @@
-import { Button } from "@/components/ui/button";
-import { PanelView } from "@/components/editor/panels/assets/views/base-panel";
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "@/components/ui/select";
-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();
-
- const handleProgress = (progress: TranscriptionProgress) => {
- if (progress.status === "loading-model") {
- setProcessingStep(`Loading model ${Math.round(progress.progress)}%`);
- } else if (progress.status === "transcribing") {
- setProcessingStep("Transcribing...");
- }
- };
-
- const handleGenerateTranscript = async () => {
- try {
- setIsProcessing(true);
- setError(null);
- setProcessingStep("Extracting audio...");
-
- const audioBlob = await extractTimelineAudio({
- tracks: editor.scenes.getActiveScene().tracks,
- mediaAssets: editor.media.getAssets(),
- totalDuration: editor.timeline.getTotalDuration(),
- });
-
- setProcessingStep("Preparing audio...");
- const { samples } = await decodeAudioToFloat32({
- audioBlob,
- sampleRate: DEFAULT_TRANSCRIPTION_SAMPLE_RATE,
- });
-
- const result = await transcriptionService.transcribe({
- audioData: samples,
- 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) {
- console.error("Transcription failed:", error);
- setError(
- error instanceof Error ? error.message : "An unexpected error occurred",
- );
- } finally {
- setIsProcessing(false);
- setProcessingStep("");
- }
- };
-
- const handleLanguageChange = ({ value }: { value: string }) => {
- if (value === "auto") {
- setSelectedLanguage("auto");
- return;
- }
-
- const matchedLanguage = TRANSCRIPTION_LANGUAGES.find(
- (language) => language.code === value,
- );
- if (!matchedLanguage) return;
- 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">
- <SectionFields>
- <SectionField label="Language">
- <Select
- value={selectedLanguage}
- onValueChange={(value) => handleLanguageChange({ value })}
- >
- <SelectTrigger>
- <SelectValue placeholder="Select a language" />
- </SelectTrigger>
- <SelectContent>
- <SelectItem value="auto">Auto detect</SelectItem>
- {TRANSCRIPTION_LANGUAGES.map((language) => (
- <SelectItem key={language.code} value={language.code}>
- {language.name}
- </SelectItem>
- ))}
- </SelectContent>
- </Select>
- </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>
- );
-}
+import { Button } from "@/components/ui/button";
+import { PanelView } from "@/components/editor/panels/assets/views/base-panel";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { useReducer, useRef, useState } 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 {
+ 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 { parseSubtitleFile } from "@/lib/subtitles/parse";
+import { Spinner } from "@/components/ui/spinner";
+import {
+ Section,
+ SectionContent,
+ SectionField,
+ SectionFields,
+} from "@/components/section";
+import { CloudUploadIcon } from "@hugeicons/core-free-icons";
+import { HugeiconsIcon } from "@hugeicons/react";
+
+type ProcessingState =
+ | { status: "idle"; error: string | null; warnings: string[] }
+ | { status: "processing"; step: string };
+
+type ProcessingAction =
+ | { type: "start"; step: string }
+ | { type: "update_step"; step: string }
+ | { type: "succeed"; warnings: string[] }
+ | { type: "fail"; error: string };
+
+const IDLE_STATE: ProcessingState = { status: "idle", error: null, warnings: [] };
+
+function processingReducer(
+ state: ProcessingState,
+ action: ProcessingAction,
+): ProcessingState {
+ switch (action.type) {
+ case "start":
+ return { status: "processing", step: action.step };
+ case "update_step":
+ if (state.status !== "processing") return state;
+ return { status: "processing", step: action.step };
+ case "succeed":
+ return { status: "idle", error: null, warnings: action.warnings };
+ case "fail":
+ return { status: "idle", error: action.error, warnings: [] };
+ }
+}
+
+export function Captions() {
+ const [selectedLanguage, setSelectedLanguage] =
+ useState<TranscriptionLanguage>("auto");
+ const [processing, dispatch] = useReducer(processingReducer, IDLE_STATE);
+ const containerRef = useRef<HTMLDivElement>(null);
+ const fileInputRef = useRef<HTMLInputElement>(null);
+ const editor = useEditor();
+
+ const isProcessing = processing.status === "processing";
+
+ const handleProgress = (progress: TranscriptionProgress) => {
+ if (progress.status === "loading-model") {
+ dispatch({
+ type: "update_step",
+ step: `Loading model ${Math.round(progress.progress)}%`,
+ });
+ } else if (progress.status === "transcribing") {
+ dispatch({ type: "update_step", step: "Transcribing..." });
+ }
+ };
+
+ const insertCaptions = ({ captions }: { captions: CaptionChunk[] }): boolean => {
+ const trackId = insertCaptionChunksAsTextTrack({ editor, captions });
+ return trackId !== null;
+ };
+
+ const handleGenerateTranscript = async () => {
+ dispatch({ type: "start", step: "Extracting audio..." });
+ try {
+ const audioBlob = await extractTimelineAudio({
+ tracks: editor.scenes.getActiveScene().tracks,
+ mediaAssets: editor.media.getAssets(),
+ totalDuration: editor.timeline.getTotalDuration(),
+ });
+
+ dispatch({ type: "update_step", step: "Preparing audio..." });
+ const { samples } = await decodeAudioToFloat32({
+ audioBlob,
+ sampleRate: DEFAULT_TRANSCRIPTION_SAMPLE_RATE,
+ });
+
+ const result = await transcriptionService.transcribe({
+ audioData: samples,
+ language: selectedLanguage === "auto" ? undefined : selectedLanguage,
+ onProgress: handleProgress,
+ });
+
+ dispatch({ type: "update_step", step: "Generating captions..." });
+ const captionChunks = buildCaptionChunks({ segments: result.segments });
+
+ if (!insertCaptions({ captions: captionChunks })) {
+ dispatch({ type: "fail", error: "No captions were generated" });
+ return;
+ }
+
+ dispatch({ type: "succeed", warnings: [] });
+ } catch (error) {
+ console.error("Transcription failed:", error);
+ dispatch({
+ type: "fail",
+ error: error instanceof Error ? error.message : "An unexpected error occurred",
+ });
+ }
+ };
+
+ const handleImportClick = () => {
+ fileInputRef.current?.click();
+ };
+
+ const handleImportFile = async ({ file }: { file: File }) => {
+ dispatch({ type: "start", step: "Reading subtitle file..." });
+ try {
+ const input = await file.text();
+ const result = parseSubtitleFile({
+ fileName: file.name,
+ input,
+ });
+
+ if (result.captions.length === 0) {
+ dispatch({
+ type: "fail",
+ error: "No valid subtitle cues were found in the subtitle file",
+ });
+ return;
+ }
+
+ dispatch({ type: "update_step", step: "Importing subtitles..." });
+
+ if (!insertCaptions({ captions: result.captions })) {
+ dispatch({ type: "fail", error: "No captions were generated" });
+ return;
+ }
+
+ const nextWarnings = [...result.warnings];
+ if (result.skippedCueCount > 0) {
+ nextWarnings.unshift(
+ `Imported ${result.captions.length} subtitle cue(s) and skipped ${result.skippedCueCount} malformed cue(s).`,
+ );
+ }
+
+ dispatch({ type: "succeed", warnings: nextWarnings });
+ } catch (error) {
+ console.error("Subtitle import failed:", error);
+ dispatch({
+ type: "fail",
+ error: error instanceof Error ? error.message : "An unexpected error occurred",
+ });
+ }
+ };
+
+ 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") {
+ setSelectedLanguage("auto");
+ return;
+ }
+
+ const matchedLanguage = TRANSCRIPTION_LANGUAGES.find(
+ (language) => language.code === value,
+ );
+ if (!matchedLanguage) return;
+ setSelectedLanguage(matchedLanguage.code);
+ };
+
+ const error = processing.status === "idle" ? processing.error : null;
+ const warnings = processing.status === "idle" ? processing.warnings : [];
+
+ return (
+ <PanelView
+ title="Captions"
+ contentClassName="px-0 flex flex-col h-full"
+ actions={
+ <Button
+ type="button"
+ variant="outline"
+ size="sm"
+ onClick={handleImportClick}
+ disabled={isProcessing}
+ className="items-center justify-center gap-1.5"
+ >
+ <HugeiconsIcon icon={CloudUploadIcon} />
+ Import
+ </Button>
+ }
+ ref={containerRef}
+ >
+ <input
+ ref={fileInputRef}
+ type="file"
+ accept=".srt,.ass"
+ 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
+ value={selectedLanguage}
+ onValueChange={(value) => handleLanguageChange({ value })}
+ >
+ <SelectTrigger>
+ <SelectValue placeholder="Select a language" />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="auto">Auto detect</SelectItem>
+ {TRANSCRIPTION_LANGUAGES.map((language) => (
+ <SelectItem key={language.code} value={language.code}>
+ {language.name}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </SectionField>
+ </SectionFields>
+
+ <Button
+ type="button"
+ className="mt-auto w-full"
+ onClick={handleGenerateTranscript}
+ disabled={isProcessing}
+ >
+ {isProcessing && <Spinner className="mr-1" />}
+ {isProcessing ? processing.step : "Generate transcript"}
+ </Button>
+ {error && (
+ <div className="bg-destructive/10 border-destructive/20 rounded-md border p-3">
+ <p className="text-destructive text-sm">{error}</p>
+ </div>
+ )}
+ {warnings.length > 0 && (
+ <div className="rounded-md border border-amber-500/20 bg-amber-500/10 p-3">
+ <ul className="space-y-1 text-sm text-amber-700">
+ {warnings.map((warning) => (
+ <li key={warning}>{warning}</li>
+ ))}
+ </ul>
+ </div>
+ )}
+ </SectionContent>
+ </Section>
+ </PanelView>
+ );
+}