refactor: review fixes

This commit is contained in:
Maze Winther 2026-04-14 02:18:05 +02:00
parent fa82d6154e
commit 073b71399e
5 changed files with 110 additions and 69 deletions

View File

@ -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<CaptionsView>("generate");
const [selectedLanguage, setSelectedLanguage] =
useState<TranscriptionLanguage>("auto");
const [isProcessing, setIsProcessing] = useState(false);
const [processingStep, setProcessingStep] = useState("");
const [error, setError] = useState<string | null>(null);
const [warnings, setWarnings] = useState<string[]>([]);
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") {
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 (
<PanelView
contentClassName="px-0 flex flex-col h-full"
@ -236,7 +263,7 @@ export function Captions() {
disabled={isProcessing}
>
{isProcessing && <Spinner className="mr-1" />}
{isProcessing ? processingStep : "Generate transcript"}
{isProcessing ? processing.step : "Generate transcript"}
</Button>
{error && (
<div className="bg-destructive/10 border-destructive/20 rounded-md border p-3">
@ -264,7 +291,7 @@ export function Captions() {
disabled={isProcessing}
>
{isProcessing && <Spinner className="mr-1" />}
{isProcessing ? processingStep : "Import subtitles"}
{isProcessing ? processing.step : "Import subtitles"}
</Button>
{error && (
<div className="bg-destructive/10 border-destructive/20 rounded-md border p-3">

View File

@ -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" } : {}),

View File

@ -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<SubtitleStyleOverrides["placement"]>;
} {
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",

View File

@ -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;

View File

@ -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<TextBackground, "enabled" | "color"> &