Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
145 changes: 145 additions & 0 deletions src/components/ai-edition/EditClipModal.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<I18nProvider>{ui}</I18nProvider>);
}

/** 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(
<EditClipModal
open
onClose={vi.fn()}
clip={clip}
assetMeta={ASSET}
videoSources={[]}
onApply={vi.fn()}
/>,
);
}

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(
<EditClipModal
open
onClose={vi.fn()}
clip={CLIP}
assetMeta={{ label: "rec" }}
videoSources={[]}
onApply={vi.fn()}
/>,
);

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<HTMLElement>("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");
});
});
67 changes: 47 additions & 20 deletions src/components/ai-edition/Modals.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 ||
Expand Down Expand Up @@ -1090,10 +1105,26 @@ export function EditClipModal({
</div>

<div style={{ flexShrink: 0 }}>
<div style={{ display: "flex", gap: 24, marginBottom: 10 }}>
<RangeStat label={t("editClipDialog.start")} value={formatSeconds(draftStart)} />
<RangeStat label={t("editClipDialog.end")} value={formatSeconds(draftEnd)} />
<RangeStat label={t("editClipDialog.duration")} value={formatSeconds(durationSec)} />
<div
style={{ display: "flex", gap: 24, marginBottom: 10 }}
aria-live="polite"
aria-atomic="true"
>
<RangeStat
label={t("editClipDialog.originalDuration")}
value={assetDurationSec === null ? "—" : formatSeconds(assetDurationSec)}
testId="edit-clip-original-duration"
/>
<RangeStat
label={t("editClipDialog.trimRange")}
value={`${formatSeconds(draftStart)}–${formatSeconds(draftEnd)}`}
testId="edit-clip-trim-range"
/>
<RangeStat
label={t("editClipDialog.duration")}
value={formatSeconds(durationSec)}
testId="edit-clip-final-duration"
/>
</div>

<div
Expand All @@ -1110,6 +1141,7 @@ export function EditClipModal({
</div>
<div
ref={trackRef}
data-testid="edit-clip-trim-track"
style={{
position: "relative",
height: 32,
Expand All @@ -1118,13 +1150,15 @@ export function EditClipModal({
borderRadius: "var(--r-sm)",
}}
>
{/* Dimmed, discarded head. Decoration only — see the tail below. */}
<div
style={{
position: "absolute",
inset: 0,
width: `${(draftStart / sourceDurationSec) * 100}%`,
background: "var(--overlay-dark)",
borderRadius: "var(--r-sm) 0 0 var(--r-sm)",
pointerEvents: "none",
}}
/>
<div
Expand All @@ -1138,9 +1172,6 @@ export function EditClipModal({
background: "var(--accent-wash)",
border: "1px solid var(--accent)",
borderRadius: "var(--r-sm)",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<button
Expand All @@ -1161,16 +1192,6 @@ export function EditClipModal({
padding: 0,
}}
/>
<span
style={{
font: "500 11px/1.4 var(--font-mono)",
color: "var(--accent-on)",
pointerEvents: "none",
whiteSpace: "nowrap",
}}
>
{formatSeconds(draftStart)}–{formatSeconds(draftEnd)}
</span>
<button
type="button"
onPointerDown={(e) => startDrag("end", e)}
Expand All @@ -1190,6 +1211,11 @@ export function EditClipModal({
}}
/>
</div>
{/* 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. */}
<div
style={{
position: "absolute",
Expand All @@ -1199,6 +1225,7 @@ export function EditClipModal({
width: `${Math.max(0, ((sourceDurationSec - draftEnd) / sourceDurationSec) * 100)}%`,
background: "var(--overlay-dark)",
borderRadius: "0 var(--r-sm) var(--r-sm) 0",
pointerEvents: "none",
}}
/>
</div>
Expand Down Expand Up @@ -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 (
<div style={{ display: "flex", flexDirection: "column", gap: 2 }}>
<div data-testid={testId} style={{ display: "flex", flexDirection: "column", gap: 2 }}>
<strong style={{ font: "600 15px/1.2 var(--font-mono)", color: "var(--fg)" }}>{value}</strong>
<small style={{ font: "500 10px/1.4 var(--font-body)", color: "var(--muted)" }}>
{label}
Expand Down
6 changes: 6 additions & 0 deletions src/components/ai-edition/v4/EditorShellV4.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
59 changes: 59 additions & 0 deletions src/components/ai-edition/v4/V4Timeline.geometry.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,13 @@ vi.mock("sonner", () => ({ toast: { error: vi.fn(), info: vi.fn(), success: vi.f
// The audio lane's pill renders a ClipWaveform; no decode in this geometry suite.
vi.mock("@/hooks/useAudioPeaks", () => ({ useAudioPeaks: () => null }));

// The duration gate reads its timecode's width off a canvas context. jsdom has
// no canvas, so every test here runs against this stub: 6px a character, the
// same figure the component's no-canvas fallback assumes, which keeps the width
// arithmetic in the tests below the one the first cut reasoned in. The
// measured-path test re-aims it at a wider face and expects the gate to follow.
const measureText = vi.fn((text: string) => ({ width: text.length * 6 }));

import { ShortcutsProvider } from "@/contexts/ShortcutsContext";
import type { useTimeline } from "@/lib/ai-edition/store/useTimeline";
import { DEFAULT_SHORTCUTS, formatBinding } from "@/lib/shortcuts";
Expand Down Expand Up @@ -55,6 +62,13 @@ beforeAll(() => {
},
}),
});
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockImplementation(
() => ({ measureText }) as unknown as CanvasRenderingContext2D,
);
});

afterEach(() => {
measureText.mockImplementation((text: string) => ({ width: text.length * 6 }));
});

function clip(startSec: number, endSec: number) {
Expand Down Expand Up @@ -461,6 +475,51 @@ 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("measures the timecode where a canvas exists, rather than averaging its length", () => {
// The stubbed face costs 9px a character against the 6px the jsdom fallback
// assumes. A 700s clip of this 3965s span is a ~159px card — roomy enough
// by the count (50 + 47 + 7×6 = 139) and too tight once the face is read
// (50 + 47 + 7×9 = 160) — so only a measured gate withholds it.
measureText.mockImplementation((text: string) => ({ width: text.length * 9 }));
renderTimeline([clip(0, 700), clip(700, 3965)]);

expect(screen.queryByText("11:40.0")).not.toBeInTheDocument();
expect(screen.getByText("54:25.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
Expand Down
Loading
Loading