@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.
- package/package.json +2 -2
- package/src/ControlsInfoModal.tsx +1 -0
- 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 +385 -4
- 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 +345 -23
- package/src/index.ts +8 -0
- package/src/schema.ts +41 -1
- package/src/video/VideoEditor.tsx +106 -10
- package/src/video/__tests__/VideoEditor.keymap.test.tsx +64 -0
- package/src/video/__tests__/VideoEditor.test.tsx +169 -0
- package/src/video/__tests__/cuts.test.ts +84 -0
- package/src/video/__tests__/exportDurationSec.test.ts +60 -0
- package/src/video/__tests__/markerDropTime.test.ts +25 -0
- package/src/video/captionStyleDefaults.ts +2 -2
- package/src/video/cuts.ts +30 -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/preview/__tests__/useVideoPlayback.canvasClock.test.ts +99 -0
- package/src/video/preview/useVideoPlayback.ts +18 -3
- package/src/video/timeline/Timeline.tsx +49 -1
- package/src/video/timeline/__tests__/Timeline.keymap.test.tsx +16 -0
- package/src/video/timeline/__tests__/markers.test.ts +125 -0
- package/src/video/timeline/__tests__/timeline-model.test.ts +307 -0
- package/src/video/timeline/canvas/TimelineCanvas.tsx +100 -4
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.test.tsx +106 -2
- package/src/video/timeline/canvas/__tests__/draw.test.ts +73 -0
- package/src/video/timeline/canvas/__tests__/hit-test.test.ts +76 -0
- package/src/video/timeline/canvas/__tests__/pointer-machine.test.ts +216 -1
- package/src/video/timeline/canvas/draw.ts +104 -7
- package/src/video/timeline/canvas/hit-test.ts +72 -1
- package/src/video/timeline/canvas/pointer-machine.ts +142 -5
- package/src/video/timeline/markers.ts +109 -0
- package/src/video/timeline/timeline-model.ts +286 -15
package/src/engine/scheduler.ts
CHANGED
|
@@ -79,7 +79,7 @@ import {
|
|
|
79
79
|
resolveAt,
|
|
80
80
|
sourceWindow,
|
|
81
81
|
} from '@bycrux/timeline-core'
|
|
82
|
-
import type { Scene, SourceWindow } from '@bycrux/timeline-core'
|
|
82
|
+
import type { ItemCrossfade, Scene, SourceWindow } from '@bycrux/timeline-core'
|
|
83
83
|
import type { EditorProject as Project, VisualItem, VisualTrack } from '../schema'
|
|
84
84
|
import type { ClipTimebase, MasterClock } from './audio-clock'
|
|
85
85
|
import type { FrameServer } from './frame-server'
|
|
@@ -140,6 +140,18 @@ export interface Painter {
|
|
|
140
140
|
size(): { width: number; height: number }
|
|
141
141
|
/** Draw one frame. The scheduler closes the frame immediately after this returns. */
|
|
142
142
|
paint(frame: VideoFrame, plan: DrawPlan): void
|
|
143
|
+
/**
|
|
144
|
+
* Draw both sides of a crossfade as ONE picture: `from` at full alpha, `to`
|
|
145
|
+
* over it at alpha `p`. Source-over composites `p*src + (1-p)*dst`, so that
|
|
146
|
+
* is `(1-p)*from + p*to` — the same lerp `encode-segment.js` emits as
|
|
147
|
+
* `blend=all_expr='A+(B-A)*p'` with A the outgoing picture.
|
|
148
|
+
*
|
|
149
|
+
* Each side carries its own `DrawPlan` because `drawPlanFor` folds that
|
|
150
|
+
* item's `sourceCrop` and its frame's display dimensions, and render puts
|
|
151
|
+
* each item down its own filter branch before blending the two finished
|
|
152
|
+
* frames. Both frames are the scheduler's to close once this returns.
|
|
153
|
+
*/
|
|
154
|
+
paintBlend(from: VideoFrame, to: VideoFrame, p: number, fromPlan: DrawPlan, toPlan: DrawPlan): void
|
|
143
155
|
/** Fill the whole surface with black (gap / opaque / preparing). */
|
|
144
156
|
clear(): void
|
|
145
157
|
}
|
|
@@ -399,6 +411,21 @@ export interface ActiveClip {
|
|
|
399
411
|
placement: SourcePlacement
|
|
400
412
|
}
|
|
401
413
|
|
|
414
|
+
/**
|
|
415
|
+
* The incoming side of a crossfade with its decode session attached — what
|
|
416
|
+
* `TickPlan.blend` names, resolved against the host. Built by `blendSideFor`,
|
|
417
|
+
* which is also where the cases that CANNOT blend are documented.
|
|
418
|
+
*/
|
|
419
|
+
interface BlendSide {
|
|
420
|
+
clipId: string
|
|
421
|
+
/** The resolver's blend factor: 0 at the overlap's start, 1 at its end. */
|
|
422
|
+
p: number
|
|
423
|
+
source: ClipSource
|
|
424
|
+
active: ActiveClip
|
|
425
|
+
/** The incoming clip's own position in its own file, in container µs. */
|
|
426
|
+
mediaUs: number
|
|
427
|
+
}
|
|
428
|
+
|
|
402
429
|
/** Everything one tick needs to know about the timeline at `t`. */
|
|
403
430
|
export interface TickPlan {
|
|
404
431
|
t: number
|
|
@@ -421,6 +448,16 @@ export interface TickPlan {
|
|
|
421
448
|
* retained source cost a sample index rather than a file.
|
|
422
449
|
*/
|
|
423
450
|
prev: { item: VisualItem; clipId: string; start: number } | null
|
|
451
|
+
/**
|
|
452
|
+
* The INCOMING clip of a crossfade and how far through it is; `active` is the
|
|
453
|
+
* outgoing one. Null on every ordinary tick.
|
|
454
|
+
*
|
|
455
|
+
* `p` is the resolver's, so preview blends on exactly the ramp the segment
|
|
456
|
+
* encoder does — `(1-p)*from + p*to`, which is `blend=all_expr='A+(B-A)*p'`
|
|
457
|
+
* with A the outgoing picture (`encode-segment.js`). Deriving a second one
|
|
458
|
+
* here is how the two engines would drift.
|
|
459
|
+
*/
|
|
460
|
+
blend: { clipId: string; p: number } | null
|
|
424
461
|
/** No track-0 video items at all — the legacy `isCanvasProject`. */
|
|
425
462
|
canvas: boolean
|
|
426
463
|
}
|
|
@@ -480,9 +517,9 @@ function withTrackAudio(track: VisualTrack | undefined, item: VisualItem): Visua
|
|
|
480
517
|
* Two formulas, because the legacy hook has two and they legitimately differ:
|
|
481
518
|
*
|
|
482
519
|
* - **Canvas projects** (no track-0 video) run the `isCanvasProject` rAF,
|
|
483
|
-
* whose ceiling is `canvasMaxEndRef` = `max(
|
|
520
|
+
* whose ceiling is `canvasMaxEndRef` = `max(visualEnd, captionEnd)`. Note
|
|
484
521
|
* what is NOT in it: audio. A canvas project whose music outlasts its
|
|
485
|
-
*
|
|
522
|
+
* visuals stops at the visuals today.
|
|
486
523
|
* - **Video projects** use `projectEnd` = `max(videoEnd, overlayEnd,
|
|
487
524
|
* audioEnd)` — captions excluded, audio included — which timeline-core
|
|
488
525
|
* already ports verbatim (including its two documented faithfulness warts:
|
|
@@ -490,18 +527,28 @@ function withTrackAudio(track: VisualTrack | undefined, item: VisualItem): Visua
|
|
|
490
527
|
*
|
|
491
528
|
* Unifying them would be a behavior change in one mode or the other, so both
|
|
492
529
|
* are kept and the divergence is named here rather than smoothed over.
|
|
530
|
+
*
|
|
531
|
+
* `visualEnd` spans EVERY enabled track, track 0 included — it is deliberately
|
|
532
|
+
* not the video path's `overlayEnd` (`tracks.slice(1)`). In canvas mode track 0
|
|
533
|
+
* is a content track like any other: it carries the background images, and an
|
|
534
|
+
* agent-authored project can put its overlays there too (an animations-workflow
|
|
535
|
+
* project is frequently ONE track holding nothing but overlays). Skipping it
|
|
536
|
+
* made the ceiling collapse to 0 for exactly those projects, so play/space
|
|
537
|
+
* started the transport and stopped it in the same tick. `OverlayItemsLayer`
|
|
538
|
+
* has always drawn track 0 in canvas mode (`isCanvasProject ? enabledTrackItems
|
|
539
|
+
* : overlayTracks`); this is the transport agreeing with what is on screen.
|
|
493
540
|
*/
|
|
494
541
|
export function transportEndFor(project: Project): number {
|
|
495
542
|
const clips = track0VideoItems(project)
|
|
496
543
|
if (clips.length > 0) return timelineProjectEnd(withEnabledItemTracks(project))
|
|
497
|
-
const
|
|
544
|
+
const visualEnd = enabledTrackItems(project)
|
|
498
545
|
.flat()
|
|
499
546
|
.reduce((m, i) => Math.max(m, i?.end ?? 0), 0)
|
|
500
547
|
const captionEnd = (project.captions?.segments ?? []).reduce(
|
|
501
548
|
(m: number, s) => Math.max(m, s.end ?? 0),
|
|
502
549
|
0,
|
|
503
550
|
)
|
|
504
|
-
return Math.max(
|
|
551
|
+
return Math.max(visualEnd, captionEnd)
|
|
505
552
|
}
|
|
506
553
|
|
|
507
554
|
/**
|
|
@@ -553,7 +600,19 @@ export const previewResolver: SceneResolver = (project, t) =>
|
|
|
553
600
|
* - **Which track-0 video item wins** when two overlap. `resolveAt` returns
|
|
554
601
|
* them in document order; the legacy hook picks the first in START order.
|
|
555
602
|
* The earliest-start rule is reproduced so an overlapping pair resolves the
|
|
556
|
-
* same way it does today.
|
|
603
|
+
* same way it does today. It still answers for every overlap the resolver
|
|
604
|
+
* does NOT call a crossfade — containment, and the three-way overlap
|
|
605
|
+
* `engine/validate.py` rejects — where something must own the picture and
|
|
606
|
+
* there is no pair to blend along.
|
|
607
|
+
* - **`blend`**, the incoming side of a crossfade. `transitionPairs` names the
|
|
608
|
+
* earlier item `from`, so a real pair's outgoing side is the same clip the
|
|
609
|
+
* earliest-start rule already picks: the crossfade does not change WHO owns
|
|
610
|
+
* the picture, it stops the incoming clip from being ignored until the
|
|
611
|
+
* outgoing one ends. That hard cut was a genuine preview/export divergence —
|
|
612
|
+
* render composites the LATER item on top for the whole overlap
|
|
613
|
+
* (`segment-plan.js`'s stable trackIdx sort over document order, then
|
|
614
|
+
* `encode-segment.js`'s overlay chain), so preview showed the outgoing clip
|
|
615
|
+
* across a window where the export showed the incoming one.
|
|
557
616
|
* - **`opaque`** is read off any active OVERLAY item on any track, matching
|
|
558
617
|
* render's `overlays.some(o => o.opaque)` (`segment-plan.js`). Track-0
|
|
559
618
|
* videos and images never carry it.
|
|
@@ -567,6 +626,10 @@ export function planTick(
|
|
|
567
626
|
const scene = resolver(project, t)
|
|
568
627
|
|
|
569
628
|
let active: ActiveClip | null = null
|
|
629
|
+
/** The winning clip's own crossfade stamp — `blend` is matched against it below. */
|
|
630
|
+
let activeCrossfade: ItemCrossfade | null = null
|
|
631
|
+
/** Every incoming side the scan saw. More than one means a three-way overlap. */
|
|
632
|
+
const incoming: Array<{ clipId: string; p: number }> = []
|
|
570
633
|
let opaque = false
|
|
571
634
|
for (const resolved of scene.items) {
|
|
572
635
|
if (resolved.kind === 'overlay' && resolved.item.opaque === true) opaque = true
|
|
@@ -575,8 +638,15 @@ export function planTick(
|
|
|
575
638
|
// in timeline-core's `ResolvedItem`), so this recovers the editor-side
|
|
576
639
|
// fields (`loop`, `volume`, `muted`) the resolver's structural view omits.
|
|
577
640
|
const item = resolved.item as unknown as VisualItem
|
|
641
|
+
// Collected, but NOT skipped by the tiebreak below: an incoming clip whose
|
|
642
|
+
// outgoing partner is an IMAGE has no video to blend into, and excluding it
|
|
643
|
+
// from the scan would leave the picture black for the whole overlap.
|
|
644
|
+
if (resolved.crossfade?.role === 'to') {
|
|
645
|
+
incoming.push({ clipId: item.id, p: resolved.crossfade.p })
|
|
646
|
+
}
|
|
578
647
|
if (active && (active.item.start ?? 0) <= (item.start ?? 0)) continue
|
|
579
648
|
const usable = engineSrcFor(item, resolved.window)
|
|
649
|
+
activeCrossfade = resolved.crossfade
|
|
580
650
|
active = {
|
|
581
651
|
item,
|
|
582
652
|
clipId: item.id,
|
|
@@ -587,6 +657,19 @@ export function planTick(
|
|
|
587
657
|
}
|
|
588
658
|
}
|
|
589
659
|
|
|
660
|
+
// Both sides of ONE pair are stamped from a single `transitionProgress` call
|
|
661
|
+
// (`activation.js`'s `crossfadesAt`), so an exactly equal `p` is what says
|
|
662
|
+
// "these two are partners" — the resolver exposes no pair identity. It only
|
|
663
|
+
// matters for the three-way overlap the validator rejects, where two pairs
|
|
664
|
+
// are live at once and the middle clip ends up stamped `from`: no incoming
|
|
665
|
+
// side then carries the active clip's `p`, and the tick degrades to today's
|
|
666
|
+
// hard cut rather than blending two clips that are not a pair.
|
|
667
|
+
let blend: TickPlan['blend'] = null
|
|
668
|
+
if (activeCrossfade?.role === 'from') {
|
|
669
|
+
const p = activeCrossfade.p
|
|
670
|
+
blend = incoming.find((side) => side.p === p) ?? null
|
|
671
|
+
}
|
|
672
|
+
|
|
590
673
|
let next: TickPlan['next'] = null
|
|
591
674
|
for (const clip of clips) {
|
|
592
675
|
if (clip.start > t) {
|
|
@@ -596,10 +679,17 @@ export function planTick(
|
|
|
596
679
|
}
|
|
597
680
|
|
|
598
681
|
// `clips` is start-sorted, so the LAST one starting before `t` that is not
|
|
599
|
-
// the active clip is the one immediately behind it.
|
|
600
|
-
//
|
|
601
|
-
//
|
|
602
|
-
//
|
|
682
|
+
// the active clip is the one immediately behind it. The id check is what
|
|
683
|
+
// keeps `prev` from naming the active clip itself, which is otherwise the
|
|
684
|
+
// answer on every ordinary tick — the active clip IS the last one to have
|
|
685
|
+
// started.
|
|
686
|
+
//
|
|
687
|
+
// Inside an overlap it names the clip AHEAD instead: `active` resolves to the
|
|
688
|
+
// EARLIEST start among the overlapping set (the loop above keeps an incumbent
|
|
689
|
+
// whose start is `<=` the candidate's), so the other side of the overlap
|
|
690
|
+
// started later and is still the last one before `t`. That is why the
|
|
691
|
+
// incoming side of a crossfade is already retained today, by accident;
|
|
692
|
+
// `retainFor` names `blend` outright rather than leaning on it.
|
|
603
693
|
let prev: TickPlan['prev'] = null
|
|
604
694
|
for (const clip of clips) {
|
|
605
695
|
if (clip.start >= t) break
|
|
@@ -607,7 +697,7 @@ export function planTick(
|
|
|
607
697
|
prev = { item: clip, clipId: clip.id, start: clip.start }
|
|
608
698
|
}
|
|
609
699
|
|
|
610
|
-
return { t, active, opaque, next, prev, canvas: clips.length === 0 }
|
|
700
|
+
return { t, active, opaque, next, prev, blend, canvas: clips.length === 0 }
|
|
611
701
|
}
|
|
612
702
|
|
|
613
703
|
// ── Injected async surfaces ─────────────────────────────────────────────────
|
|
@@ -641,6 +731,14 @@ export interface SourceRequest {
|
|
|
641
731
|
src: string
|
|
642
732
|
/** Project time this clip's clock should be anchored at when it is built. */
|
|
643
733
|
anchorProjectS: number
|
|
734
|
+
/**
|
|
735
|
+
* Give this clip its own `FrameServer` even though another clip already
|
|
736
|
+
* streams from the same `src`. Set ONLY for the incoming side of a crossfade,
|
|
737
|
+
* and only while it blends. The one-server-per-src rule in `index.ts`'s
|
|
738
|
+
* header is otherwise intact — and its actual saving, the demux, is preserved
|
|
739
|
+
* either way because `demuxCache` is keyed by `src` independently of this map.
|
|
740
|
+
*/
|
|
741
|
+
exclusiveServer?: boolean
|
|
644
742
|
}
|
|
645
743
|
|
|
646
744
|
/**
|
|
@@ -790,6 +888,11 @@ class SchedulerImpl implements Scheduler {
|
|
|
790
888
|
private clipId: string | null = null
|
|
791
889
|
/** The session with a streaming decode-ahead session open, if any. */
|
|
792
890
|
private streamingSource: ClipSource | null = null
|
|
891
|
+
/**
|
|
892
|
+
* The INCOMING crossfade session streaming alongside it. A second stream is
|
|
893
|
+
* only ever opened on a second frame server — see `blendSideFor`.
|
|
894
|
+
*/
|
|
895
|
+
private blendStream: ClipSource | null = null
|
|
793
896
|
/** Bumped on every seek / boundary / dispose; a resolved seek frame paints only if it still matches. */
|
|
794
897
|
private seekGen = 0
|
|
795
898
|
private pendingSeeks = 0
|
|
@@ -958,6 +1061,7 @@ class SchedulerImpl implements Scheduler {
|
|
|
958
1061
|
this.disposed = true
|
|
959
1062
|
this.seekGen++
|
|
960
1063
|
this.stopStream()
|
|
1064
|
+
this.stopBlendStream()
|
|
961
1065
|
if (this.clockOwner === null) this.clock.dispose()
|
|
962
1066
|
this.clockOwner = null
|
|
963
1067
|
this.clockSource = null
|
|
@@ -1088,6 +1192,20 @@ class SchedulerImpl implements Scheduler {
|
|
|
1088
1192
|
this.pictureReason = reason
|
|
1089
1193
|
|
|
1090
1194
|
// ── 6. Media session ───────────────────────────────────────────────────
|
|
1195
|
+
// The incoming side of a crossfade, resolved only while the picture is
|
|
1196
|
+
// actually the video: under an opaque overlay there is nothing to blend
|
|
1197
|
+
// into, and the blend stream below is stopped rather than left running.
|
|
1198
|
+
//
|
|
1199
|
+
// Retired BEFORE the active session is touched below, for two reasons. When
|
|
1200
|
+
// a blend ends because the outgoing clip ran out, the clip that was blending
|
|
1201
|
+
// IN becomes the active one — the same server, which serves one decode
|
|
1202
|
+
// intent at a time, so the blend's stream has to be closed before the active
|
|
1203
|
+
// path opens its own. And on a PAUSE mid-blend, the seek path stops that
|
|
1204
|
+
// stream from underneath this bookkeeping; forgetting the session here is
|
|
1205
|
+
// what lets a resume open a fresh one instead of assuming the old one lives.
|
|
1206
|
+
const incoming = picture === 'video' ? this.blendSideFor(plan, source, t) : null
|
|
1207
|
+
if (!incoming || this.transport !== 'playing') this.stopBlendStream()
|
|
1208
|
+
|
|
1091
1209
|
if (source && plan.active) {
|
|
1092
1210
|
const mediaUs = containerTsUsFor(
|
|
1093
1211
|
source.frameServer.video.firstPresentationTsUs,
|
|
@@ -1099,6 +1217,11 @@ class SchedulerImpl implements Scheduler {
|
|
|
1099
1217
|
const discontinuity = ownerChanged || clipChanged || wrapped || opts.seeked === true
|
|
1100
1218
|
|
|
1101
1219
|
if (this.transport === 'playing') {
|
|
1220
|
+
if (incoming && (this.blendStream !== incoming.source || discontinuity)) {
|
|
1221
|
+
this.stopBlendStream()
|
|
1222
|
+
incoming.source.frameServer.startStream(incoming.mediaUs)
|
|
1223
|
+
this.blendStream = incoming.source
|
|
1224
|
+
}
|
|
1102
1225
|
if (discontinuity || this.streamingSource !== source) {
|
|
1103
1226
|
// Loop wrap: the media pointer jumps back to the window start while
|
|
1104
1227
|
// project time keeps advancing — the legacy wrap site's
|
|
@@ -1117,17 +1240,21 @@ class SchedulerImpl implements Scheduler {
|
|
|
1117
1240
|
// The stream owns the canvas while playing; whatever a paused seek had
|
|
1118
1241
|
// put there is long gone.
|
|
1119
1242
|
this.paintedKey = null
|
|
1120
|
-
this.pullFrame(source, plan.active, mediaUs)
|
|
1243
|
+
this.pullFrame(source, plan.active, mediaUs, incoming)
|
|
1121
1244
|
} else {
|
|
1122
1245
|
this.stopStream()
|
|
1123
1246
|
// Repaint only when the canvas does not already hold this exact frame.
|
|
1124
1247
|
// Without this guard every project spread — an overlay drag emits one
|
|
1125
1248
|
// per pointer event — would fire a fresh decoder seek for a picture
|
|
1126
|
-
// that has not moved.
|
|
1127
|
-
|
|
1249
|
+
// that has not moved. Inside a blend the key carries BOTH positions:
|
|
1250
|
+
// the outgoing one alone would hold a stale mix as `p` moves.
|
|
1251
|
+
const key = incoming
|
|
1252
|
+
? `${plan.active.clipId}@${Math.round(mediaUs)}+${incoming.clipId}@${Math.round(incoming.mediaUs)}`
|
|
1253
|
+
: `${plan.active.clipId}@${Math.round(mediaUs)}`
|
|
1128
1254
|
if (picture === 'video' && key !== this.paintedKey) {
|
|
1129
1255
|
this.paintedKey = key
|
|
1130
|
-
this.
|
|
1256
|
+
if (incoming) this.paintBlendFromSeek(source, plan.active, mediaUs, incoming)
|
|
1257
|
+
else this.paintFromSeek(source, plan.active, mediaUs)
|
|
1131
1258
|
}
|
|
1132
1259
|
}
|
|
1133
1260
|
} else {
|
|
@@ -1181,6 +1308,35 @@ class SchedulerImpl implements Scheduler {
|
|
|
1181
1308
|
anchorProjectS: t,
|
|
1182
1309
|
})
|
|
1183
1310
|
}
|
|
1311
|
+
// The incoming side of a crossfade, named outright. `prev` happens to cover
|
|
1312
|
+
// it today (it started before `t`, and `active` is the earlier clip), but
|
|
1313
|
+
// that is a side effect of how `prev` is computed, not a guarantee — and it
|
|
1314
|
+
// says nothing about a blend that starts further back than the prewarm
|
|
1315
|
+
// lead. The session has to be live for the WHOLE blend.
|
|
1316
|
+
const blendClip = plan.blend ? this.clips.find((c) => c.id === plan.blend?.clipId) : undefined
|
|
1317
|
+
if (blendClip) {
|
|
1318
|
+
// The two sides of a blend need two read positions at once, and one
|
|
1319
|
+
// `FrameServer` serves one decode intent at a time — so ask the host for
|
|
1320
|
+
// a decoder of this clip's own, but ONLY when the sides would otherwise
|
|
1321
|
+
// land on the same one. The host keys its servers by src, so that is
|
|
1322
|
+
// exactly when the two resolve to one src.
|
|
1323
|
+
//
|
|
1324
|
+
// Asking unconditionally would be worse than useless on a cross-source
|
|
1325
|
+
// pair: it already has two servers, and the flag would only force the
|
|
1326
|
+
// host to respawn a session it had prewarmed — tearing down a decoder at
|
|
1327
|
+
// the instant the blend needs frames from it, to arrive at the state it
|
|
1328
|
+
// was already in. Exclusivity is a fix for one shape of pair, not a
|
|
1329
|
+
// property of blending.
|
|
1330
|
+
const { src } = engineSrcFor(blendClip, sourceWindow(blendClip, 'preview'))
|
|
1331
|
+
// Pushed BEFORE `next`/`prev` (either of which can name this same clip)
|
|
1332
|
+
// so the flag is on the request that actually reaches the host —
|
|
1333
|
+
// `pushRetain` keeps the first request per clipId, not the last.
|
|
1334
|
+
this.pushRetain(
|
|
1335
|
+
requests,
|
|
1336
|
+
{ item: blendClip, clipId: blendClip.id, start: blendClip.start },
|
|
1337
|
+
!!src && src === plan.active?.src,
|
|
1338
|
+
)
|
|
1339
|
+
}
|
|
1184
1340
|
if (plan.next && plan.next.start - t <= this.prewarmLeadS) {
|
|
1185
1341
|
this.pushRetain(requests, plan.next)
|
|
1186
1342
|
}
|
|
@@ -1200,7 +1356,11 @@ class SchedulerImpl implements Scheduler {
|
|
|
1200
1356
|
private pushRetain(
|
|
1201
1357
|
requests: SourceRequest[],
|
|
1202
1358
|
clip: { item: VisualItem; clipId: string; start: number },
|
|
1359
|
+
exclusiveServer = false,
|
|
1203
1360
|
): void {
|
|
1361
|
+
// `blend` and `prev` name the same clip throughout a crossfade, and two
|
|
1362
|
+
// requests for one clipId would read as two sessions to reconcile.
|
|
1363
|
+
if (requests.some((request) => request.clipId === clip.clipId)) return
|
|
1204
1364
|
const { src } = engineSrcFor(clip.item, sourceWindow(clip.item, 'preview'))
|
|
1205
1365
|
if (!src) return
|
|
1206
1366
|
requests.push({
|
|
@@ -1208,11 +1368,71 @@ class SchedulerImpl implements Scheduler {
|
|
|
1208
1368
|
item: withTrackAudio(this.clipsTrack, clip.item),
|
|
1209
1369
|
src,
|
|
1210
1370
|
anchorProjectS: clip.start,
|
|
1371
|
+
// Omitted rather than `false` on an ordinary request: the host's session
|
|
1372
|
+
// comparison reads it as a plain boolean, and an undefined field keeps
|
|
1373
|
+
// every non-blend request byte-identical to what it was before 9b.
|
|
1374
|
+
...(exclusiveServer ? { exclusiveServer: true } : {}),
|
|
1211
1375
|
})
|
|
1212
1376
|
}
|
|
1213
1377
|
|
|
1378
|
+
/**
|
|
1379
|
+
* Everything the INCOMING side of a crossfade needs to be painted, or `null`
|
|
1380
|
+
* when this tick cannot blend.
|
|
1381
|
+
*
|
|
1382
|
+
* Resolved here rather than carried on the plan because `TickPlan.blend` is
|
|
1383
|
+
* the timeline's answer (which clip, how far through) and this is the decode
|
|
1384
|
+
* session's — the same split `pushRetain` already lives on, over the same
|
|
1385
|
+
* `sourceWindow(item, 'preview')` the resolver itself computes.
|
|
1386
|
+
*
|
|
1387
|
+
* ── Why two clips off ONE proxy need TWO servers ─────────────────────────
|
|
1388
|
+
*
|
|
1389
|
+
* `FrameServer` is one per SRC, refcounted by clip (`index.ts`), and it
|
|
1390
|
+
* serves one decode intent at a time — `startStream` and `seek` both open by
|
|
1391
|
+
* stopping whatever was running. When both sides of a crossfade come off the
|
|
1392
|
+
* same proxy (two cuts of one take, the commonest crossfade there is) a
|
|
1393
|
+
* SHARED server would leave no second position to read: opening the incoming
|
|
1394
|
+
* stream would stop the outgoing one, and `nextFrameFor` would hand back a
|
|
1395
|
+
* frame from the wrong place in the file — a worse picture than the hard cut
|
|
1396
|
+
* it replaced. `retainFor` therefore asks the host for a second decoder
|
|
1397
|
+
* (`SourceRequest.exclusiveServer`) for the incoming side, for the length of
|
|
1398
|
+
* the blend. The demux cache is keyed by src independently of the server map,
|
|
1399
|
+
* so the cost is one more decoder worker rather than a second fetch.
|
|
1400
|
+
*
|
|
1401
|
+
* The same-server check below is what remains of that gap: an ASSERTION, not
|
|
1402
|
+
* a path anything is expected to take. `exclusiveServer` makes the two sides
|
|
1403
|
+
* distinct by construction, but if they ever resolve to one server again,
|
|
1404
|
+
* painting the outgoing frame alone is still better than reading a stream
|
|
1405
|
+
* from the wrong position.
|
|
1406
|
+
*/
|
|
1407
|
+
private blendSideFor(plan: TickPlan, outgoing: ClipSource | null, t: number): BlendSide | null {
|
|
1408
|
+
const blend = plan.blend
|
|
1409
|
+
if (!blend || !outgoing) return null
|
|
1410
|
+
const item = this.clips.find((clip) => clip.id === blend.clipId)
|
|
1411
|
+
if (!item) return null
|
|
1412
|
+
const state = this.host.state(blend.clipId)
|
|
1413
|
+
if (state.status !== 'ready') return null
|
|
1414
|
+
const source = state.source
|
|
1415
|
+
if (source.frameServer === outgoing.frameServer) return null
|
|
1416
|
+
const window = sourceWindow(item, 'preview')
|
|
1417
|
+
const { src } = engineSrcFor(item, window)
|
|
1418
|
+
if (!src) return null
|
|
1419
|
+
const placement = placeInSource(item, window, t)
|
|
1420
|
+
return {
|
|
1421
|
+
clipId: blend.clipId,
|
|
1422
|
+
p: blend.p,
|
|
1423
|
+
source,
|
|
1424
|
+
active: { item, clipId: blend.clipId, src, window, placement },
|
|
1425
|
+
mediaUs: containerTsUsFor(source.frameServer.video.firstPresentationTsUs, placement.mediaS),
|
|
1426
|
+
}
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1214
1429
|
/** Playback path: paint whatever frame is due at the media clock. */
|
|
1215
|
-
private pullFrame(
|
|
1430
|
+
private pullFrame(
|
|
1431
|
+
source: ClipSource,
|
|
1432
|
+
active: ActiveClip,
|
|
1433
|
+
mediaUs: number,
|
|
1434
|
+
incoming: BlendSide | null,
|
|
1435
|
+
): void {
|
|
1216
1436
|
const { frame } = source.frameServer.nextFrameFor(mediaUs)
|
|
1217
1437
|
if (!frame) return
|
|
1218
1438
|
// Under an opaque overlay the frame is still PULLED, then closed unpainted.
|
|
@@ -1223,6 +1443,16 @@ class SchedulerImpl implements Scheduler {
|
|
|
1223
1443
|
frame.close()
|
|
1224
1444
|
return
|
|
1225
1445
|
}
|
|
1446
|
+
if (incoming) {
|
|
1447
|
+
const { frame: toFrame } = incoming.source.frameServer.nextFrameFor(incoming.mediaUs)
|
|
1448
|
+
if (toFrame) {
|
|
1449
|
+
this.paintBlendFrames(frame, source, active, toFrame, incoming)
|
|
1450
|
+
return
|
|
1451
|
+
}
|
|
1452
|
+
// The incoming session has not decoded its first frame yet — it opened
|
|
1453
|
+
// this very tick. A momentarily un-blended picture beats a dropped one,
|
|
1454
|
+
// and the next tick catches up.
|
|
1455
|
+
}
|
|
1226
1456
|
this.paintFrame(frame, source, active)
|
|
1227
1457
|
}
|
|
1228
1458
|
|
|
@@ -1254,6 +1484,50 @@ class SchedulerImpl implements Scheduler {
|
|
|
1254
1484
|
})
|
|
1255
1485
|
}
|
|
1256
1486
|
|
|
1487
|
+
/**
|
|
1488
|
+
* Paused path through a crossfade: one seek per side, blended when both land.
|
|
1489
|
+
*
|
|
1490
|
+
* The two seeks run concurrently because `blendSideFor` has already
|
|
1491
|
+
* guaranteed two distinct frame servers — issuing both at one server would
|
|
1492
|
+
* supersede the first (`claimReqId`) and resolve it `null`.
|
|
1493
|
+
*/
|
|
1494
|
+
private paintBlendFromSeek(
|
|
1495
|
+
source: ClipSource,
|
|
1496
|
+
active: ActiveClip,
|
|
1497
|
+
mediaUs: number,
|
|
1498
|
+
incoming: BlendSide,
|
|
1499
|
+
): void {
|
|
1500
|
+
if (!this.painter) return
|
|
1501
|
+
const gen = this.seekGen
|
|
1502
|
+
this.pendingSeeks += 2
|
|
1503
|
+
const from = source.frameServer.seek(mediaUs).frame
|
|
1504
|
+
const to = incoming.source.frameServer.seek(incoming.mediaUs).frame
|
|
1505
|
+
void Promise.all([from, to]).then(([fromFrame, toFrame]) => {
|
|
1506
|
+
this.pendingSeeks -= 2
|
|
1507
|
+
// Either half missing leaves the canvas holding something the key does
|
|
1508
|
+
// not describe, so the key must not claim it — see `paintFromSeek`.
|
|
1509
|
+
if (!fromFrame || !toFrame) {
|
|
1510
|
+
if (gen === this.seekGen) this.paintedKey = null
|
|
1511
|
+
}
|
|
1512
|
+
if (!fromFrame) {
|
|
1513
|
+
toFrame?.close()
|
|
1514
|
+
this.publish()
|
|
1515
|
+
return
|
|
1516
|
+
}
|
|
1517
|
+
if (this.disposed || gen !== this.seekGen || !this.painter || this.picture !== 'video') {
|
|
1518
|
+
fromFrame.close()
|
|
1519
|
+
toFrame?.close()
|
|
1520
|
+
this.publish()
|
|
1521
|
+
return
|
|
1522
|
+
}
|
|
1523
|
+
// The outgoing clip alone is the same fallback the playback path takes
|
|
1524
|
+
// when the incoming frame has not arrived.
|
|
1525
|
+
if (toFrame) this.paintBlendFrames(fromFrame, source, active, toFrame, incoming)
|
|
1526
|
+
else this.paintFrame(fromFrame, source, active)
|
|
1527
|
+
this.publish()
|
|
1528
|
+
})
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1257
1531
|
private paintFrame(frame: VideoFrame, source: ClipSource, active: ActiveClip): void {
|
|
1258
1532
|
const painter = this.painter
|
|
1259
1533
|
if (!painter) {
|
|
@@ -1261,13 +1535,7 @@ class SchedulerImpl implements Scheduler {
|
|
|
1261
1535
|
return
|
|
1262
1536
|
}
|
|
1263
1537
|
try {
|
|
1264
|
-
|
|
1265
|
-
// `drawImage` reads a VideoFrame in its DISPLAY coordinates (pixel aspect
|
|
1266
|
-
// applied); the track's `coded` dims are the fallback for a frame that
|
|
1267
|
-
// does not report them.
|
|
1268
|
-
const w = frame.displayWidth || source.frameServer.video.coded.width
|
|
1269
|
-
const h = frame.displayHeight || source.frameServer.video.coded.height
|
|
1270
|
-
painter.paint(frame, drawPlanFor(active.item, w, h, size.width, size.height))
|
|
1538
|
+
painter.paint(frame, this.planFor(frame, source, active, painter.size()))
|
|
1271
1539
|
} catch (err) {
|
|
1272
1540
|
this.onError?.(`paint: ${err instanceof Error ? err.message : String(err)}`)
|
|
1273
1541
|
} finally {
|
|
@@ -1277,6 +1545,53 @@ class SchedulerImpl implements Scheduler {
|
|
|
1277
1545
|
}
|
|
1278
1546
|
}
|
|
1279
1547
|
|
|
1548
|
+
/** Both sides of a crossfade onto the canvas as one picture. */
|
|
1549
|
+
private paintBlendFrames(
|
|
1550
|
+
from: VideoFrame,
|
|
1551
|
+
fromSource: ClipSource,
|
|
1552
|
+
fromActive: ActiveClip,
|
|
1553
|
+
to: VideoFrame,
|
|
1554
|
+
incoming: BlendSide,
|
|
1555
|
+
): void {
|
|
1556
|
+
const painter = this.painter
|
|
1557
|
+
if (!painter) {
|
|
1558
|
+
from.close()
|
|
1559
|
+
to.close()
|
|
1560
|
+
return
|
|
1561
|
+
}
|
|
1562
|
+
try {
|
|
1563
|
+
const size = painter.size()
|
|
1564
|
+
painter.paintBlend(
|
|
1565
|
+
from,
|
|
1566
|
+
to,
|
|
1567
|
+
incoming.p,
|
|
1568
|
+
this.planFor(from, fromSource, fromActive, size),
|
|
1569
|
+
this.planFor(to, incoming.source, incoming.active, size),
|
|
1570
|
+
)
|
|
1571
|
+
} catch (err) {
|
|
1572
|
+
this.onError?.(`paint: ${err instanceof Error ? err.message : String(err)}`)
|
|
1573
|
+
} finally {
|
|
1574
|
+
from.close()
|
|
1575
|
+
to.close()
|
|
1576
|
+
}
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1579
|
+
/**
|
|
1580
|
+
* One frame's `drawImage` rect. `drawImage` reads a VideoFrame in its DISPLAY
|
|
1581
|
+
* coordinates (pixel aspect applied); the track's `coded` dims are the
|
|
1582
|
+
* fallback for a frame that does not report them.
|
|
1583
|
+
*/
|
|
1584
|
+
private planFor(
|
|
1585
|
+
frame: VideoFrame,
|
|
1586
|
+
source: ClipSource,
|
|
1587
|
+
active: ActiveClip,
|
|
1588
|
+
size: { width: number; height: number },
|
|
1589
|
+
): DrawPlan {
|
|
1590
|
+
const w = frame.displayWidth || source.frameServer.video.coded.width
|
|
1591
|
+
const h = frame.displayHeight || source.frameServer.video.coded.height
|
|
1592
|
+
return drawPlanFor(active.item, w, h, size.width, size.height)
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1280
1595
|
private stopStream(): void {
|
|
1281
1596
|
const source = this.streamingSource
|
|
1282
1597
|
if (!source) return
|
|
@@ -1284,6 +1599,13 @@ class SchedulerImpl implements Scheduler {
|
|
|
1284
1599
|
source.frameServer.stopStream()
|
|
1285
1600
|
}
|
|
1286
1601
|
|
|
1602
|
+
private stopBlendStream(): void {
|
|
1603
|
+
const source = this.blendStream
|
|
1604
|
+
if (!source) return
|
|
1605
|
+
this.blendStream = null
|
|
1606
|
+
source.frameServer.stopStream()
|
|
1607
|
+
}
|
|
1608
|
+
|
|
1287
1609
|
private publish(): void {
|
|
1288
1610
|
const status = this.status()
|
|
1289
1611
|
const key = [
|
package/src/index.ts
CHANGED
|
@@ -19,6 +19,7 @@ export type {
|
|
|
19
19
|
CarouselElement,
|
|
20
20
|
Slide,
|
|
21
21
|
EditorProject,
|
|
22
|
+
Marker,
|
|
22
23
|
} from './schema'
|
|
23
24
|
|
|
24
25
|
// ── Contracts (adapter, theme, render, media, component props) ────────────────
|
|
@@ -83,11 +84,15 @@ export { getOverlayDesignCanvas } from './video/design-canvas'
|
|
|
83
84
|
// Track-shape tolerance: `project.tracks` may be on disk as the legacy
|
|
84
85
|
// `VisualItem[][]` or as `VisualTrack[]`. Read through `trackItems`; normalize
|
|
85
86
|
// on open with `normalizeTracks` (same object back when already converged).
|
|
87
|
+
// `normalizeAudioTracks` is the audio sibling — `audio.tracks[*].id` is
|
|
88
|
+
// optional on disk but required by the editor, so it's backfilled the same
|
|
89
|
+
// way (same object back when already converged).
|
|
86
90
|
export {
|
|
87
91
|
effectiveItemAudio,
|
|
88
92
|
enabledTrackItems,
|
|
89
93
|
enabledTracks,
|
|
90
94
|
mapTrackItems,
|
|
95
|
+
normalizeAudioTracks,
|
|
91
96
|
normalizeTracks,
|
|
92
97
|
trackItems,
|
|
93
98
|
withEnabledItemTracks,
|
|
@@ -99,6 +104,9 @@ export {
|
|
|
99
104
|
// "land where you dropped it, without stomping existing footage".
|
|
100
105
|
export { placeDroppedClip, resolveDropTrackIndex } from './video/timeline/placement'
|
|
101
106
|
export type { DroppedClipPlacement, PlacedClipResult } from './video/timeline/placement'
|
|
107
|
+
// Marker model — pure mutations over `project.markers` (see markers.ts's file
|
|
108
|
+
// header for the "same reference when unchanged" contract they all share).
|
|
109
|
+
export { addMarker, moveMarker, renameMarker, removeMarkers, nextMarkerLabel } from './video/timeline/markers'
|
|
102
110
|
|
|
103
111
|
// ── Image tone (HDR image color mapping) ─────────────────────────────────────
|
|
104
112
|
// The picker component is exported so hosts using `onProvideImageTone` can
|
package/src/schema.ts
CHANGED
|
@@ -79,6 +79,19 @@ export interface CaptionSegment {
|
|
|
79
79
|
lane?: number
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
+
/**
|
|
83
|
+
* The CSS `text-transform` values the caption text-styling controls offer.
|
|
84
|
+
*
|
|
85
|
+
* A named union rather than `string`, because this value is ultimately spread
|
|
86
|
+
* into a React `style={{...}}` object, where `CSSProperties['textTransform']`
|
|
87
|
+
* is itself a union — a bare `string` there is a hard `TS2322` in any consumer
|
|
88
|
+
* that typechecks against this package's sources, which every consumer does
|
|
89
|
+
* (the package ships raw TS, so `skipLibCheck` cannot mask it). Narrowing the
|
|
90
|
+
* one consuming component instead of this field just moves the error to the
|
|
91
|
+
* call site that feeds it.
|
|
92
|
+
*/
|
|
93
|
+
export type CaptionTextTransform = 'uppercase' | 'lowercase' | 'capitalize' | 'none'
|
|
94
|
+
|
|
82
95
|
export interface Captions {
|
|
83
96
|
style: 'word-by-word' | 'pop' | 'karaoke' | 'subtitle' | 'highlight-box' | 'outline' | 'clean'
|
|
84
97
|
segments: CaptionSegment[]
|
|
@@ -105,7 +118,7 @@ export interface Captions {
|
|
|
105
118
|
fontWeight?: number | string // default is per style: clean/karaoke 700, subtitle 600,
|
|
106
119
|
// pop/word-by-word 800, highlight-box/outline 900 — so an
|
|
107
120
|
// existing project with no fontWeight renders unchanged.
|
|
108
|
-
textTransform?:
|
|
121
|
+
textTransform?: CaptionTextTransform
|
|
109
122
|
letterSpacing?: string // CSS length, e.g. '0.02em'
|
|
110
123
|
lineHeight?: number | string
|
|
111
124
|
textAlign?: string // 'left' | 'center' | 'right'
|
|
@@ -154,6 +167,12 @@ export interface Keyframe {
|
|
|
154
167
|
export interface KeyframeTrack {
|
|
155
168
|
prop: KeyframeProp
|
|
156
169
|
points: Keyframe[]
|
|
170
|
+
/** Marks a track as DERIVED (`'crossfade'`) rather than hand-authored. Only
|
|
171
|
+
* `computeVisualCrossfade` writes it; every reader treats an absent `origin`
|
|
172
|
+
* as hand-authored and never overwrites such a track. Ignored by the
|
|
173
|
+
* renderer and by `timeline-core` — it is editor bookkeeping that rides
|
|
174
|
+
* along in `project.json`. */
|
|
175
|
+
origin?: string
|
|
157
176
|
}
|
|
158
177
|
|
|
159
178
|
export interface VisualItem {
|
|
@@ -271,6 +290,23 @@ export interface Asset {
|
|
|
271
290
|
name?: string
|
|
272
291
|
}
|
|
273
292
|
|
|
293
|
+
/**
|
|
294
|
+
* An operator's flag on the timeline — a moment worth coming back to.
|
|
295
|
+
*
|
|
296
|
+
* Markers are an EDITING and COMMUNICATION aid: they are drawn in the editor's
|
|
297
|
+
* marker strip and handed to the agent through the context endpoint, and the
|
|
298
|
+
* renderer ignores them completely. Nothing about a marker reaches the export.
|
|
299
|
+
*
|
|
300
|
+
* `label` is always present. A new marker gets an auto-number so dropping one
|
|
301
|
+
* never interrupts the edit to type; renaming it is a separate, deliberate act.
|
|
302
|
+
*/
|
|
303
|
+
export interface Marker {
|
|
304
|
+
id: string
|
|
305
|
+
/** Timeline position in seconds. Never negative. */
|
|
306
|
+
t: number
|
|
307
|
+
label: string
|
|
308
|
+
}
|
|
309
|
+
|
|
274
310
|
// ── Carousel types ─────────────────────────────────────────────────────────
|
|
275
311
|
export interface ImageElement {
|
|
276
312
|
id: string
|
|
@@ -366,6 +402,10 @@ export interface EditorProject {
|
|
|
366
402
|
captions?: Captions
|
|
367
403
|
audio?: { tracks: AudioTrack[] }
|
|
368
404
|
assets?: Asset[]
|
|
405
|
+
/** Operator markers, kept sorted by `t`. Absent — not `[]` — when the
|
|
406
|
+
* project has none, so a marker-less project is byte-identical to one from
|
|
407
|
+
* before the feature existed (the same discipline `captions` follows). */
|
|
408
|
+
markers?: Marker[]
|
|
369
409
|
carousel?: { aspect: string }
|
|
370
410
|
profile?: string
|
|
371
411
|
derivedFrom?: string // ID of the source project this was derived from (e.g. clips workflow)
|