Add export caption sidecar option

This commit is contained in:
webadderall
2026-06-09 11:13:00 +10:00
parent 489e3ca268
commit 7b6608e25e
8 changed files with 309 additions and 10 deletions
@@ -29,6 +29,9 @@ interface ExportSettingsMenuProps {
experimentalNvidiaCudaExport?: boolean;
onExperimentalNvidiaCudaExportChange?: (enabled: boolean) => void;
nvidiaCudaExportAvailable?: boolean;
showCaptionSidecarOption?: boolean;
includeCaptionSidecar?: boolean;
onIncludeCaptionSidecarChange?: (enabled: boolean) => void;
mp4OutputDimensions?: Record<ExportQuality, { width: number; height: number }>;
gifFrameRate: GifFrameRate;
onGifFrameRateChange?: (rate: GifFrameRate) => void;
@@ -55,6 +58,9 @@ export function ExportSettingsMenu({
experimentalNvidiaCudaExport = false,
onExperimentalNvidiaCudaExportChange,
nvidiaCudaExportAvailable = false,
showCaptionSidecarOption = false,
includeCaptionSidecar = false,
onIncludeCaptionSidecarChange,
mp4OutputDimensions,
gifFrameRate,
onGifFrameRateChange,
@@ -365,6 +371,30 @@ export function ExportSettingsMenu({
/>
</div>
) : null}
{showCaptionSidecarOption ? (
<div className="mb-3 flex min-h-12 items-center justify-between gap-3 rounded-lg border border-foreground/10 bg-foreground/5 px-3 py-2">
<div className="min-w-0">
<p className="text-[11px] font-semibold text-foreground">
{tSettings("export.captionSidecar.title", "Export captions file")}
</p>
<p className="mt-0.5 truncate text-[10px] text-muted-foreground/75">
{tSettings(
"export.captionSidecar.hint",
"Save .srt and .vtt files next to your exported video.",
)}
</p>
</div>
<Switch
checked={includeCaptionSidecar}
onCheckedChange={onIncludeCaptionSidecarChange}
aria-label={tSettings(
"export.captionSidecar.toggle",
"Export captions sidecar files",
)}
className="shrink-0 scale-75 data-[state=checked]:bg-[#2563EB]"
/>
</div>
) : null}
</LayoutGroup>
) : (
<div className="mb-3 space-y-2">
+74 -4
View File
@@ -226,6 +226,14 @@ type PendingExportSave = {
fileName: string;
arrayBuffer?: ArrayBuffer;
tempFilePath?: string;
captionSidecar?: {
format: "srt" | "vtt" | "both";
cues: Array<{
startMs: number;
endMs: number;
text: string;
}>;
};
};
type CancelableExporter = {
@@ -521,6 +529,7 @@ export default function VideoEditor() {
const [autoCaptionSettings, setAutoCaptionSettings] = useState<AutoCaptionSettings>(
DEFAULT_AUTO_CAPTION_SETTINGS,
);
const [includeCaptionSidecar, setIncludeCaptionSidecar] = useState(true);
const [whisperExecutablePath, setWhisperExecutablePath] = useState<string | null>(
initialEditorPreferences.whisperExecutablePath,
);
@@ -596,6 +605,32 @@ export default function VideoEditor() {
const [gifSizePreset, setGifSizePreset] = useState<GifSizePreset>(
initialEditorPreferences.gifSizePreset,
);
const hasCaptionsForSidecar = autoCaptionSettings.enabled && autoCaptions.length > 0;
const captionSidecarCues = useMemo(
() =>
autoCaptions
.filter(
(cue) =>
Number.isFinite(cue.startMs) &&
Number.isFinite(cue.endMs) &&
cue.endMs > cue.startMs &&
typeof cue.text === "string" &&
cue.text.trim().length > 0,
)
.map((cue) => ({
startMs: cue.startMs,
endMs: cue.endMs,
text: cue.text,
})),
[autoCaptions],
);
const captionSidecarPayload =
hasCaptionsForSidecar && captionSidecarCues.length > 0 && includeCaptionSidecar
? {
format: "both" as const,
cues: captionSidecarCues,
}
: undefined;
const [exportedFilePath, setExportedFilePath] = useState<string | undefined>(undefined);
const [hasPendingExportSave, setHasPendingExportSave] = useState(false);
const [lastSavedSnapshot, setLastSavedSnapshot] = useState<EditorProjectData | null>(null);
@@ -1283,7 +1318,12 @@ export default function VideoEditor() {
}, []);
const saveBlobExport = useCallback(
async (blob: Blob, fileName: string, outputPath: string | null = null) => {
async (
blob: Blob,
fileName: string,
outputPath: string | null = null,
captionSidecar?: PendingExportSave["captionSidecar"],
) => {
const extension = fileName.split(".").pop()?.toLowerCase() || "bin";
const hasExportStreamApi =
typeof window !== "undefined" &&
@@ -1300,10 +1340,12 @@ export default function VideoEditor() {
tempPath: tempFilePath,
fileName,
outputPath,
captionSidecar,
}),
pendingSave: {
fileName,
tempFilePath,
captionSidecar,
} satisfies PendingExportSave,
};
}
@@ -1342,11 +1384,20 @@ export default function VideoEditor() {
const arrayBuffer = await blob.arrayBuffer();
return {
saveResult: outputPath
? await window.electronAPI.writeExportedVideoToPath(arrayBuffer, outputPath)
: await window.electronAPI.saveExportedVideo(arrayBuffer, fileName),
? await window.electronAPI.writeExportedVideoToPath(
arrayBuffer,
outputPath,
captionSidecar,
)
: await window.electronAPI.saveExportedVideo(
arrayBuffer,
fileName,
captionSidecar,
),
pendingSave: {
fileName,
arrayBuffer,
captionSidecar,
} satisfies PendingExportSave,
};
},
@@ -4458,6 +4509,10 @@ export default function VideoEditor() {
if (result.success && (result.blob || result.tempFilePath)) {
const timestamp = Date.now();
const fileName = `export-${timestamp}.mp4`;
const sidecarForThisExport =
settings.includeCaptionSidecar && captionSidecarPayload
? captionSidecarPayload
: undefined;
markExportAsSaving();
let saveResult: {
@@ -4479,8 +4534,13 @@ export default function VideoEditor() {
smokeExportConfig.enabled && smokeExportConfig.outputPath
? smokeExportConfig.outputPath
: null,
captionSidecar: sidecarForThisExport,
});
pendingOnCancel = { fileName, tempFilePath: result.tempFilePath };
pendingOnCancel = {
fileName,
tempFilePath: result.tempFilePath,
captionSidecar: sidecarForThisExport,
};
} else if (result.blob) {
// Legacy fallback: some export paths still surface a Blob, but in
// Electron we stream it into a temp file first so save/finalize
@@ -4489,6 +4549,7 @@ export default function VideoEditor() {
result.blob,
fileName,
smokeExportConfig.enabled ? smokeExportConfig.outputPath : null,
sidecarForThisExport,
);
saveResult = blobSave.saveResult;
pendingOnCancel = blobSave.pendingSave;
@@ -4701,6 +4762,7 @@ export default function VideoEditor() {
annotationRegions,
autoCaptions,
autoCaptionSettings,
captionSidecarPayload,
isPlaying,
exportQuality,
effectiveZoomRegions,
@@ -4858,6 +4920,7 @@ export default function VideoEditor() {
sourceWidth,
sourceHeight,
exportFormat,
includeCaptionSidecar: hasCaptionsForSidecar && includeCaptionSidecar,
exportEncodingMode,
exportQuality,
mp4FrameRate,
@@ -4881,6 +4944,8 @@ export default function VideoEditor() {
gifFrameRate,
gifLoop,
gifSizePreset,
hasCaptionsForSidecar,
includeCaptionSidecar,
exportBackendPreference,
exportPipelineModel,
handleExport,
@@ -4925,11 +4990,13 @@ export default function VideoEditor() {
tempPath: pendingSave.tempFilePath,
fileName: pendingSave.fileName,
outputPath: null,
captionSidecar: pendingSave.captionSidecar,
});
} else if (pendingSave.arrayBuffer) {
saveResult = await window.electronAPI.saveExportedVideo(
pendingSave.arrayBuffer,
pendingSave.fileName,
pendingSave.captionSidecar,
);
} else {
saveResult = { success: false, message: "No pending export to save" };
@@ -5669,6 +5736,9 @@ export default function VideoEditor() {
onGifLoopChange={setGifLoop}
gifSizePreset={gifSizePreset}
onGifSizePresetChange={setGifSizePreset}
showCaptionSidecarOption={hasCaptionsForSidecar && exportFormat === "mp4"}
includeCaptionSidecar={includeCaptionSidecar}
onIncludeCaptionSidecarChange={setIncludeCaptionSidecar}
mp4OutputDimensions={mp4OutputDimensions}
gifOutputDimensions={gifOutputDimensions}
onExport={handleStartExportFromDropdown}
@@ -5,6 +5,7 @@ const baseOptions = {
sourceWidth: 1920,
sourceHeight: 1080,
exportFormat: "mp4" as const,
includeCaptionSidecar: true,
exportEncodingMode: "balanced" as const,
exportQuality: "good" as const,
mp4FrameRate: 30 as const,
@@ -19,6 +20,7 @@ describe("resolveExportStartSettings", () => {
it("preserves MP4 dropdown settings", () => {
expect(resolveExportStartSettings(baseOptions)).toEqual({
format: "mp4",
includeCaptionSidecar: true,
encodingMode: "balanced",
mp4FrameRate: 30,
backendPreference: "auto",
@@ -41,6 +43,7 @@ describe("resolveExportStartSettings", () => {
}),
).toEqual({
format: "gif",
includeCaptionSidecar: false,
encodingMode: undefined,
mp4FrameRate: undefined,
backendPreference: undefined,
@@ -16,6 +16,7 @@ export function resolveExportStartSettings({
sourceWidth,
sourceHeight,
exportFormat,
includeCaptionSidecar,
exportEncodingMode,
exportQuality,
mp4FrameRate,
@@ -28,6 +29,7 @@ export function resolveExportStartSettings({
sourceWidth: number;
sourceHeight: number;
exportFormat: ExportFormat;
includeCaptionSidecar: boolean;
exportEncodingMode: ExportEncodingMode;
exportQuality: ExportQuality;
mp4FrameRate: ExportMp4FrameRate;
@@ -44,6 +46,7 @@ export function resolveExportStartSettings({
return {
format: exportFormat,
includeCaptionSidecar: exportFormat === "mp4" ? includeCaptionSidecar : false,
encodingMode: exportFormat === "mp4" ? exportEncodingMode : undefined,
mp4FrameRate: exportFormat === "mp4" ? mp4FrameRate : undefined,
backendPreference: exportFormat === "mp4" ? exportBackendPreference : undefined,
+1
View File
@@ -193,6 +193,7 @@ export interface GifExportConfig {
export interface ExportSettings {
format: ExportFormat;
includeCaptionSidecar?: boolean;
// MP4 settings
quality?: ExportQuality;
encodingMode?: ExportEncodingMode;