@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 +2 -2
- package/src/engine/__tests__/eligibility.test.ts +97 -0
- package/src/engine/__tests__/engine.test.ts +47 -2
- package/src/engine/__tests__/scheduler.test.ts +364 -3
- package/src/engine/__tests__/source-host.test.ts +165 -0
- package/src/engine/eligibility.ts +37 -1
- package/src/engine/index.ts +165 -29
- package/src/engine/scheduler.ts +331 -19
- package/src/index.ts +4 -0
- package/src/schema.ts +20 -1
- package/src/video/CaptionSpecimen.tsx +2 -1
- package/src/video/VideoEditor.tsx +28 -8
- package/src/video/__tests__/VideoEditor.test.tsx +169 -0
- package/src/video/captionStyleDefaults.ts +2 -2
- package/src/video/preview/OverlayItemsLayer.tsx +85 -6
- package/src/video/preview/PreviewPlayer.tsx +28 -1
- package/src/video/preview/__tests__/OverlayItemsLayer.keyframes.test.tsx +91 -0
- package/src/video/preview/__tests__/PreviewPlayer.engine.test.tsx +49 -1
- package/src/video/timeline/Timeline.tsx +43 -1
- package/src/video/timeline/__tests__/timeline-model.test.ts +307 -0
- package/src/video/timeline/canvas/__tests__/pointer-machine.test.ts +116 -0
- package/src/video/timeline/canvas/pointer-machine.ts +59 -1
- 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
|
+
}
|
package/src/engine/index.ts
CHANGED
|
@@ -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
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
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(
|
|
598
|
-
|
|
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(
|
|
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
|
|
616
|
-
private releaseServer(
|
|
617
|
-
const
|
|
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(
|
|
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
|
-
|
|
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() {
|