@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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bycrux/editor",
|
|
3
|
-
"version": "1.0
|
|
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.
|
|
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[]) =>
|
|
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
|
-
|
|
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.
|
|
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:
|
|
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', () => {
|