refactor: review fixes
This commit is contained in:
parent
fa82d6154e
commit
073b71399e
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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" } : {}),
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"> &
|
||||
|
|
|
|||
Loading…
Reference in New Issue