From 55f5c89daf4b5d5b6e2fe41bc279259df24cbf7a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 07:48:51 +0000 Subject: [PATCH] feat(clips): extract a saved clip as a standalone file from the Clip tab and its detail page Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_011UgqEY6UyGZyDmiiv4V3zJ --- frontend/src/api/clips.ts | 3 +- frontend/src/components/AssetDetail.tsx | 13 ++- frontend/src/components/ClipEditor.test.tsx | 101 ++++++++++++++++++++ frontend/src/components/ClipEditor.tsx | 19 +++- 4 files changed, 132 insertions(+), 4 deletions(-) create mode 100644 frontend/src/components/ClipEditor.test.tsx diff --git a/frontend/src/api/clips.ts b/frontend/src/api/clips.ts index 003991a..a05f39e 100644 --- a/frontend/src/api/clips.ts +++ b/frontend/src/api/clips.ts @@ -50,7 +50,8 @@ export const clipsApi = { }, /** Turns a live clip into a standalone sub-video, in place — the delete guard's - * one-click path. `clipId` is the clip's own id, not its parent's. */ + * one-click path, and the Clip tab's per-row "Extract as file". `clipId` is the + * clip's own id, not its parent's. */ promote(clipId: string): Promise { return client .post>(`/assets/${clipId}/promote`) diff --git a/frontend/src/components/AssetDetail.tsx b/frontend/src/components/AssetDetail.tsx index 2c6e50e..053fb1c 100644 --- a/frontend/src/components/AssetDetail.tsx +++ b/frontend/src/components/AssetDetail.tsx @@ -469,6 +469,17 @@ export default function AssetDetail({ const info = (
+ {ownsNoFile(asset) && ( + + )} {asset.asset_type === TEXT_TYPE && ( = {}): Asset { + return { + id: 'a1', + name: 'Interview', + description: null, + summary: null, + asset_type: 'video', + source: 'upload', + parent_asset_id: null, + in_point: null, + out_point: null, + original_name: null, + mime_type: null, + file_format: null, + size_bytes: 0, + duration_seconds: 60, + width: null, + height: null, + codec: null, + file_url: null, + thumb_url: null, + missing: false, + tags: [], + ...noAttribution, + upload_date: '2026-01-01T00:00:00', + modified_date: '2026-01-01T00:00:00', + metadata_modified_date: '2026-01-01T00:00:00', + ...overrides, + } +} + +const liveClip = makeAsset({ + id: 'c1', + name: 'Live clip', + source: 'clip', + parent_asset_id: 'a1', + in_point: 5, + out_point: 9, +}) +const subVideo = makeAsset({ + id: 'c2', + name: 'Standalone cut', + source: 'sub_video', + parent_asset_id: 'a1', + in_point: 10, + out_point: 20, +}) + +beforeEach(() => { + vi.spyOn(activityApi, 'list').mockResolvedValue([]) +}) + +afterEach(() => { + vi.restoreAllMocks() +}) + +function renderEditor() { + render( + + + + ) +} + +describe('ClipEditor', () => { + it('offers "Extract as file" on a live clip but not on an extracted one', async () => { + vi.spyOn(clipsApi, 'list').mockResolvedValue([liveClip, subVideo]) + + renderEditor() + + // Exactly one: the sub-video already owns a file. + expect( + await screen.findAllByRole('button', { name: 'Extract as file' }) + ).toHaveLength(1) + }) + + it("promotes the clip in place using the clip's own id", async () => { + vi.spyOn(clipsApi, 'list').mockResolvedValue([liveClip]) + const promote = vi.spyOn(clipsApi, 'promote').mockResolvedValue({ + id: 'j1', + asset_id: 'c1', + action: 'extract_subvideo', + status: 'queued', + } as never) + + renderEditor() + await userEvent.click(await screen.findByRole('button', { name: 'Extract as file' })) + + expect(promote).toHaveBeenCalledWith('c1') + }) +}) diff --git a/frontend/src/components/ClipEditor.tsx b/frontend/src/components/ClipEditor.tsx index 1717314..380ce4a 100644 --- a/frontend/src/components/ClipEditor.tsx +++ b/frontend/src/components/ClipEditor.tsx @@ -15,8 +15,8 @@ import EnrichmentButton from '@/components/EnrichmentButton' * A "dumb panel" like `TranscriptPanel`: it receives `currentTime` and calls `onSeek` * rather than holding a ref to the player itself, the pattern `AssetDetail` already * uses everywhere else. Only ever mounted for an asset that owns a file — `AssetDetail` - * hides this tab for a clip, since M7 does not support clipping a clip (`promote` - * already covers "turn this clip into a real file"). + * hides this tab for a clip, since M7 does not support clipping a clip. Turning a saved + * clip into a real file is `promote`, offered on each live-clip row below. */ interface Props { @@ -216,6 +216,21 @@ export default function ClipEditor({ asset, currentTime, onSeek }: Props) {

)}
+ {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 + /> + )} ) })}