merge main into beta/pr410-native-gpu-export-test

# Conflicts:
#	src/lib/exporter/modernVideoExporter.ts
This commit is contained in:
wiiiii123
2026-05-08 11:48:57 +07:00
11 changed files with 287 additions and 113 deletions
+33 -34
View File
@@ -45,7 +45,11 @@ import { useI18n, useScopedT } from "../../contexts/I18nContext";
import type { AppLocale } from "../../i18n/config";
import { SUPPORTED_LOCALES } from "../../i18n/config";
import { AnnotationSettingsPanel } from "./AnnotationSettingsPanel";
import { CURSOR_MOTION_PRESETS, type CursorMotionPresetId } from "./cursorMotionPresets";
import {
CURSOR_MOTION_PRESETS,
type CursorMotionPresetId,
getMatchingCursorMotionPresetId,
} from "./cursorMotionPresets";
import { loadEditorPreferences, saveEditorPreferences } from "./editorPreferences";
import { SliderControl } from "./SliderControl";
import { KeyboardShortcutsDialog } from "./TutorialHelp";
@@ -1486,9 +1490,7 @@ export function SettingsPanel({
onCameraSpringDampingMultiplierChange?.(
initialEditorPreferences.cameraSpringDampingMultiplier,
);
onCameraSpringMassMultiplierChange?.(
initialEditorPreferences.cameraSpringMassMultiplier,
);
onCameraSpringMassMultiplierChange?.(initialEditorPreferences.cameraSpringMassMultiplier);
onZoomInDurationMsChange?.(initialEditorPreferences.zoomInDurationMs);
onZoomOutDurationMsChange?.(initialEditorPreferences.zoomOutDurationMs);
onZoomClassicModeChange?.(false);
@@ -1515,21 +1517,18 @@ export function SettingsPanel({
const activeMotionPresetId = useMemo(() => {
return (
MOTION_PRESET_ORDER.find((presetId) => {
const preset = CURSOR_MOTION_PRESETS[presetId];
return (
preset.zoomInDurationMs === zoomInDurationMs &&
preset.zoomOutDurationMs === zoomOutDurationMs &&
preset.cursorSize === cursorSize &&
preset.cursorSmoothing === cursorSmoothing &&
preset.cursorSpringStiffnessMultiplier === cursorSpringStiffnessMultiplier &&
preset.cursorSpringDampingMultiplier === cursorSpringDampingMultiplier &&
preset.cursorSpringMassMultiplier === cursorSpringMassMultiplier &&
preset.cursorMotionBlur === cursorMotionBlur &&
preset.cursorClickBounce === cursorClickBounce &&
preset.cursorClickBounceDuration === cursorClickBounceDuration
);
}) ?? null
getMatchingCursorMotionPresetId({
zoomInDurationMs,
zoomOutDurationMs,
cursorSize,
cursorSmoothing,
cursorSpringStiffnessMultiplier,
cursorSpringDampingMultiplier,
cursorSpringMassMultiplier,
cursorMotionBlur,
cursorClickBounce,
cursorClickBounceDuration,
}) ?? "focused"
);
}, [
cursorClickBounce,
@@ -3003,21 +3002,21 @@ export function SettingsPanel({
)}
</div>
)}
<div className="rounded-lg border border-foreground/10 bg-foreground/[0.03] px-3 py-2">
<div className="text-[10px] text-muted-foreground">
{showDevMotionControls
? tSettings(
"effects.exportBlurMovedToDev",
"Export blur tuning is available in Settings > Dev.",
)
: tSettings(
"effects.exportBlurLocked",
"Export blur is fixed for this build.",
)}
</div>
<div className="mt-1 text-[12px] font-medium text-foreground">
{`${TEMPORAL_MOTION_BLUR_DEFAULT_SAMPLE_COUNT} samples · ${Math.round(TEMPORAL_MOTION_BLUR_DEFAULT_SHUTTER_FRACTION * 100)}% shutter`}
</div>
<div className="rounded-lg border border-foreground/10 bg-foreground/[0.03] px-3 py-2">
<div className="text-[10px] text-muted-foreground">
{showDevMotionControls
? tSettings(
"effects.exportBlurMovedToDev",
"Export blur tuning is available in Settings > Dev.",
)
: tSettings(
"effects.exportBlurLocked",
"Export blur is fixed for this build.",
)}
</div>
<div className="mt-1 text-[12px] font-medium text-foreground">
{`${TEMPORAL_MOTION_BLUR_DEFAULT_SAMPLE_COUNT} samples · ${Math.round(TEMPORAL_MOTION_BLUR_DEFAULT_SHUTTER_FRACTION * 100)}% shutter`}
</div>
</div>
{selectedZoomId && (
<Button
+53
View File
@@ -1,5 +1,6 @@
import * as fc from "fast-check";
import { describe, expect, it } from "vitest";
import { CURSOR_MOTION_PRESETS } from "./cursorMotionPresets";
import { fromFileUrl, normalizeProjectEditor, toFileUrl } from "./projectPersistence";
describe("Audio path handling", () => {
@@ -235,3 +236,55 @@ describe("Audio region normalization", () => {
});
});
});
describe("Motion preset normalization", () => {
it("preserves the smooth preset when the saved values match it exactly", () => {
const smooth = CURSOR_MOTION_PRESETS.smooth;
const result = normalizeProjectEditor({
zoomInDurationMs: smooth.zoomInDurationMs,
zoomOutDurationMs: smooth.zoomOutDurationMs,
cursorSize: smooth.cursorSize,
cursorSmoothing: smooth.cursorSmoothing,
cursorSpringStiffnessMultiplier: smooth.cursorSpringStiffnessMultiplier,
cursorSpringDampingMultiplier: smooth.cursorSpringDampingMultiplier,
cursorSpringMassMultiplier: smooth.cursorSpringMassMultiplier,
cursorMotionBlur: smooth.cursorMotionBlur,
cursorClickBounce: smooth.cursorClickBounce,
cursorClickBounceDuration: smooth.cursorClickBounceDuration,
} as any);
expect(result.zoomInDurationMs).toBe(smooth.zoomInDurationMs);
expect(result.zoomOutDurationMs).toBe(smooth.zoomOutDurationMs);
expect(result.cursorSpringStiffnessMultiplier).toBe(smooth.cursorSpringStiffnessMultiplier);
expect(result.cursorSpringDampingMultiplier).toBe(smooth.cursorSpringDampingMultiplier);
});
it("falls back to focused when the saved values do not match a supported preset", () => {
const focused = CURSOR_MOTION_PRESETS.focused;
const result = normalizeProjectEditor({
zoomInDurationMs: 275,
zoomOutDurationMs: 410,
cursorSize: 3.25,
cursorSmoothing: 0.52,
cursorSpringStiffnessMultiplier: 1.1,
cursorSpringDampingMultiplier: 1.05,
cursorSpringMassMultiplier: 1.6,
cursorMotionBlur: 0.9,
cursorClickBounce: 2.25,
cursorClickBounceDuration: 280,
} as any);
expect(result.zoomInDurationMs).toBe(focused.zoomInDurationMs);
expect(result.zoomOutDurationMs).toBe(focused.zoomOutDurationMs);
expect(result.cursorSize).toBe(focused.cursorSize);
expect(result.cursorSmoothing).toBe(focused.cursorSmoothing);
expect(result.cursorSpringStiffnessMultiplier).toBe(
focused.cursorSpringStiffnessMultiplier,
);
expect(result.cursorSpringDampingMultiplier).toBe(focused.cursorSpringDampingMultiplier);
expect(result.cursorSpringMassMultiplier).toBe(focused.cursorSpringMassMultiplier);
expect(result.cursorMotionBlur).toBe(focused.cursorMotionBlur);
expect(result.cursorClickBounce).toBe(focused.cursorClickBounce);
expect(result.cursorClickBounceDuration).toBe(focused.cursorClickBounceDuration);
});
});
@@ -18,6 +18,19 @@ export interface CursorMotionPreset {
cursorClickBounceDuration: number;
}
export interface CursorMotionPresetSelectionInput {
zoomInDurationMs: number;
zoomOutDurationMs: number;
cursorSize: number;
cursorSmoothing: number;
cursorSpringStiffnessMultiplier: number;
cursorSpringDampingMultiplier: number;
cursorSpringMassMultiplier: number;
cursorMotionBlur: number;
cursorClickBounce: number;
cursorClickBounceDuration: number;
}
const SHARED_CURSOR_PRESET_VALUES = {
cursorSize: 2.5,
cursorSmoothing: 0.67,
@@ -48,4 +61,35 @@ export const CURSOR_MOTION_PRESETS: Record<CursorMotionPresetId, CursorMotionPre
cursorSpringStiffnessMultiplier: 0.92,
cursorSpringDampingMultiplier: 1.36,
},
};
};
export function getMatchingCursorMotionPresetId(
values: CursorMotionPresetSelectionInput,
): CursorMotionPresetId | null {
for (const presetId of Object.keys(CURSOR_MOTION_PRESETS) as CursorMotionPresetId[]) {
const preset = CURSOR_MOTION_PRESETS[presetId];
if (
preset.zoomInDurationMs === values.zoomInDurationMs &&
preset.zoomOutDurationMs === values.zoomOutDurationMs &&
preset.cursorSize === values.cursorSize &&
preset.cursorSmoothing === values.cursorSmoothing &&
preset.cursorSpringStiffnessMultiplier === values.cursorSpringStiffnessMultiplier &&
preset.cursorSpringDampingMultiplier === values.cursorSpringDampingMultiplier &&
preset.cursorSpringMassMultiplier === values.cursorSpringMassMultiplier &&
preset.cursorMotionBlur === values.cursorMotionBlur &&
preset.cursorClickBounce === values.cursorClickBounce &&
preset.cursorClickBounceDuration === values.cursorClickBounceDuration
) {
return presetId;
}
}
return null;
}
export function resolveCursorMotionPresetId(
values: CursorMotionPresetSelectionInput,
fallback: CursorMotionPresetId = "focused",
): CursorMotionPresetId {
return getMatchingCursorMotionPresetId(values) ?? fallback;
}
@@ -4,11 +4,11 @@ import {
DEFAULT_EDITOR_PREFERENCES,
EDITOR_PREFERENCES_STORAGE_KEY,
EDITOR_PRESETS_STORAGE_KEY,
loadEditorPresets,
loadEditorPreferences,
loadEditorPresets,
normalizeEditorPreferences,
saveEditorPresets,
saveEditorPreferences,
saveEditorPresets,
} from "./editorPreferences";
import { DEFAULT_AUTO_CAPTION_SETTINGS } from "./types";
@@ -223,11 +223,11 @@ describe("editorPreferences", () => {
showCursor: false,
loopCursor: true,
cursorStyle: "figma",
cursorSize: 3,
cursorSmoothing: 1.25,
cursorMotionBlur: 0.5,
cursorClickBounce: 2.25,
cursorClickBounceDuration: 350,
cursorSize: DEFAULT_EDITOR_PREFERENCES.cursorSize,
cursorSmoothing: DEFAULT_EDITOR_PREFERENCES.cursorSmoothing,
cursorMotionBlur: DEFAULT_EDITOR_PREFERENCES.cursorMotionBlur,
cursorClickBounce: DEFAULT_EDITOR_PREFERENCES.cursorClickBounce,
cursorClickBounceDuration: DEFAULT_EDITOR_PREFERENCES.cursorClickBounceDuration,
cursorSway: 1.5,
borderRadius: 18,
padding: { top: 30, right: 30, bottom: 30, left: 30, linked: true },
@@ -19,7 +19,7 @@ import {
} from "@/lib/exporter/temporalMotionBlur";
import { DEFAULT_WALLPAPER_PATH } from "@/lib/wallpapers";
import { ASPECT_RATIOS, type AspectRatio, isCustomAspectRatio } from "@/utils/aspectRatioUtils";
import { CURSOR_MOTION_PRESETS } from "./cursorMotionPresets";
import { CURSOR_MOTION_PRESETS, resolveCursorMotionPresetId } from "./cursorMotionPresets";
import {
type AnnotationRegion,
type AudioRegion,
@@ -797,31 +797,9 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
? "tahoe-inverted"
: editor.cursorStyle
: DEFAULT_CURSOR_STYLE;
return {
wallpaper: typeof editor.wallpaper === "string" ? editor.wallpaper : DEFAULT_WALLPAPER_PATH,
shadowIntensity: typeof editor.shadowIntensity === "number" ? editor.shadowIntensity : 0.67,
backgroundBlur: normalizedBackgroundBlur,
zoomMotionBlur: normalizedZoomMotionBlur,
zoomMotionBlurTuning: normalizedZoomMotionBlurTuning,
zoomTemporalMotionBlur: normalizedZoomTemporalMotionBlur,
zoomMotionBlurSampleCount: normalizedZoomMotionBlurSampleCount,
zoomMotionBlurShutterFraction: normalizedZoomMotionBlurShutterFraction,
connectZooms: typeof editor.connectZooms === "boolean" ? editor.connectZooms : true,
const normalizedMotionValues = {
zoomInDurationMs: normalizedZoomInDurationMs,
zoomInOverlapMs: normalizedZoomInOverlapMs,
zoomOutDurationMs: normalizedZoomOutDurationMs,
connectedZoomGapMs: normalizedConnectedZoomGapMs,
connectedZoomDurationMs: normalizedConnectedZoomDurationMs,
zoomInEasing: normalizeZoomTransitionEasing(editor.zoomInEasing, DEFAULT_ZOOM_IN_EASING),
zoomOutEasing: normalizeZoomTransitionEasing(editor.zoomOutEasing, DEFAULT_ZOOM_OUT_EASING),
connectedZoomEasing: normalizeZoomTransitionEasing(
editor.connectedZoomEasing,
DEFAULT_CONNECTED_ZOOM_EASING,
),
showCursor: typeof editor.showCursor === "boolean" ? editor.showCursor : true,
loopCursor: typeof editor.loopCursor === "boolean" ? editor.loopCursor : false,
cursorStyle: normalizedCursorStyle,
cursorSize: isFiniteNumber(editor.cursorSize)
? clamp(editor.cursorSize, 0.5, 10)
: DEFAULT_MOTION_PRESET.cursorSize,
@@ -837,18 +815,6 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
cursorSpringMassMultiplier: isFiniteNumber(editor.cursorSpringMassMultiplier)
? clamp(editor.cursorSpringMassMultiplier, 0.25, 3)
: DEFAULT_MOTION_PRESET.cursorSpringMassMultiplier,
cameraSpringStiffnessMultiplier: isFiniteNumber(editor.cameraSpringStiffnessMultiplier)
? clamp(editor.cameraSpringStiffnessMultiplier, 0.25, 3)
: 1,
cameraSpringDampingMultiplier: isFiniteNumber(editor.cameraSpringDampingMultiplier)
? clamp(editor.cameraSpringDampingMultiplier, 0.25, 3)
: 1,
cameraSpringMassMultiplier: isFiniteNumber(editor.cameraSpringMassMultiplier)
? clamp(editor.cameraSpringMassMultiplier, 0.25, 3)
: 1,
zoomSmoothness: DEFAULT_ZOOM_SMOOTHNESS,
zoomClassicMode:
typeof editor.zoomClassicMode === "boolean" ? editor.zoomClassicMode : false,
cursorMotionBlur: isFiniteNumber((editor as Partial<ProjectEditorState>).cursorMotionBlur)
? clamp((editor as Partial<ProjectEditorState>).cursorMotionBlur as number, 0, 2)
: DEFAULT_MOTION_PRESET.cursorMotionBlur,
@@ -864,6 +830,54 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
500,
)
: DEFAULT_MOTION_PRESET.cursorClickBounceDuration,
};
const normalizedMotionPreset =
CURSOR_MOTION_PRESETS[resolveCursorMotionPresetId(normalizedMotionValues)];
return {
wallpaper: typeof editor.wallpaper === "string" ? editor.wallpaper : DEFAULT_WALLPAPER_PATH,
shadowIntensity: typeof editor.shadowIntensity === "number" ? editor.shadowIntensity : 0.67,
backgroundBlur: normalizedBackgroundBlur,
zoomMotionBlur: normalizedZoomMotionBlur,
zoomMotionBlurTuning: normalizedZoomMotionBlurTuning,
zoomTemporalMotionBlur: normalizedZoomTemporalMotionBlur,
zoomMotionBlurSampleCount: normalizedZoomMotionBlurSampleCount,
zoomMotionBlurShutterFraction: normalizedZoomMotionBlurShutterFraction,
connectZooms: typeof editor.connectZooms === "boolean" ? editor.connectZooms : true,
zoomInDurationMs: normalizedMotionPreset.zoomInDurationMs,
zoomInOverlapMs: normalizedZoomInOverlapMs,
zoomOutDurationMs: normalizedMotionPreset.zoomOutDurationMs,
connectedZoomGapMs: normalizedConnectedZoomGapMs,
connectedZoomDurationMs: normalizedConnectedZoomDurationMs,
zoomInEasing: normalizeZoomTransitionEasing(editor.zoomInEasing, DEFAULT_ZOOM_IN_EASING),
zoomOutEasing: normalizeZoomTransitionEasing(editor.zoomOutEasing, DEFAULT_ZOOM_OUT_EASING),
connectedZoomEasing: normalizeZoomTransitionEasing(
editor.connectedZoomEasing,
DEFAULT_CONNECTED_ZOOM_EASING,
),
showCursor: typeof editor.showCursor === "boolean" ? editor.showCursor : true,
loopCursor: typeof editor.loopCursor === "boolean" ? editor.loopCursor : false,
cursorStyle: normalizedCursorStyle,
cursorSize: normalizedMotionPreset.cursorSize,
cursorSmoothing: normalizedMotionPreset.cursorSmoothing,
cursorSpringStiffnessMultiplier: normalizedMotionPreset.cursorSpringStiffnessMultiplier,
cursorSpringDampingMultiplier: normalizedMotionPreset.cursorSpringDampingMultiplier,
cursorSpringMassMultiplier: normalizedMotionPreset.cursorSpringMassMultiplier,
cameraSpringStiffnessMultiplier: isFiniteNumber(editor.cameraSpringStiffnessMultiplier)
? clamp(editor.cameraSpringStiffnessMultiplier, 0.25, 3)
: 1,
cameraSpringDampingMultiplier: isFiniteNumber(editor.cameraSpringDampingMultiplier)
? clamp(editor.cameraSpringDampingMultiplier, 0.25, 3)
: 1,
cameraSpringMassMultiplier: isFiniteNumber(editor.cameraSpringMassMultiplier)
? clamp(editor.cameraSpringMassMultiplier, 0.25, 3)
: 1,
zoomSmoothness: DEFAULT_ZOOM_SMOOTHNESS,
zoomClassicMode:
typeof editor.zoomClassicMode === "boolean" ? editor.zoomClassicMode : false,
cursorMotionBlur: normalizedMotionPreset.cursorMotionBlur,
cursorClickBounce: normalizedMotionPreset.cursorClickBounce,
cursorClickBounceDuration: normalizedMotionPreset.cursorClickBounceDuration,
cursorSway: isFiniteNumber((editor as Partial<ProjectEditorState>).cursorSway)
? clamp((editor as Partial<ProjectEditorState>).cursorSway as number, 0, 2)
: DEFAULT_CURSOR_SWAY,
@@ -585,7 +585,7 @@ function ClipMarkerOverlay({ videoDurationMs }: { videoDurationMs: number }) {
bottom: "7.5%",
[sideProperty]: `${offset}px`,
background:
"linear-gradient(to bottom, transparent 0%, rgba(255,255,255,0.10) 35%, rgba(255,255,255,0.10) 65%, transparent 100%)",
"linear-gradient(to bottom, transparent 0%, rgba(255,255,255,0.32) 35%, rgba(255,255,255,0.32) 65%, transparent 100%)",
}}
/>
))}
@@ -858,7 +858,6 @@ function Timeline({
onMouseMove={handleTimelineMouseMove}
onMouseLeave={handleTimelineMouseLeave}
>
<div className="absolute inset-0 bg-[linear-gradient(to_right,hsl(var(--foreground)/0.03)_1px,transparent_1px)] bg-[length:20px_100%] pointer-events-none" />
<TimelineAxis videoDurationMs={videoDurationMs} currentTimeMs={currentTimeMs} />
<PlaybackCursor
currentTimeMs={currentTimeMs}
@@ -1236,12 +1235,7 @@ const TimelineEditor = forwardRef<TimelineEditorHandle, TimelineEditorProps>(
onSelectAnnotation?.(null);
onSelectAudio?.(null);
setSelectAllBlocksActive(false);
}, [
onSelectAnnotation,
onSelectAudio,
onSelectClip,
onSelectZoom,
]);
}, [onSelectAnnotation, onSelectAudio, onSelectClip, onSelectZoom]);
const hasAnyTimelineBlocks =
zoomRegions.length > 0 ||
@@ -1479,7 +1473,9 @@ const TimelineEditor = forwardRef<TimelineEditorHandle, TimelineEditorProps>(
const activeClip =
clipRegions.length === 0
? { startMs: 0, endMs: totalMs }
: clipRegions.find((clip) => startPos >= clip.startMs && startPos < clip.endMs);
: clipRegions.find(
(clip) => startPos >= clip.startMs && startPos < clip.endMs,
);
if (!activeClip) {
return false;
}
@@ -1487,7 +1483,9 @@ const TimelineEditor = forwardRef<TimelineEditorHandle, TimelineEditorProps>(
const sorted = [...zoomRegions].sort((a, b) => a.startMs - b.startMs);
const nextRegion = sorted.find((region) => region.startMs > startPos);
const gapToNextClipEdge = activeClip.endMs - startPos;
const gapToNextRegion = nextRegion ? nextRegion.startMs - startPos : gapToNextClipEdge;
const gapToNextRegion = nextRegion
? nextRegion.startMs - startPos
: gapToNextClipEdge;
const availableDuration = Math.min(gapToNextClipEdge, gapToNextRegion);
const isOverlapping = sorted.some(
+25 -4
View File
@@ -154,7 +154,7 @@ const messages: Record<AppLocale, LocaleBundle> = {
shortcuts: zhCNShortcuts,
extensions: zhCNExtensions,
},
"zh-TW": {
"zh-TW": {
common: zhTWCommon,
launch: zhTWLaunch,
editor: zhTWEditor,
@@ -207,6 +207,29 @@ function normalizeLocale(locale: string | null | undefined): AppLocale {
return DEFAULT_LOCALE;
}
function getSystemLocale(): AppLocale {
if (typeof navigator === "undefined") {
return DEFAULT_LOCALE;
}
const preferredLocales = Array.isArray(navigator.languages)
? navigator.languages
: [navigator.language];
for (const locale of preferredLocales) {
if (typeof locale !== "string" || locale.trim().length === 0) {
continue;
}
const normalized = normalizeLocale(locale);
if (normalized !== DEFAULT_LOCALE || locale.toLowerCase().startsWith(DEFAULT_LOCALE)) {
return normalized;
}
}
return DEFAULT_LOCALE;
}
function getInitialLocale(): AppLocale {
if (typeof window === "undefined") {
return DEFAULT_LOCALE;
@@ -217,9 +240,7 @@ function getInitialLocale(): AppLocale {
return normalizeLocale(storedLocale);
}
// Product default must be English on first launch unless user explicitly
// selected another locale and we persisted it in localStorage.
return DEFAULT_LOCALE;
return getSystemLocale();
}
function getMessageValue(source: unknown, key: string): string | undefined {
+12 -2
View File
@@ -1172,7 +1172,12 @@ export class ModernVideoExporter {
) {
const sourceDurationMs = Math.max(
0,
Math.round((videoInfo.streamDuration ?? videoInfo.duration) * 1000),
Math.round(
getEffectiveVideoStreamDurationSeconds({
duration: videoInfo.duration,
streamDuration: videoInfo.streamDuration,
}) * 1000,
),
);
const trimRegions = this.config.trimRegions ?? [];
const canUsePrimaryAudioFiltergraph =
@@ -1234,7 +1239,12 @@ export class ModernVideoExporter {
if ((this.config.trimRegions ?? []).length > 0) {
const sourceDurationMs = Math.max(
0,
Math.round((videoInfo.streamDuration ?? videoInfo.duration) * 1000),
Math.round(
getEffectiveVideoStreamDurationSeconds({
duration: videoInfo.duration,
streamDuration: videoInfo.streamDuration,
}) * 1000,
),
);
const trimSegments = this.buildNativeTrimSegments(sourceDurationMs);
if (trimSegments.length === 0) {
+24 -15
View File
@@ -14,6 +14,7 @@ import type {
ZoomRegion,
ZoomTransitionEasing,
} from "@/components/video-editor/types";
import { getEffectiveVideoStreamDurationSeconds } from "@/lib/mediaTiming";
import { AudioProcessor, isAacAudioEncodingSupported } from "./audioEncoder";
import { buildEditedTrackSourceSegments, classifyEditedTrackStrategy } from "./editedTrackStrategy";
import {
@@ -191,8 +192,7 @@ export class VideoExporter {
const shouldUseFfmpegAudioFallback =
!useNativeEncoder &&
audioPlan.audioMode !== "none" &&
(shouldUsePitchPreservingFfmpegAudio ||
!(await isAacAudioEncodingSupported()));
(shouldUsePitchPreservingFfmpegAudio || !(await isAacAudioEncodingSupported()));
if (!useNativeEncoder) {
await this.initializeEncoder();
@@ -564,7 +564,12 @@ export class VideoExporter {
) {
const sourceDurationMs = Math.max(
0,
Math.round((videoInfo.streamDuration ?? videoInfo.duration) * 1000),
Math.round(
getEffectiveVideoStreamDurationSeconds({
duration: videoInfo.duration,
streamDuration: videoInfo.streamDuration,
}) * 1000,
),
);
const trimRegions = this.config.trimRegions ?? [];
const canUsePrimaryAudioFiltergraph =
@@ -575,17 +580,16 @@ export class VideoExporter {
typeof primaryAudioSourceSampleRate === "number" &&
Number.isFinite(primaryAudioSourceSampleRate) &&
primaryAudioSourceSampleRate > 0;
const strategy =
canUsePrimaryAudioFiltergraph
? classifyEditedTrackStrategy({
primaryAudioSourcePath,
sourceDurationMs,
trimRegions,
speedRegions,
audioRegions,
sourceAudioFallbackPaths,
})
: "offline-render-fallback";
const strategy = canUsePrimaryAudioFiltergraph
? classifyEditedTrackStrategy({
primaryAudioSourcePath,
sourceDurationMs,
trimRegions,
speedRegions,
audioRegions,
sourceAudioFallbackPaths,
})
: "offline-render-fallback";
if (strategy === "filtergraph-fast-path") {
const audioSourcePath = primaryAudioSourcePath;
@@ -626,7 +630,12 @@ export class VideoExporter {
if ((this.config.trimRegions ?? []).length > 0) {
const sourceDurationMs = Math.max(
0,
Math.round((videoInfo.streamDuration ?? videoInfo.duration) * 1000),
Math.round(
getEffectiveVideoStreamDurationSeconds({
duration: videoInfo.duration,
streamDuration: videoInfo.streamDuration,
}) * 1000,
),
);
const trimSegments = this.buildNativeTrimSegments(sourceDurationMs);
if (trimSegments.length === 0) {
+9
View File
@@ -128,6 +128,15 @@ describe("getEffectiveVideoStreamDurationSeconds", () => {
).toBe(11.2);
});
it("uses the container duration when the video stream is much shorter", () => {
expect(
getEffectiveVideoStreamDurationSeconds({
duration: 60,
streamDuration: 40,
}),
).toBe(60);
});
it("falls back to the container duration when stream duration is missing", () => {
expect(
getEffectiveVideoStreamDurationSeconds({
+21 -4
View File
@@ -94,12 +94,29 @@ export function getEffectiveVideoStreamDurationSeconds({
duration?: number | null;
streamDuration?: number | null;
}): number {
if (Number.isFinite(streamDuration) && (streamDuration ?? 0) > 0) {
return Math.max(0, streamDuration ?? 0);
const safeDuration =
Number.isFinite(duration) && (duration ?? 0) > 0 ? Math.max(0, duration ?? 0) : 0;
const safeStreamDuration =
Number.isFinite(streamDuration) && (streamDuration ?? 0) > 0
? Math.max(0, streamDuration ?? 0)
: 0;
if (safeDuration > 0 && safeStreamDuration > 0) {
const gapSeconds = safeDuration - safeStreamDuration;
const largeMismatchThresholdSeconds = Math.max(2, safeDuration * 0.1);
if (gapSeconds > largeMismatchThresholdSeconds) {
return safeDuration;
}
return safeStreamDuration;
}
if (Number.isFinite(duration) && (duration ?? 0) > 0) {
return Math.max(0, duration ?? 0);
if (safeStreamDuration > 0) {
return safeStreamDuration;
}
if (safeDuration > 0) {
return safeDuration;
}
return 0;