Update Captions Panel UI

Split captions panel into Generate and Import tabs as they are two different approaches for adding captions.
This commit is contained in:
kcfancher 2026-04-02 07:44:40 -04:00
parent 5ca08166dd
commit 92fd8bac6f
1 changed files with 146 additions and 120 deletions

View File

@ -1,19 +1,20 @@
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 { 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 { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { 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 {
DEFAULT_TRANSCRIPTION_SAMPLE_RATE,
TRANSCRIPTION_LANGUAGES,
} from "@/constants/transcription-constants";
import type {
CaptionChunk,
TranscriptionLanguage,
@ -32,7 +33,10 @@ import {
SectionFields,
} from "@/components/section";
type CaptionsView = "generate" | "import";
export function Captions() {
const [view, setView] = useState<CaptionsView>("generate");
const [selectedLanguage, setSelectedLanguage] =
useState<TranscriptionLanguage>("auto");
const [isProcessing, setIsProcessing] = useState(false);
@ -42,59 +46,55 @@ export function Captions() {
const containerRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(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 () => {
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);
setWarning(null);
setProcessingStep("Extracting audio...");
const audioBlob = await extractTimelineAudio({
tracks: editor.timeline.getTracks(),
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,
});
const audioBlob = await extractTimelineAudio({
tracks: editor.timeline.getTracks(),
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 });
insertCaptionChunks({ captions: captionChunks });
} catch (error) {
console.error("Transcription failed:", error);
setError(
error instanceof Error ? error.message : "An unexpected error occurred",
);
} finally {
setIsProcessing(false);
setProcessingStep("");
console.error("Transcription failed:", error);
setError(
error instanceof Error ? error.message : "An unexpected error occurred",
);
} finally {
setIsProcessing(false);
setProcessingStep("");
}
};
const insertCaptionChunks = ({
captions,
}: {
captions: CaptionChunk[];
}) => {
const insertCaptionChunks = ({ captions }: { captions: CaptionChunk[] }) => {
const trackId = insertCaptionChunksAsTextTrack({
editor,
captions,
@ -109,11 +109,7 @@ export function Captions() {
fileInputRef.current?.click();
};
const handleImportFile = async ({
file,
}: {
file: File;
}) => {
const handleImportFile = async ({ file }: { file: File }) => {
try {
setIsProcessing(true);
setError(null);
@ -162,24 +158,34 @@ export function Captions() {
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 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"
actions={
<Tabs
value={view}
onValueChange={(value) => setView(value as CaptionsView)}
>
<TabsList>
<TabsTrigger value="generate">Generate</TabsTrigger>
<TabsTrigger value="import">Import</TabsTrigger>
</TabsList>
</Tabs>
}
ref={containerRef}
>
<input
@ -189,63 +195,83 @@ export function Captions() {
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>
{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">
{view === "generate" && (
<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
className="flex-1"
className="mt-auto w-full"
onClick={handleGenerateTranscript}
disabled={isProcessing}
>
{isProcessing && <Spinner className="mr-1" />}
{isProcessing ? processingStep : "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>
)}
</SectionContent>
</Section>
)}
{view === "import" && (
<Section
showTopBorder={false}
showBottomBorder={false}
className="flex-1"
>
<SectionContent className="flex flex-col gap-4 h-full pt-1">
<p className="text-muted-foreground text-sm">
Import an existing <code>.srt</code> subtitle file into the
timeline.
</p>
<Button
variant="outline"
className="flex-1"
className="mt-auto w-full"
onClick={handleImportClick}
disabled={isProcessing}
>
Import .srt
{isProcessing && <Spinner className="mr-1" />}
{isProcessing ? processingStep : "Import .srt"}
</Button>
</div>
</SectionContent>
</Section>
{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>
)}
</PanelView>
);
}
);
}