@bycrux/editor 1.0.2 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/package.json +2 -2
  2. package/src/ControlsInfoModal.tsx +1 -0
  3. package/src/engine/__tests__/eligibility.test.ts +97 -0
  4. package/src/engine/__tests__/engine.test.ts +47 -2
  5. package/src/engine/__tests__/scheduler.test.ts +385 -4
  6. package/src/engine/__tests__/source-host.test.ts +165 -0
  7. package/src/engine/eligibility.ts +37 -1
  8. package/src/engine/index.ts +165 -29
  9. package/src/engine/scheduler.ts +345 -23
  10. package/src/index.ts +8 -0
  11. package/src/schema.ts +41 -1
  12. package/src/video/VideoEditor.tsx +106 -10
  13. package/src/video/__tests__/VideoEditor.keymap.test.tsx +64 -0
  14. package/src/video/__tests__/VideoEditor.test.tsx +169 -0
  15. package/src/video/__tests__/cuts.test.ts +84 -0
  16. package/src/video/__tests__/exportDurationSec.test.ts +60 -0
  17. package/src/video/__tests__/markerDropTime.test.ts +25 -0
  18. package/src/video/captionStyleDefaults.ts +2 -2
  19. package/src/video/cuts.ts +30 -2
  20. package/src/video/preview/OverlayItemsLayer.tsx +85 -6
  21. package/src/video/preview/PreviewPlayer.tsx +28 -1
  22. package/src/video/preview/__tests__/OverlayItemsLayer.keyframes.test.tsx +91 -0
  23. package/src/video/preview/__tests__/PreviewPlayer.engine.test.tsx +49 -1
  24. package/src/video/preview/__tests__/useVideoPlayback.canvasClock.test.ts +99 -0
  25. package/src/video/preview/useVideoPlayback.ts +18 -3
  26. package/src/video/timeline/Timeline.tsx +49 -1
  27. package/src/video/timeline/__tests__/Timeline.keymap.test.tsx +16 -0
  28. package/src/video/timeline/__tests__/markers.test.ts +125 -0
  29. package/src/video/timeline/__tests__/timeline-model.test.ts +307 -0
  30. package/src/video/timeline/canvas/TimelineCanvas.tsx +100 -4
  31. package/src/video/timeline/canvas/__tests__/TimelineCanvas.test.tsx +106 -2
  32. package/src/video/timeline/canvas/__tests__/draw.test.ts +73 -0
  33. package/src/video/timeline/canvas/__tests__/hit-test.test.ts +76 -0
  34. package/src/video/timeline/canvas/__tests__/pointer-machine.test.ts +216 -1
  35. package/src/video/timeline/canvas/draw.ts +104 -7
  36. package/src/video/timeline/canvas/hit-test.ts +72 -1
  37. package/src/video/timeline/canvas/pointer-machine.ts +142 -5
  38. package/src/video/timeline/markers.ts +109 -0
  39. package/src/video/timeline/timeline-model.ts +286 -15
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bycrux/editor",
3
- "version": "1.0.2",
3
+ "version": "1.2.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "exports": {
@@ -17,7 +17,7 @@
17
17
  "lint": "eslint src"
18
18
  },
19
19
  "dependencies": {
20
- "@bycrux/timeline-core": "^0.1.0",
20
+ "@bycrux/timeline-core": "^0.2.1",
21
21
  "class-variance-authority": "^0.7",
22
22
  "clsx": "^2",
23
23
  "lucide-react": "^0.400",
@@ -309,6 +309,7 @@ export const VIDEO_CONTROLS: ControlSection[] = [
309
309
  heading: 'Keyboard',
310
310
  entries: [
311
311
  { keys: ['S'], label: 'Split at the playhead' },
312
+ { keys: ['M'], label: 'Drop a marker at the playhead (or the preview axis)' },
312
313
  { keys: ['⌘/Ctrl', 'A'], label: 'Toggle the preview axis' },
313
314
  { keys: ['⇧', 'Delete'], label: 'Ripple-delete the selection' },
314
315
  { keys: ['⌘/Ctrl', 'Z'], label: 'Undo' },
@@ -10,6 +10,7 @@ import { describe, it, expect, afterEach } from 'vitest'
10
10
  import {
11
11
  checkProjectShapeEligibility,
12
12
  engineCapabilitySupported,
13
+ engineRequiredReason,
13
14
  evaluateEngineEligibility,
14
15
  __setEngineCapabilityForTests,
15
16
  } from '../eligibility'
@@ -144,3 +145,99 @@ describe('evaluateEngineEligibility', () => {
144
145
  expect(result.reason).toMatch(/nobg_preview_src/)
145
146
  })
146
147
  })
148
+
149
+ describe('engineRequiredReason', () => {
150
+ // Overlapping track-0 clips: c2 starts (3) before c1 ends (5) and does not
151
+ // contain it — a real transitionPairs() pair, the shape the legacy <video>
152
+ // player cannot blend.
153
+ const projectWithOverlappingTrack0Clips = project([
154
+ clip({ id: 'c1', start: 0, end: 5 }),
155
+ clip({ id: 'c2', start: 3, end: 8, proxySrc: '/a/orig_proxy_hable2.mp4' }),
156
+ ])
157
+
158
+ // Same overlap shape, but on an overlay track. Overlay fades are baked
159
+ // opacity keyframe data (computeVisualCrossfade) — the legacy player
160
+ // already renders those correctly, no compositing stage required.
161
+ const projectWithOverlappingOverlays: Project = {
162
+ id: 'p2',
163
+ status: 'draft',
164
+ settings: { resolution: [1080, 1920] },
165
+ tracks: [
166
+ { id: 'trk-0', items: [clip()] },
167
+ {
168
+ id: 'trk-1',
169
+ items: [
170
+ { id: 'o1', type: 'overlay', start: 0, end: 5 },
171
+ { id: 'o2', type: 'overlay', start: 3, end: 8 },
172
+ ],
173
+ },
174
+ ],
175
+ }
176
+
177
+ // Butt-joined, not overlapping (to.start === from.end) — no pair.
178
+ const plainProject = project([clip({ id: 'c1', start: 0, end: 5 }), clip({ id: 'c2', start: 5, end: 10 })])
179
+
180
+ it('a project with a clip crossfade reports a reason the legacy player cannot serve', () => {
181
+ expect(engineRequiredReason(projectWithOverlappingTrack0Clips)).toBe('clip-crossfade')
182
+ })
183
+
184
+ it('an overlay crossfade does NOT force the engine — its fade is keyframe data', () => {
185
+ expect(engineRequiredReason(projectWithOverlappingOverlays)).toBeNull()
186
+ })
187
+
188
+ it('an ordinary project reports no reason', () => {
189
+ expect(engineRequiredReason(plainProject)).toBeNull()
190
+ })
191
+
192
+ it('the existing shape check is unchanged by this task', () => {
193
+ // Regression guard: engineRequiredReason is ADDITIVE. A project that was
194
+ // shape-eligible before must still be shape-eligible.
195
+ expect(checkProjectShapeEligibility(projectWithOverlappingTrack0Clips).eligible).toBe(true)
196
+ })
197
+
198
+ it('a DISABLED track-0 with an overlapping clip pair does not force the engine', () => {
199
+ // trackItems() (used by checkProjectShapeEligibility) sees every track,
200
+ // enabled or not — but every real blend consumer (preview, render) reads
201
+ // via enabledTrackItems(), so a skipped track's clips never actually
202
+ // reach the legacy player at all. There is no crossfade for it to fail
203
+ // at rendering, so this must not raise the banner.
204
+ const disabledTrack0: Project = {
205
+ id: 'p3',
206
+ status: 'draft',
207
+ settings: { resolution: [1080, 1920] },
208
+ tracks: [
209
+ {
210
+ id: 'trk-0',
211
+ enabled: false,
212
+ items: [
213
+ clip({ id: 'c1', start: 0, end: 5 }),
214
+ clip({ id: 'c2', start: 3, end: 8, proxySrc: '/a/orig_proxy_hable2.mp4' }),
215
+ ],
216
+ },
217
+ ],
218
+ }
219
+ expect(engineRequiredReason(disabledTrack0)).toBeNull()
220
+ })
221
+
222
+ it('a video/image clip pair on an OVERLAY track (not tracks[0]) does not force the engine', () => {
223
+ // OverlayItemsLayer blends clip pairs on overlay tracks itself, even in
224
+ // legacy mode — so unlike a track-0 pair, the legacy path already
225
+ // handles this shape and the banner would be a false alarm.
226
+ const clipPairOnOverlayTrack: Project = {
227
+ id: 'p4',
228
+ status: 'draft',
229
+ settings: { resolution: [1080, 1920] },
230
+ tracks: [
231
+ { id: 'trk-0', items: [clip({ id: 'base' })] },
232
+ {
233
+ id: 'trk-1',
234
+ items: [
235
+ clip({ id: 'c1', start: 0, end: 5 }),
236
+ clip({ id: 'c2', start: 3, end: 8, proxySrc: '/a/orig_proxy_hable2.mp4' }),
237
+ ],
238
+ },
239
+ ],
240
+ }
241
+ expect(engineRequiredReason(clipPairOnOverlayTrack)).toBeNull()
242
+ })
243
+ })
@@ -263,17 +263,26 @@ describe('createCanvasPainter', () => {
263
263
  interface Recorded {
264
264
  fills: Array<[number, number, number, number]>
265
265
  draws: unknown[][]
266
+ /** `ctx.globalAlpha` as each `drawImage` saw it, index-for-index with `draws`. */
267
+ alphas: number[]
266
268
  }
267
269
 
268
270
  function stubCanvas(width = 1080, height = 1920): { canvas: HTMLCanvasElement; rec: Recorded } {
269
271
  const canvas = document.createElement('canvas')
270
272
  canvas.width = width
271
273
  canvas.height = height
272
- const rec: Recorded = { fills: [], draws: [] }
274
+ const rec: Recorded = { fills: [], draws: [], alphas: [] }
273
275
  const ctx = {
274
276
  fillStyle: '',
277
+ globalAlpha: 1,
275
278
  fillRect: (...args: [number, number, number, number]) => rec.fills.push(args),
276
- drawImage: (...args: unknown[]) => rec.draws.push(args),
279
+ drawImage: (...args: unknown[]) => {
280
+ rec.draws.push(args)
281
+ // The alpha IN FORCE at the moment of the call. A blend is expressed
282
+ // entirely through `globalAlpha`, so a recorder that ignored it could
283
+ // not tell a blend from two opaque draws.
284
+ rec.alphas.push(ctx.globalAlpha)
285
+ },
277
286
  }
278
287
  vi.spyOn(canvas, 'getContext').mockReturnValue(ctx as unknown as CanvasRenderingContext2D)
279
288
  return { canvas, rec }
@@ -314,6 +323,42 @@ describe('createCanvasPainter', () => {
314
323
  expect(last(rec.fills)).toEqual([0, 0, 540, 960])
315
324
  })
316
325
 
326
+ it('blends by drawing the outgoing frame opaque and the incoming one at p', () => {
327
+ // `A + (B-A)*p` as source-over: the outgoing picture goes down at full
328
+ // alpha, the incoming one over it at p, giving `(1-p)*from + p*to` — the
329
+ // same lerp `encode-segment.js` hands to ffmpeg's `blend=all_expr`.
330
+ const { canvas, rec } = stubCanvas()
331
+ const to = { close() {} } as unknown as VideoFrame
332
+ const toPlan: DrawPlan = { ...plan, dx: 5, dy: 6 }
333
+ createCanvasPainter(canvas).paintBlend(frame, to, 0.25, plan, toPlan)
334
+
335
+ expect(rec.draws).toEqual([
336
+ [frame, 10, 20, 30, 40, 1, 2, 3, 4],
337
+ [to, 10, 20, 30, 40, 5, 6, 3, 4],
338
+ ])
339
+ expect(rec.alphas).toEqual([1, 0.25])
340
+ })
341
+
342
+ it('restores globalAlpha after a blend, so the next paint is opaque', () => {
343
+ // The context outlives the call. A leaked alpha would ghost every later
344
+ // frame — and the black fill with it.
345
+ const { canvas, rec } = stubCanvas()
346
+ const painter = createCanvasPainter(canvas)
347
+ painter.paintBlend(frame, frame, 0.25, plan, plan)
348
+ painter.paint(frame, plan)
349
+ expect(last(rec.alphas)).toBe(1)
350
+ })
351
+
352
+ it('skips a degenerate rect on either side of a blend', () => {
353
+ const { canvas, rec } = stubCanvas()
354
+ const painter = createCanvasPainter(canvas)
355
+ painter.paintBlend(frame, frame, 0.5, { ...plan, sw: 0 }, plan)
356
+ expect(rec.draws).toHaveLength(1)
357
+ // Still restored, even though the outgoing side drew nothing.
358
+ painter.paint(frame, plan)
359
+ expect(last(rec.alphas)).toBe(1)
360
+ })
361
+
317
362
  it('is inert when the canvas gives no 2D context', () => {
318
363
  const canvas = document.createElement('canvas')
319
364
  vi.spyOn(canvas, 'getContext').mockReturnValue(null)
@@ -190,6 +190,10 @@ interface FakeSession {
190
190
  src: string
191
191
  item: VisualItem
192
192
  server: FakeFrameServer
193
+ /** The `serverEntries` key this session holds a ref under — see `acquireServer`. */
194
+ key: string
195
+ /** Whether it was BUILT exclusive, which is what the real host stamps on its session. */
196
+ exclusive: boolean
193
197
  clock: FakeClock
194
198
  ready: boolean
195
199
  failed?: string
@@ -208,17 +212,32 @@ class FakeHost implements SourceHost {
208
212
  readonly fallbacks: FakeClock[] = []
209
213
  /** When false, a retained clip stays `loading` until `ready()` is called. */
210
214
  autoReady = true
215
+ /**
216
+ * When false, `exclusiveServer` is ignored and both sides of a blend land on
217
+ * one server — the state `blendSideFor`'s same-server assertion exists for.
218
+ * The real host always honours it; this is the only way to reach that branch.
219
+ */
220
+ honourExclusive = true
211
221
  scheduler: Scheduler | null = null
212
222
  private time = 0
213
223
  private readonly clocks: FakeClock[] = []
224
+ /** Frame servers by server KEY, with the clips referencing each — see `acquireServer`. */
225
+ private readonly serverEntries = new Map<string, { server: FakeFrameServer; refs: Set<string> }>()
214
226
 
215
227
  retain(requests: readonly SourceRequest[]): void {
216
228
  this.retainLog.push([...requests])
217
229
  for (const [clipId, session] of [...this.sessions]) {
218
- if (requests.some((r) => r.clipId === clipId && r.src === session.src)) continue
230
+ const want = requests.find((r) => r.clipId === clipId && r.src === session.src)
231
+ // A clip that BECOMES exclusive is respawned onto a server of its own,
232
+ // exactly as the real host does it (`index.ts`'s `retain`): a live
233
+ // session cannot change servers, and the incoming side of a blend is
234
+ // usually already built as `next` before the overlap starts. The reverse
235
+ // edge is NOT a respawn there either — the real host re-files the entry
236
+ // under the shared key instead, bookkeeping this fake has no way to show.
237
+ if (want && !(want.exclusiveServer && !session.exclusive)) continue
219
238
  this.sessions.delete(clipId)
220
239
  this.droppedClips.push(clipId)
221
- session.server.dispose()
240
+ this.releaseServer(session.key, clipId)
222
241
  session.clock.dispose()
223
242
  }
224
243
  for (const request of requests) {
@@ -226,16 +245,50 @@ class FakeHost implements SourceHost {
226
245
  const clock = new FakeClock('audio', request.anchorProjectS)
227
246
  clock.t = this.time
228
247
  this.clocks.push(clock)
248
+ const exclusive = !!request.exclusiveServer
249
+ const key =
250
+ exclusive && this.honourExclusive ? `${request.src}#${request.clipId}` : request.src
229
251
  this.sessions.set(request.clipId, {
230
252
  src: request.src,
231
253
  item: request.item,
232
- server: new FakeFrameServer(request.src),
254
+ server: this.acquireServer(key, request.src, request.clipId),
255
+ key,
256
+ exclusive,
233
257
  clock,
234
258
  ready: this.autoReady,
235
259
  })
236
260
  }
237
261
  }
238
262
 
263
+ /**
264
+ * One `FakeFrameServer` per KEY, refcounted by clip — the real host's rule
265
+ * (`index.ts`: "FrameServer — one per `src`, refcounted by clip … Two clips
266
+ * never stream from one server at once"), including its one carve-out: an
267
+ * `exclusiveServer` request is filed under `${src}#${clipId}` and gets a
268
+ * server of its own. A fake that handed EVERY clip its own server would let
269
+ * a crossfade between two cuts of ONE take blend in these tests and hard-cut
270
+ * in the browser; a fake that honoured no exclusivity at all would do the
271
+ * reverse, and fail the blend that now works.
272
+ */
273
+ private acquireServer(key: string, src: string, clipId: string): FakeFrameServer {
274
+ let entry = this.serverEntries.get(key)
275
+ if (!entry) {
276
+ entry = { server: new FakeFrameServer(src), refs: new Set() }
277
+ this.serverEntries.set(key, entry)
278
+ }
279
+ entry.refs.add(clipId)
280
+ return entry.server
281
+ }
282
+
283
+ private releaseServer(key: string, clipId: string): void {
284
+ const entry = this.serverEntries.get(key)
285
+ if (!entry) return
286
+ entry.refs.delete(clipId)
287
+ if (entry.refs.size > 0) return
288
+ entry.server.dispose()
289
+ this.serverEntries.delete(key)
290
+ }
291
+
239
292
  state(clipId: string): SourceState {
240
293
  const session = this.sessions.get(clipId)
241
294
  if (!session) return { status: 'idle' }
@@ -299,6 +352,14 @@ class FakeHost implements SourceHost {
299
352
 
300
353
  class FakePainter implements Painter {
301
354
  readonly paints: Array<{ frame: FakeFrame; plan: DrawPlan }> = []
355
+ /** Every `paintBlend` call — the crossfade tests read `p` and both frames off this. */
356
+ readonly blends: Array<{
357
+ from: FakeFrame
358
+ to: FakeFrame
359
+ p: number
360
+ fromPlan: DrawPlan
361
+ toPlan: DrawPlan
362
+ }> = []
302
363
  clears = 0
303
364
  constructor(
304
365
  private readonly width = 1080,
@@ -310,6 +371,15 @@ class FakePainter implements Painter {
310
371
  paint(frame: VideoFrame, plan: DrawPlan) {
311
372
  this.paints.push({ frame: frame as unknown as FakeFrame, plan })
312
373
  }
374
+ paintBlend(from: VideoFrame, to: VideoFrame, p: number, fromPlan: DrawPlan, toPlan: DrawPlan) {
375
+ this.blends.push({
376
+ from: from as unknown as FakeFrame,
377
+ to: to as unknown as FakeFrame,
378
+ p,
379
+ fromPlan,
380
+ toPlan,
381
+ })
382
+ }
313
383
  clear() {
314
384
  this.clears++
315
385
  }
@@ -349,6 +419,15 @@ function project(
349
419
  } as Project
350
420
  }
351
421
 
422
+ /**
423
+ * Two track-0 clips that really are a crossfade pair: they overlap on [3, 4)
424
+ * and `b` outlives `a`, so `transitionPairs` pairs them rather than reading it
425
+ * as containment. `p` is 0.5 at t = 3.5, the midpoint of the overlap.
426
+ */
427
+ function crossfade(overlays: VisualItem[] = [], extra: Partial<VisualItem> = {}): Project {
428
+ return project([clip('a', 0, 4, extra), clip('b', 3, 8, extra)], overlays)
429
+ }
430
+
352
431
  interface Harness {
353
432
  scheduler: Scheduler
354
433
  host: FakeHost
@@ -404,7 +483,27 @@ describe('transportEndFor', () => {
404
483
  expect(transportEndFor(p)).toBe(8)
405
484
  })
406
485
 
407
- it('uses max(overlayEnd, captionEnd) for a canvas project — audio EXCLUDED', () => {
486
+ it('counts track 0 in a canvas project — an overlay-only single track is not a zero-length transport', () => {
487
+ // The shape an animations-workflow project actually has: ONE track, holding
488
+ // nothing but overlays, and no captions. Reading the ceiling off
489
+ // `tracks.slice(1)` made this 0, so play/space started the transport and
490
+ // stopped it in the same tick — the picture never moved.
491
+ const p = project([overlay('o1', 0, 5), overlay('o2', 5, 12)])
492
+ expect(p.tracks).toHaveLength(1)
493
+ expect(transportEndFor(p)).toBe(12)
494
+ })
495
+
496
+ it('counts track 0 images in a canvas project, even past the overlay tracks', () => {
497
+ // Same defect, other content kind: a background image outlasting every
498
+ // overlay used to be invisible to the ceiling.
499
+ const p = project(
500
+ [{ id: 'img', type: 'image', src: '/a.png', start: 0, end: 9 }],
501
+ [overlay('o', 0, 4)],
502
+ )
503
+ expect(transportEndFor(p)).toBe(9)
504
+ })
505
+
506
+ it('uses max(visualEnd, captionEnd) for a canvas project — audio EXCLUDED', () => {
408
507
  // The legacy canvas rAF's ceiling is `canvasMaxEndRef`, which really does
409
508
  // leave audio out. Faithful, not unified.
410
509
  const p = project([{ id: 'img', type: 'image', src: '/a.png', start: 0, end: 3 }], [
@@ -561,6 +660,29 @@ describe('planTick', () => {
561
660
  expect(planTick(p, 2, track0VideoItems(p)).active?.clipId).toBe('early')
562
661
  })
563
662
 
663
+ it('plans a blend when two track-0 clips overlap', () => {
664
+ const p = crossfade()
665
+ const plan = planTick(p, 3.5, track0VideoItems(p))
666
+ expect(plan.active?.clipId).toBe('a')
667
+ expect(plan.blend).toEqual({ clipId: 'b', p: 0.5 })
668
+ })
669
+
670
+ it('plans no blend outside the overlap', () => {
671
+ const p = crossfade()
672
+ expect(planTick(p, 1, track0VideoItems(p)).blend).toBeNull()
673
+ expect(planTick(p, 6, track0VideoItems(p)).blend).toBeNull()
674
+ })
675
+
676
+ it('plans no blend for an overlap that is not a transition pair', () => {
677
+ // Containment — 'late' never outlives 'early', so there is no ordering to
678
+ // blend along and the resolver stamps no crossfade. Something still has to
679
+ // own the picture, which is why the earliest-start tiebreak stays.
680
+ const p = project([clip('late', 1, 4), clip('early', 0, 4)])
681
+ const plan = planTick(p, 2, track0VideoItems(p))
682
+ expect(plan.active?.clipId).toBe('early')
683
+ expect(plan.blend).toBeNull()
684
+ })
685
+
564
686
  it('flags a canvas project', () => {
565
687
  const p = project([{ id: 'img', type: 'image', src: '/a.png', start: 0, end: 4 }])
566
688
  const plan = planTick(p, 1, track0VideoItems(p))
@@ -1229,6 +1351,265 @@ describe('painting', () => {
1229
1351
  })
1230
1352
  })
1231
1353
 
1354
+ // ── Clip crossfade ──────────────────────────────────────────────────────────
1355
+
1356
+ describe('clip crossfade', () => {
1357
+ /** Let a `Promise.all` of two seek promises settle. */
1358
+ const flush = () => new Promise((resolve) => setTimeout(resolve, 0))
1359
+
1360
+ /** Both sides cut from ONE take — what a silence-trimmed timeline is made of. */
1361
+ const sameTake = { src: '/media/take.mov', proxySrc: '/proxies/take_proxy.mp4' }
1362
+
1363
+ /**
1364
+ * Play to the midpoint of the overlap with both sides supplying frames.
1365
+ *
1366
+ * The stop at 2.5 is load-bearing: it is inside the prewarm lead, so the
1367
+ * incoming clip's session is already live when the blend starts, which is
1368
+ * the state a real transport arrives in.
1369
+ */
1370
+ function intoBlend(
1371
+ opts: {
1372
+ supplyIncoming?: boolean
1373
+ overlays?: VisualItem[]
1374
+ extra?: Partial<VisualItem>
1375
+ } = {},
1376
+ ) {
1377
+ const h = harness(crossfade(opts.overlays ?? [], opts.extra ?? {}))
1378
+ h.scheduler.play()
1379
+ step(h, 2.5)
1380
+ const fromFrame = fakeFrame(2_500_000)
1381
+ const toFrame = fakeFrame(500_000)
1382
+ h.host.server('a').supply = () => fromFrame
1383
+ if (opts.supplyIncoming !== false) h.host.server('b').supply = () => toFrame
1384
+ step(h, 3.5)
1385
+ return { h, fromFrame, toFrame }
1386
+ }
1387
+
1388
+ it('retains BOTH sessions through a blend', () => {
1389
+ // the incoming clip's source must be live before the blend starts, not
1390
+ // requested at the instant it is first painted
1391
+ const h = harness(crossfade())
1392
+ h.scheduler.play()
1393
+ step(h, 3.5)
1394
+ const requests = h.host.lastRetain()
1395
+ expect(requests.map((r) => r.clipId).sort()).toEqual(['a', 'b'])
1396
+ // `prev` names the incoming clip too (it starts before `t`), so the blend
1397
+ // must not push a second request for it.
1398
+ expect(requests).toHaveLength(2)
1399
+ })
1400
+
1401
+ it('paints a blend via paintBlend, not two paint calls', () => {
1402
+ const { h, fromFrame, toFrame } = intoBlend()
1403
+ expect(h.painter.blends).toHaveLength(1)
1404
+ expect(h.painter.blends[0].from).toBe(fromFrame)
1405
+ expect(h.painter.blends[0].to).toBe(toFrame)
1406
+ expect(h.painter.blends[0].p).toBeCloseTo(0.5, 10)
1407
+ expect(h.painter.paints).toHaveLength(0)
1408
+ })
1409
+
1410
+ it('closes both frames of a blend', () => {
1411
+ const { fromFrame, toFrame } = intoBlend()
1412
+ expect(fromFrame.closed).toBe(true)
1413
+ expect(toFrame.closed).toBe(true)
1414
+ })
1415
+
1416
+ it('gives each side of the blend its own draw plan', () => {
1417
+ // `drawPlanFor` folds the item's own sourceCrop, and render composites each
1418
+ // item down its own branch before blending them — one shared plan would put
1419
+ // the outgoing clip's geometry on both pictures.
1420
+ const a = clip('a', 0, 4)
1421
+ const b = clip('b', 3, 8, {
1422
+ sourceCrop: { x: 0.25, y: 0, w: 0.5, h: 1 },
1423
+ sourceWidth: 1920,
1424
+ sourceHeight: 1080,
1425
+ })
1426
+ const h = harness(project([a, b]))
1427
+ h.scheduler.play()
1428
+ step(h, 2.5)
1429
+ h.host.server('a').supply = () => fakeFrame(2_500_000)
1430
+ h.host.server('b').supply = () => fakeFrame(500_000)
1431
+ step(h, 3.5)
1432
+
1433
+ const blend = h.painter.blends[0]
1434
+ expect(blend.fromPlan).toEqual(drawPlanFor(a, 1280, 720, 1080, 1920))
1435
+ expect(blend.toPlan).toEqual(drawPlanFor(b, 1280, 720, 1080, 1920))
1436
+ expect(blend.toPlan).not.toEqual(blend.fromPlan)
1437
+ })
1438
+
1439
+ it('falls back to the outgoing clip alone when the incoming frame is not ready', () => {
1440
+ const { h, fromFrame } = intoBlend({ supplyIncoming: false })
1441
+ expect(h.painter.blends).toHaveLength(0)
1442
+ expect(h.painter.paints.map((paint) => paint.frame)).toEqual([fromFrame])
1443
+ expect(fromFrame.closed).toBe(true)
1444
+ })
1445
+
1446
+ it('an opaque overlay still suppresses the picture during a blend', () => {
1447
+ const { h, fromFrame, toFrame } = intoBlend({ overlays: [overlay('o', 3, 4, true)] })
1448
+ expect(h.scheduler.status().picture).toBe('opaque')
1449
+ expect(h.painter.blends).toHaveLength(0)
1450
+ expect(h.painter.paints).toHaveLength(0)
1451
+ expect(h.painter.clears).toBeGreaterThan(0)
1452
+ // The outgoing frame is still pulled and closed unpainted; the incoming
1453
+ // side is not streaming at all under an opaque overlay.
1454
+ expect(fromFrame.closed).toBe(true)
1455
+ expect(toFrame.closed).toBe(false)
1456
+ })
1457
+
1458
+ it('streams the incoming clip only while the blend is live', () => {
1459
+ const h = harness(crossfade())
1460
+ h.scheduler.play()
1461
+ step(h, 2.5)
1462
+ expect(h.host.server('b').starts).toHaveLength(0)
1463
+ step(h, 3.5)
1464
+ expect(h.host.server('b').starts).toHaveLength(1)
1465
+
1466
+ // Past the outgoing clip's end the blend is over and the incoming clip owns
1467
+ // the picture outright. Its blend stream has to be retired BEFORE the
1468
+ // active path starts one, or the two fight over the same server.
1469
+ step(h, 4.5)
1470
+ expect(h.host.server('b').stops).toBeGreaterThan(0)
1471
+ expect(h.host.server('b').starts).toHaveLength(2)
1472
+ })
1473
+
1474
+ it('reopens the incoming stream after a pause lands inside the blend', () => {
1475
+ // Pausing mid-blend sends the picture down the seek path, and `seek` stops
1476
+ // whatever was streaming on that server. If the scheduler still believed
1477
+ // its blend session were live, the resume would skip `startStream` and the
1478
+ // rest of the crossfade would silently play un-blended.
1479
+ const h = harness(crossfade())
1480
+ h.scheduler.play()
1481
+ step(h, 3.4)
1482
+ expect(h.host.server('b').starts).toHaveLength(1)
1483
+
1484
+ h.scheduler.pause()
1485
+ h.scheduler.seek(3.5)
1486
+ h.scheduler.play()
1487
+ step(h, 3.6)
1488
+ expect(h.host.server('b').starts).toHaveLength(2)
1489
+ })
1490
+
1491
+ it('two clips off ONE take blend — the case Task 9 had to skip', () => {
1492
+ // Both clips carry the same proxySrc, as a silence-trimmed timeline
1493
+ // produces: a dissolve between two adjacent cuts of a single take is the
1494
+ // ordinary jump-cut softener, and the commonest crossfade there is. One
1495
+ // FrameServer serves one decode intent at a time, so the incoming side
1496
+ // asks for a decoder of its own and the pair really does have two read
1497
+ // positions.
1498
+ const h = harness(crossfade([], sameTake))
1499
+ h.scheduler.play()
1500
+ step(h, 2.5)
1501
+ // Prewarmed as `next` on the SHARED server — this is the state the blend
1502
+ // has to upgrade out of, not a state it can assume away.
1503
+ expect(h.host.server('a')).toBe(h.host.server('b'))
1504
+
1505
+ const fromFrame = fakeFrame(2_500_000)
1506
+ const toFrame = fakeFrame(500_000)
1507
+ h.host.server('a').supply = () => fromFrame
1508
+ // First tick of the overlap: the host moves `b` onto a decoder of its own,
1509
+ // so the blend engages a tick late and this one paints the outgoing clip
1510
+ // alone — the same fallback an undecoded incoming frame takes, at a `p`
1511
+ // near zero where the two pictures are indistinguishable anyway.
1512
+ step(h, 3.5)
1513
+ expect(h.host.server('a')).not.toBe(h.host.server('b'))
1514
+ expect(h.painter.blends).toHaveLength(0)
1515
+
1516
+ h.host.server('b').supply = () => toFrame
1517
+ step(h, 3.5)
1518
+
1519
+ expect(h.painter.blends).toHaveLength(1)
1520
+ expect(h.painter.blends[0].from).toBe(fromFrame)
1521
+ expect(h.painter.blends[0].to).toBe(toFrame)
1522
+ expect(h.painter.blends[0].p).toBeCloseTo(0.5, 10)
1523
+ })
1524
+
1525
+ it('the blend request is marked exclusive; the active one is not', () => {
1526
+ const h = harness(crossfade([], sameTake))
1527
+ h.scheduler.play()
1528
+ step(h, 3.5)
1529
+ const reqs = h.host.lastRetain()
1530
+ expect(reqs.find((r) => r.clipId === 'b')!.exclusiveServer).toBe(true)
1531
+ expect(reqs.find((r) => r.clipId === 'a')!.exclusiveServer).toBeFalsy()
1532
+ })
1533
+
1534
+ it('leaves a cross-source blend alone — it already has two servers', () => {
1535
+ // Asking for exclusivity here would force the host to respawn a session it
1536
+ // had already prewarmed, tearing down a decoder at the instant the blend
1537
+ // needs frames from it, to reach the state it was in to begin with.
1538
+ const h = harness(crossfade())
1539
+ h.scheduler.play()
1540
+ step(h, 2.5)
1541
+ const prewarmed = h.host.server('b')
1542
+ step(h, 3.5)
1543
+ expect(h.host.lastRetain().find((r) => r.clipId === 'b')!.exclusiveServer).toBeFalsy()
1544
+ expect(h.host.droppedClips).not.toContain('b')
1545
+ expect(h.host.server('b')).toBe(prewarmed)
1546
+ })
1547
+
1548
+ it('keeps the incoming session when the blend ends and it stops being exclusive', () => {
1549
+ // The end of a crossfade is NOT a respawn point. By then the incoming clip
1550
+ // is the active one and the transport runs on its master clock, so dropping
1551
+ // its session to re-file its server would tear down that clock and
1552
+ // terminate its decoder mid-playback — an artefact worse than the hard cut
1553
+ // this task removed. The exclusive server is released when the clip leaves
1554
+ // the retained set, not when the flag does.
1555
+ const h = harness(crossfade([], sameTake))
1556
+ h.scheduler.play()
1557
+ step(h, 3.5)
1558
+ const during = h.host.server('b')
1559
+ step(h, 4.5)
1560
+ expect(h.host.lastRetain().find((r) => r.clipId === 'b')!.exclusiveServer).toBeFalsy()
1561
+ expect(h.host.droppedClips).not.toContain('b')
1562
+ expect(h.host.server('b')).toBe(during)
1563
+ expect(during.disposed).toBe(false)
1564
+ })
1565
+
1566
+ it('still paints the outgoing frame alone if the host hands both sides one server', () => {
1567
+ // `blendSideFor`'s same-server check, which `exclusiveServer` makes
1568
+ // unreachable by construction — the only way in is a host that ignores the
1569
+ // flag. Kept as an assertion rather than deleted: two read positions on one
1570
+ // decoder would have `nextFrameFor` answer from the wrong place in the
1571
+ // file, and a stalled picture reads as a decoder bug, not a transition.
1572
+ const h = harness(crossfade([], sameTake))
1573
+ h.host.honourExclusive = false
1574
+ h.scheduler.play()
1575
+ step(h, 2.5)
1576
+ const frame = fakeFrame(2_500_000)
1577
+ h.host.server('a').supply = () => frame
1578
+ step(h, 3.5)
1579
+
1580
+ expect(h.host.server('a')).toBe(h.host.server('b'))
1581
+ expect(h.painter.blends).toHaveLength(0)
1582
+ expect(h.painter.paints.map((paint) => paint.frame)).toEqual([frame])
1583
+ })
1584
+
1585
+ it('blends a paused scrub from both seeks', async () => {
1586
+ const h = harness(crossfade())
1587
+ h.scheduler.seek(3.5)
1588
+ const fromFrame = fakeFrame(3_500_000)
1589
+ const toFrame = fakeFrame(500_000)
1590
+ h.host.server('a').pendingSeeks[0](asFrame(fromFrame))
1591
+ h.host.server('b').pendingSeeks[0](asFrame(toFrame))
1592
+ await flush()
1593
+
1594
+ expect(h.painter.blends).toHaveLength(1)
1595
+ expect(h.painter.blends[0].from).toBe(fromFrame)
1596
+ expect(h.painter.blends[0].to).toBe(toFrame)
1597
+ expect(h.painter.blends[0].p).toBeCloseTo(0.5, 10)
1598
+ })
1599
+
1600
+ it('paints the outgoing clip alone when a paused scrub lands only one frame', async () => {
1601
+ const h = harness(crossfade())
1602
+ h.scheduler.seek(3.5)
1603
+ const fromFrame = fakeFrame(3_500_000)
1604
+ h.host.server('a').pendingSeeks[0](asFrame(fromFrame))
1605
+ h.host.server('b').pendingSeeks[0](null)
1606
+ await flush()
1607
+
1608
+ expect(h.painter.blends).toHaveLength(0)
1609
+ expect(h.painter.paints.map((paint) => paint.frame)).toEqual([fromFrame])
1610
+ })
1611
+ })
1612
+
1232
1613
  // ── Lifecycle ───────────────────────────────────────────────────────────────
1233
1614
 
1234
1615
  describe('lifecycle', () => {