@bycrux/editor 1.0.2 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/ControlsInfoModal.tsx +1 -0
- package/src/engine/__tests__/eligibility.test.ts +97 -0
- package/src/engine/__tests__/engine.test.ts +47 -2
- package/src/engine/__tests__/scheduler.test.ts +385 -4
- package/src/engine/__tests__/source-host.test.ts +165 -0
- package/src/engine/eligibility.ts +37 -1
- package/src/engine/index.ts +165 -29
- package/src/engine/scheduler.ts +345 -23
- package/src/index.ts +8 -0
- package/src/schema.ts +41 -1
- package/src/video/VideoEditor.tsx +106 -10
- package/src/video/__tests__/VideoEditor.keymap.test.tsx +64 -0
- package/src/video/__tests__/VideoEditor.test.tsx +169 -0
- package/src/video/__tests__/cuts.test.ts +84 -0
- package/src/video/__tests__/exportDurationSec.test.ts +60 -0
- package/src/video/__tests__/markerDropTime.test.ts +25 -0
- package/src/video/captionStyleDefaults.ts +2 -2
- package/src/video/cuts.ts +30 -2
- package/src/video/preview/OverlayItemsLayer.tsx +85 -6
- package/src/video/preview/PreviewPlayer.tsx +28 -1
- package/src/video/preview/__tests__/OverlayItemsLayer.keyframes.test.tsx +91 -0
- package/src/video/preview/__tests__/PreviewPlayer.engine.test.tsx +49 -1
- package/src/video/preview/__tests__/useVideoPlayback.canvasClock.test.ts +99 -0
- package/src/video/preview/useVideoPlayback.ts +18 -3
- package/src/video/timeline/Timeline.tsx +49 -1
- package/src/video/timeline/__tests__/Timeline.keymap.test.tsx +16 -0
- package/src/video/timeline/__tests__/markers.test.ts +125 -0
- package/src/video/timeline/__tests__/timeline-model.test.ts +307 -0
- package/src/video/timeline/canvas/TimelineCanvas.tsx +100 -4
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.test.tsx +106 -2
- package/src/video/timeline/canvas/__tests__/draw.test.ts +73 -0
- package/src/video/timeline/canvas/__tests__/hit-test.test.ts +76 -0
- package/src/video/timeline/canvas/__tests__/pointer-machine.test.ts +216 -1
- package/src/video/timeline/canvas/draw.ts +104 -7
- package/src/video/timeline/canvas/hit-test.ts +72 -1
- package/src/video/timeline/canvas/pointer-machine.ts +142 -5
- package/src/video/timeline/markers.ts +109 -0
- package/src/video/timeline/timeline-model.ts +286 -15
|
@@ -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
|
|
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
|
-
...
|
|
454
|
+
...normalized,
|
|
443
455
|
audio: {
|
|
444
|
-
...
|
|
445
|
-
tracks: (
|
|
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
|
|
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
|
-
...
|
|
557
|
+
...withIds,
|
|
534
558
|
audio: {
|
|
535
|
-
...
|
|
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:
|
|
595
|
-
* counter starting at 2 —
|
|
596
|
-
* free. Deterministic and collision-free. `taken` is updated in
|
|
597
|
-
* id handed out.
|
|
598
|
-
|
|
599
|
-
|
|
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 =
|
|
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
|