Files
olliewazza_larry/scripts/generate-slides.js

232 lines
8.5 KiB
JavaScript
Raw Permalink Normal View History

#!/usr/bin/env node
/**
* Generate 6 TikTok slideshow images using the user's chosen image generation provider.
*
* Supported providers:
* - openai (gpt-image-1.5 STRONGLY RECOMMENDED never use gpt-image-1)
* - stability (Stable Diffusion via Stability AI API)
* - replicate (any model via Replicate API)
* - local (user provides pre-made images, skips generation)
*
* Usage: node generate-slides.js --config <config.json> --output <dir> --prompts <prompts.json>
*
* prompts.json format:
* {
* "base": "Shared base prompt for all slides",
* "slides": ["Slide 1 additions", "Slide 2 additions", ...6 total]
* }
*/
const fs = require('fs');
const path = require('path');
const args = process.argv.slice(2);
function getArg(name) {
const idx = args.indexOf(`--${name}`);
return idx !== -1 ? args[idx + 1] : null;
}
const configPath = getArg('config');
const outputDir = getArg('output');
const promptsPath = getArg('prompts');
if (!configPath || !outputDir || !promptsPath) {
console.error('Usage: node generate-slides.js --config <config.json> --output <dir> --prompts <prompts.json>');
process.exit(1);
}
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
const prompts = JSON.parse(fs.readFileSync(promptsPath, 'utf-8'));
if (!prompts.slides || prompts.slides.length !== 6) {
console.error('ERROR: prompts.json must have exactly 6 slides');
process.exit(1);
}
fs.mkdirSync(outputDir, { recursive: true });
const provider = config.imageGen?.provider || 'openai';
const model = config.imageGen?.model || 'gpt-image-1.5';
const apiKey = config.imageGen?.apiKey;
if (!apiKey && provider !== 'local') {
console.error(`ERROR: No API key found in config.imageGen.apiKey for provider "${provider}"`);
process.exit(1);
}
// Warn if using gpt-image-1 instead of 1.5
if (provider === 'openai' && model && !model.includes('1.5')) {
console.warn(`\n⚠️ WARNING: You're using "${model}" — this produces noticeably AI-looking images.`);
console.warn(` STRONGLY RECOMMENDED: Switch to "gpt-image-1.5" in your config for photorealistic results.`);
console.warn(` The quality difference is massive and directly impacts views.\n`);
}
// ─── Provider: OpenAI ───────────────────────────────────────────────
async function generateOpenAI(prompt, outPath) {
const res = await fetch('https://api.openai.com/v1/images/generations', {
method: 'POST',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
model,
prompt,
n: 1,
size: '1024x1536',
quality: 'high'
}),
signal: global.__abortSignal
});
const data = await res.json();
if (data.error) throw new Error(data.error.message);
fs.writeFileSync(outPath, Buffer.from(data.data[0].b64_json, 'base64'));
}
// ─── Provider: Stability AI ─────────────────────────────────────────
async function generateStability(prompt, outPath) {
const engineId = model || 'stable-diffusion-xl-1024-v1-0';
const res = await fetch(`https://api.stability.ai/v1/generation/${engineId}/text-to-image`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json',
'Accept': 'application/json'
},
body: JSON.stringify({
text_prompts: [{ text: prompt, weight: 1 }],
cfg_scale: 7,
height: 1536,
width: 1024,
steps: 30,
samples: 1
})
});
const data = await res.json();
if (data.message) throw new Error(data.message);
fs.writeFileSync(outPath, Buffer.from(data.artifacts[0].base64, 'base64'));
}
// ─── Provider: Replicate ────────────────────────────────────────────
async function generateReplicate(prompt, outPath) {
const replicateModel = model || 'black-forest-labs/flux-1.1-pro';
// Create prediction
const createRes = await fetch('https://api.replicate.com/v1/predictions', {
method: 'POST',
headers: {
'Authorization': `Token ${apiKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
model: replicateModel,
input: {
prompt,
width: 1024,
height: 1536,
num_outputs: 1
}
})
});
let prediction = await createRes.json();
if (prediction.error) throw new Error(prediction.error.detail || prediction.error);
// Poll for completion
while (prediction.status !== 'succeeded' && prediction.status !== 'failed') {
await new Promise(r => setTimeout(r, 2000));
const pollRes = await fetch(prediction.urls.get, {
headers: { 'Authorization': `Token ${apiKey}` }
});
prediction = await pollRes.json();
}
if (prediction.status === 'failed') throw new Error(prediction.error || 'Prediction failed');
// Download image
const imageUrl = Array.isArray(prediction.output) ? prediction.output[0] : prediction.output;
const imgRes = await fetch(imageUrl);
const buf = Buffer.from(await imgRes.arrayBuffer());
fs.writeFileSync(outPath, buf);
}
// ─── Provider: Local (skip generation) ──────────────────────────────
async function generateLocal(prompt, outPath) {
const slideNum = path.basename(outPath).match(/\d+/)?.[0];
const localPath = path.join(outputDir, `local_slide${slideNum}.png`);
if (fs.existsSync(localPath)) {
fs.copyFileSync(localPath, outPath);
} else {
throw new Error(`Place your image at ${localPath} — local provider skips generation`);
}
}
// ─── Retry with timeout ─────────────────────────────────────────────
async function withRetry(fn, retries = 2, timeoutMs = 120000) {
for (let attempt = 0; attempt <= retries; attempt++) {
try {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
// Pass abort signal via global (providers use fetch which supports it)
global.__abortSignal = controller.signal;
const result = await fn();
clearTimeout(timer);
return result;
} catch (e) {
if (attempt < retries) {
const isTimeout = e.name === 'AbortError' || e.message?.includes('timeout') || e.message?.includes('abort');
console.log(` ⚠️ ${isTimeout ? 'Timeout' : 'Error'}: ${e.message}. Retrying (${attempt + 1}/${retries})...`);
await new Promise(r => setTimeout(r, 3000 * (attempt + 1)));
} else {
throw e;
}
}
}
}
// ─── Router ─────────────────────────────────────────────────────────
const providers = {
openai: generateOpenAI,
stability: generateStability,
replicate: generateReplicate,
local: generateLocal
};
async function generate(prompt, outPath) {
const fn = providers[provider];
if (!fn) {
console.error(`Unknown provider: "${provider}". Supported: ${Object.keys(providers).join(', ')}`);
process.exit(1);
}
console.log(` Generating ${path.basename(outPath)} [${provider}/${model}]...`);
await withRetry(() => fn(prompt, outPath));
console.log(`${path.basename(outPath)}`);
}
(async () => {
console.log(`🎬 Generating 6 slides for ${config.app?.name || 'app'} using ${provider}/${model}\n`);
let success = 0;
let skipped = 0;
for (let i = 0; i < 6; i++) {
const outPath = path.join(outputDir, `slide${i + 1}_raw.png`);
// Skip if already exists (resume from partial run)
if (fs.existsSync(outPath) && fs.statSync(outPath).size > 10000) {
console.log(` ⏭ slide${i + 1}_raw.png already exists, skipping`);
success++;
skipped++;
continue;
}
const fullPrompt = `${prompts.base}\n\n${prompts.slides[i]}`;
try {
await generate(fullPrompt, outPath);
success++;
} catch (e) {
console.error(` ❌ Slide ${i + 1} failed after retries: ${e.message}`);
console.error(` Re-run this script to retry — completed slides will be skipped.`);
}
}
console.log(`\n✨ Generated ${success}/6 slides in ${outputDir}${skipped > 0 ? ` (${skipped} skipped — already existed)` : ''}`);
if (success < 6) {
console.error(`\n⚠️ ${6 - success} slides failed. Re-run to retry — completed slides are preserved.`);
process.exit(1);
}
})();