Skip to content

Commit adedcff

Browse files
committed
fix: preserve negative-start loop and held tails
1 parent 297a394 commit adedcff

5 files changed

Lines changed: 281 additions & 35 deletions

File tree

packages/engine/src/services/videoFrameExtractor.test.ts

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,46 @@ describe("resolveVideoExtractionDuration", () => {
101101
);
102102
});
103103

104+
it("preserves a short source cycle when negative preroll crosses a loop boundary", () => {
105+
expect(
106+
resolveVideoExtractionWindow(
107+
video({ start: -5, end: 10, mediaStart: 0, loop: true }),
108+
metadata(3),
109+
2,
110+
),
111+
).toEqual({
112+
compositionStart: -5,
113+
mediaStart: 0,
114+
durationSeconds: 3,
115+
preserveTimelinePhase: true,
116+
});
117+
});
118+
119+
it("preserves source frames needed to hold a non-loop final frame", () => {
120+
expect(
121+
resolveVideoExtractionWindow(
122+
video({ start: -5, end: 10, mediaStart: 0, loop: false }),
123+
metadata(3),
124+
2,
125+
),
126+
).toEqual({
127+
compositionStart: -5,
128+
mediaStart: 0,
129+
durationSeconds: 3,
130+
preserveTimelinePhase: true,
131+
});
132+
});
133+
134+
it("rebases a loop phase when the visible window stays within one cycle", () => {
135+
expect(
136+
resolveVideoExtractionWindow(
137+
video({ start: -5, end: 10, mediaStart: 0, loop: true }),
138+
metadata(3),
139+
0.5,
140+
),
141+
).toEqual({ compositionStart: 0, mediaStart: 2, durationSeconds: 0.5 });
142+
});
143+
104144
it("retains legacy behavior when no timeline end is supplied", () => {
105145
expect(resolveVideoExtractionDuration(video(), metadata(60))).toBe(60);
106146
});
@@ -1100,6 +1140,64 @@ describe.skipIf(!HAS_FFMPEG)("extractAllVideoFrames on a VFR source", () => {
11001140
expect(result).toMatchObject({ success: true, extracted: [], errors: [] });
11011141
});
11021142

1143+
it("preserves loop phase when negative preroll crosses the source boundary", async () => {
1144+
const outputDir = join(FIXTURE_DIR, "out-negative-loop");
1145+
mkdirSync(outputDir, { recursive: true });
1146+
const video: VideoElement = {
1147+
id: "negative-loop",
1148+
src: VFR_FIXTURE,
1149+
start: -19,
1150+
end: 5,
1151+
mediaStart: 0,
1152+
loop: true,
1153+
hasAudio: false,
1154+
};
1155+
1156+
const result = await extractAllVideoFrames([video], FIXTURE_DIR, {
1157+
fps: 1,
1158+
outputDir,
1159+
timelineEnd: 2,
1160+
});
1161+
1162+
expect(result.errors).toEqual([]);
1163+
const extracted = result.extracted[0];
1164+
if (!extracted) throw new Error("expected loop source frames");
1165+
const lookup = createFrameLookupTable([video], result.extracted);
1166+
expect(video).toMatchObject({ start: -19, mediaStart: 0, loop: true });
1167+
expect(lookup.getFrame("negative-loop", 0)).toBe(
1168+
extracted.framePaths.get(extracted.totalFrames - 1),
1169+
);
1170+
}, 30_000);
1171+
1172+
it("preserves the held final frame after negative preroll exhausts a source", async () => {
1173+
const outputDir = join(FIXTURE_DIR, "out-negative-held-tail");
1174+
mkdirSync(outputDir, { recursive: true });
1175+
const video: VideoElement = {
1176+
id: "negative-held-tail",
1177+
src: VFR_FIXTURE,
1178+
start: -15,
1179+
end: 5,
1180+
mediaStart: 0,
1181+
loop: false,
1182+
hasAudio: false,
1183+
};
1184+
1185+
const result = await extractAllVideoFrames([video], FIXTURE_DIR, {
1186+
fps: 1,
1187+
outputDir,
1188+
timelineEnd: 2,
1189+
});
1190+
1191+
expect(result.errors).toEqual([]);
1192+
const extracted = result.extracted[0];
1193+
if (!extracted) throw new Error("expected held-tail source frames");
1194+
const lookup = createFrameLookupTable([video], result.extracted);
1195+
expect(video).toMatchObject({ start: -15, mediaStart: 0, loop: false });
1196+
expect(lookup.getFrame("negative-held-tail", 0)).toBe(
1197+
extracted.framePaths.get(extracted.totalFrames - 1),
1198+
);
1199+
}, 30_000);
1200+
11031201
it("detects the synthesized fixture as VFR", async () => {
11041202
const md = await extractVideoMetadata(VFR_FIXTURE);
11051203
expect(md.isVFR).toBe(true);

packages/engine/src/services/videoFrameExtractor.ts

Lines changed: 51 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -738,13 +738,24 @@ export interface TimelineExtractionWindow {
738738
compositionStart: number;
739739
mediaStart: number;
740740
durationSeconds: number;
741+
/**
742+
* Preserve the authored timeline origin and mediaStart for lookup. This is
743+
* required when a negative preroll crosses a source boundary: looped media
744+
* still needs its modulo phase, while a non-looping authored slot still
745+
* needs the extracted final frame for held-tail playback.
746+
*/
747+
preserveTimelinePhase?: boolean;
741748
}
742749

750+
type TimelineWindowVideo = Pick<VideoElement, "start" | "end" | "mediaStart"> &
751+
Partial<Pick<VideoElement, "loop">>;
752+
743753
/** Intersect a resolved source interval with the render timeline. */
744754
export function resolveTimelineExtractionWindow(
745-
video: Pick<VideoElement, "start" | "end" | "mediaStart">,
755+
video: TimelineWindowVideo,
746756
resolvedDuration: number,
747757
timelineEnd?: number,
758+
sourceDuration?: number,
748759
): TimelineExtractionWindow {
749760
if (timelineEnd === undefined) {
750761
return {
@@ -762,16 +773,41 @@ export function resolveTimelineExtractionWindow(
762773
0,
763774
Math.min(resolvedDuration - trimmedPreroll, timelineEnd - compositionStart),
764775
);
776+
let mediaStart = video.mediaStart + trimmedPreroll;
777+
if (durationSeconds > 0 && sourceDuration !== undefined) {
778+
const sourceRemaining = Math.max(0, sourceDuration - video.mediaStart);
779+
const prerollCrossesSourceEnd = trimmedPreroll >= sourceRemaining;
780+
if (sourceRemaining > 0 && video.loop && trimmedPreroll > 0) {
781+
const phaseOffset = trimmedPreroll % sourceRemaining;
782+
const phaseRemaining = sourceRemaining - phaseOffset;
783+
if (durationSeconds > phaseRemaining) {
784+
return {
785+
compositionStart: video.start,
786+
mediaStart: video.mediaStart,
787+
durationSeconds: sourceRemaining,
788+
preserveTimelinePhase: true,
789+
};
790+
}
791+
mediaStart = video.mediaStart + phaseOffset;
792+
} else if (sourceRemaining > 0 && prerollCrossesSourceEnd) {
793+
return {
794+
compositionStart: video.start,
795+
mediaStart: video.mediaStart,
796+
durationSeconds: sourceRemaining,
797+
preserveTimelinePhase: true,
798+
};
799+
}
800+
}
765801
return {
766802
compositionStart,
767-
mediaStart: video.mediaStart + trimmedPreroll,
803+
mediaStart,
768804
durationSeconds,
769805
};
770806
}
771807

772808
/** Resolve source duration first, then intersect it with the render timeline. */
773809
export function resolveVideoExtractionWindow(
774-
video: Pick<VideoElement, "start" | "end" | "mediaStart">,
810+
video: TimelineWindowVideo,
775811
metadata: VideoMetadata,
776812
timelineEnd?: number,
777813
): TimelineExtractionWindow {
@@ -780,11 +816,16 @@ export function resolveVideoExtractionWindow(
780816
video.mediaStart,
781817
metadata,
782818
);
783-
return resolveTimelineExtractionWindow(video, resolvedDuration, timelineEnd);
819+
return resolveTimelineExtractionWindow(
820+
video,
821+
resolvedDuration,
822+
timelineEnd,
823+
metadata.durationSeconds,
824+
);
784825
}
785826

786827
export function resolveVideoExtractionDuration(
787-
video: Pick<VideoElement, "start" | "end" | "mediaStart">,
828+
video: TimelineWindowVideo,
788829
metadata: VideoMetadata,
789830
timelineEnd?: number,
790831
): number {
@@ -1590,9 +1631,11 @@ export async function extractAllVideoFrames(
15901631
if (videoDuration <= 0) {
15911632
return { skipped: true };
15921633
}
1593-
video.start = window.compositionStart;
1594-
video.end = window.compositionStart + videoDuration;
1595-
video.mediaStart = window.mediaStart;
1634+
if (!window.preserveTimelinePhase) {
1635+
video.start = window.compositionStart;
1636+
video.end = window.compositionStart + videoDuration;
1637+
video.mediaStart = window.mediaStart;
1638+
}
15961639
const keyInput = cacheKeyInputs[index];
15971640
if (keyInput) keyInput.mediaStart = window.mediaStart;
15981641

packages/producer/src/services/hdrCompositor.ts

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,8 @@ export interface HdrVideoFrameSource {
139139
frameSize: number;
140140
frameCount: number;
141141
scratch: Buffer;
142+
/** The raw file contains one playable source cycle and must wrap at EOF. */
143+
loop?: boolean;
142144
}
143145

144146
export function closeHdrVideoFrameSource(source: HdrVideoFrameSource, log?: ProducerLogger): void {
@@ -152,6 +154,18 @@ export function closeHdrVideoFrameSource(source: HdrVideoFrameSource, log?: Prod
152154
}
153155
}
154156

157+
export function resolveHdrVideoFrameIndex(
158+
time: number,
159+
startTime: number,
160+
fps: number,
161+
frameCount: number,
162+
loop = false,
163+
): number | null {
164+
const frameIndex = Math.round((time - startTime) * fps);
165+
if (frameIndex < 0 || frameCount < 1) return null;
166+
return loop ? frameIndex % frameCount : Math.min(frameIndex, frameCount - 1);
167+
}
168+
155169
// fallow-ignore-next-line complexity
156170
export function blitHdrVideoLayer(
157171
canvas: Buffer,
@@ -173,14 +187,18 @@ export function blitHdrVideoLayer(
173187
return;
174188
}
175189

176-
// Frame index within the video. Clamp to the extracted raw frame count so
177-
// a composition that outlives the source clip freezes on the last frame,
178-
// matching Chrome's <video> behavior.
179-
const videoFrameIndex = Math.round((time - startTime) * fps) + 1;
180-
if (videoFrameIndex < 1) return;
181-
const effectiveIndex = Math.min(videoFrameIndex, frameSource.frameCount);
182-
if (effectiveIndex < 1) return;
183-
const frameOffset = (effectiveIndex - 1) * frameSource.frameSize;
190+
// Frame index within the extracted playable source range. Loops wrap one
191+
// extracted cycle; non-loops clamp to its final frame, matching Chrome's
192+
// held-tail behavior for authored slots that outlive the source.
193+
const effectiveIndex = resolveHdrVideoFrameIndex(
194+
time,
195+
startTime,
196+
fps,
197+
frameSource.frameCount,
198+
frameSource.loop,
199+
);
200+
if (effectiveIndex === null) return;
201+
const frameOffset = effectiveIndex * frameSource.frameSize;
184202

185203
try {
186204
if (hdrPerf) hdrPerf.hdrVideoLayerBlits += 1;

0 commit comments

Comments
 (0)