@bycrux/editor 1.0.1 → 1.1.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bycrux/editor",
3
- "version": "1.0.1",
3
+ "version": "1.1.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.0",
21
21
  "class-variance-authority": "^0.7",
22
22
  "clsx": "^2",
23
23
  "lucide-react": "^0.400",
@@ -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
@@ -561,6 +640,29 @@ describe('planTick', () => {
561
640
  expect(planTick(p, 2, track0VideoItems(p)).active?.clipId).toBe('early')
562
641
  })
563
642
 
643
+ it('plans a blend when two track-0 clips overlap', () => {
644
+ const p = crossfade()
645
+ const plan = planTick(p, 3.5, track0VideoItems(p))
646
+ expect(plan.active?.clipId).toBe('a')
647
+ expect(plan.blend).toEqual({ clipId: 'b', p: 0.5 })
648
+ })
649
+
650
+ it('plans no blend outside the overlap', () => {
651
+ const p = crossfade()
652
+ expect(planTick(p, 1, track0VideoItems(p)).blend).toBeNull()
653
+ expect(planTick(p, 6, track0VideoItems(p)).blend).toBeNull()
654
+ })
655
+
656
+ it('plans no blend for an overlap that is not a transition pair', () => {
657
+ // Containment — 'late' never outlives 'early', so there is no ordering to
658
+ // blend along and the resolver stamps no crossfade. Something still has to
659
+ // own the picture, which is why the earliest-start tiebreak stays.
660
+ const p = project([clip('late', 1, 4), clip('early', 0, 4)])
661
+ const plan = planTick(p, 2, track0VideoItems(p))
662
+ expect(plan.active?.clipId).toBe('early')
663
+ expect(plan.blend).toBeNull()
664
+ })
665
+
564
666
  it('flags a canvas project', () => {
565
667
  const p = project([{ id: 'img', type: 'image', src: '/a.png', start: 0, end: 4 }])
566
668
  const plan = planTick(p, 1, track0VideoItems(p))
@@ -1229,6 +1331,265 @@ describe('painting', () => {
1229
1331
  })
1230
1332
  })
1231
1333
 
1334
+ // ── Clip crossfade ──────────────────────────────────────────────────────────
1335
+
1336
+ describe('clip crossfade', () => {
1337
+ /** Let a `Promise.all` of two seek promises settle. */
1338
+ const flush = () => new Promise((resolve) => setTimeout(resolve, 0))
1339
+
1340
+ /** Both sides cut from ONE take — what a silence-trimmed timeline is made of. */
1341
+ const sameTake = { src: '/media/take.mov', proxySrc: '/proxies/take_proxy.mp4' }
1342
+
1343
+ /**
1344
+ * Play to the midpoint of the overlap with both sides supplying frames.
1345
+ *
1346
+ * The stop at 2.5 is load-bearing: it is inside the prewarm lead, so the
1347
+ * incoming clip's session is already live when the blend starts, which is
1348
+ * the state a real transport arrives in.
1349
+ */
1350
+ function intoBlend(
1351
+ opts: {
1352
+ supplyIncoming?: boolean
1353
+ overlays?: VisualItem[]
1354
+ extra?: Partial<VisualItem>
1355
+ } = {},
1356
+ ) {
1357
+ const h = harness(crossfade(opts.overlays ?? [], opts.extra ?? {}))
1358
+ h.scheduler.play()
1359
+ step(h, 2.5)
1360
+ const fromFrame = fakeFrame(2_500_000)
1361
+ const toFrame = fakeFrame(500_000)
1362
+ h.host.server('a').supply = () => fromFrame
1363
+ if (opts.supplyIncoming !== false) h.host.server('b').supply = () => toFrame
1364
+ step(h, 3.5)
1365
+ return { h, fromFrame, toFrame }
1366
+ }
1367
+
1368
+ it('retains BOTH sessions through a blend', () => {
1369
+ // the incoming clip's source must be live before the blend starts, not
1370
+ // requested at the instant it is first painted
1371
+ const h = harness(crossfade())
1372
+ h.scheduler.play()
1373
+ step(h, 3.5)
1374
+ const requests = h.host.lastRetain()
1375
+ expect(requests.map((r) => r.clipId).sort()).toEqual(['a', 'b'])
1376
+ // `prev` names the incoming clip too (it starts before `t`), so the blend
1377
+ // must not push a second request for it.
1378
+ expect(requests).toHaveLength(2)
1379
+ })
1380
+
1381
+ it('paints a blend via paintBlend, not two paint calls', () => {
1382
+ const { h, fromFrame, toFrame } = intoBlend()
1383
+ expect(h.painter.blends).toHaveLength(1)
1384
+ expect(h.painter.blends[0].from).toBe(fromFrame)
1385
+ expect(h.painter.blends[0].to).toBe(toFrame)
1386
+ expect(h.painter.blends[0].p).toBeCloseTo(0.5, 10)
1387
+ expect(h.painter.paints).toHaveLength(0)
1388
+ })
1389
+
1390
+ it('closes both frames of a blend', () => {
1391
+ const { fromFrame, toFrame } = intoBlend()
1392
+ expect(fromFrame.closed).toBe(true)
1393
+ expect(toFrame.closed).toBe(true)
1394
+ })
1395
+
1396
+ it('gives each side of the blend its own draw plan', () => {
1397
+ // `drawPlanFor` folds the item's own sourceCrop, and render composites each
1398
+ // item down its own branch before blending them — one shared plan would put
1399
+ // the outgoing clip's geometry on both pictures.
1400
+ const a = clip('a', 0, 4)
1401
+ const b = clip('b', 3, 8, {
1402
+ sourceCrop: { x: 0.25, y: 0, w: 0.5, h: 1 },
1403
+ sourceWidth: 1920,
1404
+ sourceHeight: 1080,
1405
+ })
1406
+ const h = harness(project([a, b]))
1407
+ h.scheduler.play()
1408
+ step(h, 2.5)
1409
+ h.host.server('a').supply = () => fakeFrame(2_500_000)
1410
+ h.host.server('b').supply = () => fakeFrame(500_000)
1411
+ step(h, 3.5)
1412
+
1413
+ const blend = h.painter.blends[0]
1414
+ expect(blend.fromPlan).toEqual(drawPlanFor(a, 1280, 720, 1080, 1920))
1415
+ expect(blend.toPlan).toEqual(drawPlanFor(b, 1280, 720, 1080, 1920))
1416
+ expect(blend.toPlan).not.toEqual(blend.fromPlan)
1417
+ })
1418
+
1419
+ it('falls back to the outgoing clip alone when the incoming frame is not ready', () => {
1420
+ const { h, fromFrame } = intoBlend({ supplyIncoming: false })
1421
+ expect(h.painter.blends).toHaveLength(0)
1422
+ expect(h.painter.paints.map((paint) => paint.frame)).toEqual([fromFrame])
1423
+ expect(fromFrame.closed).toBe(true)
1424
+ })
1425
+
1426
+ it('an opaque overlay still suppresses the picture during a blend', () => {
1427
+ const { h, fromFrame, toFrame } = intoBlend({ overlays: [overlay('o', 3, 4, true)] })
1428
+ expect(h.scheduler.status().picture).toBe('opaque')
1429
+ expect(h.painter.blends).toHaveLength(0)
1430
+ expect(h.painter.paints).toHaveLength(0)
1431
+ expect(h.painter.clears).toBeGreaterThan(0)
1432
+ // The outgoing frame is still pulled and closed unpainted; the incoming
1433
+ // side is not streaming at all under an opaque overlay.
1434
+ expect(fromFrame.closed).toBe(true)
1435
+ expect(toFrame.closed).toBe(false)
1436
+ })
1437
+
1438
+ it('streams the incoming clip only while the blend is live', () => {
1439
+ const h = harness(crossfade())
1440
+ h.scheduler.play()
1441
+ step(h, 2.5)
1442
+ expect(h.host.server('b').starts).toHaveLength(0)
1443
+ step(h, 3.5)
1444
+ expect(h.host.server('b').starts).toHaveLength(1)
1445
+
1446
+ // Past the outgoing clip's end the blend is over and the incoming clip owns
1447
+ // the picture outright. Its blend stream has to be retired BEFORE the
1448
+ // active path starts one, or the two fight over the same server.
1449
+ step(h, 4.5)
1450
+ expect(h.host.server('b').stops).toBeGreaterThan(0)
1451
+ expect(h.host.server('b').starts).toHaveLength(2)
1452
+ })
1453
+
1454
+ it('reopens the incoming stream after a pause lands inside the blend', () => {
1455
+ // Pausing mid-blend sends the picture down the seek path, and `seek` stops
1456
+ // whatever was streaming on that server. If the scheduler still believed
1457
+ // its blend session were live, the resume would skip `startStream` and the
1458
+ // rest of the crossfade would silently play un-blended.
1459
+ const h = harness(crossfade())
1460
+ h.scheduler.play()
1461
+ step(h, 3.4)
1462
+ expect(h.host.server('b').starts).toHaveLength(1)
1463
+
1464
+ h.scheduler.pause()
1465
+ h.scheduler.seek(3.5)
1466
+ h.scheduler.play()
1467
+ step(h, 3.6)
1468
+ expect(h.host.server('b').starts).toHaveLength(2)
1469
+ })
1470
+
1471
+ it('two clips off ONE take blend — the case Task 9 had to skip', () => {
1472
+ // Both clips carry the same proxySrc, as a silence-trimmed timeline
1473
+ // produces: a dissolve between two adjacent cuts of a single take is the
1474
+ // ordinary jump-cut softener, and the commonest crossfade there is. One
1475
+ // FrameServer serves one decode intent at a time, so the incoming side
1476
+ // asks for a decoder of its own and the pair really does have two read
1477
+ // positions.
1478
+ const h = harness(crossfade([], sameTake))
1479
+ h.scheduler.play()
1480
+ step(h, 2.5)
1481
+ // Prewarmed as `next` on the SHARED server — this is the state the blend
1482
+ // has to upgrade out of, not a state it can assume away.
1483
+ expect(h.host.server('a')).toBe(h.host.server('b'))
1484
+
1485
+ const fromFrame = fakeFrame(2_500_000)
1486
+ const toFrame = fakeFrame(500_000)
1487
+ h.host.server('a').supply = () => fromFrame
1488
+ // First tick of the overlap: the host moves `b` onto a decoder of its own,
1489
+ // so the blend engages a tick late and this one paints the outgoing clip
1490
+ // alone — the same fallback an undecoded incoming frame takes, at a `p`
1491
+ // near zero where the two pictures are indistinguishable anyway.
1492
+ step(h, 3.5)
1493
+ expect(h.host.server('a')).not.toBe(h.host.server('b'))
1494
+ expect(h.painter.blends).toHaveLength(0)
1495
+
1496
+ h.host.server('b').supply = () => toFrame
1497
+ step(h, 3.5)
1498
+
1499
+ expect(h.painter.blends).toHaveLength(1)
1500
+ expect(h.painter.blends[0].from).toBe(fromFrame)
1501
+ expect(h.painter.blends[0].to).toBe(toFrame)
1502
+ expect(h.painter.blends[0].p).toBeCloseTo(0.5, 10)
1503
+ })
1504
+
1505
+ it('the blend request is marked exclusive; the active one is not', () => {
1506
+ const h = harness(crossfade([], sameTake))
1507
+ h.scheduler.play()
1508
+ step(h, 3.5)
1509
+ const reqs = h.host.lastRetain()
1510
+ expect(reqs.find((r) => r.clipId === 'b')!.exclusiveServer).toBe(true)
1511
+ expect(reqs.find((r) => r.clipId === 'a')!.exclusiveServer).toBeFalsy()
1512
+ })
1513
+
1514
+ it('leaves a cross-source blend alone — it already has two servers', () => {
1515
+ // Asking for exclusivity here would force the host to respawn a session it
1516
+ // had already prewarmed, tearing down a decoder at the instant the blend
1517
+ // needs frames from it, to reach the state it was in to begin with.
1518
+ const h = harness(crossfade())
1519
+ h.scheduler.play()
1520
+ step(h, 2.5)
1521
+ const prewarmed = h.host.server('b')
1522
+ step(h, 3.5)
1523
+ expect(h.host.lastRetain().find((r) => r.clipId === 'b')!.exclusiveServer).toBeFalsy()
1524
+ expect(h.host.droppedClips).not.toContain('b')
1525
+ expect(h.host.server('b')).toBe(prewarmed)
1526
+ })
1527
+
1528
+ it('keeps the incoming session when the blend ends and it stops being exclusive', () => {
1529
+ // The end of a crossfade is NOT a respawn point. By then the incoming clip
1530
+ // is the active one and the transport runs on its master clock, so dropping
1531
+ // its session to re-file its server would tear down that clock and
1532
+ // terminate its decoder mid-playback — an artefact worse than the hard cut
1533
+ // this task removed. The exclusive server is released when the clip leaves
1534
+ // the retained set, not when the flag does.
1535
+ const h = harness(crossfade([], sameTake))
1536
+ h.scheduler.play()
1537
+ step(h, 3.5)
1538
+ const during = h.host.server('b')
1539
+ step(h, 4.5)
1540
+ expect(h.host.lastRetain().find((r) => r.clipId === 'b')!.exclusiveServer).toBeFalsy()
1541
+ expect(h.host.droppedClips).not.toContain('b')
1542
+ expect(h.host.server('b')).toBe(during)
1543
+ expect(during.disposed).toBe(false)
1544
+ })
1545
+
1546
+ it('still paints the outgoing frame alone if the host hands both sides one server', () => {
1547
+ // `blendSideFor`'s same-server check, which `exclusiveServer` makes
1548
+ // unreachable by construction — the only way in is a host that ignores the
1549
+ // flag. Kept as an assertion rather than deleted: two read positions on one
1550
+ // decoder would have `nextFrameFor` answer from the wrong place in the
1551
+ // file, and a stalled picture reads as a decoder bug, not a transition.
1552
+ const h = harness(crossfade([], sameTake))
1553
+ h.host.honourExclusive = false
1554
+ h.scheduler.play()
1555
+ step(h, 2.5)
1556
+ const frame = fakeFrame(2_500_000)
1557
+ h.host.server('a').supply = () => frame
1558
+ step(h, 3.5)
1559
+
1560
+ expect(h.host.server('a')).toBe(h.host.server('b'))
1561
+ expect(h.painter.blends).toHaveLength(0)
1562
+ expect(h.painter.paints.map((paint) => paint.frame)).toEqual([frame])
1563
+ })
1564
+
1565
+ it('blends a paused scrub from both seeks', async () => {
1566
+ const h = harness(crossfade())
1567
+ h.scheduler.seek(3.5)
1568
+ const fromFrame = fakeFrame(3_500_000)
1569
+ const toFrame = fakeFrame(500_000)
1570
+ h.host.server('a').pendingSeeks[0](asFrame(fromFrame))
1571
+ h.host.server('b').pendingSeeks[0](asFrame(toFrame))
1572
+ await flush()
1573
+
1574
+ expect(h.painter.blends).toHaveLength(1)
1575
+ expect(h.painter.blends[0].from).toBe(fromFrame)
1576
+ expect(h.painter.blends[0].to).toBe(toFrame)
1577
+ expect(h.painter.blends[0].p).toBeCloseTo(0.5, 10)
1578
+ })
1579
+
1580
+ it('paints the outgoing clip alone when a paused scrub lands only one frame', async () => {
1581
+ const h = harness(crossfade())
1582
+ h.scheduler.seek(3.5)
1583
+ const fromFrame = fakeFrame(3_500_000)
1584
+ h.host.server('a').pendingSeeks[0](asFrame(fromFrame))
1585
+ h.host.server('b').pendingSeeks[0](null)
1586
+ await flush()
1587
+
1588
+ expect(h.painter.blends).toHaveLength(0)
1589
+ expect(h.painter.paints.map((paint) => paint.frame)).toEqual([fromFrame])
1590
+ })
1591
+ })
1592
+
1232
1593
  // ── Lifecycle ───────────────────────────────────────────────────────────────
1233
1594
 
1234
1595
  describe('lifecycle', () => {