diff --git a/electron/ipc/nativeVideoExport.test.ts b/electron/ipc/nativeVideoExport.test.ts index 1d0630d9..eefbad03 100644 --- a/electron/ipc/nativeVideoExport.test.ts +++ b/electron/ipc/nativeVideoExport.test.ts @@ -40,4 +40,19 @@ describe("buildEditedTrackSourceAudioFilter", () => { ), ).toBeNull(); }); + + it("returns null when the edited-track segments are malformed", () => { + expect( + buildEditedTrackSourceAudioFilter( + [{ startMs: Number.NaN, endMs: 2_000, speed: 1.5 }], + 44_100, + ), + ).toBeNull(); + expect( + buildEditedTrackSourceAudioFilter([{ startMs: 0, endMs: 2_000, speed: 0 }], 44_100), + ).toBeNull(); + expect( + buildEditedTrackSourceAudioFilter([{ startMs: 0, endMs: 2_000, speed: 1 }], 0.4), + ).toBeNull(); + }); }); diff --git a/electron/ipc/nativeVideoExport.ts b/electron/ipc/nativeVideoExport.ts index fd7435fa..0f59eddf 100644 --- a/electron/ipc/nativeVideoExport.ts +++ b/electron/ipc/nativeVideoExport.ts @@ -181,24 +181,46 @@ export function buildEditedTrackSourceAudioFilter( } const normalizedSourceSampleRate = Math.round(sourceSampleRate); + if (normalizedSourceSampleRate < 1) { + return null; + } + const filterParts: string[] = []; const segmentLabels: string[] = []; + let hasInvalidSegment = false; segments.forEach((segment, index) => { + if (!Number.isFinite(segment.startMs) || !Number.isFinite(segment.endMs)) { + hasInvalidSegment = true; + return; + } + if (segment.endMs - segment.startMs <= 0.5) { + hasInvalidSegment = true; return; } const label = `edited_audio_${index}`; - const speed = Number.isFinite(segment.speed) && segment.speed > 0 ? segment.speed : 1; + const speed = segment.speed; + if (!Number.isFinite(speed) || speed <= 0) { + hasInvalidSegment = true; + return; + } + const segmentFilter = [ `[1:a]atrim=start=${formatFfmpegSeconds(segment.startMs)}:end=${formatFfmpegSeconds(segment.endMs)}`, "asetpts=PTS-STARTPTS", ]; if (Math.abs(speed - 1) > 0.0001) { + const adjustedSampleRate = Math.round(normalizedSourceSampleRate * speed); + if (adjustedSampleRate < 1) { + hasInvalidSegment = true; + return; + } + segmentFilter.push( - `asetrate=${Math.max(1, Math.round(normalizedSourceSampleRate * speed))}`, + `asetrate=${adjustedSampleRate}`, `aresample=${normalizedSourceSampleRate}`, ); } @@ -207,7 +229,7 @@ export function buildEditedTrackSourceAudioFilter( segmentLabels.push(`[${label}]`); }); - if (segmentLabels.length === 0) { + if (hasInvalidSegment || segmentLabels.length === 0) { return null; } diff --git a/src/lib/exporter/editedTrackStrategy.test.ts b/src/lib/exporter/editedTrackStrategy.test.ts index d70dd83f..9201b533 100644 --- a/src/lib/exporter/editedTrackStrategy.test.ts +++ b/src/lib/exporter/editedTrackStrategy.test.ts @@ -100,6 +100,23 @@ describe("editedTrackStrategy", () => { ).toBe("offline-render-fallback"); }); + it("falls back when source duration or speed-region bounds are invalid", () => { + const invalidBounds: SpeedRegion[] = [ + { id: "speed-1", startMs: Number.NaN, endMs: 10_000, speed: 1.5 }, + ]; + + expect( + classifyEditedTrackStrategy({ + primaryAudioSourcePath: "recording.mp4", + sourceDurationMs: Number.POSITIVE_INFINITY, + trimRegions: EMPTY_TRIMS, + speedRegions: invalidBounds, + audioRegions: [], + sourceAudioFallbackPaths: [], + }), + ).toBe("offline-render-fallback"); + }); + it("builds source segments that preserve trims and speed boundaries", () => { const trimRegions: TrimRegion[] = [{ id: "trim-1", startMs: 10_000, endMs: 12_000 }]; const speedRegions: SpeedRegion[] = [ @@ -118,4 +135,12 @@ describe("editedTrackStrategy", () => { { startMs: 18_000, endMs: 20_000, speed: 1 }, ]); }); + + it("returns no source segments when speed-region bounds are invalid", () => { + expect( + buildEditedTrackSourceSegments(SOURCE_DURATION_MS, EMPTY_TRIMS, [ + { id: "speed-1", startMs: 15_000, endMs: 10_000, speed: 1.5 }, + ]), + ).toEqual([]); + }); }); diff --git a/src/lib/exporter/editedTrackStrategy.ts b/src/lib/exporter/editedTrackStrategy.ts index 52a10783..5bee89ee 100644 --- a/src/lib/exporter/editedTrackStrategy.ts +++ b/src/lib/exporter/editedTrackStrategy.ts @@ -26,8 +26,29 @@ function isSafeFiltergraphSpeed(speed: number): boolean { ); } -function hasSafeFiltergraphSpeedRegions(speedRegions: SpeedRegion[]): boolean { - return speedRegions.every((region) => isSafeFiltergraphSpeed(region.speed)); +function hasFiniteTimelineRange(startMs: number, endMs: number, sourceDurationMs: number): boolean { + return ( + Number.isFinite(startMs) && + Number.isFinite(endMs) && + startMs >= 0 && + endMs > startMs && + endMs <= sourceDurationMs + ); +} + +function hasSafeFiltergraphSpeedRegions( + speedRegions: SpeedRegion[], + sourceDurationMs: number, +): boolean { + if (!Number.isFinite(sourceDurationMs) || sourceDurationMs <= 0) { + return false; + } + + return speedRegions.every( + (region) => + hasFiniteTimelineRange(region.startMs, region.endMs, sourceDurationMs) && + isSafeFiltergraphSpeed(region.speed), + ); } function buildKeptRanges( @@ -64,6 +85,20 @@ export function buildEditedTrackSourceSegments( trimRegions: TrimRegion[], speedRegions: SpeedRegion[], ): EditedTrackSourceSegment[] { + if (!Number.isFinite(sourceDurationMs) || sourceDurationMs <= 0) { + return []; + } + + if ( + speedRegions.some( + (region) => + !hasFiniteTimelineRange(region.startMs, region.endMs, sourceDurationMs) || + !isSafeFiltergraphSpeed(region.speed), + ) + ) { + return []; + } + const segments: EditedTrackSourceSegment[] = []; const keptRanges = buildKeptRanges(sourceDurationMs, trimRegions); @@ -119,7 +154,7 @@ export function classifyEditedTrackStrategy(input: EditedTrackStrategyInput): Ed return "offline-render-fallback"; } - if (!hasSafeFiltergraphSpeedRegions(input.speedRegions)) { + if (!hasSafeFiltergraphSpeedRegions(input.speedRegions, input.sourceDurationMs)) { return "offline-render-fallback"; }