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:
parent
7ec004b8bd
commit
c9f3355a0d
2 changed files with 67 additions and 25 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue