diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index 72acdff0..86080a29 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -62,14 +62,12 @@ import { type ExportPipelineModel, type ExportProgress, type ExportQuality, - type ExportRenderBackend, type ExportSettings, FrameRenderer, GIF_SIZE_PRESETS, GifExporter, type GifFrameRate, type GifSizePreset, - isValidMp4FrameRate, ModernVideoExporter, probeSupportedMp4Dimensions, type SupportedMp4Dimensions, @@ -89,10 +87,7 @@ import { getAspectRatioValue, } from "@/utils/aspectRatioUtils"; import { ExtensionIcon } from "./ExtensionIcon"; -import { - calculateMp4ExportDimensions, - calculateMp4SourceDimensions, -} from "./exportDimensions"; +import { calculateMp4ExportDimensions, calculateMp4SourceDimensions } from "./exportDimensions"; const PhCursorFill = (props: { className?: string; weight?: "fill" | "regular" }) => ( @@ -113,11 +108,21 @@ const PhSettings = (props: { className?: string; weight?: "fill" | "regular" }) ); +import type { SourceAudioTrackSettings } from "@/components/video-editor/audio/audioTypes"; import { extensionHost } from "@/lib/extensions"; +import { useVideoEditorAudio } from "./audio/useVideoEditorAudio"; import { resolveAutoCaptionSourcePath } from "./autoCaptionSource"; import { CropControl } from "./CropControl"; import { ExportSettingsMenu } from "./ExportSettingsMenu"; import ExtensionManager from "./ExtensionManager"; +import { + createEditorHistoryStack, + type EditorHistorySnapshot, + recordEditorHistorySnapshot, + redoEditorHistoryStack, + resetEditorHistoryStack, + undoEditorHistoryStack, +} from "./editorHistory"; import { type EditorPreset, type EditorPresetSnapshot, @@ -127,15 +132,8 @@ import { saveEditorPresets, serializeEditorPresetSnapshot, } from "./editorPreferences"; -import { - createEditorHistoryStack, - type EditorHistorySnapshot, - recordEditorHistorySnapshot, - redoEditorHistoryStack, - resetEditorHistoryStack, - undoEditorHistoryStack, -} from "./editorHistory"; import ProjectBrowserDialog, { type ProjectLibraryEntry } from "./ProjectBrowserDialog"; +import { hasUnsavedProjectChanges } from "./projectDirtyState"; import { createProjectData, deriveNextId, @@ -147,9 +145,8 @@ import { toFileUrl, validateProjectData, } from "./projectPersistence"; -import { hasUnsavedProjectChanges } from "./projectDirtyState"; import { SettingsPanel } from "./SettingsPanel"; -import { useVideoEditorAudio } from "./audio/useVideoEditorAudio"; +import { getDevOpenRecordingConfig, getSmokeExportConfig } from "./smokeExportConfig"; import { APP_HEADER_ICON_BUTTON_CLASS, DiscordLinkButton, @@ -159,7 +156,6 @@ import { } from "./TutorialHelp"; import TimelineEditor, { type TimelineEditorHandle } from "./timeline/TimelineEditor"; import { normalizeCursorTelemetry } from "./timeline/zoomSuggestionUtils"; -import type { SourceAudioTrackSettings } from "@/components/video-editor/audio/audioTypes"; import { type AnnotationRegion, type AudioRegion, @@ -229,27 +225,6 @@ type CancelableExporter = { cancel(): void; }; -type SmokeExportConfig = { - enabled: boolean; - inputPath: string | null; - outputPath: string | null; - useNativeExport: boolean; - encodingMode?: ExportEncodingMode; - shadowIntensity?: number; - webcamInputPath?: string | null; - webcamShadow?: number; - webcamSize?: number; - pipelineModel?: ExportPipelineModel; - backendPreference?: ExportBackendPreference; - renderBackend?: ExportRenderBackend; - maxEncodeQueue?: number; - maxDecodeQueue?: number; - maxPendingFrames?: number; - projectPath?: string | null; - quality?: ExportQuality; - fps?: ExportMp4FrameRate; -}; - const EXPORT_BLOB_STREAM_CHUNK_BYTES = 16 * 1024 * 1024; async function streamExportBlobToTempFile(blob: Blob, extension: string): Promise { @@ -308,11 +283,6 @@ type SaveProjectOptions = { captureThumbnail?: boolean; }; -type DevOpenRecordingConfig = { - inputPath: string | null; - webcamInputPath: string | null; -}; - async function writeSmokeExportReport( outputPath: string | null, report: Record, @@ -362,108 +332,6 @@ function cloneStructured(value: T): T { return globalThis.structuredClone(value); } -function parseSmokeExportNumber(value: string | null): number | undefined { - if (value === null) { - return undefined; - } - - const parsed = Number.parseInt(value, 10); - return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined; -} - -function parseSmokeExportNonNegativeNumber(value: string | null): number | undefined { - if (value === null) { - return undefined; - } - - const parsed = Number.parseFloat(value); - return Number.isFinite(parsed) && parsed >= 0 ? parsed : undefined; -} - -function parseSmokeExportQuality(value: string | null): ExportQuality | undefined { - if (value === "medium" || value === "good" || value === "high" || value === "source") { - return value; - } - return undefined; -} - -function parseSmokeExportFps(value: string | null): ExportMp4FrameRate | undefined { - if (value === null) return undefined; - const parsed = Number.parseInt(value, 10); - return isValidMp4FrameRate(parsed) ? parsed : undefined; -} - -function parseSmokeRenderBackend(value: string | null): ExportRenderBackend | undefined { - return value === "webgl" || value === "webgpu" ? value : undefined; -} - -function getSmokeExportConfig(search: string): SmokeExportConfig { - const params = new URLSearchParams(search); - const enabled = params.get("smokeExport") === "1"; - - return { - enabled, - inputPath: enabled ? params.get("smokeInput") : null, - outputPath: enabled ? params.get("smokeOutput") : null, - useNativeExport: enabled ? params.get("smokeUseNativeExport") === "1" : false, - encodingMode: - enabled && params.get("smokeEncodingMode") === "fast" - ? "fast" - : enabled && params.get("smokeEncodingMode") === "balanced" - ? "balanced" - : enabled && params.get("smokeEncodingMode") === "quality" - ? "quality" - : undefined, - shadowIntensity: enabled - ? parseSmokeExportNonNegativeNumber(params.get("smokeShadowIntensity")) - : undefined, - webcamInputPath: enabled ? params.get("smokeWebcamInput") : null, - webcamShadow: enabled - ? parseSmokeExportNonNegativeNumber(params.get("smokeWebcamShadow")) - : undefined, - webcamSize: enabled - ? parseSmokeExportNonNegativeNumber(params.get("smokeWebcamSize")) - : undefined, - pipelineModel: - enabled && params.get("smokePipelineModel") === "modern" - ? "modern" - : enabled && params.get("smokePipelineModel") === "legacy" - ? "legacy" - : undefined, - backendPreference: - enabled && params.get("smokeBackendPreference") === "auto" - ? "auto" - : enabled && params.get("smokeBackendPreference") === "webcodecs" - ? "webcodecs" - : enabled && params.get("smokeBackendPreference") === "breeze" - ? "breeze" - : undefined, - renderBackend: enabled - ? parseSmokeRenderBackend(params.get("smokeRenderBackend")) - : undefined, - maxEncodeQueue: enabled - ? parseSmokeExportNumber(params.get("smokeMaxEncodeQueue")) - : undefined, - maxDecodeQueue: enabled - ? parseSmokeExportNumber(params.get("smokeMaxDecodeQueue")) - : undefined, - maxPendingFrames: enabled - ? parseSmokeExportNumber(params.get("smokeMaxPendingFrames")) - : undefined, - projectPath: enabled ? params.get("smokeProject") : null, - quality: enabled ? parseSmokeExportQuality(params.get("smokeQuality")) : undefined, - fps: enabled ? parseSmokeExportFps(params.get("smokeFps")) : undefined, - }; -} - -function getDevOpenRecordingConfig(search: string): DevOpenRecordingConfig { - const params = new URLSearchParams(search); - return { - inputPath: params.get("devOpenInput"), - webcamInputPath: params.get("devOpenWebcam"), - }; -} - function getErrorMessage(error: unknown): string { if (error instanceof Error) { return error.message; @@ -623,9 +491,8 @@ export default function VideoEditor() { const [sourceAudioTrackSettingsByClip, setSourceAudioTrackSettingsByClip] = useState< Record >({}); - const [defaultSourceAudioTrackSettings, setDefaultSourceAudioTrackSettings] = useState< - SourceAudioTrackSettings - >({}); + const [defaultSourceAudioTrackSettings, setDefaultSourceAudioTrackSettings] = + useState({}); const [hasClipSourceAudio, setHasClipSourceAudio] = useState(false); const [autoCaptions, setAutoCaptions] = useState([]); const [autoCaptionSettings, setAutoCaptionSettings] = useState( @@ -1915,43 +1782,39 @@ export default function VideoEditor() { selectedAudioId, ]); - const applyHistorySnapshot = useCallback( - (snapshot: EditorHistorySnapshot) => { - applyingHistoryRef.current = true; - const cloned = cloneStructured(snapshot); - setZoomRegions(cloned.zoomRegions); - setClipRegions(cloned.clipRegions); - setSpeedRegions(cloned.speedRegions); - setAnnotationRegions(cloned.annotationRegions); - setAudioRegions(cloned.audioRegions); - setAutoCaptions(cloned.autoCaptions); - setSelectedZoomId(cloned.selectedZoomId); - setSelectedClipId(cloned.selectedClipId); - setSelectedAnnotationId(cloned.selectedAnnotationId); - setSelectedAudioId(cloned.selectedAudioId); + const applyHistorySnapshot = useCallback((snapshot: EditorHistorySnapshot) => { + applyingHistoryRef.current = true; + const cloned = cloneStructured(snapshot); + setZoomRegions(cloned.zoomRegions); + setClipRegions(cloned.clipRegions); + setSpeedRegions(cloned.speedRegions); + setAnnotationRegions(cloned.annotationRegions); + setAudioRegions(cloned.audioRegions); + setAutoCaptions(cloned.autoCaptions); + setSelectedZoomId(cloned.selectedZoomId); + setSelectedClipId(cloned.selectedClipId); + setSelectedAnnotationId(cloned.selectedAnnotationId); + setSelectedAudioId(cloned.selectedAudioId); - nextZoomIdRef.current = deriveNextId( - "zoom", - cloned.zoomRegions.map((region) => region.id), - ); - nextClipIdRef.current = deriveNextId( - "clip", - cloned.clipRegions.map((region) => region.id), - ); - nextAnnotationIdRef.current = deriveNextId( - "annotation", - cloned.annotationRegions.map((region) => region.id), - ); - nextAudioIdRef.current = deriveNextId( - "audio", - cloned.audioRegions.map((region) => region.id), - ); - nextAnnotationZIndexRef.current = - cloned.annotationRegions.reduce((max, region) => Math.max(max, region.zIndex), 0) + - 1; - }, - [], - ); + nextZoomIdRef.current = deriveNextId( + "zoom", + cloned.zoomRegions.map((region) => region.id), + ); + nextClipIdRef.current = deriveNextId( + "clip", + cloned.clipRegions.map((region) => region.id), + ); + nextAnnotationIdRef.current = deriveNextId( + "annotation", + cloned.annotationRegions.map((region) => region.id), + ); + nextAudioIdRef.current = deriveNextId( + "audio", + cloned.audioRegions.map((region) => region.id), + ); + nextAnnotationZIndexRef.current = + cloned.annotationRegions.reduce((max, region) => Math.max(max, region.zIndex), 0) + 1; + }, []); const handleUndo = useCallback(() => { const previous = undoEditorHistoryStack(editorHistoryRef.current, buildHistorySnapshot()); @@ -2063,7 +1926,9 @@ export default function VideoEditor() { setSpeedRegions(normalizedEditor.speedRegions); setAnnotationRegions(normalizedEditor.annotationRegions); setAudioRegions(normalizedEditor.audioRegions); - setSourceAudioTrackSettingsByClip(normalizedEditor.sourceAudioTrackSettingsByClip ?? {}); + setSourceAudioTrackSettingsByClip( + normalizedEditor.sourceAudioTrackSettingsByClip ?? {}, + ); setDefaultSourceAudioTrackSettings( normalizedEditor.defaultSourceAudioTrackSettings ?? {}, ); @@ -2429,7 +2294,7 @@ export default function VideoEditor() { sessionVideoPath: session?.videoPath, videoSourcePath: videoSourcePath, match: session?.videoPath === videoSourcePath, - webcamPath: session?.webcamPath + webcamPath: session?.webcamPath, }); if (!session || session.videoPath !== videoSourcePath) { @@ -3324,21 +3189,27 @@ export default function VideoEditor() { setAutoSuggestZoomsTrigger(0); }, []); - const handleSeek = useCallback((time: number, options: { pause?: boolean } = {}) => { - const playback = videoPlaybackRef.current; - const video = playback?.video; - if (!video) return; + const handleSeek = useCallback( + (time: number, options: { pause?: boolean } = {}) => { + const playback = videoPlaybackRef.current; + const video = playback?.video; + if (!video) return; - if (options.pause && !video.paused) { - playback?.pause(); - } + if (options.pause && !video.paused) { + playback?.pause(); + } - video.currentTime = mapTimelineTimeToSourceTime(time * 1000) / 1000; - }, [mapTimelineTimeToSourceTime]); + video.currentTime = mapTimelineTimeToSourceTime(time * 1000) / 1000; + }, + [mapTimelineTimeToSourceTime], + ); - const handleTimelineSeek = useCallback((time: number) => { - handleSeek(time, { pause: true }); - }, [handleSeek]); + const handleTimelineSeek = useCallback( + (time: number) => { + handleSeek(time, { pause: true }); + }, + [handleSeek], + ); const handleSelectZoom = useCallback((id: string | null) => { setSelectedZoomId(id); @@ -3735,17 +3606,17 @@ export default function VideoEditor() { } }, []); - const handleAudioAdded = useCallback((span: Span, audioPath: string, trackIndex?: number) => { - const id = `audio-${nextAudioIdRef.current++}`; - const newRegion: AudioRegion = { - id, - startMs: Math.round(span.start), - endMs: Math.round(span.end), - audioPath, - volume: 1, - normalize: false, - trackIndex, - }; + const handleAudioAdded = useCallback((span: Span, audioPath: string, trackIndex?: number) => { + const id = `audio-${nextAudioIdRef.current++}`; + const newRegion: AudioRegion = { + id, + startMs: Math.round(span.start), + endMs: Math.round(span.end), + audioPath, + volume: 1, + normalize: false, + trackIndex, + }; setAudioRegions((prev) => [...prev, newRegion]); setSelectedAudioId(id); setSelectedZoomId(null); @@ -3795,29 +3666,29 @@ export default function VideoEditor() { [selectedAudioId], ); - const handleAudioDelete = useCallback( - (id: string) => { + const handleAudioDelete = useCallback( + (id: string) => { setAudioRegions((prev) => prev.filter((region) => region.id !== id)); if (selectedAudioId === id) { setSelectedAudioId(null); } }, - [selectedAudioId], - ); + [selectedAudioId], + ); - const handleAudioNormalizeChange = useCallback( - (normalize: boolean) => { - if (!selectedAudioId) { - return; - } - setAudioRegions((prev) => - prev.map((region) => - region.id === selectedAudioId ? { ...region, normalize } : region, - ), - ); - }, - [selectedAudioId], - ); + const handleAudioNormalizeChange = useCallback( + (normalize: boolean) => { + if (!selectedAudioId) { + return; + } + setAudioRegions((prev) => + prev.map((region) => + region.id === selectedAudioId ? { ...region, normalize } : region, + ), + ); + }, + [selectedAudioId], + ); const handleAnnotationAdded = useCallback((span: Span, trackIndex = 0) => { const id = `annotation-${nextAnnotationIdRef.current++}`; @@ -5715,19 +5586,20 @@ export default function VideoEditor() { selectedClipId={selectedClipId} selectedClipSpeed={ selectedClipId - ? clipRegions.find((c) => c.id === selectedClipId)?.speed ?? 1 + ? (clipRegions.find((c) => c.id === selectedClipId) + ?.speed ?? 1) : null } selectedClipMuted={ selectedClipId - ? clipRegions.find((c) => c.id === selectedClipId)?.muted ?? - false + ? (clipRegions.find((c) => c.id === selectedClipId) + ?.muted ?? false) : null } selectedClipShowSourceAudio={ selectedClipId - ? clipRegions.find((c) => c.id === selectedClipId) - ?.showSourceAudio ?? false + ? (clipRegions.find((c) => c.id === selectedClipId) + ?.showSourceAudio ?? false) : null } onClipSpeedChange={handleClipSpeedChange} @@ -5736,7 +5608,9 @@ export default function VideoEditor() { onClipDelete={handleClipDelete} hasClipSourceAudio={hasClipSourceAudio} sourceAudioTrackMeta={audio.sourceAudioTrackMeta} - sourceAudioTrackSettings={audio.selectedClipSourceAudioTrackSettings} + sourceAudioTrackSettings={ + audio.selectedClipSourceAudioTrackSettings + } onSourceAudioTrackVolumeChange={ audio.onSelectedClipSourceAudioTrackVolumeChange } @@ -5744,21 +5618,21 @@ export default function VideoEditor() { audio.onSelectedClipSourceAudioTrackNormalizeChange } selectedAudioId={selectedAudioId} - selectedAudioVolume={ - selectedAudioId - ? (audioRegions.find((r) => r.id === selectedAudioId) - ?.volume ?? null) - : null - } - selectedAudioNormalize={ - selectedAudioId - ? (audioRegions.find((r) => r.id === selectedAudioId) - ?.normalize ?? false) - : null - } - onAudioVolumeChange={handleAudioVolumeChange} - onAudioNormalizeChange={handleAudioNormalizeChange} - onAudioDelete={handleAudioDelete} + selectedAudioVolume={ + selectedAudioId + ? (audioRegions.find((r) => r.id === selectedAudioId) + ?.volume ?? null) + : null + } + selectedAudioNormalize={ + selectedAudioId + ? (audioRegions.find((r) => r.id === selectedAudioId) + ?.normalize ?? false) + : null + } + onAudioVolumeChange={handleAudioVolumeChange} + onAudioNormalizeChange={handleAudioNormalizeChange} + onAudioDelete={handleAudioDelete} shadowIntensity={shadowIntensity} onShadowChange={setShadowIntensity} backgroundBlur={backgroundBlur} @@ -6055,13 +5929,15 @@ export default function VideoEditor() { } cursorSway={cursorSway} volume={ - audio.shouldMutePreviewVideo || audio.isCurrentClipMuted + audio.shouldMutePreviewVideo || + audio.isCurrentClipMuted ? 0 : Math.max( 0, Math.min( 1, - previewVolume * audio.embeddedSourcePreviewGain, + previewVolume * + audio.embeddedSourcePreviewGain, ), ) } diff --git a/src/components/video-editor/smokeExportConfig.test.ts b/src/components/video-editor/smokeExportConfig.test.ts new file mode 100644 index 00000000..c221e1e4 --- /dev/null +++ b/src/components/video-editor/smokeExportConfig.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from "vitest"; + +import { getDevOpenRecordingConfig, getSmokeExportConfig } from "./smokeExportConfig"; + +describe("getSmokeExportConfig", () => { + it("keeps smoke export disabled when the flag is absent", () => { + expect(getSmokeExportConfig("?smokeInput=/tmp/input.mp4")).toEqual({ + enabled: false, + inputPath: null, + outputPath: null, + useNativeExport: false, + encodingMode: undefined, + shadowIntensity: undefined, + webcamInputPath: null, + webcamShadow: undefined, + webcamSize: undefined, + pipelineModel: undefined, + backendPreference: undefined, + renderBackend: undefined, + maxEncodeQueue: undefined, + maxDecodeQueue: undefined, + maxPendingFrames: undefined, + projectPath: null, + quality: undefined, + fps: undefined, + }); + }); + + it("parses the enabled smoke export query contract", () => { + const config = getSmokeExportConfig( + "?smokeExport=1" + + "&smokeInput=/tmp/input.mp4" + + "&smokeOutput=/tmp/output.mp4" + + "&smokeUseNativeExport=1" + + "&smokeEncodingMode=quality" + + "&smokeShadowIntensity=0" + + "&smokeWebcamInput=/tmp/webcam.mp4" + + "&smokeWebcamShadow=1.5" + + "&smokeWebcamSize=0.75" + + "&smokePipelineModel=legacy" + + "&smokeBackendPreference=breeze" + + "&smokeRenderBackend=webgpu" + + "&smokeMaxEncodeQueue=8" + + "&smokeMaxDecodeQueue=9" + + "&smokeMaxPendingFrames=10" + + "&smokeProject=/tmp/project.recordly" + + "&smokeQuality=source" + + "&smokeFps=60", + ); + + expect(config).toEqual({ + enabled: true, + inputPath: "/tmp/input.mp4", + outputPath: "/tmp/output.mp4", + useNativeExport: true, + encodingMode: "quality", + shadowIntensity: 0, + webcamInputPath: "/tmp/webcam.mp4", + webcamShadow: 1.5, + webcamSize: 0.75, + pipelineModel: "legacy", + backendPreference: "breeze", + renderBackend: "webgpu", + maxEncodeQueue: 8, + maxDecodeQueue: 9, + maxPendingFrames: 10, + projectPath: "/tmp/project.recordly", + quality: "source", + fps: 60, + }); + }); + + it("drops invalid optional smoke export values", () => { + const config = getSmokeExportConfig( + "?smokeExport=1" + + "&smokeEncodingMode=slow" + + "&smokeShadowIntensity=-1" + + "&smokeWebcamShadow=nan" + + "&smokeWebcamSize=-0.1" + + "&smokePipelineModel=classic" + + "&smokeBackendPreference=native" + + "&smokeRenderBackend=canvas" + + "&smokeMaxEncodeQueue=0" + + "&smokeMaxDecodeQueue=-4" + + "&smokeMaxPendingFrames=abc" + + "&smokeQuality=ultra" + + "&smokeFps=25", + ); + + expect(config).toMatchObject({ + enabled: true, + useNativeExport: false, + encodingMode: undefined, + shadowIntensity: undefined, + webcamShadow: undefined, + webcamSize: undefined, + pipelineModel: undefined, + backendPreference: undefined, + renderBackend: undefined, + maxEncodeQueue: undefined, + maxDecodeQueue: undefined, + maxPendingFrames: undefined, + quality: undefined, + fps: undefined, + }); + }); +}); + +describe("getDevOpenRecordingConfig", () => { + it("reads dev-open paths independently from smoke export", () => { + expect( + getDevOpenRecordingConfig( + "?devOpenInput=/tmp/input.mp4&devOpenWebcam=/tmp/webcam.mp4", + ), + ).toEqual({ + inputPath: "/tmp/input.mp4", + webcamInputPath: "/tmp/webcam.mp4", + }); + }); +}); diff --git a/src/components/video-editor/smokeExportConfig.ts b/src/components/video-editor/smokeExportConfig.ts new file mode 100644 index 00000000..0d71779b --- /dev/null +++ b/src/components/video-editor/smokeExportConfig.ts @@ -0,0 +1,131 @@ +import { + isValidMp4FrameRate, + type ExportBackendPreference, + type ExportEncodingMode, + type ExportMp4FrameRate, + type ExportPipelineModel, + type ExportQuality, + type ExportRenderBackend, +} from "@/lib/exporter/types"; + +export type SmokeExportConfig = { + enabled: boolean; + inputPath: string | null; + outputPath: string | null; + useNativeExport: boolean; + encodingMode?: ExportEncodingMode; + shadowIntensity?: number; + webcamInputPath?: string | null; + webcamShadow?: number; + webcamSize?: number; + pipelineModel?: ExportPipelineModel; + backendPreference?: ExportBackendPreference; + renderBackend?: ExportRenderBackend; + maxEncodeQueue?: number; + maxDecodeQueue?: number; + maxPendingFrames?: number; + projectPath?: string | null; + quality?: ExportQuality; + fps?: ExportMp4FrameRate; +}; + +export type DevOpenRecordingConfig = { + inputPath: string | null; + webcamInputPath: string | null; +}; + +function parseSmokeExportNumber(value: string | null): number | undefined { + if (value === null) { + return undefined; + } + + const parsed = Number.parseInt(value, 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined; +} + +function parseSmokeExportNonNegativeNumber(value: string | null): number | undefined { + if (value === null) { + return undefined; + } + + const parsed = Number.parseFloat(value); + return Number.isFinite(parsed) && parsed >= 0 ? parsed : undefined; +} + +function parseSmokeExportQuality(value: string | null): ExportQuality | undefined { + if (value === "medium" || value === "good" || value === "high" || value === "source") { + return value; + } + return undefined; +} + +function parseSmokeExportFps(value: string | null): ExportMp4FrameRate | undefined { + if (value === null) return undefined; + const parsed = Number.parseInt(value, 10); + return isValidMp4FrameRate(parsed) ? parsed : undefined; +} + +function parseSmokeRenderBackend(value: string | null): ExportRenderBackend | undefined { + return value === "webgl" || value === "webgpu" ? value : undefined; +} + +export function getSmokeExportConfig(search: string): SmokeExportConfig { + const params = new URLSearchParams(search); + const enabled = params.get("smokeExport") === "1"; + + return { + enabled, + inputPath: enabled ? params.get("smokeInput") : null, + outputPath: enabled ? params.get("smokeOutput") : null, + useNativeExport: enabled ? params.get("smokeUseNativeExport") === "1" : false, + encodingMode: + enabled && params.get("smokeEncodingMode") === "fast" + ? "fast" + : enabled && params.get("smokeEncodingMode") === "balanced" + ? "balanced" + : enabled && params.get("smokeEncodingMode") === "quality" + ? "quality" + : undefined, + shadowIntensity: enabled + ? parseSmokeExportNonNegativeNumber(params.get("smokeShadowIntensity")) + : undefined, + webcamInputPath: enabled ? params.get("smokeWebcamInput") : null, + webcamShadow: enabled + ? parseSmokeExportNonNegativeNumber(params.get("smokeWebcamShadow")) + : undefined, + webcamSize: enabled + ? parseSmokeExportNonNegativeNumber(params.get("smokeWebcamSize")) + : undefined, + pipelineModel: + enabled && params.get("smokePipelineModel") === "modern" + ? "modern" + : enabled && params.get("smokePipelineModel") === "legacy" + ? "legacy" + : undefined, + backendPreference: + enabled && params.get("smokeBackendPreference") === "auto" + ? "auto" + : enabled && params.get("smokeBackendPreference") === "webcodecs" + ? "webcodecs" + : enabled && params.get("smokeBackendPreference") === "breeze" + ? "breeze" + : undefined, + renderBackend: enabled ? parseSmokeRenderBackend(params.get("smokeRenderBackend")) : undefined, + maxEncodeQueue: enabled ? parseSmokeExportNumber(params.get("smokeMaxEncodeQueue")) : undefined, + maxDecodeQueue: enabled ? parseSmokeExportNumber(params.get("smokeMaxDecodeQueue")) : undefined, + maxPendingFrames: enabled + ? parseSmokeExportNumber(params.get("smokeMaxPendingFrames")) + : undefined, + projectPath: enabled ? params.get("smokeProject") : null, + quality: enabled ? parseSmokeExportQuality(params.get("smokeQuality")) : undefined, + fps: enabled ? parseSmokeExportFps(params.get("smokeFps")) : undefined, + }; +} + +export function getDevOpenRecordingConfig(search: string): DevOpenRecordingConfig { + const params = new URLSearchParams(search); + return { + inputPath: params.get("devOpenInput"), + webcamInputPath: params.get("devOpenWebcam"), + }; +}