luckiest-co 1.0.13 → 1.0.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/package.json +1 -1
  4. package/skills/luckiest-design-website/ATTRIBUTION.md +38 -0
  5. package/skills/luckiest-design-website/CHANGELOG.md +104 -0
  6. package/skills/luckiest-design-website/LICENSE +21 -0
  7. package/skills/luckiest-design-website/SKILL.md +372 -0
  8. package/skills/luckiest-design-website/UPSTREAM-CHANGELOG.md +526 -0
  9. package/skills/luckiest-design-website/engine/scrollcraft.css +432 -0
  10. package/skills/luckiest-design-website/engine/scrollcraft.js +1167 -0
  11. package/skills/luckiest-design-website/evals/evals.json +33 -0
  12. package/skills/luckiest-design-website/references/assets.md +286 -0
  13. package/skills/luckiest-design-website/references/device-diag.html +214 -0
  14. package/skills/luckiest-design-website/references/devices.md +466 -0
  15. package/skills/luckiest-design-website/references/feel.md +277 -0
  16. package/skills/luckiest-design-website/references/registry-examples.md +305 -0
  17. package/skills/luckiest-design-website/references/taste.md +304 -0
  18. package/skills/luckiest-design-website/references/template.html +138 -0
  19. package/skills/luckiest-design-website/references/uniqueness.md +480 -0
  20. package/skills/luckiest-design-website/references/verify.md +381 -0
  21. package/skills/luckiest-design-website/references/worldflight.md +349 -0
  22. package/skills/luckiest-design-website/references/worlds.md +178 -0
  23. package/skills/luckiest-design-website/scripts/doctor.mjs +177 -0
  24. package/skills/luckiest-design-website/scripts/encode.sh +80 -0
  25. package/skills/luckiest-design-website/scripts/kie.mjs +202 -0
  26. package/skills/luckiest-design-website/scripts/serve.mjs +52 -0
  27. package/skills/luckiest-design-website/scripts/shoot.mjs +644 -0
  28. package/skills/luckiest-design-website/scripts/workspace.mjs +106 -0
  29. package/skills/luckiest-design-website/scripts/worldflight-assert.mjs +273 -0
  30. package/skills/luckiest-design-website/templates/FINGERPRINTS.md +65 -0
@@ -0,0 +1,177 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Preflight. Run this BEFORE the interview, not after the first failure.
4
+ *
5
+ * node scripts/doctor.mjs check everything
6
+ * node scripts/doctor.mjs --probe also spend one API call to read the balance
7
+ *
8
+ * Every check that can fail deep inside a build with a misleading message is
9
+ * checked here with an honest one. The three that actually bite:
10
+ *
11
+ * - a STRIPPED ffmpeg on PATH. It carries ~50 filters and silently lacks
12
+ * scale, fps, psnr and the webp muxer, then fails with "No option name
13
+ * near ..." or "Unable to choose an output format", both of which read as
14
+ * a mistake in your command rather than a missing feature.
15
+ * - no KIE_AI_API_KEY, which only matters if you are generating assets.
16
+ * - playwright-core resolving from the wrong directory. It is required from
17
+ * the BUILD folder, not from the skill.
18
+ */
19
+
20
+ import fs from "node:fs";
21
+ import path from "node:path";
22
+ import { execFileSync } from "node:child_process";
23
+ import { createRequire } from "node:module";
24
+ import { fileURLToPath } from "node:url";
25
+ import { paths } from "./workspace.mjs";
26
+
27
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
28
+ const rows = [];
29
+ const add = (sev, name, ok, detail, fix) => rows.push({ sev, name, ok, detail, fix });
30
+
31
+ const run = (cmd, args) => {
32
+ try {
33
+ return execFileSync(cmd, args, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
34
+ } catch {
35
+ return null;
36
+ }
37
+ };
38
+
39
+ // ---------------------------------------------------------------- node ----
40
+ const major = Number(process.versions.node.split(".")[0]);
41
+ add("required", "node", major >= 18, `v${process.versions.node}`,
42
+ "Install Node 18 or newer.");
43
+
44
+ // -------------------------------------------------------------- ffmpeg ----
45
+ function globWinGet() {
46
+ const home = process.env.USERPROFILE || process.env.HOME || "";
47
+ const base = path.join(home, "AppData/Local/Microsoft/WinGet/Packages");
48
+ if (!fs.existsSync(base)) return [];
49
+ const out = [];
50
+ for (const d of fs.readdirSync(base)) {
51
+ if (!/^Gyan\.FFmpeg/i.test(d)) continue;
52
+ const inner = path.join(base, d);
53
+ for (const e of fs.readdirSync(inner)) {
54
+ const p = path.join(inner, e, "bin/ffmpeg.exe");
55
+ if (fs.existsSync(p)) out.push(p);
56
+ }
57
+ }
58
+ return out;
59
+ }
60
+
61
+ const candidates = [
62
+ process.env.SCROLLCRAFT_FFMPEG,
63
+ "ffmpeg",
64
+ ...globWinGet(),
65
+ "/usr/local/bin/ffmpeg",
66
+ "/opt/homebrew/bin/ffmpeg",
67
+ "/usr/bin/ffmpeg",
68
+ "/snap/bin/ffmpeg",
69
+ ].filter(Boolean);
70
+
71
+ let ffmpeg = null, filterCount = 0;
72
+ for (const c of candidates) {
73
+ const out = run(c, ["-hide_banner", "-filters"]);
74
+ if (!out) continue;
75
+ const n = out.split("\n").length;
76
+ if (n > filterCount) { filterCount = n; ffmpeg = c; }
77
+ if (n > 200) break;
78
+ }
79
+ add("required", "ffmpeg (full build)", filterCount > 200,
80
+ ffmpeg ? `${ffmpeg} (${filterCount} filters)` : "not found",
81
+ "A stripped ffmpeg lacks scale/fps/psnr and the webp muxer. Install a full build (macOS: brew install ffmpeg, Linux: apt install ffmpeg, Windows: winget install Gyan.FFmpeg) or set SCROLLCRAFT_FFMPEG to one.");
82
+
83
+ if (ffmpeg && filterCount > 200) {
84
+ const enc = run(ffmpeg, ["-hide_banner", "-encoders"]) || "";
85
+ add("optional", " └ libwebp encoder", /libwebp/.test(enc),
86
+ /libwebp/.test(enc) ? "present" : "missing",
87
+ "Posters fall back to JPEG. Not fatal, just heavier.");
88
+ }
89
+
90
+ // ---------------------------------------------------------- playwright ----
91
+ let pw = false, pwWhere = "";
92
+ try {
93
+ createRequire(path.join(process.cwd(), "package.json"))("playwright-core");
94
+ pw = true; pwWhere = "resolves from cwd";
95
+ } catch {
96
+ try {
97
+ createRequire(path.join(HERE, "package.json"))("playwright-core");
98
+ pw = true; pwWhere = "resolves from the skill, but NOT from cwd";
99
+ } catch { pwWhere = "not installed"; }
100
+ }
101
+ add("verify", "playwright-core", pw, pwWhere,
102
+ "Run `npm i playwright-core` inside the build folder. Only needed for the verification pass.");
103
+
104
+ const chrome = [
105
+ process.env.SCROLLCRAFT_CHROME,
106
+ // Windows
107
+ "C:/Program Files/Google/Chrome/Application/chrome.exe",
108
+ "C:/Program Files (x86)/Google/Chrome/Application/chrome.exe",
109
+ "C:/Program Files/Microsoft/Edge/Application/msedge.exe",
110
+ // macOS
111
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
112
+ "/Applications/Chromium.app/Contents/MacOS/Chromium",
113
+ "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
114
+ // Linux
115
+ "/usr/bin/google-chrome",
116
+ "/usr/bin/google-chrome-stable",
117
+ "/usr/bin/chromium",
118
+ "/usr/bin/chromium-browser",
119
+ "/snap/bin/chromium",
120
+ ].find((p) => p && fs.existsSync(p));
121
+ add("verify", "Chrome", Boolean(chrome), chrome || "not found",
122
+ "Install Chrome, or set SCROLLCRAFT_CHROME to an executable.");
123
+
124
+ // ------------------------------------------------------------- kie key ----
125
+ function findKey() {
126
+ if (process.env.KIE_AI_API_KEY) return "env";
127
+ let dir = process.cwd();
128
+ for (let i = 0; i < 8; i++) {
129
+ const p = path.join(dir, ".env");
130
+ if (fs.existsSync(p) && /^\s*KIE_AI_API_KEY\s*=\s*\S+/m.test(fs.readFileSync(p, "utf8"))) return p;
131
+ const up = path.dirname(dir);
132
+ if (up === dir) break;
133
+ dir = up;
134
+ }
135
+ return null;
136
+ }
137
+ const keyWhere = findKey();
138
+ add("optional", "KIE_AI_API_KEY", Boolean(keyWhere), keyWhere || "not set",
139
+ "Only needed to GENERATE imagery. Building from your own photos and footage needs no key and no spend. Copy .env.example to .env to set one.");
140
+
141
+ // ----------------------------------------------------------- workspace ----
142
+ let ws = null;
143
+ try {
144
+ ws = paths();
145
+ add("required", "workspace", true, `${ws.workspace}\n via ${ws.via}`, "");
146
+ add("optional", " └ registry", fs.existsSync(ws.fingerprints),
147
+ fs.existsSync(ws.fingerprints) ? "present" : "not created yet",
148
+ "Run `node scripts/workspace.mjs --ensure` to create it.");
149
+ } catch (e) {
150
+ add("required", "workspace", false, e.message, "Fix or delete the offending .scrollcraft.json.");
151
+ }
152
+
153
+ // -------------------------------------------------------------- report ----
154
+ const mark = (r) => (r.ok ? "\u001b[32m ok \u001b[0m" : r.sev === "required" ? "\u001b[31mFAIL\u001b[0m" : "\u001b[33mwarn\u001b[0m");
155
+ console.log("\nscrollcraft preflight\n");
156
+ for (const r of rows) {
157
+ console.log(` [${mark(r)}] ${r.name.padEnd(22)} ${r.detail}`);
158
+ if (!r.ok && r.fix) console.log(` ${"\u001b[2m"}${r.fix}${"\u001b[0m"}`);
159
+ }
160
+
161
+ const hardFails = rows.filter((r) => !r.ok && r.sev === "required");
162
+ const softFails = rows.filter((r) => !r.ok && r.sev !== "required");
163
+ console.log("");
164
+ if (hardFails.length) {
165
+ console.log(`\u001b[31m${hardFails.length} required check(s) failed. Fix these before building.\u001b[0m\n`);
166
+ process.exit(1);
167
+ }
168
+ console.log(softFails.length
169
+ ? `\u001b[33mReady, with ${softFails.length} optional item(s) missing (see above).\u001b[0m\n`
170
+ : "\u001b[32mReady.\u001b[0m\n");
171
+
172
+ if (process.argv.includes("--probe") && keyWhere) {
173
+ const { execFileSync: x } = await import("node:child_process");
174
+ try {
175
+ console.log("credit: " + x(process.execPath, [path.join(HERE, "kie.mjs"), "probe"], { encoding: "utf8" }).trim().replace(/^credit:\s*/, ""));
176
+ } catch (e) { console.log("balance probe failed: " + e.message); }
177
+ }
@@ -0,0 +1,80 @@
1
+ #!/usr/bin/env bash
2
+ # scrollcraft: encode a clip for scrubbing, not for playback.
3
+ #
4
+ # A normal web encode puts a keyframe every 2-5 seconds. Scrubbing seeks to an
5
+ # arbitrary time, and the decoder must walk from the previous keyframe to get
6
+ # there, so a sparse-GOP file feels like mud under the wheel while playing back
7
+ # perfectly. Dense keyframes cost file size and buy responsiveness. That trade
8
+ # is the entire point of this script.
9
+ #
10
+ # ./encode.sh in.mp4 out.mp4 desktop master (1080p, -g 8, crf 20)
11
+ # ./encode.sh in.mp4 out-m.mp4 mobile phone variant (720p, -g 4, crf 24)
12
+ #
13
+ # CRF override, for the grain-heavy worlds assets.md warns about (marine snow,
14
+ # bioluminescence, smoke, film grain). A dense GOP doubles the cost of grain, so
15
+ # a world like that wants 22-23 rather than the default 20:
16
+ #
17
+ # ./encode.sh in.mp4 out.mp4 desktop 23 fourth positional argument
18
+ # SCROLLCRAFT_CRF=23 ./encode.sh in.mp4 out.mp4 or the env var
19
+ #
20
+ # The positional argument wins over the env var; with neither, the defaults
21
+ # below are unchanged.
22
+ #
23
+ # Audio is stripped: these clips are scrubbed, never played, and a muted track
24
+ # is dead weight plus an autoplay-policy hazard.
25
+ set -euo pipefail
26
+
27
+ # Resolve a FULL ffmpeg build. Several toolchains (Remotion, some Electron apps)
28
+ # put a stripped ffmpeg on PATH that carries maybe 50 filters and silently lacks
29
+ # scale/fps/tile. It fails with "No option name near ..." on any filter chain,
30
+ # which reads like a syntax error in your command rather than a missing filter.
31
+ # Count the filters and go looking for a real build if the count is low.
32
+ pick_ffmpeg() {
33
+ local cand
34
+ for cand in \
35
+ "${SCROLLCRAFT_FFMPEG:-}" \
36
+ "$(command -v ffmpeg 2>/dev/null || true)" \
37
+ "${HOME:-/nonexistent}"/AppData/Local/Microsoft/WinGet/Packages/Gyan.FFmpeg_*/ffmpeg-*-full_build/bin/ffmpeg.exe \
38
+ /usr/local/bin/ffmpeg /opt/homebrew/bin/ffmpeg /usr/bin/ffmpeg /snap/bin/ffmpeg
39
+ do
40
+ [ -n "$cand" ] && [ -x "$cand" ] || continue
41
+ if [ "$("$cand" -hide_banner -filters 2>/dev/null | wc -l)" -gt 200 ]; then
42
+ echo "$cand"; return 0
43
+ fi
44
+ done
45
+ echo "ERROR: no full ffmpeg build found. Set SCROLLCRAFT_FFMPEG to one." >&2
46
+ return 1
47
+ }
48
+ FFMPEG="$(pick_ffmpeg)"
49
+ FFPROBE="$(dirname "$FFMPEG")/ffprobe"
50
+ [ -x "$FFPROBE" ] || FFPROBE="$(command -v ffprobe)"
51
+
52
+ IN="${1:?usage: encode.sh <in> <out> [mobile|desktop] [crf]}"
53
+ OUT="${2:?usage: encode.sh <in> <out> [mobile|desktop] [crf]}"
54
+ MODE="${3:-desktop}"
55
+
56
+ if [ "$MODE" = "mobile" ]; then
57
+ SCALE="scale=-2:720"; GOP=4; CRF=24
58
+ else
59
+ SCALE="scale=-2:1080"; GOP=8; CRF=20
60
+ fi
61
+
62
+ # Positional beats env var beats the mode default.
63
+ CRF="${4:-${SCROLLCRAFT_CRF:-$CRF}}"
64
+ case "$CRF" in
65
+ ''|*[!0-9]*) echo "ERROR: crf must be an integer, got '$CRF'" >&2; exit 1 ;;
66
+ esac
67
+
68
+ mkdir -p "$(dirname "$OUT")"
69
+
70
+ "$FFMPEG" -y -hide_banner -loglevel error -i "$IN" \
71
+ -an \
72
+ -vf "${SCALE}:flags=lanczos,format=yuv420p" \
73
+ -c:v libx264 -profile:v high -preset slow -crf "$CRF" \
74
+ -g "$GOP" -keyint_min "$GOP" -sc_threshold 0 \
75
+ -movflags +faststart \
76
+ "$OUT"
77
+
78
+ SIZE=$(du -h "$OUT" | cut -f1)
79
+ DUR=$("$FFPROBE" -v error -show_entries format=duration -of csv=p=0 "$OUT")
80
+ echo "$OUT ${SIZE} ${DUR}s gop=${GOP} crf=${CRF}"
@@ -0,0 +1,202 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * scrollcraft asset generator: kie.ai unified jobs API.
4
+ *
5
+ * POST https://api.kie.ai/api/v1/jobs/createTask { model, input }
6
+ * GET https://api.kie.ai/api/v1/jobs/recordInfo?taskId=...
7
+ *
8
+ * COMMANDS
9
+ * still <prompt> <out.png> [--ar 16:9] [--ref a.png]
10
+ * seedream/5-pro-text-to-image (or -image-to-image with --ref).
11
+ * Photoreal by default. Stills are cheap; generate, look, reroll.
12
+ *
13
+ * shot <prompt> <in.png> <out.mp4> [--tail b.png] [--dur 5]
14
+ * kling/v2-1-pro image-to-video. --tail pins the LAST frame, which is
15
+ * the whole trick behind a seamless chain: leg N's tail is leg N+1's
16
+ * head, so the cut between them is frame-identical and invisible.
17
+ *
18
+ * probe print account credit and exit.
19
+ *
20
+ * Env: KIE_AI_API_KEY, read from the project-root .env if not already set.
21
+ */
22
+
23
+ import fs from "node:fs";
24
+ import path from "node:path";
25
+
26
+ const API = "https://api.kie.ai";
27
+ const UPLOAD = "https://kieai.redpandaai.co/api/file-base64-upload";
28
+
29
+ const MODELS = {
30
+ still: "seedream/5-pro-text-to-image",
31
+ stillEdit: "seedream/5-pro-image-to-image",
32
+ shot: "kling/v2-1-pro",
33
+ };
34
+
35
+ // ---------------------------------------------------------------- key ----
36
+ function findEnv(start) {
37
+ let dir = path.resolve(start);
38
+ for (let i = 0; i < 8; i++) {
39
+ const p = path.join(dir, ".env");
40
+ if (fs.existsSync(p)) return p;
41
+ const up = path.dirname(dir);
42
+ if (up === dir) break;
43
+ dir = up;
44
+ }
45
+ return null;
46
+ }
47
+ function loadKey() {
48
+ if (process.env.KIE_AI_API_KEY) return process.env.KIE_AI_API_KEY;
49
+ const envPath = findEnv(process.cwd());
50
+ if (!envPath) throw new Error("KIE_AI_API_KEY not set and no .env found walking up from " + process.cwd());
51
+ for (const line of fs.readFileSync(envPath, "utf8").split(/\r?\n/)) {
52
+ const m = line.match(/^\s*KIE_AI_API_KEY\s*=\s*(.+?)\s*$/);
53
+ if (m) return m[1].replace(/^["']|["']$/g, "");
54
+ }
55
+ throw new Error("KIE_AI_API_KEY not found in " + envPath);
56
+ }
57
+ const KEY = loadKey();
58
+ const H = { "Content-Type": "application/json", Authorization: `Bearer ${KEY}` };
59
+
60
+ // ------------------------------------------------------------- helpers ----
61
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
62
+
63
+ async function uploadLocal(file) {
64
+ const abs = path.resolve(file);
65
+ if (!fs.existsSync(abs)) throw new Error("input not found: " + abs);
66
+ const ext = path.extname(abs).slice(1).toLowerCase();
67
+ const mime = ext === "jpg" ? "image/jpeg" : `image/${ext}`;
68
+ const dataUrl = `data:${mime};base64,${fs.readFileSync(abs).toString("base64")}`;
69
+ const res = await fetch(UPLOAD, {
70
+ method: "POST", headers: H,
71
+ body: JSON.stringify({ base64Data: dataUrl, uploadPath: "scrollcraft", fileName: path.basename(abs) }),
72
+ });
73
+ const j = await res.json();
74
+ const url = j?.data?.downloadUrl || j?.data?.fileUrl || j?.data?.url;
75
+ if (!url) throw new Error("upload failed: " + JSON.stringify(j));
76
+ return url;
77
+ }
78
+
79
+ // A local path becomes a hosted URL; an http(s) string passes straight through.
80
+ const asUrl = (v) => (/^https?:\/\//i.test(v) ? Promise.resolve(v) : uploadLocal(v));
81
+
82
+ async function createTask(model, input) {
83
+ const res = await fetch(`${API}/api/v1/jobs/createTask`, {
84
+ method: "POST", headers: H, body: JSON.stringify({ model, input }),
85
+ });
86
+ const j = await res.json();
87
+ if (j.code !== 200 || !j?.data?.taskId) throw new Error(`createTask ${model}: ${JSON.stringify(j)}`);
88
+ return j.data.taskId;
89
+ }
90
+
91
+ async function waitTask(taskId, { label = "job", timeoutMs = 15 * 60 * 1000 } = {}) {
92
+ const t0 = Date.now();
93
+ let delay = 4000;
94
+ for (;;) {
95
+ if (Date.now() - t0 > timeoutMs) throw new Error(`${label}: timed out after ${Math.round((Date.now() - t0) / 1000)}s`);
96
+ const res = await fetch(`${API}/api/v1/jobs/recordInfo?taskId=${encodeURIComponent(taskId)}`, { headers: H });
97
+ const j = await res.json();
98
+ const d = j?.data || {};
99
+ const state = d.state || d.status;
100
+ if (state === "success") {
101
+ let out = d.resultJson;
102
+ if (typeof out === "string") { try { out = JSON.parse(out); } catch {} }
103
+ const urls = out?.resultUrls || out?.result_urls || out?.urls || [];
104
+ if (!urls.length) throw new Error(`${label}: success with no result url: ${JSON.stringify(d)}`);
105
+ return urls;
106
+ }
107
+ if (state === "fail" || state === "failed") {
108
+ throw new Error(`${label} failed: ${d.failMsg || d.failCode || JSON.stringify(d)}`);
109
+ }
110
+ process.stderr.write(` ${label}: ${state || "queued"} (${Math.round((Date.now() - t0) / 1000)}s)\n`);
111
+ await sleep(delay);
112
+ delay = Math.min(delay * 1.25, 15000);
113
+ }
114
+ }
115
+
116
+ async function download(url, out) {
117
+ fs.mkdirSync(path.dirname(path.resolve(out)), { recursive: true });
118
+ const res = await fetch(url);
119
+ if (!res.ok) throw new Error(`download ${res.status} ${url}`);
120
+ fs.writeFileSync(path.resolve(out), Buffer.from(await res.arrayBuffer()));
121
+ return out;
122
+ }
123
+
124
+ function flag(argv, name, dflt = null) {
125
+ const i = argv.indexOf(name);
126
+ return i > -1 && argv[i + 1] ? argv[i + 1] : dflt;
127
+ }
128
+ function flags(argv, name) {
129
+ const out = [];
130
+ argv.forEach((a, i) => { if (a === name && argv[i + 1]) out.push(argv[i + 1]); });
131
+ return out;
132
+ }
133
+
134
+ // ---------------------------------------------------------------- main ----
135
+ const [cmd, ...rest] = process.argv.slice(2);
136
+
137
+ try {
138
+ if (cmd === "probe") {
139
+ const r = await fetch(`${API}/api/v1/chat/credit`, { headers: H });
140
+ const j = await r.json();
141
+ console.log("credit:", j.data);
142
+
143
+ } else if (cmd === "still") {
144
+ const [prompt, out] = rest;
145
+ if (!prompt || !out) throw new Error('usage: kie.mjs still "<prompt>" <out.png> [--ar 16:9] [--ref a.png]');
146
+ const ar = flag(rest, "--ar", "16:9");
147
+ const refs = flags(rest, "--ref");
148
+ let model = MODELS.still;
149
+ // aspect_ratio, quality and output_format are all required by seedream;
150
+ // omitting any one returns a bare "This field is required" that does not
151
+ // name the field, so keep them explicit rather than relying on defaults.
152
+ const input = {
153
+ prompt,
154
+ aspect_ratio: ar,
155
+ quality: flag(rest, "--quality", "high"),
156
+ output_format: "png",
157
+ nsfw_checker: false,
158
+ };
159
+ if (refs.length) {
160
+ model = MODELS.stillEdit;
161
+ input.image_urls = await Promise.all(refs.map(asUrl));
162
+ }
163
+ const id = await createTask(model, input);
164
+ const urls = await waitTask(id, { label: path.basename(out) });
165
+ await download(urls[0], out);
166
+ console.log(out);
167
+
168
+ } else if (cmd === "shot") {
169
+ const [prompt, head, out] = rest;
170
+ if (!prompt || !head || !out) {
171
+ throw new Error('usage: kie.mjs shot "<prompt>" <head.png> <out.mp4> [--tail b.png] [--dur 5]');
172
+ }
173
+ const dur = flag(rest, "--dur", "5");
174
+ const tail = flag(rest, "--tail");
175
+ const input = {
176
+ prompt,
177
+ image_url: await asUrl(head),
178
+ duration: String(dur),
179
+ // Camera-move clips are graded on smoothness, so the negative prompt
180
+ // targets exactly what breaks a scrub: judder, warping, cuts.
181
+ negative_prompt: "blur, distortion, low quality, warping, morphing, jitter, flicker, text, watermark, cut, scene change",
182
+ cfg_scale: 0.5,
183
+ };
184
+ if (tail) input.tail_image_url = await asUrl(tail);
185
+ const id = await createTask(MODELS.shot, input);
186
+ const urls = await waitTask(id, { label: path.basename(out), timeoutMs: 20 * 60 * 1000 });
187
+ await download(urls[0], out);
188
+ console.log(out);
189
+
190
+ } else {
191
+ console.error(`scrollcraft asset generator
192
+
193
+ node kie.mjs probe
194
+ node kie.mjs still "<prompt>" <out.png> [--ar 16:9] [--ref ref.png]
195
+ node kie.mjs shot "<prompt>" <head.png> <out.mp4> [--tail tail.png] [--dur 5]
196
+ `);
197
+ process.exit(1);
198
+ }
199
+ } catch (err) {
200
+ console.error("ERROR:", err.message);
201
+ process.exit(1);
202
+ }
@@ -0,0 +1,52 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * scrollcraft static server.
4
+ *
5
+ * A scrollcraft page cannot be verified from file://. The engine fetches each
6
+ * clip as a Blob, and file:// fetches are blocked by CORS in every browser, so
7
+ * the page silently falls back to posters and looks fine while proving nothing.
8
+ * Serve it.
9
+ *
10
+ * node serve.mjs --root builds/perkform --port 4500
11
+ */
12
+ import http from "node:http";
13
+ import fs from "node:fs";
14
+ import path from "node:path";
15
+
16
+ const argv = process.argv.slice(2);
17
+ const arg = (n, d) => { const i = argv.indexOf(n); return i > -1 && argv[i + 1] ? argv[i + 1] : d; };
18
+
19
+ const ROOT = path.resolve(arg("--root", "."));
20
+ const PORT = parseInt(arg("--port", "4500"), 10);
21
+
22
+ const TYPES = {
23
+ ".html": "text/html; charset=utf-8", ".css": "text/css; charset=utf-8",
24
+ ".js": "text/javascript; charset=utf-8", ".json": "application/json",
25
+ ".mp4": "video/mp4", ".webm": "video/webm",
26
+ ".webp": "image/webp", ".png": "image/png", ".jpg": "image/jpeg",
27
+ ".svg": "image/svg+xml", ".woff2": "font/woff2",
28
+ };
29
+
30
+ http.createServer((req, res) => {
31
+ const url = decodeURIComponent(req.url.split("?")[0]);
32
+ let file = path.join(ROOT, url === "/" ? "/index.html" : url);
33
+
34
+ // Refuse to serve outside the root even if the path walks up.
35
+ if (!file.startsWith(ROOT)) { res.writeHead(403).end("forbidden"); return; }
36
+ if (fs.existsSync(file) && fs.statSync(file).isDirectory()) file = path.join(file, "index.html");
37
+ if (!fs.existsSync(file)) { res.writeHead(404).end("not found"); return; }
38
+
39
+ const ext = path.extname(file).toLowerCase();
40
+ const stat = fs.statSync(file);
41
+ res.writeHead(200, {
42
+ "Content-Type": TYPES[ext] || "application/octet-stream",
43
+ "Content-Length": stat.size,
44
+ // No caching: verification loops re-shoot the same URLs after edits, and a
45
+ // cached clip or stylesheet makes you screenshot the previous build.
46
+ "Cache-Control": "no-store",
47
+ "Accept-Ranges": "bytes",
48
+ });
49
+ fs.createReadStream(file).pipe(res);
50
+ }).listen(PORT, () => {
51
+ console.log(`scrollcraft: ${ROOT}\n http://localhost:${PORT}`);
52
+ });