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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/package.json +1 -1
- package/skills/luckiest-design-website/ATTRIBUTION.md +38 -0
- package/skills/luckiest-design-website/CHANGELOG.md +104 -0
- package/skills/luckiest-design-website/LICENSE +21 -0
- package/skills/luckiest-design-website/SKILL.md +372 -0
- package/skills/luckiest-design-website/UPSTREAM-CHANGELOG.md +526 -0
- package/skills/luckiest-design-website/engine/scrollcraft.css +432 -0
- package/skills/luckiest-design-website/engine/scrollcraft.js +1167 -0
- package/skills/luckiest-design-website/evals/evals.json +33 -0
- package/skills/luckiest-design-website/references/assets.md +286 -0
- package/skills/luckiest-design-website/references/device-diag.html +214 -0
- package/skills/luckiest-design-website/references/devices.md +466 -0
- package/skills/luckiest-design-website/references/feel.md +277 -0
- package/skills/luckiest-design-website/references/registry-examples.md +305 -0
- package/skills/luckiest-design-website/references/taste.md +304 -0
- package/skills/luckiest-design-website/references/template.html +138 -0
- package/skills/luckiest-design-website/references/uniqueness.md +480 -0
- package/skills/luckiest-design-website/references/verify.md +381 -0
- package/skills/luckiest-design-website/references/worldflight.md +349 -0
- package/skills/luckiest-design-website/references/worlds.md +178 -0
- package/skills/luckiest-design-website/scripts/doctor.mjs +177 -0
- package/skills/luckiest-design-website/scripts/encode.sh +80 -0
- package/skills/luckiest-design-website/scripts/kie.mjs +202 -0
- package/skills/luckiest-design-website/scripts/serve.mjs +52 -0
- package/skills/luckiest-design-website/scripts/shoot.mjs +644 -0
- package/skills/luckiest-design-website/scripts/workspace.mjs +106 -0
- package/skills/luckiest-design-website/scripts/worldflight-assert.mjs +273 -0
- 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
|
+
});
|