-
Notifications
You must be signed in to change notification settings - Fork 133
Expand file tree
/
Copy pathtimelineMap.ts
More file actions
600 lines (568 loc) · 26 KB
/
Copy pathtimelineMap.ts
File metadata and controls
600 lines (568 loc) · 26 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
// The single place that maps between the timeline's three coordinate systems.
// See technical-documentation/architecture/timeline-model.md.
//
// RAW virtual — the ruler the user manipulates; trims still occupy their
// space. `currentTimeSec`, the playhead, region authoring, and
// `document.timeline.clips[].timelineStartSec` all live here.
// source — a clip's own media time (assetId + sourceSec); what the
// decoders, the native compositor, and trims are expressed in.
// compressed — the trim-narrowed playback sequence (`resolvePlaybackSegments`),
// laid out back-to-back from 0; what the native free-run stream
// and the export frame count advance through.
//
// A raw clip is identity between source-time and raw-virtual-time (its timeline
// length equals its source length — no speed baked into the geometry), so raw↔
// source within one clip is a plain shift by the clip's own start offset. The
// bug this module fixes was projecting regions authored in RAW coordinates
// against the COMPRESSED segment layout, which slips every region after a trim
// forward by the trimmed duration.
import type { AxcutClip } from "../schema";
import { ventilateSpanAcrossClips } from "./region-ventilation";
import { findRawClipForSegment, getRawVirtualStartTime } from "./virtual-preview";
/** A modifier fragment anchored to one clip in that clip's own source time — the
* Stage B storage shape (see technical-documentation/architecture/timeline-model.md).
* Trims narrow the clip's kept source ranges, so an anchored fragment is clipped/
* hidden by the same interval math with no reprojection; a reorder carries it with
* its clip by `clipId`.
*
* A region the user authored across a clip boundary is stored as one fragment per
* covered clip. Nothing records that they belong together: by the universal merge rule
* they share the same properties, so they render as one pill whenever they are adjacent —
* and stop doing so if one of them is edited. `T` is the payload minus the RAW-ms span. */
export type ClipAnchored<T> = Omit<T, "startMs" | "endMs"> & {
clipId: string;
sourceStartSec: number;
sourceEndSec: number;
};
/**
* Migrate RAW-virtual-ms regions (the v4 document-level storage) to clip-anchored
* source-time fragments (the v5 storage). Each region is ventilated across the RAW
* clip layout it was authored against: wholly inside one clip → one fragment;
* straddling a boundary → one fragment per covered clip (they re-merge on display by the
* merge rule, since they share properties — no bookkeeping). Each fragment
* gets its own unique `id` (first keeps the original region id; extras from
* `makeId`). A zero-length / off-timeline region covers no clip and is dropped (it
* could never play). Pure; reused by the v4→v5 schema migration and by re-anchoring
* after a raw edit.
*/
export function anchorRawRegionsToClips<T extends { id: string; startMs: number; endMs: number }>(
regions: T[],
rawClips: AxcutClip[],
makeId: () => string,
): ClipAnchored<T>[] {
const byId = new Map(rawClips.map((c) => [c.id, c]));
const out: ClipAnchored<T>[] = [];
for (const region of regions) {
const frags = ventilateSpanAcrossClips(region.startMs / 1000, region.endMs / 1000, rawClips);
frags.forEach((f, i) => {
const clip = byId.get(f.clipId);
if (!clip) return;
// `groupId` is dropped, not carried: it is a dead marker from the removed group
// model, and re-anchoring is the natural place to stop it propagating into new
// fragments. (Identity ignores it anyway — see NON_IDENTITY_FIELDS.)
const {
startMs: _s,
endMs: _e,
groupId: _g,
...payload
} = region as T & { groupId?: string };
out.push({
...(payload as Omit<T, "startMs" | "endMs">),
id: i === 0 ? region.id : makeId(),
clipId: f.clipId,
sourceStartSec: clip.sourceStartSec + f.localStartSec,
sourceEndSec: clip.sourceStartSec + f.localEndSec,
});
});
}
return out;
}
/**
* The RAW ruler span a clip-anchored fragment currently occupies, derived from its
* clip's live position — the single forward map source↔raw for one fragment (a raw
* clip is identity between source and raw-virtual time). Returns null when the
* fragment's `clipId` is gone (clip deleted → the fragment is not shown). Used to
* place pills (`V4Timeline`), derive the transition `startMs/endMs`, and by
* `coalesceAnchoredFragments`.
*/
export function anchoredToRawSpanSec(
fragment: { clipId: string; sourceStartSec: number; sourceEndSec: number },
clips: AxcutClip[],
): { startSec: number; endSec: number } | null {
const clip = clips.find((c) => c.id === fragment.clipId);
if (!clip) return null;
return {
startSec: clip.timelineStartSec + (fragment.sourceStartSec - clip.sourceStartSec),
endSec: clip.timelineStartSec + (fragment.sourceEndSec - clip.sourceStartSec),
};
}
// ─── The two universal region rules ────────────────────────────────────────
// Every kind of ruler region (trim, zoom, speed, annotation, full-camera) obeys the
// same two rules, expressed once here rather than re-derived per kind:
//
// 1. MERGE — two regions of the same kind with the SAME identity that touch are
// indistinguishable to the user, so they are one pill. However they
// came to be adjacent (authored side by side, split by a reorder then
// rejoined, …) is irrelevant: identity is what a region IS, never where
// it came from or how it got there.
// 2. REPEL — two regions of the same kind with DIFFERENT identities may not
// overlap. An edit clamps to the neighbour's edge; the neighbour never
// moves (no cascade).
//
// A kind with no properties (trim, full-camera) collapses to a constant identity, so
// its regions always merge — the long-standing trim behaviour, now *derived* from the
// general rule instead of hand-coded beside it.
/** Fields that say WHERE a region sits or WHERE IT CAME FROM — never what it is. */
const NON_IDENTITY_FIELDS = new Set([
// position
"id",
"clipId",
"assetId",
"sourceStartSec",
"sourceEndSec",
"startMs",
"endMs",
"startSec",
"endSec",
// provenance / metadata
"reason",
"origin",
"source",
"annotationSource",
// Legacy provenance marker from the removed group model. It no longer exists in
// code, but it SURVIVES in documents already migrated to v5 (which never re-run the
// migration), and two independently authored regions carry different ones. Left in,
// it would silently make otherwise identical regions refuse to merge — which is
// exactly the bug this list must prevent: provenance never decides identity.
"groupId",
]);
/** Canonical serialisation — key order must not affect identity. */
function stableStringify(value: unknown): string {
if (value === null || typeof value !== "object") return JSON.stringify(value) ?? "null";
if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
const obj = value as Record<string, unknown>;
const keys = Object.keys(obj)
.filter((k) => obj[k] !== undefined)
.sort();
return `{${keys.map((k) => `${JSON.stringify(k)}:${stableStringify(obj[k])}`).join(",")}}`;
}
/**
* What a region IS: every property that affects how it renders/behaves, canonically
* serialised; position and provenance excluded. Two regions of one kind sharing this
* key are the same pill when adjacent. A propertyless kind yields a constant key, so
* all its regions merge.
*/
export function regionIdentityKey(region: Record<string, unknown>): string {
const payload: Record<string, unknown> = {};
for (const [key, value] of Object.entries(region)) {
if (!NON_IDENTITY_FIELDS.has(key) && value !== undefined) payload[key] = value;
}
return stableStringify(payload);
}
export interface IdentifiedSpan {
id: string;
/** RAW timeline seconds. */
start: number;
end: number;
identity: string;
}
export interface MergedSpan {
start: number;
end: number;
/** Underlying region ids under this span, left-to-right. */
ids: string[];
identity: string;
}
/**
* Rule 1 — merge touching spans that share an identity, and only those. Spans of
* different identities never merge even when they touch. This is the single coalescing
* primitive for every region kind: trims pass a constant identity (→ always merge),
* modifiers pass {@link regionIdentityKey}.
*/
export function coalesceByIdentity(spans: IdentifiedSpan[], epsilonSec = 0.001): MergedSpan[] {
const byIdentity = new Map<string, IdentifiedSpan[]>();
for (const span of spans) {
const list = byIdentity.get(span.identity) ?? [];
list.push(span);
byIdentity.set(span.identity, list);
}
const out: MergedSpan[] = [];
for (const [identity, list] of byIdentity) {
let cur: MergedSpan | null = null;
for (const span of [...list].sort((a, b) => a.start - b.start)) {
if (cur && span.start - cur.end <= epsilonSec) {
cur.end = Math.max(cur.end, span.end);
cur.ids.push(span.id);
} else {
cur = { start: span.start, end: span.end, ids: [span.id], identity };
out.push(cur);
}
}
}
return out.sort((a, b) => a.start - b.start);
}
/**
* Rule 2 — clamp an edited span so it cannot overlap a same-kind span of a DIFFERENT
* identity. Blocking neighbours act as walls: the edited span stops at the nearest
* blocking edge on each side and the neighbour is never modified or displaced, so an
* edit can never cascade into regions the user did not touch. Same-identity spans are
* not obstacles — overlapping them is harmless, they simply merge (rule 1).
*/
export function clampSpanAgainstNeighbours(
desired: { start: number; end: number },
identity: string,
others: IdentifiedSpan[],
): { start: number; end: number } {
let start = Math.min(desired.start, desired.end);
let end = Math.max(desired.start, desired.end);
for (const other of [...others].sort((a, b) => a.start - b.start)) {
if (other.identity === identity) continue;
if (other.end <= start || other.start >= end) continue; // no overlap
if (other.start <= start) start = Math.max(start, other.end);
else end = Math.min(end, other.start);
}
return { start, end: Math.max(start, end) };
}
/** A pill as the ruler draws it: a run of same-identity regions that touch. */
export interface RegionPill<T> extends MergedSpan {
/** First region under the pill — carries the payload the label needs. */
member: T;
}
/**
* Group stored regions into the pills the ruler draws, via the universal merge rule.
* Works off the DERIVED `startMs`/`endMs`: they are always present (even before a region
* is anchored) and kept in sync with the anchor, so a region can never become invisible
* merely because it has no anchor yet.
*/
export function coalesceRegionsForRuler<T extends { id: string; startMs: number; endMs: number }>(
regions: T[],
epsilonSec = 0.001,
): RegionPill<T>[] {
const byId = new Map(regions.map((r) => [r.id, r]));
const spans = regions.map((r) => ({
id: r.id,
start: r.startMs / 1000,
end: r.endMs / 1000,
identity: regionIdentityKey(r as unknown as Record<string, unknown>),
}));
return coalesceByIdentity(spans, epsilonSec).map((merged) => ({
...merged,
member: byId.get(merged.ids[0]) as T,
}));
}
/**
* The regions that render as the SAME pill as `id`. Recomputed from the merge rule
* rather than stored: nothing records which regions belong together, because equal
* properties + adjacency already say so, and that stays correct however they came to be
* adjacent. Every mutation routes through this so it acts on exactly what the user sees
* as one pill.
*/
export function resolvePillIds<T extends { id: string; startMs: number; endMs: number }>(
regions: T[],
id: string,
epsilonSec = 0.001,
): string[] {
return coalesceRegionsForRuler(regions, epsilonSec).find((p) => p.ids.includes(id))?.ids ?? [id];
}
/**
* Deleting a pill deletes every region under it. Which regions those are is RESOLVED from
* the universal merge rule (same properties + touching = one pill), never from stored
* provenance — so it stays correct however they came to be adjacent. Lives here, in the
* pure layer, so the store (UI delete) and the document layer (agent delete) drop a pill
* exactly the same way.
*/
export function dropPillById<T extends { id: string; startMs: number; endMs: number }>(
regions: T[],
id: string,
): T[] {
const under = new Set(resolvePillIds(regions, id));
return regions.filter((r) => !under.has(r.id));
}
/** Batch variant of {@link dropPillById} — expands every selected id to its whole pill. */
export function dropPillsByIds<T extends { id: string; startMs: number; endMs: number }>(
regions: T[],
ids: Iterable<string>,
): T[] {
let out = regions;
for (const id of ids) out = dropPillById(out, id);
return out;
}
/**
* Move/resize the pill containing `id`, obeying both rules: the requested span is first
* CLAMPED against pills of a different identity (rule 2 — they act as walls and never
* move), then the pill's regions are replaced by fragments re-anchored to the clamped
* span, carrying the pill's payload. Crossing a clip boundary re-splits into one fragment
* per clip; coming back inside one clip collapses again; and neighbours of the same
* identity simply merge on display (rule 1). No provenance is consulted anywhere.
*/
export function replacePillSpan<T extends { id: string; startMs: number; endMs: number }>(
regions: T[],
id: string,
startMs: number,
endMs: number,
clips: AxcutClip[],
makeId: () => string,
epsilonSec = 0.001,
): T[] {
const pills = coalesceRegionsForRuler(regions, epsilonSec);
const pill = pills.find((p) => p.ids.includes(id));
if (!pill) return regions;
const clamped = clampSpanAgainstNeighbours(
{ start: Math.min(startMs, endMs) / 1000, end: Math.max(startMs, endMs) / 1000 },
pill.identity,
pills
.filter((p) => p !== pill)
.map((p) => ({ id: p.ids[0], start: p.start, end: p.end, identity: p.identity })),
);
const under = new Set(pill.ids);
const { startMs: _s, endMs: _e, ...payload } = pill.member;
const rebuilt = anchorRegionsWithDerivedMs(
[
{
...payload,
id: pill.ids[0],
startMs: Math.round(clamped.start * 1000),
endMs: Math.round(clamped.end * 1000),
} as unknown as T,
],
clips,
makeId,
) as unknown as T[];
return [...regions.filter((r) => !under.has(r.id)), ...rebuilt];
}
/** A region after v5 migration: either an anchored fragment (with its derived ms
* cache) or — when nothing could be anchored — the original region, untouched. */
export type MigratedRegion<T extends { id: string; startMs: number; endMs: number }> =
| T
| (ClipAnchored<T> & { startMs: number; endMs: number });
/**
* The v5 STORED shape of a migrated region: a clip-anchored fragment plus a
* DERIVED `startMs`/`endMs` cache (its current RAW ruler span). During the Stage B
* transition the anchor is the SSOT while un-migrated consumers keep reading
* `startMs`/`endMs`; a structural op (or migration) re-derives them from the anchor.
* Shared by the disk-load path (`documentSchema`'s v4→v5 preprocess) and the v2
* migration so both emit identical v5 regions.
*
* **Never loses a region.** Unlike the low-level `anchorRawRegionsToClips` (which
* yields no fragments when a region covers no clip — correct for a ventilation
* primitive), a region that cannot be anchored is passed through UNANCHORED, keeping
* its original `startMs`/`endMs`. That case is real and must not drop user data: a v2
* project imported before its asset duration is probed has a zero-extent clip, so
* nothing overlaps yet. Such regions stay valid (the anchor is optional) and get
* anchored later once the clip has a real duration.
*/
export function anchorRegionsWithDerivedMs<
T extends { id: string; startMs: number; endMs: number },
>(regions: T[], clips: AxcutClip[], makeId: () => string): MigratedRegion<T>[] {
return regions.flatMap<MigratedRegion<T>>((region) => {
const frags = anchorRawRegionsToClips([region], clips, makeId);
if (frags.length === 0) return [region];
return frags.map((frag) => {
const span = anchoredToRawSpanSec(frag, clips);
return {
...frag,
startMs: span ? Math.round(span.startSec * 1000) : region.startMs,
endMs: span ? Math.round(span.endSec * 1000) : region.endMs,
};
});
});
}
/**
* RAW-virtual extent of a (possibly trim-narrowed) playback segment: where it
* sits on the ruler, derived from its parent raw clip via `getRawVirtualStartTime`.
* Note the raw span of two segments split by a trim has a GAP between them — the
* removed stretch keeps its place on the raw ruler — which is exactly what makes
* a region land on the source moment it was authored on rather than sliding into
* the gap.
*/
export function segmentRawSpanSec(
segment: AxcutClip,
rawClips: AxcutClip[],
): { startSec: number; endSec: number } {
const startSec = getRawVirtualStartTime(segment, rawClips);
const lenSec = (segment.sourceEndSec ?? segment.sourceStartSec) - segment.sourceStartSec;
return { startSec, endSec: startSec + lenSec };
}
/** The clip anchor a region may carry: WHERE IN THE SOURCE MEDIA it lives. Optional
* by design — migration keeps a region it cannot anchor rather than dropping it. */
interface RegionClipAnchor {
clipId?: string;
sourceStartSec?: number;
sourceEndSec?: number;
}
/** True when the anchor is usable on its own (all three parts present), i.e. the
* region can be placed without consulting raw-virtual time at all. Anything missing
* a part of `{clipId, sourceStartSec, sourceEndSec}` still relies on its RAW ms.
*
* THE definition, so "is this anchored?" can never be asked two different ways. It
* used to be asked twice: the document layer had its own copy testing
* `sourceStartSec !== undefined`, which called a region carrying `null` anchored
* while this one called it unanchored. `null` survives any in-memory mutation that
* does not round-trip through zod, and the two answers sent the same region down
* two different paths -- `rederiveAnchoredRegion` rewrote it to `Math.max(null, ...)`
* i.e. the clip start, while the exporter went on using its raw ms. Preview and
* export disagreed. The `typeof` tests below are what make that unreachable, so
* keep them: `!== undefined` is not the same question. */
export function hasCompleteClipAnchor<T extends RegionClipAnchor>(
region: T,
): region is T & Required<RegionClipAnchor> {
return (
typeof region.clipId === "string" &&
typeof region.sourceStartSec === "number" &&
typeof region.sourceEndSec === "number"
);
}
/**
* Resolve regions (zoom / annotation / speed / camera-fullscreen) onto the SOURCE-ms
* ranges the native compositor matches against, plus the `clipIndex` into the
* trim-compressed stream.
*
* Two paths, chosen per region:
* - **anchored** (`{clipId, sourceStartSec, sourceEndSec}` present): the source span
* is read straight off the anchor and intersected with the kept segments of that
* clip. This is the SSOT path — it never consults `startMs`/`endMs` nor raw-virtual
* time, so a stale derived cache or a shifted ruler layout cannot move the region.
* - **unanchored**: the legacy raw→source mapping through each segment's own raw
* extent, kept because migration deliberately preserves un-anchorable regions.
*
* In both paths a region split across two kept segments by a trim yields one entry per
* segment (fresh id for the extra copies, original id on the first), and a region
* overlapping no visible segment passes through unchanged with no `clipIndex` (native
* falls back to time-overlap) — the contract callers already relied on.
*
* `visibleSegments` MUST be the same array (same order) serialized to `Scene.clips` so
* the emitted `clipIndex` lines up with the native stream; `rawClips` is
* `document.timeline.clips`.
*/
export function projectRegionsToSource<
T extends { id: string; startMs: number; endMs: number } & RegionClipAnchor,
>(
regions: T[],
visibleSegments: AxcutClip[],
rawClips: AxcutClip[],
makeId: () => string,
): (T & { clipIndex?: number })[] {
// RAW extents + owning raw clip per visible segment. Both are only consulted by the
// path that needs them (raw fallback / anchor match), but resolving them once keeps
// the per-region loop free of repeated lookups.
const spans = visibleSegments.map((seg) => segmentRawSpanSec(seg, rawClips));
const segmentRawClipIds = visibleSegments.map((seg) => findRawClipForSegment(seg, rawClips)?.id);
const out: (T & { clipIndex?: number })[] = [];
for (const region of regions) {
let emitted = 0;
const emit = (clipIndex: number, srcStartSec: number, srcEndSec: number) => {
out.push({
...region,
id: emitted === 0 ? region.id : makeId(),
startMs: Math.round(srcStartSec * 1000),
endMs: Math.round(srcEndSec * 1000),
clipIndex,
});
emitted += 1;
};
if (hasCompleteClipAnchor(region)) {
// ANCHORED — the region already states where it lives in the source media, so
// there is nothing to project: intersect its source span with each kept segment
// of its OWN clip. No detour through raw-virtual time means no dependency on the
// ruler layout (or on `startMs`/`endMs` being freshly re-derived), which is the
// entire class of drift this model exists to remove.
const lo = Math.min(region.sourceStartSec, region.sourceEndSec);
const hi = Math.max(region.sourceStartSec, region.sourceEndSec);
visibleSegments.forEach((seg, clipIndex) => {
if (segmentRawClipIds[clipIndex] !== region.clipId) return;
const s = Math.max(lo, seg.sourceStartSec);
const e = Math.min(hi, seg.sourceEndSec ?? seg.sourceStartSec);
if (e <= s) return;
emit(clipIndex, s, e);
});
} else {
// UNANCHORED — migration kept this region rather than dropping it (see
// `anchorRegionsWithDerivedMs`), so it only has its RAW span. Map that through
// each segment's own raw extent, exactly as before the anchor existed.
const lo = Math.min(region.startMs, region.endMs) / 1000;
const hi = Math.max(region.startMs, region.endMs) / 1000;
visibleSegments.forEach((seg, clipIndex) => {
const { startSec: segRawStart, endSec: segRawEnd } = spans[clipIndex];
const s = Math.max(lo, segRawStart);
const e = Math.min(hi, segRawEnd);
if (e <= s) return;
emit(
clipIndex,
seg.sourceStartSec + (s - segRawStart),
seg.sourceStartSec + (e - segRawStart),
);
});
}
// A region that WAS resolved against real segments but overlapped none of them
// sits entirely under a trim (or off the visible timeline) — its content was
// removed, so it must NOT render. Re-emitting it here is exactly what let a
// fully-trimmed effect resurface elsewhere: it would carry its RAW-virtual
// startMs/endMs and NO clipIndex, and native's `belongs()` (scene.rs) accepts a
// clipIndex-less region on ANY clip whose SOURCE window numerically overlaps those
// raw numbers — so the effect fired *later* on an unrelated clip instead of being
// ignored (the same wrong-clip class as the `speed_at` fix in regions.rs). Only
// when there are no segments AT ALL is there no layout to resolve against; keep the
// passthrough for that degenerate case (it reaches an empty native clip list and so
// can never be matched anyway).
if (emitted === 0 && visibleSegments.length === 0) out.push(region);
}
return out;
}
export interface NativePosition {
/** The trim-narrowed playback segment (from `visibleSegments`) that is active. */
clip: AxcutClip;
/** Its index in `visibleSegments`, matching `SceneDescription.clips` / native `clip_index`. */
clipIndex: number;
/** Screen-source seconds the native decoder should present for this segment. */
sourceTimeSec: number;
}
// A hair before a segment's source end, so a scrub/seek never asks the decoder
// for a frame past EOF (returns a null D3D frame → crash). Mirrors the margin the
// old `resolveNativePlaybackPosition` carried; ~1 frame @ 30fps.
const NATIVE_EOF_MARGIN_SEC = 0.033;
/**
* Resolve a RAW timeline playhead to the active native clip's screen-source clock.
*
* The playhead (`currentTimeSec`) lives on the RAW ruler (trims still occupy their
* space); the native compositor plays the trim-COMPRESSED `visibleSegments`. This
* maps raw→source through each segment's OWN raw extent (via `rawClips`, the
* un-compressed layout) so the source time is correct after a trim, and returns
* the segment's `clipIndex` in the compressed stream so `setActiveClip`/`presentTime`
* address the right decoder + the right paired camera. When the raw playhead sits
* over a trimmed-out stretch, it snaps to the next kept segment (where content
* resumes) rather than the removed frames. Returns null only when there are no
* segments at all. Replaces `nativePlaybackPosition.resolveNativePlaybackPosition`,
* which conflated the raw and compressed layouts (correct only without trims).
*/
export function resolveNativePosition(
rawSec: number,
visibleSegments: AxcutClip[],
rawClips: AxcutClip[],
): NativePosition | null {
if (!Number.isFinite(rawSec) || visibleSegments.length === 0) return null;
const spans = visibleSegments.map((seg) => segmentRawSpanSec(seg, rawClips));
// Segment whose RAW extent contains the playhead (last segment's end inclusive).
let index = spans.findIndex((s, i) => {
const isLast = i === spans.length - 1;
return rawSec >= s.startSec && (rawSec < s.endSec || (isLast && rawSec <= s.endSec));
});
// Over a trimmed-out gap (or before the first kept frame): snap to the next kept
// segment; if the playhead is past all kept content, clamp into the last one.
let clampToSegmentStart = false;
if (index < 0) {
index = spans.findIndex((s) => s.startSec >= rawSec);
if (index < 0) index = visibleSegments.length - 1;
else clampToSegmentStart = true;
}
const seg = visibleSegments[index];
const segSourceEnd = seg.sourceEndSec ?? seg.sourceStartSec;
const unclamped = clampToSegmentStart
? seg.sourceStartSec
: seg.sourceStartSec + (rawSec - spans[index].startSec);
const maxSource = Math.max(seg.sourceStartSec, segSourceEnd - NATIVE_EOF_MARGIN_SEC);
return {
clip: seg,
clipIndex: index,
sourceTimeSec: Math.max(seg.sourceStartSec, Math.min(maxSource, unclamped)),
};
}