@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
@@ -18,6 +18,12 @@
18
18
  * when `src` itself changed;
19
19
  * - `volume`/`muted` edits on a live clip never reached the session at all
20
20
  * (`MasterClock.setVolume` had zero call sites anywhere in the engine).
21
+ *
22
+ * SP-transitions 9b added the exclusive-server block at the foot of the file,
23
+ * for the same reason: `acquireServer`'s keying and its refcount hygiene are
24
+ * only reachable from here. `demuxCalls` is the fetch counter those tests
25
+ * assert on and `serverInstances` is the decoder ledger — both already hoisted
26
+ * above, no parallel helpers needed.
21
27
  */
22
28
  import { beforeEach, describe, expect, it, vi } from 'vitest'
23
29
  import type { EditorProject as Project, VisualItem } from '../../schema'
@@ -453,3 +459,162 @@ describe('EngineSourceHost.acquirePinnedDemux — shared demux LRU', () => {
453
459
  engine.dispose()
454
460
  })
455
461
  })
462
+
463
+ // ── 9b: the exclusive-server carve-out ──────────────────────────────────────
464
+ describe('EngineSourceHost.acquireServer — a second decoder for a blending pair off ONE src', () => {
465
+ /**
466
+ * Three cuts of ONE take — what a silence-trimmed timeline is made of. `a`
467
+ * and `b` overlap on [3, 4) so the resolver reads them as a crossfade pair:
468
+ * the commonest crossfade there is, and the one the engine could not blend
469
+ * before 9b. `c` picks the picture up at 8 off the same take, which is what
470
+ * makes the demotion observable — it is the clip that inherits `b`'s decoder.
471
+ */
472
+ function sameTakeProject(): Project {
473
+ const cut = (id: string, start: number, end: number): VisualItem =>
474
+ videoItem({
475
+ id,
476
+ src: '/media/take.mov',
477
+ proxySrc: '/proxies/take_proxy.mp4',
478
+ start,
479
+ end,
480
+ inPoint: start,
481
+ outPoint: end,
482
+ })
483
+ return {
484
+ id: 'p1',
485
+ status: 'draft',
486
+ settings: { resolution: [1080, 1920], fps: 30 },
487
+ tracks: [{ id: 'trk-0', items: [cut('a', 0, 4), cut('b', 3, 8), cut('c', 8, 12)] }],
488
+ } as Project
489
+ }
490
+
491
+ const engineFor = (project: Project) =>
492
+ createEngine(project, { fileUrl: (p) => p, nowMs: () => 0 })
493
+
494
+ it('an exclusive request gets its OWN FrameServer for the same src', async () => {
495
+ const engine = engineFor(sameTakeProject())
496
+ engine.seek(3.5) // mid-overlap: `a` outgoing, `b` incoming and exclusive
497
+ await flush()
498
+
499
+ // Two decoders off one proxy — the carve-out. Before 9b this was one, and
500
+ // the blend had no second read position to ask for.
501
+ expect(serverInstances).toHaveLength(2)
502
+
503
+ engine.dispose()
504
+ })
505
+
506
+ it('non-exclusive requests on one src still SHARE a server — the invariant holds', async () => {
507
+ const engine = engineFor(sameTakeProject())
508
+ engine.seek(2.5) // `a` active, `b` prewarmed as `next`; no blend yet
509
+ await flush()
510
+
511
+ expect(serverInstances).toHaveLength(1)
512
+
513
+ engine.dispose()
514
+ })
515
+
516
+ it('the exclusive server reuses the cached demux rather than re-fetching', async () => {
517
+ // The whole justification for the carve-out. If this fails the carve-out is
518
+ // not cheap and the invariant should win instead.
519
+ const engine = engineFor(sameTakeProject())
520
+ engine.seek(2.5)
521
+ await flush()
522
+ const fetches = demuxCalls.length
523
+ expect(fetches).toBe(1)
524
+
525
+ engine.seek(3.5) // `b` moves onto a decoder of its own
526
+ await flush()
527
+
528
+ expect(serverInstances.length).toBeGreaterThan(1) // it really did spawn one
529
+ // LENGTH, not membership: a re-demux appends a SECOND '/proxies/take_proxy
530
+ // .mp4' to this array, so `toContain` would stay green through the exact
531
+ // regression this test exists to catch.
532
+ expect(demuxCalls).toHaveLength(fetches)
533
+
534
+ engine.dispose()
535
+ })
536
+
537
+ it('releasing the exclusive clip disposes ITS server and leaves the shared one alive', async () => {
538
+ // The refcount key CHANGES between calls — the release path must use the
539
+ // same key the acquire used, or the entry is orphaned and never disposed.
540
+ const project = sameTakeProject()
541
+ const engine = engineFor(project)
542
+ engine.seek(3.5)
543
+ await flush()
544
+ expect(serverInstances).toHaveLength(2)
545
+ const [shared, exclusive] = serverInstances
546
+
547
+ // Drop `b` outright, leaving the shared clip retained.
548
+ const items = project.tracks![0].items as VisualItem[]
549
+ engine.updateProject({ ...project, tracks: [{ id: 'trk-0', items: [items[0]] }] } as Project)
550
+ await flush()
551
+
552
+ // Named instances, not a count: "exactly one server was disposed" is also
553
+ // true when the WRONG one was, which is precisely what an inverted key does.
554
+ expect(exclusive.disposed).toBe(true)
555
+ expect(shared.disposed).toBe(false)
556
+
557
+ engine.dispose()
558
+ })
559
+
560
+ it('keeps the exclusive server while the outgoing clip still holds the shared key', async () => {
561
+ // The blend ends at 4, but `retainFor` keeps the outgoing clip as `prev`,
562
+ // so the shared key is still TAKEN at the moment `b` stops being exclusive.
563
+ // Two live servers for one src cannot be merged into one: re-filing `b`
564
+ // there would either overwrite `a`'s entry — orphaning a worker that is
565
+ // still decoding, and one nothing would ever dispose — or merge the
566
+ // refcounts, so the first release would tear down a server the other clip
567
+ // is still streaming from. `demoteServer` declines and waits instead.
568
+ //
569
+ // Both halves have to be in ONE test: the orphan an overwrite creates is
570
+ // invisible until `a` is released and its server fails to be disposed.
571
+ const engine = engineFor(sameTakeProject())
572
+ engine.seek(3.5)
573
+ await flush()
574
+ expect(serverInstances).toHaveLength(2)
575
+ const [outgoing, incoming] = serverInstances
576
+
577
+ engine.seek(4.5) // past the overlap; `b` active, `a` still retained as `prev`
578
+ await flush()
579
+ expect(serverInstances).toHaveLength(2)
580
+ expect(outgoing.disposed).toBe(false)
581
+ expect(incoming.disposed).toBe(false)
582
+
583
+ engine.seek(8.5) // `a` finally leaves the retained set
584
+ await flush()
585
+
586
+ // `a`'s worker was terminated under the key `a` itself held. An overwrite
587
+ // back at 4.5 would have left this false forever — `a`'s release would find
588
+ // `b`'s entry under the shared key, delete a ref that was never there, and
589
+ // walk away from a running decoder.
590
+ expect(outgoing.disposed).toBe(true)
591
+ expect(incoming.disposed).toBe(false)
592
+ // And `c` still shares the demoted server rather than spawning a third.
593
+ expect(serverInstances).toHaveLength(2)
594
+
595
+ engine.dispose()
596
+ })
597
+
598
+ it('hands the exclusive server back to the shared key once that key frees', async () => {
599
+ // The demotion on its own, with no contended step in between: once `a`
600
+ // leaves the retained set its entry is disposed and removed in the SAME
601
+ // `retain` — the drop loop runs before the demotion loop — so the shared
602
+ // key is free by the time `b` is re-filed under it, decoder untouched. `c`
603
+ // then finds that entry and shares it, which is the invariant restored
604
+ // rather than merely deferred.
605
+ const engine = engineFor(sameTakeProject())
606
+ engine.seek(3.5)
607
+ await flush()
608
+ expect(serverInstances).toHaveLength(2)
609
+
610
+ engine.seek(8.5) // `c` active, `b` is `prev`, `a` has left the retained set
611
+ await flush()
612
+
613
+ // Still two: `c` found `b`'s demoted server instead of spawning a third.
614
+ expect(serverInstances).toHaveLength(2)
615
+ expect(serverInstances[0].disposed).toBe(true)
616
+ expect(serverInstances[1].disposed).toBe(false)
617
+
618
+ engine.dispose()
619
+ })
620
+ })
@@ -29,8 +29,9 @@
29
29
  * placeholder fallback rather than a whole-project revert) land in T6. This
30
30
  * module is the pure/async logic T6 calls into.
31
31
  */
32
+ import { transitionPairs } from '@bycrux/timeline-core'
32
33
  import type { EditorProject as Project } from '../schema'
33
- import { trackItems } from '../video/timeline/timeline-model'
34
+ import { enabledTrackItems, trackItems } from '../video/timeline/timeline-model'
34
35
 
35
36
  export interface EligibilityResult {
36
37
  eligible: boolean
@@ -138,3 +139,38 @@ export async function evaluateEngineEligibility(project: Project): Promise<Eligi
138
139
  }
139
140
  return { eligible: true }
140
141
  }
142
+
143
+ /**
144
+ * Does this project need the engine for a reason the LEGACY `<video>` player
145
+ * cannot serve on its own? Additive to the checks above — this is not part of
146
+ * `checkProjectShapeEligibility`/`evaluateEngineEligibility`'s eligibility
147
+ * verdict, it answers a different question ("is legacy playback wrong here",
148
+ * not "can the engine run this project").
149
+ *
150
+ * Today the only such reason is a CLIP (video/image) crossfade on the
151
+ * PRIMARY footage track (`tracks[0]`): the resolver stamps
152
+ * `ResolvedItem.crossfade` for clip pairs per `transitionPairs`
153
+ * (`@bycrux/timeline-core`, matching `activation.js`'s `crossfadesAt`), and
154
+ * both render and the engine's own preview blend it — but the legacy player
155
+ * mounts one `<video>` element per clip with no compositing stage, so it can
156
+ * only hard-cut. Clip pairs on an OVERLAY track are excluded, and not just
157
+ * because their fade is baked `opacity` keyframe data
158
+ * (`computeVisualCrossfade`, which the legacy player already renders
159
+ * correctly via the DOM/CSS layer): `OverlayItemsLayer` blends overlay clip
160
+ * pairs itself in legacy mode too, so there is nothing here the legacy path
161
+ * cannot already serve. A track-0-only check also means `transitionPairs`
162
+ * naturally short-circuits (needs 2+ items) so an empty or single-clip
163
+ * primary track costs nothing extra.
164
+ *
165
+ * Reads via `enabledTrackItems`, not `trackItems`: a `enabled: false` track's
166
+ * clips are invisible to every real blend consumer (preview, render), so an
167
+ * overlapping pair sitting on a skipped track must not raise this banner —
168
+ * there is no crossfade for the legacy player to fail at rendering.
169
+ */
170
+ export function engineRequiredReason(project: Project): 'clip-crossfade' | null {
171
+ const primary = enabledTrackItems(project)[0] ?? []
172
+ const clips = primary.filter((item) => item.type === 'video' || item.type === 'image')
173
+ if (clips.length < 2) return null
174
+ if (transitionPairs(clips).length > 0) return 'clip-crossfade'
175
+ return null
176
+ }
@@ -31,6 +31,14 @@
31
31
  * stream from one server at once — the scheduler stops the outgoing
32
32
  * session before starting the incoming one — and when the last clip
33
33
  * referencing a src is dropped, the worker really is terminated.
34
+ *
35
+ * ONE carve-out, and it does not overturn the reasoning above: the two
36
+ * sides of a crossfade cut from a single take need two read positions at
37
+ * the same instant, which one server cannot serve, so the incoming side
38
+ * asks for its own (`SourceRequest.exclusiveServer`) for the length of the
39
+ * blend. What the rule actually saves is the DEMUX, and that survives — the
40
+ * cache below is keyed by `src` independently of the server map, so the
41
+ * second decoder costs one worker and not one byte of extra fetch.
34
42
  * - **`MasterClock` — one per CLIP**, because it is anchored to that clip's
35
43
  * `start`/`inPoint`/`volume`/`muted` and T4 is explicit that a live clock is
36
44
  * never reconfigured.
@@ -52,6 +60,7 @@ import type { FileUrlResolver } from './media-loader'
52
60
  import {
53
61
  createScheduler,
54
62
  type ClipSource,
63
+ type DrawPlan,
55
64
  type EngineStatus,
56
65
  type Painter,
57
66
  type Scheduler,
@@ -249,23 +258,43 @@ export function createCanvasPainter(canvas: HTMLCanvasElement): Painter {
249
258
  ctx.fillStyle = '#000'
250
259
  ctx.fillRect(0, 0, canvas.width, canvas.height)
251
260
  }
261
+ const draw = (frame: VideoFrame, plan: DrawPlan) => {
262
+ if (!ctx) return
263
+ if (plan.sw <= 0 || plan.sh <= 0 || plan.dw <= 0 || plan.dh <= 0) return
264
+ ctx.drawImage(
265
+ frame,
266
+ plan.sx,
267
+ plan.sy,
268
+ plan.sw,
269
+ plan.sh,
270
+ plan.dx,
271
+ plan.dy,
272
+ plan.dw,
273
+ plan.dh,
274
+ )
275
+ }
252
276
  return {
253
277
  size: () => ({ width: canvas.width, height: canvas.height }),
254
278
  paint(frame, plan) {
255
279
  if (!ctx) return
256
280
  fill()
257
- if (plan.sw <= 0 || plan.sh <= 0 || plan.dw <= 0 || plan.dh <= 0) return
258
- ctx.drawImage(
259
- frame,
260
- plan.sx,
261
- plan.sy,
262
- plan.sw,
263
- plan.sh,
264
- plan.dx,
265
- plan.dy,
266
- plan.dw,
267
- plan.dh,
268
- )
281
+ draw(frame, plan)
282
+ },
283
+ paintBlend(from, to, p, fromPlan, toPlan) {
284
+ if (!ctx) return
285
+ fill()
286
+ draw(from, fromPlan)
287
+ // Source-over at alpha p composites `p*to + (1-p)*from` onto the outgoing
288
+ // picture already on the canvas — the lerp `encode-segment.js` emits as
289
+ // `blend=all_expr='A+(B-A)*p'`. Restored in `finally` because the context
290
+ // is long-lived: a leaked alpha would ghost every later paint, including
291
+ // the black fill.
292
+ try {
293
+ ctx.globalAlpha = p
294
+ draw(to, toPlan)
295
+ } finally {
296
+ ctx.globalAlpha = 1
297
+ }
269
298
  },
270
299
  clear: fill,
271
300
  }
@@ -311,6 +340,23 @@ interface Session {
311
340
  * not.
312
341
  */
313
342
  speed: number
343
+ /**
344
+ * `!!request.exclusiveServer` as of the last (re)build — see
345
+ * {@link serverKeyFor}. Stamped alongside the fields above so `retain`'s drop
346
+ * test can see it change, but it is NOT one of them: a clip that stops being
347
+ * the incoming side of a blend keeps the session and the decoder it already
348
+ * has. See `retain`.
349
+ */
350
+ exclusive: boolean
351
+ /**
352
+ * The `servers` key this session actually holds a ref under, set when
353
+ * `acquireServer` returns. Stored rather than re-derived at release time:
354
+ * the REQUEST's exclusivity can change between two `retain` calls while the
355
+ * acquired entry cannot, and a release keyed off the current request would
356
+ * leave the acquired entry orphaned in the map with its worker running for
357
+ * the rest of the session's life. Undefined until the server is acquired.
358
+ */
359
+ serverKey?: string
314
360
  /** Rebuild attempts already spent on this clip — see {@link MAX_SESSION_RETRIES}. */
315
361
  retries: number
316
362
  /**
@@ -320,8 +366,25 @@ interface Session {
320
366
  retryAfterMs: number
321
367
  }
322
368
 
369
+ /**
370
+ * The `servers` key for one clip's decode session.
371
+ *
372
+ * `src` for the ordinary case — that IS the one-server-per-src rule this file's
373
+ * header states. An exclusive request gets an entry of its own instead, so the
374
+ * two sides of a crossfade cut from a single take have two read positions; see
375
+ * `SourceRequest.exclusiveServer` for why that carve-out is cheap.
376
+ */
377
+ const serverKeyFor = (src: string, clipId: string, exclusive: boolean): string =>
378
+ exclusive ? `${src}#${clipId}` : src
379
+
323
380
  interface ServerEntry {
324
381
  server: FrameServer
382
+ /**
383
+ * The src this server decodes. Not derivable from the map key, which carries
384
+ * a clip id too on an exclusive entry — and `evictDemux` has to know whether
385
+ * ANY live session is reading a src before it drops that src's demux.
386
+ */
387
+ src: string
325
388
  refs: Set<string>
326
389
  }
327
390
 
@@ -373,9 +436,27 @@ class EngineSourceHost implements SourceHost {
373
436
  want.item.start !== session.start ||
374
437
  sourceWindow(want.item, 'preview').inPoint !== session.inPoint ||
375
438
  !!want.item.muted !== session.muted ||
376
- (want.item.speed ?? 1) !== session.speed
439
+ (want.item.speed ?? 1) !== session.speed ||
440
+ // A clip that BECOMES the incoming side of a blend has to move onto a
441
+ // decoder of its own, and a live session cannot change servers. It is
442
+ // usually already built by then — the scheduler prewarms it as `next`
443
+ // before the overlap starts — so without this the blend would find both
444
+ // sides on one server and decline. Deliberately one-directional: see
445
+ // the demotion loop below for why the reverse is NOT a respawn.
446
+ (!!want.exclusiveServer && !session.exclusive)
377
447
  if (changed) this.dropSession(clipId)
378
448
  }
449
+ // The other edge of `exclusiveServer`: a clip that STOPS being the incoming
450
+ // side of a blend, which happens the instant the outgoing clip ends. By
451
+ // then it is the ACTIVE clip — the transport is running on its master clock
452
+ // — so a respawn here would tear down that clock and terminate its decoder
453
+ // mid-playback at the end of every crossfade, an artefact worse than the
454
+ // hard cut 9b removed. The session keeps the decoder it already has; all
455
+ // that changes is the key it is filed under.
456
+ for (const request of requests) {
457
+ const session = this.sessions.get(request.clipId)
458
+ if (session?.exclusive && !request.exclusiveServer) this.demoteServer(session)
459
+ }
379
460
  // A clip's volume can change without a rebuild: push it straight to the
380
461
  // live clock (`MasterClock.setVolume`) rather than tearing the session
381
462
  // down. Only sessions that survived the drop loop above and already have a
@@ -468,6 +549,7 @@ class EngineSourceHost implements SourceHost {
468
549
  muted: !!request.item.muted,
469
550
  volume: request.item.volume ?? 1,
470
551
  speed: request.item.speed ?? 1,
552
+ exclusive: !!request.exclusiveServer,
471
553
  retries,
472
554
  retryAfterMs: 0,
473
555
  }
@@ -483,7 +565,7 @@ class EngineSourceHost implements SourceHost {
483
565
  try {
484
566
  const demuxed = await this.acquireDemux(session.src)
485
567
  if (session.cancelled) return
486
- const server = this.acquireServer(session.src, demuxed, session.clipId)
568
+ const server = this.acquireServer(session, demuxed, !!request.exclusiveServer)
487
569
  acquired = true
488
570
 
489
571
  const window = sourceWindow(request.item, 'preview')
@@ -523,7 +605,7 @@ class EngineSourceHost implements SourceHost {
523
605
  })
524
606
  if (session.cancelled) {
525
607
  clock.dispose()
526
- this.releaseServer(session.src, session.clipId)
608
+ this.releaseServer(session)
527
609
  return
528
610
  }
529
611
  session.source = {
@@ -535,7 +617,7 @@ class EngineSourceHost implements SourceHost {
535
617
  }
536
618
  session.status = 'ready'
537
619
  } catch (err) {
538
- if (acquired) this.releaseServer(session.src, session.clipId)
620
+ if (acquired) this.releaseServer(session)
539
621
  if (session.cancelled) return
540
622
  const message = err instanceof Error ? err.message : String(err)
541
623
  this.markFailed(session, message)
@@ -563,7 +645,7 @@ class EngineSourceHost implements SourceHost {
563
645
  this.sessions.delete(clipId)
564
646
  session.cancelled = true
565
647
  session.source?.clock.dispose()
566
- if (session.source) this.releaseServer(session.src, clipId)
648
+ if (session.source) this.releaseServer(session)
567
649
  else this.abandonDemux(session.src)
568
650
  }
569
651
 
@@ -586,7 +668,7 @@ class EngineSourceHost implements SourceHost {
586
668
  session.source?.clock.dispose()
587
669
  if (session.source) {
588
670
  session.source = undefined
589
- this.releaseServer(src, session.clipId)
671
+ this.releaseServer(session)
590
672
  }
591
673
  this.scheduler?.sourceChanged(session.clipId)
592
674
  }
@@ -594,32 +676,68 @@ class EngineSourceHost implements SourceHost {
594
676
 
595
677
  // ── per-src resources ─────────────────────────────────────────────────────
596
678
 
597
- private acquireServer(src: string, source: DemuxedSource, clipId: string): FrameServer {
598
- let entry = this.servers.get(src)
679
+ private acquireServer(session: Session, source: DemuxedSource, exclusive: boolean): FrameServer {
680
+ const src = session.src
681
+ const key = serverKeyFor(src, session.clipId, exclusive)
682
+ let entry = this.servers.get(key)
599
683
  if (!entry) {
600
684
  entry = {
601
685
  server: createFrameServer({
602
686
  source,
603
687
  hardwareAcceleration: this.deps.hardwareAcceleration,
604
688
  decodeAheadFrames: this.deps.decodeAheadFrames,
689
+ // Reported against the RAW src, never the key: a decode error is a
690
+ // property of the file, so every session reading it has to hear
691
+ // about it — including the one on the other side of a blend.
605
692
  onError: (message) => this.onDecodeError(src, message),
606
693
  }),
694
+ src,
607
695
  refs: new Set(),
608
696
  }
609
- this.servers.set(src, entry)
697
+ this.servers.set(key, entry)
610
698
  }
611
- entry.refs.add(clipId)
699
+ entry.refs.add(session.clipId)
700
+ // Stamped here rather than by the caller so the key a session releases is
701
+ // by construction the key it acquired, whatever the request said later.
702
+ session.serverKey = key
703
+ session.exclusive = exclusive
612
704
  return entry.server
613
705
  }
614
706
 
615
- /** Last clip off this src leaves ⇒ the worker is terminated. The spike's `load` rule. */
616
- private releaseServer(src: string, clipId: string): void {
617
- const entry = this.servers.get(src)
707
+ /** Last clip off this server leaves ⇒ the worker is terminated. The spike's `load` rule. */
708
+ private releaseServer(session: Session): void {
709
+ const key = session.serverKey
710
+ if (key === undefined) return
711
+ session.serverKey = undefined
712
+ const entry = this.servers.get(key)
618
713
  if (!entry) return
619
- entry.refs.delete(clipId)
714
+ entry.refs.delete(session.clipId)
620
715
  if (entry.refs.size > 0) return
621
716
  entry.server.dispose()
622
- this.servers.delete(src)
717
+ this.servers.delete(key)
718
+ }
719
+
720
+ /**
721
+ * File a no-longer-exclusive session's server back under the shared key,
722
+ * without disturbing the decoder itself — see the demotion loop in `retain`
723
+ * for why this is not a respawn.
724
+ *
725
+ * Only possible while the shared key is free: two live servers for one src
726
+ * cannot be merged into one. When it is taken (the outgoing clip of the
727
+ * crossfade is usually still retained as `prev`, holding exactly that key)
728
+ * the exclusive entry simply stays where it is and is terminated with the
729
+ * clip, which is bounded by the retained set and costs one worker — never a
730
+ * leak, because the session still holds the key it acquired.
731
+ */
732
+ private demoteServer(session: Session): void {
733
+ const key = session.serverKey
734
+ if (key === undefined || key === session.src) return
735
+ const entry = this.servers.get(key)
736
+ if (!entry || this.servers.has(session.src)) return
737
+ this.servers.delete(key)
738
+ this.servers.set(session.src, entry)
739
+ session.serverKey = session.src
740
+ session.exclusive = false
623
741
  }
624
742
 
625
743
  private async acquireDemux(src: string): Promise<DemuxedSource> {
@@ -706,8 +824,11 @@ class EngineSourceHost implements SourceHost {
706
824
  while (this.demuxCache.size > DEMUX_CACHE_MAX) {
707
825
  let victim: string | null = null
708
826
  for (const src of this.demuxCache.keys()) {
709
- // Never evict a source a live decode session is reading from.
710
- if (this.servers.has(src)) continue
827
+ // Never evict a source a live decode session is reading from. Asked of
828
+ // the ENTRIES, not the keys: an exclusive server is filed under a key
829
+ // that carries a clip id, and evicting the demux out from under one is
830
+ // exactly the re-fetch the carve-out promises not to cause.
831
+ if (this.hasServerFor(src)) continue
711
832
  // Or one an outside consumer (the drag-scrub source) is holding.
712
833
  if (this.demuxPins.has(src)) continue
713
834
  victim = src
@@ -718,6 +839,14 @@ class EngineSourceHost implements SourceHost {
718
839
  }
719
840
  }
720
841
 
842
+ /** Any live decode session reading this src — under the shared key or an exclusive one. */
843
+ private hasServerFor(src: string): boolean {
844
+ for (const [, entry] of this.servers) {
845
+ if (entry.src === src) return true
846
+ }
847
+ return false
848
+ }
849
+
721
850
  /**
722
851
  * Shared with the drag-scrub audio source. `acquireDemux` already coalesces
723
852
  * concurrent builds and populates the LRU; this wraps it in a per-src pin
@@ -878,12 +1007,19 @@ export function createEngine(project: Project, deps: EngineDeps): Engine {
878
1007
  sizeCanvas()
879
1008
  const painter = createCanvasPainter(next)
880
1009
  // Wrapped only to feed `stats().fps` — every other call is `painter`'s own.
1010
+ // A blend is ONE painted frame however many sources it composites, so it
1011
+ // records exactly like a plain paint; leaving it out would read as a
1012
+ // frame rate collapse for the length of every crossfade.
881
1013
  scheduler.attach({
882
1014
  ...painter,
883
1015
  paint: (frame, plan) => {
884
1016
  recordPaint()
885
1017
  painter.paint(frame, plan)
886
1018
  },
1019
+ paintBlend: (from, to, p, fromPlan, toPlan) => {
1020
+ recordPaint()
1021
+ painter.paintBlend(from, to, p, fromPlan, toPlan)
1022
+ },
887
1023
  })
888
1024
  },
889
1025
  play() {