From c4d4a3c8df708339af3e4a252a63fa463647d35f Mon Sep 17 00:00:00 2001 From: Aiden Guan <108591903+aiden-guan@users.noreply.github.com> Date: Wed, 14 Oct 2026 16:10:01 -0700 Subject: [PATCH] feat: add stacked timeline tracks for annotations --- .../NewEditorShell.timelineHeight.test.tsx | 38 +++++- src/components/ai-edition/NewEditorShell.tsx | 20 ++- .../v4/V4Timeline.geometry.test.tsx | 85 ++++++++++++ src/components/ai-edition/v4/V4Timeline.tsx | 128 +++++++++++++++--- src/i18n/locales/ar/timeline.json | 4 + src/i18n/locales/cs/timeline.json | 4 + src/i18n/locales/de/timeline.json | 4 + src/i18n/locales/en/timeline.json | 4 + src/i18n/locales/es/timeline.json | 4 + src/i18n/locales/fr/timeline.json | 4 + src/i18n/locales/it/timeline.json | 4 + src/i18n/locales/ja-JP/timeline.json | 4 + src/i18n/locales/ko-KR/timeline.json | 4 + src/i18n/locales/pt-BR/timeline.json | 4 + src/i18n/locales/ru/timeline.json | 4 + src/i18n/locales/tr/timeline.json | 4 + src/i18n/locales/vi/timeline.json | 4 + src/i18n/locales/zh-CN/timeline.json | 4 + src/i18n/locales/zh-TW/timeline.json | 4 + src/lib/ai-edition/store/useTimeline.test.ts | 120 ++++++++++++++++ src/lib/ai-edition/store/useTimeline.ts | 33 ++++- .../ai-edition/timeline/regionTracks.test.ts | 53 ++++++++ src/lib/ai-edition/timeline/regionTracks.ts | 54 ++++++++ .../ai-edition/timeline/timelineMap.test.ts | 45 ++++++ src/lib/ai-edition/timeline/timelineMap.ts | 61 +++++++-- .../architecture/timeline-model.md | 39 ++++-- .../assets/annotation-tracks.png | Bin 0 -> 11408 bytes tests/e2e/v4-shell.spec.ts | 70 +++++++++- 28 files changed, 764 insertions(+), 42 deletions(-) create mode 100644 src/lib/ai-edition/timeline/regionTracks.test.ts create mode 100644 src/lib/ai-edition/timeline/regionTracks.ts create mode 100644 technical-documentation/assets/annotation-tracks.png diff --git a/src/components/ai-edition/NewEditorShell.timelineHeight.test.tsx b/src/components/ai-edition/NewEditorShell.timelineHeight.test.tsx index 9ecf446fc..fafa2a192 100644 --- a/src/components/ai-edition/NewEditorShell.timelineHeight.test.tsx +++ b/src/components/ai-edition/NewEditorShell.timelineHeight.test.tsx @@ -29,7 +29,11 @@ vi.mock("@/contexts/I18nContext", () => ({ import { TooltipProvider } from "@/components/ui/tooltip"; import { EditorDialogsProvider } from "@/contexts/EditorDialogsContext"; import { AUDIO_ROW_EXPANSION_PX } from "@/lib/ai-edition/document/audioTracks"; -import { createAudioTrack, createEmptyDocument } from "@/lib/ai-edition/schema"; +import { + annotationRegionSchema, + createAudioTrack, + createEmptyDocument, +} from "@/lib/ai-edition/schema"; import { useProjectStore } from "@/lib/ai-edition/store/projectStore"; import { DEFAULT_TIMELINE_HEIGHT_PX, @@ -235,4 +239,36 @@ describe("NewEditorShell timeline height", () => { expect(root.style.gridTemplateRows).toBe(`58px 1fr ${DEFAULT_TIMELINE_HEIGHT_PX}px`); }); + + it("expands for annotation layers, caps the panel height and restores the saved base height", () => { + useProjectStore.getState().clear(); + const { container } = renderShell(); + const root = container.firstElementChild as HTMLElement; + const doc = createEmptyDocument({ projectId: "p", title: "t" }); + doc.annotations = Array.from({ length: 8 }, (_, i) => + annotationRegionSchema.parse({ + id: `ann-${i}`, + startMs: 0, + endMs: 5000, + type: "text", + content: "Hello", + zIndex: i, + position: { x: 20, y: 20 }, + size: { width: 30, height: 20 }, + style: { color: "#ffffff", fontSize: 32, fontFamily: "Inter" }, + }), + ); + act(() => { + useProjectStore.setState({ document: { ...doc, annotations: doc.annotations.slice(0, 2) } }); + }); + expect(root.style.gridTemplateRows).toBe(`58px 1fr ${DEFAULT_TIMELINE_HEIGHT_PX + 27}px`); + act(() => { + useProjectStore.setState({ document: doc }); + }); + expect(root.style.gridTemplateRows).toBe(`58px 1fr ${MAX_TIMELINE_HEIGHT_PX}px`); + act(() => { + useProjectStore.setState({ document: { ...doc, annotations: [] } }); + }); + expect(root.style.gridTemplateRows).toBe(`58px 1fr ${DEFAULT_TIMELINE_HEIGHT_PX}px`); + }); }); diff --git a/src/components/ai-edition/NewEditorShell.tsx b/src/components/ai-edition/NewEditorShell.tsx index 08b52f21e..87cd4e951 100644 --- a/src/components/ai-edition/NewEditorShell.tsx +++ b/src/components/ai-edition/NewEditorShell.tsx @@ -37,6 +37,8 @@ import { useTimeline } from "@/lib/ai-edition/store/useTimeline"; import { isGeneratedAssetId } from "@/lib/ai-edition/timeline/clip-parts"; import { mergeCloseCuts } from "@/lib/ai-edition/timeline/cut-breath"; import { newRegionDurationSec } from "@/lib/ai-edition/timeline/newRegionDuration"; +import { packRegionRows, REGION_ROW_STEP_PX } from "@/lib/ai-edition/timeline/regionTracks"; +import { coalesceRegionsForRuler } from "@/lib/ai-edition/timeline/timelineMap"; import { dropTrimPillsByIds, trimAppliesToClip, @@ -244,9 +246,25 @@ export function NewEditorShell() { [document?.audioTracks], ); const extraAudioHeightPx = Math.max(0, audioRowCount - 1) * AUDIO_ROW_EXPANSION_PX; + const annotationRowCount = useMemo( + () => + packRegionRows( + coalesceRegionsForRuler(document?.annotations ?? []).map((p) => ({ + id: p.ids[0], + start: p.start, + end: p.end, + layer: p.member.zIndex, + })), + ).rowCount, + [document?.annotations], + ); + const extraAnnotationHeightPx = (annotationRowCount - 1) * REGION_ROW_STEP_PX; const timelineHeightPx = Math.min( MAX_TIMELINE_HEIGHT_PX, - Math.max(MIN_TIMELINE_HEIGHT_PX, timelineBaseHeightPx + extraAudioHeightPx), + Math.max( + MIN_TIMELINE_HEIGHT_PX, + timelineBaseHeightPx + extraAudioHeightPx + extraAnnotationHeightPx, + ), ); const [inspectorOpen, setInspectorOpen] = useState(true); const [facet, setFacet] = useState("effects"); diff --git a/src/components/ai-edition/v4/V4Timeline.geometry.test.tsx b/src/components/ai-edition/v4/V4Timeline.geometry.test.tsx index 3eba71197..95de2528b 100644 --- a/src/components/ai-edition/v4/V4Timeline.geometry.test.tsx +++ b/src/components/ai-edition/v4/V4Timeline.geometry.test.tsx @@ -203,6 +203,91 @@ function zoomIn(notches: number) { wheelZoomOn(document.querySelector("[class*=tlTracks]") as HTMLElement, notches); } +describe("timeline tracks", () => { + afterEach(cleanup); + + it("shows identical nested annotations on separate rows and selects each independently", () => { + const { tl } = renderTimeline(undefined, undefined, undefined, undefined, { + annotationRegions: [ + { id: "main", startMs: 0, endMs: 67000, type: "text", content: "Same", zIndex: 1 }, + { id: "second", startMs: 35000, endMs: 40000, type: "text", content: "Same", zIndex: 1 }, + ], + }); + const pills = [...document.querySelectorAll("[data-region-id]")]; + expect(pills).toHaveLength(2); + expect(new Set(pills.map((p) => p.style.top)).size).toBe(2); + for (const pill of pills) { + fireEvent.keyDown(pill, { key: "Enter" }); + expect(tl.selectRegion).toHaveBeenLastCalledWith("annotation", pill.dataset.regionId, { + additive: false, + }); + } + }); + + it("creates a separate live row when a horizontal drag moves into an existing annotation", () => { + const { tl } = renderTimeline(undefined, undefined, undefined, undefined, { + annotationRegions: [ + { id: "main", startMs: 0, endMs: 100000, zIndex: 1 }, + { id: "second", startMs: 200000, endMs: 300000, zIndex: 1 }, + ], + }); + const second = document.querySelector('[data-region-id="second"]') as HTMLElement; + const main = document.querySelector('[data-region-id="main"]') as HTMLElement; + fireEvent.pointerDown(second, { clientX: 100, clientY: 10 }); + act(() => { + window.dispatchEvent(new MouseEvent("pointermove", { clientX: 10, clientY: 10 })); + }); + expect(second.style.top).not.toBe(main.style.top); + expect( + document.querySelector('[data-track-kind="annotation"]')?.style.height, + ).toBe("51px"); + act(() => { + window.dispatchEvent(new MouseEvent("pointerup")); + }); + expect(tl.updateAnnotationSpan).toHaveBeenCalledWith("second", 20000, 120000); + }); + + it("moves annotations vertically between existing layers and creates layers above and below", () => { + const { tl } = renderTimeline(undefined, undefined, undefined, undefined, { + annotationRegions: [ + { id: "low", startMs: 0, endMs: 100000, zIndex: 0 }, + { id: "high", startMs: 0, endMs: 100000, zIndex: 4 }, + ], + }); + const low = document.querySelector('[data-region-id="low"]') as HTMLElement; + expect(low.dataset.trackRow).toBe("1"); + for (const [dy, layer] of [ + [-27, 4], + [-54, 5], + [27, -1], + ]) { + fireEvent.pointerDown(low, { clientX: 10, clientY: 100 }); + act(() => { + window.dispatchEvent(new MouseEvent("pointermove", { clientX: 10, clientY: 100 + dy })); + }); + act(() => { + window.dispatchEvent(new MouseEvent("pointerup")); + }); + expect(tl.updateAnnotationSpan).toHaveBeenLastCalledWith("low", 0, 100000, layer); + } + }); + + it("stacks overlapping speed regions without changing their values", () => { + renderTimeline(undefined, undefined, undefined, undefined, { + annotationRegions: [], + speedRegions: [ + { id: "a", startMs: 0, endMs: 67000, speed: 2 }, + { id: "b", startMs: 35000, endMs: 40000, speed: 3 }, + ], + }); + const pills = [ + ...document.querySelectorAll('[data-track-kind="speed"] [data-region-id]'), + ]; + expect(pills).toHaveLength(2); + expect(pills[0].style.top).not.toBe(pills[1].style.top); + }); +}); + describe("V4Timeline scrubbing", () => { it("publishes at most one React scrub-state update per animation frame", () => { const frames = new Map(); diff --git a/src/components/ai-edition/v4/V4Timeline.tsx b/src/components/ai-edition/v4/V4Timeline.tsx index 8ca815883..d42ddbbb6 100644 --- a/src/components/ai-edition/v4/V4Timeline.tsx +++ b/src/components/ai-edition/v4/V4Timeline.tsx @@ -61,6 +61,12 @@ import { setTimelineScale, } from "@/lib/ai-edition/timeline/newRegionDuration"; import { ventilateSpanAcrossClips } from "@/lib/ai-edition/timeline/region-ventilation"; +import { + packRegionRows, + REGION_LANE_PAD_PX, + REGION_ROW_STEP_PX, + regionLaneHeight, +} from "@/lib/ai-edition/timeline/regionTracks"; import { coalesceRegionsForRuler } from "@/lib/ai-edition/timeline/timelineMap"; import { coalescedTrimGroups, @@ -602,6 +608,8 @@ interface LanePill { label: string; /** Underlying row ids this pill represents — >1 for a coalesced trim group. */ sourceIds: string[]; + layer?: number; + stackOrder?: number; } export function V4Timeline({ @@ -738,15 +746,19 @@ export function V4Timeline({ // touching-but-different regions must not silently merge into one. // Pills follow the universal merge rule (timelineMap): regions of the same kind whose // PROPERTIES are equal and whose spans touch render as ONE pill, however they came to be - // adjacent. Different properties never merge (and cannot overlap — they repel on edit). + // adjacent. Annotations can overlap on separate tracks; other modifiers repel on edit. // Trims obey the same rule with an empty property set, so they always merge. const annPills: LanePill[] = coalesceRegionsForRuler(tl.annotationRegions).map((p) => ({ id: p.ids[0], kind: "annotation", start: p.start, end: p.end, - label: t("toolbar.newAnnotation"), + label: + (p.member.type === "text" ? p.member.content || p.member.textContent : "")?.trim() || + t("toolbar.newAnnotation"), sourceIds: p.ids, + layer: p.member.zIndex, + stackOrder: Math.max(...p.ids.map((id) => tl.annotationRegions.findIndex((a) => a.id === id))), })); const speedRuns = coalesceRegionsForRuler(tl.speedRegions); const speedPills: LanePill[] = speedRuns.map((p) => ({ @@ -920,12 +932,16 @@ export function V4Timeline({ kind: LanePill["kind"]; start: number; end: number; + layer?: number; + previewLayer?: number; } | null>(null); const activePillDragRef = useRef<{ id: string; kind: LanePill["kind"]; start: number; end: number; + layer?: number; + previewLayer?: number; } | null>(null); // A merged pill can be several regions that merely match (#1017), and an edit must reach @@ -969,6 +985,9 @@ export function V4Timeline({ // A move rebuilds the pill as one region that keeps this id, so the selection survives. const grabbed = selectPill(pill, e.shiftKey, ((e.clientX - r.left) / r.width) * total); const startX = e.clientX; + const startY = e.clientY; + const annotationRows = packRegionRows(annPills, pxPerSec); + const originalRow = annotationRows.rowOf.get(pill.id) ?? 0; const dur = pill.end - pill.start; // A trim can span several clips; it's stored as one source-time entry per // covered clip. `trimOwned` are the entry ids this drag controls — seeded @@ -1006,14 +1025,15 @@ export function V4Timeline({ setSnapPct(best === v ? null : (best / total) * 100); return best; }; - const apply = async (start: number, end: number): Promise => { + const apply = async (start: number, end: number, layer?: number): Promise => { const s = Math.max(0, Math.min(end - MIN_REGION_SEC, start)); const en = Math.min(total, Math.max(s + MIN_REGION_SEC, end)); if (pill.kind === "zoom") await tl.updateZoomSpan(grabbed, s * 1000, en * 1000); else if (pill.kind === "speed") await tl.updateSpeedSpan(grabbed, s * 1000, en * 1000); - else if (pill.kind === "annotation") - await tl.updateAnnotationSpan(grabbed, s * 1000, en * 1000); - else if (pill.kind === "cameraFullscreen") + else if (pill.kind === "annotation") { + if (layer === undefined) await tl.updateAnnotationSpan(grabbed, s * 1000, en * 1000); + else await tl.updateAnnotationSpan(grabbed, s * 1000, en * 1000, layer); + } else if (pill.kind === "cameraFullscreen") await tl.updateCameraFullscreenSpan(grabbed, s * 1000, en * 1000); else { // Trims are stored in source-time per asset but manipulated on the @@ -1050,7 +1070,34 @@ export function V4Timeline({ ns = pill.start; ne = Math.min(total, Math.max(pill.start + MIN_REGION_SEC, snap(pill.end + dxSec))); } - const nextState = { id: pill.id, kind: pill.kind, start: ns, end: ne }; + let layer: number | undefined; + if (pill.kind === "annotation" && dragMode === "move") { + const rowDelta = Math.round((ev.clientY - startY) / REGION_ROW_STEP_PX); + if (rowDelta !== 0) { + const targetRow = originalRow + rowDelta; + const layers = annotationRows.layers.map((l) => l ?? 0); + layer = + targetRow < 0 + ? Math.max(...layers) + 1 + : targetRow >= layers.length + ? Math.min(...layers) - 1 + : layers[targetRow]; + } + } + // A filled destination gets a new track beside it on drop. Preview the + // same order without changing the neighbours' saved layer numbers yet. + const requestedLayer = layer ?? pill.layer; + const occupied = + pill.kind === "annotation" && + requestedLayer !== undefined && + annPills.some( + (p) => p.id !== pill.id && p.layer === requestedLayer && p.start < ne && p.end > ns, + ); + const previewLayer = + occupied && requestedLayer !== undefined + ? requestedLayer + (requestedLayer >= (pill.layer ?? 0) ? 0.5 : -0.5) + : layer; + const nextState = { id: pill.id, kind: pill.kind, start: ns, end: ne, layer, previewLayer }; activePillDragRef.current = nextState; setActivePillDrag(nextState); }; @@ -1060,7 +1107,7 @@ export function V4Timeline({ window.removeEventListener("pointerup", up); const finalDrag = activePillDragRef.current; if (finalDrag) { - void apply(finalDrag.start, finalDrag.end).finally(() => { + void apply(finalDrag.start, finalDrag.end, finalDrag.layer).finally(() => { if (activePillDragRef.current === finalDrag) { activePillDragRef.current = null; setActivePillDrag(null); @@ -1074,7 +1121,7 @@ export function V4Timeline({ window.addEventListener("pointermove", move); window.addEventListener("pointerup", up); }, - [tl, selectPill, total, clips, pxPerSec], + [tl, selectPill, total, clips, pxPerSec, annPills], ); // Live preview geometry for an audio track being dragged (issue #350), the @@ -1668,6 +1715,7 @@ export function V4Timeline({ // edge de-styled so a split pill still reads as one continuous shape. const renderOnePill = (seg: { pill: LanePill; + row: number; key: string; segStart: number; segEnd: number; @@ -1687,11 +1735,19 @@ export function V4Timeline({
(id: string, p: T): T => + const liveSpan = ( + id: string, + p: T, + ): T => activePillDrag && activePillDrag.id === id - ? { ...p, start: activePillDrag.start, end: activePillDrag.end } + ? { + ...p, + start: activePillDrag.start, + end: activePillDrag.end, + ...(activePillDrag.previewLayer === undefined + ? {} + : { layer: activePillDrag.previewLayer }), + } : p; const renderPills = (pills: LanePill[], emptyLabel: string) => { const effectivePills = pills.map((p) => liveSpan(p.id, p)); + const rows = packRegionRows(effectivePills, pxPerSec); return ( <> {effectivePills.length === 0 ? ( @@ -1805,6 +1872,7 @@ export function V4Timeline({ return [ renderOnePill({ pill: p, + row: rows.rowOf.get(p.id) ?? 0, key: `${p.id}__f${i}`, segStart: c.timelineStartSec + f.localStartSec, segEnd: c.timelineStartSec + f.localEndSec, @@ -1824,6 +1892,7 @@ export function V4Timeline({ return [ renderOnePill({ pill: p, + row: rows.rowOf.get(p.id) ?? 0, key: p.id, segStart: p.start, segEnd: p.end, @@ -1840,6 +1909,15 @@ export function V4Timeline({ ); }; + const laneStyle = (pills: LanePill[]) => ({ + height: regionLaneHeight( + packRegionRows( + pills.map((p) => liveSpan(p.id, p)), + pxPerSec, + ).rowCount, + ), + }); + // Each zoom's camera moves (#1028): the pill shows the hold, the zoom-in runs in the trail // before it and the zoom-out in the trail after it, on the speed-aware clock the compositor // uses. The part a trim or a clip junction cuts is hatched: the export jumps there instead of @@ -1848,6 +1926,10 @@ export function V4Timeline({ // not a control. Hidden while a clip is dragged, when the pills split and slide. const renderZoomTrails = () => { if (clipDrag) return null; + const rows = packRegionRows( + zoomPills.map((p) => liveSpan(p.id, p)), + pxPerSec, + ); const ms = (span: { start: number; end: number }) => ({ startMs: span.start * 1000, endMs: span.end * 1000, @@ -1923,6 +2005,7 @@ export function V4Timeline({ data-zoom-trail={trail.side} className={`${styles.zoomTrail} ${styles.laneZoom}`} style={{ + top: 5 + (rows.rowOf.get(r.ids[0]) ?? 0) * REGION_ROW_STEP_PX, left: `${pctAt(trail.fromMs / 1000)}%`, width: `${pctOf(lengthMs / 1000)}%`, }} @@ -2266,7 +2349,12 @@ export function V4Timeline({ strings the pre-v4 timeline used, so the keys stay translated. The key is the live binding, formatted like the toolbar tooltip's chip, so a rebind in the shortcuts dialog moves the hint with it (#966). */} -
+
{renderPills( annPills, t("hints.pressAnnotation", { @@ -2274,19 +2362,23 @@ export function V4Timeline({ }), )}
-
+
{renderPills( speedPills, t("hints.pressSpeed", { key: formatBinding(shortcuts.addSpeed, isMac) }), )}
-
+
{renderPills( trimPills, t("hints.pressTrim", { key: formatBinding(shortcuts.addTrim, isMac) }), )}
-
+
{/* Its own provider, like the toolbar's: see the note there. */} {renderZoomTrails()} {renderPills( @@ -2294,7 +2386,11 @@ export function V4Timeline({ t("hints.pressZoom", { key: formatBinding(shortcuts.addZoom, isMac) }), )}
-
+
{/* Advertising "Press C" on a project with no webcam invites a keystroke that `addCameraFullscreen` now refuses (#353). The toolbar button is already disabled; this keeps the lane from contradicting it. */} diff --git a/src/i18n/locales/ar/timeline.json b/src/i18n/locales/ar/timeline.json index 21e5d5fae..c802060db 100644 --- a/src/i18n/locales/ar/timeline.json +++ b/src/i18n/locales/ar/timeline.json @@ -112,5 +112,9 @@ "recordingUnavailable": "التسجيل غير متاح هنا", "saveFailed": "تعذر حفظ التسجيل", "importFailed": "تعذر استيراد الملف الصوتي" + }, + "tracks": { + "annotation": "طبقة التعليق {{index}}", + "dragHint": "اسحب يمينًا أو يسارًا لتغيير الوقت. اسحب لأعلى أو لأسفل لتغيير الطبقة؛ تظهر الطبقات الأعلى في المقدمة." } } diff --git a/src/i18n/locales/cs/timeline.json b/src/i18n/locales/cs/timeline.json index 9e93b4eb7..8fe84616b 100644 --- a/src/i18n/locales/cs/timeline.json +++ b/src/i18n/locales/cs/timeline.json @@ -112,5 +112,9 @@ "recordingUnavailable": "Nahrávání zde není dostupné", "saveFailed": "Nepodařilo se uložit nahrávku", "importFailed": "Nepodařilo se importovat zvukový soubor" + }, + "tracks": { + "annotation": "Vrstva anotace {{index}}", + "dragHint": "Tažením doleva nebo doprava posunete čas. Tažením nahoru nebo dolů změníte vrstvu; vyšší vrstvy jsou v popředí." } } diff --git a/src/i18n/locales/de/timeline.json b/src/i18n/locales/de/timeline.json index fa18db607..9707bbfcc 100644 --- a/src/i18n/locales/de/timeline.json +++ b/src/i18n/locales/de/timeline.json @@ -112,5 +112,9 @@ "recordingUnavailable": "Aufnahme ist hier nicht verfügbar", "saveFailed": "Die Aufnahme konnte nicht gespeichert werden", "importFailed": "Die Audiodatei konnte nicht importiert werden" + }, + "tracks": { + "annotation": "Anmerkungsebene {{index}}", + "dragHint": "Nach links oder rechts ziehen, um die Zeit zu ändern. Nach oben oder unten ziehen, um die Ebene zu ändern; höhere Ebenen liegen vorne." } } diff --git a/src/i18n/locales/en/timeline.json b/src/i18n/locales/en/timeline.json index ec4b8d28f..febfb7bc3 100644 --- a/src/i18n/locales/en/timeline.json +++ b/src/i18n/locales/en/timeline.json @@ -112,5 +112,9 @@ "recordingUnavailable": "Recording is not available here", "saveFailed": "Could not save the recording", "importFailed": "Could not import the audio file" + }, + "tracks": { + "annotation": "Annotation layer {{index}}", + "dragHint": "Drag left or right to move in time. Drag up or down to change layer; higher layers appear on top." } } diff --git a/src/i18n/locales/es/timeline.json b/src/i18n/locales/es/timeline.json index f4fcd4374..70df7142d 100644 --- a/src/i18n/locales/es/timeline.json +++ b/src/i18n/locales/es/timeline.json @@ -112,5 +112,9 @@ "recordingUnavailable": "La grabación no está disponible aquí", "saveFailed": "No se pudo guardar la grabación", "importFailed": "No se pudo importar el archivo de audio" + }, + "tracks": { + "annotation": "Capa de anotación {{index}}", + "dragHint": "Arrastra a izquierda o derecha para cambiar el tiempo. Arrastra arriba o abajo para cambiar de capa; las capas superiores aparecen delante." } } diff --git a/src/i18n/locales/fr/timeline.json b/src/i18n/locales/fr/timeline.json index 4fd7b3123..ed36c7d2f 100644 --- a/src/i18n/locales/fr/timeline.json +++ b/src/i18n/locales/fr/timeline.json @@ -112,5 +112,9 @@ "recordingUnavailable": "L'enregistrement n'est pas disponible ici", "saveFailed": "Impossible d'enregistrer la capture", "importFailed": "Impossible d'importer le fichier audio" + }, + "tracks": { + "annotation": "Calque d’annotation {{index}}", + "dragHint": "Glissez à gauche ou à droite pour déplacer dans le temps. Glissez vers le haut ou le bas pour changer de calque ; les calques supérieurs apparaissent devant." } } diff --git a/src/i18n/locales/it/timeline.json b/src/i18n/locales/it/timeline.json index e73a04db9..c712b6880 100644 --- a/src/i18n/locales/it/timeline.json +++ b/src/i18n/locales/it/timeline.json @@ -112,5 +112,9 @@ "recordingUnavailable": "La registrazione non è disponibile qui", "saveFailed": "Impossibile salvare la registrazione", "importFailed": "Impossibile importare il file audio" + }, + "tracks": { + "annotation": "Livello annotazione {{index}}", + "dragHint": "Trascina a sinistra o destra per spostare nel tempo. Trascina in alto o in basso per cambiare livello; i livelli superiori appaiono davanti." } } diff --git a/src/i18n/locales/ja-JP/timeline.json b/src/i18n/locales/ja-JP/timeline.json index 0f41b8fc9..3b6d983f7 100644 --- a/src/i18n/locales/ja-JP/timeline.json +++ b/src/i18n/locales/ja-JP/timeline.json @@ -112,5 +112,9 @@ "recordingUnavailable": "ここでは録音できません", "saveFailed": "録音を保存できませんでした", "importFailed": "音声ファイルを読み込めませんでした" + }, + "tracks": { + "annotation": "注釈レイヤー {{index}}", + "dragHint": "左右にドラッグして時間を移動します。上下にドラッグしてレイヤーを変更します。上のレイヤーが手前に表示されます。" } } diff --git a/src/i18n/locales/ko-KR/timeline.json b/src/i18n/locales/ko-KR/timeline.json index 64670c712..b1724c757 100644 --- a/src/i18n/locales/ko-KR/timeline.json +++ b/src/i18n/locales/ko-KR/timeline.json @@ -112,5 +112,9 @@ "recordingUnavailable": "여기에서는 녹음할 수 없습니다", "saveFailed": "녹음을 저장하지 못했습니다", "importFailed": "오디오 파일을 가져오지 못했습니다" + }, + "tracks": { + "annotation": "주석 레이어 {{index}}", + "dragHint": "좌우로 드래그하여 시간을 이동합니다. 위아래로 드래그하여 레이어를 변경합니다. 위쪽 레이어가 앞에 표시됩니다." } } diff --git a/src/i18n/locales/pt-BR/timeline.json b/src/i18n/locales/pt-BR/timeline.json index fee6e6933..7680fb05c 100644 --- a/src/i18n/locales/pt-BR/timeline.json +++ b/src/i18n/locales/pt-BR/timeline.json @@ -112,5 +112,9 @@ "recordingUnavailable": "A gravação não está disponível aqui", "saveFailed": "Não foi possível salvar a gravação", "importFailed": "Não foi possível importar o arquivo de áudio" + }, + "tracks": { + "annotation": "Camada de anotação {{index}}", + "dragHint": "Arraste para a esquerda ou direita para mover no tempo. Arraste para cima ou baixo para mudar de camada; as camadas superiores aparecem na frente." } } diff --git a/src/i18n/locales/ru/timeline.json b/src/i18n/locales/ru/timeline.json index f7ad39905..18e2850d9 100644 --- a/src/i18n/locales/ru/timeline.json +++ b/src/i18n/locales/ru/timeline.json @@ -112,5 +112,9 @@ "recordingUnavailable": "Запись здесь недоступна", "saveFailed": "Не удалось сохранить запись", "importFailed": "Не удалось импортировать аудиофайл" + }, + "tracks": { + "annotation": "Слой аннотации {{index}}", + "dragHint": "Перетаскивайте влево или вправо для перемещения по времени. Перетаскивайте вверх или вниз для смены слоя; верхние слои отображаются впереди." } } diff --git a/src/i18n/locales/tr/timeline.json b/src/i18n/locales/tr/timeline.json index d0dae7ceb..977041ea8 100644 --- a/src/i18n/locales/tr/timeline.json +++ b/src/i18n/locales/tr/timeline.json @@ -112,5 +112,9 @@ "recordingUnavailable": "Burada kayıt kullanılamıyor", "saveFailed": "Kayıt kaydedilemedi", "importFailed": "Ses dosyası içe aktarılamadı" + }, + "tracks": { + "annotation": "Açıklama katmanı {{index}}", + "dragHint": "Zamanı değiştirmek için sola veya sağa sürükleyin. Katmanı değiştirmek için yukarı veya aşağı sürükleyin; üst katmanlar önde görünür." } } diff --git a/src/i18n/locales/vi/timeline.json b/src/i18n/locales/vi/timeline.json index 9baf8a115..da6c13099 100644 --- a/src/i18n/locales/vi/timeline.json +++ b/src/i18n/locales/vi/timeline.json @@ -112,5 +112,9 @@ "recordingUnavailable": "Không thể ghi âm ở đây", "saveFailed": "Không thể lưu bản ghi", "importFailed": "Không thể nhập tệp âm thanh" + }, + "tracks": { + "annotation": "Lớp chú thích {{index}}", + "dragHint": "Kéo sang trái hoặc phải để di chuyển theo thời gian. Kéo lên hoặc xuống để đổi lớp; lớp cao hơn hiển thị phía trước." } } diff --git a/src/i18n/locales/zh-CN/timeline.json b/src/i18n/locales/zh-CN/timeline.json index 149a56a61..d15a420b7 100644 --- a/src/i18n/locales/zh-CN/timeline.json +++ b/src/i18n/locales/zh-CN/timeline.json @@ -112,5 +112,9 @@ "recordingUnavailable": "此处无法录音", "saveFailed": "无法保存录音", "importFailed": "无法导入音频文件" + }, + "tracks": { + "annotation": "注释图层 {{index}}", + "dragHint": "左右拖动以调整时间。上下拖动以切换图层;上方图层显示在前面。" } } diff --git a/src/i18n/locales/zh-TW/timeline.json b/src/i18n/locales/zh-TW/timeline.json index ed255420b..ec99b7582 100644 --- a/src/i18n/locales/zh-TW/timeline.json +++ b/src/i18n/locales/zh-TW/timeline.json @@ -112,5 +112,9 @@ "recordingUnavailable": "此處無法錄音", "saveFailed": "無法儲存錄音", "importFailed": "無法匯入音訊檔案" + }, + "tracks": { + "annotation": "註解圖層 {{index}}", + "dragHint": "左右拖曳以調整時間。上下拖曳以切換圖層;上方圖層顯示在前面。" } } diff --git a/src/lib/ai-edition/store/useTimeline.test.ts b/src/lib/ai-edition/store/useTimeline.test.ts index 917188529..c8ea28749 100644 --- a/src/lib/ai-edition/store/useTimeline.test.ts +++ b/src/lib/ai-edition/store/useTimeline.test.ts @@ -734,6 +734,126 @@ describe("useTimeline.addAnnotation", () => { expect([position.x + size.width / 2, position.y + size.height / 2]).toEqual([50, 50]); expect(annotation.style.backgroundColor).toBe(DEFAULT_TEXT_PLATE); }); + + it("adds above the highest saved layer even after other annotations were deleted", async () => { + const { result } = renderTimeline(); + await act(async () => { + await result.current.addAnnotation(); + }); + const doc = useProjectStore.getState().document as AxcutDocument; + act(() => { + useProjectStore.setState({ + document: { + ...doc, + annotations: [{ ...doc.annotations[0], zIndex: 12 }], + }, + }); + }); + await act(async () => { + await result.current.addAnnotation(); + }); + expect(useProjectStore.getState().document?.annotations.map((a) => a.zIndex)).toEqual([12, 13]); + }); + + it("inserts a layer when dropped onto an occupied track without changing its neighbour", async () => { + const { result } = renderTimeline(); + await act(async () => { + await result.current.addAnnotation(); + }); + await act(async () => { + await result.current.addAnnotation(); + }); + const before = useProjectStore.getState().document as AxcutDocument; + const [low, high] = before.annotations; + await act(async () => { + await result.current.updateAnnotationSpan(low.id, 1500, 3500, high.zIndex); + }); + const after = useProjectStore.getState().document as AxcutDocument; + expect(after.annotations.find((a) => a.id === high.id)).toEqual(high); + expect(after.annotations.find((a) => a.id === low.id)?.zIndex).toBe(high.zIndex + 1); + }); + + it("automatically persists a new layer when moving into an identical annotation on the same layer", async () => { + const { result } = renderTimeline(); + await act(async () => { + await result.current.addAnnotation(); + }); + const doc = useProjectStore.getState().document as AxcutDocument; + const original = doc.annotations[0]; + act(() => { + useProjectStore.setState({ + document: { + ...doc, + annotations: [ + original, + { + ...original, + id: "second", + startMs: 5000, + endMs: 7000, + sourceStartSec: 5, + sourceEndSec: 7, + }, + ], + }, + }); + }); + await act(async () => { + await result.current.updateAnnotationSpan("second", 2000, 4000); + }); + const after = useProjectStore.getState().document as AxcutDocument; + expect(after.annotations.find((a) => a.id === original.id)).toEqual(original); + expect(after.annotations.find((a) => a.id === "second")).toMatchObject({ + zIndex: original.zIndex + 1, + startMs: 2000, + endMs: 4000, + }); + }); + + it("saves an overlapping span and bottom-layer move together and supports undo, redo and reload", async () => { + const { result } = renderTimeline(); + await act(async () => { + await result.current.addAnnotation(); + }); + await act(async () => { + await result.current.addAnnotation(); + }); + const doc = useProjectStore.getState().document as AxcutDocument; + act(() => { + useProjectStore.setState({ + document: { + ...doc, + annotations: doc.annotations.map((a, i) => ({ ...a, zIndex: i })), + }, + }); + }); + const before = useProjectStore.getState().document as AxcutDocument; + clearHistory(); + bridgeMocks.save.mockClear(); + const id = before.annotations[1].id; + await act(async () => { + await result.current.updateAnnotationSpan(id, 2000, 4000, -1); + }); + const after = useProjectStore.getState().document as AxcutDocument; + expect(bridgeMocks.save).toHaveBeenCalledTimes(1); + expect(after.annotations.find((a) => a.id === id)).toMatchObject({ + startMs: 2000, + endMs: 4000, + zIndex: 0, + }); + expect(after.annotations.find((a) => a.id !== id)?.zIndex).toBe(1); + expect(parseDocumentFile(JSON.parse(JSON.stringify(after))).annotations).toEqual( + after.annotations, + ); + await act(async () => { + await undo(); + }); + expect(useProjectStore.getState().document?.annotations).toEqual(before.annotations); + await act(async () => { + await redo(); + }); + expect(useProjectStore.getState().document?.annotations).toEqual(after.annotations); + }); }); // The Clear timeline button. Every edit region on every clip, one write; the content stays. diff --git a/src/lib/ai-edition/store/useTimeline.ts b/src/lib/ai-edition/store/useTimeline.ts index 7c173f870..027497ce4 100644 --- a/src/lib/ai-edition/store/useTimeline.ts +++ b/src/lib/ai-edition/store/useTimeline.ts @@ -43,6 +43,7 @@ import { hasAnyClipWithCamera } from "../timeline/camera"; import { probeAudioDuration, probeVideoDimensions, probeVideoDuration } from "../timeline/duration"; import { anchorRegionsWithDerivedMs, + coalesceRegionsForRuler, dropPillsByIds, replacePillSpan, resolveRegionIds, @@ -470,7 +471,7 @@ export function useTimeline() { textAlign: "center", textAnimation: "none", }, - zIndex: document.annotations.length + 1, + zIndex: Math.max(0, ...document.annotations.map((a) => a.zIndex)) + 1, }; const frameAspect = resolveAspectRatioValue( document, @@ -919,19 +920,45 @@ export function useTimeline() { ); const updateAnnotationSpan = useCallback( - async (id: string, startMs: number, endMs: number) => { + async (id: string, startMs: number, endMs: number, layer?: number) => { if (!document) return; const s = finiteMs(startMs); const e = finiteMs(endMs); + // -1 is a drop below layer zero: make room beneath the existing layers. + // Span and layer are saved together, so a vertical move is one undo step. + let annotations = document.annotations; + let targetLayer = layer; + const pill = coalesceRegionsForRuler(annotations).find((p) => p.ids.includes(id)); + if (!pill) return; + const requestedLayer = layer ?? pill.member.zIndex; + const occupied = annotations.some( + (a) => + !pill.ids.includes(a.id) && + a.zIndex === requestedLayer && + a.startMs < Math.max(s, e) && + a.endMs > Math.min(s, e), + ); + if (layer === -1 || occupied) { + // Dropping onto a filled track inserts a layer beside it, preserving + // both objects and making the visual track order the compositing order. + targetLayer = + layer === -1 ? 0 : requestedLayer + (requestedLayer >= pill.member.zIndex ? 1 : 0); + const insertAt = targetLayer; + annotations = annotations.map((a) => + a.zIndex >= insertAt ? { ...a, zIndex: a.zIndex + 1 } : a, + ); + } const next: AxcutDocument = { ...document, annotations: replacePillSpan( - document.annotations, + annotations, id, Math.min(s, e), Math.max(s, e), document.timeline.clips, () => createId("ann"), + 0.001, + targetLayer === undefined ? {} : { zIndex: Math.max(0, Math.round(targetLayer)) }, ), }; await saveDocument(next, { history: true }); diff --git a/src/lib/ai-edition/timeline/regionTracks.test.ts b/src/lib/ai-edition/timeline/regionTracks.test.ts new file mode 100644 index 000000000..b6df2d9af --- /dev/null +++ b/src/lib/ai-edition/timeline/regionTracks.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; +import { packRegionRows, regionLaneHeight } from "./regionTracks"; + +describe("region tracks", () => { + it("keeps nested and identical spans independently reachable", () => { + const tracks = packRegionRows([ + { id: "background", start: 0, end: 67 }, + { id: "second", start: 35, end: 40 }, + { id: "third", start: 35, end: 40 }, + ]); + expect(tracks.rowCount).toBe(3); + expect(new Set(tracks.rowOf.values()).size).toBe(3); + expect(regionLaneHeight(tracks.rowCount)).toBe(78); + }); + + it("reuses rows for nonoverlapping elements but preserves explicit layers", () => { + const tracks = packRegionRows([ + { id: "low", start: 0, end: 2, layer: 1 }, + { id: "high", start: 0, end: 2, layer: 10 }, + { id: "later", start: 2, end: 4, layer: 10 }, + ]); + expect(tracks.rowCount).toBe(2); + expect(tracks.rowOf.get("high")).toBe(0); + expect(tracks.rowOf.get("later")).toBe(0); + expect(tracks.rowOf.get("low")).toBe(1); + expect(tracks.layers).toEqual([10, 1]); + }); + + it("separates adjacent compact pills whose resize handles would overlap", () => { + const spans = [ + { id: "a", start: 0, end: 0.1 }, + { id: "b", start: 0.2, end: 0.3 }, + ]; + expect(packRegionRows(spans, 10).rowCount).toBe(2); + expect(packRegionRows(spans, 200).rowCount).toBe(1); + }); + + it("keeps one empty track for the add-element hint", () => { + expect(packRegionRows([]).rowCount).toBe(1); + expect(regionLaneHeight(1)).toBe(24); + }); + + it("matches compositing order for legacy annotations sharing a layer", () => { + const tracks = packRegionRows([ + { id: "back", start: 0, end: 67, layer: 1, stackOrder: 0 }, + { id: "front", start: 35, end: 40, layer: 1, stackOrder: 1 }, + { id: "later", start: 45, end: 50, layer: 1, stackOrder: 2 }, + ]); + expect(tracks.rowOf.get("front")).toBe(0); + expect(tracks.rowOf.get("back")).toBe(1); + expect(tracks.rowOf.get("later")).toBe(0); + }); +}); diff --git a/src/lib/ai-edition/timeline/regionTracks.ts b/src/lib/ai-edition/timeline/regionTracks.ts new file mode 100644 index 000000000..5f32c3571 --- /dev/null +++ b/src/lib/ai-edition/timeline/regionTracks.ts @@ -0,0 +1,54 @@ +// Track geometry shared by the timeline and its automatic height adjustment. +export const REGION_ROW_HEIGHT_PX = 22; +export const REGION_ROW_GAP_PX = 5; +export const REGION_LANE_PAD_PX = 1; +export const REGION_ROW_STEP_PX = REGION_ROW_HEIGHT_PX + REGION_ROW_GAP_PX; + +export interface TrackSpan { + id: string; + start: number; + end: number; + /** Higher annotation layers appear above lower ones, as in the compositor. */ + layer?: number; + /** Stable compositing order for annotations that share a saved zIndex. */ + stackOrder?: number; +} + +/** Pack overlaps into separate tracks. Distinct explicit layers never share a row. + * Include compact pills' outside resize handles at the current timeline scale, so + * even two very short, adjacent elements remain independently reachable. */ +export function packRegionRows(spans: readonly TrackSpan[], pxPerSec = 0) { + const rowOf = new Map(); + const rows: Array<{ layer: number | undefined; spans: Array<{ start: number; end: number }> }> = + []; + const sorted = [...spans].sort( + (a, b) => + (b.layer ?? 0) - (a.layer ?? 0) || + (b.stackOrder ?? 0) - (a.stackOrder ?? 0) || + a.start - b.start || + a.end - b.end, + ); + for (const span of sorted) { + const compact = pxPerSec > 0 && (span.end - span.start) * pxPerSec < 18; + const padding = compact ? 10 / pxPerSec : 0; + const start = span.start - padding; + const end = Math.max(span.end, span.start + (pxPerSec > 0 ? 1 / pxPerSec : 0)) + padding; + let row = rows.findIndex( + (r) => r.layer === span.layer && r.spans.every((s) => s.end <= start || s.start >= end), + ); + if (row < 0) { + row = rows.length; + rows.push({ layer: span.layer, spans: [{ start, end }] }); + } else { + rows[row].spans.push({ start, end }); + } + rowOf.set(span.id, row); + } + return { rowOf, rowCount: Math.max(1, rows.length), layers: rows.map((r) => r.layer) }; +} + +export function regionLaneHeight(rowCount: number) { + return ( + rowCount * REGION_ROW_HEIGHT_PX + (rowCount - 1) * REGION_ROW_GAP_PX + REGION_LANE_PAD_PX * 2 + ); +} diff --git a/src/lib/ai-edition/timeline/timelineMap.test.ts b/src/lib/ai-edition/timeline/timelineMap.test.ts index 11aa30c50..9f92b4ff2 100644 --- a/src/lib/ai-edition/timeline/timelineMap.test.ts +++ b/src/lib/ai-edition/timeline/timelineMap.test.ts @@ -12,6 +12,7 @@ import { clampSpanAgainstNeighbours, coalesceByIdentity, coalesceRegionsForRuler, + dropPillById, projectRegionsToSource, regionIdentityKey, replacePillSpan, @@ -787,6 +788,50 @@ describe("pills wired to the universal rules", () => { expect(pills[0].ids).toEqual(["a", "b"]); }); + it("keeps identical overlapping annotations separate for selection, edits and deletion", () => { + const regions = [ + { id: "background", startMs: 0, endMs: 67000, type: "text", content: "Hi", zIndex: 1 }, + { id: "second", startMs: 35000, endMs: 40000, type: "text", content: "Hi", zIndex: 1 }, + ]; + expect(coalesceRegionsForRuler(regions).map((p) => p.ids)).toEqual([ + ["background"], + ["second"], + ]); + expect(resolveRegionIds(regions, "second")).toEqual(["second"]); + expect(dropPillById(regions, "second")).toEqual([regions[0]]); + const moved = replacePillSpan(regions, "second", 5000, 10000, [], ids()); + expect(moved).toContainEqual(regions[0]); + expect(moved.find((r) => r.id === "second")).toMatchObject({ startMs: 5000, endMs: 10000 }); + }); + + it("lets annotations overlap across layers and preserves a layer change across clip fragments", () => { + const regions = [ + { id: "low", startMs: 0, endMs: 10000, type: "text", content: "Low", zIndex: 1 }, + { id: "high", startMs: 30000, endMs: 40000, type: "text", content: "High", zIndex: 2 }, + ]; + const moved = replacePillSpan(regions, "high", 5000, 30000, clips, ids(), 0.001, { zIndex: 0 }); + expect(moved[0]).toEqual(regions[0]); + const fragments = moved.slice(1); + expect(fragments.length).toBeGreaterThan(1); + expect(fragments.every((r) => r.zIndex === 0)).toBe(true); + const pill = coalesceRegionsForRuler(fragments)[0]; + expect(pill).toMatchObject({ start: 5, end: 30 }); + expect(resolveRegionIds(moved, "high")).toHaveLength(fragments.length); + }); + + it("does not exchange ambiguous identical annotation fragments at a shared clip boundary", () => { + const payload = { type: "text", content: "Same", zIndex: 1 }; + const regions = [ + { ...payload, id: "back-a", startMs: 0, endMs: 25000, clipId: "a" }, + { ...payload, id: "back-b", startMs: 25000, endMs: 50000, clipId: "b" }, + { ...payload, id: "front-a", startMs: 20000, endMs: 25000, clipId: "a" }, + { ...payload, id: "front-b", startMs: 25000, endMs: 30000, clipId: "b" }, + ]; + expect(coalesceRegionsForRuler(regions)).toHaveLength(4); + expect(resolveRegionIds(regions, "front-a")).toEqual(["front-a"]); + expect(dropPillById(regions, "front-a")).toEqual(regions.filter((r) => r.id !== "front-a")); + }); + it("keeps adjacent regions with different properties as separate pills", () => { const pills = coalesceRegionsForRuler([ { id: "a", startMs: 2000, endMs: 5000, speed: 3 }, diff --git a/src/lib/ai-edition/timeline/timelineMap.ts b/src/lib/ai-edition/timeline/timelineMap.ts index 451987ce9..646e3e2ca 100644 --- a/src/lib/ai-edition/timeline/timelineMap.ts +++ b/src/lib/ai-edition/timeline/timelineMap.ts @@ -131,7 +131,8 @@ export function anchoredToRawSpanSec( // 2. REPEL — a moved or resized pill may not overlap another pill of its kind, of // any identity. An edit clamps to the neighbour's edge; the neighbour // never moves (no cascade). Once flush, a same-identity neighbour merges -// with it on display (rule 1). +// with it on display (rule 1). Annotations are composited layers: +// they may overlap and each overlapping instance stays a separate pill. // // 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 @@ -298,7 +299,40 @@ export function coalesceRegionsForRuler), })); - return coalesceByIdentity(spans, epsilonSec).map((merged) => ({ + // Identical overlapping annotations are still two independently selectable objects. + // Partition them before coalescing, while keeping touching clip fragments together. + const annotationsByIdentity = new Map(); + const ordinary: IdentifiedSpan[] = []; + for (const span of [...spans].sort((a, b) => a.start - b.start || a.end - b.end)) { + const member = byId.get(span.id) as T & { zIndex?: number }; + if (typeof member.zIndex !== "number") { + ordinary.push(span); + continue; + } + const list = annotationsByIdentity.get(span.identity) ?? []; + list.push(span); + annotationsByIdentity.set(span.identity, list); + } + const annotationPills = [...annotationsByIdentity.values()].flatMap((list) => { + const runs: IdentifiedSpan[][] = []; + for (const span of list) { + // Old projects can have identical overlapping objects split at the same + // clip boundary. There is no provenance to pair their fragments safely. + // Only join an unambiguous continuation; otherwise keep each selectable. + const touching = (end: number) => end <= span.start && span.start - end <= epsilonSec; + const run = runs.find((r) => touching(r[r.length - 1].end)); + const uniqueEnd = list.filter((s) => touching(s.end)).length === 1; + const uniqueStart = + list.filter((s) => Math.abs(s.start - span.start) <= epsilonSec).length === 1; + if (run && uniqueEnd && uniqueStart) run.push(span); + else runs.push([span]); + } + return runs.flatMap((run) => coalesceByIdentity(run, epsilonSec)); + }); + const mergedSpans = [...coalesceByIdentity(ordinary, epsilonSec), ...annotationPills].sort( + (a, b) => a.start - b.start, + ); + return mergedSpans.map((merged) => ({ ...merged, member: byId.get(merged.ids[0]) as T, })); @@ -328,7 +362,7 @@ export function resolvePillIds string, epsilonSec = 0.001, + payloadPatch: Partial = {}, ): 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 }, - pills.filter((p) => p !== pill), - pill, - ); + const desired = { start: Math.min(startMs, endMs) / 1000, end: Math.max(startMs, endMs) / 1000 }; + const clamped = + typeof (pill.member as T & { zIndex?: number }).zIndex === "number" + ? desired + : clampSpanAgainstNeighbours( + desired, + pills.filter((p) => p !== pill), + pill, + ); const under = new Set(pill.ids); const { startMs: _s, endMs: _e, ...payload } = pill.member; @@ -420,6 +460,7 @@ export function replacePillSpan2AzLJcn!VcI2^dbz+k{g ziwP^cWgY_%y;OdK`pr(Ys`uJbys63fa1g_bi^(+O#6R4zRKPJfzrw82{Pr%TBhRpe zQ1vmOf>Fi3q}CE{Ss1ng0f8BTJdnxn*z@x8hVNP9)YCn%w=^ks2mIJM=)N}!;B#H| zis#$Q;xWo%55g27fe_+}&878|lF5o76a;<~DG>Qd@f;CzN&C%2O5sI2m|EHgKk?6% zb|nH~v?;iKMo8E&UtzB2B@G~fvJgA$Nw?6#4GHL-I&e>fa#O|9`M5P5=}c7iRMHTh2}XzY?l~Bk{c1Pw zSH40q_34~Z1kv!sU4bv+_CE)mcNdHrMCoy^P0E)uZ>ze5MB{Z#)YngN7Wpn%Em~lij-` zOp3%tp0bZ2GROjt!@n|XixnOrL8BiVj^#A)V~5+y=!Q@4)TK4I?(J z@$>iZpdAZLU(YpsG5z|5bef%)eo|TIY)|dyaz3U@(y{T?Fc|$1fKPW^F~d^8oIW&W zxLZH`0PC>R9N*107l?^yoy4O!%W;Sb zd<3JUSgu_Mn_H#ep{9razDc~biqM#k{C3tcaiS+Gpe)a9KD++f@<^(yoh#QE0!xO< z>vXy8b}Nr%$ngef$@1P>h6jbgzmD zU1fpWP2BS%^slKG^7g zHBR8OTf6KbJt^KwNoC`;m^Hy~o%i1fX|nx|Y3pO{ZPG#VO=kDzt9-b59Xh%1uWQu^ zltS6c$F4j?7@-si$`{|k0AOjz=`;Q4T}6K!_&|JC*UFLUWa|$%XwOY51G2qy<^{yHu3M}9nx^zV74JWxWJe#O3$P+Xts5YyI$1$3o zSsGdW6I}ZCs-0(?+sH6{n6U9F&fga4kmWr=k%#GF>d)oXI4zf%<&IW6sqFnD-?(la zDu!7c+CGaEz}Y(7*E767!{$_Wjng`+CmU)_kipld@u^-}0WIRV?RMLc2ANxxq0%;> zZRxVwcDlFwVe~_vI16t5al!08_RX1DN)vu!0y#(OE0~-kztDeoDnYb6GUML&F1J@r z@T;s0I+fb`?a3}6o3#B&xeZ$jJV7T7ZS*V#0p`X}TYF~nhkz$)+eEcl&WT@a4?juh zW>vs9n?pezYfH4l&D-@vm3YZbdP@qFlmuR-J`MdH=Bm3I0fyF9h20KwN@c@CWhau-aUD)30EA;?Jw~gjGti?C+GZSyzg2ZnO=1$y?5=vVt zhZ^@I8@nCOB4w~4be?EjOn?mbC!&n0ijI!Dn-!+ItHMGZPm4*y46DkF)L!{_uCy%` zs|P;dtHm;A=~goj-j2Nqt|nF6@=SqG!l3>s-vNhS%}?GM4M##s#g7$W_G7``%921- zio93Iw9x#6&rR?WKjVW4ZEus6K6Rw>DB%N`=h|g-n9A23TZCjG6 zgvKhj`6jLeUzu=7DfQK_Pnxrh$?v)gvKLAYngd4Kq_A|1|4v$7#cX|2 zZb?oyALU#ASt;ZHX!j-Eed)BX%JJ$7NyjvSrl`)x84)v`e0acT>Iyl-?Ty2|SQz*u z5#@jwGCJ!~h}I@Z6 z^L?Kru0oUN+ogT3`Hq-2_v_a%LU{0)|7N}N;$m1+V-VLWIfKskJF>N_hF=VA&7&!C z#x`5pN%>vCQO;dRE()3=EgP9|FL7~6{(M4r{}}PCpy3McUNvV@fQNYbQ}I`l4KsX@ zuV?Jo;b5x4H~YA|2+~lLXi0|86?3G^RsO7U;_Bx&nVg+i#M0S!W=RV_J1(RtZM%z? zrF>S(-%S;@cx7~yo+TJ8c#jj4R_HjGDt}8m>N!Cb5M%(l6w!tf<8XOR3;>Xe0ER03 zDTicl9Lu3QmvoVDQ!2=I?iHAG>$@!aom`}++1fQlIW^U@vY$LgMo~`2R#xoK$D?1;GQB!-vLiK2R6mXo2(W=>t*-nq{OuR4&Ds`V?ejyVlub4po26vC=b|We4Q;<3 zKeMTsA0K#8^a1{?dGg}CG=0Z*?%Ya;kJ$#)AN73OXs}lLeL)j5qRLQr5;cOsOh6aQ zAQxLwZ0#bWakm)B`OLt>ZB69ap7-ODXg=N%v?9_WEBe_-*}kYxr+5rWi>*s!p340A@E0j2-+F zxMg+R9wH`k-kl^x-$3Wnixa6IEIF;T!5+?KptlVb%>wJ@Ek4W)hp(5Xa?^^L+lWk~ zd?c+=M}-1s%;i0A6uA=9jZ#0Y3y8=Drhnl42$GHJH-}bWuz$ml<5v!a;RGVi)c!t^ z;#C@NXX?-**)?5$h4iOSR*I3!U~-J=kQ5%_X3=el@g@46m@jW!6EVty9zyG>z04jn zsR(l&ToL8ib6PcIgfjff#VzFVPSm2PdX*8a+mda-vqYM8EDa=q@kqoOs#oKfVU1 z=MJj%59yO=chzzC0CLl~}C*TQZpilt41P z$YnXxKLx3_7I7WNoyG1f83BYtR_&dbY+&RbgS0tw!>9!<`QfxtVSZF1|1C3Ri`9A^ zo@iQ8Hfu~jq5FJNxYo`wr%r15JRnC)M$Y%cm`3U5Om=}i&C~|1XCpslpic0d69%UF zgB{08vMI*B=!Ju)w_2z5&> zn{3-0gHTb)w3qF@Z*{?upSKzRiy#9p}*fp1zl zON>*0{?&hAo89I&WU6V=h>P%Ew|H`L=|~;c83WmqPQ+ZE@AK+yQ=eap9>w8*E1?Lu zfPSaO5iP2|h%{f9kem3J?tCxE4TUfCN70c~Ur|eMh<6_@J~e%C&BeXu!_dK|J*qy8 z74$Dv5T`f^4#36wcU0?8eN&t(k#GiFnBf2hX>n?R$|BH)+v5R(8D8Q=w3{tNVG7#~FK z4to6xnu|n~?2*#LFE>YFbqZDD1>iQS^o2b?MAcjx$C~mL81Q~fw(Lan^jw^H^UG*E z-%S6ko-!Lw=F;r__4{Mmxk`(E0iG>afn(^Ot`;pMIXFksJtXnoa1l~m6a=SP-ZFOf z`OASJrA}xRdAU5Xe%bO-ua%$rro%R}Rd!G%CDh}k_Q#j>B+-TX2SU(@DM@*qBE9y< zKVIf$zXMR|iB0tpVxlI(HI{V_I2{9p{?`i_FSqXxjFSkn{8GK{fI?Y;s^U(O^i{e> zX9k1DAf%1|WdGHCOA#?Yj3>A@|E>O6mdiEF@yb**$v($TIC_}16l~SoEe>f4U ztk1Sd5{h?Xc%(U*guy{k>Yv*C6pC z01ugN#jHBW=HQ_Kj$e@y3Jud7{uoEh5MR#0!lXtLb?6I)DiGI?0T|qo{U5nmP;1sc|{V)u>xV0t*r#< z1RaG?687x*_lFURMTSNrkK(m}1Ld|~R3v*&HZFS?)Y^5l)pxU4yC+m3lsj@)PeA>_ z6Ym#dIM2>mTKh;_syN|8?5)7TS{L~i0^@aR3e^7<)!s*A28j&j1G`rSkEZgO}Cvx zx=-Ar{)Yrdf2~1|b3sd1?pqQojgE(@iD}qMDL@8KN2R3iy4{gs2P&Xd#0KwS!ChxwWCdJ1cbVlk&|G zW|Of^_O+N*@#8i(BD6~b^&xa`5W7Tr%^Uo0vNSc?szNRqAUJ~ArGp`RD7%>Eq$-7s zDo8(OS&xA;!MZs;FW+vn++1{8E^L41qia36Ki)o3FeBjOG+Y^cul7!kG-aB0>SAD+ z+B8AH*3wj??o6R1gtWr;+Fq}-nv=SQrif8XNdTM{u#`pky;ic&q0!>v2^!f@WZ0*- zM&IF@b`%wIKgTyiSJ0(|cCMsZQM8KRc-ZwBnEfX;^>TEtZcb3~yszg`0zJy};1Nj| zGW{`3^j^#cQh)Qb6J+qmNG(RD>HE*EC^^m|a730}WslgK5E*{{DRC=X$kkJf%d|Xc zhPLvC#%*_8F#@l*wu45U>z|G?NwlgJH}}JAU1r^D!;Ng)0~{xikjVcXF5U}76P+Rq z47ap}$Xx~15#^}OwPkG0BL_mg9&cBEA<$701Yd`3)?9FAD`#`EdYsVCeVQroRI9n|xNu>!NyD}=nH;wIvv884Z+-tRyR9C5b1fdwyP(Iai zu!;%Wn0Id2{c}Y@Ir43T{eeu+Gx5MwdR_fj!lHm3h3s`yb{O*$z7N zY6z^pz(VW}U~-u%8TtPmEtM!c<_J=Qp&N4gihJF_2V}O1H{aOY5@BNq$gwPg$562) z=btX@|6~qh3ofnPZlBf6`e?GW5%^_U6jpe8rHuR;V{l57OJA0oBE9y6RVKVM(`vNA zTSeS-xR)YQ>B6Q%BQT(L#3fzK+M5gtBB@~YLVDw&?#_S1T_w_Ua?zkUwo0L!x_uM2 zPOU_wshP)fW|v^^Z2u~bJWs9JT}yXse(5GCo%WdiBJjKd)=?HCnZ+`{$vaQkCW73X zVhz%&JCmGcUf-`ck>yrVOo5S=_WEzm{hN7*hj$CG8NW*heJ&hUe`7HhXh!~iLTK9n zWTOYbsXdyU=)4Z2+)QrOXwo;44h+)ul9*1HxB6Yu>LUmg)`Q>?mK zLp8hzDwdPr2p#;K+dJm;=Ujs|w`_-m58PDwWVFG#MU1YS&`i1Jyfmpu=^Mx>ky?0N ze>f=1c9DG_!(l>g1ueJT>z+5+4UO-k%C5>7^^`#KYYcd!Qf>R!BjPL(yMv3{d03b& zRt^SX{v&xNYz6VF%D^Q!z(gW(+f583M(6Yf@9azdk@j>@ww-cqjdZyKB*#(>_dybws6FKOI7N-CMjeCur+ZrQ7ir+3cmb)GI zIeY?|gpQ`uactm_B|buM3H{UR+V*#jPyWxNt%J1|f6G zc0dZf?8XTHLS`A&zwJn=x1!%<*!8U}Z8ZDv-0gaAcJ?biPJSzu!)mQn1$)9sITbY@{VP(HF(l0*U_Y-r%^?@du&G)KpT;J14@AeDM zWFHdlye~bU2h4vm!b@s+G+tzKfkMi2;$`<4YA;@9Bcj0V5EAzSpPiPdA~3t4XIT%4 zC`2rak35uSZT?EWG!sj@B@+{6j;gQCc~&?IDC zpiQ0S+Ou|n8W${46+x2gl4V+WvyfrycU_@Iq!F4sV&_lXZ!lH!`PVv7$K`XPNq$7f zp%e2;2srJ(hm7YMZHo%J^VXpwL-)@7D;hPW0e%`M7sxxR)(7e`qvu%)jIqWm2RYlNrIG9MNTdX##4?|EpVIZcZ&E(K}b61*TuL z?Ofi7=o8k^#JWu}=C?Vn^3V#<13V3_p8wyJFW~DId@4Lk z5lz{KdE?#~wIAhJ%_g3+&RpP}P|6_Io9+NICfDAR-C=n`~sWA5k4%1^^@=ZLkO?X4@ zB>$GL2=efLf&UrDXz}U99EGG|hZO}n5=Qb{%Yv3qkzxoH@KriE{u^T92yvQ7;QX3{YV(dhy4*^4cWB}yl zgHETu-aCYg#P7cL%!Rk{M%AcZ$MW7O$ zU>fy=@v>ld7;`31at6n`4=|RU{rO15)crLPx`+`Bix77VFmMJazt9?BqW-TtBE@QJ zq5tgd0fNV}!yE^(id#fyXWT7IR#-s!z0N_=O52<+6SQt=p#Xc-+J%WuIkunxMi$o; zeFsn=tJIvR+qOuf)~mm*77W%lW0m#JfRQyJA|t8g1F9PiEJuQ>LagzHi)5_5xt|wS zlfRUJ+C7$B>0g`VWwOJ~6kuSuc?PayUPDnaSsRM-DL$qXhXSRIa4^#VD&x#-(ukcL zEI$;=LI*L1UXbq@>2)bs!x#X2jch}C}sxn z;iYQl_ZjKZ0)y5z*(Wzuag8#f%Be@cT!$5~76Nu`I52NK8q+ZJ(;eM0_n0D9#ud!aQyiUSInn3#JzDNfcRpU!=pVm`=j#A>5rl0N7I^ z>qH#6r_6h-)BSbXydxh@II1$jxDI~`rGzpJpbIx?J zbk+*WCx1ejZ3M)?GHn?CSLjHS{6i>qH2h(i$%w7k+*|7GED~n>we$Tf+Z#Tu3In^d zRO$xw{Uj1%df+G%J!s@%>Qv`#uf~D&Y`^Ux9~&2W>n`q&Fa7-x=kMp1o~ih9 z+PtUO2MaJifMBXjQljT}Nw?7IM#H~S~G5Oo`?u35^TJ`GASyWr;G+Z7tK1`vn(5RkI-Ig zGR!5tOKzfxlrk?HDFn=*Kc1Zb?)e@!8+!ciFQ+Q)hMwK&`RvzRM1OrkND2L-3H8JL zrJGe#Pca24s)JBCNNVZ@i@BN}@uhKn8k0cJf#;&FFb00OH?2K#X4f$Qx5@zDhpqBV zz7&!nzUkD@y}g10b6xS&IUpn$(8EzO|ZSJ1OoP=FM^)wD#g-PsI}_|A$e<1p0P4^H4fk+j>ui$Elrn&88RF zCLZ%`@;d4-^LGpjP*Wa`7QCIa#q)HGY_{*8{z%78TfiiEgLTwVyrT=RtACxz;!b^4 zXtHv@?8jGVVnS8tqnEPMg=AWi>?8B$#@dn$-mt8^p zChBE-K|7SG%#U#WG6ElWo_*IRYJ729AZ5)N*^Kjz`OUV4AiW6qOfK%=rIa?6hXEN$ zC{%56ke!q{OJUJe=^X}Yt3CANMKosU-_8F7*Uv6ri@jnQ9NSR>5AN|adT$_0X8E=R zrZAS>w6~Wy=*3yx?3D0b65_R~56B4IT*l21(o1F%K!e!^vdCu7?m;#kKh|E-`cnBU9zI2!dOz|?x0=~JlaogU-+hqf>d2H|bNvR+ zR8|&JnsZV5_U02eKORby5&+uYQ?xl7U~1{5hQit1x(W}V*YD;{VIIs9L{pXZ_>E67 za~;pcEnNpzN9|}y9$9XneY%5LM9Smmhek_~XfWF#)o~f5cYM5oxK7LGFdWP8V{Cb*bkeAzn0~1+# zI6BVgbjWG7<(AsI^wu=&oD&%4*R6zT&w%8h9SF}EYH6^n{2sOB+O}p}mBZaUY!e;4 z_Z|8|wE9&c%aIGQx#7sOU#Q71<|d{Pz2;vdkBh+s?kqktBwCC*yj*78JoOR{PfxSy zP(+>v&QO-Z@a~KZb}oZkyT^<(9y1ENLxVz;WwO76eJ)?$@o;o!=wMB}W?h zZAhp7A!M`5BAHWcZQ~di7UI8e=_2^r*jBwVUNzX;UpBA06%up2g^Vdp$}YKh_Km(k z{Sq0emJwX?N^zAx4|$LvIqPLS{6y?7VA(g*h>x7^54kr9zXWV6o}c;npcxzu@h?k4 za2pj?ROup`=)s!j7u#v?QZNj0+1I9P6L>5?LXRAYa=$FRu6ZktasZfk9(o{#$YD$N zn$l#jWBnO>5T>FTch0luC6TF)|MNDw86ulC_dJ>$%pa7c7Ztt-hZGK}{hX}l>Khi4 zf@!Va>_vEnu9HQm^beCe6FkIgd#+n-IdQX$u#)3L=V0_AJhCf-1&qfFZ2 zJ5vp{M2Q7a(=?Q-fo&HxBE_k z-PE@xCIAuwicxR)-Sk~*c%m8}ME41Ao|I54?vJSn43-I^01&BDvLCU>S|@yvtVk_{~XOkfCGk|FXm1QW}lj zpv`)b1a84QbS(!T?S-R7UMk`c`EIvd6E5KfaLmhJvJZc69A#ESo@0<4Eh;A!$kG z>xqfG!&i#z4Pa`DQ_tp5T1<+VI&>dSxXMN)*uQEnU!~O@!o~?(Z+W)t?^0ZQ!=g;x zXO)tL-Lr(hOetEscX;uU4_R9j$k&_UF_#n_qBc|RRz#!Ki&^{u-trbNbnKk_{RBQe z3Ax>1+AKuLdaPaLc_=tIQ+Y~nkEVzQ;Xd)(H@?f2DH8fLejZ)jZh)ut6?Jll4*MS4 zQZacPvYHC&>CE|lIh97n>Wz2YuY1ecn0scQFM#xuzJ{?1Q$pw`d)y$$u`qOP7R-!e z@1cBG)|9RO7>0pnRHAgDM^IwG^%PtzDhw@dp?7A%e$l-=OOu0t1aLD?U;4-sT>XAv zzZJ^A4$&?4Jhk5Y^^}Zrg=IaVIY6RY$h2}a6Qg>NCY?T$7qrArvO6U$ONGiZwt~-$ zUiCY@`>4eAjB1Wy5uLDyY2ffNAZv_ZiL-Sev~| z&u1SJ-ij`?IZ_Kpj=2 zl3`pAXT)4Odjhsnp;+&Vi+@Qz+>|hTMpYT$KrM)R3SC`xKvhb6WKTHJMs>#fg{km(0a^tK^^;9n zPMX^t1dcET^7}(ofgS+L!EH(Q7F}PbaJYRnc|?9wlXSiQS`(5rGB8eLi{56npO`AO z`F{4uCr@Lb%QMspSOWH*cYx}`i;yl+VH!6-Z^zLqruokS))(ubhtiN7fsk_h6x|0r zEW0>eWc4-L&=Y6PwrIDAu=@{JGl3oGYS^<|YY9E8xhJ@9!$zjd4?MaHsCFDSfz>, zoomRanges: [] as ZoomFixture[], legacyEditor: null, agent: { pendingQuestions: [], suggestions: [], lastAppliedOperations: [] }, @@ -90,6 +90,10 @@ function makeDoc() { // and `ZOOM_DEPTH_SCALES` renders it as the "1.80×" the pill is addressed by below. function makeZoomDoc(): ReturnType { const doc = makeDoc(); + // The keyboard test exercises all four presets. A 1080p source now hides + // the deepest one through zoomScaleLimit's source-magnification guard. + doc.assets[0].video.width = 3840; + doc.assets[0].video.height = 2160; doc.zoomRanges = [ { id: "zoom_e2e", @@ -141,7 +145,7 @@ function makeTwoClipDoc(): ReturnType { // it is not (a title next to a visible label would repeat it), so a pill is found by either. function zoomPill(page: Page, label: string): Locator { return page.locator( - `[class*="lanePill"][title="${label}"], [class*="lanePill"]:has-text("${label}")`, + `[data-track-kind="zoom"] [data-region-id][title="${label}"], [data-track-kind="zoom"] [data-region-id]:has-text("${label}")`, ); } @@ -166,6 +170,68 @@ async function seedAndOpen(page: Page, doc: ReturnType = makeDoc } test.describe("v4 editor shell", () => { + test("overlapping annotations stay reachable through dragging, layer changes and undo", async ({ + page, + }, testInfo) => { + const doc = makeDoc(); + doc.project.title = "Annotation tracks"; + doc.timeline.clips[0].timelineEndSec = 67.2; + doc.timeline.clips[0].sourceEndSec = 67.2; + doc.annotations = [ + { id: "background", startMs: 0, endMs: 67200, content: "Main annotation", zIndex: 1 }, + { id: "second", startMs: 34000, endMs: 42000, content: "Second annotation", zIndex: 2 }, + { id: "third", startMs: 36000, endMs: 47000, content: "Third annotation", zIndex: 3 }, + ].map((a) => ({ + ...a, + type: "text", + position: { x: 30, y: 40 }, + size: { width: 30, height: 20 }, + style: { color: "#ffffff", fontSize: 32, fontFamily: "Inter" }, + })); + await page.setViewportSize({ width: 1440, height: 1000 }); + await seedAndOpen(page, doc); + const pills = page.locator('[data-track-kind="annotation"] [data-region-id]'); + await expect(pills).toHaveCount(3); + const positions = await pills.evaluateAll((els) => + els.map((el) => el.getBoundingClientRect().y), + ); + expect(new Set(positions).size).toBe(3); + for (const id of ["background", "second", "third"]) { + const pill = page.locator(`[data-region-id="${id}"]`); + await pill.click(); + await expect(pill).toHaveClass(/lanePillSel/); + } + const stored = () => + page.evaluate(() => { + const state = JSON.parse(localStorage.getItem("browser-shim-projects-v2") ?? "{}"); + return state.documents.proj_e2e.annotations as Array<{ + id: string; + startMs: number; + zIndex: number; + }>; + }); + const second = page.locator('[data-region-id="second"]'); + const box = await second.boundingBox(); + if (!box) throw new Error("second annotation has no bounding box"); + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + await page.mouse.down(); + await page.mouse.move(box.x + box.width / 2 + 45, box.y + box.height / 2 + 54, { steps: 10 }); + await page.mouse.up(); + await expect.poll(async () => (await stored()).find((a) => a.id === "second")?.zIndex).toBe(0); + const moved = (await stored()).find((a) => a.id === "second"); + expect(moved?.startMs).toBeGreaterThan(34000); + await second.click(); + await expect(second).toHaveClass(/lanePillSel/); + await page.getByRole("button", { name: "Undo", exact: true }).click(); + await expect.poll(async () => (await stored()).find((a) => a.id === "second")?.zIndex).toBe(2); + await page.getByRole("button", { name: "Redo", exact: true }).click(); + await expect.poll(async () => (await stored()).find((a) => a.id === "second")?.zIndex).toBe(0); + await expect(pills).toHaveCount(3); + await page + .locator('[data-track-kind="annotation"]') + .screenshot({ path: testInfo.outputPath("annotation-tracks.png") }); + }); + test("media stage shows each asset's file size, em-dash when unknown", async ({ page }) => { await seedAndOpen(page); await page.getByRole("tab", { name: "Media" }).click();