ltmarx / web /src /components /EmbedPanel.tsx
harelcain's picture
Upload 16 files
44f463d verified
import { useState, useRef, useCallback, useEffect } from 'react';
import type { PresetName } from '@core/types.js';
import { getPreset, PRESET_DESCRIPTIONS } from '@core/presets.js';
import { streamExtractAndEmbed } from '../lib/video-io.js';
import StrengthSlider from './StrengthSlider.js';
import ComparisonView from './ComparisonView.js';
import RobustnessTest from './RobustnessTest.js';
const PLEASE_HOLD = [
{ art: ' (β€’_β€’)\n ( β€’_β€’)>βŒβ– -β– \n (βŒβ– _β– )', caption: 'Putting on invisibility shades...' },
{ art: ' β”Œ(β–€ΔΉΜ―β–€)┐\n β”Œ(β–€ΔΉΜ―β–€)β”˜\n β””(β–€ΔΉΜ―β–€)┐', caption: 'Robot dance while we wait...' },
{ art: ' ╔══╗\n β•‘β–“β–“β•‘ β˜•\n β•šβ•β•β•', caption: 'Brewing the perfect watermark...' },
{ art: ' 🎬 β†’ πŸ”¬ β†’ πŸ’Ž', caption: 'Turning pixels into secrets...' },
{ art: ' [β–“β–“β–“β–‘β–‘β–‘β–‘β–‘β–‘β–‘]\n [β–“β–“β–“β–“β–“β–‘β–‘β–‘β–‘β–‘]\n [β–“β–“β–“β–“β–“β–“β–“β–“β–‘β–‘]', caption: 'Hiding bits in plain sight...' },
{ art: ' /\\_/\\\n ( o.o )\n > ^ <', caption: 'Even the cat can\'t see the watermark...' },
{ art: ' β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”\n β”‚ 01101001 β”‚\n β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜', caption: 'Whispering bits into wavelets...' },
{ art: ' ~~ 🌊 ~~\n ~πŸ„~ ~~ \n ~~~~~~~~', caption: 'Surfing the frequency domain...' },
{ art: ' πŸ“Ό ➜ 🧬 ➜ πŸ“Ό', caption: 'Splicing invisible DNA into frames...' },
{ art: ' Β―\\_(ツ)_/Β―', caption: 'Trust us, the watermark is there...' },
];
/** Max frames to keep in memory for the comparison view */
const COMPARISON_SAMPLE = 30;
export default function EmbedPanel() {
const [videoUrl, setVideoUrl] = useState<string | null>(null);
const [videoName, setVideoName] = useState('');
const [key, setKey] = useState('');
const [payload, setPayload] = useState('DEADBEEF');
const [preset, setPreset] = useState<PresetName>('light');
const [alpha, setAlpha] = useState(0.00);
const [processing, setProcessing] = useState(false);
const [progress, setProgress] = useState({ phase: '', current: 0, total: 0 });
const [jokeIndex, setJokeIndex] = useState(0);
const [result, setResult] = useState<{
blob: Blob;
psnr: number;
frames: number;
originalFrames: ImageData[];
watermarkedFrames: ImageData[];
width: number;
height: number;
fps: number;
embedTimeMs: number;
pixelsPerSecond: number;
} | null>(null);
const fileRef = useRef<HTMLInputElement>(null);
// Rotate jokes every 4s while processing
useEffect(() => {
if (!processing) return;
setJokeIndex(Math.floor(Math.random() * PLEASE_HOLD.length));
const id = setInterval(() => {
setJokeIndex((i) => (i + 1) % PLEASE_HOLD.length);
}, 4000);
return () => clearInterval(id);
}, [processing]);
const handleFile = useCallback((file: File) => {
const url = URL.createObjectURL(file);
setVideoUrl(url);
setVideoName(file.name);
setResult(null);
}, [result]);
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
const file = e.dataTransfer.files[0];
if (file?.type.startsWith('video/')) handleFile(file);
},
[handleFile]
);
const handleEmbed = async () => {
if (!videoUrl || !key) return;
setProcessing(true);
setResult(null);
try {
// Parse payload
const payloadHex = payload.replace(/^0x/, '');
const payloadBytes = new Uint8Array(
(payloadHex.length % 2 ? '0' + payloadHex : payloadHex)
.match(/.{2}/g)!
.map((b) => parseInt(b, 16))
);
const config = getPreset(preset);
// Stream: extract -> watermark -> encode in chunks
const embedResult = await streamExtractAndEmbed(
videoUrl,
payloadBytes,
key,
config,
COMPARISON_SAMPLE,
(phase, current, total) => setProgress({ phase, current, total })
);
setResult({
blob: embedResult.blob,
psnr: embedResult.avgPsnr,
frames: embedResult.totalFrames,
originalFrames: embedResult.sampleOriginal,
watermarkedFrames: embedResult.sampleWatermarked,
width: embedResult.width,
height: embedResult.height,
fps: embedResult.fps,
embedTimeMs: embedResult.embedTimeMs,
pixelsPerSecond: embedResult.pixelsPerSecond,
});
} catch (e) {
console.error('Embed error:', e);
alert(`Error: ${e}`);
} finally {
setProcessing(false);
}
};
const handleDownload = () => {
if (!result) return;
const url = URL.createObjectURL(result.blob);
const a = document.createElement('a');
a.href = url;
a.download = videoName.replace(/\.[^.]+$/, '') + '_watermarked.mp4';
a.click();
URL.revokeObjectURL(url);
};
const maxPayloadHexChars = 8; // Always 32 bits = 8 hex chars
return (
<div className="space-y-8">
{/* Upload area */}
<div
onDrop={handleDrop}
onDragOver={(e) => e.preventDefault()}
onClick={() => fileRef.current?.click()}
className={`group cursor-pointer rounded-2xl border-2 border-dashed p-10 text-center transition-all duration-200
${videoUrl
? 'border-zinc-700 bg-zinc-900/30'
: 'border-zinc-800 bg-zinc-900/20 hover:border-blue-600/40 hover:bg-zinc-900/40 hover:shadow-lg hover:shadow-blue-600/5'
}`}
>
<input
ref={fileRef}
type="file"
accept="video/*"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) handleFile(file);
}}
/>
{videoUrl ? (
<div className="space-y-2">
<p className="text-sm font-medium text-zinc-300">🎬 {videoName}</p>
<p className="text-xs text-zinc-500">Click or drop to replace</p>
</div>
) : (
<div className="space-y-3">
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-xl bg-zinc-800/80 transition-colors group-hover:bg-blue-600/10">
<svg className="h-6 w-6 text-zinc-500 transition-colors group-hover:text-blue-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5" />
</svg>
</div>
<p className="text-sm text-zinc-400">Drop a video file or click to browse</p>
<p className="text-xs text-zinc-600">MP4, WebM, MOV supported</p>
</div>
)}
</div>
{/* Configuration */}
<div className="grid gap-6 sm:grid-cols-2">
<div className="space-y-1.5">
<label className="text-sm font-medium text-zinc-300">πŸ”‘ Secret Key</label>
<input
type="text"
value={key}
onChange={(e) => setKey(e.target.value)}
placeholder="Enter a secret key..."
className="w-full rounded-lg border border-zinc-800 bg-zinc-900/50 px-3 py-2 text-sm text-zinc-100
placeholder:text-zinc-600 focus:border-blue-600 focus:outline-none focus:ring-1 focus:ring-blue-600
transition-colors"
/>
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium text-zinc-300">πŸ“¦ Payload (hex)</label>
<input
type="text"
value={payload}
onChange={(e) => setPayload(e.target.value.replace(/[^0-9a-fA-F]/g, '').slice(0, maxPayloadHexChars))}
placeholder="DEADBEEF"
className="w-full rounded-lg border border-zinc-800 bg-zinc-900/50 px-3 py-2 font-mono text-sm text-zinc-100
placeholder:text-zinc-600 focus:border-blue-600 focus:outline-none focus:ring-1 focus:ring-blue-600
transition-colors"
/>
<p className="text-[10px] text-zinc-600">32-bit payload, 4 bytes hex</p>
</div>
</div>
<StrengthSlider
value={alpha}
onChange={(v, p) => {
setAlpha(v);
setPreset(p);
}}
disabled={processing}
/>
<p className="text-xs text-zinc-500">
{PRESET_DESCRIPTIONS[preset]}
</p>
{/* Embed button */}
<button
onClick={handleEmbed}
disabled={!videoUrl || !key || processing}
className="w-full rounded-xl bg-gradient-to-r from-blue-600 to-blue-500 px-4 py-3 text-sm font-semibold text-white
shadow-lg shadow-blue-600/20 transition-all hover:from-blue-500 hover:to-blue-400 hover:shadow-blue-500/30
disabled:cursor-not-allowed disabled:from-zinc-800 disabled:to-zinc-800 disabled:text-zinc-500 disabled:shadow-none"
>
{processing ? (
<span className="flex items-center justify-center gap-2">
<span className="h-4 w-4 animate-spin rounded-full border-2 border-zinc-400 border-t-white" />
{progress.phase} {progress.total > 0 ? `${progress.current}/${progress.total}` : ''}
</span>
) : (
'✨ Embed Watermark'
)}
</button>
{/* Please-hold jokes while processing */}
{processing && (
<div className="flex flex-col items-center gap-2 rounded-xl bg-zinc-900/50 py-6 text-center">
<pre className="font-mono text-sm leading-tight text-zinc-400">
{PLEASE_HOLD[jokeIndex].art}
</pre>
<p className="text-xs text-zinc-500">{PLEASE_HOLD[jokeIndex].caption}</p>
</div>
)}
{/* Results */}
{result && (
<div className="space-y-6 rounded-2xl border border-emerald-800/30 bg-emerald-950/10 p-6">
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-semibold text-emerald-400">βœ… Embedding Complete</h3>
<p className="mt-1 text-xs text-zinc-500">
{result.frames} frames processed β€” Average PSNR: {result.psnr.toFixed(1)} dB
<br />
Embedded in {(result.embedTimeMs / 1000).toFixed(1)}s β€” {(result.pixelsPerSecond / 1e6).toFixed(1)} Mpx/s
</p>
</div>
<button
onClick={handleDownload}
className="rounded-lg bg-emerald-600/20 px-4 py-2 text-sm font-medium text-emerald-400
ring-1 ring-emerald-600/30 transition-all hover:bg-emerald-600/30 hover:ring-emerald-500/40"
>
⬇️ Download
</button>
</div>
<ComparisonView
originalFrames={result.originalFrames}
watermarkedFrames={result.watermarkedFrames}
width={result.width}
height={result.height}
fps={result.fps}
/>
<RobustnessTest
blob={result.blob}
width={result.width}
height={result.height}
payload={payload}
secretKey={key}
/>
</div>
)}
</div>
);
}