From c7d873ea3e3b1a0066d91b35443cb94e809474dd Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 22:53:34 +0000 Subject: [PATCH] Precise clip times: HH:MM:SS:mmm fields, nudges, and re-trimming MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - In and out points are typed as HH:MM:SS:mmm, with ±1s / ±10ms nudge buttons, set-to-playhead and play-from buttons beside each. - A live clip can be re-trimmed after it is saved, from its parent's Clip tab ("Edit times" on the row) or from the clip's own new Trim tab. - New PUT /api/assets/{clip_id}/range validates against the parent the same way creating a clip does, refuses while a promote is running, and renames a default "(m:ss–m:ss)" name to the new range. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01V1geD6GoCf1ZjN16BxcEu2 --- backend/app/routers/clips.py | 45 ++++ backend/app/services/assets.py | 37 ++- backend/tests/test_clips_api.py | 101 ++++++++ frontend/src/api/clips.ts | 11 + frontend/src/components/AssetDetail.tsx | 38 ++- frontend/src/components/ClipEditor.test.tsx | 45 ++++ frontend/src/components/ClipEditor.tsx | 265 ++++++++++++-------- frontend/src/components/ClipRangeForm.tsx | 126 ++++++++++ frontend/src/components/TimecodeInput.tsx | 145 +++++++++++ frontend/src/stores/library.ts | 18 ++ frontend/src/utils/format.test.ts | 40 ++- frontend/src/utils/format.ts | 50 ++++ frontend/src/views/AssetView.test.tsx | 38 ++- 13 files changed, 840 insertions(+), 119 deletions(-) create mode 100644 frontend/src/components/ClipRangeForm.tsx create mode 100644 frontend/src/components/TimecodeInput.tsx diff --git a/backend/app/routers/clips.py b/backend/app/routers/clips.py index a642fa5..7e57adc 100644 --- a/backend/app/routers/clips.py +++ b/backend/app/routers/clips.py @@ -57,6 +57,11 @@ class ClipCreate(BaseModel): name: Optional[str] = Field(default=None, min_length=1, max_length=255) +class ClipRangeUpdate(BaseModel): + in_point: float = Field(ge=0) + out_point: float = Field(gt=0) + + class SubvideoCreate(BaseModel): in_point: float = Field(ge=0) out_point: float = Field(gt=0) @@ -120,6 +125,46 @@ def list_clips( ) +@router.put("/{clip_id}/range", response_model=DataResponse[AssetRead]) +def set_clip_range( + clip_id: str, + payload: ClipRangeUpdate, + user: CurrentUser, + session: Session = Depends(get_session), + storage: LocalStorage = Depends(get_storage), +) -> DataResponse[AssetRead]: + """Re-trim a live clip, from its parent's Clip tab or from the clip's own page. + + Its own route rather than fields on `PATCH /assets/{id}`: the range is validated + against the parent, means nothing on anything but a live clip, and a name derived + from it has to follow — none of which the generic metadata edit knows about. + """ + clip = _owned_asset(clip_id, user.id, session) + parent = session.get(Asset, clip.parent_asset_id) if clip.parent_asset_id else None + if clip.storage_key is not None or parent is None: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={"code": "not_a_clip", "message": "This asset is not a clip"}, + ) + # A promote reads the range when it runs, so moving it underneath one would cut a + # file that matches neither the old range nor the new one the user is looking at. + _require_no_active_extraction(session, clip.id) + try: + clip = asset_service.set_clip_range( + session, clip, parent, in_point=payload.in_point, out_point=payload.out_point + ) + except ValueError as exc: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={"code": "invalid_clip_range", "message": str(exc)}, + ) from exc + return DataResponse( + data=asset_service.to_read_model( + clip, storage, tag_service.tags_for(session, clip.id), parent=parent + ) + ) + + # ─── sub-video extraction ─────────────────────────────────────────────────────── diff --git a/backend/app/services/assets.py b/backend/app/services/assets.py index e7e0b8d..94bdda2 100644 --- a/backend/app/services/assets.py +++ b/backend/app/services/assets.py @@ -681,6 +681,13 @@ def _format_range(start: float, end: float) -> str: return f"{_format_timestamp(start)}–{_format_timestamp(end)}" +def _check_range(parent: Asset, in_point: float, out_point: float) -> None: + if in_point < 0 or out_point <= in_point: + raise ValueError("The out point must be after the in point") + if parent.duration_seconds is not None and out_point > parent.duration_seconds: + raise ValueError("The out point is past the end of the asset") + + def create_clip( session: Session, parent: Asset, @@ -703,10 +710,7 @@ def create_clip( raise ValueError("Only video and audio assets can be clipped") if parent.storage_key is None: raise ValueError("A clip cannot itself be clipped — clip the original asset") - if in_point < 0 or out_point <= in_point: - raise ValueError("The out point must be after the in point") - if parent.duration_seconds is not None and out_point > parent.duration_seconds: - raise ValueError("The out point is past the end of the asset") + _check_range(parent, in_point, out_point) clip = Asset( user_id=parent.user_id, @@ -736,6 +740,31 @@ def create_clip( return clip +def set_clip_range( + session: Session, clip: Asset, parent: Asset, *, in_point: float, out_point: float +) -> Asset: + """Move a live clip's in and out points, against the same bounds `create_clip` uses. + + Only a live clip: once promoted, the range is baked into a file of its own and + changing these columns would describe bytes that do not exist. The default name + carries the range it was cut at, so that suffix follows the new range; a name the + user chose (anything not ending in the old range) is theirs and is left alone. + """ + _check_range(parent, in_point, out_point) + old_suffix = f"({_format_range(clip.in_point or 0.0, clip.out_point or 0.0)})" + if clip.name.endswith(old_suffix): + clip.name = clip.name[: -len(old_suffix)] + f"({_format_range(in_point, out_point)})" + clip.in_point = in_point + clip.out_point = out_point + clip.duration_seconds = out_point - in_point + clip.metadata_modified_date = utcnow() + session.add(clip) + session.commit() + session.refresh(clip) + _reindex(session, clip) + return clip + + def list_children(session: Session, parent_id: str, user_id: str) -> list[Asset]: """Everything derived from this asset: live clips and past promotions/extractions. diff --git a/backend/tests/test_clips_api.py b/backend/tests/test_clips_api.py index 3a9ac8c..ea52eeb 100644 --- a/backend/tests/test_clips_api.py +++ b/backend/tests/test_clips_api.py @@ -282,6 +282,107 @@ def test_list_clips_of_someone_elses_asset_is_a_404(library, session): assert library.get(f"/api/assets/{other.id}/clips").status_code == 404 +# ─── re-trimming a clip ──────────────────────────────────────────────────────── + + +def _with_duration(session, asset_id: str, seconds: float) -> None: + row = session.get(Asset, asset_id) + row.duration_seconds = seconds + session.add(row) + session.commit() + + +def test_set_range_moves_a_clips_bounds(library, session): + parent = _upload_video(library) + _with_duration(session, parent["id"], 120.0) + clip = _clip(library, parent["id"], 1.0, 2.0) + + response = library.put( + f"/api/assets/{clip['id']}/range", json={"in_point": 61.25, "out_point": 70.5} + ) + assert response.status_code == 200 + data = response.json()["data"] + assert data["in_point"] == 61.25 + assert data["out_point"] == 70.5 + assert data["duration_seconds"] == pytest.approx(9.25) + # Still a window into the parent, and still listed under it. + assert data["source"] == "clip" + listed = library.get(f"/api/assets/{parent['id']}/clips").json()["data"] + assert [c["in_point"] for c in listed] == [61.25] + + +def test_set_range_renames_a_default_name(library, session): + parent = _upload_video(library) + _with_duration(session, parent["id"], 120.0) + clip = _clip(library, parent["id"], 1.0, 2.0) + + data = library.put( + f"/api/assets/{clip['id']}/range", json={"in_point": 61.0, "out_point": 70.0} + ).json()["data"] + assert data["name"].endswith("(1:01–1:10)") + assert "0:01" not in data["name"] + + +def test_set_range_keeps_a_chosen_name(library): + parent = _upload_video(library) + clip = _clip(library, parent["id"], name="The nano weapons moment") + + data = library.put( + f"/api/assets/{clip['id']}/range", json={"in_point": 0.1, "out_point": 0.4} + ).json()["data"] + assert data["name"] == "The nano weapons moment" + + +def test_set_range_refuses_a_backwards_range(library): + parent = _upload_video(library) + clip = _clip(library, parent["id"]) + response = library.put( + f"/api/assets/{clip['id']}/range", json={"in_point": 0.4, "out_point": 0.2} + ) + assert response.status_code == 400 + assert response.json()["detail"]["code"] == "invalid_clip_range" + + +def test_set_range_refuses_past_the_parents_duration(library, session): + parent = _upload_video(library) + _with_duration(session, parent["id"], 2.0) + clip = _clip(library, parent["id"]) + response = library.put( + f"/api/assets/{clip['id']}/range", json={"in_point": 0.0, "out_point": 5.0} + ) + assert response.status_code == 400 + + +def test_set_range_refuses_a_non_clip(library): + asset = _upload_video(library) + response = library.put( + f"/api/assets/{asset['id']}/range", json={"in_point": 0.0, "out_point": 0.5} + ) + assert response.status_code == 400 + assert response.json()["detail"]["code"] == "not_a_clip" + + +def test_set_range_is_refused_while_the_clip_is_being_promoted(library): + parent = _upload_video(library) + clip = _clip(library, parent["id"]) + library.post(f"/api/assets/{clip['id']}/promote") + + response = library.put( + f"/api/assets/{clip['id']}/range", json={"in_point": 0.1, "out_point": 0.4} + ) + assert response.status_code == 409 + + +def test_set_range_of_someone_elses_clip_is_a_404(library, session): + other = Asset(user_id="somebody-else", name="Theirs", asset_type="video", source="clip") + session.add(other) + session.commit() + response = library.put( + f"/api/assets/{other.id}/range", json={"in_point": 0.0, "out_point": 0.5} + ) + assert response.status_code == 404 + + # ─── starting a sub-video extraction (queues a job) ─────────────────────────── diff --git a/frontend/src/api/clips.ts b/frontend/src/api/clips.ts index a05f39e..10c3d47 100644 --- a/frontend/src/api/clips.ts +++ b/frontend/src/api/clips.ts @@ -41,6 +41,17 @@ export const clipsApi = { .then((r) => r.data.data) }, + /** Re-trims a live clip — from its parent's Clip tab or its own Trim tab. A default + * name carrying the old range is renamed to the new one; a chosen name is kept. */ + setRange( + clipId: string, + range: { in_point: number; out_point: number } + ): Promise { + return client + .put>(`/assets/${clipId}/range`, range) + .then((r) => r.data.data) + }, + /** Queues a fresh, standalone extraction. The source asset is untouched; the new * asset's id lands on the finished job as `result_asset_id`. */ extractSubvideo(assetId: string, range: ClipRange): Promise { diff --git a/frontend/src/components/AssetDetail.tsx b/frontend/src/components/AssetDetail.tsx index 66c4e29..7fcf6f9 100644 --- a/frontend/src/components/AssetDetail.tsx +++ b/frontend/src/components/AssetDetail.tsx @@ -25,11 +25,18 @@ import { formatCost, usageApi, type UsageTotals } from '@/api/usage' import { apiErrorMessage } from '@/api/client' import { useLibraryStore } from '@/stores/library' import { useTagStore } from '@/stores/tags' -import { formatBytes, formatDate, formatDimensions, formatDuration } from '@/utils/format' +import { + formatBytes, + formatDate, + formatDimensions, + formatDuration, + formatTimecode, +} from '@/utils/format' import { useAutoGrow } from '@/utils/useAutoGrow' import AssetThumb from '@/components/AssetThumb' import AttributionPanel from '@/components/AttributionPanel' import ClipEditor from '@/components/ClipEditor' +import ClipRangeForm from '@/components/ClipRangeForm' import DocumentTextPanel from '@/components/DocumentTextPanel' import EmbedButton from '@/components/EmbedButton' import EnrichmentButton from '@/components/EnrichmentButton' @@ -513,7 +520,7 @@ export default function AssetDetail({ {asset.in_point !== null && asset.out_point !== null && ( )} @@ -664,6 +671,33 @@ export default function AssetDetail({ }, ] : []), + // The clip's own page can re-trim it, not only its parent's Clip tab. Same form, + // same player: a clip plays its parent's file, so the playhead is already in the + // parent's timeline, which is the one in/out points are measured in. The parent's + // length is not on a clip's row, so the player's own duration bounds the nudges. + ...(ownsNoFile(asset) && asset.in_point !== null + ? [ + { + id: 'trim', + label: 'Trim', + icon: Scissors, + content: ( +
+ +
+ ), + }, + ] + : []), ...(asset.asset_type === TEXT_TYPE ? [ { diff --git a/frontend/src/components/ClipEditor.test.tsx b/frontend/src/components/ClipEditor.test.tsx index aee4444..e46cb63 100644 --- a/frontend/src/components/ClipEditor.test.tsx +++ b/frontend/src/components/ClipEditor.test.tsx @@ -227,4 +227,49 @@ describe('ClipEditor, deleting a clip', () => { expect(await screen.findByText(/an extraction is still running/i)).toBeInTheDocument() expect(screen.getByText('Live clip')).toBeInTheDocument() }) + it('saves a clip from typed and nudged timecodes', async () => { + vi.spyOn(clipsApi, 'list').mockResolvedValue([]) + const create = vi + .spyOn(clipsApi, 'create') + .mockResolvedValue({ ...liveClip, id: 'c4', in_point: 1.5, out_point: 12.01 }) + renderEditor() + + await userEvent.type(await screen.findByLabelText('In point seconds'), '1') + await userEvent.type(screen.getByLabelText('In point milliseconds'), '500') + await userEvent.type(screen.getByLabelText('Out point seconds'), '11') + await userEvent.click(screen.getByRole('button', { name: 'Out point +1s' })) + await userEvent.click(screen.getByRole('button', { name: 'Out point +10ms' })) + await userEvent.click(screen.getByRole('button', { name: 'Save as clip' })) + + expect(create).toHaveBeenCalledWith('a1', { in_point: 1.5, out_point: 12.01 }) + }) + + it('stops a nudge at the end of the asset', async () => { + vi.spyOn(clipsApi, 'list').mockResolvedValue([]) + renderEditor() + + await userEvent.type(await screen.findByLabelText('Out point seconds'), '59') + await userEvent.click(screen.getByRole('button', { name: 'Out point +1s' })) + await userEvent.click(screen.getByRole('button', { name: 'Out point +1s' })) + expect(screen.getByLabelText('Out point minutes')).toHaveValue(1) + expect(screen.getByLabelText('Out point seconds')).toHaveValue(0) + }) + + it('re-trims an existing clip from its row, and the row shows the new times', async () => { + vi.spyOn(clipsApi, 'list').mockResolvedValue([liveClip]) + const setRange = vi + .spyOn(clipsApi, 'setRange') + .mockResolvedValue({ ...liveClip, in_point: 4.99, out_point: 9 }) + renderEditor() + + await userEvent.click( + await screen.findByRole('button', { name: "Edit this clip's times" }) + ) + // The new-clip fields are above; the row's form is the second pair. + await userEvent.click(screen.getAllByRole('button', { name: 'In point −10ms' })[1]) + await userEvent.click(screen.getByRole('button', { name: 'Save times' })) + + expect(setRange).toHaveBeenCalledWith(liveClip.id, { in_point: 4.99, out_point: 9 }) + expect(await screen.findByText('00:00:04:990 – 00:00:09:000')).toBeInTheDocument() + }) }) diff --git a/frontend/src/components/ClipEditor.tsx b/frontend/src/components/ClipEditor.tsx index 0a09df3..8330f5d 100644 --- a/frontend/src/components/ClipEditor.tsx +++ b/frontend/src/components/ClipEditor.tsx @@ -1,13 +1,15 @@ import { useCallback, useEffect, useState } from 'react' import { Link } from 'react-router-dom' -import { Film, Play, Scissors, Trash2 } from 'lucide-react' +import { Film, Pencil, Play, Scissors, Trash2 } from 'lucide-react' import { clipsApi } from '@/api/clips' import type { ActivityJob } from '@/api/transcripts' import { apiErrorMessage } from '@/api/client' import type { Asset } from '@/api/assets' import { useLibraryStore } from '@/stores/library' -import { formatDuration } from '@/utils/format' +import { formatTimecode } from '@/utils/format' +import ClipRangeForm from '@/components/ClipRangeForm' import EnrichmentButton from '@/components/EnrichmentButton' +import TimecodeInput from '@/components/TimecodeInput' /** * Cutting a clip or a sub-video from the asset open in the player above. @@ -18,9 +20,10 @@ import EnrichmentButton from '@/components/EnrichmentButton' * hides this tab for a clip, since M7 does not support clipping a clip. * * Each row's name opens that clip's own page. A live-clip row can also be played here, - * promoted to a real file (`promote`), or deleted. Deleting a clip never touches the - * file it was cut from, nor the poster it shares with it — the server only unlinks a - * key no surviving row still points at. An extracted sub-video gets no delete here: it + * re-trimmed (the same form the clip's own Trim tab shows), promoted to a real file + * (`promote`), or deleted. Deleting a clip never touches the file it was cut from, nor + * the poster it shares with it — the server only unlinks a key no surviving row still + * points at. An extracted sub-video gets no delete here: it * owns real bytes, and that goes through its own page's confirm. */ @@ -43,6 +46,8 @@ export default function ClipEditor({ asset, currentTime, onSeek }: Props) { // One row at a time: asking about a second clip abandons the first question. const [confirmingId, setConfirmingId] = useState(null) const [deletingId, setDeletingId] = useState(null) + // One row's times open at a time, for the same reason as `confirmingId`. + const [editingId, setEditingId] = useState(null) const openById = useLibraryStore((s) => s.openById) const remove = useLibraryStore((s) => s.remove) const prepend = useLibraryStore((s) => s.prepend) @@ -66,6 +71,7 @@ export default function ClipEditor({ asset, currentTime, onSeek }: Props) { setOutPoint(null) setCreated(null) setConfirmingId(null) + setEditingId(null) void load() }, [load]) @@ -131,36 +137,32 @@ export default function ClipEditor({ asset, currentTime, onSeek }: Props) { return (
-
- - -
+ {/* Bounded by the asset's own length, so a nudge cannot run off its end; the + server checks the same bound on save. */} + + -
- In: {inPoint === null ? '—' : formatDuration(inPoint)} - Out: {outPoint === null ? '—' : formatDuration(outPoint)} - {rangeValid && ( - - )} -
+ {inPoint !== null && outPoint !== null && !rangeValid && ( +

+ The out point must be after the in point. +

+ )}
- ) : ( - - - - )} -
- {/* Every row, a live clip's included: its own page is where its tags, +
  • +
    + {isLiveClip ? ( + + ) : ( + + + + )} +
    + {/* Every row, a live clip's included: its own page is where its tags, attribution and bounded player are, and the Info-tab Delete too. */} - - {clip.name} - - {isLiveClip && clip.in_point !== null && clip.out_point !== null && ( -

    - {formatDuration(clip.in_point)}–{formatDuration(clip.out_point)} -

    + + {clip.name} + + {isLiveClip && + clip.in_point !== null && + clip.out_point !== null && ( +

    + {formatTimecode(clip.in_point)} –{' '} + {formatTimecode(clip.out_point)} +

    + )} +
    + {isLiveClip && ( + )} -
    - {isLiveClip && ( - // In place: the row stops being a live clip and becomes a file of its - // own, so reload on finish for the list to show it as one. - void load()} - iconOnly - /> - )} - {isLiveClip && - (confirmingId === clip.id ? ( - - + {isLiveClip && ( + // In place: the row stops being a live clip and becomes a file of its + // own, so reload on finish for the list to show it as one. + void load()} + iconOnly + /> + )} + {isLiveClip && + (confirmingId === clip.id ? ( + + + + + ) : ( - - ) : ( - - ))} + ))} +
  • + {isLiveClip && editingId === clip.id && ( +
    + + setClips((current) => + current.map((c) => (c.id === saved.id ? saved : c)) + ) + } + onCancel={() => setEditingId(null)} + /> +
    + )} ) })} diff --git a/frontend/src/components/ClipRangeForm.tsx b/frontend/src/components/ClipRangeForm.tsx new file mode 100644 index 0000000..7763804 --- /dev/null +++ b/frontend/src/components/ClipRangeForm.tsx @@ -0,0 +1,126 @@ +import { useEffect, useState } from 'react' +import type { Asset } from '@/api/assets' +import { apiErrorMessage } from '@/api/client' +import { useLibraryStore } from '@/stores/library' +import TimecodeInput from '@/components/TimecodeInput' + +/** + * Re-trimming a live clip that already exists. + * + * Shared by the parent's Clip tab (a row's "Edit times") and the clip's own Trim tab, + * so the two can never disagree about what editing a range means. Both play the + * parent's bytes, so `currentTime` is in the parent's timeline either way and "set to + * the playhead" means the same thing on both pages. + */ + +interface Props { + clip: Asset + currentTime: number + onSeek: (seconds: number) => void + /** The parent's length, when known, to stop a nudge running off its end. */ + max?: number + onSaved?: (clip: Asset) => void + onCancel?: () => void +} + +export default function ClipRangeForm({ + clip, + currentTime, + onSeek, + max, + onSaved, + onCancel, +}: Props) { + const [inPoint, setInPoint] = useState(clip.in_point) + const [outPoint, setOutPoint] = useState(clip.out_point) + const [saving, setSaving] = useState(false) + const [error, setError] = useState(null) + const setClipRange = useLibraryStore((s) => s.setClipRange) + + // Re-seed from the saved range when it changes underneath — a save from the other + // page, or a different clip opening in the same panel. + useEffect(() => { + setInPoint(clip.in_point) + setOutPoint(clip.out_point) + setError(null) + }, [clip.id, clip.in_point, clip.out_point]) + + const rangeValid = inPoint !== null && outPoint !== null && outPoint > inPoint + const dirty = inPoint !== clip.in_point || outPoint !== clip.out_point + + const save = async () => { + if (inPoint === null || outPoint === null || !rangeValid) return + setSaving(true) + setError(null) + try { + // Not `onSaved?.(await …)`: an optional call skips evaluating its arguments, so + // without an `onSaved` (the Trim tab passes none) the save would never be sent. + const saved = await setClipRange(clip.id, inPoint, outPoint) + onSaved?.(saved) + } catch (err) { + setError(apiErrorMessage(err, 'Could not change the clip times')) + } finally { + setSaving(false) + } + } + + return ( +
    + + + {!rangeValid && ( +

    + The out point must be after the in point. +

    + )} +
    + + {dirty && ( + + )} + {onCancel && ( + + )} +
    + {error &&

    {error}

    } +
    + ) +} diff --git a/frontend/src/components/TimecodeInput.tsx b/frontend/src/components/TimecodeInput.tsx new file mode 100644 index 0000000..567bdd6 --- /dev/null +++ b/frontend/src/components/TimecodeInput.tsx @@ -0,0 +1,145 @@ +import { useEffect, useRef, useState } from 'react' +import { Crosshair, Play } from 'lucide-react' +import { fromTimecodeParts, toTimecodeParts, type TimecodeParts } from '@/utils/format' + +/** The nudge buttons, smallest step innermost. Frames are 33–42 ms at common rates, + * so 10 ms is fine enough to land on one without a hundred presses. */ +const NUDGES = [ + { delta: -1, label: '−1s' }, + { delta: -0.01, label: '−10ms' }, + { delta: 0.01, label: '+10ms' }, + { delta: 1, label: '+1s' }, +] + +const FIELDS: { key: keyof TimecodeParts; name: string; width: string; max: number }[] = [ + { key: 'hours', name: 'hours', width: 'w-9', max: 99 }, + { key: 'minutes', name: 'minutes', width: 'w-9', max: 59 }, + { key: 'seconds', name: 'seconds', width: 'w-9', max: 59 }, + { key: 'millis', name: 'milliseconds', width: 'w-12', max: 999 }, +] + +interface Props { + /** "In point" or "Out point": names the fields and buttons for a screen reader. */ + label: string + value: number | null + onChange: (seconds: number) => void + /** The end of the media, when known. The server checks it either way. */ + max?: number + /** Where the player is; "set to playhead" copies it. */ + currentTime: number + onSeek: (seconds: number) => void + /** The set-to-playhead button's accessible name — "Set in point" in the Clip tab, + * which predates this component and is what its tests and users already know. */ + setLabel?: string +} + +/** Rounded to the millisecond, the precision the fields show: without it, ten +10 ms + * nudges from 0 land on 0.09999999999999999 and the display drifts a millisecond. */ +function clamp(seconds: number, max: number | undefined): number { + const bounded = Math.max(0, max === undefined ? seconds : Math.min(seconds, max)) + return Math.round(bounded * 1000) / 1000 +} + +/** + * One point in time as HH:MM:SS:mmm, typed or nudged. + * + * The fields hold their own text while one is focused: re-deriving them from `value` + * on every keystroke would turn "7" then "75" seconds into "01:15" under the cursor. + * They are normalised from `value` once focus leaves, and whenever `value` changes + * from outside (a nudge, the playhead) while none is being typed in. + */ +export default function TimecodeInput({ + label, + value, + onChange, + max, + currentTime, + onSeek, + setLabel = `Set ${label.toLowerCase()} to the playhead`, +}: Props) { + const [draft, setDraft] = useState(() => toTimecodeParts(value)) + const typing = useRef(false) + + useEffect(() => { + if (!typing.current) setDraft(toTimecodeParts(value)) + }, [value]) + + const edit = (key: keyof TimecodeParts, text: string) => { + const next = { ...draft, [key]: text } + setDraft(next) + const seconds = fromTimecodeParts(next) + if (seconds !== null) onChange(clamp(seconds, max)) + } + + const nudge = (delta: number) => onChange(clamp((value ?? currentTime) + delta, max)) + + return ( +
    +
    + + {label.split(' ')[0]} + +
    + {FIELDS.map((field, i) => ( + + {i > 0 && :} + { + typing.current = true + }} + onBlur={() => { + typing.current = false + setDraft(toTimecodeParts(value)) + }} + onChange={(e) => edit(field.key, e.target.value)} + /> + + ))} +
    + + +
    +
    + {NUDGES.map((n) => ( + + ))} +
    +
    + ) +} diff --git a/frontend/src/stores/library.ts b/frontend/src/stores/library.ts index ee38638..de24796 100644 --- a/frontend/src/stores/library.ts +++ b/frontend/src/stores/library.ts @@ -87,6 +87,10 @@ interface LibraryState { /** Put an asset made somewhere other than the grid — a clip saved from the Clip * tab — at the top of it. */ prepend: (asset: Asset) => void + /** Move a live clip's in and out points and put the server's copy in the grid. + * Rejects with the server's error and sets no store `error`, as `remove` does: the + * form that asked shows it. */ + setClipRange: (clipId: string, inPoint: number, outPoint: number) => Promise /** Replace one asset's tags with the authoritative set the server just returned. */ setAssetTags: (assetId: string, tags: Tag[]) => void /** Re-read one asset from the server, for when something other than the user @@ -351,6 +355,20 @@ export const useLibraryStore = create((set, get) => ({ ) }, + async setClipRange(clipId, inPoint, outPoint) { + // Not optimistic, unlike `update`: the server may refuse the range (past the end, + // a promote running), and the player bounds itself by these values the moment they + // land, so it should only ever be bounded by a range that was accepted. + const saved = await clipsApi.setRange(clipId, { + in_point: inPoint, + out_point: outPoint, + }) + set((state) => ({ + assets: state.assets.map((a) => (a.id === clipId ? saved : a)), + })) + return saved + }, + async openById(id) { // Fetches one asset *and puts it in `assets`*, which is the whole point. `update`, // `remove`, `setAssetTags` and `refreshAsset` all work by mapping over that list, so diff --git a/frontend/src/utils/format.test.ts b/frontend/src/utils/format.test.ts index f415ef6..8e4e7ef 100644 --- a/frontend/src/utils/format.test.ts +++ b/frontend/src/utils/format.test.ts @@ -1,5 +1,13 @@ import { describe, expect, it } from 'vitest' -import { formatBytes, formatDate, formatDimensions, formatDuration } from '@/utils/format' +import { + formatBytes, + formatDate, + formatDimensions, + formatDuration, + formatTimecode, + fromTimecodeParts, + toTimecodeParts, +} from '@/utils/format' describe('formatBytes', () => { it('scales to a readable unit', () => { @@ -55,3 +63,33 @@ describe('formatDate', () => { expect(formatDate('2026-09-13T10:00:00Z')).not.toBe('') }) }) + +describe('timecodes', () => { + it('splits seconds into HH:MM:SS:mmm', () => { + expect(toTimecodeParts(3723.456)).toEqual({ + hours: '01', + minutes: '02', + seconds: '03', + millis: '456', + }) + expect(formatTimecode(61.5)).toBe('00:01:01:500') + }) + + it('rounds to the millisecond as a whole, not field by field', () => { + expect(formatTimecode(1.9996)).toBe('00:00:02:000') + }) + + it('is empty for no point at all', () => { + expect(toTimecodeParts(null).seconds).toBe('') + expect(formatTimecode(null)).toBe('') + }) + + it('reads parts back, treating blanks as zero and overflow as the typist meant', () => { + expect( + fromTimecodeParts({ hours: '', minutes: '1', seconds: '90', millis: '5' }) + ).toBe(150.005) + expect( + fromTimecodeParts({ hours: '', minutes: '', seconds: '1.5', millis: '' }) + ).toBe(null) + }) +}) diff --git a/frontend/src/utils/format.ts b/frontend/src/utils/format.ts index 122f5e8..c3a5ffc 100644 --- a/frontend/src/utils/format.ts +++ b/frontend/src/utils/format.ts @@ -59,3 +59,53 @@ export function formatDate(iso: string): string { day: 'numeric', }) } + +/** A timecode's four fields, each as typed text, so a half-typed value survives. */ +export interface TimecodeParts { + hours: string + minutes: string + seconds: string + millis: string +} + +/** + * Seconds as editable HH:MM:SS:mmm parts. + * + * Rounded to the millisecond once, as a whole, rather than per field: flooring the + * seconds and rounding the remainder separately turns 1.9996 into "01:1000". + */ +export function toTimecodeParts(seconds: number | null): TimecodeParts { + if (seconds === null || !Number.isFinite(seconds) || seconds < 0) { + return { hours: '', minutes: '', seconds: '', millis: '' } + } + const totalMs = Math.round(seconds * 1000) + const pad = (n: number, width: number) => String(n).padStart(width, '0') + return { + hours: pad(Math.floor(totalMs / 3_600_000), 2), + minutes: pad(Math.floor((totalMs % 3_600_000) / 60_000), 2), + seconds: pad(Math.floor((totalMs % 60_000) / 1000), 2), + millis: pad(totalMs % 1000, 3), + } +} + +/** + * The parts back to seconds, or null if any field is not a whole number. + * + * Fields are not range-checked: 90 seconds is read as 1:30, the way a typist means + * it, and the display normalises once the field is left. + */ +export function fromTimecodeParts(parts: TimecodeParts): number | null { + const fields = [parts.hours, parts.minutes, parts.seconds, parts.millis] + // An empty field is zero, so typing just the seconds of a fresh point works. + if (fields.some((f) => f.trim() !== '' && !/^\d+$/.test(f.trim()))) return null + const [h, m, s, ms] = fields.map((f) => (f.trim() === '' ? 0 : Number(f))) + return (h * 3_600_000 + m * 60_000 + s * 1000 + ms) / 1000 +} + +/** Seconds as a fixed-width HH:MM:SS:mmm, for anywhere a precise point is shown. */ +export function formatTimecode(seconds: number | null | undefined): string { + if (seconds === null || seconds === undefined) return '' + const p = toTimecodeParts(seconds) + if (!p.hours) return '' + return `${p.hours}:${p.minutes}:${p.seconds}:${p.millis}` +} diff --git a/frontend/src/views/AssetView.test.tsx b/frontend/src/views/AssetView.test.tsx index f01797c..fa587a7 100644 --- a/frontend/src/views/AssetView.test.tsx +++ b/frontend/src/views/AssetView.test.tsx @@ -243,7 +243,43 @@ describe('clips (M7)', () => { await screen.findByRole('heading', { name: /clip of giordano/i }) await userEvent.click(screen.getByRole('tab', { name: 'Info' })) - expect(screen.getByText('1:01–1:10')).toBeInTheDocument() + expect(screen.getByText('00:01:01:000 – 00:01:10:000')).toBeInTheDocument() + }) + + it('re-trims a clip from its own Trim tab, which an ordinary asset does not have', async () => { + vi.spyOn(assetsApi, 'get').mockResolvedValue(clip()) + const setRange = vi.spyOn(clipsApi, 'setRange').mockResolvedValue( + clip({ + in_point: 62.25, + out_point: 70, + name: 'Clip of Giordano interview (1:02–1:10)', + }) + ) + renderAt('/a/clip1') + await screen.findByRole('heading', { name: /clip of giordano/i }) + + await userEvent.click(screen.getByRole('tab', { name: 'Trim' })) + expect(screen.getByLabelText('In point seconds')).toHaveValue(1) + await userEvent.click(screen.getByRole('button', { name: 'In point +1s' })) + const millis = screen.getByLabelText('In point milliseconds') + await userEvent.clear(millis) + await userEvent.type(millis, '250') + await userEvent.click(screen.getByRole('button', { name: 'Save times' })) + + await waitFor(() => + expect(setRange).toHaveBeenCalledWith('clip1', { in_point: 62.25, out_point: 70 }) + ) + // The page follows the store's fresh copy: the new default name is the heading. + expect( + await screen.findByRole('heading', { name: /\(1:02–1:10\)/ }) + ).toBeInTheDocument() + }) + + it('has no Trim tab for an ordinary asset', async () => { + vi.spyOn(assetsApi, 'get').mockResolvedValue(asset()) + renderAt('/a/a1') + await screen.findByRole('heading', { name: 'Giordano interview' }) + expect(screen.queryByRole('tab', { name: 'Trim' })).not.toBeInTheDocument() }) it('blocks deleting an asset with dependent clips and offers to promote them', async () => {