fix exporter
This commit is contained in:
@@ -5,6 +5,7 @@ export interface DecodedVideoInfo {
|
||||
width: number;
|
||||
height: number;
|
||||
duration: number; // seconds
|
||||
streamDuration?: number; // seconds
|
||||
frameRate: number;
|
||||
codec: string;
|
||||
hasAudio: boolean;
|
||||
@@ -23,7 +24,7 @@ type OnFrameCallback = (
|
||||
* Way faster than seeking an HTMLVideoElement per frame.
|
||||
*
|
||||
* Frames in trimmed regions are decoded (needed for P/B-frame state) but discarded.
|
||||
* Non-trimmed frames get buffered per segment and resampled to the target frame rate.
|
||||
* Kept frames are resampled to the target frame rate in a streaming pass.
|
||||
*/
|
||||
export class StreamingVideoDecoder {
|
||||
private demuxer: WebDemuxer | null = null;
|
||||
@@ -61,6 +62,10 @@ export class StreamingVideoDecoder {
|
||||
width: videoStream?.width || 1920,
|
||||
height: videoStream?.height || 1080,
|
||||
duration: mediaInfo.duration,
|
||||
streamDuration:
|
||||
typeof videoStream?.duration === "number" && Number.isFinite(videoStream.duration)
|
||||
? videoStream.duration
|
||||
: undefined,
|
||||
frameRate,
|
||||
codec: videoStream?.codec_string || "unknown",
|
||||
hasAudio: !!audioStream,
|
||||
@@ -81,11 +86,17 @@ export class StreamingVideoDecoder {
|
||||
}
|
||||
|
||||
const decoderConfig = await this.demuxer.getDecoderConfig("video");
|
||||
const codec = this.metadata.codec.toLowerCase();
|
||||
const shouldPreferSoftwareDecode = codec.includes("av01") || codec.includes("av1");
|
||||
const segments = this.splitBySpeed(
|
||||
this.computeSegments(this.metadata.duration, trimRegions),
|
||||
speedRegions,
|
||||
);
|
||||
const segmentOutputFrameCounts = segments.map((segment) =>
|
||||
Math.ceil(((segment.endSec - segment.startSec) / segment.speed) * targetFrameRate),
|
||||
);
|
||||
const frameDurationUs = 1_000_000 / targetFrameRate;
|
||||
const epsilonSec = 0.001;
|
||||
|
||||
// Async frame queue — decoder pushes, consumer pulls
|
||||
const pendingFrames: VideoFrame[] = [];
|
||||
@@ -112,7 +123,22 @@ export class StreamingVideoDecoder {
|
||||
}
|
||||
},
|
||||
});
|
||||
this.decoder.configure(decoderConfig);
|
||||
const preferredDecoderConfig = shouldPreferSoftwareDecode
|
||||
? {
|
||||
...decoderConfig,
|
||||
hardwareAcceleration: "prefer-software" as const,
|
||||
}
|
||||
: decoderConfig;
|
||||
|
||||
try {
|
||||
this.decoder.configure(preferredDecoderConfig);
|
||||
} catch (error) {
|
||||
if (!shouldPreferSoftwareDecode) {
|
||||
throw error;
|
||||
}
|
||||
// Fall back to default decoder config if software preference isn't supported.
|
||||
this.decoder.configure(decoderConfig);
|
||||
}
|
||||
|
||||
const getNextFrame = (): Promise<VideoFrame | null> => {
|
||||
if (decodeError) throw decodeError;
|
||||
@@ -123,8 +149,10 @@ export class StreamingVideoDecoder {
|
||||
});
|
||||
};
|
||||
|
||||
// One forward stream through the whole file
|
||||
const reader = this.demuxer.read("video").getReader();
|
||||
// One forward stream through the whole file.
|
||||
// Pass explicit range because some containers are truncated when no end is provided.
|
||||
const readEndSec = Math.max(this.metadata.duration, this.metadata.streamDuration ?? 0) + 0.5;
|
||||
const reader = this.demuxer.read("video", 0, readEndSec).getReader();
|
||||
|
||||
// Feed chunks to decoder in background with backpressure
|
||||
const feedPromise = (async () => {
|
||||
@@ -133,7 +161,11 @@ export class StreamingVideoDecoder {
|
||||
const { done, value: chunk } = await reader.read();
|
||||
if (done || !chunk) break;
|
||||
|
||||
while (this.decoder!.decodeQueueSize > 10 && !this.cancelled) {
|
||||
// Backpressure on both decode queue and decoded frame backlog.
|
||||
while (
|
||||
(this.decoder!.decodeQueueSize > 10 || pendingFrames.length > 24) &&
|
||||
!this.cancelled
|
||||
) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1));
|
||||
}
|
||||
if (this.cancelled) break;
|
||||
@@ -158,65 +190,131 @@ export class StreamingVideoDecoder {
|
||||
|
||||
// Route decoded frames into segments by timestamp, then deliver with VFR→CFR resampling
|
||||
let segmentIdx = 0;
|
||||
let segmentFrameIndex = 0;
|
||||
let exportFrameIndex = 0;
|
||||
let segmentBuffer: VideoFrame[] = [];
|
||||
let lastDecodedFrameSec: number | null = null;
|
||||
let heldFrame: VideoFrame | null = null;
|
||||
let heldFrameSec = 0;
|
||||
|
||||
const emitHeldFrameForTarget = async (segment: {
|
||||
startSec: number;
|
||||
endSec: number;
|
||||
speed: number;
|
||||
}) => {
|
||||
if (!heldFrame) return false;
|
||||
const segmentFrameCount = segmentOutputFrameCounts[segmentIdx];
|
||||
if (segmentFrameIndex >= segmentFrameCount) return false;
|
||||
|
||||
const sourceTimeSec =
|
||||
segment.startSec + (segmentFrameIndex / targetFrameRate) * segment.speed;
|
||||
if (sourceTimeSec >= segment.endSec - epsilonSec) return false;
|
||||
|
||||
const clone = new VideoFrame(heldFrame, { timestamp: heldFrame.timestamp });
|
||||
await onFrame(clone, exportFrameIndex * frameDurationUs, sourceTimeSec * 1000);
|
||||
segmentFrameIndex++;
|
||||
exportFrameIndex++;
|
||||
return true;
|
||||
};
|
||||
|
||||
while (!this.cancelled && segmentIdx < segments.length) {
|
||||
const frame = await getNextFrame();
|
||||
if (!frame) break;
|
||||
|
||||
const frameTimeSec = frame.timestamp / 1_000_000;
|
||||
const currentSegment = segments[segmentIdx];
|
||||
lastDecodedFrameSec = frameTimeSec;
|
||||
|
||||
// Before current segment — trimmed or pre-video
|
||||
if (frameTimeSec < currentSegment.startSec - 0.001) {
|
||||
// Finalize completed segments before handling this frame.
|
||||
while (
|
||||
segmentIdx < segments.length &&
|
||||
frameTimeSec >= segments[segmentIdx].endSec - epsilonSec
|
||||
) {
|
||||
const segment = segments[segmentIdx];
|
||||
while (!this.cancelled && (await emitHeldFrameForTarget(segment))) {
|
||||
// Keep emitting remaining output frames for this segment from the last known frame.
|
||||
}
|
||||
|
||||
segmentIdx++;
|
||||
segmentFrameIndex = 0;
|
||||
if (
|
||||
heldFrame &&
|
||||
segmentIdx < segments.length &&
|
||||
heldFrameSec < segments[segmentIdx].startSec - epsilonSec
|
||||
) {
|
||||
heldFrame.close();
|
||||
heldFrame = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (segmentIdx >= segments.length) {
|
||||
frame.close();
|
||||
continue;
|
||||
}
|
||||
|
||||
// Past current segment — flush buffer and advance
|
||||
if (frameTimeSec >= currentSegment.endSec - 0.001) {
|
||||
exportFrameIndex = await this.deliverSegment(
|
||||
segmentBuffer,
|
||||
currentSegment,
|
||||
targetFrameRate,
|
||||
frameDurationUs,
|
||||
exportFrameIndex,
|
||||
onFrame,
|
||||
);
|
||||
for (const f of segmentBuffer) f.close();
|
||||
segmentBuffer = [];
|
||||
const currentSegment = segments[segmentIdx];
|
||||
|
||||
segmentIdx++;
|
||||
while (
|
||||
segmentIdx < segments.length &&
|
||||
frameTimeSec >= segments[segmentIdx].endSec - 0.001
|
||||
) {
|
||||
segmentIdx++;
|
||||
}
|
||||
|
||||
if (segmentIdx < segments.length && frameTimeSec >= segments[segmentIdx].startSec - 0.001) {
|
||||
segmentBuffer.push(frame);
|
||||
} else {
|
||||
frame.close();
|
||||
}
|
||||
// Before current segment (trimmed region or pre-roll).
|
||||
if (frameTimeSec < currentSegment.startSec - epsilonSec) {
|
||||
frame.close();
|
||||
continue;
|
||||
}
|
||||
|
||||
segmentBuffer.push(frame);
|
||||
if (!heldFrame) {
|
||||
heldFrame = frame;
|
||||
heldFrameSec = frameTimeSec;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Any target timestamp before this midpoint is closer to heldFrame than current frame.
|
||||
const handoffBoundarySec = (heldFrameSec + frameTimeSec) / 2;
|
||||
while (!this.cancelled) {
|
||||
const segmentFrameCount = segmentOutputFrameCounts[segmentIdx];
|
||||
if (segmentFrameIndex >= segmentFrameCount) {
|
||||
break;
|
||||
}
|
||||
|
||||
const sourceTimeSec =
|
||||
currentSegment.startSec + (segmentFrameIndex / targetFrameRate) * currentSegment.speed;
|
||||
if (sourceTimeSec >= currentSegment.endSec - epsilonSec) {
|
||||
break;
|
||||
}
|
||||
if (sourceTimeSec > handoffBoundarySec) {
|
||||
break;
|
||||
}
|
||||
|
||||
const clone = new VideoFrame(heldFrame, { timestamp: heldFrame.timestamp });
|
||||
await onFrame(clone, exportFrameIndex * frameDurationUs, sourceTimeSec * 1000);
|
||||
segmentFrameIndex++;
|
||||
exportFrameIndex++;
|
||||
}
|
||||
|
||||
heldFrame.close();
|
||||
heldFrame = frame;
|
||||
heldFrameSec = frameTimeSec;
|
||||
}
|
||||
|
||||
// Flush last segment
|
||||
if (segmentBuffer.length > 0 && segmentIdx < segments.length) {
|
||||
exportFrameIndex = await this.deliverSegment(
|
||||
segmentBuffer,
|
||||
segments[segmentIdx],
|
||||
targetFrameRate,
|
||||
frameDurationUs,
|
||||
exportFrameIndex,
|
||||
onFrame,
|
||||
);
|
||||
for (const f of segmentBuffer) f.close();
|
||||
// Flush remaining output frames for the last decoded frame.
|
||||
if (heldFrame && segmentIdx < segments.length) {
|
||||
while (!this.cancelled && segmentIdx < segments.length) {
|
||||
const segment = segments[segmentIdx];
|
||||
if (heldFrameSec < segment.startSec - epsilonSec) {
|
||||
break;
|
||||
}
|
||||
|
||||
while (!this.cancelled && (await emitHeldFrameForTarget(segment))) {
|
||||
// Keep emitting output frames for the active segment.
|
||||
}
|
||||
|
||||
segmentIdx++;
|
||||
segmentFrameIndex = 0;
|
||||
if (
|
||||
segmentIdx < segments.length &&
|
||||
heldFrameSec < segments[segmentIdx].startSec - epsilonSec
|
||||
) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
heldFrame.close();
|
||||
heldFrame = null;
|
||||
}
|
||||
|
||||
// Drain leftover decoded frames
|
||||
@@ -239,39 +337,17 @@ export class StreamingVideoDecoder {
|
||||
this.decoder.close();
|
||||
}
|
||||
this.decoder = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resample buffered frames to fill the target frame count for this segment.
|
||||
* Handles VFR sources by duplicating/decimating as needed.
|
||||
*/
|
||||
private async deliverSegment(
|
||||
frames: VideoFrame[],
|
||||
segment: { startSec: number; endSec: number; speed: number },
|
||||
targetFrameRate: number,
|
||||
frameDurationUs: number,
|
||||
startExportFrameIndex: number,
|
||||
onFrame: OnFrameCallback,
|
||||
): Promise<number> {
|
||||
if (frames.length === 0) return startExportFrameIndex;
|
||||
|
||||
const segmentFrameCount = Math.ceil(
|
||||
((segment.endSec - segment.startSec) / segment.speed) * targetFrameRate,
|
||||
);
|
||||
let exportFrameIndex = startExportFrameIndex;
|
||||
|
||||
for (let i = 0; i < segmentFrameCount && !this.cancelled; i++) {
|
||||
const sourceIdx = Math.min(
|
||||
Math.floor((i * frames.length) / segmentFrameCount),
|
||||
frames.length - 1,
|
||||
const requiredEndSec = segments.length > 0 ? segments[segments.length - 1].endSec : 0;
|
||||
if (
|
||||
!this.cancelled &&
|
||||
lastDecodedFrameSec !== null &&
|
||||
requiredEndSec - lastDecodedFrameSec > 1
|
||||
) {
|
||||
throw new Error(
|
||||
`Video decode ended early at ${lastDecodedFrameSec.toFixed(3)}s (needed ${requiredEndSec.toFixed(3)}s).`,
|
||||
);
|
||||
const sourceFrame = frames[sourceIdx];
|
||||
const clone = new VideoFrame(sourceFrame, { timestamp: sourceFrame.timestamp });
|
||||
await onFrame(clone, exportFrameIndex * frameDurationUs, sourceFrame.timestamp / 1000);
|
||||
exportFrameIndex++;
|
||||
}
|
||||
|
||||
return exportFrameIndex;
|
||||
}
|
||||
|
||||
private computeSegments(
|
||||
@@ -364,7 +440,9 @@ export class StreamingVideoDecoder {
|
||||
if (this.demuxer) {
|
||||
try {
|
||||
this.demuxer.destroy();
|
||||
} catch {}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
this.demuxer = null;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user