@bycrux/editor 1.0.2 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/package.json +2 -2
  2. package/src/ControlsInfoModal.tsx +1 -0
  3. package/src/engine/__tests__/eligibility.test.ts +97 -0
  4. package/src/engine/__tests__/engine.test.ts +47 -2
  5. package/src/engine/__tests__/scheduler.test.ts +385 -4
  6. package/src/engine/__tests__/source-host.test.ts +165 -0
  7. package/src/engine/eligibility.ts +37 -1
  8. package/src/engine/index.ts +165 -29
  9. package/src/engine/scheduler.ts +345 -23
  10. package/src/index.ts +8 -0
  11. package/src/schema.ts +41 -1
  12. package/src/video/VideoEditor.tsx +106 -10
  13. package/src/video/__tests__/VideoEditor.keymap.test.tsx +64 -0
  14. package/src/video/__tests__/VideoEditor.test.tsx +169 -0
  15. package/src/video/__tests__/cuts.test.ts +84 -0
  16. package/src/video/__tests__/exportDurationSec.test.ts +60 -0
  17. package/src/video/__tests__/markerDropTime.test.ts +25 -0
  18. package/src/video/captionStyleDefaults.ts +2 -2
  19. package/src/video/cuts.ts +30 -2
  20. package/src/video/preview/OverlayItemsLayer.tsx +85 -6
  21. package/src/video/preview/PreviewPlayer.tsx +28 -1
  22. package/src/video/preview/__tests__/OverlayItemsLayer.keyframes.test.tsx +91 -0
  23. package/src/video/preview/__tests__/PreviewPlayer.engine.test.tsx +49 -1
  24. package/src/video/preview/__tests__/useVideoPlayback.canvasClock.test.ts +99 -0
  25. package/src/video/preview/useVideoPlayback.ts +18 -3
  26. package/src/video/timeline/Timeline.tsx +49 -1
  27. package/src/video/timeline/__tests__/Timeline.keymap.test.tsx +16 -0
  28. package/src/video/timeline/__tests__/markers.test.ts +125 -0
  29. package/src/video/timeline/__tests__/timeline-model.test.ts +307 -0
  30. package/src/video/timeline/canvas/TimelineCanvas.tsx +100 -4
  31. package/src/video/timeline/canvas/__tests__/TimelineCanvas.test.tsx +106 -2
  32. package/src/video/timeline/canvas/__tests__/draw.test.ts +73 -0
  33. package/src/video/timeline/canvas/__tests__/hit-test.test.ts +76 -0
  34. package/src/video/timeline/canvas/__tests__/pointer-machine.test.ts +216 -1
  35. package/src/video/timeline/canvas/draw.ts +104 -7
  36. package/src/video/timeline/canvas/hit-test.ts +72 -1
  37. package/src/video/timeline/canvas/pointer-machine.ts +142 -5
  38. package/src/video/timeline/markers.ts +109 -0
  39. package/src/video/timeline/timeline-model.ts +286 -15
@@ -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(overlayEnd, captionEnd)`. Note
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
- * overlays stops at the overlays today.
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 overlayEnd = enabledTrackItems(project).slice(1)
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(overlayEnd, captionEnd)
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. Excluding the active clip
600
- // by id matters when clips overlap: `active` resolves to the latest start
601
- // among the overlapping set, and without the check `prev` would name that
602
- // same clip and retain nothing extra.
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
- const key = `${plan.active.clipId}@${Math.round(mediaUs)}`
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.paintFromSeek(source, plan.active, mediaUs)
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(source: ClipSource, active: ActiveClip, mediaUs: number): void {
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
- const size = painter.size()
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?: string // 'uppercase' | 'lowercase' | 'capitalize' | 'none'
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)