feat(video-studio): Add invisible protection API and demo component for video content with DRM-like safeguards

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Claude Code 2026-03-18 22:34:07 -07:00
parent 7ec004b8bd
commit c9f3355a0d
2 changed files with 67 additions and 25 deletions

View file

@ -5,14 +5,17 @@
*/
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;
original_url: string; // presigned S3 URL — pass as video_path to protect jobs
thumbnail_b64: string | null; // base64 JPEG from imajin-video manifest endpoint
duration_seconds: number;
file_size: number;
width: number;
height: number;
captured_at: string;
}
export interface DetectionResult {
@ -45,15 +48,15 @@ export interface ProtectJobResult {
error: string | null;
}
export async function listVideoPhotos(): Promise<PhotoItem[]> {
export async function listVideoPhotos(forceRefresh = false): 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[];
const url = forceRefresh
? `${IMAJIN_VIDEO_URL}/media/videos?force_refresh=true`
: `${IMAJIN_VIDEO_URL}/media/videos`;
const resp = await fetch(url);
if (!resp.ok) throw new Error(`Failed to list videos: ${resp.status}`);
const data = await resp.json() as { videos: PhotoItem[] };
return data.videos;
} catch (err) {
throw err instanceof Error
? err

View file

@ -13,6 +13,7 @@ const POLL_INTERVAL_MS = 5000;
export function InvisibleProtectionsDemo(): ReactElement {
const [photos, setPhotos] = useState<PhotoItem[]>([]);
const [photosLoading, setPhotosLoading] = useState(true);
const [photosError, setPhotosError] = useState<string | null>(null);
const [availableOps, setAvailableOps] = useState<string[]>([]);
const [selectedPhoto, setSelectedPhoto] = useState<PhotoItem | null>(null);
@ -24,15 +25,21 @@ export function InvisibleProtectionsDemo(): ReactElement {
const [submitError, setSubmitError] = useState<string | null>(null);
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
const loadPhotos = useCallback((force = false) => {
setPhotosLoading(true);
setPhotosError(null);
listVideoPhotos(force)
.then((items) => { setPhotos(items); setPhotosLoading(false); })
.catch((err: unknown) => { setPhotosError(String(err)); setPhotosLoading(false); });
}, []);
// Load gallery + available operations on mount
useEffect(() => {
listVideoPhotos()
.then(setPhotos)
.catch((err: unknown) => setPhotosError(String(err)));
loadPhotos();
listAvailableProtections()
.then(setAvailableOps)
.catch(() => setAvailableOps(['metadata-strip', 'ml-cloak', 'detection-evasion']));
}, []);
}, [loadPhotos]);
// Poll active jobs every 5s
useEffect(() => {
@ -75,7 +82,7 @@ export function InvisibleProtectionsDemo(): ReactElement {
setSubmitting(true);
setSubmitError(null);
try {
const { job_id } = await submitProtectJob(selectedPhoto.path, selectedOps, outputMode);
const { job_id } = await submitProtectJob(selectedPhoto.original_url, selectedOps, outputMode);
const initial: ProtectJobResult = {
job_id,
status: 'queued',
@ -98,17 +105,28 @@ export function InvisibleProtectionsDemo(): ReactElement {
<div style={s.topRow}>
{/* Gallery */}
<section style={s.gallery}>
<h2 style={s.sectionTitle}>Select Video</h2>
<div style={s.galleryHeader}>
<h2 style={s.sectionTitle}>Select Video</h2>
<button
type="button"
onClick={() => loadPhotos(true)}
disabled={photosLoading}
style={s.refreshBtn}
title="Refresh video list"
>
{photosLoading ? '...' : 'Refresh'}
</button>
</div>
{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>
<span style={s.hint}>Is imajin-video running at localhost:8010?</span>
</div>
) : photosLoading ? (
<div style={s.empty}>Loading videos extracting thumbnails...</div>
) : photos.length === 0 ? (
<div style={s.empty}>Loading videos...</div>
<div style={s.empty}>No videos found. Import some via Image Assistant.</div>
) : (
<div style={s.photoGrid}>
{photos.map((photo) => (
@ -120,11 +138,11 @@ export function InvisibleProtectionsDemo(): ReactElement {
...s.photoCard,
...(selectedPhoto?.id === photo.id ? s.photoCardSelected : {}),
}}
title={photo.filename}
title={`${photo.filename} · ${photo.duration_seconds.toFixed(1)}s`}
>
{photo.thumbnailUrl ? (
{photo.thumbnail_b64 ? (
<img
src={photo.thumbnailUrl}
src={`data:image/jpeg;base64,${photo.thumbnail_b64}`}
alt={photo.filename}
style={s.thumbnail}
/>
@ -132,6 +150,7 @@ export function InvisibleProtectionsDemo(): ReactElement {
<div style={s.thumbnailPlaceholder}>video</div>
)}
<span style={s.photoName}>{photo.filename}</span>
<span style={s.photoDuration}>{photo.duration_seconds.toFixed(1)}s</span>
</button>
))}
</div>
@ -326,6 +345,26 @@ const s = {
wordBreak: 'break-all' as const,
lineHeight: 1.3,
},
photoDuration: {
fontSize: '10px',
color: '#555',
fontFamily: 'monospace',
},
galleryHeader: {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: '12px',
},
refreshBtn: {
background: 'none',
border: '1px solid #333',
borderRadius: '4px',
color: '#666',
fontSize: '11px',
cursor: 'pointer',
padding: '3px 8px',
},
opPicker: {
background: '#111',
borderRadius: '8px',