feat(video-studio): ✨ Add AdversaryPanel and InvisibleProtectionsDemo UI components with updated API integration and App routing for video studio feature
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
66bbd0e6e5
commit
7ec004b8bd
4 changed files with 1036 additions and 2 deletions
|
|
@ -3,7 +3,7 @@ import { LiveCameraView } from './components/LiveCameraView';
|
|||
import { FileVideoView } from './components/FileVideoView';
|
||||
import { IdentityPanel } from './components/IdentityPanel';
|
||||
import { usePresets } from './hooks/usePresets';
|
||||
import { ProtectionsPage } from '@lilith/video-studio-ui';
|
||||
import { InvisibleProtectionsDemo } from './components/InvisibleProtectionsDemo';
|
||||
import type { FaceIdentity } from '@vs-live/components/FaceSelectionOverlay';
|
||||
import type { DisguiseConfig } from '@vs-live/renderers/params';
|
||||
|
||||
|
|
@ -170,7 +170,7 @@ const handleFrameAccounting = useCallback((assignments: ReadonlyMap<number, stri
|
|||
|
||||
{isProtectionsTab ? (
|
||||
<div style={styles.protectionsContent} role="tabpanel">
|
||||
<ProtectionsPage token="" />
|
||||
<InvisibleProtectionsDemo />
|
||||
</div>
|
||||
) : (
|
||||
<div style={styles.body}>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,111 @@
|
|||
/**
|
||||
* Direct API client for invisible protection services.
|
||||
* Bypasses NestJS — calls Python services directly.
|
||||
* No auth required (developer demo tool).
|
||||
*/
|
||||
|
||||
const IMAJIN_VIDEO_URL = 'http://localhost:8010';
|
||||
const IMAGE_ASSISTANT_URL = 'http://10.0.0.116:5220';
|
||||
|
||||
export interface PhotoItem {
|
||||
id: string;
|
||||
filename: string;
|
||||
path: string;
|
||||
mediaType: string;
|
||||
thumbnailUrl: string | null;
|
||||
}
|
||||
|
||||
export interface DetectionResult {
|
||||
bboxes: [number, number, number, number][]; // [x1, y1, x2, y2] pixel coords
|
||||
confidences: number[];
|
||||
max_confidence: number;
|
||||
face_count: number;
|
||||
}
|
||||
|
||||
export interface FrameSample {
|
||||
frame_index: number;
|
||||
input_b64: string;
|
||||
output_b64: string;
|
||||
input_detection: DetectionResult | null;
|
||||
output_detection: DetectionResult | null;
|
||||
}
|
||||
|
||||
export interface ProtectionProof {
|
||||
operation_type: string;
|
||||
frames_affected: number;
|
||||
evidence: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ProtectJobResult {
|
||||
job_id: string;
|
||||
status: 'queued' | 'processing' | 'done' | 'failed';
|
||||
output_path: string | null;
|
||||
proofs: ProtectionProof[];
|
||||
frame_samples: FrameSample[];
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export async function listVideoPhotos(): Promise<PhotoItem[]> {
|
||||
try {
|
||||
const resp = await fetch(`${IMAGE_ASSISTANT_URL}/api/photos?mediaType=video`);
|
||||
if (!resp.ok) throw new Error(`Failed to list photos: ${resp.status}`);
|
||||
const data: unknown = await resp.json();
|
||||
if (Array.isArray(data)) return data as PhotoItem[];
|
||||
const obj = data as Record<string, unknown>;
|
||||
const list = obj['photos'] ?? obj['items'] ?? [];
|
||||
return list as PhotoItem[];
|
||||
} catch (err) {
|
||||
throw err instanceof Error
|
||||
? err
|
||||
: new Error(`listVideoPhotos: unexpected error — ${String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function listAvailableProtections(): Promise<string[]> {
|
||||
try {
|
||||
const resp = await fetch(`${IMAJIN_VIDEO_URL}/protections`);
|
||||
if (!resp.ok) throw new Error(`Failed to list protections: ${resp.status}`);
|
||||
const data: unknown = await resp.json();
|
||||
const obj = data as Record<string, unknown>;
|
||||
return (obj['operations'] as string[]) ?? [];
|
||||
} catch (err) {
|
||||
throw err instanceof Error
|
||||
? err
|
||||
: new Error(`listAvailableProtections: unexpected error — ${String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function submitProtectJob(
|
||||
videoPath: string,
|
||||
operations: string[],
|
||||
outputMode: 'default' | 'lossless',
|
||||
): Promise<{ job_id: string }> {
|
||||
try {
|
||||
const resp = await fetch(`${IMAJIN_VIDEO_URL}/invisible-protect`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ video_path: videoPath, operations, output_mode: outputMode }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const errText = await resp.text();
|
||||
throw new Error(`Failed to submit job: ${resp.status} — ${errText}`);
|
||||
}
|
||||
return resp.json() as Promise<{ job_id: string }>;
|
||||
} catch (err) {
|
||||
throw err instanceof Error
|
||||
? err
|
||||
: new Error(`submitProtectJob: unexpected error — ${String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function pollProtectJob(jobId: string): Promise<ProtectJobResult> {
|
||||
try {
|
||||
const resp = await fetch(`${IMAJIN_VIDEO_URL}/protect-jobs/${jobId}`);
|
||||
if (!resp.ok) throw new Error(`Failed to poll job ${jobId}: ${resp.status}`);
|
||||
return resp.json() as Promise<ProtectJobResult>;
|
||||
} catch (err) {
|
||||
throw err instanceof Error
|
||||
? err
|
||||
: new Error(`pollProtectJob(${jobId}): unexpected error — ${String(err)}`);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,469 @@
|
|||
import { useCallback, useEffect, useRef, useState, type ReactElement } from 'react';
|
||||
import type { ProtectJobResult, FrameSample } from '../api/invisible-protect-api';
|
||||
|
||||
interface Props {
|
||||
job: ProtectJobResult;
|
||||
}
|
||||
|
||||
export function AdversaryPanel({ job }: Props): ReactElement {
|
||||
return (
|
||||
<div style={s.panel}>
|
||||
{job.proofs.map((proof) => (
|
||||
<OperationSection key={proof.operation_type} proof={proof} frameSamples={job.frame_samples} />
|
||||
))}
|
||||
{job.proofs.length === 0 && (
|
||||
<div style={s.empty}>No proof data available.</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface SectionProps {
|
||||
proof: ProtectJobResult['proofs'][number];
|
||||
frameSamples: FrameSample[];
|
||||
}
|
||||
|
||||
function OperationSection({ proof, frameSamples }: SectionProps): ReactElement {
|
||||
switch (proof.operation_type) {
|
||||
case 'metadata-strip':
|
||||
return <MetadataStripSection proof={proof} />;
|
||||
case 'detection-evasion':
|
||||
return <DetectionEvasionSection proof={proof} frameSamples={frameSamples} />;
|
||||
case 'ml-cloak':
|
||||
return <MlCloakSection proof={proof} />;
|
||||
default:
|
||||
return <GenericSection proof={proof} />;
|
||||
}
|
||||
}
|
||||
|
||||
function MetadataStripSection({ proof }: { proof: SectionProps['proof'] }): ReactElement {
|
||||
const ev = proof.evidence as Record<string, unknown>;
|
||||
const fieldsRemoved = Number(ev.fields_removed ?? 0);
|
||||
const bytesSaved = Number(ev.bytes_saved ?? 0);
|
||||
const removedFields = (ev.removed_fields as string[] | undefined) ?? [];
|
||||
|
||||
return (
|
||||
<div style={s.opSection}>
|
||||
<h3 style={s.opTitle}>Metadata Strip</h3>
|
||||
<div style={s.statsRow}>
|
||||
<Stat label="Fields removed" value={String(fieldsRemoved)} />
|
||||
<Stat label="Bytes saved" value={`${bytesSaved.toLocaleString()} B`} />
|
||||
</div>
|
||||
{removedFields.length > 0 && (
|
||||
<div style={s.fieldList}>
|
||||
<div style={s.fieldListLabel}>Removed fields:</div>
|
||||
<div style={s.fieldTags}>
|
||||
{removedFields.map((f) => (
|
||||
<span key={f} style={s.fieldTag}>{f}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<p style={s.explanation}>
|
||||
All identifying metadata has been stripped. GPS coordinates, device serial numbers,
|
||||
software fingerprints, and creator information are no longer recoverable.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DetectionEvasionSection({
|
||||
proof,
|
||||
frameSamples,
|
||||
}: {
|
||||
proof: SectionProps['proof'];
|
||||
frameSamples: FrameSample[];
|
||||
}): ReactElement {
|
||||
const ev = proof.evidence as Record<string, unknown>;
|
||||
const confBefore = Number(ev.conf_before ?? 0);
|
||||
const confAfter = Number(ev.conf_after ?? 0);
|
||||
const linf = Number(ev.linf ?? 0);
|
||||
const suppression =
|
||||
confBefore > 0 ? Math.round(((confBefore - confAfter) / confBefore) * 100) : 0;
|
||||
|
||||
const [frameIdx, setFrameIdx] = useState(0);
|
||||
const sample = frameSamples[frameIdx] ?? null;
|
||||
const total = frameSamples.length;
|
||||
|
||||
return (
|
||||
<div style={s.opSection}>
|
||||
<h3 style={s.opTitle}>Detection Evasion</h3>
|
||||
<div style={s.statsRow}>
|
||||
<Stat
|
||||
label="Adversary confidence"
|
||||
value={`${confBefore.toFixed(3)} → ${confAfter.toFixed(3)}`}
|
||||
/>
|
||||
<Stat label="Suppression" value={`${suppression}%`} highlight />
|
||||
<Stat label="Max Linf" value={linf.toFixed(4)} />
|
||||
</div>
|
||||
|
||||
{total === 0 ? (
|
||||
<div style={s.noFrames}>Frame samples not available.</div>
|
||||
) : (
|
||||
<>
|
||||
<div style={s.framePair}>
|
||||
<FrameWithBboxes
|
||||
label="Before"
|
||||
b64={sample?.input_b64 ?? null}
|
||||
bboxes={sample?.input_detection?.bboxes ?? []}
|
||||
confidences={sample?.input_detection?.confidences ?? []}
|
||||
/>
|
||||
<FrameWithBboxes
|
||||
label="After"
|
||||
b64={sample?.output_b64 ?? null}
|
||||
bboxes={[]}
|
||||
confidences={[]}
|
||||
noDetectionOverlay={
|
||||
sample?.output_detection
|
||||
? sample.output_detection.face_count === 0
|
||||
: true
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{total > 1 && (
|
||||
<div style={s.frameNav}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFrameIdx((i) => Math.max(0, i - 1))}
|
||||
disabled={frameIdx === 0}
|
||||
style={s.navBtn}
|
||||
>
|
||||
◀ Prev
|
||||
</button>
|
||||
<span style={s.frameCounter}>
|
||||
Frame {frameIdx + 1} of {total}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFrameIdx((i) => Math.min(total - 1, i + 1))}
|
||||
disabled={frameIdx === total - 1}
|
||||
style={s.navBtn}
|
||||
>
|
||||
Next ▶
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<p style={s.explanation}>
|
||||
Adversarial perturbation (L∞ ≤ {linf.toFixed(3)}) suppresses SCRFD-10GF face detection
|
||||
confidence by {suppression}%. The perturbation is imperceptible to humans but breaks
|
||||
automated detection pipelines.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MlCloakSection({ proof }: { proof: SectionProps['proof'] }): ReactElement {
|
||||
const ev = proof.evidence as Record<string, unknown>;
|
||||
const l2 = Number(ev.mean_l2 ?? ev.l2 ?? 0);
|
||||
const linf = Number(ev.max_linf ?? ev.linf ?? 0);
|
||||
const model = String(ev.model ?? 'arcface');
|
||||
const framesAffected = proof.frames_affected;
|
||||
|
||||
return (
|
||||
<div style={s.opSection}>
|
||||
<h3 style={s.opTitle}>ML Cloak (ArcFace)</h3>
|
||||
<div style={s.statsRow}>
|
||||
<Stat label="Frames cloaked" value={`${framesAffected}`} />
|
||||
<Stat label="Mean L2" value={l2.toFixed(4)} />
|
||||
<Stat label="Max Linf" value={linf.toFixed(4)} />
|
||||
<Stat label="Model" value={model} />
|
||||
</div>
|
||||
<p style={s.explanation}>
|
||||
Each frame has been perturbed with an adversarial signal targeting{' '}
|
||||
<strong>{model}</strong> embeddings. The perturbation maximises identity-embedding
|
||||
distance — face recognition fails while the video remains visually unchanged (ε ≤ 0.03).
|
||||
</p>
|
||||
<p style={s.subExplanation}>
|
||||
No side-by-side comparison is shown because the perturbation is imperceptible: the
|
||||
visual difference between cloaked and uncloaked frames is below the threshold of human
|
||||
perception.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GenericSection({ proof }: { proof: SectionProps['proof'] }): ReactElement {
|
||||
return (
|
||||
<div style={s.opSection}>
|
||||
<h3 style={s.opTitle}>{proof.operation_type}</h3>
|
||||
<pre style={s.pre}>{JSON.stringify(proof.evidence, null, 2)}</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface FrameWithBboxesProps {
|
||||
label: string;
|
||||
b64: string | null;
|
||||
bboxes: [number, number, number, number][];
|
||||
confidences: number[];
|
||||
noDetectionOverlay?: boolean;
|
||||
}
|
||||
|
||||
function FrameWithBboxes({
|
||||
label,
|
||||
b64,
|
||||
bboxes,
|
||||
confidences,
|
||||
noDetectionOverlay = false,
|
||||
}: FrameWithBboxesProps): ReactElement {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const imgRef = useRef<HTMLImageElement>(null);
|
||||
|
||||
const drawBboxes = useCallback(() => {
|
||||
const canvas = canvasRef.current;
|
||||
const img = imgRef.current;
|
||||
if (!canvas || !img) return;
|
||||
|
||||
canvas.width = img.naturalWidth || img.width;
|
||||
canvas.height = img.naturalHeight || img.height;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
if (noDetectionOverlay && bboxes.length === 0) {
|
||||
// Draw "No faces detected" text overlay
|
||||
ctx.fillStyle = 'rgba(0,0,0,0.55)';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.fillStyle = '#4caf50';
|
||||
ctx.font = `bold ${Math.max(14, canvas.width / 20)}px monospace`;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText('No faces detected', canvas.width / 2, canvas.height / 2);
|
||||
return;
|
||||
}
|
||||
|
||||
bboxes.forEach(([x1, y1, x2, y2], i) => {
|
||||
const conf = confidences[i] ?? 0;
|
||||
ctx.strokeStyle = '#00e676';
|
||||
ctx.lineWidth = Math.max(2, canvas.width / 200);
|
||||
ctx.strokeRect(x1, y1, x2 - x1, y2 - y1);
|
||||
|
||||
const bboxLabel = conf.toFixed(2);
|
||||
const fontSize = Math.max(12, canvas.width / 30);
|
||||
ctx.font = `bold ${fontSize}px monospace`;
|
||||
ctx.fillStyle = '#00e676';
|
||||
ctx.fillRect(x1, y1 - fontSize - 4, ctx.measureText(bboxLabel).width + 8, fontSize + 4);
|
||||
ctx.fillStyle = '#000';
|
||||
ctx.fillText(bboxLabel, x1 + 4, y1 - 4);
|
||||
});
|
||||
}, [bboxes, confidences, noDetectionOverlay]);
|
||||
|
||||
useEffect(() => {
|
||||
if (b64 && imgRef.current) {
|
||||
imgRef.current.onload = drawBboxes;
|
||||
imgRef.current.src = `data:image/png;base64,${b64}`;
|
||||
}
|
||||
}, [b64, drawBboxes]);
|
||||
|
||||
return (
|
||||
<div style={s.frameContainer}>
|
||||
<div style={s.frameLabel}>{label}</div>
|
||||
<div style={s.frameWrapper}>
|
||||
{b64 ? (
|
||||
<>
|
||||
<img ref={imgRef} alt={label} style={s.frameImg} />
|
||||
<canvas ref={canvasRef} style={s.frameCanvas} />
|
||||
</>
|
||||
) : (
|
||||
<div style={s.framePlaceholder}>No frame</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({
|
||||
label,
|
||||
value,
|
||||
highlight = false,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
highlight?: boolean;
|
||||
}): ReactElement {
|
||||
return (
|
||||
<div style={s.stat}>
|
||||
<span style={s.statLabel}>{label}</span>
|
||||
<span style={{ ...s.statValue, ...(highlight ? s.statValueHighlight : {}) }}>{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const s = {
|
||||
panel: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column' as const,
|
||||
gap: '0',
|
||||
borderTop: '1px solid #1e1e1e',
|
||||
},
|
||||
opSection: {
|
||||
padding: '20px 20px',
|
||||
borderBottom: '1px solid #1a1a1a',
|
||||
display: 'flex',
|
||||
flexDirection: 'column' as const,
|
||||
gap: '12px',
|
||||
},
|
||||
opTitle: {
|
||||
fontSize: '15px',
|
||||
fontWeight: 700,
|
||||
color: '#d0a0f0',
|
||||
margin: 0,
|
||||
fontFamily: 'monospace',
|
||||
},
|
||||
statsRow: {
|
||||
display: 'flex',
|
||||
gap: '20px',
|
||||
flexWrap: 'wrap' as const,
|
||||
},
|
||||
stat: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column' as const,
|
||||
gap: '2px',
|
||||
},
|
||||
statLabel: {
|
||||
fontSize: '10px',
|
||||
color: '#555',
|
||||
textTransform: 'uppercase' as const,
|
||||
letterSpacing: '0.06em',
|
||||
},
|
||||
statValue: {
|
||||
fontSize: '16px',
|
||||
fontWeight: 700,
|
||||
color: '#e0e0e0',
|
||||
fontFamily: 'monospace',
|
||||
},
|
||||
statValueHighlight: {
|
||||
color: '#4caf50',
|
||||
fontSize: '20px',
|
||||
},
|
||||
framePair: {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr 1fr',
|
||||
gap: '12px',
|
||||
},
|
||||
frameContainer: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column' as const,
|
||||
gap: '6px',
|
||||
},
|
||||
frameLabel: {
|
||||
fontSize: '11px',
|
||||
fontWeight: 600,
|
||||
color: '#666',
|
||||
textTransform: 'uppercase' as const,
|
||||
letterSpacing: '0.08em',
|
||||
},
|
||||
frameWrapper: {
|
||||
position: 'relative' as const,
|
||||
background: '#0a0a0a',
|
||||
borderRadius: '6px',
|
||||
overflow: 'hidden',
|
||||
aspectRatio: '16/9',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
frameImg: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'contain' as const,
|
||||
display: 'block',
|
||||
},
|
||||
frameCanvas: {
|
||||
position: 'absolute' as const,
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
pointerEvents: 'none' as const,
|
||||
},
|
||||
framePlaceholder: {
|
||||
color: '#333',
|
||||
fontSize: '12px',
|
||||
fontFamily: 'monospace',
|
||||
},
|
||||
frameNav: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
frameCounter: {
|
||||
fontSize: '12px',
|
||||
color: '#666',
|
||||
fontFamily: 'monospace',
|
||||
},
|
||||
navBtn: {
|
||||
background: 'none',
|
||||
border: '1px solid #333',
|
||||
borderRadius: '4px',
|
||||
color: '#888',
|
||||
fontSize: '12px',
|
||||
cursor: 'pointer',
|
||||
padding: '4px 10px',
|
||||
},
|
||||
explanation: {
|
||||
fontSize: '13px',
|
||||
color: '#666',
|
||||
lineHeight: 1.5,
|
||||
margin: 0,
|
||||
borderLeft: '3px solid #2a2a2a',
|
||||
paddingLeft: '12px',
|
||||
},
|
||||
subExplanation: {
|
||||
fontSize: '12px',
|
||||
color: '#444',
|
||||
lineHeight: 1.5,
|
||||
margin: 0,
|
||||
fontStyle: 'italic' as const,
|
||||
},
|
||||
noFrames: {
|
||||
fontSize: '13px',
|
||||
color: '#444',
|
||||
fontStyle: 'italic' as const,
|
||||
},
|
||||
fieldList: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column' as const,
|
||||
gap: '6px',
|
||||
},
|
||||
fieldListLabel: {
|
||||
fontSize: '11px',
|
||||
color: '#555',
|
||||
textTransform: 'uppercase' as const,
|
||||
letterSpacing: '0.06em',
|
||||
},
|
||||
fieldTags: {
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap' as const,
|
||||
gap: '4px',
|
||||
},
|
||||
fieldTag: {
|
||||
fontSize: '11px',
|
||||
background: '#1a1a2a',
|
||||
color: '#8888cc',
|
||||
borderRadius: '3px',
|
||||
padding: '2px 6px',
|
||||
fontFamily: 'monospace',
|
||||
},
|
||||
pre: {
|
||||
fontSize: '11px',
|
||||
color: '#666',
|
||||
background: '#0a0a0a',
|
||||
padding: '8px',
|
||||
borderRadius: '4px',
|
||||
overflow: 'auto',
|
||||
margin: 0,
|
||||
fontFamily: 'monospace',
|
||||
},
|
||||
empty: {
|
||||
color: '#444',
|
||||
fontSize: '13px',
|
||||
padding: '16px 20px',
|
||||
},
|
||||
} as const;
|
||||
|
|
@ -0,0 +1,454 @@
|
|||
import { useCallback, useEffect, useRef, useState, type CSSProperties, type ReactElement } from 'react';
|
||||
import {
|
||||
listVideoPhotos,
|
||||
listAvailableProtections,
|
||||
submitProtectJob,
|
||||
pollProtectJob,
|
||||
type PhotoItem,
|
||||
type ProtectJobResult,
|
||||
} from '../api/invisible-protect-api';
|
||||
import { AdversaryPanel } from './AdversaryPanel';
|
||||
|
||||
const POLL_INTERVAL_MS = 5000;
|
||||
|
||||
export function InvisibleProtectionsDemo(): ReactElement {
|
||||
const [photos, setPhotos] = useState<PhotoItem[]>([]);
|
||||
const [photosError, setPhotosError] = useState<string | null>(null);
|
||||
const [availableOps, setAvailableOps] = useState<string[]>([]);
|
||||
const [selectedPhoto, setSelectedPhoto] = useState<PhotoItem | null>(null);
|
||||
const [selectedOps, setSelectedOps] = useState<string[]>([]);
|
||||
const [outputMode, setOutputMode] = useState<'default' | 'lossless'>('lossless');
|
||||
const [jobs, setJobs] = useState<ProtectJobResult[]>([]);
|
||||
const [expandedJobId, setExpandedJobId] = useState<string | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
// Load gallery + available operations on mount
|
||||
useEffect(() => {
|
||||
listVideoPhotos()
|
||||
.then(setPhotos)
|
||||
.catch((err: unknown) => setPhotosError(String(err)));
|
||||
listAvailableProtections()
|
||||
.then(setAvailableOps)
|
||||
.catch(() => setAvailableOps(['metadata-strip', 'ml-cloak', 'detection-evasion']));
|
||||
}, []);
|
||||
|
||||
// Poll active jobs every 5s
|
||||
useEffect(() => {
|
||||
const hasActive = jobs.some((j) => j.status === 'queued' || j.status === 'processing');
|
||||
if (!hasActive) {
|
||||
if (pollRef.current) clearInterval(pollRef.current);
|
||||
pollRef.current = null;
|
||||
return;
|
||||
}
|
||||
if (pollRef.current) return;
|
||||
pollRef.current = setInterval(async () => {
|
||||
const updates = await Promise.allSettled(
|
||||
jobs
|
||||
.filter((j) => j.status === 'queued' || j.status === 'processing')
|
||||
.map((j) => pollProtectJob(j.job_id)),
|
||||
);
|
||||
setJobs((prev) =>
|
||||
prev.map((job) => {
|
||||
const match = updates.find(
|
||||
(r) => r.status === 'fulfilled' && r.value.job_id === job.job_id,
|
||||
);
|
||||
return match && match.status === 'fulfilled' ? match.value : job;
|
||||
}),
|
||||
);
|
||||
}, POLL_INTERVAL_MS);
|
||||
return () => {
|
||||
if (pollRef.current) clearInterval(pollRef.current);
|
||||
pollRef.current = null;
|
||||
};
|
||||
}, [jobs]);
|
||||
|
||||
const toggleOp = useCallback((op: string) => {
|
||||
setSelectedOps((prev) =>
|
||||
prev.includes(op) ? prev.filter((o) => o !== op) : [...prev, op],
|
||||
);
|
||||
}, []);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (!selectedPhoto || selectedOps.length === 0) return;
|
||||
setSubmitting(true);
|
||||
setSubmitError(null);
|
||||
try {
|
||||
const { job_id } = await submitProtectJob(selectedPhoto.path, selectedOps, outputMode);
|
||||
const initial: ProtectJobResult = {
|
||||
job_id,
|
||||
status: 'queued',
|
||||
output_path: null,
|
||||
proofs: [],
|
||||
frame_samples: [],
|
||||
error: null,
|
||||
};
|
||||
setJobs((prev) => [initial, ...prev]);
|
||||
} catch (err: unknown) {
|
||||
setSubmitError(String(err));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}, [selectedPhoto, selectedOps, outputMode]);
|
||||
|
||||
return (
|
||||
<div style={s.root}>
|
||||
{/* Top section: gallery + operation picker */}
|
||||
<div style={s.topRow}>
|
||||
{/* Gallery */}
|
||||
<section style={s.gallery}>
|
||||
<h2 style={s.sectionTitle}>Select Video</h2>
|
||||
{photosError ? (
|
||||
<div style={s.error}>
|
||||
Failed to load gallery: {photosError}
|
||||
<br />
|
||||
<span style={s.hint}>
|
||||
Is image-assistant running at 10.0.0.116:5220?
|
||||
</span>
|
||||
</div>
|
||||
) : photos.length === 0 ? (
|
||||
<div style={s.empty}>Loading videos...</div>
|
||||
) : (
|
||||
<div style={s.photoGrid}>
|
||||
{photos.map((photo) => (
|
||||
<button
|
||||
key={photo.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedPhoto(photo)}
|
||||
style={{
|
||||
...s.photoCard,
|
||||
...(selectedPhoto?.id === photo.id ? s.photoCardSelected : {}),
|
||||
}}
|
||||
title={photo.filename}
|
||||
>
|
||||
{photo.thumbnailUrl ? (
|
||||
<img
|
||||
src={photo.thumbnailUrl}
|
||||
alt={photo.filename}
|
||||
style={s.thumbnail}
|
||||
/>
|
||||
) : (
|
||||
<div style={s.thumbnailPlaceholder}>video</div>
|
||||
)}
|
||||
<span style={s.photoName}>{photo.filename}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Operation picker */}
|
||||
<section style={s.opPicker}>
|
||||
<h2 style={s.sectionTitle}>Protections</h2>
|
||||
<div style={s.opList}>
|
||||
{(availableOps.length > 0
|
||||
? availableOps
|
||||
: ['metadata-strip', 'ml-cloak', 'detection-evasion']
|
||||
).map((op) => (
|
||||
<label key={op} style={s.opLabel}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedOps.includes(op)}
|
||||
onChange={() => toggleOp(op)}
|
||||
style={s.checkbox}
|
||||
/>
|
||||
<span style={s.opName}>{op}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={s.outputModeRow}>
|
||||
<span style={s.outputModeLabel}>Output:</span>
|
||||
{(['default', 'lossless'] as const).map((mode) => (
|
||||
<label key={mode} style={s.radioLabel}>
|
||||
<input
|
||||
type="radio"
|
||||
name="outputMode"
|
||||
value={mode}
|
||||
checked={outputMode === mode}
|
||||
onChange={() => setOutputMode(mode)}
|
||||
style={s.radio}
|
||||
/>
|
||||
{mode === 'lossless' ? 'Lossless (FFV1)' : 'Standard (H.264)'}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
disabled={!selectedPhoto || selectedOps.length === 0 || submitting}
|
||||
style={{
|
||||
...s.submitBtn,
|
||||
...(!selectedPhoto || selectedOps.length === 0 || submitting
|
||||
? s.submitBtnDisabled
|
||||
: {}),
|
||||
}}
|
||||
>
|
||||
{submitting ? 'Submitting...' : 'Submit ->'}
|
||||
</button>
|
||||
|
||||
{submitError && <div style={s.error}>{submitError}</div>}
|
||||
|
||||
{selectedPhoto && (
|
||||
<div style={s.selectedHint}>
|
||||
Selected: <strong>{selectedPhoto.filename}</strong>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* Job queue */}
|
||||
{jobs.length > 0 && (
|
||||
<section style={s.jobQueue}>
|
||||
<h2 style={s.sectionTitle}>Jobs</h2>
|
||||
{jobs.map((job) => (
|
||||
<div key={job.job_id} style={s.jobCard}>
|
||||
<div style={s.jobHeader}>
|
||||
<span style={{ ...s.jobStatus, ...statusColor(job.status) }}>
|
||||
{statusIcon(job.status)} {job.status}
|
||||
</span>
|
||||
<span style={s.jobId}>{job.job_id.slice(0, 8)}...</span>
|
||||
{job.status === 'done' && (
|
||||
<button
|
||||
type="button"
|
||||
style={s.expandBtn}
|
||||
onClick={() =>
|
||||
setExpandedJobId((prev) => (prev === job.job_id ? null : job.job_id))
|
||||
}
|
||||
>
|
||||
{expandedJobId === job.job_id ? 'Collapse ^' : 'Adversary View v'}
|
||||
</button>
|
||||
)}
|
||||
{job.error && <span style={s.jobError}>{job.error}</span>}
|
||||
</div>
|
||||
|
||||
{expandedJobId === job.job_id && job.status === 'done' && (
|
||||
<AdversaryPanel job={job} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function statusIcon(status: string): string {
|
||||
switch (status) {
|
||||
case 'queued': return '[queued]';
|
||||
case 'processing': return '[running]';
|
||||
case 'done': return '[done]';
|
||||
case 'failed': return '[failed]';
|
||||
default: return '[?]';
|
||||
}
|
||||
}
|
||||
|
||||
function statusColor(status: string): CSSProperties {
|
||||
switch (status) {
|
||||
case 'done': return { color: '#4caf50' };
|
||||
case 'failed': return { color: '#e53935' };
|
||||
case 'processing': return { color: '#ff9800' };
|
||||
default: return { color: '#888' };
|
||||
}
|
||||
}
|
||||
|
||||
const s = {
|
||||
root: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column' as const,
|
||||
gap: '24px',
|
||||
},
|
||||
topRow: {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr 280px',
|
||||
gap: '24px',
|
||||
alignItems: 'flex-start',
|
||||
},
|
||||
gallery: {
|
||||
background: '#111',
|
||||
borderRadius: '8px',
|
||||
padding: '16px',
|
||||
border: '1px solid #222',
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: '14px',
|
||||
fontWeight: 600,
|
||||
color: '#999',
|
||||
textTransform: 'uppercase' as const,
|
||||
letterSpacing: '0.08em',
|
||||
margin: '0 0 12px',
|
||||
},
|
||||
photoGrid: {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(120px, 1fr))',
|
||||
gap: '8px',
|
||||
},
|
||||
photoCard: {
|
||||
background: '#1a1a1a',
|
||||
border: '2px solid #2a2a2a',
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer',
|
||||
padding: '8px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column' as const,
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
transition: 'border-color 0.15s',
|
||||
},
|
||||
photoCardSelected: {
|
||||
borderColor: '#9b59b6',
|
||||
background: '#1a0e2a',
|
||||
},
|
||||
thumbnail: {
|
||||
width: '100%',
|
||||
aspectRatio: '16/9',
|
||||
objectFit: 'cover' as const,
|
||||
borderRadius: '4px',
|
||||
},
|
||||
thumbnailPlaceholder: {
|
||||
width: '100%',
|
||||
aspectRatio: '16/9',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '11px',
|
||||
color: '#555',
|
||||
background: '#222',
|
||||
borderRadius: '4px',
|
||||
fontFamily: 'monospace',
|
||||
},
|
||||
photoName: {
|
||||
fontSize: '11px',
|
||||
color: '#888',
|
||||
textAlign: 'center' as const,
|
||||
wordBreak: 'break-all' as const,
|
||||
lineHeight: 1.3,
|
||||
},
|
||||
opPicker: {
|
||||
background: '#111',
|
||||
borderRadius: '8px',
|
||||
padding: '16px',
|
||||
border: '1px solid #222',
|
||||
display: 'flex',
|
||||
flexDirection: 'column' as const,
|
||||
gap: '12px',
|
||||
},
|
||||
opList: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column' as const,
|
||||
gap: '8px',
|
||||
},
|
||||
opLabel: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '13px',
|
||||
color: '#ccc',
|
||||
},
|
||||
checkbox: { cursor: 'pointer', accentColor: '#9b59b6' },
|
||||
opName: { fontFamily: 'monospace' },
|
||||
outputModeRow: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column' as const,
|
||||
gap: '4px',
|
||||
borderTop: '1px solid #222',
|
||||
paddingTop: '10px',
|
||||
},
|
||||
outputModeLabel: { fontSize: '12px', color: '#666', marginBottom: '4px' },
|
||||
radioLabel: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
fontSize: '12px',
|
||||
color: '#aaa',
|
||||
cursor: 'pointer',
|
||||
},
|
||||
radio: { cursor: 'pointer', accentColor: '#9b59b6' },
|
||||
submitBtn: {
|
||||
background: '#7b2d8b',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
padding: '10px 16px',
|
||||
fontSize: '14px',
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
marginTop: '4px',
|
||||
transition: 'background 0.15s',
|
||||
},
|
||||
submitBtnDisabled: {
|
||||
background: '#2a2a2a',
|
||||
color: '#555',
|
||||
cursor: 'not-allowed',
|
||||
},
|
||||
selectedHint: {
|
||||
fontSize: '11px',
|
||||
color: '#666',
|
||||
wordBreak: 'break-all' as const,
|
||||
},
|
||||
jobQueue: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column' as const,
|
||||
gap: '8px',
|
||||
},
|
||||
jobCard: {
|
||||
background: '#111',
|
||||
border: '1px solid #222',
|
||||
borderRadius: '8px',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
jobHeader: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
padding: '12px 16px',
|
||||
flexWrap: 'wrap' as const,
|
||||
},
|
||||
jobStatus: {
|
||||
fontSize: '13px',
|
||||
fontWeight: 600,
|
||||
fontFamily: 'monospace',
|
||||
},
|
||||
jobId: {
|
||||
fontSize: '11px',
|
||||
color: '#555',
|
||||
fontFamily: 'monospace',
|
||||
flex: 1,
|
||||
},
|
||||
expandBtn: {
|
||||
background: 'none',
|
||||
border: '1px solid #333',
|
||||
borderRadius: '4px',
|
||||
color: '#c090e0',
|
||||
fontSize: '12px',
|
||||
cursor: 'pointer',
|
||||
padding: '4px 10px',
|
||||
},
|
||||
jobError: {
|
||||
fontSize: '12px',
|
||||
color: '#e53935',
|
||||
flex: 1,
|
||||
},
|
||||
error: {
|
||||
fontSize: '13px',
|
||||
color: '#e57373',
|
||||
background: '#1a0000',
|
||||
borderRadius: '6px',
|
||||
padding: '10px 12px',
|
||||
border: '1px solid #3a1010',
|
||||
},
|
||||
hint: {
|
||||
fontSize: '11px',
|
||||
color: '#666',
|
||||
display: 'block',
|
||||
marginTop: '4px',
|
||||
},
|
||||
empty: {
|
||||
color: '#555',
|
||||
fontSize: '13px',
|
||||
padding: '12px 0',
|
||||
},
|
||||
} as const;
|
||||
Loading…
Add table
Reference in a new issue