fix(export): keep native video path with offline audio

This commit is contained in:
wiiiii123
2026-05-06 18:40:31 +07:00
parent 8fc47d3e31
commit ec6fb0d05d
2 changed files with 118 additions and 49 deletions
@@ -40,6 +40,7 @@ function createExporter(overrides: Record<string, unknown> = {}) {
...overrides,
} as never) as unknown as {
buildNativeAudioPlan: (videoInfo: DecodedVideoInfo) => unknown;
getNativeStaticLayoutEffectiveDuration: (videoInfo: DecodedVideoInfo) => number;
getNativeStaticLayoutSkipReason: (
audioPlan: unknown,
videoInfo: DecodedVideoInfo,
@@ -182,7 +183,16 @@ describe("ModernVideoExporter native static-layout eligibility", () => {
).toBe("unsupported-background-blur");
});
it("rejects native static-layout when speed edits do not have a native timeline map", () => {
it("uses speed timeline duration during native static-layout preflight", () => {
const speedRegions: SpeedRegion[] = [
{ id: "speed-1", startMs: 1_000, endMs: 4_000, speed: 1.5 },
];
const exporter = createExporter({ speedRegions });
expect(exporter.getNativeStaticLayoutEffectiveDuration(videoInfo)).toBeCloseTo(59, 3);
});
it("allows native speed timelines when audio needs offline rendering", () => {
const speedRegions: SpeedRegion[] = [
{ id: "speed-1", startMs: 1_000, endMs: 4_000, speed: 1.5 },
];
@@ -197,6 +207,24 @@ describe("ModernVideoExporter native static-layout eligibility", () => {
videoInfo,
60,
),
).toBeNull();
});
it("rejects native static-layout when speed edits do not have a native timeline map", () => {
const speedRegions: SpeedRegion[] = [
{ id: "speed-1", startMs: 1_000, endMs: 4_000, speed: 3 },
];
const exporter = createExporter({ speedRegions });
expect(
exporter.getNativeStaticLayoutSkipReason(
{
audioMode: "edited-track",
strategy: "offline-render-fallback",
},
videoInfo,
58,
),
).toBe("unsupported-native-speed-timeline");
});
@@ -250,6 +278,30 @@ describe("ModernVideoExporter native static-layout eligibility", () => {
).toBeNull();
});
it("allows slow-speed native timelines with webcam when audio renders offline", () => {
const speedRegions: SpeedRegion[] = [
{ id: "speed-1", startMs: 1_000, endMs: 4_000, speed: 0.5 },
];
const exporter = createExporter({
speedRegions,
webcam: {
enabled: true,
sourcePath: "C:\\recordly\\webcam.mp4",
},
});
expect(
exporter.getNativeStaticLayoutSkipReason(
{
audioMode: "edited-track",
strategy: "offline-render-fallback",
},
videoInfo,
63,
),
).toBeNull();
});
it("allows native speed timelines with a resolvable webcam source", () => {
const speedRegions: SpeedRegion[] = [
{ id: "speed-1", startMs: 1_000, endMs: 4_000, speed: 1.5 },
+65 -48
View File
@@ -436,8 +436,7 @@ export class ModernVideoExporter {
const shouldUseFfmpegAudioFallback =
!useNativeEncoder &&
nativeAudioPlan.audioMode !== "none" &&
(shouldUsePitchPreservingFfmpegAudio ||
!(await isAacAudioEncodingSupported()));
(shouldUsePitchPreservingFfmpegAudio || !(await isAacAudioEncodingSupported()));
const effectiveDuration = this.streamingDecoder.getEffectiveDuration(
this.config.trimRegions,
this.config.speedRegions,
@@ -976,13 +975,45 @@ export class ModernVideoExporter {
duration: videoInfo.duration,
streamDuration: videoInfo.streamDuration,
});
const trimSegments = this.buildNativeTrimSegments(sourceDurationSec * 1000);
const sourceDurationMs = sourceDurationSec * 1000;
const speedRegions = this.config.speedRegions ?? [];
if (speedRegions.length > 0) {
const timelineSegments = this.buildNativeStaticLayoutSourceSegments(sourceDurationMs);
if (timelineSegments.length > 0) {
return timelineSegments.reduce(
(totalSec, segment) =>
totalSec + (segment.endMs - segment.startMs) / segment.speed / 1000,
0,
);
}
}
const trimSegments = this.buildNativeTrimSegments(sourceDurationMs);
return trimSegments.reduce(
(totalSec, segment) => totalSec + (segment.endMs - segment.startMs) / 1000,
0,
);
}
private buildNativeStaticLayoutSourceSegments(sourceDurationMs: number) {
return buildEditedTrackSourceSegments(
sourceDurationMs,
this.config.trimRegions ?? [],
this.config.speedRegions ?? [],
);
}
private buildNativeStaticLayoutVideoTimelineSegments(
videoInfo: DecodedVideoInfo,
): NativeStaticLayoutTimelineSegment[] {
const sourceDurationMs = Math.max(
0,
Math.round((videoInfo.streamDuration ?? videoInfo.duration) * 1000),
);
const sourceSegments = this.buildNativeStaticLayoutSourceSegments(sourceDurationMs);
return buildNativeStaticLayoutTimelineSegments(sourceSegments);
}
private buildNativeAudioPlan(videoInfo: DecodedVideoInfo): NativeAudioPlan {
const speedRegions = this.config.speedRegions ?? [];
const audioRegions = this.config.audioRegions ?? [];
@@ -1031,17 +1062,16 @@ export class ModernVideoExporter {
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;
@@ -1185,11 +1215,7 @@ export class ModernVideoExporter {
const hasZoomRegions = (this.config.zoomRegions ?? []).length > 0;
if (
speedRegions.length > 0 &&
!(
audioPlan.audioMode === "edited-track" &&
audioPlan.strategy === "filtergraph-fast-path" &&
audioPlan.editedTrackSegments.length > 0
)
this.buildNativeStaticLayoutVideoTimelineSegments(videoInfo).length === 0
) {
return "unsupported-native-speed-timeline";
}
@@ -1637,10 +1663,7 @@ export class ModernVideoExporter {
}
const sourcePath = this.getNativeVideoSourcePath();
const audioOptions = await this.getNativeStaticLayoutAudioOptions(
audioPlan,
totalFrames,
);
const audioOptions = await this.getNativeStaticLayoutAudioOptions(audioPlan, totalFrames);
if (!sourcePath || !audioOptions) {
this.nativeStaticLayoutSkipReason = !sourcePath
? "missing-source-path"
@@ -1701,10 +1724,8 @@ export class ModernVideoExporter {
cursorTelemetry,
);
const timelineSegments =
(this.config.speedRegions ?? []).length > 0 &&
audioPlan.audioMode === "edited-track" &&
audioPlan.strategy === "filtergraph-fast-path"
? buildNativeStaticLayoutTimelineSegments(audioPlan.editedTrackSegments)
(this.config.speedRegions ?? []).length > 0
? this.buildNativeStaticLayoutVideoTimelineSegments(videoInfo)
: undefined;
if ((this.config.speedRegions ?? []).length > 0 && !timelineSegments?.length) {
this.nativeStaticLayoutSkipReason = "invalid-native-speed-timeline";
@@ -1789,10 +1810,7 @@ export class ModernVideoExporter {
const progressPercentFrame = Number.isFinite(progress.percentage)
? Math.floor((nativeTotalFrames * progress.percentage) / 100)
: 0;
const nativeCurrentFrame = Math.max(
rawNativeCurrentFrame,
progressPercentFrame,
);
const nativeCurrentFrame = Math.max(rawNativeCurrentFrame, progressPercentFrame);
const nativeFramesComplete = nativeCurrentFrame >= nativeTotalFrames;
const nativeFinalizingProgress =
progress.stage === "finalizing" && Number.isFinite(progress.percentage)
@@ -1805,7 +1823,9 @@ export class ModernVideoExporter {
0,
Math.min(
totalFrames - 1,
Math.floor(totalFrames * (NATIVE_STATIC_LAYOUT_MAX_EXTRACTING_PROGRESS / 100)),
Math.floor(
totalFrames * (NATIVE_STATIC_LAYOUT_MAX_EXTRACTING_PROGRESS / 100),
),
),
);
const currentFrame = Math.min(
@@ -1816,20 +1836,17 @@ export class ModernVideoExporter {
progress.stage === "finalizing"
? null
: typeof progress.instantFps === "number" &&
Number.isFinite(progress.instantFps) &&
progress.instantFps > 0
? progress.instantFps
: typeof progress.averageFps === "number" &&
Number.isFinite(progress.averageFps) &&
progress.averageFps > 0
? progress.averageFps
: null;
Number.isFinite(progress.instantFps) &&
progress.instantFps > 0
? progress.instantFps
: typeof progress.averageFps === "number" &&
Number.isFinite(progress.averageFps) &&
progress.averageFps > 0
? progress.averageFps
: null;
this.processedFrameCount = currentFrame;
if (progress.stage === "finalizing" || nativeFramesComplete) {
this.reportFinalizingProgress(
totalFrames,
nativeFinalizingProgress,
);
this.reportFinalizingProgress(totalFrames, nativeFinalizingProgress);
} else {
this.reportProgress(currentFrame, totalFrames, "extracting");
}
@@ -2443,10 +2460,10 @@ export class ModernVideoExporter {
phase === "preparing"
? 0
: phase === "finalizing"
? (safeRenderProgress ?? 100)
: totalFrames > 0
? (currentFrame / totalFrames) * 100
: 100;
? (safeRenderProgress ?? 100)
: totalFrames > 0
? (currentFrame / totalFrames) * 100
: 100;
if (nowMs - this.lastThroughputLogTimeMs >= 1000 || currentFrame === totalFrames) {
const safeFrameCount = Math.max(this.processedFrameCount, 1);