@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.
@@ -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
  }
@@ -553,7 +590,19 @@ export const previewResolver: SceneResolver = (project, t) =>
553
590
  * - **Which track-0 video item wins** when two overlap. `resolveAt` returns
554
591
  * them in document order; the legacy hook picks the first in START order.
555
592
  * The earliest-start rule is reproduced so an overlapping pair resolves the
556
- * same way it does today.
593
+ * same way it does today. It still answers for every overlap the resolver
594
+ * does NOT call a crossfade — containment, and the three-way overlap
595
+ * `engine/validate.py` rejects — where something must own the picture and
596
+ * there is no pair to blend along.
597
+ * - **`blend`**, the incoming side of a crossfade. `transitionPairs` names the
598
+ * earlier item `from`, so a real pair's outgoing side is the same clip the
599
+ * earliest-start rule already picks: the crossfade does not change WHO owns
600
+ * the picture, it stops the incoming clip from being ignored until the
601
+ * outgoing one ends. That hard cut was a genuine preview/export divergence —
602
+ * render composites the LATER item on top for the whole overlap
603
+ * (`segment-plan.js`'s stable trackIdx sort over document order, then
604
+ * `encode-segment.js`'s overlay chain), so preview showed the outgoing clip
605
+ * across a window where the export showed the incoming one.
557
606
  * - **`opaque`** is read off any active OVERLAY item on any track, matching
558
607
  * render's `overlays.some(o => o.opaque)` (`segment-plan.js`). Track-0
559
608
  * videos and images never carry it.
@@ -567,6 +616,10 @@ export function planTick(
567
616
  const scene = resolver(project, t)
568
617
 
569
618
  let active: ActiveClip | null = null
619
+ /** The winning clip's own crossfade stamp — `blend` is matched against it below. */
620
+ let activeCrossfade: ItemCrossfade | null = null
621
+ /** Every incoming side the scan saw. More than one means a three-way overlap. */
622
+ const incoming: Array<{ clipId: string; p: number }> = []
570
623
  let opaque = false
571
624
  for (const resolved of scene.items) {
572
625
  if (resolved.kind === 'overlay' && resolved.item.opaque === true) opaque = true
@@ -575,8 +628,15 @@ export function planTick(
575
628
  // in timeline-core's `ResolvedItem`), so this recovers the editor-side
576
629
  // fields (`loop`, `volume`, `muted`) the resolver's structural view omits.
577
630
  const item = resolved.item as unknown as VisualItem
631
+ // Collected, but NOT skipped by the tiebreak below: an incoming clip whose
632
+ // outgoing partner is an IMAGE has no video to blend into, and excluding it
633
+ // from the scan would leave the picture black for the whole overlap.
634
+ if (resolved.crossfade?.role === 'to') {
635
+ incoming.push({ clipId: item.id, p: resolved.crossfade.p })
636
+ }
578
637
  if (active && (active.item.start ?? 0) <= (item.start ?? 0)) continue
579
638
  const usable = engineSrcFor(item, resolved.window)
639
+ activeCrossfade = resolved.crossfade
580
640
  active = {
581
641
  item,
582
642
  clipId: item.id,
@@ -587,6 +647,19 @@ export function planTick(
587
647
  }
588
648
  }
589
649
 
650
+ // Both sides of ONE pair are stamped from a single `transitionProgress` call
651
+ // (`activation.js`'s `crossfadesAt`), so an exactly equal `p` is what says
652
+ // "these two are partners" — the resolver exposes no pair identity. It only
653
+ // matters for the three-way overlap the validator rejects, where two pairs
654
+ // are live at once and the middle clip ends up stamped `from`: no incoming
655
+ // side then carries the active clip's `p`, and the tick degrades to today's
656
+ // hard cut rather than blending two clips that are not a pair.
657
+ let blend: TickPlan['blend'] = null
658
+ if (activeCrossfade?.role === 'from') {
659
+ const p = activeCrossfade.p
660
+ blend = incoming.find((side) => side.p === p) ?? null
661
+ }
662
+
590
663
  let next: TickPlan['next'] = null
591
664
  for (const clip of clips) {
592
665
  if (clip.start > t) {
@@ -596,10 +669,17 @@ export function planTick(
596
669
  }
597
670
 
598
671
  // `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.
672
+ // the active clip is the one immediately behind it. The id check is what
673
+ // keeps `prev` from naming the active clip itself, which is otherwise the
674
+ // answer on every ordinary tick — the active clip IS the last one to have
675
+ // started.
676
+ //
677
+ // Inside an overlap it names the clip AHEAD instead: `active` resolves to the
678
+ // EARLIEST start among the overlapping set (the loop above keeps an incumbent
679
+ // whose start is `<=` the candidate's), so the other side of the overlap
680
+ // started later and is still the last one before `t`. That is why the
681
+ // incoming side of a crossfade is already retained today, by accident;
682
+ // `retainFor` names `blend` outright rather than leaning on it.
603
683
  let prev: TickPlan['prev'] = null
604
684
  for (const clip of clips) {
605
685
  if (clip.start >= t) break
@@ -607,7 +687,7 @@ export function planTick(
607
687
  prev = { item: clip, clipId: clip.id, start: clip.start }
608
688
  }
609
689
 
610
- return { t, active, opaque, next, prev, canvas: clips.length === 0 }
690
+ return { t, active, opaque, next, prev, blend, canvas: clips.length === 0 }
611
691
  }
612
692
 
613
693
  // ── Injected async surfaces ─────────────────────────────────────────────────
@@ -641,6 +721,14 @@ export interface SourceRequest {
641
721
  src: string
642
722
  /** Project time this clip's clock should be anchored at when it is built. */
643
723
  anchorProjectS: number
724
+ /**
725
+ * Give this clip its own `FrameServer` even though another clip already
726
+ * streams from the same `src`. Set ONLY for the incoming side of a crossfade,
727
+ * and only while it blends. The one-server-per-src rule in `index.ts`'s
728
+ * header is otherwise intact — and its actual saving, the demux, is preserved
729
+ * either way because `demuxCache` is keyed by `src` independently of this map.
730
+ */
731
+ exclusiveServer?: boolean
644
732
  }
645
733
 
646
734
  /**
@@ -790,6 +878,11 @@ class SchedulerImpl implements Scheduler {
790
878
  private clipId: string | null = null
791
879
  /** The session with a streaming decode-ahead session open, if any. */
792
880
  private streamingSource: ClipSource | null = null
881
+ /**
882
+ * The INCOMING crossfade session streaming alongside it. A second stream is
883
+ * only ever opened on a second frame server — see `blendSideFor`.
884
+ */
885
+ private blendStream: ClipSource | null = null
793
886
  /** Bumped on every seek / boundary / dispose; a resolved seek frame paints only if it still matches. */
794
887
  private seekGen = 0
795
888
  private pendingSeeks = 0
@@ -958,6 +1051,7 @@ class SchedulerImpl implements Scheduler {
958
1051
  this.disposed = true
959
1052
  this.seekGen++
960
1053
  this.stopStream()
1054
+ this.stopBlendStream()
961
1055
  if (this.clockOwner === null) this.clock.dispose()
962
1056
  this.clockOwner = null
963
1057
  this.clockSource = null
@@ -1088,6 +1182,20 @@ class SchedulerImpl implements Scheduler {
1088
1182
  this.pictureReason = reason
1089
1183
 
1090
1184
  // ── 6. Media session ───────────────────────────────────────────────────
1185
+ // The incoming side of a crossfade, resolved only while the picture is
1186
+ // actually the video: under an opaque overlay there is nothing to blend
1187
+ // into, and the blend stream below is stopped rather than left running.
1188
+ //
1189
+ // Retired BEFORE the active session is touched below, for two reasons. When
1190
+ // a blend ends because the outgoing clip ran out, the clip that was blending
1191
+ // IN becomes the active one — the same server, which serves one decode
1192
+ // intent at a time, so the blend's stream has to be closed before the active
1193
+ // path opens its own. And on a PAUSE mid-blend, the seek path stops that
1194
+ // stream from underneath this bookkeeping; forgetting the session here is
1195
+ // what lets a resume open a fresh one instead of assuming the old one lives.
1196
+ const incoming = picture === 'video' ? this.blendSideFor(plan, source, t) : null
1197
+ if (!incoming || this.transport !== 'playing') this.stopBlendStream()
1198
+
1091
1199
  if (source && plan.active) {
1092
1200
  const mediaUs = containerTsUsFor(
1093
1201
  source.frameServer.video.firstPresentationTsUs,
@@ -1099,6 +1207,11 @@ class SchedulerImpl implements Scheduler {
1099
1207
  const discontinuity = ownerChanged || clipChanged || wrapped || opts.seeked === true
1100
1208
 
1101
1209
  if (this.transport === 'playing') {
1210
+ if (incoming && (this.blendStream !== incoming.source || discontinuity)) {
1211
+ this.stopBlendStream()
1212
+ incoming.source.frameServer.startStream(incoming.mediaUs)
1213
+ this.blendStream = incoming.source
1214
+ }
1102
1215
  if (discontinuity || this.streamingSource !== source) {
1103
1216
  // Loop wrap: the media pointer jumps back to the window start while
1104
1217
  // project time keeps advancing — the legacy wrap site's
@@ -1117,17 +1230,21 @@ class SchedulerImpl implements Scheduler {
1117
1230
  // The stream owns the canvas while playing; whatever a paused seek had
1118
1231
  // put there is long gone.
1119
1232
  this.paintedKey = null
1120
- this.pullFrame(source, plan.active, mediaUs)
1233
+ this.pullFrame(source, plan.active, mediaUs, incoming)
1121
1234
  } else {
1122
1235
  this.stopStream()
1123
1236
  // Repaint only when the canvas does not already hold this exact frame.
1124
1237
  // Without this guard every project spread — an overlay drag emits one
1125
1238
  // 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)}`
1239
+ // that has not moved. Inside a blend the key carries BOTH positions:
1240
+ // the outgoing one alone would hold a stale mix as `p` moves.
1241
+ const key = incoming
1242
+ ? `${plan.active.clipId}@${Math.round(mediaUs)}+${incoming.clipId}@${Math.round(incoming.mediaUs)}`
1243
+ : `${plan.active.clipId}@${Math.round(mediaUs)}`
1128
1244
  if (picture === 'video' && key !== this.paintedKey) {
1129
1245
  this.paintedKey = key
1130
- this.paintFromSeek(source, plan.active, mediaUs)
1246
+ if (incoming) this.paintBlendFromSeek(source, plan.active, mediaUs, incoming)
1247
+ else this.paintFromSeek(source, plan.active, mediaUs)
1131
1248
  }
1132
1249
  }
1133
1250
  } else {
@@ -1181,6 +1298,35 @@ class SchedulerImpl implements Scheduler {
1181
1298
  anchorProjectS: t,
1182
1299
  })
1183
1300
  }
1301
+ // The incoming side of a crossfade, named outright. `prev` happens to cover
1302
+ // it today (it started before `t`, and `active` is the earlier clip), but
1303
+ // that is a side effect of how `prev` is computed, not a guarantee — and it
1304
+ // says nothing about a blend that starts further back than the prewarm
1305
+ // lead. The session has to be live for the WHOLE blend.
1306
+ const blendClip = plan.blend ? this.clips.find((c) => c.id === plan.blend?.clipId) : undefined
1307
+ if (blendClip) {
1308
+ // The two sides of a blend need two read positions at once, and one
1309
+ // `FrameServer` serves one decode intent at a time — so ask the host for
1310
+ // a decoder of this clip's own, but ONLY when the sides would otherwise
1311
+ // land on the same one. The host keys its servers by src, so that is
1312
+ // exactly when the two resolve to one src.
1313
+ //
1314
+ // Asking unconditionally would be worse than useless on a cross-source
1315
+ // pair: it already has two servers, and the flag would only force the
1316
+ // host to respawn a session it had prewarmed — tearing down a decoder at
1317
+ // the instant the blend needs frames from it, to arrive at the state it
1318
+ // was already in. Exclusivity is a fix for one shape of pair, not a
1319
+ // property of blending.
1320
+ const { src } = engineSrcFor(blendClip, sourceWindow(blendClip, 'preview'))
1321
+ // Pushed BEFORE `next`/`prev` (either of which can name this same clip)
1322
+ // so the flag is on the request that actually reaches the host —
1323
+ // `pushRetain` keeps the first request per clipId, not the last.
1324
+ this.pushRetain(
1325
+ requests,
1326
+ { item: blendClip, clipId: blendClip.id, start: blendClip.start },
1327
+ !!src && src === plan.active?.src,
1328
+ )
1329
+ }
1184
1330
  if (plan.next && plan.next.start - t <= this.prewarmLeadS) {
1185
1331
  this.pushRetain(requests, plan.next)
1186
1332
  }
@@ -1200,7 +1346,11 @@ class SchedulerImpl implements Scheduler {
1200
1346
  private pushRetain(
1201
1347
  requests: SourceRequest[],
1202
1348
  clip: { item: VisualItem; clipId: string; start: number },
1349
+ exclusiveServer = false,
1203
1350
  ): void {
1351
+ // `blend` and `prev` name the same clip throughout a crossfade, and two
1352
+ // requests for one clipId would read as two sessions to reconcile.
1353
+ if (requests.some((request) => request.clipId === clip.clipId)) return
1204
1354
  const { src } = engineSrcFor(clip.item, sourceWindow(clip.item, 'preview'))
1205
1355
  if (!src) return
1206
1356
  requests.push({
@@ -1208,11 +1358,71 @@ class SchedulerImpl implements Scheduler {
1208
1358
  item: withTrackAudio(this.clipsTrack, clip.item),
1209
1359
  src,
1210
1360
  anchorProjectS: clip.start,
1361
+ // Omitted rather than `false` on an ordinary request: the host's session
1362
+ // comparison reads it as a plain boolean, and an undefined field keeps
1363
+ // every non-blend request byte-identical to what it was before 9b.
1364
+ ...(exclusiveServer ? { exclusiveServer: true } : {}),
1211
1365
  })
1212
1366
  }
1213
1367
 
1368
+ /**
1369
+ * Everything the INCOMING side of a crossfade needs to be painted, or `null`
1370
+ * when this tick cannot blend.
1371
+ *
1372
+ * Resolved here rather than carried on the plan because `TickPlan.blend` is
1373
+ * the timeline's answer (which clip, how far through) and this is the decode
1374
+ * session's — the same split `pushRetain` already lives on, over the same
1375
+ * `sourceWindow(item, 'preview')` the resolver itself computes.
1376
+ *
1377
+ * ── Why two clips off ONE proxy need TWO servers ─────────────────────────
1378
+ *
1379
+ * `FrameServer` is one per SRC, refcounted by clip (`index.ts`), and it
1380
+ * serves one decode intent at a time — `startStream` and `seek` both open by
1381
+ * stopping whatever was running. When both sides of a crossfade come off the
1382
+ * same proxy (two cuts of one take, the commonest crossfade there is) a
1383
+ * SHARED server would leave no second position to read: opening the incoming
1384
+ * stream would stop the outgoing one, and `nextFrameFor` would hand back a
1385
+ * frame from the wrong place in the file — a worse picture than the hard cut
1386
+ * it replaced. `retainFor` therefore asks the host for a second decoder
1387
+ * (`SourceRequest.exclusiveServer`) for the incoming side, for the length of
1388
+ * the blend. The demux cache is keyed by src independently of the server map,
1389
+ * so the cost is one more decoder worker rather than a second fetch.
1390
+ *
1391
+ * The same-server check below is what remains of that gap: an ASSERTION, not
1392
+ * a path anything is expected to take. `exclusiveServer` makes the two sides
1393
+ * distinct by construction, but if they ever resolve to one server again,
1394
+ * painting the outgoing frame alone is still better than reading a stream
1395
+ * from the wrong position.
1396
+ */
1397
+ private blendSideFor(plan: TickPlan, outgoing: ClipSource | null, t: number): BlendSide | null {
1398
+ const blend = plan.blend
1399
+ if (!blend || !outgoing) return null
1400
+ const item = this.clips.find((clip) => clip.id === blend.clipId)
1401
+ if (!item) return null
1402
+ const state = this.host.state(blend.clipId)
1403
+ if (state.status !== 'ready') return null
1404
+ const source = state.source
1405
+ if (source.frameServer === outgoing.frameServer) return null
1406
+ const window = sourceWindow(item, 'preview')
1407
+ const { src } = engineSrcFor(item, window)
1408
+ if (!src) return null
1409
+ const placement = placeInSource(item, window, t)
1410
+ return {
1411
+ clipId: blend.clipId,
1412
+ p: blend.p,
1413
+ source,
1414
+ active: { item, clipId: blend.clipId, src, window, placement },
1415
+ mediaUs: containerTsUsFor(source.frameServer.video.firstPresentationTsUs, placement.mediaS),
1416
+ }
1417
+ }
1418
+
1214
1419
  /** Playback path: paint whatever frame is due at the media clock. */
1215
- private pullFrame(source: ClipSource, active: ActiveClip, mediaUs: number): void {
1420
+ private pullFrame(
1421
+ source: ClipSource,
1422
+ active: ActiveClip,
1423
+ mediaUs: number,
1424
+ incoming: BlendSide | null,
1425
+ ): void {
1216
1426
  const { frame } = source.frameServer.nextFrameFor(mediaUs)
1217
1427
  if (!frame) return
1218
1428
  // Under an opaque overlay the frame is still PULLED, then closed unpainted.
@@ -1223,6 +1433,16 @@ class SchedulerImpl implements Scheduler {
1223
1433
  frame.close()
1224
1434
  return
1225
1435
  }
1436
+ if (incoming) {
1437
+ const { frame: toFrame } = incoming.source.frameServer.nextFrameFor(incoming.mediaUs)
1438
+ if (toFrame) {
1439
+ this.paintBlendFrames(frame, source, active, toFrame, incoming)
1440
+ return
1441
+ }
1442
+ // The incoming session has not decoded its first frame yet — it opened
1443
+ // this very tick. A momentarily un-blended picture beats a dropped one,
1444
+ // and the next tick catches up.
1445
+ }
1226
1446
  this.paintFrame(frame, source, active)
1227
1447
  }
1228
1448
 
@@ -1254,6 +1474,50 @@ class SchedulerImpl implements Scheduler {
1254
1474
  })
1255
1475
  }
1256
1476
 
1477
+ /**
1478
+ * Paused path through a crossfade: one seek per side, blended when both land.
1479
+ *
1480
+ * The two seeks run concurrently because `blendSideFor` has already
1481
+ * guaranteed two distinct frame servers — issuing both at one server would
1482
+ * supersede the first (`claimReqId`) and resolve it `null`.
1483
+ */
1484
+ private paintBlendFromSeek(
1485
+ source: ClipSource,
1486
+ active: ActiveClip,
1487
+ mediaUs: number,
1488
+ incoming: BlendSide,
1489
+ ): void {
1490
+ if (!this.painter) return
1491
+ const gen = this.seekGen
1492
+ this.pendingSeeks += 2
1493
+ const from = source.frameServer.seek(mediaUs).frame
1494
+ const to = incoming.source.frameServer.seek(incoming.mediaUs).frame
1495
+ void Promise.all([from, to]).then(([fromFrame, toFrame]) => {
1496
+ this.pendingSeeks -= 2
1497
+ // Either half missing leaves the canvas holding something the key does
1498
+ // not describe, so the key must not claim it — see `paintFromSeek`.
1499
+ if (!fromFrame || !toFrame) {
1500
+ if (gen === this.seekGen) this.paintedKey = null
1501
+ }
1502
+ if (!fromFrame) {
1503
+ toFrame?.close()
1504
+ this.publish()
1505
+ return
1506
+ }
1507
+ if (this.disposed || gen !== this.seekGen || !this.painter || this.picture !== 'video') {
1508
+ fromFrame.close()
1509
+ toFrame?.close()
1510
+ this.publish()
1511
+ return
1512
+ }
1513
+ // The outgoing clip alone is the same fallback the playback path takes
1514
+ // when the incoming frame has not arrived.
1515
+ if (toFrame) this.paintBlendFrames(fromFrame, source, active, toFrame, incoming)
1516
+ else this.paintFrame(fromFrame, source, active)
1517
+ this.publish()
1518
+ })
1519
+ }
1520
+
1257
1521
  private paintFrame(frame: VideoFrame, source: ClipSource, active: ActiveClip): void {
1258
1522
  const painter = this.painter
1259
1523
  if (!painter) {
@@ -1261,13 +1525,7 @@ class SchedulerImpl implements Scheduler {
1261
1525
  return
1262
1526
  }
1263
1527
  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))
1528
+ painter.paint(frame, this.planFor(frame, source, active, painter.size()))
1271
1529
  } catch (err) {
1272
1530
  this.onError?.(`paint: ${err instanceof Error ? err.message : String(err)}`)
1273
1531
  } finally {
@@ -1277,6 +1535,53 @@ class SchedulerImpl implements Scheduler {
1277
1535
  }
1278
1536
  }
1279
1537
 
1538
+ /** Both sides of a crossfade onto the canvas as one picture. */
1539
+ private paintBlendFrames(
1540
+ from: VideoFrame,
1541
+ fromSource: ClipSource,
1542
+ fromActive: ActiveClip,
1543
+ to: VideoFrame,
1544
+ incoming: BlendSide,
1545
+ ): void {
1546
+ const painter = this.painter
1547
+ if (!painter) {
1548
+ from.close()
1549
+ to.close()
1550
+ return
1551
+ }
1552
+ try {
1553
+ const size = painter.size()
1554
+ painter.paintBlend(
1555
+ from,
1556
+ to,
1557
+ incoming.p,
1558
+ this.planFor(from, fromSource, fromActive, size),
1559
+ this.planFor(to, incoming.source, incoming.active, size),
1560
+ )
1561
+ } catch (err) {
1562
+ this.onError?.(`paint: ${err instanceof Error ? err.message : String(err)}`)
1563
+ } finally {
1564
+ from.close()
1565
+ to.close()
1566
+ }
1567
+ }
1568
+
1569
+ /**
1570
+ * One frame's `drawImage` rect. `drawImage` reads a VideoFrame in its DISPLAY
1571
+ * coordinates (pixel aspect applied); the track's `coded` dims are the
1572
+ * fallback for a frame that does not report them.
1573
+ */
1574
+ private planFor(
1575
+ frame: VideoFrame,
1576
+ source: ClipSource,
1577
+ active: ActiveClip,
1578
+ size: { width: number; height: number },
1579
+ ): DrawPlan {
1580
+ const w = frame.displayWidth || source.frameServer.video.coded.width
1581
+ const h = frame.displayHeight || source.frameServer.video.coded.height
1582
+ return drawPlanFor(active.item, w, h, size.width, size.height)
1583
+ }
1584
+
1280
1585
  private stopStream(): void {
1281
1586
  const source = this.streamingSource
1282
1587
  if (!source) return
@@ -1284,6 +1589,13 @@ class SchedulerImpl implements Scheduler {
1284
1589
  source.frameServer.stopStream()
1285
1590
  }
1286
1591
 
1592
+ private stopBlendStream(): void {
1593
+ const source = this.blendStream
1594
+ if (!source) return
1595
+ this.blendStream = null
1596
+ source.frameServer.stopStream()
1597
+ }
1598
+
1287
1599
  private publish(): void {
1288
1600
  const status = this.status()
1289
1601
  const key = [
package/src/index.ts CHANGED
@@ -83,11 +83,15 @@ export { getOverlayDesignCanvas } from './video/design-canvas'
83
83
  // Track-shape tolerance: `project.tracks` may be on disk as the legacy
84
84
  // `VisualItem[][]` or as `VisualTrack[]`. Read through `trackItems`; normalize
85
85
  // on open with `normalizeTracks` (same object back when already converged).
86
+ // `normalizeAudioTracks` is the audio sibling — `audio.tracks[*].id` is
87
+ // optional on disk but required by the editor, so it's backfilled the same
88
+ // way (same object back when already converged).
86
89
  export {
87
90
  effectiveItemAudio,
88
91
  enabledTrackItems,
89
92
  enabledTracks,
90
93
  mapTrackItems,
94
+ normalizeAudioTracks,
91
95
  normalizeTracks,
92
96
  trackItems,
93
97
  withEnabledItemTracks,
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 {
@@ -16,6 +16,7 @@
16
16
  * beyond the width measurement below, and never touches the project.
17
17
  */
18
18
  import { useEffect, useRef, useState } from 'react'
19
+ import type { CSSProperties } from 'react'
19
20
  import type { Captions } from '../schema'
20
21
  import { activeCaptionWord } from './captionActiveWord'
21
22
  import { findFontOption } from '../text/FontPicker'
@@ -47,7 +48,7 @@ export interface CaptionSpecimenProps {
47
48
  fontFamily?: string
48
49
  /** The caption's real render font size, in project pixels (`captions.fontsize`). */
49
50
  fontSize: number
50
- textTransform?: string
51
+ textTransform?: CSSProperties['textTransform']
51
52
  letterSpacing?: string
52
53
  /** `captions.fontWeight` — absent means the active style's own designed
53
54
  * weight (the browser/template default), so leaving this unset is what
@@ -14,7 +14,7 @@ import { collapseGaps, rippleDelete, splitAtTime } from './cuts'
14
14
  import { repairCaptionWords } from './captionRepair'
15
15
  import { maxCaptionLane, normalizeCaptionLanes } from './captionLanes'
16
16
  import Timeline, { type TimelineActions, type TimelineMode } from './timeline/Timeline'
17
- import { computeAutoCrossfade, computeDerivedTiming, enabledTrackItems, mapTrackItems, trackItems } from './timeline/timeline-model'
17
+ import { computeAutoCrossfade, computeDerivedTiming, computeVisualCrossfade, enabledTrackItems, mapTrackItems, normalizeAudioTracks, trackItems } from './timeline/timeline-model'
18
18
  import { makeCaptionEdit, type CaptionEditPatch } from './timeline/makeCaptionEdit'
19
19
  import PreviewPlayer, { type TransportHandle, type ScrubHandle } from './preview/PreviewPlayer'
20
20
  import SocialPreviewMenu, { PlatformGlyph, platformOption } from './preview/SocialPreviewMenu'
@@ -253,6 +253,18 @@ export default function VideoEditor<P extends Project = Project>({
253
253
  // `onProjectChange` and paying an extra render for it. Chained, the host only
254
254
  // ever sees the input or the finished result.
255
255
  //
256
+ // A THIRD pass, same treatment, backfills AUDIO track ids
257
+ // (`normalizeAudioTracks`). `audio.tracks[*].id` is optional on disk
258
+ // (`docs/schemas/project.md` only requires `src`) but required by the
259
+ // editor's `AudioTrack` type, and every audio mutation — `updateAudioTrack`,
260
+ // this component's own track-replace path, multi-select mute/delete — keys
261
+ // a track by `id` with `===`. Id-less tracks all read as the same
262
+ // `undefined`, so editing one fanned the edit out to every audio track at
263
+ // once. Chained onto the captions result (not `sync.project` directly) for
264
+ // the same half-normalized-frame reason as the lane pass, and keyed into the
265
+ // deps below on `sync.project.audio` so an id-less track arriving via SSE
266
+ // mid-session self-heals too, not just on first mount.
267
+ //
256
268
  // `applyExternal` — no save, no undo push: this is normalization of loaded
257
269
  // data, not a user edit, so it must not dirty the project or contend with the
258
270
  // undo stack; the ids and lanes persist naturally the next time the operator
@@ -267,9 +279,10 @@ export default function VideoEditor<P extends Project = Project>({
267
279
  const captions = captionGestureRef.current
268
280
  ? backfilled.captions
269
281
  : normalizeCaptionLanes(backfilled.captions)
270
- const normalized = captions === backfilled.captions ? backfilled : { ...backfilled, captions }
282
+ const withCaptions = captions === backfilled.captions ? backfilled : { ...backfilled, captions }
283
+ const normalized = normalizeAudioTracks(withCaptions)
271
284
  if (normalized !== sync.project) sync.applyExternal(normalized)
272
- }, [sync.project.id, sync.project.captions])
285
+ }, [sync.project.id, sync.project.captions, sync.project.audio])
273
286
 
274
287
  // Notify the host of every authoritative change — edits, undo/redo, and SSE
275
288
  // frames — so its non-editor chrome (title, status pill) stays in sync. Mirrors
@@ -1385,13 +1398,20 @@ function ReviewSurface<P extends Project>({
1385
1398
  */
1386
1399
  function commitTimelineEdit(p: Project) {
1387
1400
  captionGestureRef.current = false
1388
- // Fold the auto-crossfade into the SAME commit as the gesture, so an audio
1401
+ // Fold BOTH derived crossfades into the SAME commit as the gesture, so a
1389
1402
  // drag/trim that ends overlapping a neighbour lands as ONE undo step (the
1390
1403
  // move and its derived fade together) rather than the move here plus a
1391
- // separate fade commit. Idempotent — a project needing no fade comes back
1392
- // unchanged — so video moves and non-overlapping audio moves are untouched.
1393
- const faded = computeAutoCrossfade(p) ?? p
1394
- sync.mutateTransient(() => faded as P)
1404
+ // separate fade commit. Both passes are idempotent — a gesture that creates
1405
+ // no overlap gets its project back unchanged — so a move that overlaps
1406
+ // nothing is untouched by either.
1407
+ //
1408
+ // Audio first, then visual, on the audio pass's OUTPUT: the two touch
1409
+ // disjoint parts of the project (`audio.tracks` vs `tracks`), so the order
1410
+ // is not load-bearing, but chaining them means one commit carries both
1411
+ // rather than the second silently dropping the first's work.
1412
+ const withAudio = computeAutoCrossfade(p) ?? p
1413
+ const withVisual = computeVisualCrossfade(withAudio) ?? withAudio
1414
+ sync.mutateTransient(() => withVisual as P)
1395
1415
  void sync.commit()
1396
1416
  }
1397
1417