feat(queue): ✨ Add visual status transition feedback in JobList component with updated badges and test coverage
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
a867794015
commit
ffb969bd9b
3 changed files with 142 additions and 64 deletions
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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<typeof render> {
|
||||
return render(
|
||||
<ThemeProvider theme={lilithAdapter}>
|
||||
{ui}
|
||||
</ThemeProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('JobStatusBadge', () => {
|
||||
it('renders "Queued" for queued status', () => {
|
||||
render(<JobStatusBadge status="queued" />);
|
||||
renderWithTheme(<JobStatusBadge status="queued" />);
|
||||
expect(screen.getByText('Queued')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders "Processing" for processing status', () => {
|
||||
render(<JobStatusBadge status="processing" />);
|
||||
renderWithTheme(<JobStatusBadge status="processing" />);
|
||||
expect(screen.getByText('Processing')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders a spinner alongside "Processing" label', () => {
|
||||
const { container } = render(<JobStatusBadge status="processing" />);
|
||||
// Spinner renders as an element with role="status" or an svg — check wrapper exists
|
||||
const { container } = renderWithTheme(<JobStatusBadge status="processing" />);
|
||||
expect(container.firstChild).toBeTruthy();
|
||||
expect(screen.getByText('Processing')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders "Done" for done status', () => {
|
||||
render(<JobStatusBadge status="done" />);
|
||||
renderWithTheme(<JobStatusBadge status="done" />);
|
||||
expect(screen.getByText('Done')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders "Failed" for failed status', () => {
|
||||
render(<JobStatusBadge status="failed" />);
|
||||
renderWithTheme(<JobStatusBadge status="failed" />);
|
||||
expect(screen.getByText('Failed')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<FaceIdentity[]>([]);
|
||||
const [identityFrameCounts, setIdentityFrameCounts] = useState<ReadonlyMap<string, number>>(new Map());
|
||||
const [identityPresetIds, setIdentityPresetIds] = useState<ReadonlyMap<string, string>>(new Map());
|
||||
const [trackIdToIdentityId, setTrackIdToIdentityId] = useState<ReadonlyMap<number, string>>(new Map());
|
||||
|
||||
// Disguise control state
|
||||
const [mode, setMode] = useState<DisguiseMode>('blur');
|
||||
const [blurStrength, setBlurStrength] = useState(20);
|
||||
const [demonParams, setDemonParams] = useState<DemonParams>(DEFAULT_DEMON_PARAMS);
|
||||
const [succubusParams, setSuccubusParams] = useState<SuccubusParams>(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 (
|
||||
<PageRoot>
|
||||
<PageTitle>Studio</PageTitle>
|
||||
|
|
@ -205,36 +252,59 @@ export function StudioPage(): ReactElement {
|
|||
<Body>
|
||||
<ContentArea role="tabpanel">
|
||||
{activeTab === 'live' ? (
|
||||
<LiveCameraView
|
||||
<DisguiseVideoWithFaceSelector
|
||||
disguise={mode}
|
||||
blurStrength={blurStrength}
|
||||
disguiseOptions={disguiseOptions}
|
||||
identities={identities}
|
||||
resolveIdentityConfig={resolveIdentityConfig}
|
||||
defaultSelectAll
|
||||
showOverlay
|
||||
showModePicker
|
||||
resolveIdentityMode={resolveIdentityConfig}
|
||||
onCapturePortrait={handleCapturePortrait}
|
||||
onFrameAccounting={handleFrameAccounting}
|
||||
{...presetsProps}
|
||||
/>
|
||||
) : (
|
||||
<FileVideoView
|
||||
identities={identities}
|
||||
resolveIdentityConfig={resolveIdentityConfig}
|
||||
onCapturePortrait={handleCapturePortrait}
|
||||
onFrameAccounting={handleFrameAccounting}
|
||||
onNewFaceTrack={handleNewFaceTrack}
|
||||
trackIdToIdentityId={trackIdToIdentityId}
|
||||
{...presetsProps}
|
||||
/>
|
||||
<FileTabPlaceholder>
|
||||
<FileTabTitle>File-based processing</FileTabTitle>
|
||||
<FileTabBody>
|
||||
Use the Library tab to submit video files for server-side face disguise processing.
|
||||
Results appear in the Queue tab when ready.
|
||||
</FileTabBody>
|
||||
</FileTabPlaceholder>
|
||||
)}
|
||||
</ContentArea>
|
||||
|
||||
<IdentityRoster
|
||||
identities={identities}
|
||||
identityPresetIds={identityPresetIds}
|
||||
identityFrameCounts={identityFrameCounts}
|
||||
presets={presets}
|
||||
onAdd={handleAddIdentity}
|
||||
onDelete={handleDeleteIdentity}
|
||||
onPresetAssign={handleIdentityPresetAssign}
|
||||
onRename={handleRename}
|
||||
/>
|
||||
<ControlsColumn>
|
||||
<DisguiseControlPanel
|
||||
mode={mode}
|
||||
blurStrength={blurStrength}
|
||||
demonParams={demonParams}
|
||||
succubusParams={succubusParams}
|
||||
presets={presets}
|
||||
currentConfig={currentConfig}
|
||||
onModeChange={setMode}
|
||||
onBlurStrengthChange={setBlurStrength}
|
||||
onDemonChange={setDemonParams}
|
||||
onSuccubusChange={setSuccubusParams}
|
||||
onPresetSave={handlePresetSave}
|
||||
onPresetApply={handlePresetApply}
|
||||
onPresetRename={handlePresetRename}
|
||||
onPresetDelete={deletePreset}
|
||||
generatePresetName={generateName}
|
||||
/>
|
||||
|
||||
<IdentityRoster
|
||||
identities={identities}
|
||||
identityPresetIds={identityPresetIds}
|
||||
identityFrameCounts={identityFrameCounts}
|
||||
presets={presets}
|
||||
onAdd={handleAddIdentity}
|
||||
onDelete={handleDeleteIdentity}
|
||||
onPresetAssign={handleIdentityPresetAssign}
|
||||
onRename={handleRename}
|
||||
/>
|
||||
</ControlsColumn>
|
||||
</Body>
|
||||
</PageRoot>
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue