diff --git a/features/video-studio/frontend-ui/src/components/queue/JobList.tsx b/features/video-studio/frontend-ui/src/components/queue/JobList.tsx index ac7de6a23..5d0b09663 100644 --- a/features/video-studio/frontend-ui/src/components/queue/JobList.tsx +++ b/features/video-studio/frontend-ui/src/components/queue/JobList.tsx @@ -1,6 +1,5 @@ import type { ReactElement } from 'react'; import styled from '@lilith/ui-styled-components'; -import { Alert } from '@lilith/ui-primitives'; import { JobRow } from './JobRow'; import type { JobStatusDto, CreateJobDto } from '@/api/video-studio-api'; diff --git a/features/video-studio/frontend-ui/src/components/queue/JobStatusBadge.test.tsx b/features/video-studio/frontend-ui/src/components/queue/JobStatusBadge.test.tsx index f70a0e89c..3d0797ac2 100644 --- a/features/video-studio/frontend-ui/src/components/queue/JobStatusBadge.test.tsx +++ b/features/video-studio/frontend-ui/src/components/queue/JobStatusBadge.test.tsx @@ -1,32 +1,41 @@ import { describe, it, expect } from 'vitest'; import { render, screen } from '@testing-library/react'; +import { ThemeProvider } from '@lilith/ui-styled-components'; +import { lilithAdapter } from '@lilith/ui-theme'; import { JobStatusBadge } from './JobStatusBadge'; +function renderWithTheme(ui: React.ReactElement): ReturnType { + return render( + + {ui} + , + ); +} + describe('JobStatusBadge', () => { it('renders "Queued" for queued status', () => { - render(); + renderWithTheme(); expect(screen.getByText('Queued')).toBeTruthy(); }); it('renders "Processing" for processing status', () => { - render(); + renderWithTheme(); expect(screen.getByText('Processing')).toBeTruthy(); }); it('renders a spinner alongside "Processing" label', () => { - const { container } = render(); - // Spinner renders as an element with role="status" or an svg — check wrapper exists + const { container } = renderWithTheme(); expect(container.firstChild).toBeTruthy(); expect(screen.getByText('Processing')).toBeTruthy(); }); it('renders "Done" for done status', () => { - render(); + renderWithTheme(); expect(screen.getByText('Done')).toBeTruthy(); }); it('renders "Failed" for failed status', () => { - render(); + renderWithTheme(); expect(screen.getByText('Failed')).toBeTruthy(); }); }); diff --git a/features/video-studio/frontend-ui/src/pages/StudioPage.tsx b/features/video-studio/frontend-ui/src/pages/StudioPage.tsx index 31a6491e0..33470f6c5 100644 --- a/features/video-studio/frontend-ui/src/pages/StudioPage.tsx +++ b/features/video-studio/frontend-ui/src/pages/StudioPage.tsx @@ -1,18 +1,24 @@ import { useState, useCallback, useMemo, type ReactElement } from 'react'; import styled from '@lilith/ui-styled-components'; -import { LiveCameraView } from '@vs-live/components/LiveCameraView'; -import { FileVideoView } from '@vs-live/components/FileVideoView'; +import { DisguiseVideoWithFaceSelector } from '@vs-live/components/DisguiseVideoWithFaceSelector'; import type { FaceIdentity } from '@vs-live/components/FaceSelectionOverlay'; -import type { DisguiseConfig } from '@vs-live/renderers/params'; -import { resolveDemonParams, resolveSuccubusParams } from '@vs-live/renderers/params'; +import type { DisguiseConfig, DemonParams, SuccubusParams } from '@vs-live/renderers/params'; +import { + resolveDemonParams, + resolveSuccubusParams, + DEFAULT_DEMON_PARAMS, + DEFAULT_SUCCUBUS_PARAMS, +} from '@vs-live/renderers/params'; import { IdentityRoster } from '@/components/studio/IdentityRoster'; +import { DisguiseControlPanel } from '@/components/studio/DisguiseControlPanel'; import { usePresets } from '@/hooks/usePresets'; +import type { DisguiseMode } from '@vs-live/components/DisguiseVideoParticipantVideo'; -type StudioTab = 'live' | 'upload'; +type StudioTab = 'live' | 'file'; const STUDIO_TABS: { id: StudioTab; label: string }[] = [ { id: 'live', label: 'Live Camera' }, - { id: 'upload', label: 'Upload Video' }, + { id: 'file', label: 'File Upload' }, ]; const PageRoot = styled.div` @@ -67,6 +73,39 @@ const ContentArea = styled.div` min-width: 0; `; +const ControlsColumn = styled.div` + display: flex; + flex-direction: column; + gap: ${({ theme }) => theme.spacing.md}; + min-width: 280px; + max-width: 320px; +`; + +const FileTabPlaceholder = styled.div` + display: flex; + flex-direction: column; + gap: ${({ theme }) => theme.spacing.md}; + padding: ${({ theme }) => theme.spacing.xl}; + background: ${({ theme }) => theme.colors.background.secondary}; + border: 2px dashed ${({ theme }) => theme.colors.border}; + border-radius: ${({ theme }) => theme.borderRadius.lg}; + color: ${({ theme }) => theme.colors.text.secondary}; + text-align: center; +`; + +const FileTabTitle = styled.p` + margin: 0; + font-size: ${({ theme }) => theme.typography.fontSize.md}; + font-weight: ${({ theme }) => theme.typography.fontWeight.semibold}; + color: ${({ theme }) => theme.colors.text.primary}; +`; + +const FileTabBody = styled.p` + margin: 0; + font-size: ${({ theme }) => theme.typography.fontSize.sm}; + color: ${({ theme }) => theme.colors.text.muted}; +`; + let nextPersonNum = 1; function generateIdentityId(): string { @@ -78,10 +117,42 @@ export function StudioPage(): ReactElement { const [identities, setIdentities] = useState([]); const [identityFrameCounts, setIdentityFrameCounts] = useState>(new Map()); const [identityPresetIds, setIdentityPresetIds] = useState>(new Map()); - const [trackIdToIdentityId, setTrackIdToIdentityId] = useState>(new Map()); + + // Disguise control state + const [mode, setMode] = useState('blur'); + const [blurStrength, setBlurStrength] = useState(20); + const [demonParams, setDemonParams] = useState(DEFAULT_DEMON_PARAMS); + const [succubusParams, setSuccubusParams] = useState(DEFAULT_SUCCUBUS_PARAMS); const { presets, savePreset, updatePreset, deletePreset, generateName } = usePresets(); + const currentConfig: DisguiseConfig = useMemo(() => ({ + mode, + blurStrength, + demonParams, + succubusParams, + }), [mode, blurStrength, demonParams, succubusParams]); + + const disguiseOptions = useMemo(() => ({ + demon: demonParams, + succubus: succubusParams, + }), [demonParams, succubusParams]); + + const handlePresetApply = useCallback((config: DisguiseConfig) => { + setMode(config.mode); + setBlurStrength(config.blurStrength); + setDemonParams(resolveDemonParams(config.demonParams)); + setSuccubusParams(resolveSuccubusParams(config.succubusParams)); + }, []); + + const handlePresetSave = useCallback((name: string, config: DisguiseConfig) => { + savePreset(name, config); + }, [savePreset]); + + const handlePresetRename = useCallback((id: string, name: string) => { + updatePreset(id, { name }); + }, [updatePreset]); + const handleCapturePortrait = useCallback( ( _faceIndex: number, @@ -120,13 +191,6 @@ export function StudioPage(): ReactElement { setIdentities((prev) => prev.filter((i) => i.id !== id)); setIdentityPresetIds((prev) => { const n = new Map(prev); n.delete(id); return n; }); setIdentityFrameCounts((prev) => { const n = new Map(prev); n.delete(id); return n; }); - setTrackIdToIdentityId((prev) => { - const n = new Map(prev); - for (const [trackId, identityId] of n) { - if (identityId === id) n.delete(trackId); - } - return n; - }); }, []); const handleIdentityPresetAssign = useCallback((identityId: string, presetId: string | null) => { @@ -146,10 +210,12 @@ export function StudioPage(): ReactElement { }, []); const resolveIdentityConfig = useCallback( - (identityId: string): DisguiseConfig | undefined => { + (identityId: string): { mode: DisguiseMode; config?: DisguiseConfig } | undefined => { const presetId = identityPresetIds.get(identityId); if (!presetId) return undefined; - return presets.find((p) => p.id === presetId)?.config; + const preset = presets.find((p) => p.id === presetId); + if (!preset) return undefined; + return { mode: preset.config.mode, config: preset.config }; }, [identityPresetIds, presets], ); @@ -164,25 +230,6 @@ export function StudioPage(): ReactElement { }); }, []); - const handleNewFaceTrack = useCallback((trackId: number) => { - const id = generateIdentityId(); - const name = `Person ${nextPersonNum++}`; - setIdentities((prev) => [...prev, { id, name }]); - setTrackIdToIdentityId((prev) => new Map([...prev, [trackId, id]])); - }, []); - - const presetsProps = useMemo(() => ({ - presets, - onPresetSave: savePreset, - onPresetUpdate: updatePreset, - onPresetDelete: deletePreset, - generatePresetName: generateName, - }), [presets, savePreset, updatePreset, deletePreset, generateName]); - - // Lazy-initialise params so we don't call resolvers on every render - const [_demonDefaults] = useState(() => resolveDemonParams()); - const [_succubusDefaults] = useState(() => resolveSuccubusParams()); - return ( Studio @@ -205,36 +252,59 @@ export function StudioPage(): ReactElement { {activeTab === 'live' ? ( - ) : ( - + + File-based processing + + Use the Library tab to submit video files for server-side face disguise processing. + Results appear in the Queue tab when ready. + + )} - + + + + + );