Merge remote-tracking branch 'origin/main' into refactor/editor-project-dirty-state-local

# Conflicts:
#	src/components/video-editor/VideoEditor.tsx
This commit is contained in:
wiiiii123
2026-05-20 23:51:40 +07:00
3 changed files with 376 additions and 249 deletions
+125 -249
View File
@@ -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" }) => (
<Cursor weight="fill" className={props.className} />
@@ -113,11 +108,21 @@ const PhSettings = (props: { className?: string; weight?: "fill" | "regular" })
<Gear weight={props.weight ?? "regular"} className={props.className} />
);
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<string | null> {
@@ -308,11 +283,6 @@ type SaveProjectOptions = {
captureThumbnail?: boolean;
};
type DevOpenRecordingConfig = {
inputPath: string | null;
webcamInputPath: string | null;
};
async function writeSmokeExportReport(
outputPath: string | null,
report: Record<string, unknown>,
@@ -362,108 +332,6 @@ function cloneStructured<T>(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<string, SourceAudioTrackSettings>
>({});
const [defaultSourceAudioTrackSettings, setDefaultSourceAudioTrackSettings] = useState<
SourceAudioTrackSettings
>({});
const [defaultSourceAudioTrackSettings, setDefaultSourceAudioTrackSettings] =
useState<SourceAudioTrackSettings>({});
const [hasClipSourceAudio, setHasClipSourceAudio] = useState(false);
const [autoCaptions, setAutoCaptions] = useState<CaptionCue[]>([]);
const [autoCaptionSettings, setAutoCaptionSettings] = useState<AutoCaptionSettings>(
@@ -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,
),
)
}
@@ -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",
});
});
});
@@ -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"),
};
}