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
45 changes: 45 additions & 0 deletions backend/app/routers/clips.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 ───────────────────────────────────────────────────────


Expand Down
37 changes: 33 additions & 4 deletions backend/app/services/assets.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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.

Expand Down
101 changes: 101 additions & 0 deletions backend/tests/test_clips_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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) ───────────────────────────


Expand Down
11 changes: 11 additions & 0 deletions frontend/src/api/clips.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Asset> {
return client
.put<DataResponse<Asset>>(`/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<ActivityJob> {
Expand Down
38 changes: 36 additions & 2 deletions frontend/src/components/AssetDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -513,7 +520,7 @@ export default function AssetDetail({
{asset.in_point !== null && asset.out_point !== null && (
<Fact
label="Clip"
value={`${formatDuration(asset.in_point)}–${formatDuration(asset.out_point)}`}
value={`${formatTimecode(asset.in_point)} – ${formatTimecode(asset.out_point)}`}
/>
)}
<Fact label="Format" value={asset.file_format ?? ''} />
Expand Down Expand Up @@ -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: (
<div className="min-h-0 flex-1 overflow-auto p-4">
<ClipRangeForm
clip={asset}
currentTime={currentTime}
onSeek={seekTo}
max={
player && Number.isFinite(player.duration)
? player.duration
: undefined
}
/>
</div>
),
},
]
: []),
...(asset.asset_type === TEXT_TYPE
? [
{
Expand Down
45 changes: 45 additions & 0 deletions frontend/src/components/ClipEditor.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()
})
})
Loading
Loading