diff --git a/src/components/ai-edition/v4/EditorShellV4.module.css b/src/components/ai-edition/v4/EditorShellV4.module.css index 7e8896512..461aa52ab 100644 --- a/src/components/ai-edition/v4/EditorShellV4.module.css +++ b/src/components/ai-edition/v4/EditorShellV4.module.css @@ -1476,6 +1476,41 @@ overflow: hidden; text-overflow: ellipsis; } +/* The rest of an audio pill's tape (audioGhostExtent): where its file's content still + sits around the pill. Dimmed and unclickable, below the pill (z-index 2 there), so + the pill reads as a window onto it — a resize now shows what it crops and where the + content stops before the edge's hard stop. */ +.lanePillGhost { + position: absolute; + top: 1px; + height: 22px; + min-width: 1px; + overflow: hidden; + border-radius: 6px; + border: 1px dashed color-mix(in oklch, var(--accent) 55%, transparent); + pointer-events: none; + z-index: 1; +} +.lanePillGhost .tlWave { + opacity: 0.4; +} +/* Crop readout pinned to the pointer while an audio pill's edge is being pulled — + in → out over the file's length. Rendered at the component root (see the JSX note + about the canvas transform). */ +.tlDragTip { + position: fixed; + z-index: 1200; + transform: translate(-50%, calc(-100% - 18px)); + padding: 3px 8px; + border-radius: 6px; + border: 1px solid var(--border-hi); + background: var(--bg); + color: var(--fg); + font-size: 11px; + font-variant-numeric: tabular-nums; + white-space: nowrap; + pointer-events: none; +} .laneAnnotation { border-color: var(--annotation); background: var(--annotation-wash); diff --git a/src/components/ai-edition/v4/V4Timeline.tsx b/src/components/ai-edition/v4/V4Timeline.tsx index e89891dca..d424b7cb2 100644 --- a/src/components/ai-edition/v4/V4Timeline.tsx +++ b/src/components/ai-edition/v4/V4Timeline.tsx @@ -39,6 +39,7 @@ import { useTimelineTranscriptGate } from "@/lib/ai-edition/store/transcriptionS import { useChatPromptBus } from "@/lib/ai-edition/store/useChatPromptBus"; import { useEditorSettings } from "@/lib/ai-edition/store/useEditorSettings"; import type { useTimeline } from "@/lib/ai-edition/store/useTimeline"; +import { audioContentBounds, audioGhostExtent } from "@/lib/ai-edition/timeline/audio-placement"; import { hasAnyClipWithCamera } from "@/lib/ai-edition/timeline/camera"; import { formatSec } from "@/lib/ai-edition/timeline/format"; import { @@ -692,17 +693,43 @@ export function V4Timeline({ [seekToClientX, tl, setCurrentTime, showLanes], ); + // What an in-flight drag of an AUDIO pill also needs to know: the edge being pulled + // (`mode`), and where the content stood when the gesture started — so the live + // preview can keep the ghost and the readout in step with the offset a "l" edge is + // trimming, and clamp the edges to the file's own bounds. + type AudioDragContext = { + originStartT: number; + originEndT: number; + offsetSec: number; + durationSec: number | undefined; + }; const [activePillDrag, setActivePillDrag] = useState<{ id: string; kind: LanePill["kind"]; start: number; end: number; + mode: "move" | "l" | "r"; + audio?: AudioDragContext; } | null>(null); const activePillDragRef = useRef<{ id: string; kind: LanePill["kind"]; start: number; end: number; + mode: "move" | "l" | "r"; + audio?: AudioDragContext; + } | null>(null); + + // The crop readout while an audio edge is being pulled: where the played window now + // sits in the file, pinned to the pointer. Numbers only — the boundary states are + // self-evident (in 0:00.0 = the file's start) and localised copy would freeze this + // prototype into thirteen locale files. + const [audioDragTip, setAudioDragTip] = useState<{ + x: number; + y: number; + inSec: number; + outSec: number; + durationSec: number; } | null>(null); // The one door for "this pill is the thing I mean", called by both the pointer and the @@ -734,6 +761,18 @@ export function V4Timeline({ const r = el.getBoundingClientRect(); const startX = e.clientX; const dur = pill.end - pill.start; + // Audio pills drag their own content, not just a span: remember where the + // window stood when the gesture began, so the edges can be stopped at the + // file's own bounds and the ghost/readout can follow a left-edge trim live. + const audioCtx: AudioDragContext | undefined = + pill.waveform && (pill.kind === "voiceover" || pill.kind === "music") + ? { + originStartT: pill.start, + originEndT: pill.end, + offsetSec: pill.waveform.sourceStartSec, + durationSec: pill.waveform.assetDurationSec, + } + : undefined; // 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 // from every row the grabbed (possibly already-coalesced) pill represents, @@ -783,9 +822,27 @@ export function V4Timeline({ // trims its in-point (the media stays where it is in time), while moving the // body carries the media with it. Every other kind holds a value over a span, // so which edge you grabbed changes nothing about what it plays. - else if (pill.kind === "voiceover" || pill.kind === "music") - await tl.updateAudioSpan(pill.id, s * 1000, en * 1000, dragMode); - else { + else if (pill.kind === "voiceover" || pill.kind === "music") { + // The same content bounds as the live drag — apply is the last door, and + // clamping here too keeps a committed span from ever running past the + // file even if a future caller skips the drag machinery. + let as = s; + let ae = en; + if (pill.waveform) { + const bounds = audioContentBounds( + pill.waveform.sourceStartSec, + pill.end - pill.start, + pill.waveform.assetDurationSec, + pill.start, + pill.end, + ); + if (bounds) { + if (dragMode === "l") as = Math.max(bounds.minStartT, as); + if (dragMode === "r") ae = Math.min(bounds.maxEndT, ae); + } + } + await tl.updateAudioSpan(pill.id, as * 1000, ae * 1000, dragMode); + } else { // Trims are stored in source-time per asset but manipulated on the // timeline like every other pill. Ventilate the new span across the // clips it covers (one source range per clip) — the same primitive @@ -820,12 +877,45 @@ 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 }; + // A resize is a CROP of the file: the edges stop where its content stops, + // the same kind of wall a neighbouring pill already is. Before this, an + // edge stretched on into silence however far past the media it was pulled. + if (audioCtx && (dragMode === "l" || dragMode === "r")) { + const bounds = audioContentBounds( + audioCtx.offsetSec, + pill.end - pill.start, + audioCtx.durationSec, + pill.start, + pill.end, + ); + if (bounds) { + if (dragMode === "l") ns = Math.max(bounds.minStartT, ns); + else ne = Math.min(bounds.maxEndT, ne); + } + // The readout answers "where am I in the file": the in-point a left-edge + // trim is moving, the out-point a right-edge pull is extending. + setAudioDragTip({ + x: ev.clientX, + y: ev.clientY, + inSec: audioCtx.offsetSec + (ns - pill.start), + outSec: audioCtx.offsetSec + (ne - pill.start), + durationSec: audioCtx.durationSec ?? 0, + }); + } + const nextState = { + id: pill.id, + kind: pill.kind, + start: ns, + end: ne, + mode: dragMode, + audio: audioCtx, + }; activePillDragRef.current = nextState; setActivePillDrag(nextState); }; const up = () => { setSnapPct(null); + setAudioDragTip(null); window.removeEventListener("pointermove", move); window.removeEventListener("pointerup", up); const finalDrag = activePillDragRef.current; @@ -1310,6 +1400,44 @@ export function V4Timeline({ ) : null} {effectivePills.flatMap((p) => { + // An audio pill is a window onto its file; behind its edges, the rest of + // the tape — dimmed, unclickable, bounded by the file's own start and + // end. It is what makes a resize read as a crop: you SEE what is still + // available on each side before the edge stops. While a left edge is + // being pulled the in-point moves with it, so the ghost follows live. + const ghostDrag = activePillDrag?.id === p.id ? activePillDrag : null; + const ghost = + p.waveform && (p.kind === "voiceover" || p.kind === "music") + ? audioGhostExtent( + ghostDrag?.audio && ghostDrag.mode === "l" + ? ghostDrag.audio.offsetSec + (p.start - ghostDrag.audio.originStartT) + : (p.waveform.sourceStartSec ?? 0), + p.end - p.start, + p.waveform.assetDurationSec, + p.start, + p.end, + total, + ) + : null; + const ghostEl = + ghost && p.waveform ? ( +
+ +
+ ) : null; // Eager split preview: the instant a clip is grabbed, a pill that // straddles the dragged clip's junction shows the same per-clip // split it would resolve to on drop (via moveClip's reprojection), @@ -1335,6 +1463,9 @@ export function V4Timeline({ const c = clipById.get(f.clipId); if (!c) return []; return [ + // The ghost belongs to the WHOLE pill, so it goes with the + // leading fragment only. + i === 0 ? ghostEl : null, renderOnePill({ pill: p, key: `${p.id}__f${i}`, @@ -1354,6 +1485,7 @@ export function V4Timeline({ } const shift = regionPreviewShift(p.start); return [ + ghostEl, renderOnePill({ pill: p, key: p.id, @@ -1374,6 +1506,16 @@ export function V4Timeline({ return (
+ {/* The crop readout while an audio pill's edge is being pulled (audioDragTip). + Lives at the ROOT, not in the canvas: the canvas carries the zoom/pan + transform, and a fixed-position child of a transformed element is fixed to + that box, not the viewport. */} + {audioDragTip ? ( +
+ {formatSec(audioDragTip.inSec)} → {formatSec(audioDragTip.outSec)} + {audioDragTip.durationSec > 0 ? ` / ${formatSec(audioDragTip.durationSec)}` : ""} +
+ ) : null}
{showLanes ? (
diff --git a/src/lib/ai-edition/timeline/audio-placement.test.ts b/src/lib/ai-edition/timeline/audio-placement.test.ts index 89917efab..f29f620dc 100644 --- a/src/lib/ai-edition/timeline/audio-placement.test.ts +++ b/src/lib/ai-edition/timeline/audio-placement.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from "vitest"; import type { AxcutAudioRegion, AxcutClip, AxcutTrimRange } from "../schema"; -import { placeAudioRegions, resolveAudioPlayback } from "./audio-placement"; +import { + audioContentBounds, + audioGhostExtent, + placeAudioRegions, + resolveAudioPlayback, +} from "./audio-placement"; function clip(over: Partial = {}): AxcutClip { return { @@ -228,3 +233,60 @@ describe("resolveAudioPlayback", () => { expect(resolveAudioPlayback([], 0)).toEqual({ targetTimeSec: 0, shouldPlay: false }); }); }); + +describe("audioContentBounds — where a resize must stop at the file's own edges", () => { + it("stops the left edge where the in-point would hit the file's start", () => { + // Pill at 10–20s playing the file from 4s: only 4s of tape exist to the left. + const b = audioContentBounds(4, 10, 120, 10, 20); + expect(b).not.toBeNull(); + expect(b?.minStartT).toBe(6); + // The right edge can still reach the file's end: 4+10=14 played, 106 left. + expect(b?.maxEndT).toBe(126); + }); + + it("clamps the left bound to the timeline's own start", () => { + // More in-point than timeline seconds before the pill: the lane starts at 0. + expect(audioContentBounds(30, 10, 120, 10, 20)?.minStartT).toBe(0); + }); + + it("stops the right edge where the played window runs off the file's end", () => { + // Pill at 0–10s playing 100–110 of a 115s file: only 5s of tape remain. + const b = audioContentBounds(100, 10, 115, 0, 10); + expect(b?.maxEndT).toBe(15); + expect(b?.minStartT).toBe(0); + }); + + it("leaves both edges free while the pill is a window inside the file", () => { + const b = audioContentBounds(10, 10, 120, 5, 15); + expect(b?.minStartT).toBe(0); + expect(b?.maxEndT).toBe(115); + }); + + it("returns null while the duration is unknown — a failed probe must not freeze the pill", () => { + expect(audioContentBounds(10, 10, null, 5, 15)).toBeNull(); + expect(audioContentBounds(10, 10, 0, 5, 15)).toBeNull(); + }); +}); + +describe("audioGhostExtent — the rest of the tape around the pill", () => { + it("spans from the file's start to its end, mapped onto the timeline", () => { + // Pill at 10–20s playing 4–14s of a 120s file: the tape runs from 6s (where + // 0:00 sits) to 126s (where the file ends). + const g = audioGhostExtent(4, 10, 120, 10, 20, 300); + expect(g).toEqual({ startT: 6, endT: 126, sourceStartSec: 0, sourceEndSec: 120 }); + }); + + it("clips to the timeline's bounds and reports the visible source window", () => { + // Same pill on a 60s timeline: the tape's tail is cut at 60s = source 54s. + const g = audioGhostExtent(4, 10, 120, 10, 20, 60); + expect(g).toEqual({ startT: 6, endT: 60, sourceStartSec: 0, sourceEndSec: 54 }); + }); + + it("is null when the pill already plays the whole file", () => { + expect(audioGhostExtent(0, 120, 120, 0, 120, 300)).toBeNull(); + }); + + it("is null while the duration is unknown", () => { + expect(audioGhostExtent(4, 10, null, 10, 20, 300)).toBeNull(); + }); +}); diff --git a/src/lib/ai-edition/timeline/audio-placement.ts b/src/lib/ai-edition/timeline/audio-placement.ts index 539f21aef..91e1d7eca 100644 --- a/src/lib/ai-edition/timeline/audio-placement.ts +++ b/src/lib/ai-edition/timeline/audio-placement.ts @@ -28,6 +28,56 @@ import { hasCompleteClipAnchor, } from "./timelineMap"; +/** + * Where an audio pill's own content stops: the LEFT edge cannot move before the point + * where the in-point would reach the file's start, the RIGHT edge not past where the + * played window would run off the file's end. Resizing is a crop, and a crop cannot + * crop past the tape — before these bounds the edges stretched into implicit silence + * (and a left-edge overrun silently extended the tail, since the out-point is derived + * as `offset + span`). Null while the duration is unknown: a failed probe must not + * freeze the pill. + */ +export function audioContentBounds( + offsetSec: number, + spanSec: number, + durationSec: number | null | undefined, + pillStartT: number, + pillEndT: number, +): { minStartT: number; maxEndT: number } | null { + if (durationSec == null || !(durationSec > 0)) return null; + const minStartT = Math.max(0, pillStartT - Math.max(0, offsetSec)); + const maxEndT = pillEndT + Math.max(0, durationSec - (offsetSec + spanSec)); + return { minStartT, maxEndT }; +} + +/** + * The file's extent around an audio pill, as the lane can draw it: where the content + * still available on each side sits on the timeline ([startT, endT], clipped to the + * timeline's own bounds), and which source window that stretch shows. This is the + * "rest of the tape" a resize reveals — the pill is a window, this is what is behind + * its edges. Null when the duration is unknown or the pill already spans the whole + * file. + */ +export function audioGhostExtent( + offsetSec: number, + spanSec: number, + durationSec: number | null | undefined, + pillStartT: number, + pillEndT: number, + totalT: number, +): { startT: number; endT: number; sourceStartSec: number; sourceEndSec: number } | null { + if (durationSec == null || !(durationSec > 0)) return null; + const startT = Math.max(0, pillStartT - Math.max(0, offsetSec)); + const endT = Math.min(totalT, pillEndT + Math.max(0, durationSec - (offsetSec + spanSec))); + if (endT - startT <= pillEndT - pillStartT + 1e-6) return null; + return { + startT, + endT, + sourceStartSec: offsetSec - (pillStartT - startT), + sourceEndSec: offsetSec + (endT - pillStartT), + }; +} + /** One fragment of one audio pill, resolved onto the output programme. */ export interface AudioPlacement { /** The pill the fragment belongs to — the id the ruler, the inspector and the agent