videoapp-compiler / api /generate.php
josephrw's picture
Upload api/generate.php with huggingface_hub
285cfa0 verified
Raw
History Blame Contribute Delete
17.5 kB
<?php
// generate.php — Generate a UNIQUE micro-game from video analysis
// Output: a single playable HTML file with embedded game logic
// Every video produces a different game
require_once __DIR__ . '/config.php';
ensure_dirs();
$id = $_POST['id'] ?? $_GET['id'] ?? null;
$format = $_POST['format'] ?? $_GET['format'] ?? 'arcade';
if (!$id || !preg_match('/^[a-f0-9]{16}$/', $id)) {
fail_json('Invalid id');
}
$receipt = read_json_file(RECEIPT_DIR . "/$id/receipt.json");
$analysis = read_json_file(RECEIPT_DIR . "/$id/analysis.json");
if (!$receipt) fail_json('Receipt not found', 404);
if (!$analysis) fail_json('Analysis missing. Run analyze first.', 400);
$appDir = GENERATED_DIR . "/$id";
if (!is_dir($appDir)) {
mkdir($appDir, 0775, true);
}
// ─── EXTRACT GAME DESIGN ─────────────────────────────
$gameTitle = htmlspecialchars($analysis['game_title'] ?? 'MicroGame');
$gameConcept = htmlspecialchars($analysis['game_concept'] ?? 'A game inspired by video content');
$gameMechanic = htmlspecialchars($analysis['game_mechanic'] ?? 'Click to play');
$gameGenre = $analysis['game_genre'] ?? 'arcade';
$gameWin = htmlspecialchars($analysis['game_win'] ?? 'Reach target score');
$gameLose = htmlspecialchars($analysis['game_lose'] ?? 'Miss too many');
$inspiration = htmlspecialchars($analysis['inspiration'] ?? '');
$understanding = htmlspecialchars($analysis['understanding'] ?? '');
$colors = $analysis['game_colors'] ?? [];
$bgColor = $colors['bg'] ?? '#0a0a0f';
$primaryColor = $colors['primary'] ?? '#ff8c32';
$accentColor = $colors['accent'] ?? '#3aff7a';
$textColor = $colors['text'] ?? '#eee8df';
// Build JSON config for the game engine (injected via script tag)
$gameConfig = json_encode([
'title' => $analysis['game_title'] ?? 'MicroGame',
'concept' => $analysis['game_concept'] ?? '',
'mechanic' => $analysis['game_mechanic'] ?? '',
'genre' => $gameGenre,
'entities' => $analysis['game_entities'] ?? ['target'],
'powerups' => $analysis['game_powerups'] ?? [],
'win' => $analysis['game_win'] ?? 'Reach target score',
'lose' => $analysis['game_lose'] ?? 'Miss too many',
'scoring' => $analysis['game_scoring'] ?? 'Score increases on hit',
'difficulty' => $analysis['game_difficulty'] ?? 'Speed increases over time',
'inspiration' => $analysis['inspiration'] ?? '',
'understanding' => $analysis['understanding'] ?? '',
'colors' => [
'bg' => $bgColor,
'primary' => $primaryColor,
'accent' => $accentColor,
'text' => $textColor
]
], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
// ─── HTML HEAD + BODY (PHP heredoc with interpolation) ───
$html = <<<HTML
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>$gameTitle</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{background:$bgColor;color:$textColor;font-family:ui-monospace,monospace;overflow:hidden;display:flex;flex-direction:column;align-items:center;justify-content:center;height:100vh}
#gameContainer{position:relative;width:min(800px,95vw);height:min(600px,85vh)}
canvas{display:block;border-radius:12px;box-shadow:0 0 40px rgba(255,140,50,.15)}
#hud{position:absolute;top:12px;left:12px;right:12px;display:flex;justify-content:space-between;pointer-events:none;z-index:10}
.hud-item{background:rgba(0,0,0,.6);padding:8px 16px;border-radius:8px;font-size:14px;border:1px solid rgba(255,255,255,.1)}
#score{color:$primaryColor;font-weight:bold}
#lives{color:$accentColor}
#timer{color:$textColor}
#overlay{position:absolute;inset:0;background:rgba(0,0,0,.85);display:flex;flex-direction:column;align-items:center;justify-content:center;z-index:20;border-radius:12px}
#overlay h1{color:$primaryColor;font-size:32px;margin-bottom:12px;text-align:center}
#overlay p{color:$textColor;font-size:14px;max-width:400px;text-align:center;margin-bottom:8px;line-height:1.6}
#overlay .concept{color:$accentColor;font-style:italic;margin-bottom:20px}
#startBtn{padding:14px 40px;background:$primaryColor;color:$bgColor;border:none;border-radius:10px;font-size:18px;font-weight:bold;cursor:pointer;font-family:inherit}
#startBtn:hover{opacity:.85}
#inspiration{position:absolute;bottom:12px;left:12px;right:12px;color:rgba(255,255,255,.3);font-size:10px;text-align:center;pointer-events:none}
.hidden{display:none!important}
.game-over-stats{margin:16px 0;text-align:center}
.game-over-stats .label{color:rgba(255,255,255,.4);font-size:11px}
.game-over-stats .value{color:$primaryColor;font-size:24px;font-weight:bold}
</style>
</head>
<body>
<div id="gameContainer">
<div id="hud">
<div class="hud-item" id="score">SCORE: 0</div>
<div class="hud-item" id="timer">TIME: 0s</div>
<div class="hud-item" id="lives">LIVES: 3</div>
</div>
<canvas id="game" width="800" height="600"></canvas>
<div id="overlay">
<h1 id="title">$gameTitle</h1>
<p class="concept">$gameConcept</p>
<p>$gameMechanic</p>
<p style="color:rgba(255,255,255,.5);font-size:12px">Win: $gameWin &middot; Lose: $gameLose</p>
<button id="startBtn">PLAY</button>
</div>
<div id="inspiration">Inspired by video evidence &middot; $gameGenre &middot; source video exposed: false</div>
</div>
<script id="gameConfig" type="application/json">$gameConfig</script>
<script src="game.js"></script>
</body>
</html>
HTML;
// ─── GAME ENGINE JS (PHP nowdoc — NO interpolation) ───
$js = <<<'JSEOF'
(function(){
const cfg = JSON.parse(document.getElementById('gameConfig').textContent);
const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');
const overlay = document.getElementById('overlay');
const scoreEl = document.getElementById('score');
const livesEl = document.getElementById('lives');
const timerEl = document.getElementById('timer');
const startBtn = document.getElementById('startBtn');
const C = cfg.colors;
let W = canvas.width, H = canvas.height;
let state = 'menu';
let score = 0, lives = 3, time = 0, startTime = 0;
let entities = [], particles = [], powerupActive = null;
let difficulty = 1;
let lastTime = 0;
let mouse = {x: W/2, y: H/2, down: false};
function resize() {
const container = document.getElementById('gameContainer');
const rect = container.getBoundingClientRect();
canvas.width = Math.min(800, rect.width);
canvas.height = Math.min(600, rect.height);
W = canvas.width; H = canvas.height;
}
window.addEventListener('resize', resize);
resize();
canvas.addEventListener('mousemove', e => {
const rect = canvas.getBoundingClientRect();
mouse.x = (e.clientX - rect.left) * (canvas.width / rect.width);
mouse.y = (e.clientY - rect.top) * (canvas.height / rect.height);
});
canvas.addEventListener('mousedown', () => { mouse.down = true; });
canvas.addEventListener('mouseup', () => { mouse.down = false; });
canvas.addEventListener('click', e => {
if (state === 'playing') handleClick(mouse.x, mouse.y);
});
function makeEntity(type, x, y) {
const names = cfg.entities.length ? cfg.entities : ['target'];
const name = names[Math.floor(Math.random() * names.length)] || 'target';
return {
type: type || 'target',
name: name,
x: x !== undefined ? x : Math.random() * (W - 60) + 30,
y: y !== undefined ? y : Math.random() * (H - 60) + 30,
vx: (Math.random() - 0.5) * (2 + difficulty),
vy: (Math.random() - 0.5) * (2 + difficulty),
r: 20 + Math.random() * 15,
hp: type === 'enemy' ? 2 : 1,
maxHp: type === 'enemy' ? 2 : 1,
color: type === 'enemy' ? '#ff4444' : C.primary,
alive: true,
age: 0,
pulse: Math.random() * Math.PI * 2,
points: type === 'enemy' ? 50 : 10
};
}
function makeParticle(x, y, color) {
return {
x, y,
vx: (Math.random() - 0.5) * 6,
vy: (Math.random() - 0.5) * 6,
life: 1,
color: color || C.primary,
size: 2 + Math.random() * 3
};
}
function spawnEntities() {
const count = 3 + Math.floor(difficulty);
for (let i = 0; i < count; i++) {
const isEnemy = Math.random() < 0.25 + difficulty * 0.03;
entities.push(makeEntity(isEnemy ? 'enemy' : 'target'));
}
}
function handleClick(x, y) {
let hit = false;
for (let e of entities) {
if (!e.alive) continue;
const dx = x - e.x, dy = y - e.y;
if (dx*dx + dy*dy < e.r*e.r) {
e.hp--;
hit = true;
if (e.hp <= 0) {
e.alive = false;
score += e.points;
scoreEl.textContent = 'SCORE: ' + score;
for (let i = 0; i < 12; i++) {
particles.push(makeParticle(e.x, e.y, e.color));
}
if (cfg.powerups.length > 0 && Math.random() < 0.15) {
const pu = cfg.powerups[Math.floor(Math.random() * cfg.powerups.length)];
powerupActive = {name: pu, time: 5};
}
} else {
for (let i = 0; i < 5; i++) {
particles.push(makeParticle(e.x, e.y, e.color));
}
}
}
}
if (!hit && (cfg.genre === 'arcade' || cfg.genre === 'action')) {
score = Math.max(0, score - 5);
scoreEl.textContent = 'SCORE: ' + score;
}
}
function updateGame(dt) {
time = (Date.now() - startTime) / 1000;
timerEl.textContent = 'TIME: ' + Math.floor(time) + 's';
difficulty = 1 + time * 0.05;
for (let e of entities) {
if (!e.alive) continue;
e.age += dt;
e.pulse += dt * 3;
if (cfg.genre === 'action' || cfg.genre === 'arcade') {
e.x += e.vx * difficulty;
e.y += e.vy * difficulty;
if (e.x < e.r) { e.x = e.r; e.vx *= -1; }
if (e.x > W - e.r) { e.x = W - e.r; e.vx *= -1; }
if (e.y < e.r) { e.y = e.r; e.vy *= -1; }
if (e.y > H - e.r) { e.y = H - e.r; e.vy *= -1; }
} else if (cfg.genre === 'puzzle') {
e.x += e.vx * 0.3;
e.y += e.vy * 0.3;
if (e.x < e.r || e.x > W - e.r) e.vx *= -1;
if (e.y < e.r || e.y > H - e.r) e.vy *= -1;
} else {
e.y += Math.sin(e.pulse) * 0.5;
e.x += Math.cos(e.pulse * 0.7) * 0.3;
}
if (e.type === 'enemy' && cfg.genre === 'action') {
const dx = mouse.x - e.x, dy = mouse.y - e.y;
if (dx*dx + dy*dy < (e.r + 15) * (e.r + 15)) {
lives--;
livesEl.textContent = 'LIVES: ' + lives;
e.alive = false;
for (let i = 0; i < 15; i++) {
particles.push(makeParticle(e.x, e.y, '#ff4444'));
}
if (lives <= 0) { gameOver(); return; }
}
}
}
entities = entities.filter(e => e.alive);
if (entities.length < 3 + Math.floor(difficulty)) {
const isEnemy = Math.random() < 0.2 + difficulty * 0.03;
entities.push(makeEntity(isEnemy ? 'enemy' : 'target'));
}
for (let p of particles) {
p.x += p.vx;
p.y += p.vy;
p.vy += 0.15;
p.life -= dt * 1.5;
}
particles = particles.filter(p => p.life > 0);
if (powerupActive) {
powerupActive.time -= dt;
if (powerupActive.time <= 0) powerupActive = null;
}
if (score >= 500) gameWin();
}
function drawGame() {
ctx.fillStyle = C.bg;
ctx.fillRect(0, 0, W, H);
ctx.strokeStyle = 'rgba(255,255,255,.03)';
ctx.lineWidth = 1;
for (let x = 0; x < W; x += 40) {
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke();
}
for (let y = 0; y < H; y += 40) {
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(W, y); ctx.stroke();
}
for (let e of entities) {
if (!e.alive) continue;
const pulseR = e.r + Math.sin(e.pulse) * 3;
const grad = ctx.createRadialGradient(e.x, e.y, 0, e.x, e.y, pulseR * 2);
grad.addColorStop(0, e.color + '88');
grad.addColorStop(1, e.color + '00');
ctx.fillStyle = grad;
ctx.beginPath();
ctx.arc(e.x, e.y, pulseR * 2, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = e.color;
ctx.beginPath();
ctx.arc(e.x, e.y, pulseR, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = 'rgba(255,255,255,.3)';
ctx.beginPath();
ctx.arc(e.x - pulseR * 0.3, e.y - pulseR * 0.3, pulseR * 0.3, 0, Math.PI * 2);
ctx.fill();
if (e.type === 'enemy' && e.hp < e.maxHp) {
ctx.fillStyle = 'rgba(255,255,255,.2)';
ctx.fillRect(e.x - e.r, e.y - e.r - 8, e.r * 2, 4);
ctx.fillStyle = '#ff4444';
ctx.fillRect(e.x - e.r, e.y - e.r - 8, (e.r * 2) * (e.hp / e.maxHp), 4);
}
ctx.fillStyle = 'rgba(255,255,255,.4)';
ctx.font = '9px monospace';
ctx.textAlign = 'center';
ctx.fillText(e.name.substring(0, 12), e.x, e.y + e.r + 14);
}
for (let p of particles) {
ctx.globalAlpha = p.life;
ctx.fillStyle = p.color;
ctx.beginPath();
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
ctx.fill();
}
ctx.globalAlpha = 1;
if (state === 'playing') {
ctx.strokeStyle = C.accent;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(mouse.x, mouse.y, 12, 0, Math.PI * 2);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(mouse.x - 6, mouse.y);
ctx.lineTo(mouse.x + 6, mouse.y);
ctx.moveTo(mouse.x, mouse.y - 6);
ctx.lineTo(mouse.x, mouse.y + 6);
ctx.stroke();
}
if (powerupActive) {
ctx.fillStyle = C.accent;
ctx.font = 'bold 14px monospace';
ctx.textAlign = 'left';
ctx.fillText('PWR: ' + powerupActive.name + ' (' + powerupActive.time.toFixed(1) + 's)', 12, H - 12);
}
}
function startGame() {
state = 'playing';
score = 0; lives = 3; time = 0;
startTime = Date.now();
difficulty = 1;
entities = []; particles = [];
powerupActive = null;
scoreEl.textContent = 'SCORE: 0';
livesEl.textContent = 'LIVES: 3';
timerEl.textContent = 'TIME: 0s';
overlay.classList.add('hidden');
spawnEntities();
}
function gameWin() {
state = 'won';
overlay.classList.remove('hidden');
overlay.innerHTML =
'<h1 style="color:' + C.accent + '">YOU WIN!</h1>' +
'<div class="game-over-stats">' +
'<div class="label">FINAL SCORE</div>' +
'<div class="value">' + score + '</div>' +
'<div class="label">TIME</div>' +
'<div class="value" style="font-size:18px">' + Math.floor(time) + 's</div>' +
'</div>' +
'<p style="color:rgba(255,255,255,.4);font-size:11px;max-width:350px">' + cfg.inspiration + '</p>' +
'<button id="startBtn" style="margin-top:16px">PLAY AGAIN</button>';
document.getElementById('startBtn').onclick = startGame;
}
function gameOver() {
state = 'lost';
overlay.classList.remove('hidden');
overlay.innerHTML =
'<h1 style="color:#ff4444">GAME OVER</h1>' +
'<div class="game-over-stats">' +
'<div class="label">FINAL SCORE</div>' +
'<div class="value">' + score + '</div>' +
'<div class="label">TIME SURVIVED</div>' +
'<div class="value" style="font-size:18px">' + Math.floor(time) + 's</div>' +
'</div>' +
'<p style="color:rgba(255,255,255,.4);font-size:11px;max-width:350px">' + cfg.inspiration + '</p>' +
'<button id="startBtn" style="margin-top:16px">TRY AGAIN</button>';
document.getElementById('startBtn').onclick = startGame;
}
function loop(ts) {
const dt = Math.min(0.05, (ts - lastTime) / 1000) || 0;
lastTime = ts;
if (state === 'playing') updateGame(dt);
drawGame();
requestAnimationFrame(loop);
}
startBtn.onclick = startGame;
requestAnimationFrame(loop);
})();
JSEOF;
file_put_contents("$appDir/index.html", $html);
file_put_contents("$appDir/game.js", $js);
write_json_file("$appDir/app_manifest.json", [
'id' => $id,
'game_title' => $analysis['game_title'] ?? 'MicroGame',
'game_genre' => $gameGenre,
'game_concept' => $analysis['game_concept'] ?? '',
'inspiration' => $analysis['inspiration'] ?? '',
'understanding' => $analysis['understanding'] ?? '',
'created_at' => time(),
'source_video_exposed' => false
]);
// Save to gallery
$galleryFile = RECEIPT_DIR . '/gallery.json';
$gallery = file_exists($galleryFile) ? (read_json_file($galleryFile) ?: []) : [];
$entry = [
'videoId' => $id,
'videoName' => $receipt['videoName'] ?? 'unknown',
'gameTitle' => $analysis['game_title'] ?? 'MicroGame',
'gameGenre' => $gameGenre,
'gameConcept' => $analysis['game_concept'] ?? '',
'createdAt' => date('c'),
'duration' => $receipt['duration'] ?? 0
];
$gallery = array_filter($gallery, fn($e) => ($e['videoId'] ?? '') !== $id);
array_unshift($gallery, $entry);
$gallery = array_slice($gallery, 0, 50);
write_json_file($galleryFile, $gallery);
json_response([
'ok' => true,
'id' => $id,
'game_title' => $analysis['game_title'] ?? 'MicroGame',
'game_genre' => $gameGenre,
'game_concept' => $analysis['game_concept'] ?? '',
'preview_url' => "/generated/$id/index.html"
]);