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 6925c048..0342d1d6 100644 --- a/apps/web/src/components/editor/panels/assets/views/captions.tsx +++ b/apps/web/src/components/editor/panels/assets/views/captions.tsx @@ -8,7 +8,7 @@ import { SelectValue, } from "@/components/ui/select"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { useRef, useState } from "react"; +import { useReducer, useRef, useState } from "react"; import { extractTimelineAudio } from "@/lib/media/mediabunny"; import { useEditor } from "@/hooks/use-editor"; import { @@ -35,40 +35,72 @@ import { type CaptionsView = "generate" | "import"; +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 [view, setView] = useState("generate"); const [selectedLanguage, setSelectedLanguage] = useState("auto"); - const [isProcessing, setIsProcessing] = useState(false); - const [processingStep, setProcessingStep] = useState(""); - const [error, setError] = useState(null); - const [warnings, setWarnings] = useState([]); + const [processing, dispatch] = useReducer(processingReducer, IDLE_STATE); const containerRef = useRef(null); const fileInputRef = useRef(null); const editor = useEditor(); + const isProcessing = processing.status === "processing"; + const handleProgress = (progress: TranscriptionProgress) => { if (progress.status === "loading-model") { - setProcessingStep(`Loading model ${Math.round(progress.progress)}%`); + dispatch({ + type: "update_step", + step: `Loading model ${Math.round(progress.progress)}%`, + }); } else if (progress.status === "transcribing") { - setProcessingStep("Transcribing..."); + dispatch({ type: "update_step", step: "Transcribing..." }); } }; - const handleGenerateTranscript = async () => { - try { - setIsProcessing(true); - setError(null); - setWarnings([]); - setProcessingStep("Extracting audio..."); + 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(), }); - setProcessingStep("Preparing audio..."); + dispatch({ type: "update_step", step: "Preparing audio..." }); const { samples } = await decodeAudioToFloat32({ audioBlob, sampleRate: DEFAULT_TRANSCRIPTION_SAMPLE_RATE, @@ -80,28 +112,21 @@ export function Captions() { onProgress: handleProgress, }); - setProcessingStep("Generating captions..."); + dispatch({ type: "update_step", step: "Generating captions..." }); const captionChunks = buildCaptionChunks({ segments: result.segments }); - insertCaptionChunks({ captions: captionChunks }); + + if (!insertCaptions({ captions: captionChunks })) { + dispatch({ type: "fail", error: "No captions were generated" }); + return; + } + + dispatch({ type: "succeed", warnings: [] }); } catch (error) { 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 trackId = insertCaptionChunksAsTextTrack({ - editor, - captions, - }); - - if (!trackId) { - throw new Error("No captions were generated"); + dispatch({ + type: "fail", + error: error instanceof Error ? error.message : "An unexpected error occurred", + }); } }; @@ -110,12 +135,8 @@ export function Captions() { }; const handleImportFile = async ({ file }: { file: File }) => { + dispatch({ type: "start", step: "Reading subtitle file..." }); try { - setIsProcessing(true); - setError(null); - setWarnings([]); - setProcessingStep("Reading subtitle file..."); - const input = await file.text(); const result = parseSubtitleFile({ fileName: file.name, @@ -123,13 +144,19 @@ export function Captions() { }); if (result.captions.length === 0) { - throw new Error( - "No valid subtitle cues were found in the subtitle file", - ); + dispatch({ + type: "fail", + error: "No valid subtitle cues were found in the subtitle file", + }); + return; } - setProcessingStep("Importing subtitles..."); - insertCaptionChunks({ captions: result.captions }); + 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) { @@ -137,17 +164,14 @@ export function Captions() { `Imported ${result.captions.length} subtitle cue(s) and skipped ${result.skippedCueCount} malformed cue(s).`, ); } - if (nextWarnings.length > 0) { - setWarnings(nextWarnings); - } + + dispatch({ type: "succeed", warnings: nextWarnings }); } catch (error) { console.error("Subtitle import failed:", error); - setError( - error instanceof Error ? error.message : "An unexpected error occurred", - ); - } finally { - setIsProcessing(false); - setProcessingStep(""); + dispatch({ + type: "fail", + error: error instanceof Error ? error.message : "An unexpected error occurred", + }); } }; @@ -178,6 +202,9 @@ export function Captions() { setSelectedLanguage(matchedLanguage.code); }; + const error = processing.status === "idle" ? processing.error : null; + const warnings = processing.status === "idle" ? processing.warnings : []; + return ( {isProcessing && } - {isProcessing ? processingStep : "Generate transcript"} + {isProcessing ? processing.step : "Generate transcript"} {error && (
@@ -264,7 +291,7 @@ export function Captions() { disabled={isProcessing} > {isProcessing && } - {isProcessing ? processingStep : "Import subtitles"} + {isProcessing ? processing.step : "Import subtitles"} {error && (
diff --git a/apps/web/src/lib/subtitles/ass.ts b/apps/web/src/lib/subtitles/ass.ts index 8ecbfe6d..030c5b36 100644 --- a/apps/web/src/lib/subtitles/ass.ts +++ b/apps/web/src/lib/subtitles/ass.ts @@ -1,4 +1,3 @@ -import { FONT_SIZE_SCALE_REFERENCE } from "@/constants/text-constants"; import type { ParseSubtitleResult, SubtitleCue, @@ -368,7 +367,6 @@ function mapAssStyleToSubtitleStyle({ hasUnsupportedFeatures: boolean; } { const fontSize = parseFloat(style.fontsize ?? ""); - const fontSizeRatio = FONT_SIZE_SCALE_REFERENCE / scriptInfo.playResY; const primaryColor = parseAssColor({ input: style.primarycolour }); const backColor = parseAssColor({ input: style.backcolour }); const bold = parseAssBoolean({ input: style.bold }); @@ -381,9 +379,11 @@ function mapAssStyleToSubtitleStyle({ const marginLeft = parseFloat(style.marginl ?? ""); const marginRight = parseFloat(style.marginr ?? ""); const marginVertical = parseFloat(style.marginv ?? ""); - const mappedFontSize = Number.isFinite(fontSize) - ? Math.round(fontSize * fontSizeRatio * 1000) / 1000 - : Number.NaN; + // Store as a ratio of playResY so the builder can convert to app units + // without the parser needing to know the app's coordinate system. + const fontSizeRatioOfPlayHeight = Number.isFinite(fontSize) + ? Math.round((fontSize / scriptInfo.playResY) * 1000) / 1000 + : null; const mappedAlignment = ALIGNMENT_MAP[Math.round(alignment)] ?? ALIGNMENT_MAP[2]; @@ -408,8 +408,8 @@ function mapAssStyleToSubtitleStyle({ const styleOverrides: SubtitleStyleOverrides = { ...(style.fontname ? { fontFamily: style.fontname.trim() } : {}), - ...(Number.isFinite(mappedFontSize) && mappedFontSize > 0 - ? { fontSize: mappedFontSize } + ...(fontSizeRatioOfPlayHeight !== null && fontSizeRatioOfPlayHeight > 0 + ? { fontSizeRatioOfPlayHeight } : {}), ...(primaryColor?.cssColor ? { color: primaryColor.cssColor } : {}), ...(bold !== null ? { fontWeight: bold ? "bold" : "normal" } : {}), diff --git a/apps/web/src/lib/subtitles/build-subtitle-text-element.ts b/apps/web/src/lib/subtitles/build-subtitle-text-element.ts index a2c6a923..476b65d2 100644 --- a/apps/web/src/lib/subtitles/build-subtitle-text-element.ts +++ b/apps/web/src/lib/subtitles/build-subtitle-text-element.ts @@ -11,6 +11,7 @@ import type { SubtitleCue, SubtitleStyleOverrides } from "./types"; const SUBTITLE_MAX_WIDTH_RATIO = 0.8; const SUBTITLE_BOTTOM_MARGIN_RATIO = 0.05; const SUBTITLE_FONT_SIZE = 5; +const MEASUREMENT_CANVAS_SIZE = 4096; function quoteFontFamily({ fontFamily }: { fontFamily: string }): string { return `"${fontFamily.replace(/"/g, '\\"')}"`; @@ -18,8 +19,8 @@ function quoteFontFamily({ fontFamily }: { fontFamily: string }): string { function createMeasurementContext(): CanvasRenderingContext2D | null { const canvas = document.createElement("canvas"); - canvas.width = 4096; - canvas.height = 4096; + canvas.width = MEASUREMENT_CANVAS_SIZE; + canvas.height = MEASUREMENT_CANVAS_SIZE; return canvas.getContext("2d"); } @@ -100,7 +101,6 @@ function measureWrappedTextBlock({ const block = measureTextBlock({ lineMetrics, lineHeightPx, - fallbackFontSize: scaledFontSize, }); const visualRect = getTextVisualRect({ textAlign, @@ -132,9 +132,14 @@ function resolveSubtitleStyle({ background: CreateTextElement["background"]; placement: NonNullable; } { + const fontSize = + style?.fontSizeRatioOfPlayHeight != null + ? style.fontSizeRatioOfPlayHeight * FONT_SIZE_SCALE_REFERENCE + : (style?.fontSize ?? SUBTITLE_FONT_SIZE); + return { fontFamily: style?.fontFamily ?? DEFAULTS.text.element.fontFamily, - fontSize: style?.fontSize ?? SUBTITLE_FONT_SIZE, + fontSize, color: style?.color ?? DEFAULTS.text.element.color, textAlign: style?.textAlign ?? "center", fontWeight: style?.fontWeight ?? "bold", diff --git a/apps/web/src/lib/subtitles/insert.ts b/apps/web/src/lib/subtitles/insert.ts index c6a8d7fc..1bb04fc8 100644 --- a/apps/web/src/lib/subtitles/insert.ts +++ b/apps/web/src/lib/subtitles/insert.ts @@ -1,5 +1,4 @@ import type { EditorCore } from "@/core"; -import type { Command } from "@/lib/commands"; import { AddTrackCommand, BatchCommand, @@ -33,10 +32,8 @@ export function insertCaptionChunksAsTextTrack({ }), }), ); - const commands = [addTrackCommand, ...insertCommands] as unknown as Command[]; - editor.command.execute({ - command: new BatchCommand(commands), + command: new BatchCommand([addTrackCommand, ...insertCommands]), }); return trackId; diff --git a/apps/web/src/lib/subtitles/types.ts b/apps/web/src/lib/subtitles/types.ts index 42fa048a..86f6525d 100644 --- a/apps/web/src/lib/subtitles/types.ts +++ b/apps/web/src/lib/subtitles/types.ts @@ -9,7 +9,19 @@ export interface SubtitlePlacementStyle { } export interface SubtitleStyleOverrides { + /** + * Font size in app units (same coordinate space as TextElement.fontSize). + * Use fontSizeRatioOfPlayHeight when the source coordinate space is unknown + * (e.g. ASS files, where font size is relative to the script's play resolution). + */ fontSize?: number; + /** + * Font size expressed as a fraction of the reference canvas height. + * Set by the ASS parser so the builder can convert to app units without + * the parser needing to know about the app's coordinate system. + * Takes precedence over fontSize when both are present. + */ + fontSizeRatioOfPlayHeight?: number; fontFamily?: string; color?: string; background?: Pick &