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
3 changes: 2 additions & 1 deletion frontend/src/api/clips.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ActivityJob> {
return client
.post<DataResponse<ActivityJob>>(`/assets/${clipId}/promote`)
Expand Down
13 changes: 12 additions & 1 deletion frontend/src/components/AssetDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -469,6 +469,17 @@ export default function AssetDetail({

const info = (
<div className="min-h-0 flex-1 space-y-3 overflow-auto p-4">
{ownsNoFile(asset) && (
<EnrichmentButton
assetId={asset.id}
action="extract_subvideo"
icon={Scissors}
label="Extract as file"
runningLabel="Extracting…"
start={clipsApi.promote}
failureMessage="Could not start extracting"
/>
)}
{asset.asset_type === TEXT_TYPE && (
<EnrichmentButton
assetId={asset.id}
Expand Down Expand Up @@ -590,7 +601,7 @@ export default function AssetDetail({

// Neither applies to a clip: it has no transcript of its own (only the parent does,
// for the whole recording rather than this range), and clipping a clip is out of
// scope for M7 — `promote` already covers "turn this clip into a real file".
// scope for M7 — "Extract as file" (`promote`) turns a clip into a real file instead.
const clippable = SPEECH_TYPES.has(asset.asset_type) && !ownsNoFile(asset)

const tabs: TabSpec[] = [
Expand Down
101 changes: 101 additions & 0 deletions frontend/src/components/ClipEditor.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { MemoryRouter } from 'react-router-dom'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import ClipEditor from '@/components/ClipEditor'
import { clipsApi } from '@/api/clips'
import { activityApi } from '@/api/transcripts'
import type { Asset } from '@/api/assets'
import { noAttribution } from '@/test-fixtures'

function makeAsset(overrides: Partial<Asset> = {}): 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(
<MemoryRouter>
<ClipEditor asset={makeAsset()} currentTime={0} onSeek={vi.fn()} />
</MemoryRouter>
)
}

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')
})
})
19 changes: 17 additions & 2 deletions frontend/src/components/ClipEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -216,6 +216,21 @@ export default function ClipEditor({ asset, currentTime, onSeek }: Props) {
</p>
)}
</div>
{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.
<EnrichmentButton
assetId={clip.id}
action="extract_subvideo"
icon={Scissors}
label="Extract as file"
runningLabel="Extracting…"
start={clipsApi.promote}
failureMessage="Could not start extracting"
onFinish={() => void load()}
iconOnly
/>
)}
</li>
)
})}
Expand Down
Loading