ltmarx / server /api.ts
harelcain's picture
Upload 37 files
f2f99a3 verified
/**
* Optional HTTP API for watermark embedding/detection
*
* Serves the web UI and provides API endpoints for server-side processing.
* Used for HuggingFace Space deployment.
*/
import { createServer } from 'node:http';
import { readFile, stat } from 'node:fs/promises';
import { join, extname } from 'node:path';
import { tmpdir } from 'node:os';
import { randomUUID } from 'node:crypto';
import { writeFile, unlink } from 'node:fs/promises';
import { probeVideo, readYuvFrames, createEncoder } from './ffmpeg-io.js';
import { embedWatermark } from '../core/embedder.js';
import { detectWatermark, detectWatermarkMultiFrame } from '../core/detector.js';
import { getPreset } from '../core/presets.js';
import type { PresetName } from '../core/types.js';
const MIME_TYPES: Record<string, string> = {
'.html': 'text/html',
'.js': 'application/javascript',
'.css': 'text/css',
'.json': 'application/json',
'.png': 'image/png',
'.svg': 'image/svg+xml',
'.woff2': 'font/woff2',
};
const PORT = parseInt(process.env.PORT || '7860', 10);
const STATIC_DIR = process.env.STATIC_DIR || join(import.meta.dirname || '.', '../dist/web');
async function serveStatic(url: string): Promise<{ data: Buffer; contentType: string } | null> {
const safePath = url.replace(/\.\./g, '').replace(/\/+/g, '/');
const filePath = join(STATIC_DIR, safePath === '/' ? 'index.html' : safePath);
try {
const s = await stat(filePath);
if (!s.isFile()) return null;
const data = await readFile(filePath);
const ext = extname(filePath);
return { data, contentType: MIME_TYPES[ext] || 'application/octet-stream' };
} catch {
return null;
}
}
const server = createServer(async (req, res) => {
const url = new URL(req.url || '/', `http://localhost:${PORT}`);
// CORS + SharedArrayBuffer headers (required for ffmpeg.wasm)
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
res.setHeader('Cross-Origin-Opener-Policy', 'same-origin');
res.setHeader('Cross-Origin-Embedder-Policy', 'require-corp');
if (req.method === 'OPTIONS') {
res.writeHead(200);
res.end();
return;
}
// API: Health check
if (url.pathname === '/api/health') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ status: 'ok' }));
return;
}
// API: Embed
if (url.pathname === '/api/embed' && req.method === 'POST') {
try {
const chunks: Buffer[] = [];
for await (const chunk of req) chunks.push(chunk as Buffer);
const body = Buffer.concat(chunks);
// Parse multipart or raw JSON
const contentType = req.headers['content-type'] || '';
if (contentType.includes('application/json')) {
const { videoBase64, key, preset, payload } = JSON.parse(body.toString());
const videoBuffer = Buffer.from(videoBase64, 'base64');
const inputPath = join(tmpdir(), `ltmarx-in-${randomUUID()}.mp4`);
const outputPath = join(tmpdir(), `ltmarx-out-${randomUUID()}.mp4`);
await writeFile(inputPath, videoBuffer);
const config = getPreset((preset || 'moderate') as PresetName);
const payloadBytes = hexToBytes(payload || 'DEADBEEF');
const info = await probeVideo(inputPath);
const encoder = createEncoder(outputPath, info.width, info.height, info.fps);
const ySize = info.width * info.height;
const uvSize = (info.width / 2) * (info.height / 2);
let totalPsnr = 0;
let frameCount = 0;
for await (const frame of readYuvFrames(inputPath, info.width, info.height)) {
const result = embedWatermark(frame.y, info.width, info.height, payloadBytes, key, config);
totalPsnr += result.psnr;
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++;
}
encoder.stdin.end();
await new Promise<void>((resolve) => encoder.process.on('close', () => resolve()));
const outputBuffer = await readFile(outputPath);
// Cleanup temp files
await unlink(inputPath).catch(() => {});
await unlink(outputPath).catch(() => {});
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
videoBase64: outputBuffer.toString('base64'),
frames: frameCount,
avgPsnr: totalPsnr / frameCount,
}));
} else {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Expected application/json content type' }));
}
} catch (e) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: String(e) }));
}
return;
}
// API: Detect
if (url.pathname === '/api/detect' && req.method === 'POST') {
try {
const chunks: Buffer[] = [];
for await (const chunk of req) chunks.push(chunk as Buffer);
const body = JSON.parse(Buffer.concat(chunks).toString());
const { videoBase64, key, preset, frames: maxFrames } = body;
const videoBuffer = Buffer.from(videoBase64, 'base64');
const inputPath = join(tmpdir(), `ltmarx-det-${randomUUID()}.mp4`);
await writeFile(inputPath, videoBuffer);
const config = getPreset((preset || 'moderate') as PresetName);
const info = await probeVideo(inputPath);
const framesToRead = Math.min(maxFrames || 10, info.totalFrames);
const yPlanes: Uint8Array[] = [];
let count = 0;
for await (const frame of readYuvFrames(inputPath, info.width, info.height)) {
yPlanes.push(new Uint8Array(frame.y));
count++;
if (count >= framesToRead) break;
}
await unlink(inputPath).catch(() => {});
const result = detectWatermarkMultiFrame(yPlanes, info.width, info.height, key, config);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
detected: result.detected,
payload: result.payload ? bytesToHex(result.payload) : null,
confidence: result.confidence,
tilesDecoded: result.tilesDecoded,
tilesTotal: result.tilesTotal,
}));
} catch (e) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: String(e) }));
}
return;
}
// Static file serving
const staticResult = await serveStatic(url.pathname);
if (staticResult) {
res.writeHead(200, { 'Content-Type': staticResult.contentType });
res.end(staticResult.data);
return;
}
// SPA fallback
const indexResult = await serveStatic('/');
if (indexResult) {
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end(indexResult.data);
return;
}
res.writeHead(404, { 'Content-Type': 'text/plain' });
res.end('Not Found');
});
server.listen(PORT, () => {
console.log(`LTMarx server listening on http://localhost:${PORT}`);
});
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();
}