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"),
+ };
+}