@@ -124,34 +124,67 @@ describe("resolveVideoExtractionDuration", () => {
124124 2 ,
125125 ) ,
126126 ) . toEqual ( {
127- compositionStart : - 5 ,
128- mediaStart : 0 ,
129- durationSeconds : 3 ,
130- preserveTimelinePhase : true ,
127+ compositionStart : - 3 ,
128+ mediaStart : 2 ,
129+ durationSeconds : 1 ,
130+ preserveTimelineEnd : true ,
131131 } ) ;
132132 } ) ;
133133
134134 it . each ( [
135- { loop : true , label : "loop" } ,
136- { loop : false , label : "held tail" } ,
137- ] ) ( "caps a finite long slot to one short source range for $label playback" , ( { loop } ) => {
138- expect ( resolveVideoExtractionWindow ( video ( { end : 60 , loop } ) , metadata ( 3 ) , 60 ) ) . toEqual ( {
135+ { loop : true , preservation : { preserveTimelinePhase : true } , label : "loop" } ,
136+ { loop : false , preservation : { preserveTimelineEnd : true } , label : "held tail" } ,
137+ ] ) (
138+ "caps a finite long slot to one short source range for $label playback" ,
139+ ( { loop, preservation } ) => {
140+ expect ( resolveVideoExtractionWindow ( video ( { end : 60 , loop } ) , metadata ( 3 ) , 60 ) ) . toEqual ( {
141+ compositionStart : 0 ,
142+ mediaStart : 0 ,
143+ durationSeconds : 3 ,
144+ ...preservation ,
145+ } ) ;
146+ } ,
147+ ) ;
148+
149+ it ( "preserves authored timing when the visible interval partially crosses a held tail" , ( ) => {
150+ expect (
151+ resolveVideoExtractionWindow (
152+ video ( { start : - 2 , end : 10 , mediaStart : 0 , loop : false } ) ,
153+ metadata ( 3 ) ,
154+ 2 ,
155+ ) ,
156+ ) . toEqual ( {
139157 compositionStart : 0 ,
140- mediaStart : 0 ,
141- durationSeconds : 3 ,
142- preserveTimelinePhase : true ,
158+ mediaStart : 2 ,
159+ durationSeconds : 1 ,
160+ preserveTimelineEnd : true ,
143161 } ) ;
144162 } ) ;
145163
146- it ( "preserves authored timing when the visible interval partially crosses a held tail " , ( ) => {
164+ it ( "bounds an entirely held long source to its final-frame sample " , ( ) => {
147165 expect (
148166 resolveVideoExtractionWindow (
149- video ( { start : - 2 , end : 10 , mediaStart : 0 , loop : false } ) ,
167+ video ( { start : - 600 , end : 10 , mediaStart : 0 , loop : false } ) ,
168+ metadata ( 120 ) ,
169+ 2 ,
170+ ) ,
171+ ) . toEqual ( {
172+ compositionStart : - 481 ,
173+ mediaStart : 119 ,
174+ durationSeconds : 1 ,
175+ preserveTimelineEnd : true ,
176+ } ) ;
177+ } ) ;
178+
179+ it ( "preserves a complete loop cycle when visibility ends exactly on a wrap boundary" , ( ) => {
180+ expect (
181+ resolveVideoExtractionWindow (
182+ video ( { start : - 1 , end : 10 , mediaStart : 0 , loop : true } ) ,
150183 metadata ( 3 ) ,
151184 2 ,
152185 ) ,
153186 ) . toEqual ( {
154- compositionStart : - 2 ,
187+ compositionStart : - 1 ,
155188 mediaStart : 0 ,
156189 durationSeconds : 3 ,
157190 preserveTimelinePhase : true ,
@@ -167,6 +200,30 @@ describe("resolveVideoExtractionDuration", () => {
167200 } ) ;
168201 } ) ;
169202
203+ it ( "never plans more extraction than the playable source range" , ( ) => {
204+ for ( const loop of [ false , true ] ) {
205+ for ( const sourceDuration of [ 0.5 , 3 , 120 ] ) {
206+ for ( const mediaStart of [ 0 , sourceDuration / 3 ] ) {
207+ for ( const start of [ - 600 , - 5 , - 1 , 0 , 2 ] ) {
208+ const window = resolveVideoExtractionWindow (
209+ video ( { start, end : start + 60 , mediaStart, loop } ) ,
210+ metadata ( sourceDuration ) ,
211+ 10 ,
212+ ) ;
213+ expect ( window . durationSeconds ) . toBeLessThanOrEqual ( sourceDuration - mediaStart ) ;
214+ expect ( window . durationSeconds ) . toBeGreaterThanOrEqual ( 0 ) ;
215+ }
216+ }
217+ }
218+ }
219+ } ) ;
220+
221+ it ( "rejects a media start at source EOF before planning extraction" , ( ) => {
222+ expect ( ( ) =>
223+ resolveVideoExtractionWindow ( video ( { mediaStart : 3 } ) , metadata ( 3 ) , 10 ) ,
224+ ) . toThrowError ( expect . objectContaining ( { kind : "media_start_out_of_range" , retryable : false } ) ) ;
225+ } ) ;
226+
170227 it ( "rebases a loop phase when the visible window stays within one cycle" , ( ) => {
171228 expect (
172229 resolveVideoExtractionWindow (
@@ -1228,7 +1285,10 @@ describe.skipIf(!HAS_FFMPEG)("extractAllVideoFrames on a VFR source", () => {
12281285 const extracted = result . extracted [ 0 ] ;
12291286 if ( ! extracted ) throw new Error ( "expected held-tail source frames" ) ;
12301287 const lookup = createFrameLookupTable ( [ video ] , result . extracted ) ;
1231- expect ( video ) . toMatchObject ( { start : - 15 , mediaStart : 0 , loop : false } ) ;
1288+ // The authored slot remains active through end=5, but extraction is
1289+ // rebased to the source's final one-second sample instead of materializing
1290+ // the full ten-second source just to hold its last frame.
1291+ expect ( video ) . toMatchObject ( { start : - 6 , end : 5 , mediaStart : 9 , loop : false } ) ;
12321292 expect ( lookup . getFrame ( "negative-held-tail" , 0 ) ) . toBe (
12331293 extracted . framePaths . get ( extracted . totalFrames - 1 ) ,
12341294 ) ;
@@ -1453,6 +1513,52 @@ describe.skipIf(!HAS_FFMPEG)("extractAllVideoFrames on a VFR source", () => {
14531513 rmSync ( CACHE_DIR , { recursive : true , force : true } ) ;
14541514 } , 60_000 ) ;
14551515
1516+ it ( "reuses one-cycle loop extraction across different authored starts" , async ( ) => {
1517+ const cacheDir = mkdtempSync ( join ( tmpdir ( ) , "hf-extract-loop-phase-cache-test-" ) ) ;
1518+ const src = await synthCfrClip ( "cache-loop-phase-src.mp4" , 3 ) ;
1519+ try {
1520+ const firstOutputDir = join ( FIXTURE_DIR , "out-cache-loop-phase-first" ) ;
1521+ const secondOutputDir = join ( FIXTURE_DIR , "out-cache-loop-phase-second" ) ;
1522+ mkdirSync ( firstOutputDir , { recursive : true } ) ;
1523+ mkdirSync ( secondOutputDir , { recursive : true } ) ;
1524+
1525+ const first = await extractAllVideoFrames (
1526+ [
1527+ {
1528+ ...cfrClipElement ( "loop-cache-first" , src , 60 ) ,
1529+ loop : true ,
1530+ } ,
1531+ ] ,
1532+ FIXTURE_DIR ,
1533+ { fps : 30 , outputDir : firstOutputDir , timelineEnd : 60 } ,
1534+ undefined ,
1535+ { extractCacheDir : cacheDir } ,
1536+ ) ;
1537+ expect ( first . errors ) . toEqual ( [ ] ) ;
1538+ expect ( first . phaseBreakdown . cacheMisses ) . toBe ( 1 ) ;
1539+
1540+ const second = await extractAllVideoFrames (
1541+ [
1542+ {
1543+ ...cfrClipElement ( "loop-cache-second" , src , 66 ) ,
1544+ start : - 6 ,
1545+ loop : true ,
1546+ } ,
1547+ ] ,
1548+ FIXTURE_DIR ,
1549+ { fps : 30 , outputDir : secondOutputDir , timelineEnd : 60 } ,
1550+ undefined ,
1551+ { extractCacheDir : cacheDir } ,
1552+ ) ;
1553+ expect ( second . errors ) . toEqual ( [ ] ) ;
1554+ expect ( second . phaseBreakdown . cacheHits ) . toBe ( 1 ) ;
1555+ expect ( second . phaseBreakdown . cacheMisses ) . toBe ( 0 ) ;
1556+ expect ( second . extracted [ 0 ] ?. totalFrames ) . toBe ( first . extracted [ 0 ] ?. totalFrames ) ;
1557+ } finally {
1558+ rmSync ( cacheDir , { recursive : true , force : true } ) ;
1559+ }
1560+ } , 60_000 ) ;
1561+
14561562 it ( "updates the cache sentinel mtime on a hit" , async ( ) => {
14571563 const CACHE_DIR = mkdtempSync ( join ( tmpdir ( ) , "hf-extract-cache-touch-test-" ) ) ;
14581564 const SRC = await synthCfrClip ( "cache-touch-src.mp4" , 1 ) ;
0 commit comments