cozyclay 1.0.0 → 1.2.0
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/README.md +59 -24
- package/THIRD_PARTY_NOTICES.md +31 -0
- package/bin/cozyclay.mjs +105 -11
- package/dist/CNAME +1 -0
- package/dist/app/index.html +57 -0
- package/dist/assets/app-Cgpk2hwX.js +4803 -0
- package/dist/assets/app-DgZvaAE1.css +1 -0
- package/dist/favicon.ico +0 -0
- package/dist/icons/apple-touch-icon.png +0 -0
- package/dist/icons/icon-192.png +0 -0
- package/dist/icons/icon-32.png +0 -0
- package/dist/icons/icon-512.png +0 -0
- package/dist/icons/icon-maskable-512.png +0 -0
- package/dist/index.html +406 -5
- package/dist/manifest.webmanifest +42 -0
- package/dist/media/cozyclay-demo-poster.jpg +0 -0
- package/dist/media/cozyclay-logo.png +0 -0
- package/dist/robots.txt +4 -0
- package/dist/sitemap.xml +9 -0
- package/dist/sw.js +127 -0
- package/package.json +15 -3
- package/src/App.jsx +1393 -648
- package/src/ardy/client.js +5 -4
- package/src/ardy/mp4-duration.js +102 -0
- package/src/ardy/timeline-coordinates.js +11 -0
- package/src/ardy/timeline.jsx +528 -67
- package/src/ardy/waypoints.js +14 -9
- package/src/camera-block.js +60 -0
- package/src/camera-follow.js +49 -10
- package/src/camera-rail-schedule.js +176 -0
- package/src/controls.jsx +16 -2
- package/src/cuts.js +184 -0
- package/src/generation/client.js +56 -0
- package/src/generation/session.js +47 -0
- package/src/generation/shot-spec.js +40 -0
- package/src/generation/use-generation.js +55 -0
- package/src/hierarchy-model.js +9 -17
- package/src/hierarchy-panel.jsx +170 -23
- package/src/locale-toggle.jsx +18 -0
- package/src/locale.js +34 -0
- package/src/main.jsx +5 -0
- package/src/object-catalog.jsx +32 -3
- package/src/planview.jsx +174 -66
- package/src/pose-extract/browser.js +42 -0
- package/src/pose-extract/fit.js +194 -0
- package/src/pose-extract/index.js +53 -0
- package/src/pose-extract/keyframes.js +74 -0
- package/src/pose-extract/landmarks.js +162 -0
- package/src/poses.js +114 -0
- package/src/posestudio.jsx +75 -13
- package/src/pwa.js +42 -0
- package/src/result-modal.jsx +124 -0
- package/src/scenes.js +223 -0
- package/src/shot-authoring.js +204 -77
- package/src/shot.js +8 -4
- package/src/styles.css +839 -38
- package/src/ui.jsx +54 -5
- package/tools/ardy/BRIDGE.md +70 -4
- package/tools/ardy/README.md +55 -0
- package/tools/ardy/bridge.mjs +89 -389
- package/tools/ardy/cclay_constrained_generate.py +449 -0
- package/tools/ardy/cclay_motion_edit.py +74 -9
- package/tools/ardy/cclay_sequence_generate.py +68 -7
- package/tools/ardy/cclay_worker.py +304 -0
- package/tools/ardy/merge-text-encoder.py +160 -0
- package/tools/ardy/run-local.mjs +233 -0
- package/tools/ardy/runners/index.mjs +23 -0
- package/tools/ardy/runners/local.mjs +452 -0
- package/tools/ardy/runners/proc.mjs +125 -0
- package/tools/ardy/runners/remote.mjs +246 -0
- package/tools/ardy/setup-local.mjs +189 -0
- package/tools/ardy/setup-text-encoder-on-box.sh +93 -0
- package/tools/ardy/setup-text-encoder.py +296 -0
- package/tools/generation/README.md +29 -0
- package/tools/generation/bridge.mjs +149 -0
- package/tools/generation/job-store.mjs +51 -0
- package/tools/generation/providers/kling.mjs +76 -0
- package/tools/generation/providers/runway.mjs +101 -0
- package/tools/generation/providers/seedance.mjs +87 -0
- package/tools/generation/providers/shared.mjs +49 -0
- package/tools/generation/providers/veo.mjs +84 -0
- package/tools/metrics/archive-traffic.mjs +138 -0
- package/dist/assets/index-BW_S1YRy.js +0 -4395
- package/dist/assets/index-nRmmJgap.css +0 -1
- package/tools/ardy/__pycache__/cclay_sequence_generate.cpython-313.pyc +0 -0
- package/tools/ardy/out/gen-1786443326924-c6019e-generated.npz +0 -0
- package/tools/ardy/out/gen-1786443609325-0053db-generated.npz +0 -0
- package/tools/ardy/out/gen-1786443835628-749ed1-generated.npz +0 -0
- package/tools/ardy/out/gen-1786462605247-03cb19-generated.npz +0 -0
- package/tools/qa-browser.mjs +0 -84
- package/tools/qa-crop3.mjs +0 -22
- package/tools/qa-playview.mjs +0 -49
- package/tools/qa-screenshot.mjs +0 -24
- package/tools/qa-visual.mjs +0 -51
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""setup-text-encoder.py - provision the ARDY text-encoder models without a
|
|
3
|
+
Hugging Face account, approval wait, or access token.
|
|
4
|
+
|
|
5
|
+
ARDY's text encoder is LLM2Vec on top of meta-llama/Meta-Llama-3-8B-Instruct.
|
|
6
|
+
The official Meta repository is gated (manual approval + token). This script
|
|
7
|
+
instead downloads a byte-pinned stack from public, ungated repositories:
|
|
8
|
+
|
|
9
|
+
base NousResearch/Meta-Llama-3-8B-Instruct (public mirror of the
|
|
10
|
+
official weights; every safetensors shard is verified against a
|
|
11
|
+
pinned SHA-256 before use)
|
|
12
|
+
adapters McGill-NLP/LLM2Vec-Meta-Llama-3-8B-Instruct-mntp
|
|
13
|
+
McGill-NLP/LLM2Vec-Meta-Llama-3-8B-Instruct-mntp-supervised
|
|
14
|
+
|
|
15
|
+
and lays them out for ARDY's `TEXT_ENCODERS_DIR` override
|
|
16
|
+
(ardy/model/llm2vec/llm2vec_wrapper.py), rewriting each adapter's
|
|
17
|
+
`base_model_name_or_path` to the local base directory so nothing ever
|
|
18
|
+
resolves back to the gated meta-llama repository at runtime.
|
|
19
|
+
|
|
20
|
+
Every revision is pinned to a full commit SHA and every LFS payload to a
|
|
21
|
+
SHA-256, so re-runs are reproducible and a tampered or changed upstream file
|
|
22
|
+
fails loudly instead of silently drifting the embeddings (and with them the
|
|
23
|
+
generated motion).
|
|
24
|
+
|
|
25
|
+
Licenses: the base weights are Meta Llama 3 Community License (the LICENSE
|
|
26
|
+
and USE_POLICY.md files are downloaded alongside the weights and must stay
|
|
27
|
+
there); the McGill adapters are MIT. This script only downloads for local
|
|
28
|
+
use - it does not redistribute anything.
|
|
29
|
+
|
|
30
|
+
Usage (typically on the ARDY box; stdlib only, python >= 3.8):
|
|
31
|
+
|
|
32
|
+
python3 setup-text-encoder.py # into ~/cclay-text-encoders
|
|
33
|
+
python3 setup-text-encoder.py --dest /srv/enc # custom destination
|
|
34
|
+
python3 setup-text-encoder.py --verify-only # re-hash an existing tree
|
|
35
|
+
|
|
36
|
+
Afterwards start the encoder service with the printed environment:
|
|
37
|
+
|
|
38
|
+
TEXT_ENCODERS_DIR=<dest> python scripts/run_text_encoder_server.py
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
import argparse
|
|
42
|
+
import hashlib
|
|
43
|
+
import json
|
|
44
|
+
import os
|
|
45
|
+
import sys
|
|
46
|
+
import time
|
|
47
|
+
import urllib.error
|
|
48
|
+
import urllib.request
|
|
49
|
+
|
|
50
|
+
HF = "https://huggingface.co"
|
|
51
|
+
|
|
52
|
+
BASE_DIR = "NousResearch/Meta-Llama-3-8B-Instruct"
|
|
53
|
+
MNTP_DIR = "McGill-NLP/LLM2Vec-Meta-Llama-3-8B-Instruct-mntp"
|
|
54
|
+
# The mntp *download* lands in a -src dir: merge-text-encoder.py bakes the
|
|
55
|
+
# base+MNTP merge into the runtime MNTP_DIR path (transformers v5 dropped
|
|
56
|
+
# automatic adapter-dir resolution, so the runtime path must be a full model).
|
|
57
|
+
MNTP_SRC_DIR = MNTP_DIR + "-src"
|
|
58
|
+
SUP_DIR = "McGill-NLP/LLM2Vec-Meta-Llama-3-8B-Instruct-mntp-supervised"
|
|
59
|
+
|
|
60
|
+
# (local dir, repo id, pinned full commit SHA, files)
|
|
61
|
+
# Each file: (path, size, sha256-or-None). SHA-256 comes from the HF LFS
|
|
62
|
+
# metadata of the pinned revision; small non-LFS files carry only a size and
|
|
63
|
+
# are already immutable because the revision is a commit hash.
|
|
64
|
+
# Pins match nv-tlabs/ardy issue #9's reproduction stack.
|
|
65
|
+
MANIFEST = [
|
|
66
|
+
(
|
|
67
|
+
BASE_DIR,
|
|
68
|
+
"NousResearch/Meta-Llama-3-8B-Instruct",
|
|
69
|
+
"53346005fb0ef11d3b6a83b12c895cca40156b6c",
|
|
70
|
+
[
|
|
71
|
+
("LICENSE", 7801, None),
|
|
72
|
+
("USE_POLICY.md", 4696, None),
|
|
73
|
+
("config.json", 654, None),
|
|
74
|
+
("generation_config.json", 187, None),
|
|
75
|
+
("model-00001-of-00004.safetensors", 4976698672,
|
|
76
|
+
"d8cf9c4d0dd972e1a2131bfe656235ee98221679711a3beef6d46dadf0f20b5c"),
|
|
77
|
+
("model-00002-of-00004.safetensors", 4999802720,
|
|
78
|
+
"8d4782b4a69ef03845159ce1a15e272aadaaf134dc138d68f616098e8531729c"),
|
|
79
|
+
("model-00003-of-00004.safetensors", 4915916176,
|
|
80
|
+
"3acdd690e65c24f42a24581b8467af98bd3ca357444580f8012aacd2bd607921"),
|
|
81
|
+
("model-00004-of-00004.safetensors", 1168138808,
|
|
82
|
+
"67e9ad31c8c32abf3a55ee7fc7217b3ecb35fd3c74d98a5bd233e0e4d6964f46"),
|
|
83
|
+
("model.safetensors.index.json", 23950, None),
|
|
84
|
+
("special_tokens_map.json", 73, None),
|
|
85
|
+
("tokenizer.json", 9085698, None),
|
|
86
|
+
("tokenizer_config.json", 50977, None),
|
|
87
|
+
],
|
|
88
|
+
),
|
|
89
|
+
(
|
|
90
|
+
MNTP_SRC_DIR,
|
|
91
|
+
"McGill-NLP/LLM2Vec-Meta-Llama-3-8B-Instruct-mntp",
|
|
92
|
+
"31474e395ada192e8ed1586db6be79fb3b70c9c0",
|
|
93
|
+
[
|
|
94
|
+
("README.md", 3315, None),
|
|
95
|
+
("adapter_config.json", 794, None),
|
|
96
|
+
("adapter_model.safetensors", 167829552,
|
|
97
|
+
"de9c8736618a13173c6a1623cdef1b75e86c69317f1073ae82cd516ac36a632d"),
|
|
98
|
+
("attn_mask_utils.py", 11132, None),
|
|
99
|
+
("config.json", 781, None),
|
|
100
|
+
("modeling_llama_encoder.py", 2856, None),
|
|
101
|
+
("special_tokens_map.json", 335, None),
|
|
102
|
+
("tokenizer.json", 9085671, None),
|
|
103
|
+
("tokenizer_config.json", 51042, None),
|
|
104
|
+
],
|
|
105
|
+
),
|
|
106
|
+
(
|
|
107
|
+
SUP_DIR,
|
|
108
|
+
"McGill-NLP/LLM2Vec-Meta-Llama-3-8B-Instruct-mntp-supervised",
|
|
109
|
+
"baa8ebf04a1c2500e61288e7dad65e8ae42601a7",
|
|
110
|
+
[
|
|
111
|
+
("README.md", 62702, None),
|
|
112
|
+
("adapter_config.json", 794, None),
|
|
113
|
+
("adapter_model.safetensors", 167829552,
|
|
114
|
+
"53f8f94ebdf396667ba99dd96e78203edae27bbcdbd1cf5f12b611e1d916b225"),
|
|
115
|
+
],
|
|
116
|
+
),
|
|
117
|
+
]
|
|
118
|
+
|
|
119
|
+
# adapter_config.json is rewritten after download (base_model_name_or_path ->
|
|
120
|
+
# local base dir), so its byte size legitimately changes; everything else must
|
|
121
|
+
# keep the manifest size forever.
|
|
122
|
+
MUTATED = {"adapter_config.json"}
|
|
123
|
+
|
|
124
|
+
CHUNK = 1024 * 1024
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def log(msg):
|
|
128
|
+
print("setup-text-encoder: %s" % msg, flush=True)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def fail(msg):
|
|
132
|
+
print("setup-text-encoder: ERROR: %s" % msg, file=sys.stderr, flush=True)
|
|
133
|
+
sys.exit(1)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def sha256_file(path):
|
|
137
|
+
h = hashlib.sha256()
|
|
138
|
+
with open(path, "rb") as f:
|
|
139
|
+
while True:
|
|
140
|
+
block = f.read(CHUNK)
|
|
141
|
+
if not block:
|
|
142
|
+
break
|
|
143
|
+
h.update(block)
|
|
144
|
+
return h.hexdigest()
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def file_ok(path, size, sha, verify_hash):
|
|
148
|
+
"""Is the on-disk file already the pinned artifact?"""
|
|
149
|
+
if not os.path.isfile(path):
|
|
150
|
+
return False
|
|
151
|
+
name = os.path.basename(path)
|
|
152
|
+
if name in MUTATED:
|
|
153
|
+
return True # rewritten in place; checked by rewrite_adapter_config
|
|
154
|
+
if os.path.getsize(path) != size:
|
|
155
|
+
return False
|
|
156
|
+
if sha is not None and verify_hash:
|
|
157
|
+
return sha256_file(path) == sha
|
|
158
|
+
return True
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def download(url, dest, size, sha):
|
|
162
|
+
"""Resumable download to dest + '.partial', verified, then atomic rename."""
|
|
163
|
+
part = dest + ".partial"
|
|
164
|
+
os.makedirs(os.path.dirname(dest), exist_ok=True)
|
|
165
|
+
for attempt in range(1, 6):
|
|
166
|
+
try:
|
|
167
|
+
have = os.path.getsize(part) if os.path.isfile(part) else 0
|
|
168
|
+
if have > size:
|
|
169
|
+
os.remove(part)
|
|
170
|
+
have = 0
|
|
171
|
+
if have < size:
|
|
172
|
+
req = urllib.request.Request(url)
|
|
173
|
+
req.add_header("User-Agent", "cozyclay-setup-text-encoder/1")
|
|
174
|
+
if have:
|
|
175
|
+
req.add_header("Range", "bytes=%d-" % have)
|
|
176
|
+
with urllib.request.urlopen(req, timeout=60) as resp:
|
|
177
|
+
if have and resp.status != 206:
|
|
178
|
+
# Server ignored the Range header; start over.
|
|
179
|
+
have = 0
|
|
180
|
+
mode = "wb"
|
|
181
|
+
else:
|
|
182
|
+
mode = "ab" if have else "wb"
|
|
183
|
+
done = have
|
|
184
|
+
t0 = time.time()
|
|
185
|
+
with open(part, mode) as out:
|
|
186
|
+
while True:
|
|
187
|
+
block = resp.read(CHUNK)
|
|
188
|
+
if not block:
|
|
189
|
+
break
|
|
190
|
+
out.write(block)
|
|
191
|
+
done += len(block)
|
|
192
|
+
if size > CHUNK and time.time() - t0 > 5:
|
|
193
|
+
log(" ... %d / %d MiB" % (done // 2**20, size // 2**20))
|
|
194
|
+
t0 = time.time()
|
|
195
|
+
got = os.path.getsize(part)
|
|
196
|
+
if got != size:
|
|
197
|
+
raise IOError("size mismatch: got %d, want %d" % (got, size))
|
|
198
|
+
if sha is not None:
|
|
199
|
+
actual = sha256_file(part)
|
|
200
|
+
if actual != sha:
|
|
201
|
+
os.remove(part)
|
|
202
|
+
raise IOError("sha256 mismatch: got %s, want %s" % (actual, sha))
|
|
203
|
+
os.replace(part, dest)
|
|
204
|
+
return
|
|
205
|
+
except (urllib.error.URLError, IOError, OSError) as e:
|
|
206
|
+
if attempt == 5:
|
|
207
|
+
fail("giving up on %s after 5 attempts: %s" % (url, e))
|
|
208
|
+
log(" retry %d/5 for %s (%s)" % (attempt, os.path.basename(dest), e))
|
|
209
|
+
time.sleep(2 * attempt)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def rewrite_adapter_config(path, base_path):
|
|
213
|
+
"""Point the adapter at the local base so nothing resolves to gated repos."""
|
|
214
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
215
|
+
cfg = json.load(f)
|
|
216
|
+
if cfg.get("base_model_name_or_path") == base_path:
|
|
217
|
+
return False
|
|
218
|
+
cfg["base_model_name_or_path"] = base_path
|
|
219
|
+
tmp = path + ".tmp"
|
|
220
|
+
with open(tmp, "w", encoding="utf-8") as f:
|
|
221
|
+
json.dump(cfg, f, indent=2)
|
|
222
|
+
f.write("\n")
|
|
223
|
+
os.replace(tmp, path)
|
|
224
|
+
return True
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def main():
|
|
228
|
+
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
|
229
|
+
ap.add_argument("--dest", default=os.environ.get(
|
|
230
|
+
"TEXT_ENCODERS_DIR", os.path.expanduser("~/cclay-text-encoders")),
|
|
231
|
+
help="target directory (default: $TEXT_ENCODERS_DIR or ~/cclay-text-encoders)")
|
|
232
|
+
ap.add_argument("--verify-only", action="store_true",
|
|
233
|
+
help="verify an existing tree (sizes + SHA-256), download nothing")
|
|
234
|
+
ap.add_argument("--print-plan", action="store_true",
|
|
235
|
+
help="list every pinned file and exit")
|
|
236
|
+
args = ap.parse_args()
|
|
237
|
+
|
|
238
|
+
dest = os.path.abspath(os.path.expanduser(args.dest))
|
|
239
|
+
base_path = os.path.join(dest, BASE_DIR)
|
|
240
|
+
|
|
241
|
+
if args.print_plan:
|
|
242
|
+
for local_dir, repo, rev, files in MANIFEST:
|
|
243
|
+
print("%s @ %s" % (repo, rev))
|
|
244
|
+
for path, size, sha in files:
|
|
245
|
+
print(" %-40s %12d %s" % (path, size, sha or "-"))
|
|
246
|
+
return
|
|
247
|
+
|
|
248
|
+
total = sum(size for _, _, _, files in MANIFEST for _, size, _ in files)
|
|
249
|
+
missing = []
|
|
250
|
+
for local_dir, repo, rev, files in MANIFEST:
|
|
251
|
+
for path, size, sha in files:
|
|
252
|
+
target = os.path.join(dest, local_dir, path)
|
|
253
|
+
if not file_ok(target, size, sha, verify_hash=args.verify_only):
|
|
254
|
+
missing.append((local_dir, repo, rev, path, size, sha))
|
|
255
|
+
|
|
256
|
+
if args.verify_only:
|
|
257
|
+
if missing:
|
|
258
|
+
for local_dir, _, _, path, _, _ in missing:
|
|
259
|
+
print(" BAD/MISSING %s/%s" % (local_dir, path))
|
|
260
|
+
fail("%d file(s) failed verification under %s" % (len(missing), dest))
|
|
261
|
+
log("all %d files verified OK under %s" % (
|
|
262
|
+
sum(len(f) for _, _, _, f in MANIFEST), dest))
|
|
263
|
+
return
|
|
264
|
+
|
|
265
|
+
if missing:
|
|
266
|
+
todo = sum(m[4] for m in missing)
|
|
267
|
+
log("destination: %s" % dest)
|
|
268
|
+
log("downloading %d file(s), %.1f GiB of %.1f GiB total pinned payload"
|
|
269
|
+
% (len(missing), todo / 2**30, total / 2**30))
|
|
270
|
+
for local_dir, repo, rev, path, size, sha in missing:
|
|
271
|
+
url = "%s/%s/resolve/%s/%s?download=true" % (HF, repo, rev, path)
|
|
272
|
+
log("fetch %s/%s (%d bytes)" % (local_dir, path, size))
|
|
273
|
+
download(url, os.path.join(dest, local_dir, path), size, sha)
|
|
274
|
+
else:
|
|
275
|
+
log("all pinned files already present under %s" % dest)
|
|
276
|
+
|
|
277
|
+
for adapter_dir in (MNTP_SRC_DIR, SUP_DIR):
|
|
278
|
+
cfg = os.path.join(dest, adapter_dir, "adapter_config.json")
|
|
279
|
+
if rewrite_adapter_config(cfg, base_path):
|
|
280
|
+
log("repointed %s -> local base" % os.path.join(adapter_dir, "adapter_config.json"))
|
|
281
|
+
|
|
282
|
+
log("downloads verified. Bake the MNTP merge next (one-time, ~5 min):")
|
|
283
|
+
log(" cd <ardy checkout> && <ardy venv python> merge-text-encoder.py --dest %s" % dest)
|
|
284
|
+
print()
|
|
285
|
+
print("Start ARDY's text-encoder service (and any local ARDY run) with:")
|
|
286
|
+
print()
|
|
287
|
+
print(" export TEXT_ENCODERS_DIR=%s" % dest)
|
|
288
|
+
print(" python scripts/run_text_encoder_server.py")
|
|
289
|
+
print()
|
|
290
|
+
print("The base weights are Meta Llama 3 (Meta Llama 3 Community License;")
|
|
291
|
+
print("see %s/LICENSE)." % base_path)
|
|
292
|
+
print("This product is built with Meta Llama 3.")
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
if __name__ == "__main__":
|
|
296
|
+
main()
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# Video generation bridge
|
|
2
|
+
|
|
3
|
+
The bridge keeps provider credentials out of the browser and translates CozyClay's provider-neutral ShotSpec into provider requests.
|
|
4
|
+
|
|
5
|
+
## Run locally
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
RUNWAYML_API_SECRET=key_here npm run dev
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
The bridge binds to `127.0.0.1:5182`; Vite proxies `/generation` to it. The static GitHub Pages build remains generation-free.
|
|
12
|
+
|
|
13
|
+
## Endpoints
|
|
14
|
+
|
|
15
|
+
- `GET /generation/health`
|
|
16
|
+
- `GET /generation/models`
|
|
17
|
+
- `POST /generation/validate`
|
|
18
|
+
- `POST /generation/jobs`
|
|
19
|
+
- `GET /generation/jobs/:id`
|
|
20
|
+
- `DELETE /generation/jobs/:id` (when the provider exposes verified remote cancellation)
|
|
21
|
+
|
|
22
|
+
## Provider credentials
|
|
23
|
+
|
|
24
|
+
- Runway: `RUNWAYML_API_SECRET`
|
|
25
|
+
- Seedance / BytePlus ModelArk: `ARK_API_KEY`
|
|
26
|
+
- Kling: `KLING_API_TOKEN` (a server-side bearer token produced by Kling's authentication flow)
|
|
27
|
+
- Veo / Vertex AI: `GOOGLE_CLOUD_ACCESS_TOKEN`, `GOOGLE_CLOUD_PROJECT`, and optionally `GOOGLE_CLOUD_LOCATION`
|
|
28
|
+
|
|
29
|
+
The browser receives only model descriptors. Provider payloads and credentials stay inside `providers/`; completed MP4 files are downloaded immediately into the shared local job store.
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { createServer } from "node:http";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { createReadStream } from "node:fs";
|
|
5
|
+
import { mkdir, rename, stat } from "node:fs/promises";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { pipeline } from "node:stream/promises";
|
|
8
|
+
import { Readable } from "node:stream";
|
|
9
|
+
import { pathToFileURL } from "node:url";
|
|
10
|
+
import { createJobStore } from "./job-store.mjs";
|
|
11
|
+
import { klingProvider } from "./providers/kling.mjs";
|
|
12
|
+
import { runwayProvider } from "./providers/runway.mjs";
|
|
13
|
+
import { seedanceProvider } from "./providers/seedance.mjs";
|
|
14
|
+
import { veoProvider } from "./providers/veo.mjs";
|
|
15
|
+
|
|
16
|
+
const HOST = "127.0.0.1";
|
|
17
|
+
const PORT = Number(process.env.CCLAY_GENERATION_PORT || 5182);
|
|
18
|
+
const MAX_BODY = 16 * 1024 * 1024;
|
|
19
|
+
const RETRYABLE = new Set([429, 502, 503, 504]);
|
|
20
|
+
|
|
21
|
+
function json(res, status, body) {
|
|
22
|
+
const data = Buffer.from(JSON.stringify(body));
|
|
23
|
+
res.writeHead(status, { "Content-Type": "application/json; charset=utf-8", "Content-Length": data.length, "Cache-Control": "no-store" });
|
|
24
|
+
res.end(data);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function readJson(req) {
|
|
28
|
+
const chunks = [];
|
|
29
|
+
let size = 0;
|
|
30
|
+
for await (const chunk of req) {
|
|
31
|
+
size += chunk.length;
|
|
32
|
+
if (size > MAX_BODY) throw Object.assign(new Error("request body exceeds 16 MB"), { status: 413 });
|
|
33
|
+
chunks.push(chunk);
|
|
34
|
+
}
|
|
35
|
+
try { return JSON.parse(Buffer.concat(chunks).toString("utf8")); }
|
|
36
|
+
catch { throw Object.assign(new Error("invalid JSON body"), { status: 400 }); }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function publicJob(job) {
|
|
40
|
+
const { raw, outputPath, remoteOutputUrl, ...safe } = job;
|
|
41
|
+
return { ...safe, outputUrl: outputPath ? `/generation/jobs/${encodeURIComponent(job.id)}/output` : null };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function downloadResult(url, target, { fetchImpl = fetch, retries = 3, timeoutMs = 30000, sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) } = {}) {
|
|
45
|
+
await mkdir(join(target, ".."), { recursive: true });
|
|
46
|
+
const temp = `${target}.${process.pid}.tmp`;
|
|
47
|
+
for (let attempt = 0; attempt <= retries; attempt += 1) {
|
|
48
|
+
const response = await fetchImpl(url, { signal: AbortSignal.timeout(timeoutMs) });
|
|
49
|
+
if (response.ok && response.body) {
|
|
50
|
+
await pipeline(Readable.fromWeb(response.body), await import("node:fs").then(({ createWriteStream }) => createWriteStream(temp)));
|
|
51
|
+
await rename(temp, target);
|
|
52
|
+
return target;
|
|
53
|
+
}
|
|
54
|
+
await response.body?.cancel().catch(() => {});
|
|
55
|
+
if (!RETRYABLE.has(response.status) || attempt === retries) throw new Error(`result download failed (HTTP ${response.status})`);
|
|
56
|
+
await sleep(250 * (2 ** attempt));
|
|
57
|
+
}
|
|
58
|
+
throw new Error("result download failed");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function createGenerationServer({ store = createJobStore(), providers = { runway: runwayProvider, seedance: seedanceProvider, kling: klingProvider, veo: veoProvider }, download = downloadResult, now = () => new Date().toISOString() } = {}) {
|
|
62
|
+
const ready = store.load();
|
|
63
|
+
return createServer(async (req, res) => {
|
|
64
|
+
try {
|
|
65
|
+
await ready;
|
|
66
|
+
const url = new URL(req.url, `http://${HOST}:${PORT}`);
|
|
67
|
+
if (req.method === "GET" && url.pathname === "/generation/health") {
|
|
68
|
+
return json(res, 200, { ok: true, providers: Object.fromEntries(Object.entries(providers).map(([id, provider]) => [id, provider.available()])) });
|
|
69
|
+
}
|
|
70
|
+
if (req.method === "GET" && url.pathname === "/generation/models") {
|
|
71
|
+
const models = Object.entries(providers).flatMap(([providerId, provider]) => provider.models.map((model) => ({ ...model, provider: providerId, available: provider.available() })));
|
|
72
|
+
return json(res, 200, { models });
|
|
73
|
+
}
|
|
74
|
+
if (req.method === "GET" && url.pathname === "/generation/jobs") {
|
|
75
|
+
return json(res, 200, { jobs: store.list().map(publicJob) });
|
|
76
|
+
}
|
|
77
|
+
if (req.method === "POST" && url.pathname === "/generation/validate") {
|
|
78
|
+
const spec = await readJson(req);
|
|
79
|
+
const provider = providers[spec.provider];
|
|
80
|
+
if (!provider) return json(res, 422, { reason: spec.provider ? `unsupported provider: ${spec.provider}` : "provider is required" });
|
|
81
|
+
if (!spec.model) return json(res, 422, { reason: "model is required" });
|
|
82
|
+
const verdict = provider.validate(spec);
|
|
83
|
+
return json(res, verdict.ok ? 200 : 422, { ok: verdict.ok, errors: verdict.errors, warnings: verdict.warnings, estimatedCostUsd: verdict.ok ? provider.estimateCost(spec) : null });
|
|
84
|
+
}
|
|
85
|
+
if (req.method === "POST" && url.pathname === "/generation/jobs") {
|
|
86
|
+
const spec = await readJson(req);
|
|
87
|
+
const provider = providers[spec.provider];
|
|
88
|
+
if (!provider) return json(res, 422, { reason: spec.provider ? `unsupported provider: ${spec.provider}` : "provider is required" });
|
|
89
|
+
if (!provider.available()) return json(res, 503, { reason: `${spec.provider} is not configured` });
|
|
90
|
+
const verdict = provider.validate(spec);
|
|
91
|
+
if (!verdict.ok) return json(res, 422, { reason: verdict.errors[0], errors: verdict.errors, warnings: verdict.warnings });
|
|
92
|
+
const submitted = await provider.submit(spec);
|
|
93
|
+
const timestamp = now();
|
|
94
|
+
const job = { id: randomUUID(), provider: spec.provider, model: spec.model, shot: spec.shot ?? null, camera: spec.camera ?? null, subjects: spec.subjects ?? [], providerJobId: submitted.providerJobId, status: "processing", progress: null, outputPath: null, remoteOutputUrl: null, failure: null, warnings: submitted.warnings, estimatedCostUsd: provider.estimateCost(spec), createdAt: timestamp, updatedAt: timestamp };
|
|
95
|
+
await store.set(job);
|
|
96
|
+
return json(res, 202, publicJob(job));
|
|
97
|
+
}
|
|
98
|
+
const outputMatch = url.pathname.match(/^\/generation\/jobs\/([^/]+)\/output$/);
|
|
99
|
+
if (req.method === "GET" && outputMatch) {
|
|
100
|
+
const job = store.get(outputMatch[1]);
|
|
101
|
+
if (!job?.outputPath) return json(res, 404, { reason: "result not available" });
|
|
102
|
+
const info = await stat(job.outputPath).catch(() => null);
|
|
103
|
+
if (!info) return json(res, 410, { reason: "saved result is missing" });
|
|
104
|
+
res.writeHead(200, { "Content-Type": "video/mp4", "Content-Length": info.size, "Content-Disposition": `attachment; filename="cozyclay-${job.id}.mp4"`, "Cache-Control": "no-store" });
|
|
105
|
+
createReadStream(job.outputPath).pipe(res);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
const match = url.pathname.match(/^\/generation\/jobs\/([^/]+)$/);
|
|
109
|
+
if (req.method === "DELETE" && match) {
|
|
110
|
+
const job = store.get(match[1]);
|
|
111
|
+
if (!job) return json(res, 404, { reason: "job not found" });
|
|
112
|
+
if (job.status !== "processing") return json(res, 200, publicJob(job));
|
|
113
|
+
const provider = providers[job.provider];
|
|
114
|
+
if (!provider?.cancel) return json(res, 409, { reason: `${job.provider} does not expose a verified remote cancellation operation` });
|
|
115
|
+
if (!provider.available()) return json(res, 503, { reason: `${job.provider} is not configured; restore its key to cancel this job` });
|
|
116
|
+
await provider.cancel(job.providerJobId, { model: job.model });
|
|
117
|
+
Object.assign(job, { status: "canceled", failure: "generation canceled", updatedAt: now() });
|
|
118
|
+
await store.set(job);
|
|
119
|
+
return json(res, 200, publicJob(job));
|
|
120
|
+
}
|
|
121
|
+
if (req.method === "GET" && match) {
|
|
122
|
+
const job = store.get(match[1]);
|
|
123
|
+
if (!job) return json(res, 404, { reason: "job not found" });
|
|
124
|
+
if (job.status === "processing") {
|
|
125
|
+
const provider = providers[job.provider];
|
|
126
|
+
if (!provider?.available()) return json(res, 503, { reason: `${job.provider} is not configured; restore its key to resume this job` });
|
|
127
|
+
const state = await provider.poll(job.providerJobId, { model: job.model });
|
|
128
|
+
Object.assign(job, state, { remoteOutputUrl: state.outputUrl ?? job.remoteOutputUrl, updatedAt: now() });
|
|
129
|
+
if (state.status === "succeeded" && state.outputUrl) {
|
|
130
|
+
const target = join(store.resultDir, `${job.id}.mp4`);
|
|
131
|
+
try { await download(state.outputUrl, target); job.outputPath = target; }
|
|
132
|
+
catch (error) { job.status = "failed"; job.failure = `provider finished, but its expiring output could not be saved locally: ${error.message}`; }
|
|
133
|
+
}
|
|
134
|
+
await store.set(job);
|
|
135
|
+
}
|
|
136
|
+
return json(res, 200, publicJob(job));
|
|
137
|
+
}
|
|
138
|
+
return json(res, 404, { reason: "not found" });
|
|
139
|
+
} catch (error) {
|
|
140
|
+
return json(res, error.status || 500, { reason: error.message || String(error) });
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
146
|
+
const server = createGenerationServer();
|
|
147
|
+
server.listen(PORT, HOST, () => console.log(`[generation] listening on http://${HOST}:${PORT}`));
|
|
148
|
+
for (const signal of ["SIGINT", "SIGTERM"]) process.on(signal, () => server.close(() => process.exit(0)));
|
|
149
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
|
|
5
|
+
export const DEFAULT_JOB_FILE = join(homedir(), ".cozyclay", "jobs.json");
|
|
6
|
+
export const DEFAULT_RESULT_DIR = join(homedir(), ".cozyclay", "results");
|
|
7
|
+
export const DEFAULT_COMPLETED_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
8
|
+
const TERMINAL = new Set(["succeeded", "failed"]);
|
|
9
|
+
|
|
10
|
+
export function createJobStore({ file = DEFAULT_JOB_FILE, resultDir = DEFAULT_RESULT_DIR, completedTtlMs = DEFAULT_COMPLETED_TTL_MS, now = () => Date.now() } = {}) {
|
|
11
|
+
const jobs = new Map();
|
|
12
|
+
let writeQueue = Promise.resolve();
|
|
13
|
+
|
|
14
|
+
async function load() {
|
|
15
|
+
try {
|
|
16
|
+
const parsed = JSON.parse(await readFile(file, "utf8"));
|
|
17
|
+
for (const job of Array.isArray(parsed.jobs) ? parsed.jobs : []) if (job?.id) jobs.set(job.id, job);
|
|
18
|
+
} catch (error) {
|
|
19
|
+
if (error.code !== "ENOENT") throw error;
|
|
20
|
+
}
|
|
21
|
+
await cleanup();
|
|
22
|
+
return jobs;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function persist() {
|
|
26
|
+
writeQueue = writeQueue.then(async () => {
|
|
27
|
+
await mkdir(dirname(file), { recursive: true });
|
|
28
|
+
const temp = `${file}.${process.pid}.tmp`;
|
|
29
|
+
await writeFile(temp, JSON.stringify({ version: 1, jobs: [...jobs.values()] }, null, 2));
|
|
30
|
+
await rename(temp, file);
|
|
31
|
+
});
|
|
32
|
+
return writeQueue;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function cleanup() {
|
|
36
|
+
const cutoff = now() - completedTtlMs;
|
|
37
|
+
let changed = false;
|
|
38
|
+
for (const [id, job] of jobs) {
|
|
39
|
+
if (!TERMINAL.has(job.status) || Date.parse(job.updatedAt) >= cutoff) continue;
|
|
40
|
+
jobs.delete(id);
|
|
41
|
+
if (job.outputPath) await unlink(job.outputPath).catch(() => {});
|
|
42
|
+
changed = true;
|
|
43
|
+
}
|
|
44
|
+
if (changed) await persist();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function set(job) { jobs.set(job.id, job); await persist(); return job; }
|
|
48
|
+
function get(id) { return jobs.get(id) ?? null; }
|
|
49
|
+
function list() { return [...jobs.values()].sort((a, b) => b.createdAt.localeCompare(a.createdAt)); }
|
|
50
|
+
return { file, resultDir, load, cleanup, set, get, list };
|
|
51
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { fetchProviderJson, findModel, noKnownUsdEstimate } from "./shared.mjs";
|
|
2
|
+
|
|
3
|
+
const API = "https://api.klingai.com/v1/videos/image2video";
|
|
4
|
+
|
|
5
|
+
export const KLING_MODELS = Object.freeze([
|
|
6
|
+
{
|
|
7
|
+
id: "kling-v3",
|
|
8
|
+
label: "Kling Video 3.0",
|
|
9
|
+
durations: [5, 10],
|
|
10
|
+
resolutions: ["720p", "1080p"],
|
|
11
|
+
capabilities: { prompt: true, startFrame: true, endFrame: true, referenceVideo: false, cameraControl: false, audio: true },
|
|
12
|
+
},
|
|
13
|
+
]);
|
|
14
|
+
|
|
15
|
+
const authHeaders = (token) => ({ Authorization: `Bearer ${token}`, "Content-Type": "application/json" });
|
|
16
|
+
const modelOf = (id) => findModel(KLING_MODELS, id, "Kling");
|
|
17
|
+
|
|
18
|
+
export function validateKlingSpec(spec) {
|
|
19
|
+
const model = modelOf(spec.model);
|
|
20
|
+
const errors = [];
|
|
21
|
+
const warnings = [];
|
|
22
|
+
if (!spec.prompt?.trim()) errors.push("prompt is required");
|
|
23
|
+
if (!model.durations.includes(spec.durationSeconds)) errors.push(`${model.label} duration must be 5 or 10 seconds`);
|
|
24
|
+
if (!model.resolutions.includes(spec.resolution)) errors.push(`${model.label} resolution must be 720p or 1080p`);
|
|
25
|
+
if (!spec.conditioning?.startFrame) errors.push(`${model.label} requires a staged start frame`);
|
|
26
|
+
if (spec.conditioning?.referenceVideo) warnings.push("Kling reference-video request mapping is unverified and will not be sent");
|
|
27
|
+
if (spec.camera) warnings.push("Kling 3.0 camera-control payload is unverified; staged frames and composed prompt will carry the authored camera move");
|
|
28
|
+
return { ok: errors.length === 0, errors, warnings, model };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function mapKlingRequest(spec) {
|
|
32
|
+
const verdict = validateKlingSpec(spec);
|
|
33
|
+
if (!verdict.ok) throw new Error(verdict.errors[0]);
|
|
34
|
+
const body = {
|
|
35
|
+
model_name: verdict.model.id,
|
|
36
|
+
image: spec.conditioning.startFrame,
|
|
37
|
+
prompt: spec.prompt,
|
|
38
|
+
mode: spec.resolution === "1080p" ? "pro" : "std",
|
|
39
|
+
duration: String(spec.durationSeconds),
|
|
40
|
+
sound: "on",
|
|
41
|
+
};
|
|
42
|
+
if (spec.conditioning.endFrame) body.image_tail = spec.conditioning.endFrame;
|
|
43
|
+
return body;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function submitKling(spec, { token = process.env.KLING_API_TOKEN, fetchImpl = fetch, signal, ...retryOptions } = {}) {
|
|
47
|
+
if (!token) throw new Error("KLING_API_TOKEN is not configured; provide a server-side token generated by Kling's documented authentication flow");
|
|
48
|
+
const verdict = validateKlingSpec(spec);
|
|
49
|
+
if (!verdict.ok) throw new Error(verdict.errors[0]);
|
|
50
|
+
const { response, payload } = await fetchProviderJson(API, { method: "POST", headers: authHeaders(token), body: JSON.stringify(mapKlingRequest(spec)) }, { fetchImpl, signal, ...retryOptions });
|
|
51
|
+
if (!response.ok) throw new Error(`Kling submit failed (HTTP ${response.status})`);
|
|
52
|
+
const providerJobId = payload.data?.task_id;
|
|
53
|
+
if (!providerJobId) throw new Error("Kling submit response has no task id");
|
|
54
|
+
return { providerJobId, warnings: verdict.warnings };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function pollKling(providerJobId, { token = process.env.KLING_API_TOKEN, fetchImpl = fetch, signal, ...retryOptions } = {}) {
|
|
58
|
+
if (!token) throw new Error("KLING_API_TOKEN is not configured");
|
|
59
|
+
const { response, payload } = await fetchProviderJson(`${API}/${encodeURIComponent(providerJobId)}`, { headers: authHeaders(token) }, { fetchImpl, signal, ...retryOptions });
|
|
60
|
+
if (!response.ok) throw new Error(`Kling poll failed (HTTP ${response.status})`);
|
|
61
|
+
const remote = String(payload.data?.task_status || "").toLowerCase();
|
|
62
|
+
const status = remote === "succeed" || remote === "succeeded" ? "succeeded" : ["failed", "canceled", "cancelled"].includes(remote) ? "failed" : "processing";
|
|
63
|
+
return { status, progress: null, outputUrl: status === "succeeded" ? payload.data?.task_result?.videos?.[0]?.url ?? null : null, failure: status === "failed" ? payload.data?.task_status_msg || payload.message || "generation failed" : null, raw: payload };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export const klingProvider = Object.freeze({
|
|
67
|
+
id: "kling",
|
|
68
|
+
label: "Kling AI",
|
|
69
|
+
auth: { type: "bearer-env", env: "KLING_API_TOKEN" },
|
|
70
|
+
available: () => Boolean(process.env.KLING_API_TOKEN),
|
|
71
|
+
models: KLING_MODELS,
|
|
72
|
+
validate: validateKlingSpec,
|
|
73
|
+
estimateCost: noKnownUsdEstimate,
|
|
74
|
+
submit: submitKling,
|
|
75
|
+
poll: pollKling,
|
|
76
|
+
});
|