diff --git a/src/components/ai-edition/NewEditorShell.timelineHeight.test.tsx b/src/components/ai-edition/NewEditorShell.timelineHeight.test.tsx index 9ecf446fc..405dca77b 100644 --- a/src/components/ai-edition/NewEditorShell.timelineHeight.test.tsx +++ b/src/components/ai-edition/NewEditorShell.timelineHeight.test.tsx @@ -29,10 +29,16 @@ 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, + shaderEffectRangeSchema, +} from "@/lib/ai-edition/schema"; import { useProjectStore } from "@/lib/ai-edition/store/projectStore"; import { DEFAULT_TIMELINE_HEIGHT_PX, + EFFECT_LANE_EXPANSION_PX, MAX_TIMELINE_HEIGHT_PX, MIN_TIMELINE_HEIGHT_PX, NewEditorShell, @@ -235,4 +241,67 @@ describe("NewEditorShell timeline height", () => { expect(root.style.gridTemplateRows).toBe(`58px 1fr ${DEFAULT_TIMELINE_HEIGHT_PX}px`); }); + + it("combines annotation, audio and effect lane expansion, caps the height and restores the base", () => { + 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`); + const twoLayerDoc = { ...doc, annotations: doc.annotations.slice(0, 2) }; + const withEffects = { + ...twoLayerDoc, + shaderEffectRanges: [ + shaderEffectRangeSchema.parse({ + id: "effect-range", + effectId: "effect", + startMs: 0, + endMs: 5000, + }), + ], + audioTracks: (["voiceover", "music"] as const).map((kind) => + createAudioTrack({ + assetId: kind, + kind, + durationSec: 5, + timelineStartSec: 0, + spanSec: 5, + }), + ), + }; + act(() => { + useProjectStore.setState({ document: withEffects }); + }); + expect(root.style.gridTemplateRows).toBe( + `58px 1fr ${DEFAULT_TIMELINE_HEIGHT_PX + 27 + AUDIO_ROW_EXPANSION_PX + EFFECT_LANE_EXPANSION_PX}px`, + ); + act(() => { + useProjectStore.setState({ document: twoLayerDoc }); + }); + 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 13e5c1c47..f981d65e7 100644 --- a/src/components/ai-edition/NewEditorShell.tsx +++ b/src/components/ai-edition/NewEditorShell.tsx @@ -43,6 +43,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, @@ -252,10 +254,23 @@ export function NewEditorShell() { () => computeAudioRowCount(document?.audioTracks ?? []), [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 hasEffects = (document?.shaderEffectRanges?.length ?? 0) > 0; const extraLaneHeightPx = - Math.max(0, audioRowCount - 1) * AUDIO_ROW_EXPANSION_PX + - (hasEffects ? EFFECT_LANE_EXPANSION_PX : 0); + extraAudioHeightPx + extraAnnotationHeightPx + (hasEffects ? EFFECT_LANE_EXPANSION_PX : 0); const timelineHeightPx = Math.min( MAX_TIMELINE_HEIGHT_PX, Math.max(MIN_TIMELINE_HEIGHT_PX, timelineBaseHeightPx + extraLaneHeightPx), diff --git a/src/components/ai-edition/v4/V4Timeline.geometry.test.tsx b/src/components/ai-edition/v4/V4Timeline.geometry.test.tsx index bebe463b1..cd353873b 100644 --- a/src/components/ai-edition/v4/V4Timeline.geometry.test.tsx +++ b/src/components/ai-edition/v4/V4Timeline.geometry.test.tsx @@ -205,6 +205,109 @@ 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); + }); + + it("gives overlapping shader effects enough lane height above the annotation tracks", () => { + renderTimeline(undefined, undefined, undefined, undefined, { + shaderEffects: [ + { id: "fx-a", name: "Ripple" }, + { id: "fx-b", name: "Glow" }, + ], + shaderEffectRanges: [ + { id: "fx-range-a", effectId: "fx-a", startMs: 0, endMs: 67000 }, + { id: "fx-range-b", effectId: "fx-b", startMs: 35000, endMs: 40000 }, + ], + }); + const lane = document.querySelector('[data-track-kind="shaderEffect"]'); + const pills = [...(lane?.querySelectorAll("[data-region-id]") ?? [])]; + expect(pills).toHaveLength(2); + expect(pills[0].style.top).not.toBe(pills[1].style.top); + expect(lane?.style.height).toBe("51px"); + }); +}); + 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 bb026fcd8..5c5245abf 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) => ({ @@ -929,12 +941,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 @@ -979,6 +995,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 @@ -1016,14 +1035,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 if (pill.kind === "shaderEffect") await tl.updateShaderEffectSpan(grabbed, s * 1000, en * 1000); @@ -1062,7 +1082,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); }; @@ -1072,7 +1119,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); @@ -1086,7 +1133,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 @@ -1684,6 +1731,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; @@ -1703,11 +1751,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 ? ( @@ -1821,6 +1888,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, @@ -1840,6 +1908,7 @@ export function V4Timeline({ return [ renderOnePill({ pill: p, + row: rows.rowOf.get(p.id) ?? 0, key: p.id, segStart: p.start, segEnd: p.end, @@ -1856,6 +1925,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 @@ -1864,6 +1942,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, @@ -1939,6 +2021,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)}%`, }} @@ -2282,7 +2365,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", { @@ -2290,19 +2378,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( @@ -2310,7 +2402,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. */} @@ -2328,7 +2424,13 @@ export function V4Timeline({ row would offer nothing. The shell grows by its height (`EFFECT_LANE_EXPANSION_PX`). */} {tl.shaderEffectRanges.length > 0 ? ( -
{renderPills(shaderEffectPills, "")}
+
+ {renderPills(shaderEffectPills, "")} +
) : null} {/* Imported audio tracks (issue #350). Always shown, like every other lane — "Add audio" is a toolbar peer of the region tools now (and 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 f8e6ba8a3..8b552d52e 100644 --- a/src/lib/ai-edition/store/useTimeline.test.ts +++ b/src/lib/ai-edition/store/useTimeline.test.ts @@ -838,6 +838,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 7d650ed85..ee9f6ff59 100644 --- a/src/lib/ai-edition/store/useTimeline.ts +++ b/src/lib/ai-edition/store/useTimeline.ts @@ -53,6 +53,7 @@ import { hasAnyClipWithCamera } from "../timeline/camera"; import { probeAudioDuration, probeVideoDimensions, probeVideoDuration } from "../timeline/duration"; import { anchorRegionsWithDerivedMs, + coalesceRegionsForRuler, dropPillsByIds, replacePillSpan, resolvePillIds, @@ -520,7 +521,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, @@ -969,19 +970,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 d613614bb..f5cfc2854 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, @@ -788,6 +789,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("wholePillOf gives the pill a fragment belongs to, over its whole span", () => { // One zoom cut by the clip boundary at 25.557 s: two fragments, one pill. const regions = [ diff --git a/src/lib/ai-edition/timeline/timelineMap.ts b/src/lib/ai-edition/timeline/timelineMap.ts index 80f77ed4d..f82683553 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, })); @@ -346,7 +380,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; @@ -438,6 +478,7 @@ export function replacePillSpan>, 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();