From 8361e1ff77bbab31638f6a45f45b683718be08e8 Mon Sep 17 00:00:00 2001 From: IAnMove <216241348+IAnMove@users.noreply.github.com> Date: Mon, 7 Sep 2026 23:09:22 +0200 Subject: [PATCH] feat(video-editor): add timeline clips through the shared catalog picker Replace the ad-hoc HocusPocus multi-select modal with AssetExplorerDialog. Catalog confirm appends one clip via the existing probe path and keeps the current timeline, trims and order. Local multi-file import is unchanged. --- .../video-editor/VideoEditorPanel.tsx | 267 +++--------------- .../video-editor/videoEditorCatalogPick.ts | 16 ++ ui/tests/videoEditorPicker.test.tsx | 81 ++---- 3 files changed, 77 insertions(+), 287 deletions(-) create mode 100644 ui/src/features/video-editor/videoEditorCatalogPick.ts diff --git a/ui/src/features/video-editor/VideoEditorPanel.tsx b/ui/src/features/video-editor/VideoEditorPanel.tsx index aa2bcfd4..ec518545 100644 --- a/ui/src/features/video-editor/VideoEditorPanel.tsx +++ b/ui/src/features/video-editor/VideoEditorPanel.tsx @@ -22,12 +22,12 @@ import { WandSparkles, X, } from 'lucide-react' -import { Fragment, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, type ReactNode, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' +import { Fragment, lazy, Suspense, type KeyboardEvent as ReactKeyboardEvent, type PointerEvent as ReactPointerEvent, type ReactNode, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import type { ParseKeys } from 'i18next' import { useUiTranslation } from '../../i18n' import * as api from '../../api/client' import { useStore } from '../../stores/useStore' -import { ModalShell } from '../../components/common/ModalShell' +import { videoEditorClipFromOutput } from './videoEditorCatalogPick' import { clearVideoEditorReplacementResult, clearVideoEditorReplacementTarget, @@ -108,7 +108,9 @@ interface PendingEditorSequence { const VIDEO_ACCEPT = '.mp4,.webm,.mov,.mkv,.avi,.m4v' const VIDEO_EDITOR_PENDING_SEQUENCE_KEY = 'maestro-video-editor-pending-sequence' const VIDEO_EDITOR_EXPORT_KEY = 'maestro-video-editor-export-v1' -const MAESTRO_PICKER_PAGE_SIZE = 24 +const AssetExplorerDialog = lazy(() => + import('../../components/common/AssetExplorerDialog.tsx').then(module => ({ default: module.AssetExplorerDialog })), +) const VIDEO_EDITOR_ACTIVE_STATUSES = new Set([ 'queued', 'waiting_resource', @@ -739,12 +741,6 @@ export function VideoEditorPanel() { const [adding, setAdding] = useState(false) const [addProgress, setAddProgress] = useState('') const [pickerOpen, setPickerOpen] = useState(false) - const [maestroVideos, setMaestroVideos] = useState([]) - const [maestroVideoTotal, setMaestroVideoTotal] = useState(0) - const [pickerLoading, setPickerLoading] = useState(false) - const [pickerSelected, setPickerSelected] = useState([]) - const pickerAnchorRef = useRef(null) - const pickerSelectedSet = useMemo(() => new Set(pickerSelected), [pickerSelected]) const [error, setError] = useState(draft.warning) const [exportJob, setExportJob] = useState(() => { const jobId = readVideoEditorExportId(activeWorkspace) @@ -1071,111 +1067,21 @@ export function VideoEditorPanel() { if (failures.length) setError(failures.join('\n')) } - const closeMaestroPicker = () => { - setPickerOpen(false) - setPickerSelected([]) - pickerAnchorRef.current = null - } - - const openMaestroPicker = async () => { - setPickerOpen(true) - setPickerSelected([]) - pickerAnchorRef.current = null - setPickerLoading(true) - setError(null) - setMaestroVideos([]) - setMaestroVideoTotal(0) - try { - const result = await api.fetchOutputs(MAESTRO_PICKER_PAGE_SIZE, 0, { mediaType: 'video', workspace: activeWorkspace }) - setMaestroVideos(result.outputs) - setMaestroVideoTotal(result.total) - } catch (reason) { - setError((reason as Error).message) - } finally { - setPickerLoading(false) - } - } - - const loadMoreMaestroVideos = async () => { - if (pickerLoading || maestroVideos.length >= maestroVideoTotal) return - setPickerLoading(true) + const addCatalogOutput = async (item: api.ApiOutput) => { + const workspace = activeWorkspace + setAdding(true) setError(null) + setAddProgress(t('status.addingNamed', { current: 1, total: 1, name: item.name })) try { - const result = await api.fetchOutputs( - MAESTRO_PICKER_PAGE_SIZE, - maestroVideos.length, - { mediaType: 'video', workspace: activeWorkspace }, - ) - setMaestroVideos(current => { - const known = new Set(current.map(output => output.name)) - return [...current, ...result.outputs.filter(output => !known.has(output.name))] - }) - setMaestroVideoTotal(result.total) + if (useStore.getState().activeWorkspace !== workspace) return + const next = videoEditorClipFromOutput(item, workspace) + await addSource(next.source, next.previewUrl, next.name, next.thumbnailUrl) } catch (reason) { - setError((reason as Error).message) + setError(`${item.name}: ${(reason as Error).message}`) } finally { - setPickerLoading(false) - } - } - - const toggleMaestroVideo = (output: api.ApiOutput, event: ReactMouseEvent) => { - const index = maestroVideos.findIndex(item => item.name === output.name) - if (index < 0) return - if (event.shiftKey && pickerAnchorRef.current !== null) { - const start = Math.min(pickerAnchorRef.current, index) - const end = Math.max(pickerAnchorRef.current, index) - const range = maestroVideos.slice(start, end + 1).map(item => item.name) - setPickerSelected(current => { - const seen = new Set(current) - const next = [...current] - for (const name of range) { - if (!seen.has(name)) { - seen.add(name) - next.push(name) - } - } - return next - }) - return - } - pickerAnchorRef.current = index - setPickerSelected(current => ( - current.includes(output.name) - ? current.filter(name => name !== output.name) - : [...current, output.name] - )) - } - - const addSelectedMaestroVideos = async () => { - const selected = pickerSelected - .map(name => maestroVideos.find(item => item.name === name)) - .filter((item): item is api.ApiOutput => Boolean(item)) - if (!selected.length) { - setError(t('errors.selectVideos')) - return - } - setAdding(true) - closeMaestroPicker() - setError(null) - const failures: string[] = [] - for (let index = 0; index < selected.length; index++) { - const output = selected[index] - setAddProgress(t('status.addingNamed', { current: index + 1, total: selected.length, name: output.name })) - try { - const source = api.getFileUrl(output.name, activeWorkspace) - await addSource( - source, - source, - output.name, - output.thumbnail_url || api.getOutputThumbnailUrl(output.name, activeWorkspace), - ) - } catch (reason) { - failures.push(`${output.name}: ${(reason as Error).message}`) - } + setAdding(false) + setAddProgress('') } - setAdding(false) - setAddProgress('') - if (failures.length) setError(failures.join('\n')) } const reorder = (id: string, direction: -1 | 1) => { @@ -2062,7 +1968,7 @@ export function VideoEditorPanel() { {t('toolbar.import')} - -
- {pickerLoading && maestroVideos.length === 0 ? ( -
- -
- ) : maestroVideos.length ? ( -
- {maestroVideos.map(output => { - const selected = pickerSelectedSet.has(output.name) - return ( - - ) - })} -
- ) : ( -
- {t('picker.empty', { workspace: activeWorkspace })} -
- )} - {maestroVideos.length < maestroVideoTotal && ( -
- -
- )} -
- {maestroVideos.length > 0 && ( -
- - - - {t('picker.hint', { count: pickerSelected.length })} - - -
- )} - - - )} + {pickerOpen ? ( + }> + setPickerOpen(false)} + onChoose={item => { + setPickerOpen(false) + if (item) void addCatalogOutput(item) + }} + /> + + ) : null} ) } diff --git a/ui/src/features/video-editor/videoEditorCatalogPick.ts b/ui/src/features/video-editor/videoEditorCatalogPick.ts new file mode 100644 index 00000000..96fae697 --- /dev/null +++ b/ui/src/features/video-editor/videoEditorCatalogPick.ts @@ -0,0 +1,16 @@ +import { getFileUrl, getOutputThumbnailUrl, type ApiOutput } from '../../api/outputs' + +export function videoEditorClipFromOutput(item: ApiOutput, workspace: string): { + source: string + previewUrl: string + name: string + thumbnailUrl: string +} { + const source = item.url || getFileUrl(item.name, workspace) + return { + source, + previewUrl: source, + name: item.name, + thumbnailUrl: item.thumbnail_url || getOutputThumbnailUrl(item.name, workspace), + } +} diff --git a/ui/tests/videoEditorPicker.test.tsx b/ui/tests/videoEditorPicker.test.tsx index 84ac5c6a..eafaeb44 100644 --- a/ui/tests/videoEditorPicker.test.tsx +++ b/ui/tests/videoEditorPicker.test.tsx @@ -28,20 +28,6 @@ function installDom() { const dom = installDom() -function videoOutput(name: string) { - return { - name, - type: 'video', - mode: 'video', - url: `/api/v1/file/${name}?workspace=default`, - thumbnail_url: `/api/v1/outputs/thumbnail/${name}?workspace=default`, - size: 12, - created_at: 1, - completed_at: 1, - favorite: false, - } -} - test('editorSourcePath strips workspace query from gallery file URLs', async () => { const { editorSourcePath } = await import('../src/features/video-editor/editorHandoff.ts') assert.equal( @@ -52,49 +38,38 @@ test('editorSourcePath strips workspace query from gallery file URLs', async () assert.equal(editorSourcePath('opening.mp4'), 'opening.mp4') }) -test('Video Editor picker keeps multiple HocusPocus videos selected until Add', { concurrency: false }, async () => { - const { render, screen, waitFor, cleanup, fireEvent } = await import('@testing-library/react') +test('catalog clip binding keeps workspace URL and does not invent a re-upload', async () => { + const { videoEditorClipFromOutput } = await import('../src/features/video-editor/videoEditorCatalogPick.ts') + const item = { + name: 'opening.mp4', + type: 'video' as const, + mode: 'video', + size: 12, + created_at: 1, + url: '/api/v1/file/opening.mp4?workspace=film', + thumbnail_url: '/api/v1/outputs/thumbnail/opening.mp4?workspace=film', + workspace_id: 'film', + path: 'opening.mp4', + asset_id: 'asset-opening', + } + const next = videoEditorClipFromOutput(item, 'film') + assert.equal(next.source, item.url) + assert.equal(next.previewUrl, item.url) + assert.equal(next.name, 'opening.mp4') + assert.equal(next.thumbnailUrl, item.thumbnail_url) +}) + +test('Video Editor keeps device import and library origin without opening the explorer', { concurrency: false }, async () => { + const { render, screen, cleanup } = await import('@testing-library/react') const { VideoEditorPanel } = await import('../src/features/video-editor/VideoEditorPanel.tsx') dom.window.localStorage.clear() - const probed: Array<{ source: string; workspace?: string }> = [] - globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { - const url = String(input) - if (url.includes('/api/v1/outputs')) { - return { - ok: true, - json: async () => ({ - outputs: [videoOutput('minimax_h3_713afac9.mp4'), videoOutput('second_clip.mp4')], - total: 2, - }), - } as Response - } - if (url.includes('/api/v1/video-editor/probe')) { - const body = JSON.parse(String(init?.body || '{}')) as { source: string; workspace?: string } - probed.push(body) - return { - ok: true, - json: async () => ({ - duration: 8, width: 1280, height: 720, fps: 24, - has_audio: true, pixel_format: 'yuv420p', has_alpha: false, - }), - } as Response - } - throw new Error(`Unexpected request: ${url}`) + globalThis.fetch = (async () => { + return { ok: true, json: async () => ({}) } as Response }) as typeof fetch - const view = render() - fireEvent.click(screen.getByRole('button', { name: 'From HocusPocus' })) - await screen.findByRole('listbox', { name: 'HocusPocus videos' }) - fireEvent.click(screen.getByRole('option', { name: 'minimax_h3_713afac9.mp4' })) - fireEvent.click(screen.getByRole('option', { name: 'second_clip.mp4' })) - assert.equal(screen.getByRole('option', { name: 'minimax_h3_713afac9.mp4' }).getAttribute('aria-selected'), 'true') - assert.equal(screen.getByRole('option', { name: 'second_clip.mp4' }).getAttribute('aria-selected'), 'true') - fireEvent.click(screen.getByRole('button', { name: 'Add 2 videos' })) - await waitFor(() => assert.match(screen.getByText(/Timeline · 2 clips/).textContent || '', /Timeline · 2 clips/)) - assert.equal(probed.length, 2) - assert.equal(probed[0].source, 'minimax_h3_713afac9.mp4') - assert.equal(probed[0].workspace, 'default') - assert.equal(probed[1].source, 'second_clip.mp4') + assert.ok(screen.getByRole('button', { name: 'From HocusPocus' })) + assert.ok(screen.getByRole('button', { name: 'Import' })) + assert.equal(document.querySelector('[data-testid="asset-explorer"]'), null) view.unmount() cleanup() })