diff --git a/src/lib/exporter/gifExporter.ts b/src/lib/exporter/gifExporter.ts index 747e34e..58ed693 100644 --- a/src/lib/exporter/gifExporter.ts +++ b/src/lib/exporter/gifExporter.ts @@ -174,11 +174,11 @@ export class GifExporter { }); // Calculate effective duration and frame count (excluding trim regions) - const effectiveDuration = this.streamingDecoder.getEffectiveDuration( + const { effectiveDuration, totalFrames } = this.streamingDecoder.getExportMetrics( + this.config.frameRate, this.config.trimRegions, this.config.speedRegions, ); - const totalFrames = Math.ceil(effectiveDuration * this.config.frameRate); // Calculate frame delay in milliseconds (gif.js uses ms) const frameDelay = Math.round(1000 / this.config.frameRate); diff --git a/src/lib/exporter/streamingDecoder.ts b/src/lib/exporter/streamingDecoder.ts index ee67576..651a557 100644 --- a/src/lib/exporter/streamingDecoder.ts +++ b/src/lib/exporter/streamingDecoder.ts @@ -2,7 +2,7 @@ import { WebDemuxer } from "web-demuxer"; import type { SpeedRegion, TrimRegion } from "@/components/video-editor/types"; const SOURCE_LOAD_TIMEOUT_MS = 60_000; - +const EPSILON_SEC = 0.001; /** * Build a full WebCodecs-compatible AV1 codec string from the AV1CodecConfigurationRecord. * web-demuxer may return a bare "av01" when the WASM-side parser fails to read @@ -246,10 +246,11 @@ export class StreamingVideoDecoder { speedRegions, ); const segmentOutputFrameCounts = segments.map((segment) => - Math.ceil(((segment.endSec - segment.startSec) / segment.speed) * targetFrameRate), + Math.ceil( + ((segment.endSec - segment.startSec - EPSILON_SEC) / segment.speed) * targetFrameRate, + ), ); const frameDurationUs = 1_000_000 / targetFrameRate; - const epsilonSec = 0.001; // Async frame queue — decoder pushes, consumer pulls const pendingFrames: VideoFrame[] = []; @@ -360,7 +361,7 @@ export class StreamingVideoDecoder { const sourceTimeSec = segment.startSec + (segmentFrameIndex / targetFrameRate) * segment.speed; - if (sourceTimeSec >= segment.endSec - epsilonSec) return false; + if (sourceTimeSec >= segment.endSec - EPSILON_SEC) return false; const clone = new VideoFrame(heldFrame, { timestamp: heldFrame.timestamp }); await onFrame(clone, exportFrameIndex * frameDurationUs, sourceTimeSec * 1000); @@ -379,7 +380,7 @@ export class StreamingVideoDecoder { // Finalize completed segments before handling this frame. while ( segmentIdx < segments.length && - frameTimeSec >= segments[segmentIdx].endSec - epsilonSec + frameTimeSec >= segments[segmentIdx].endSec - EPSILON_SEC ) { const segment = segments[segmentIdx]; while (!this.cancelled && (await emitHeldFrameForTarget(segment))) { @@ -391,7 +392,7 @@ export class StreamingVideoDecoder { if ( heldFrame && segmentIdx < segments.length && - heldFrameSec < segments[segmentIdx].startSec - epsilonSec + heldFrameSec < segments[segmentIdx].startSec - EPSILON_SEC ) { heldFrame.close(); heldFrame = null; @@ -406,7 +407,7 @@ export class StreamingVideoDecoder { const currentSegment = segments[segmentIdx]; // Before current segment (trimmed region or pre-roll). - if (frameTimeSec < currentSegment.startSec - epsilonSec) { + if (frameTimeSec < currentSegment.startSec - EPSILON_SEC) { frame.close(); continue; } @@ -427,7 +428,7 @@ export class StreamingVideoDecoder { const sourceTimeSec = currentSegment.startSec + (segmentFrameIndex / targetFrameRate) * currentSegment.speed; - if (sourceTimeSec >= currentSegment.endSec - epsilonSec) { + if (sourceTimeSec >= currentSegment.endSec - EPSILON_SEC) { break; } if (sourceTimeSec > handoffBoundarySec) { @@ -449,7 +450,7 @@ export class StreamingVideoDecoder { if (heldFrame && segmentIdx < segments.length) { while (!this.cancelled && segmentIdx < segments.length) { const segment = segments[segmentIdx]; - if (heldFrameSec < segment.startSec - epsilonSec) { + if (heldFrameSec < segment.startSec - EPSILON_SEC) { break; } @@ -461,7 +462,7 @@ export class StreamingVideoDecoder { segmentFrameIndex = 0; if ( segmentIdx < segments.length && - heldFrameSec < segments[segmentIdx].startSec - epsilonSec + heldFrameSec < segments[segmentIdx].startSec - EPSILON_SEC ) { break; } @@ -536,11 +537,24 @@ export class StreamingVideoDecoder { return segments; } - getEffectiveDuration(trimRegions?: TrimRegion[], speedRegions?: SpeedRegion[]): number { + getExportMetrics( + targetFrameRate: number, + trimRegions?: TrimRegion[], + speedRegions?: SpeedRegion[], + ): { effectiveDuration: number; totalFrames: number } { if (!this.metadata) throw new Error("Must call loadMetadata() first"); const trimSegments = this.computeSegments(this.metadata.duration, trimRegions); - const speedSegments = this.splitBySpeed(trimSegments, speedRegions); - return speedSegments.reduce((sum, seg) => sum + (seg.endSec - seg.startSec) / seg.speed, 0); + const segments = this.splitBySpeed(trimSegments, speedRegions); + return { + effectiveDuration: segments.reduce( + (sum, seg) => sum + (seg.endSec - seg.startSec) / seg.speed, + 0, + ), + totalFrames: segments.reduce((sum, seg) => { + const segDur = seg.endSec - seg.startSec - EPSILON_SEC; + return sum + Math.max(0, Math.ceil((segDur / seg.speed) * targetFrameRate)); + }, 0), + }; } private splitBySpeed( diff --git a/src/lib/exporter/videoExporter.ts b/src/lib/exporter/videoExporter.ts index d0affd1..dcfcc3e 100644 --- a/src/lib/exporter/videoExporter.ts +++ b/src/lib/exporter/videoExporter.ts @@ -157,11 +157,11 @@ export class VideoExporter { this.muxer = muxer; await muxer.initialize(); - const effectiveDuration = streamingDecoder.getEffectiveDuration( + const { effectiveDuration, totalFrames } = streamingDecoder.getExportMetrics( + this.config.frameRate, this.config.trimRegions, this.config.speedRegions, ); - const totalFrames = Math.ceil(effectiveDuration * this.config.frameRate); const readEndSec = Math.max(videoInfo.duration, videoInfo.streamDuration ?? 0) + 0.5; console.log("[VideoExporter] Original duration:", videoInfo.duration, "s");