Spaces:
Running
Running
| /** | |
| * LTMarx CLI — Video watermark embedding and detection | |
| * | |
| * Usage: | |
| * ltmarx embed -i input.mp4 -o output.mp4 --key SECRET --preset moderate | |
| * ltmarx detect -i video.mp4 --key SECRET --preset moderate | |
| */ | |
| import { parseArgs } from 'node:util'; | |
| import { probeVideo, readYuvFrames, createEncoder } from './ffmpeg-io.js'; | |
| import { embedWatermark } from '../core/embedder.js'; | |
| import { detectWatermark, detectWatermarkMultiFrame } from '../core/detector.js'; | |
| import { getPreset, PRESET_DESCRIPTIONS } from '../core/presets.js'; | |
| import type { PresetName } from '../core/types.js'; | |
| function usage() { | |
| console.log(` | |
| LTMarx — Video Watermarking System | |
| Commands: | |
| embed Embed a watermark into a video | |
| detect Detect and extract a watermark from a video | |
| presets List available presets | |
| Usage: | |
| ltmarx embed -i input.mp4 -o output.mp4 --key SECRET --preset moderate --payload DEADBEEF | |
| ltmarx detect -i video.mp4 --key SECRET --preset moderate [--frames N] | |
| ltmarx presets | |
| Options: | |
| -i, --input Input video file | |
| -o, --output Output video file (embed only) | |
| --key Secret key for watermark | |
| --preset Preset name: light, moderate, strong, fortress | |
| --payload 32-bit payload as hex string (embed only, default: DEADBEEF) | |
| --frames Number of frames to analyze (detect only, default: 10) | |
| --crf Output CRF quality (embed only, default: 18) | |
| `); | |
| process.exit(1); | |
| } | |
| async function main() { | |
| const args = process.argv.slice(2); | |
| const command = args[0]; | |
| if (!command || command === '--help' || command === '-h') usage(); | |
| if (command === 'presets') { | |
| console.log('\nAvailable presets:\n'); | |
| for (const [name, desc] of Object.entries(PRESET_DESCRIPTIONS)) { | |
| console.log(` ${name.padEnd(12)} ${desc}`); | |
| } | |
| console.log(); | |
| process.exit(0); | |
| } | |
| const { values } = parseArgs({ | |
| args: args.slice(1), | |
| options: { | |
| input: { type: 'string', short: 'i' }, | |
| output: { type: 'string', short: 'o' }, | |
| key: { type: 'string' }, | |
| preset: { type: 'string' }, | |
| payload: { type: 'string' }, | |
| frames: { type: 'string' }, | |
| crf: { type: 'string' }, | |
| }, | |
| }); | |
| const input = values.input; | |
| const key = values.key; | |
| const presetName = (values.preset || 'moderate') as PresetName; | |
| if (!input) { console.error('Error: --input is required'); process.exit(1); } | |
| if (!key) { console.error('Error: --key is required'); process.exit(1); } | |
| const config = getPreset(presetName); | |
| if (command === 'embed') { | |
| const output = values.output; | |
| if (!output) { console.error('Error: --output is required for embed'); process.exit(1); } | |
| const payloadHex = values.payload || 'DEADBEEF'; | |
| const payload = hexToBytes(payloadHex); | |
| const crf = parseInt(values.crf || '18', 10); | |
| console.log(`Embedding watermark...`); | |
| console.log(` Input: ${input}`); | |
| console.log(` Output: ${output}`); | |
| console.log(` Preset: ${presetName}`); | |
| console.log(` Payload: ${payloadHex}`); | |
| console.log(` CRF: ${crf}`); | |
| const info = await probeVideo(input); | |
| console.log(` Video: ${info.width}x${info.height} @ ${info.fps.toFixed(2)} fps, ${info.totalFrames} frames`); | |
| const encoder = createEncoder(output, info.width, info.height, info.fps, crf); | |
| let frameCount = 0; | |
| let totalPsnr = 0; | |
| const ySize = info.width * info.height; | |
| const uvSize = (info.width / 2) * (info.height / 2); | |
| for await (const frame of readYuvFrames(input, info.width, info.height)) { | |
| const result = embedWatermark(frame.y, info.width, info.height, payload, key, config); | |
| totalPsnr += result.psnr; | |
| // Write YUV420p frame: watermarked Y + original U + V | |
| const yuvFrame = Buffer.alloc(ySize + 2 * uvSize); | |
| yuvFrame.set(result.yPlane, 0); | |
| yuvFrame.set(frame.u, ySize); | |
| yuvFrame.set(frame.v, ySize + uvSize); | |
| encoder.stdin.write(yuvFrame); | |
| frameCount++; | |
| if (frameCount % 30 === 0) { | |
| process.stdout.write(`\r Progress: ${frameCount} frames (PSNR: ${(totalPsnr / frameCount).toFixed(1)} dB)`); | |
| } | |
| } | |
| encoder.stdin.end(); | |
| await new Promise<void>((resolve) => encoder.process.on('close', () => resolve())); | |
| console.log(`\r Complete: ${frameCount} frames, avg PSNR: ${(totalPsnr / frameCount).toFixed(1)} dB`); | |
| console.log(` Output saved to: ${output}`); | |
| } else if (command === 'detect') { | |
| const maxFrames = parseInt(values.frames || '10', 10); | |
| console.log(`Detecting watermark...`); | |
| console.log(` Input: ${input}`); | |
| console.log(` Preset: ${presetName}`); | |
| console.log(` Frames: ${maxFrames}`); | |
| const info = await probeVideo(input); | |
| console.log(` Video: ${info.width}x${info.height} @ ${info.fps.toFixed(2)} fps`); | |
| const yPlanes: Uint8Array[] = []; | |
| let frameCount = 0; | |
| for await (const frame of readYuvFrames(input, info.width, info.height)) { | |
| yPlanes.push(new Uint8Array(frame.y)); | |
| frameCount++; | |
| if (frameCount >= maxFrames) break; | |
| process.stdout.write(`\r Reading frame ${frameCount}/${maxFrames}...`); | |
| } | |
| console.log(`\r Analyzing ${yPlanes.length} frames...`); | |
| // Try multi-frame detection first | |
| if (yPlanes.length > 1) { | |
| const result = detectWatermarkMultiFrame(yPlanes, info.width, info.height, key, config); | |
| if (result.detected) { | |
| console.log(`\n WATERMARK DETECTED (multi-frame)`); | |
| console.log(` Payload: ${bytesToHex(result.payload!)}`); | |
| console.log(` Confidence: ${(result.confidence * 100).toFixed(1)}%`); | |
| console.log(` Tiles: ${result.tilesDecoded}/${result.tilesTotal}`); | |
| process.exit(0); | |
| } | |
| } | |
| // Try single-frame detection | |
| for (let i = 0; i < yPlanes.length; i++) { | |
| const result = detectWatermark(yPlanes[i], info.width, info.height, key, config); | |
| if (result.detected) { | |
| console.log(`\n WATERMARK DETECTED (frame ${i + 1})`); | |
| console.log(` Payload: ${bytesToHex(result.payload!)}`); | |
| console.log(` Confidence: ${(result.confidence * 100).toFixed(1)}%`); | |
| console.log(` Tiles: ${result.tilesDecoded}/${result.tilesTotal}`); | |
| process.exit(0); | |
| } | |
| } | |
| console.log(`\n No watermark detected.`); | |
| process.exit(1); | |
| } else { | |
| console.error(`Unknown command: ${command}`); | |
| usage(); | |
| } | |
| } | |
| function hexToBytes(hex: string): Uint8Array { | |
| const clean = hex.replace(/^0x/, ''); | |
| const padded = clean.length % 2 ? '0' + clean : clean; | |
| const bytes = new Uint8Array(padded.length / 2); | |
| for (let i = 0; i < bytes.length; i++) { | |
| bytes[i] = parseInt(padded.slice(i * 2, i * 2 + 2), 16); | |
| } | |
| return bytes; | |
| } | |
| function bytesToHex(bytes: Uint8Array): string { | |
| return Array.from(bytes).map((b) => b.toString(16).padStart(2, '0')).join('').toUpperCase(); | |
| } | |
| main().catch((e) => { | |
| console.error('Error:', e.message || e); | |
| process.exit(1); | |
| }); | |