joeygambino's picture
Rename cartridge format: joypack -> RiftCast (.riftcast); ship WREN + HANK demo cartridges
e39b101 verified
Raw
History Blame
10.6 kB
"""RiftCast - portable video-character cartridges (.riftcast), spec v1.0.
Library + CLI. See RIFTCAST_SPEC.md for the format. A .joypack is a plain zip;
this module packs one from a source folder and safely unpacks/materializes one
into the JoyEcho host conventions (joyecho_voices/<tag>/, refs root, loras).
CLI:
python riftcast.py pack <source_dir> <out.joypack>
python riftcast.py inspect <file.joypack>
Security: archives are DATA. No execution, no path traversal (rejected), no
pickle weight formats (safetensors only for loras/).
"""
from __future__ import annotations
import hashlib
import json
import os
import shutil
import sys
import zipfile
SPEC_VERSION = "1.0"
REQUIRED_KEYS = ("riftcast", "name", "speaker_tag", "voice", "refs", "dna")
VOICE_EXTS = (".mp4", ".wav")
REF_EXTS = (".png", ".jpg", ".jpeg", ".webp")
class JoypackError(ValueError):
pass
def _sha256(path):
h = hashlib.sha256()
with open(path, "rb") as f:
for chunk in iter(lambda: f.read(1 << 20), b""):
h.update(chunk)
return h.hexdigest()
def _safe_name(entry_name):
"""Reject traversal and absolute paths; return normalized relative path."""
n = entry_name.replace("\\", "/")
if n.startswith("/") or ".." in n.split("/") or (len(n) > 1 and n[1] == ":"):
raise JoypackError(f"unsafe path in archive: {entry_name!r}")
return n
def pack(source_dir, out_path):
"""Build a .joypack from a folder laid out per the spec.
If the folder has no manifest.json, a minimal one is synthesized from the
layout (name = folder name, speaker_tag = lowercased name).
"""
source_dir = os.path.abspath(source_dir)
man_path = os.path.join(source_dir, "manifest.json")
if os.path.isfile(man_path):
manifest = json.load(open(man_path, encoding="utf-8"))
else:
name = os.path.basename(source_dir.rstrip("\\/"))
manifest = {"riftcast": SPEC_VERSION, "name": name,
"speaker_tag": name.lower()}
# discover required components if unlisted
def _find(sub, exts):
d = os.path.join(source_dir, sub)
if not os.path.isdir(d):
return []
return sorted(f"{sub}/{f}" for f in os.listdir(d)
if f.lower().endswith(exts))
if "voice" not in manifest or not manifest["voice"].get("file"):
vs = _find("voice", VOICE_EXTS)
if not vs:
raise JoypackError("no voice anchor found (voice/anchor.mp4|wav)")
manifest.setdefault("voice", {})["file"] = vs[0]
if not manifest.get("refs"):
manifest["refs"] = _find("refs", REF_EXTS)
if not manifest["refs"]:
raise JoypackError("no reference stills found (refs/*.png)")
if not manifest.get("dna"):
p = "prompts/dna.txt"
if not os.path.isfile(os.path.join(source_dir, p)):
raise JoypackError("no DNA text found (prompts/dna.txt)")
manifest["dna"] = p
manifest.setdefault("render_law", {"video_fps": 24})
# collect every real file under the spec dirs + the manifest
files = []
for root, _, names in os.walk(source_dir):
for fn in names:
full = os.path.join(root, fn)
rel = os.path.relpath(full, source_dir).replace("\\", "/")
if rel == "manifest.json":
continue
if rel.startswith("loras/") and not fn.lower().endswith(".safetensors"):
raise JoypackError(f"loras/ may only contain .safetensors: {rel}")
files.append(rel)
manifest["sha256"] = {manifest["voice"]["file"]:
_sha256(os.path.join(source_dir, manifest["voice"]["file"]))}
for k in REQUIRED_KEYS:
if k not in manifest:
raise JoypackError(f"manifest missing required key: {k}")
with zipfile.ZipFile(out_path, "w", zipfile.ZIP_DEFLATED) as z:
z.writestr("manifest.json", json.dumps(manifest, indent=1))
for rel in files:
z.write(os.path.join(source_dir, rel), rel)
return out_path, manifest
def read_manifest(pack_path):
with zipfile.ZipFile(pack_path) as z:
try:
manifest = json.loads(z.read("manifest.json").decode("utf-8"))
except KeyError:
raise JoypackError("not a riftcast: manifest.json missing")
if "riftcast" not in manifest and "joypack" in manifest:
manifest["riftcast"] = manifest.pop("joypack") # pre-rename cartridge
for k in REQUIRED_KEYS:
if k not in manifest:
raise JoypackError(f"manifest missing required key: {k}")
return manifest
def materialize(pack_path, voices_dir, refs_dir, loras_dir, cache_dir):
"""Unpack + install a cartridge into host conventions.
Returns a dict: {name, speaker_tag, dna, accent_line, rooms, triggers,
ltx_loras, zimage_loras, installed_paths}
"""
manifest = read_manifest(pack_path)
tag = manifest["speaker_tag"]
name = manifest["name"]
out = {"name": name, "speaker_tag": tag, "installed_paths": []}
with zipfile.ZipFile(pack_path) as z:
names = {_safe_name(n) for n in z.namelist()}
def _extract(member, dst_dir, dst_name=None):
member = _safe_name(member)
if member not in names:
raise JoypackError(f"manifest references missing file: {member}")
os.makedirs(dst_dir, exist_ok=True)
dst = os.path.join(dst_dir, dst_name or os.path.basename(member))
with z.open(member) as src, open(dst, "wb") as f:
shutil.copyfileobj(src, f)
out["installed_paths"].append(dst)
return dst
# voice anchor -> joyecho_voices/<tag>/
vfile = manifest["voice"]["file"]
want = manifest.get("sha256", {}).get(vfile)
vdst = _extract(vfile, os.path.join(voices_dir, tag))
if want and _sha256(vdst) != want:
os.remove(vdst)
raise JoypackError("voice anchor failed sha256 verification")
# refs -> <refs_root>/<NAME>/
for r in manifest["refs"]:
_extract(r, os.path.join(refs_dir, name))
# loras -> models/loras/joypack/<name>/
out["ltx_loras"], out["zimage_loras"] = [], []
loras = manifest.get("loras", {})
for fam, key in (("ltx", "ltx_loras"), ("zimage", "zimage_loras")):
for entry in loras.get(fam, []) or []:
dst = _extract(entry["file"], os.path.join(loras_dir, name))
out[key].append({"path": dst,
"strength": float(entry.get("strength", 1.0)),
"trigger": entry.get("trigger", "")})
# texts
out["dna"] = z.read(_safe_name(manifest["dna"])).decode("utf-8").strip()
out["accent_line"] = manifest.get("voice", {}).get("accent_line", "")
rooms = manifest.get("environment", {}).get("rooms")
out["rooms"] = (z.read(_safe_name(rooms)).decode("utf-8").strip()
if rooms and _safe_name(rooms) in names else "")
env_stills = manifest.get("environment", {}).get("stills", []) or []
for s in env_stills:
_extract(s, os.path.join(cache_dir, name, "environment"))
out["render_law"] = manifest.get("render_law", {})
out["triggers"] = [e.get("trigger", "") for e in
(manifest.get("loras", {}).get("zimage", []) or []) if e.get("trigger")]
return out
def cut(master_path, name, dna_path, out_path=None, speech_at=1.0,
speech_dur=5.0, ref_times=None, ffmpeg="ffmpeg"):
"""Cut a cartridge directly from a finished render.
master_path: an mp4 where the character speaks alone with face visible.
dna_path: the canonical DNA text file (authorial - cannot be automated).
Cuts a voice anchor at speech_at..+speech_dur and reference stills at
ref_times (default: three spread frames), then packs.
"""
import subprocess
import tempfile
tag = name.lower()
ref_times = ref_times or [speech_at + 1.0, speech_at + 3.0, speech_at + 4.5]
with tempfile.TemporaryDirectory() as td:
for sub in ("voice", "refs", "prompts"):
os.makedirs(os.path.join(td, sub))
r = subprocess.run([ffmpeg, "-y", "-v", "error", "-ss", str(speech_at),
"-t", str(speech_dur), "-i", master_path,
"-c:v", "libx264", "-crf", "18",
"-c:a", "aac", "-b:a", "192k",
os.path.join(td, "voice", "anchor.mp4")],
capture_output=True, text=True)
if r.returncode != 0:
raise JoypackError(f"anchor cut failed: {r.stderr[:200]}")
for i, t in enumerate(ref_times, 1):
subprocess.run([ffmpeg, "-y", "-v", "error", "-ss", str(t),
"-i", master_path, "-frames:v", "1",
os.path.join(td, "refs", f"{tag}_{i:02d}.png")],
capture_output=True, text=True)
shutil.copyfile(dna_path, os.path.join(td, "prompts", "dna.txt"))
manifest = {"riftcast": SPEC_VERSION, "name": name, "speaker_tag": tag,
"display_name": name.title(),
"voice": {"file": "voice/anchor.mp4"},
"render_law": {"video_fps": 24}}
json.dump(manifest, open(os.path.join(td, "manifest.json"), "w"), indent=1)
out_path = out_path or f"{name}.riftcast"
return pack(td, out_path)
def main():
if len(sys.argv) < 3:
print(__doc__)
return 1
cmd = sys.argv[1]
if cmd == "pack":
out, man = pack(sys.argv[2], sys.argv[3])
print(f"packed {man['name']} -> {out}")
print(f" speaker_tag={man['speaker_tag']} refs={len(man['refs'])} "
f"voice={man['voice']['file']}")
elif cmd == "cut":
# joypack.py cut <master.mp4> <NAME> <dna.txt> [out.joypack] [speech_at] [speech_dur]
a = sys.argv
out, man = cut(a[2], a[3], a[4],
out_path=a[5] if len(a) > 5 else None,
speech_at=float(a[6]) if len(a) > 6 else 1.0,
speech_dur=float(a[7]) if len(a) > 7 else 5.0,
ffmpeg=os.environ.get("RIFTCAST_FFMPEG", "ffmpeg"))
print(f"cut {man['name']} -> {out}")
elif cmd == "inspect":
man = read_manifest(sys.argv[2])
print(json.dumps(man, indent=1))
else:
print(__doc__)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())