@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
@@ -3,7 +3,8 @@
3
3
  // here is behavior BOTH surfaces must reproduce identically, so it lives
4
4
  // outside either render path.
5
5
 
6
- import type { AudioTrack, VisualItem, VisualTrack } from '../../schema'
6
+ import { fadeShape, transitionPairs } from '@bycrux/timeline-core'
7
+ import type { AudioTrack, KeyframeTrack, VisualItem, VisualTrack } from '../../schema'
7
8
  import type { Project } from '../../types'
8
9
 
9
10
  // ── Row geometry ─────────────────────────────────────────────────────────
@@ -436,13 +437,24 @@ export function moveItemAcrossTracks(args: CrossTrackMoveArgs): VisualTrack[] {
436
437
 
437
438
  /** Patch one audio track by id, leaving the rest of the project alone. Shared
438
439
  * by the canvas pointer machine and Timeline.tsx so audio edits take the
439
- * same shape wherever they're made. */
440
+ * same shape wherever they're made.
441
+ *
442
+ * Normalizes audio track ids first (`normalizeAudioTracks`, below) — the
443
+ * same reason `mapTrackItems` normalizes visual tracks before touching them.
444
+ * Without it, a project with id-less audio tracks (legal per
445
+ * `docs/schemas/project.md`; VideoEditor's mount effect backfills them on
446
+ * open, but this function has to be correct on its own too) reads every
447
+ * track's `id` as the same `undefined`, so `t.id === trackId` matches ALL of
448
+ * them at once — one fade-handle drag fanned out and clobbered every other
449
+ * track's `src`. Normalizing first gives every track a real, distinct id
450
+ * before the `===` match runs, so the patch lands on exactly one. */
440
451
  export function updateAudioTrack(project: Project, trackId: string, changes: Partial<AudioTrack>): Project {
452
+ const normalized = normalizeAudioTracks(project)
441
453
  return {
442
- ...project,
454
+ ...normalized,
443
455
  audio: {
444
- ...project.audio,
445
- tracks: (project.audio?.tracks ?? []).map(t =>
456
+ ...normalized.audio,
457
+ tracks: (normalized.audio?.tracks ?? []).map(t =>
446
458
  t.id === trackId ? { ...t, ...changes } : t,
447
459
  ),
448
460
  },
@@ -490,9 +502,21 @@ export function computeDerivedTiming(project: Project): DerivedTiming {
490
502
  * Returns `null` — the no-change signal — when no track's fade needs to
491
503
  * change, so the caller's effect can skip calling `onProjectChange` and
492
504
  * avoid re-triggering itself forever.
505
+ *
506
+ * Normalizes audio track ids first (`normalizeAudioTracks`, below) for the
507
+ * same reason `updateAudioTrack` does: this is very likely the SHARPEST edge
508
+ * of the id-less-audio-track defect, because it runs unconditionally on any
509
+ * project with two overlapping audio tracks — no user edit required. The
510
+ * `trackMap = new Map(updated.map(t => [t.id, t]))` below keys on `t.id`; with
511
+ * every track's `id` reading as the same `undefined`, the whole map collapses
512
+ * to ONE entry — whichever track was processed last — and every track in the
513
+ * output becomes a byte-identical COPY of it, `src` included. That is this
514
+ * function's own account of the exact symptom reported live ("both tracks
515
+ * became byte-identical copies of the edited one").
493
516
  */
494
517
  export function computeAutoCrossfade(project: Project): Project | null {
495
- const audioTracks = project.audio?.tracks ?? []
518
+ const withIds = normalizeAudioTracks(project)
519
+ const audioTracks = withIds.audio?.tracks ?? []
496
520
  if (!audioTracks.length) return null
497
521
 
498
522
  const sorted = [...audioTracks].sort((a, b) => a.start - b.start)
@@ -530,14 +554,175 @@ export function computeAutoCrossfade(project: Project): Project | null {
530
554
 
531
555
  const trackMap = new Map(updated.map(t => [t.id, t]))
532
556
  return {
533
- ...project,
557
+ ...withIds,
534
558
  audio: {
535
- ...project.audio,
559
+ ...withIds.audio,
536
560
  tracks: audioTracks.map(t => trackMap.get(t.id) ?? t),
537
561
  },
538
562
  }
539
563
  }
540
564
 
565
+ // ── Visual crossfade (overlays) ───────────────────────────────────────────
566
+
567
+ /** Marks an `opacity` track as DERIVED from an overlap rather than authored by
568
+ * hand. Without it this function cannot tell its own previous output from a
569
+ * curve the operator drew, and would either clobber their work or refuse to
570
+ * ever update its own. The flag rides on the track, not the item, so an item
571
+ * can carry a derived opacity curve beside a hand-authored offsetX one. */
572
+ const DERIVED_FADE = 'crossfade' as const
573
+
574
+ /**
575
+ * Auto-crossfade for OVERLAY items: when two overlays overlap on the same
576
+ * track, write complementary `opacity` keyframe curves across the overlap.
577
+ *
578
+ * The visual sibling of `computeAutoCrossfade` above, with the same contract —
579
+ * idempotent, and `null` means "nothing to change" so the caller's effect does
580
+ * not re-trigger itself forever.
581
+ *
582
+ * ── Why this writes DATA, and why only for overlays ────────────────────────
583
+ *
584
+ * An overlay's opacity already animates end to end: `bundle.js`'s shim bakes
585
+ * `geometryAt(item, 'overlay', frame/fps).opacity` into the Puppeteer capture
586
+ * per frame, and `buildOverlayFilterParts` composites the result full-canvas.
587
+ * So the entire overlay crossfade is expressible as keyframes that already
588
+ * render — no resolver change, no render change, and the curves show up in the
589
+ * inspector where the operator can adjust them.
590
+ *
591
+ * A CLIP cannot do this at any price: `colorchannelmixer=aa=` takes a
592
+ * `<double>` and no expression, so ffmpeg cannot vary a clip's alpha over time
593
+ * (see `keyframeOps.ts`'s `canKeyframeProp`, which refuses to write such a
594
+ * track at all). Clip crossfades are therefore structural and derived at draw
595
+ * time from the overlap itself — `timeline-core`'s `crossfade` field — never
596
+ * data. This function skips every non-overlay item for that reason; writing an
597
+ * opacity curve onto a clip would promise a fade the export cannot produce.
598
+ *
599
+ * ── What it will not touch ────────────────────────────────────────────────
600
+ *
601
+ * An item whose `opacity` track is NOT marked `origin: 'crossfade'` was drawn
602
+ * by hand, and is left exactly as it is — the pair simply gets no derived fade.
603
+ * That is deliberate: a derived curve silently overwriting an authored one is
604
+ * far worse than a missing transition, which is visible immediately.
605
+ */
606
+ export function computeVisualCrossfade(project: Project): Project | null {
607
+ const withTracks = normalizeTracks(project)
608
+ const tracks = (withTracks as { tracks?: VisualTrack[] }).tracks ?? []
609
+ let changed = false
610
+
611
+ const nextTracks = tracks.map((track, trackIdx) => {
612
+ // tracks[0] is the primary footage row — video clips only, which cannot
613
+ // carry a derived opacity curve. Skip it wholesale rather than relying on
614
+ // the per-item type test below, so the intent is legible.
615
+ if (trackIdx === 0) return track
616
+
617
+ const overlays = track.items.filter(it => it.type === 'overlay')
618
+ const pairs = transitionPairs(overlays) as unknown as Array<{
619
+ from: VisualItem; to: VisualItem; start: number; end: number
620
+ }>
621
+
622
+ // What each item's derived curve SHOULD be, by id. An item absent from this
623
+ // map should carry no derived curve at all.
624
+ const wanted = new Map<string, KeyframeTrack>()
625
+ for (const pair of pairs) {
626
+ if (hasAuthoredOpacity(pair.from) || hasAuthoredOpacity(pair.to)) continue
627
+ const shapeStart = fadeShape(pair, 0)
628
+ const shapeEnd = fadeShape(pair, 1)
629
+ // `fadeShape` returns absolute 0..1 fractions of a FULL fade — it knows
630
+ // nothing about either item's own authored `opacity`. An overlay drawn
631
+ // at 0.5 must fade between 0.5 and 0 (its own visible level and gone),
632
+ // not between 1 and 0, or the derived curve silently overwrites the
633
+ // static opacity for the item's whole pre/post-overlap life. Scale by
634
+ // the item's own base opacity when building the points; the hold test
635
+ // below stays on the UNSCALED shapeStart/shapeEnd, since multiplying
636
+ // both sides by the same constant preserves (or preserves the absence
637
+ // of) their equality either way.
638
+ const fromBase = pair.from.opacity ?? 1
639
+ const toBase = pair.to.opacity ?? 1
640
+ // Item-relative seconds — keyframe `t` is measured from the item's own
641
+ // `start`, never from the timeline origin (docs/schemas/project.md).
642
+ const fromT0 = pair.start - (pair.from.start ?? 0)
643
+ const fromT1 = pair.end - (pair.from.start ?? 0)
644
+ const toT0 = pair.start - (pair.to.start ?? 0)
645
+ const toT1 = pair.end - (pair.to.start ?? 0)
646
+ // A held side (opaque outgoing) gets NO track — a flat 1→1 curve is a
647
+ // no-op that would flip the item onto the full-canvas baked capture path
648
+ // (`encode-segment.js`'s `keyframed` test is "has any keyframes", not
649
+ // "has a non-trivial curve") for no benefit and a real cost.
650
+ if (shapeStart.from !== shapeEnd.from) {
651
+ wanted.set(pair.from.id, {
652
+ prop: 'opacity',
653
+ origin: DERIVED_FADE,
654
+ points: [
655
+ { t: fromT0, value: shapeStart.from * fromBase },
656
+ { t: fromT1, value: shapeEnd.from * fromBase },
657
+ ],
658
+ })
659
+ }
660
+ if (shapeStart.to !== shapeEnd.to) {
661
+ wanted.set(pair.to.id, {
662
+ prop: 'opacity',
663
+ origin: DERIVED_FADE,
664
+ points: [
665
+ { t: toT0, value: shapeStart.to * toBase },
666
+ { t: toT1, value: shapeEnd.to * toBase },
667
+ ],
668
+ })
669
+ }
670
+ }
671
+
672
+ // `.map()` always allocates a new array, so comparing its result against
673
+ // `track.items` by reference below would never be true — this flag is
674
+ // the real "did anything in this track actually change" test.
675
+ let trackChanged = false
676
+
677
+ const items = track.items.map(item => {
678
+ if (item.type !== 'overlay') return item
679
+ const existing = (item.keyframes ?? []).find(k => k.prop === 'opacity')
680
+ const derived = existing?.origin === DERIVED_FADE
681
+ const target = wanted.get(item.id)
682
+
683
+ if (!target) {
684
+ if (!derived) return item // nothing there, or hand-authored
685
+ changed = true
686
+ trackChanged = true
687
+ const rest = (item.keyframes ?? []).filter(k => k.prop !== 'opacity')
688
+ const next = { ...item }
689
+ if (rest.length) next.keyframes = rest
690
+ else delete next.keyframes
691
+ return next
692
+ }
693
+
694
+ if (existing && !derived) return item // hand-authored — never clobber
695
+ if (derived && sameTrack(existing!, target)) return item // already converged
696
+ changed = true
697
+ trackChanged = true
698
+ return {
699
+ ...item,
700
+ keyframes: [...(item.keyframes ?? []).filter(k => k.prop !== 'opacity'), target],
701
+ }
702
+ })
703
+
704
+ return trackChanged ? { ...track, items } : track
705
+ })
706
+
707
+ if (!changed) return null
708
+ return { ...withTracks, tracks: nextTracks } as Project
709
+ }
710
+
711
+ /** An `opacity` track the operator drew — anything not marked as our own. */
712
+ function hasAuthoredOpacity(item: VisualItem): boolean {
713
+ const track = (item.keyframes ?? []).find(k => k.prop === 'opacity')
714
+ return !!track && track.origin !== DERIVED_FADE
715
+ }
716
+
717
+ /** Point-for-point equality. The convergence test: comparing anything looser
718
+ * (length, endpoints) makes this non-idempotent the moment a trim moves a
719
+ * boundary by less than the compared tolerance — the same defect
720
+ * `computeAutoCrossfade` above records in its rounding comment. */
721
+ function sameTrack(a: KeyframeTrack, b: KeyframeTrack): boolean {
722
+ if (a.points.length !== b.points.length) return false
723
+ return a.points.every((pt, i) => pt.t === b.points[i].t && pt.value === b.points[i].value)
724
+ }
725
+
541
726
  // ── Track shape (legacy VisualItem[][] ⟷ VisualTrack[]) ───────────────────
542
727
 
543
728
  /**
@@ -591,15 +776,18 @@ function isTrackObjectForm(tracks: unknown[]): boolean {
591
776
  }
592
777
 
593
778
  /** Generate an id for the track at `index`, avoiding every id in `taken`. The
594
- * rule: `trk-<index>`; if that is already taken, append an incrementing
595
- * counter starting at 2 — `trk-<index>-2`, `trk-<index>-3`, … — until one is
596
- * free. Deterministic and collision-free. `taken` is updated in place with the
597
- * id handed out. */
598
- function assignTrackId(index: number, taken: Set<string>): string {
599
- let candidate = `trk-${index}`
779
+ * rule: `<prefix>-<index>`; if that is already taken, append an incrementing
780
+ * counter starting at 2 — `<prefix>-<index>-2`, `<prefix>-<index>-3`, … —
781
+ * until one is free. Deterministic and collision-free. `taken` is updated in
782
+ * place with the id handed out. `prefix` defaults to `trk` (every existing
783
+ * caller is a VISUAL track); `normalizeAudioTracks` below passes `aud` so a
784
+ * project's audio and visual ids can never collide with each other by
785
+ * construction. */
786
+ function assignTrackId(index: number, taken: Set<string>, prefix: string = 'trk'): string {
787
+ let candidate = `${prefix}-${index}`
600
788
  let suffix = 2
601
789
  while (taken.has(candidate)) {
602
- candidate = `trk-${index}-${suffix}`
790
+ candidate = `${prefix}-${index}-${suffix}`
603
791
  suffix += 1
604
792
  }
605
793
  taken.add(candidate)
@@ -758,6 +946,89 @@ export function normalizeTracks<T extends { tracks?: unknown }>(project: T): T {
758
946
  return { ...project, tracks: orderedTrackArray(out as unknown as VisualTrack[]) } as T
759
947
  }
760
948
 
949
+ // ── Audio track id policy ────────────────────────────────────────────────
950
+
951
+ /** True when `tracks` needs no work: every element is an object carrying a
952
+ * non-empty string `id`, and no two share an id. Audio tracks have no legacy
953
+ * alternate shape the way `project.tracks` does — `docs/schemas/project.md`
954
+ * has always described `audio.tracks` as an array of track objects — so
955
+ * unlike `isTrackObjectForm` there is nothing else to tolerate here. */
956
+ function isAudioTrackIdForm(tracks: unknown[]): boolean {
957
+ const seen = new Set<string>()
958
+ for (const track of tracks) {
959
+ if (!isTrackObject(track)) return false
960
+ if (!isTrackId(track.id)) return false
961
+ if (seen.has(track.id)) return false
962
+ seen.add(track.id)
963
+ }
964
+ return true
965
+ }
966
+
967
+ /**
968
+ * Return `project` with every `audio.tracks[*].id` filled in wherever it's
969
+ * missing, not a string, empty, or a duplicate of an earlier track's id — the
970
+ * audio sibling of `normalizeTracks`' id policy above. Read that function's
971
+ * doc comment for the property in full; this mirrors it exactly, minus the
972
+ * legacy array-of-arrays tolerance that belongs to `project.tracks` alone.
973
+ *
974
+ * `docs/schemas/project.md`'s audio-tracks field table has never listed `id`
975
+ * as something an agent/CLI has to write — only `src` is required — while the
976
+ * editor's `AudioTrack` type (schema.ts) declares `id: string` required and
977
+ * every editor mutation keys a track by it with `===`: `updateAudioTrack`
978
+ * below, the track-replace path in VideoEditor.tsx, and the multi-select
979
+ * mute/delete ops in multiSelectOps.ts. With every track's `id` reading as
980
+ * the SAME `undefined`, `t.id === trackId` was true for every track at once —
981
+ * editing one audio track (e.g. dragging a single fade handle) fanned the
982
+ * edit out to all of them and clobbered their `src`. This is the fix: give
983
+ * every track a real, distinct id before anything addresses one by id.
984
+ *
985
+ * Pure — never mutates the input, at any depth. Tolerant of a project with no
986
+ * `audio`, an `audio` that isn't an object, or a `tracks` that is missing,
987
+ * `null`, or not an array: normalization is not validation and never throws
988
+ * on malformed input.
989
+ *
990
+ * Identity-preserving: when every track already carries a usable, unique id,
991
+ * the input object itself is returned, so `normalizeAudioTracks(p) === p`.
992
+ * Same load-bearing contract as `normalizeTracks` — this is what VideoEditor's
993
+ * mount effect (the same one that backfills caption ids) reads as "nothing to
994
+ * write", so a converged project triggers no save.
995
+ *
996
+ * Ids are minted `aud-<index>` via `assignTrackId`'s `aud` prefix — same
997
+ * collision rule as visual tracks (`-2`, `-3`, … on collision), but a
998
+ * different prefix so an audio id can never collide with a visual track id.
999
+ * Existing real ids — the `vo-01`-style ids `project/init.py` writes for a
1000
+ * voiceover track — are preserved untouched; only a track missing an id, or
1001
+ * colliding with an earlier one, gets a generated replacement.
1002
+ */
1003
+ export function normalizeAudioTracks<T extends { audio?: unknown }>(project: T): T {
1004
+ if (project === null || typeof project !== 'object') return project
1005
+ const audio: unknown = (project as { audio?: unknown }).audio
1006
+ if (!isTrackObject(audio)) return project
1007
+ const tracks: unknown = audio.tracks
1008
+ if (!Array.isArray(tracks)) return project
1009
+ if (isAudioTrackIdForm(tracks)) return project
1010
+
1011
+ // Every explicit id already on the project's audio tracks, collected up
1012
+ // front so a generated `aud-<i>` can never land on an id a LATER track
1013
+ // already claims.
1014
+ const taken = new Set<string>()
1015
+ for (const track of tracks) {
1016
+ if (isTrackObject(track) && isTrackId(track.id)) taken.add(track.id)
1017
+ }
1018
+
1019
+ const kept = new Set<string>() // ids handed out so far, so a duplicate loses to the first holder
1020
+ const out = tracks.map((track, index) => {
1021
+ // Not an object — not this function's job to fix the track's shape, only
1022
+ // its id policy; leave it as-is rather than fabricate fields.
1023
+ if (!isTrackObject(track)) return track
1024
+ const id = isTrackId(track.id) && !kept.has(track.id) ? track.id : assignTrackId(index, taken, 'aud')
1025
+ kept.add(id)
1026
+ return { ...track, id }
1027
+ })
1028
+
1029
+ return { ...project, audio: { ...audio, tracks: out } } as T
1030
+ }
1031
+
761
1032
  /**
762
1033
  * Just the items, in track order — for the many callers that only read.
763
1034
  * `[]` when the project has no tracks (or a `tracks` too malformed to