Files
hermes-agent/optional-skills/web-development/scrollcraft/scripts/kie.mjs
T
Teknium 56cc2bd814 feat(skills): scrollcraft — premium scroll-driven landing pages (port of nateherkai/scroll-craft, 1.2k★ MIT)
Optional skill: scroll-as-timeline landing pages on a deterministic
CSS/JS engine, with interview → page grammar → signature move workflow
and screenshot-based scroll verification. Engine and scripts vendored
verbatim; asset generation re-anchored on image_generate with the
upstream kie.ai flow kept as an optional path.
2026-09-13 21:11:25 -07:00

203 lines
7.6 KiB
JavaScript

#!/usr/bin/env node
/**
* scrollcraft asset generator: kie.ai unified jobs API.
*
* POST https://api.kie.ai/api/v1/jobs/createTask { model, input }
* GET https://api.kie.ai/api/v1/jobs/recordInfo?taskId=...
*
* COMMANDS
* still <prompt> <out.png> [--ar 16:9] [--ref a.png]
* seedream/5-pro-text-to-image (or -image-to-image with --ref).
* Photoreal by default. Stills are cheap; generate, look, reroll.
*
* shot <prompt> <in.png> <out.mp4> [--tail b.png] [--dur 5]
* kling/v2-1-pro image-to-video. --tail pins the LAST frame, which is
* the whole trick behind a seamless chain: leg N's tail is leg N+1's
* head, so the cut between them is frame-identical and invisible.
*
* probe print account credit and exit.
*
* Env: KIE_AI_API_KEY, read from the project-root .env if not already set.
*/
import fs from "node:fs";
import path from "node:path";
const API = "https://api.kie.ai";
const UPLOAD = "https://kieai.redpandaai.co/api/file-base64-upload";
const MODELS = {
still: "seedream/5-pro-text-to-image",
stillEdit: "seedream/5-pro-image-to-image",
shot: "kling/v2-1-pro",
};
// ---------------------------------------------------------------- key ----
function findEnv(start) {
let dir = path.resolve(start);
for (let i = 0; i < 8; i++) {
const p = path.join(dir, ".env");
if (fs.existsSync(p)) return p;
const up = path.dirname(dir);
if (up === dir) break;
dir = up;
}
return null;
}
function loadKey() {
if (process.env.KIE_AI_API_KEY) return process.env.KIE_AI_API_KEY;
const envPath = findEnv(process.cwd());
if (!envPath) throw new Error("KIE_AI_API_KEY not set and no .env found walking up from " + process.cwd());
for (const line of fs.readFileSync(envPath, "utf8").split(/\r?\n/)) {
const m = line.match(/^\s*KIE_AI_API_KEY\s*=\s*(.+?)\s*$/);
if (m) return m[1].replace(/^["']|["']$/g, "");
}
throw new Error("KIE_AI_API_KEY not found in " + envPath);
}
const KEY = loadKey();
const H = { "Content-Type": "application/json", Authorization: `Bearer ${KEY}` };
// ------------------------------------------------------------- helpers ----
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
async function uploadLocal(file) {
const abs = path.resolve(file);
if (!fs.existsSync(abs)) throw new Error("input not found: " + abs);
const ext = path.extname(abs).slice(1).toLowerCase();
const mime = ext === "jpg" ? "image/jpeg" : `image/${ext}`;
const dataUrl = `data:${mime};base64,${fs.readFileSync(abs).toString("base64")}`;
const res = await fetch(UPLOAD, {
method: "POST", headers: H,
body: JSON.stringify({ base64Data: dataUrl, uploadPath: "scrollcraft", fileName: path.basename(abs) }),
});
const j = await res.json();
const url = j?.data?.downloadUrl || j?.data?.fileUrl || j?.data?.url;
if (!url) throw new Error("upload failed: " + JSON.stringify(j));
return url;
}
// A local path becomes a hosted URL; an http(s) string passes straight through.
const asUrl = (v) => (/^https?:\/\//i.test(v) ? Promise.resolve(v) : uploadLocal(v));
async function createTask(model, input) {
const res = await fetch(`${API}/api/v1/jobs/createTask`, {
method: "POST", headers: H, body: JSON.stringify({ model, input }),
});
const j = await res.json();
if (j.code !== 200 || !j?.data?.taskId) throw new Error(`createTask ${model}: ${JSON.stringify(j)}`);
return j.data.taskId;
}
async function waitTask(taskId, { label = "job", timeoutMs = 15 * 60 * 1000 } = {}) {
const t0 = Date.now();
let delay = 4000;
for (;;) {
if (Date.now() - t0 > timeoutMs) throw new Error(`${label}: timed out after ${Math.round((Date.now() - t0) / 1000)}s`);
const res = await fetch(`${API}/api/v1/jobs/recordInfo?taskId=${encodeURIComponent(taskId)}`, { headers: H });
const j = await res.json();
const d = j?.data || {};
const state = d.state || d.status;
if (state === "success") {
let out = d.resultJson;
if (typeof out === "string") { try { out = JSON.parse(out); } catch {} }
const urls = out?.resultUrls || out?.result_urls || out?.urls || [];
if (!urls.length) throw new Error(`${label}: success with no result url: ${JSON.stringify(d)}`);
return urls;
}
if (state === "fail" || state === "failed") {
throw new Error(`${label} failed: ${d.failMsg || d.failCode || JSON.stringify(d)}`);
}
process.stderr.write(` ${label}: ${state || "queued"} (${Math.round((Date.now() - t0) / 1000)}s)\n`);
await sleep(delay);
delay = Math.min(delay * 1.25, 15000);
}
}
async function download(url, out) {
fs.mkdirSync(path.dirname(path.resolve(out)), { recursive: true });
const res = await fetch(url);
if (!res.ok) throw new Error(`download ${res.status} ${url}`);
fs.writeFileSync(path.resolve(out), Buffer.from(await res.arrayBuffer()));
return out;
}
function flag(argv, name, dflt = null) {
const i = argv.indexOf(name);
return i > -1 && argv[i + 1] ? argv[i + 1] : dflt;
}
function flags(argv, name) {
const out = [];
argv.forEach((a, i) => { if (a === name && argv[i + 1]) out.push(argv[i + 1]); });
return out;
}
// ---------------------------------------------------------------- main ----
const [cmd, ...rest] = process.argv.slice(2);
try {
if (cmd === "probe") {
const r = await fetch(`${API}/api/v1/chat/credit`, { headers: H });
const j = await r.json();
console.log("credit:", j.data);
} else if (cmd === "still") {
const [prompt, out] = rest;
if (!prompt || !out) throw new Error('usage: kie.mjs still "<prompt>" <out.png> [--ar 16:9] [--ref a.png]');
const ar = flag(rest, "--ar", "16:9");
const refs = flags(rest, "--ref");
let model = MODELS.still;
// aspect_ratio, quality and output_format are all required by seedream;
// omitting any one returns a bare "This field is required" that does not
// name the field, so keep them explicit rather than relying on defaults.
const input = {
prompt,
aspect_ratio: ar,
quality: flag(rest, "--quality", "high"),
output_format: "png",
nsfw_checker: false,
};
if (refs.length) {
model = MODELS.stillEdit;
input.image_urls = await Promise.all(refs.map(asUrl));
}
const id = await createTask(model, input);
const urls = await waitTask(id, { label: path.basename(out) });
await download(urls[0], out);
console.log(out);
} else if (cmd === "shot") {
const [prompt, head, out] = rest;
if (!prompt || !head || !out) {
throw new Error('usage: kie.mjs shot "<prompt>" <head.png> <out.mp4> [--tail b.png] [--dur 5]');
}
const dur = flag(rest, "--dur", "5");
const tail = flag(rest, "--tail");
const input = {
prompt,
image_url: await asUrl(head),
duration: String(dur),
// Camera-move clips are graded on smoothness, so the negative prompt
// targets exactly what breaks a scrub: judder, warping, cuts.
negative_prompt: "blur, distortion, low quality, warping, morphing, jitter, flicker, text, watermark, cut, scene change",
cfg_scale: 0.5,
};
if (tail) input.tail_image_url = await asUrl(tail);
const id = await createTask(MODELS.shot, input);
const urls = await waitTask(id, { label: path.basename(out), timeoutMs: 20 * 60 * 1000 });
await download(urls[0], out);
console.log(out);
} else {
console.error(`scrollcraft asset generator
node kie.mjs probe
node kie.mjs still "<prompt>" <out.png> [--ar 16:9] [--ref ref.png]
node kie.mjs shot "<prompt>" <head.png> <out.mp4> [--tail tail.png] [--dur 5]
`);
process.exit(1);
}
} catch (err) {
console.error("ERROR:", err.message);
process.exit(1);
}