Files
Greenlens/scripts/social-video-renderer.html
2026-03-29 10:26:38 -05:00

287 lines
8.6 KiB
HTML

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>GreenLens Social Video Renderer</title>
<style>
html, body {
margin: 0;
width: 100%;
height: 100%;
overflow: hidden;
background: #0b1711;
}
canvas {
width: 360px;
height: 640px;
display: block;
margin: 0 auto;
image-rendering: auto;
}
</style>
</head>
<body>
<canvas id="canvas" width="720" height="1280"></canvas>
<script>
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
const WIDTH = canvas.width;
const HEIGHT = canvas.height;
function roundedRectPath(x, y, w, h, r) {
const radius = Math.min(r, w / 2, h / 2);
ctx.beginPath();
ctx.moveTo(x + radius, y);
ctx.arcTo(x + w, y, x + w, y + h, radius);
ctx.arcTo(x + w, y + h, x, y + h, radius);
ctx.arcTo(x, y + h, x, y, radius);
ctx.arcTo(x, y, x + w, y, radius);
ctx.closePath();
}
function easeOutCubic(t) {
return 1 - Math.pow(1 - t, 3);
}
function clamp(value, min, max) {
return Math.max(min, Math.min(max, value));
}
function drawBackground(accentA, accentB) {
const bg = ctx.createLinearGradient(0, 0, WIDTH, HEIGHT);
bg.addColorStop(0, '#07110c');
bg.addColorStop(0.55, accentA || '#10251a');
bg.addColorStop(1, accentB || '#29553f');
ctx.fillStyle = bg;
ctx.fillRect(0, 0, WIDTH, HEIGHT);
ctx.globalAlpha = 0.2;
ctx.fillStyle = '#d9f5d0';
ctx.beginPath();
ctx.arc(80, 120, 180, 0, Math.PI * 2);
ctx.fill();
ctx.globalAlpha = 0.12;
ctx.beginPath();
ctx.arc(WIDTH - 40, HEIGHT - 120, 260, 0, Math.PI * 2);
ctx.fill();
ctx.globalAlpha = 1;
}
function drawImageCover(img, x, y, w, h, scale = 1, offsetX = 0, offsetY = 0) {
const imgRatio = img.width / img.height;
const boxRatio = w / h;
let drawW;
let drawH;
if (imgRatio > boxRatio) {
drawH = h * scale;
drawW = drawH * imgRatio;
} else {
drawW = w * scale;
drawH = drawW / imgRatio;
}
const dx = x + (w - drawW) / 2 + offsetX;
const dy = y + (h - drawH) / 2 + offsetY;
ctx.drawImage(img, dx, dy, drawW, drawH);
}
function drawCard(scene, progress) {
const imageX = 52;
const imageY = 160;
const imageW = WIDTH - 104;
const imageH = 700;
const scale = 1.02 + progress * 0.07;
const slide = (1 - progress) * 22;
ctx.save();
roundedRectPath(imageX, imageY, imageW, imageH, 42);
ctx.clip();
drawImageCover(scene.image, imageX, imageY, imageW, imageH, scale, 0, slide);
const overlay = ctx.createLinearGradient(0, imageY, 0, imageY + imageH);
overlay.addColorStop(0, 'rgba(4, 10, 7, 0.08)');
overlay.addColorStop(0.55, 'rgba(4, 10, 7, 0.18)');
overlay.addColorStop(1, 'rgba(4, 10, 7, 0.7)');
ctx.fillStyle = overlay;
ctx.fillRect(imageX, imageY, imageW, imageH);
ctx.restore();
ctx.save();
ctx.strokeStyle = 'rgba(255,255,255,0.12)';
ctx.lineWidth = 2;
roundedRectPath(imageX, imageY, imageW, imageH, 42);
ctx.stroke();
ctx.restore();
}
function drawLogo(logo, progress) {
const size = 84;
const x = 56;
const y = 56 - (1 - progress) * 10;
ctx.save();
ctx.globalAlpha = progress;
roundedRectPath(x, y, size, size, 24);
ctx.clip();
ctx.drawImage(logo, x, y, size, size);
ctx.restore();
}
function drawText(scene, progress) {
const titleY = 942 - (1 - progress) * 18;
const subtitleY = 1128 - (1 - progress) * 14;
const alpha = clamp(progress * 1.15, 0, 1);
ctx.globalAlpha = alpha;
if (scene.badge) {
ctx.save();
ctx.fillStyle = 'rgba(217,245,208,0.13)';
roundedRectPath(56, 922 - 76, 220, 48, 22);
ctx.fill();
ctx.fillStyle = '#d9f5d0';
ctx.font = '700 24px Arial';
ctx.fillText(scene.badge.toUpperCase(), 76, 878);
ctx.restore();
}
ctx.fillStyle = '#ffffff';
ctx.font = '700 64px Arial';
wrapText(scene.title, 56, titleY, WIDTH - 112, 74);
ctx.fillStyle = 'rgba(255,255,255,0.84)';
ctx.font = '400 30px Arial';
wrapText(scene.subtitle, 56, subtitleY, WIDTH - 112, 42);
if (scene.cta) {
ctx.save();
const ctaY = HEIGHT - 98;
ctx.fillStyle = 'rgba(255,255,255,0.10)';
roundedRectPath(56, ctaY - 38, WIDTH - 112, 58, 20);
ctx.fill();
ctx.fillStyle = '#d9f5d0';
ctx.font = '700 24px Arial';
ctx.fillText(scene.cta, 82, ctaY);
ctx.restore();
}
ctx.globalAlpha = 1;
}
function wrapText(text, x, y, maxWidth, lineHeight) {
const words = text.split(/\s+/);
let line = '';
let lineY = y;
for (const word of words) {
const testLine = line ? `${line} ${word}` : word;
const width = ctx.measureText(testLine).width;
if (width > maxWidth && line) {
ctx.fillText(line, x, lineY);
line = word;
lineY += lineHeight;
} else {
line = testLine;
}
}
if (line) {
ctx.fillText(line, x, lineY);
}
}
function loadImage(src) {
return new Promise((resolve, reject) => {
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => resolve(img);
img.onerror = reject;
img.src = src;
});
}
async function renderVideo(payload) {
const logo = await loadImage(payload.logo);
const scenes = await Promise.all(
payload.scenes.map(async (scene) => ({
...scene,
image: await loadImage(scene.image),
}))
);
const stream = canvas.captureStream(payload.fps || 30);
const mimeType = 'video/webm;codecs=vp9';
const recorder = new MediaRecorder(stream, { mimeType, videoBitsPerSecond: 5_000_000 });
const chunks = [];
recorder.ondataavailable = (event) => {
if (event.data && event.data.size > 0) {
chunks.push(event.data);
}
};
const stopped = new Promise((resolve) => {
recorder.onstop = resolve;
});
const totalDuration = scenes.reduce((sum, scene) => sum + scene.durationMs, 0);
const startedAt = performance.now();
recorder.start(250);
await new Promise((resolve) => {
function frame(now) {
const elapsed = now - startedAt;
const cappedElapsed = Math.min(elapsed, totalDuration);
let offset = 0;
let activeScene = scenes[scenes.length - 1];
let sceneElapsed = activeScene.durationMs;
for (const scene of scenes) {
if (cappedElapsed <= offset + scene.durationMs) {
activeScene = scene;
sceneElapsed = cappedElapsed - offset;
break;
}
offset += scene.durationMs;
}
const normalized = clamp(sceneElapsed / activeScene.durationMs, 0, 1);
const intro = easeOutCubic(clamp(normalized / 0.24, 0, 1));
drawBackground(payload.accentA, payload.accentB);
drawCard(activeScene, normalized);
drawLogo(logo, intro);
drawText(activeScene, intro);
if (elapsed < totalDuration) {
requestAnimationFrame(frame);
} else {
resolve();
}
}
requestAnimationFrame(frame);
});
await new Promise((resolve) => setTimeout(resolve, 350));
recorder.stop();
await stopped;
const blob = new Blob(chunks, { type: mimeType });
const uploadUrl = `/upload?name=${encodeURIComponent(payload.outputName)}`;
const response = await fetch(uploadUrl, { method: 'POST', body: blob });
if (!response.ok) {
throw new Error(`Upload failed with status ${response.status}`);
}
return response.text();
}
window.renderVideo = renderVideo;
</script>
</body>
</html>