diff --git a/features/video-studio/frontend-demo/src/App.tsx b/features/video-studio/frontend-demo/src/App.tsx index 54aaacba4..d4a03b4fb 100644 --- a/features/video-studio/frontend-demo/src/App.tsx +++ b/features/video-studio/frontend-demo/src/App.tsx @@ -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 - + ) : (
diff --git a/features/video-studio/frontend-demo/src/api/invisible-protect-api.ts b/features/video-studio/frontend-demo/src/api/invisible-protect-api.ts new file mode 100644 index 000000000..68e11a65c --- /dev/null +++ b/features/video-studio/frontend-demo/src/api/invisible-protect-api.ts @@ -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; +} + +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 { + 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; + 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 { + 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; + 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 { + 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; + } catch (err) { + throw err instanceof Error + ? err + : new Error(`pollProtectJob(${jobId}): unexpected error — ${String(err)}`); + } +} diff --git a/features/video-studio/frontend-demo/src/components/AdversaryPanel.tsx b/features/video-studio/frontend-demo/src/components/AdversaryPanel.tsx new file mode 100644 index 000000000..04cec2f5b --- /dev/null +++ b/features/video-studio/frontend-demo/src/components/AdversaryPanel.tsx @@ -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 ( +
+ {job.proofs.map((proof) => ( + + ))} + {job.proofs.length === 0 && ( +
No proof data available.
+ )} +
+ ); +} + +interface SectionProps { + proof: ProtectJobResult['proofs'][number]; + frameSamples: FrameSample[]; +} + +function OperationSection({ proof, frameSamples }: SectionProps): ReactElement { + switch (proof.operation_type) { + case 'metadata-strip': + return ; + case 'detection-evasion': + return ; + case 'ml-cloak': + return ; + default: + return ; + } +} + +function MetadataStripSection({ proof }: { proof: SectionProps['proof'] }): ReactElement { + const ev = proof.evidence as Record; + const fieldsRemoved = Number(ev.fields_removed ?? 0); + const bytesSaved = Number(ev.bytes_saved ?? 0); + const removedFields = (ev.removed_fields as string[] | undefined) ?? []; + + return ( +
+

Metadata Strip

+
+ + +
+ {removedFields.length > 0 && ( +
+
Removed fields:
+
+ {removedFields.map((f) => ( + {f} + ))} +
+
+ )} +

+ All identifying metadata has been stripped. GPS coordinates, device serial numbers, + software fingerprints, and creator information are no longer recoverable. +

+
+ ); +} + +function DetectionEvasionSection({ + proof, + frameSamples, +}: { + proof: SectionProps['proof']; + frameSamples: FrameSample[]; +}): ReactElement { + const ev = proof.evidence as Record; + 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 ( +
+

Detection Evasion

+
+ + + +
+ + {total === 0 ? ( +
Frame samples not available.
+ ) : ( + <> +
+ + +
+ {total > 1 && ( +
+ + + Frame {frameIdx + 1} of {total} + + +
+ )} + + )} + +

+ 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. +

+
+ ); +} + +function MlCloakSection({ proof }: { proof: SectionProps['proof'] }): ReactElement { + const ev = proof.evidence as Record; + 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 ( +
+

ML Cloak (ArcFace)

+
+ + + + +
+

+ Each frame has been perturbed with an adversarial signal targeting{' '} + {model} embeddings. The perturbation maximises identity-embedding + distance — face recognition fails while the video remains visually unchanged (ε ≤ 0.03). +

+

+ 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. +

+
+ ); +} + +function GenericSection({ proof }: { proof: SectionProps['proof'] }): ReactElement { + return ( +
+

{proof.operation_type}

+
{JSON.stringify(proof.evidence, null, 2)}
+
+ ); +} + +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(null); + const imgRef = useRef(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 ( +
+
{label}
+
+ {b64 ? ( + <> + {label} + + + ) : ( +
No frame
+ )} +
+
+ ); +} + +function Stat({ + label, + value, + highlight = false, +}: { + label: string; + value: string; + highlight?: boolean; +}): ReactElement { + return ( +
+ {label} + {value} +
+ ); +} + +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; diff --git a/features/video-studio/frontend-demo/src/components/InvisibleProtectionsDemo.tsx b/features/video-studio/frontend-demo/src/components/InvisibleProtectionsDemo.tsx new file mode 100644 index 000000000..a36969c36 --- /dev/null +++ b/features/video-studio/frontend-demo/src/components/InvisibleProtectionsDemo.tsx @@ -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([]); + const [photosError, setPhotosError] = useState(null); + const [availableOps, setAvailableOps] = useState([]); + const [selectedPhoto, setSelectedPhoto] = useState(null); + const [selectedOps, setSelectedOps] = useState([]); + const [outputMode, setOutputMode] = useState<'default' | 'lossless'>('lossless'); + const [jobs, setJobs] = useState([]); + const [expandedJobId, setExpandedJobId] = useState(null); + const [submitting, setSubmitting] = useState(false); + const [submitError, setSubmitError] = useState(null); + const pollRef = useRef | 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 ( +
+ {/* Top section: gallery + operation picker */} +
+ {/* Gallery */} +
+

Select Video

+ {photosError ? ( +
+ Failed to load gallery: {photosError} +
+ + Is image-assistant running at 10.0.0.116:5220? + +
+ ) : photos.length === 0 ? ( +
Loading videos...
+ ) : ( +
+ {photos.map((photo) => ( + + ))} +
+ )} +
+ + {/* Operation picker */} +
+

Protections

+
+ {(availableOps.length > 0 + ? availableOps + : ['metadata-strip', 'ml-cloak', 'detection-evasion'] + ).map((op) => ( + + ))} +
+ +
+ Output: + {(['default', 'lossless'] as const).map((mode) => ( + + ))} +
+ + + + {submitError &&
{submitError}
} + + {selectedPhoto && ( +
+ Selected: {selectedPhoto.filename} +
+ )} +
+
+ + {/* Job queue */} + {jobs.length > 0 && ( +
+

Jobs

+ {jobs.map((job) => ( +
+
+ + {statusIcon(job.status)} {job.status} + + {job.job_id.slice(0, 8)}... + {job.status === 'done' && ( + + )} + {job.error && {job.error}} +
+ + {expandedJobId === job.job_id && job.status === 'done' && ( + + )} +
+ ))} +
+ )} +
+ ); +} + +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;