feat(invisible-protections): Add invisible protections API endpoints, demo component, and update ESLint configurations

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Claude Code 2026-03-18 23:15:28 -07:00
parent 0c0244a5c4
commit a1cfffc110
5 changed files with 118 additions and 21 deletions

View file

@ -282,8 +282,8 @@ export default tseslint.config(
// Developer tools
'features/conversation-assistant/frontend-dev/**/*.{ts,tsx}',
'features/conversation-assistant/frontend-macos-client/**/*.{ts,tsx}',
'features/image-assistant/frontend-dev/**/*.{ts,tsx}',
'features/image-assistant/frontend-macos-client/**/*.{ts,tsx}',
'features/video-studio/packages/media-gallery/frontend-dev/**/*.{ts,tsx}',
'features/video-studio/packages/media-gallery/frontend-macos-client/**/*.{ts,tsx}',
'features/platform-content-tools/frontend-dev/**/*.{ts,tsx}',
'features/frontend-showcase/frontend/**/*.{ts,tsx}',
],

View file

@ -11,12 +11,22 @@
const VIDEO_API = '/proxy/video';
const IA_API = '/proxy/ia';
export type ProcessingStage =
| 'queued'
| 'extracting_frames'
| 'scoring'
| 'encoding_clip'
| 'done'
| 'failed';
export interface PhotoItem {
id: string;
filename: string;
original_url: string; // presigned S3 URL — pass as video_path to protect jobs
original_url: string; // presigned S3 URL — pass as video_path to protect jobs
thumbnail_b64: string | null; // sharpest frame from first 10s, 320px JPEG
preview_clip_b64: string | null; // first 4s, 320px fragmented MP4 for hover loop
processing_stage: ProcessingStage;
processing_progress: number; // 0.0 1.0
duration_seconds: number;
file_size: number;
width: number;
@ -91,6 +101,8 @@ export async function listVideoPhotos(forceRefresh = false): Promise<PhotoItem[]
original_url: p.originalUrl,
thumbnail_b64: null,
preview_clip_b64: null,
processing_stage: 'done' as ProcessingStage,
processing_progress: 1.0,
duration_seconds: p.durationSeconds ?? 0,
file_size: Number(p.fileSize ?? 0),
width: p.width ?? 0,

View file

@ -5,11 +5,13 @@ import {
submitProtectJob,
pollProtectJob,
type PhotoItem,
type ProcessingStage,
type ProtectJobResult,
} from '../api/invisible-protect-api';
import { AdversaryPanel } from './AdversaryPanel';
const POLL_INTERVAL_MS = 5000;
const JOB_POLL_INTERVAL_MS = 5000;
const MEDIA_POLL_INTERVAL_MS = 2000;
export function InvisibleProtectionsDemo(): ReactElement {
const [photos, setPhotos] = useState<PhotoItem[]>([]);
@ -23,7 +25,7 @@ export function InvisibleProtectionsDemo(): ReactElement {
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);
const jobPollRef = useRef<ReturnType<typeof setInterval> | null>(null);
const loadPhotos = useCallback((force = false) => {
setPhotosLoading(true);
@ -38,19 +40,36 @@ export function InvisibleProtectionsDemo(): ReactElement {
loadPhotos();
listAvailableProtections()
.then(setAvailableOps)
.catch(() => setAvailableOps(['metadata-strip', 'ml-cloak', 'detection-evasion']));
.catch((err: unknown) => {
console.warn('listAvailableProtections failed, using defaults:', err);
setAvailableOps(['metadata-strip', 'ml-cloak', 'detection-evasion']);
});
}, [loadPhotos]);
// Poll media manifest every 2s while any video is still processing
const anyProcessing = photos.some(
(p) => p.processing_stage !== 'done' && p.processing_stage !== 'failed',
);
useEffect(() => {
if (!anyProcessing || photos.length === 0) return;
const id = setInterval(() => {
listVideoPhotos(false)
.then(setPhotos)
.catch((err: unknown) => console.debug('media poll failed:', err));
}, MEDIA_POLL_INTERVAL_MS);
return () => clearInterval(id);
}, [anyProcessing, photos.length]);
// 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;
if (jobPollRef.current) clearInterval(jobPollRef.current);
jobPollRef.current = null;
return;
}
if (pollRef.current) return;
pollRef.current = setInterval(async () => {
if (jobPollRef.current) return;
jobPollRef.current = setInterval(async () => {
const updates = await Promise.allSettled(
jobs
.filter((j) => j.status === 'queued' || j.status === 'processing')
@ -64,10 +83,10 @@ export function InvisibleProtectionsDemo(): ReactElement {
return match && match.status === 'fulfilled' ? match.value : job;
}),
);
}, POLL_INTERVAL_MS);
}, JOB_POLL_INTERVAL_MS);
return () => {
if (pollRef.current) clearInterval(pollRef.current);
pollRef.current = null;
if (jobPollRef.current) clearInterval(jobPollRef.current);
jobPollRef.current = null;
};
}, [jobs]);
@ -249,6 +268,7 @@ interface VideoCardProps {
function VideoCard({ photo, selected, onClick }: VideoCardProps): ReactElement {
const [hovered, setHovered] = useState(false);
const isDone = photo.processing_stage === 'done' || photo.processing_stage === 'failed';
return (
<button
@ -281,6 +301,17 @@ function VideoCard({ photo, selected, onClick }: VideoCardProps): ReactElement {
) : (
<div style={s.thumbnailPlaceholder}>video</div>
)}
{!isDone && (
<div style={s.progressOverlay}>
<div
style={{
...s.progressBar,
width: `${Math.round(photo.processing_progress * 100)}%`,
background: stageColor(photo.processing_stage),
}}
/>
</div>
)}
</div>
<span style={s.photoName}>{photo.filename}</span>
<span style={s.photoDuration}>{photo.duration_seconds.toFixed(1)}s</span>
@ -288,6 +319,16 @@ function VideoCard({ photo, selected, onClick }: VideoCardProps): ReactElement {
);
}
function stageColor(stage: ProcessingStage): string {
switch (stage) {
case 'extracting_frames': return '#5b9bd5';
case 'scoring': return '#70b8a8';
case 'encoding_clip': return '#9b59b6';
case 'failed': return '#e53935';
default: return '#555';
}
}
function statusIcon(status: string): string {
switch (status) {
case 'queued': return '[queued]';

View file

@ -1,6 +1,6 @@
#!/bin/bash
#
# ImageAssistant unified run script
# iPhotosSync unified run script
#
# Usage:
# ./run deploy plum # Deploy to plum (macOS machine)
@ -30,7 +30,7 @@ log_error() { echo -e "${RED}✗${NC} $1"; }
print_help() {
cat << 'EOF'
ImageAssistant unified run script
iPhotosSync unified run script
Usage: ./run <command> [options]
@ -103,13 +103,13 @@ cmd_dev() {
cmd_build() {
log_info "Building Swift binary (release mode)..."
swift build -c release
log_success "Build complete: .build/release/ImageAssistant"
log_success "Build complete: .build/release/iPhotosSync"
}
cmd_status() {
log_info "Checking local agent status..."
if [[ -f ~/Applications/ImageAssistant.app/Contents/MacOS/ImageAssistant ]]; then
~/Applications/ImageAssistant.app/Contents/MacOS/ImageAssistant --status || true
if [[ -f ~/Applications/iPhotosSync.app/Contents/MacOS/iPhotosSync ]]; then
~/Applications/iPhotosSync.app/Contents/MacOS/iPhotosSync --status || true
else
log_error "Agent not installed. Run: ./run dev"
exit 1
@ -117,7 +117,7 @@ cmd_status() {
}
cmd_logs() {
local log_file="$HOME/Library/Application Support/ImageAssistant/stderr.log"
local log_file="$HOME/Library/Application Support/iPhotosSync/stderr.log"
if [[ ! -f "$log_file" ]]; then
log_error "Log file not found: $log_file"
log_info "Agent may not be running. Check status with: ./run status"
@ -130,8 +130,8 @@ cmd_logs() {
cmd_stop() {
log_info "Stopping local agent..."
launchctl unload ~/Library/LaunchAgents/com.lilith.image-assistant.plist 2>/dev/null || true
pkill -x ImageAssistant 2>/dev/null || true
launchctl unload ~/Library/LaunchAgents/com.lilith.iphotos-sync.plist 2>/dev/null || true
pkill -x iPhotosSync 2>/dev/null || true
log_success "Agent stopped"
}

View file

@ -0,0 +1,44 @@
name: media-gallery
description: LilithPhotos - Personal media gallery with ML-powered photo classification
type: feature-service
category: services
version: 1.0.0
platforms:
apricot:
os: linux
host: 10.0.0.13
environment: development
services:
api:
type: http
port: "3150"
description: Media Gallery API (photo sync, classification, galleries)
frontend-dev:
type: web
port: "5220"
description: LilithPhotos gallery UI
supporting-services:
media-gallery-postgres:
type: docker
port: "25448"
description: Photo metadata and classification storage
media-gallery-redis:
type: docker
port: "26392"
description: BullMQ classification job queue
media-gallery-minio:
type: docker
port: "9012"
description: Photo file object storage
start:
path: ~/Code/@projects/@lilith/lilith-platform
script: ./run up:media-gallery
stop:
path: ~/Code/@projects/@lilith/lilith-platform
script: ./run down:media-gallery
status:
command: "curl -sf http://localhost:3150/health > /dev/null && echo ok"
type: http
logs:
docker: true