From daf98201f58f906bc250b85fce9e143cde5a7947 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B6=85=E8=B6=85=E8=B6=85=E8=B6=85=E8=B6=85=E7=BA=A7?= =?UTF-8?q?=E5=96=9C=E6=AC=A2=E4=BD=A0=E7=9A=84=E8=BE=BE=E5=A6=AE=E5=A8=85?= <176143450+My-Denia@users.noreply.github.com> Date: Thu, 10 Sep 2026 22:28:49 +0800 Subject: [PATCH 1/2] feat(editor): show what a trim keeps, while the handles are still moving MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Edit clip dialog printed Start / End / Duration and then repeated that same range a second time inside the selection bar. Nothing said how long the source was, so there was no figure to read the kept length against, and the kept length itself was gone the moment the dialog closed. The stats row is now Original duration / Trim range / Final duration, and each clip card carries the length that clip contributes to the film. Original duration is the asset's own length and reads an em dash when the document carries none: the track's scale falls back to the out-point so it can always hold the selection, and that fallback must never be shown as the source length — a 0:20-1:45 selection would have claimed a 1:45 source. Two things the running window turned up: The dimmed head and tail are painted after the selection, so the tail sat above the 6px by which the end handle overhangs it. As soon as the range was narrower than the handle, the tail swallowed the grab: a range dragged down to the 0.05s minimum could then only be recovered with Reset. Both are decoration and now take no pointer events. The card timecode does not shrink, and the label pill is capped at calc(100% - 50px) to clear the delete button. Between the 120px narrow gate and about 131px the timecode escaped the pill and came to rest on that button, so a card only carries it from 132px up. Every duration here is a raw-ruler length, the clock the timeline, the transport readout and the cards already share. A speed region changes how long a span plays, not how long it is, and only the export and audio paths integrate that; scaling this one number alone would put it at odds with the ruler above it. --- .../ai-edition/EditClipModal.test.tsx | 145 ++++++++++++++++++ src/components/ai-edition/Modals.tsx | 67 +++++--- .../ai-edition/v4/EditorShellV4.module.css | 6 + .../v4/V4Timeline.geometry.test.tsx | 22 +++ src/components/ai-edition/v4/V4Timeline.tsx | 9 ++ src/i18n/locales/ar/editor.json | 6 +- src/i18n/locales/en/editor.json | 6 +- src/i18n/locales/es/editor.json | 6 +- src/i18n/locales/fr/editor.json | 6 +- src/i18n/locales/it/editor.json | 6 +- src/i18n/locales/ja-JP/editor.json | 6 +- src/i18n/locales/ko-KR/editor.json | 6 +- src/i18n/locales/pt-BR/editor.json | 6 +- src/i18n/locales/ru/editor.json | 6 +- src/i18n/locales/tr/editor.json | 6 +- src/i18n/locales/vi/editor.json | 6 +- src/i18n/locales/zh-CN/editor.json | 6 +- src/i18n/locales/zh-TW/editor.json | 6 +- 18 files changed, 268 insertions(+), 59 deletions(-) create mode 100644 src/components/ai-edition/EditClipModal.test.tsx 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..fc577185c 100644 --- a/src/components/ai-edition/v4/V4Timeline.geometry.test.tsx +++ b/src/components/ai-edition/v4/V4Timeline.geometry.test.tsx @@ -388,6 +388,28 @@ 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("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..1052aa1fc 100644 --- a/src/components/ai-edition/v4/V4Timeline.tsx +++ b/src/components/ai-edition/v4/V4Timeline.tsx @@ -151,6 +151,12 @@ 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; +// A card wide enough to also carry its edited duration. The label pill is capped +// at `calc(100% - 50px)` to clear the delete button, and its incompressible +// content — padding, the pencil, two gaps and the timecode — is ~76px, so below +// this the timecode escapes the pill and lands on that button. Measured in the +// running window: overlapping at 121px, clean from 131px. +const CLIP_DURATION_PX = 132; const CLIP_GUTTER_PX = 6; /** @@ -2232,6 +2238,9 @@ export function V4Timeline({ {tl.assets.find((a) => a.id === c.assetId)?.label ?? c.assetId} + {boxLen * pxPerSec >= CLIP_DURATION_PX ? ( + {formatSec(dur)} + ) : null}
{selected ? (