summaryrefslogtreecommitdiff
path: root/apps/web/src/components/editor/panels/assets/views/captions.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src/components/editor/panels/assets/views/captions.tsx')
-rw-r--r--apps/web/src/components/editor/panels/assets/views/captions.tsx337
1 files changed, 0 insertions, 337 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
deleted file mode 100644
index 040df749..00000000
--- a/apps/web/src/components/editor/panels/assets/views/captions.tsx
+++ /dev/null
@@ -1,337 +0,0 @@
-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 { TRANSCRIPTION_DIAGNOSTICS_SCOPE } from "@/lib/transcription/diagnostics";
-import { DEFAULT_TRANSCRIPTION_SAMPLE_RATE } from "@/lib/transcription/audio";
-import { TRANSCRIPTION_LANGUAGES } from "@/lib/transcription/supported-languages";
-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 { AlertCircleIcon, CloudUploadIcon } from "@hugeicons/core-free-icons";
-import { HugeiconsIcon } from "@hugeicons/react";
-import {
- Tooltip,
- TooltipContent,
- TooltipProvider,
- TooltipTrigger,
-} from "@/components/ui/tooltip";
-import type { DiagnosticSeverity } from "@/lib/diagnostics/types";
-
-const DIAGNOSTIC_BUTTON_VARIANT: Record<
- DiagnosticSeverity,
- "caution" | "destructive-foreground"
-> = {
- caution: "caution",
- error: "destructive-foreground",
-};
-
-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 activeDiagnostics = useEditor((e) =>
- e.diagnostics.getActive({ scope: TRANSCRIPTION_DIAGNOSTICS_SCOPE }),
- );
-
- 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={
- <TooltipProvider>
- <div className="flex items-center gap-1.5">
- {!isProcessing &&
- activeDiagnostics.map((diagnostic) => (
- <Tooltip key={diagnostic.id}>
- <TooltipTrigger asChild>
- <Button
- variant={DIAGNOSTIC_BUTTON_VARIANT[diagnostic.severity]}
- size="icon"
- aria-label={diagnostic.message}
- >
- <HugeiconsIcon icon={AlertCircleIcon} size={16} />
- </Button>
- </TooltipTrigger>
- <TooltipContent>{diagnostic.message}</TooltipContent>
- </Tooltip>
- ))}
- <Button
- type="button"
- variant="outline"
- size="sm"
- onClick={handleImportClick}
- disabled={isProcessing}
- className="items-center justify-center gap-1.5"
- >
- <HugeiconsIcon icon={CloudUploadIcon} />
- Import
- </Button>
- </div>
- </TooltipProvider>
- }
- 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 || activeDiagnostics.length > 0}
- >
- {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>
- );
-}