diff --git a/src/components/ai-edition/EditClipModal.test.tsx b/src/components/ai-edition/EditClipModal.test.tsx new file mode 100644 index 000000000..eb12ad059 --- /dev/null +++ b/src/components/ai-edition/EditClipModal.test.tsx @@ -0,0 +1,145 @@ +// @vitest-environment jsdom +import "@testing-library/jest-dom"; +import { act, cleanup, fireEvent, render, screen } from "@testing-library/react"; +import type { ReactElement } from "react"; +import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { I18nProvider } from "@/contexts/I18nContext"; +import type { AxcutClip } from "@/lib/ai-edition/schema"; +import { EditClipModal } from "./Modals"; + +function renderWithI18n(ui: ReactElement) { + return render({ui}); +} + +/** Issue #558's example: original 2:35, keep 0:20–1:45, final 1:25. */ +const CLIP: AxcutClip = { + id: "clip_1", + assetId: "asset_1", + sourceStartSec: 20, + sourceEndSec: 105, + timelineStartSec: 0, + timelineEndSec: 85, + wordRefs: [], + origin: "user", + reason: "", +}; + +const ASSET = { label: "rec", durationSec: 155 }; + +beforeAll(() => { + // The trim-handle drag converts pointer delta against the track width into + // seconds. jsdom reports 0, which would make every drag a no-op. + Object.defineProperty(HTMLElement.prototype, "clientWidth", { + configurable: true, + get() { + return this.getAttribute?.("data-testid") === "edit-clip-trim-track" ? 1550 : 0; + }, + }); +}); + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +function renderModal(clip: AxcutClip = CLIP) { + return renderWithI18n( + , + ); +} + +describe("EditClipModal trim duration readout (#558)", () => { + it("shows original duration, trim range, and final duration for the selected range", () => { + renderModal(); + + expect(screen.getByTestId("edit-clip-original-duration")).toHaveTextContent("2:35.0"); + expect(screen.getByTestId("edit-clip-original-duration")).toHaveTextContent( + "Original duration", + ); + expect(screen.getByTestId("edit-clip-trim-range")).toHaveTextContent("0:20.0–1:45.0"); + expect(screen.getByTestId("edit-clip-trim-range")).toHaveTextContent("Trim range"); + expect(screen.getByTestId("edit-clip-final-duration")).toHaveTextContent("1:25.0"); + expect(screen.getByTestId("edit-clip-final-duration")).toHaveTextContent("Final duration"); + }); + + it("updates the final duration as the start handle is dragged", () => { + renderModal(); + + fireEvent.pointerDown(screen.getByRole("button", { name: "Adjust clip start" }), { + clientX: 0, + }); + act(() => { + window.dispatchEvent(new MouseEvent("pointermove", { clientX: 100 })); + }); + + expect(screen.getByTestId("edit-clip-original-duration")).toHaveTextContent("2:35.0"); + expect(screen.getByTestId("edit-clip-trim-range")).toHaveTextContent("0:30.0–1:45.0"); + expect(screen.getByTestId("edit-clip-final-duration")).toHaveTextContent("1:15.0"); + }); + + it("will not pass the out-point off as the source length", () => { + // `durationSec` is optional in the asset schema, so a document can reach + // this dialog without one. The track still has to be drawn against + // something that contains the selection (the out-point), but calling that + // the original duration would claim a 2:35 source was 1:45 long. + renderWithI18n( + , + ); + + expect(screen.getByTestId("edit-clip-original-duration")).toHaveTextContent("—"); + expect(screen.getByTestId("edit-clip-original-duration")).not.toHaveTextContent("1:45.0"); + // The kept range and its length are still known, and still shown. + expect(screen.getByTestId("edit-clip-trim-range")).toHaveTextContent("0:20.0–1:45.0"); + expect(screen.getByTestId("edit-clip-final-duration")).toHaveTextContent("1:25.0"); + }); + + it("states the kept range once, in the stats row", () => { + renderModal(); + + // The range used to be printed a second time inside the selection bar, 40px + // under the stat that now carries it. One reading of a number is enough. + expect(screen.getAllByText("0:20.0–1:45.0")).toHaveLength(1); + }); + + it("keeps the discarded head and tail out of the pointer's way", () => { + const { container } = renderModal(); + + // The dimmed tail is painted after the selection, so it covers the end + // handle's 6px overhang and, once the range is narrower than the handle, + // the handle itself. jsdom does not hit-test, so this pins the property + // rather than the grab; the grab is checked by driving the real window. + const dimmed = [...container.querySelectorAll("div")].filter( + (el) => el.style.background === "var(--overlay-dark)", + ); + expect(dimmed).toHaveLength(2); + for (const el of dimmed) expect(el.style.pointerEvents).toBe("none"); + }); + + it("updates the final duration as the end handle is dragged", () => { + renderModal(); + + fireEvent.pointerDown(screen.getByRole("button", { name: "Adjust clip end" }), { + clientX: 0, + }); + act(() => { + window.dispatchEvent(new MouseEvent("pointermove", { clientX: -50 })); + }); + + expect(screen.getByTestId("edit-clip-trim-range")).toHaveTextContent("0:20.0–1:40.0"); + expect(screen.getByTestId("edit-clip-final-duration")).toHaveTextContent("1:20.0"); + }); +}); diff --git a/src/components/ai-edition/Modals.tsx b/src/components/ai-edition/Modals.tsx index f74c68a78..f24500224 100644 --- a/src/components/ai-edition/Modals.tsx +++ b/src/components/ai-edition/Modals.tsx @@ -771,7 +771,22 @@ export function EditClipModal({ if (!clip) return null; - const sourceDurationSec = Math.max(assetMeta?.durationSec ?? 0, clip.sourceEndSec ?? 0, 0.001); + // The asset's own length, or null when the document never carried one + // (`durationSec` is optional in the schema, and an unprobed import has none). + // Only this may be shown as the original duration. + const assetDurationSec = + assetMeta?.durationSec && assetMeta.durationSec > 0 ? assetMeta.durationSec : null; + // What the track is drawn against. It has to hold the selection whatever the + // metadata says, so it falls back to the out-point — which is why it cannot + // double as the original-duration readout: with no asset duration it would + // report the current trim end as the source length. + const sourceDurationSec = Math.max(assetDurationSec ?? 0, clip.sourceEndSec ?? 0, 0.001); + // What the trim keeps, on the raw ruler — the same clock the timeline, the + // transport readout and the clip cards all run on. A speed region does change + // how long that span PLAYS (`outputDurationOfRawSpan` integrates 1/speed for + // the export and audio paths), but nothing in the editor's own chrome reports + // playback time, so scaling it here alone would disagree with the ruler + // directly above this dialog. const durationSec = Math.max(0.001, draftEnd - draftStart); const hasTrimChanges = Math.abs(draftStart - clip.sourceStartSec) > 0.001 || @@ -1090,10 +1105,26 @@ export function EditClipModal({
-
- - - +
+ + +
+ {/* Dimmed, discarded head. Decoration only — see the tail below. */}
+ {/* Dimmed, discarded tail. It is painted after the selection, so it sits + ABOVE the end handle that overhangs the selection's right edge by 6px: + without pointer-events:none it swallows the grab as soon as the range is + narrower than the handle, and a range dragged down to the 0.05s minimum + can then only be recovered with Reset. */}
@@ -1331,9 +1358,9 @@ export function EditClipModal({ ); } -function RangeStat({ label, value }: { label: string; value: string }) { +function RangeStat({ label, value, testId }: { label: string; value: string; testId?: string }) { return ( -
+
{value} {label} diff --git a/src/components/ai-edition/v4/EditorShellV4.module.css b/src/components/ai-edition/v4/EditorShellV4.module.css index 787d42292..4b456274d 100644 --- a/src/components/ai-edition/v4/EditorShellV4.module.css +++ b/src/components/ai-edition/v4/EditorShellV4.module.css @@ -1971,6 +1971,12 @@ overflow: hidden; text-overflow: ellipsis; } +.tlClipDuration { + font: 500 10px/1.2 var(--font-mono); + color: rgba(255, 255, 255, 0.7); + white-space: nowrap; + flex-shrink: 0; +} .tlClipDelete { position: absolute; right: 8px; diff --git a/src/components/ai-edition/v4/V4Timeline.geometry.test.tsx b/src/components/ai-edition/v4/V4Timeline.geometry.test.tsx index 5119eedae..70257043b 100644 --- a/src/components/ai-edition/v4/V4Timeline.geometry.test.tsx +++ b/src/components/ai-edition/v4/V4Timeline.geometry.test.tsx @@ -388,6 +388,39 @@ describe("V4Timeline clip row", () => { expect(pill.style.left).toBe(clipEls[1].style.left); }); + it("shows each clip's edited duration on the card", () => { + renderTimeline(CLIPS); + // 600s / 300s / 900s of an 1800s source: each card reads the clip's own + // length on the timeline (out − in), not the asset's original length. A + // speed region over the clip changes how long it plays, not this number. + expect(screen.getByText("10:00.0")).toBeInTheDocument(); + expect(screen.getByText("5:00.0")).toBeInTheDocument(); + expect(screen.getByText("15:00.0")).toBeInTheDocument(); + }); + + it("withholds the duration from a card too small to hold it", () => { + // 250s at this zoom is a 125px card: past the narrow gate, so it still shows + // its name and pencil, but not wide enough for the timecode — which would + // otherwise escape the label pill and sit on the delete button. Measured in + // the running window, not derived here. + renderTimeline([clip(0, 250), clip(250, TOTAL_SEC)]); + + expect(screen.queryByText("4:10.0")).not.toBeInTheDocument(); + // The card that does have the room still reads its length. + expect(screen.getByText("25:50.0")).toBeInTheDocument(); + }); + + it("asks for the room this card's own timecode needs, not the shortest one", () => { + // 600s of 3965s is a ~130px card. `0:12.0` would fit there; `10:00.0` is a + // character wider and does not, and `formatSec` has no hour field to stop + // the string growing — a clip past a hundred minutes reads `100:00.0`. A + // single fixed width would have let those through onto the delete button. + renderTimeline([clip(0, 600), clip(600, 3965)]); + + expect(screen.queryByText("10:00.0")).not.toBeInTheDocument(); + expect(screen.getByText("56:05.0")).toBeInTheDocument(); + }); + it("takes the card gutter out of each clip's own width", () => { // The 6px is what separates two cards. Taken off the clip's width it stays // local to that clip; inserted between them (a flex gap) it displaced every diff --git a/src/components/ai-edition/v4/V4Timeline.tsx b/src/components/ai-edition/v4/V4Timeline.tsx index cd2edb07f..fed94e408 100644 --- a/src/components/ai-edition/v4/V4Timeline.tsx +++ b/src/components/ai-edition/v4/V4Timeline.tsx @@ -151,6 +151,22 @@ const PILL_SNAP_PX = 8; * clips that follow — which is what a flex `gap` did, once per junction. */ /** Below this a clip cannot show a label and a delete button inside itself. */ const NARROW_CLIP_PX = 120; +// Whether a card can also carry its edited duration. The label pill is capped at +// `calc(100% - 50px)` so it clears the delete button, and everything inside it +// but the name is incompressible: 15px of padding, the 16px pencil, two 8px +// gaps, and the timecode. The timecode is the part that varies — `formatSec` +// never prints an hour field, so a clip past ten minutes reads `16:40.0` and one +// past a hundred `100:00.0` — so the room is measured against THIS card's own +// text rather than a single number that only ever fitted the short form. +// Measured in the running window: 6.0px per character at 10px in the mono face, +// and a 6-character code overlapping the delete button at a 121px card, clear at +// 131px. +const CLIP_LABEL_RESERVE_PX = 50; +const CLIP_LABEL_FIXED_PX = 47; +const CLIP_LABEL_CHAR_PX = 6; +function cardFitsDuration(cardPx: number, text: string): boolean { + return cardPx >= CLIP_LABEL_RESERVE_PX + CLIP_LABEL_FIXED_PX + text.length * CLIP_LABEL_CHAR_PX; +} const CLIP_GUTTER_PX = 6; /** @@ -2173,6 +2189,9 @@ export function V4Timeline({ // there is no arrangement that fits a button inside that — so while // it is selected the controls step outside the box instead. const narrow = boxLen * pxPerSec < NARROW_CLIP_PX; + // The gutter is taken out of the card's own width below, so the + // room the label actually has is that much less than the span. + const durText = formatSec(dur); return (
{tl.assets.find((a) => a.id === c.assetId)?.label ?? c.assetId} + {cardFitsDuration(boxLen * pxPerSec - CLIP_GUTTER_PX, durText) ? ( + {durText} + ) : null}
{selected ? (