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. */}
{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 (