diff --git a/.gitignore b/.gitignore index 0673d33..d431218 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ __pycache__/ .env runs/ .DS_Store +site/ diff --git a/README.md b/README.md index 7ae0d7a..2b59739 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,14 @@ your own ComfyUI. | **Disintegrate** | **Colossus** | **Zero Gravity** | **Frozen Rain** (beta) | | ![](web/thumbs/disintegrate.jpg) | ![](web/thumbs/colossus.jpg) | ![](web/thumbs/zero-gravity.jpg) | ![](web/thumbs/frozen-rain.jpg) | +## Try it + +* **In your browser, no key:** [vfx.nomastudio.ai](https://vfx.nomastudio.ai) plays every effect. + Pick one and open it as a Comfy Cloud app: sign in with your Comfy account, add a photo and run + it. The run bills your own Comfy account. +* **On your machine, everything:** the full app adds video mode, a second photo for the likeness, 9:16 + and HQ. It runs with your own Comfy API key, see [Run it](#run-it). + ## Two ways in **From a photo.** Upload one or two photos of a person. Each preset builds its own film scene @@ -29,7 +37,8 @@ cut, then the effect, joined on the same frame so the cut is invisible. Clips fr can also run as a Comfy Cloud App Mode app: one graph that takes your photo, builds the scene and renders the effect. You open it with your own Comfy account and run it with your own credits; nobody hands an API key to anyone. -`tools/build_app_graph.py` builds those graphs from the presets. +`tools/build_app_graph.py` builds those graphs from the presets, and each preset keeps the share link +of its app in `comfy_app`. ## How it works @@ -56,8 +65,8 @@ first frame --> Qwen Image Edit 2511 (or a mirror) --> last frame --> LTX-2.5 fi repeats the uploads and downloads that time out, and splices videos with ffmpeg. * `web/` is the interface: plain HTML, CSS and JavaScript, no build step. It shows the prompt that was sent, keeps your results across reloads and compares a spliced clip with your original. -* `tools/build_app_graph.py` builds the App Mode graphs and `tools/batch_presets.py` renders test - versions of the presets; `tools/dev/` keeps the A/B scripts behind the choices in +* `tools/build_app_graph.py` builds the App Mode graphs, `tools/build_site.py` builds the static + catalog site and `tools/batch_presets.py` renders test versions of the presets; `tools/dev/` keeps the A/B scripts behind the choices in [workflows/README.md](workflows/README.md). ## Run it @@ -83,7 +92,7 @@ ffmpeg ships inside the `imageio-ffmpeg` wheel, so there is nothing else to inst ``` The tests run offline in a few seconds. They check every preset and graph, the App Mode builder, -the upload limits, the ffmpeg steps (phone clips shot vertical included), the HTTP API with its +the static site build, the upload limits, the ffmpeg steps (phone clips shot vertical included), the HTTP API with its hosted mode, access code and daily cap, the job queue, and that every model the graphs load is listed in `THIRD_PARTY_NOTICES.md`. They never reach Comfy Cloud (the test package blanks `COMFY_API_KEY` before the app loads), so they cost nothing. GitHub Actions runs them on Linux and @@ -235,6 +244,22 @@ sends a strict Content Security Policy and serves the fonts itself, so the page other sites. If you host it for others, the LTX license asks you to put its Section 4 and the Acceptable Use Policy into your own terms (see [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md)). +### The catalog site + +A public page that spends nothing does not need the server at all. `tools/build_site.py` writes +`site/`: the same interface in static mode, where every card plays its preview and opens the +preset's Comfy Cloud app (`comfy_app`), so each visitor runs it with their own Comfy account. The +studio and the key settings are left out, `presets.json` replaces the API, and `vercel.json` sends +the same security headers as the server. + +```bash +.venv/Scripts/python tools/build_site.py --demo path/to/demo.mp4 # --demo is optional +cd site && vercel deploy # or any static host +``` + +On Vercel, the first deployment of a project goes to production and takes the project's domains, +even without `--prod`. + ## Consent Only use photos and videos of people who agreed to it. The app asks you to confirm that before diff --git a/presets/walk-of-fire.json b/presets/walk-of-fire.json index d49d3c4..3e9701b 100644 --- a/presets/walk-of-fire.json +++ b/presets/walk-of-fire.json @@ -19,5 +19,5 @@ "scene_prompt": "Create a new cinematic film still in {aspect_words} format. {references} The {subject} is the only person in the new still, with exactly {their} face, eyes, nose, jaw, hairline, hairstyle, hair colour{facial_hair}, skin tone and build{identity_notes}. The clothing, the place and the light come only from this description. The {subject} walks straight toward the camera down the middle of an empty industrial street at night, mid-stride, wearing {wardrobe}, hands loose at the sides. Red-brick warehouses line both sides, sodium streetlights cast pools of orange light, steam rises from a grate in the distance, and the wet asphalt holds long glossy reflections. Low-angle wide shot from knee height, symmetrical one-point perspective with the street vanishing behind {them}, the full body filling the middle third of the frame height. Cinematic night photography, anamorphic look, deep shadows with warm sodium highlights and a cool blue moonlight rim on the shoulders. Correct human anatomy, natural skin texture, sharp focus on the {subject}, plain unmarked brick walls throughout.", "effect_prompt": "Use the provided start image as the first frame. The {subject} is ALREADY walking toward the camera in a calm, heavy, unhurried \"funeral march\" stride, weight landing on each heel, shoulders level, arms swinging loose, and keeps walking at exactly the same pace for the whole shot. On the second step, fire erupts from {their} shoulders and spreads over the whole body in a single roaring burst, and the {subject} walks on fully engulfed in tall orange flames, the fire streaming backward like a cape, while {their} face stays clear, calm and unharmed inside the blaze. Every footstep leaves a burning footprint on the wet asphalt, and the flames throw dancing orange light across the brick walls and long fire reflections across the puddles. {Their} eyes stay locked on a point just past the camera, one slow deliberate blink when the flames reach the collar, and the stride never breaks. The camera holds a low angle and slowly dollies backward at walking pace, keeping the {subject} centered and the same size in frame. Sound: a deep whoomph as the fire ignites, then a steady roaring blaze, heavy footsteps on wet asphalt and a low distant city hum. Photorealistic, cinematic night visual effects, fire with real weight and turbulence, sharp detail, stable picture.", "generic_prompt": "Use the provided start image as the first frame. The {subject} in the frame carries on with exactly the same action and pace. Within the first second, fire erupts from {their} shoulders and spreads over {their} whole body in a single roaring burst, tall orange flames streaming upward and backward with real weight and turbulence, while {their} face stays clear, calm and unharmed inside the blaze. Embers drift upward, dark smoke rises, and the firelight throws flickering orange light across everything around {them}. The camera keeps the same framing and the same movement it already has. Sound: a deep whoomph as the fire ignites, then a steady roaring blaze and crackling embers. Photorealistic, cinematic visual effects, sharp detail, stable picture.", - "comfy_app": "" + "comfy_app": "https://cloud.comfy.org/?share=21dcb7ff424d" } diff --git a/tests/test_site.py b/tests/test_site.py new file mode 100644 index 0000000..5d4c7d9 --- /dev/null +++ b/tests/test_site.py @@ -0,0 +1,65 @@ +"""The static catalog site builds from web/ and the presets, with no server behind it.""" + +import json +import sys +import tempfile +import unittest +from pathlib import Path + +from vfx4all.config import ROOT +from vfx4all.presets import load_presets + +sys.path.insert(0, str(ROOT / "tools")) +import build_site # noqa: E402 + + +class SiteTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.tmp = tempfile.TemporaryDirectory() + cls.out = Path(cls.tmp.name) / "site" + demo = Path(cls.tmp.name) / "demo.mp4" + demo.write_bytes(b"not really a video") + cls.report = build_site.build(cls.out, demo) + cls.html = (cls.out / "index.html").read_text(encoding="utf-8") + + @classmethod + def tearDownClass(cls): + cls.tmp.cleanup() + + def test_page_runs_in_static_mode(self): + self.assertIn(build_site.STATIC_MODE, self.html) + self.assertNotIn(build_site.APP_MODE, self.html) + self.assertNotIn("{{v}}", self.html) + self.assertTrue((self.out / "static" / "app.js").exists()) + + def test_every_preset_is_listed_with_its_app_mode_status(self): + presets = json.loads((self.out / "static" / "presets.json").read_text(encoding="utf-8")) + self.assertEqual(len(presets), len(load_presets())) + apps = {p.stem for p in (ROOT / "workflows" / "apps").glob("*.json")} + for preset in presets: + self.assertEqual(preset["app_mode"], preset["id"] in apps, preset["id"]) + self.assertTrue((self.out / preset["thumb"].lstrip("/")).exists(), preset["thumb"]) + self.assertTrue((self.out / preset["preview"].lstrip("/")).exists(), preset["preview"]) + self.assertEqual(self.report["app_mode"], len(apps)) + + def test_app_links_are_public_share_links(self): + # a # record link only opens for members of the workspace + for preset in load_presets(): + link = preset.get("comfy_app") or "" + if link: + self.assertTrue(link.startswith("https://cloud.comfy.org/?share="), preset["id"]) + + def test_demo_replaces_the_marker(self): + self.assertIn(build_site.DEMO_VIDEO, self.html) + self.assertTrue((self.out / "static" / "media" / "demo.mp4").exists()) + + def test_headers_match_the_server(self): + from vfx4all.server import CSP + vercel = json.loads((self.out / "vercel.json").read_text(encoding="utf-8")) + sent = {h["key"]: h["value"] for h in vercel["headers"][0]["headers"]} + self.assertEqual(sent["Content-Security-Policy"], CSP) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/build_site.py b/tools/build_site.py new file mode 100644 index 0000000..d7d103e --- /dev/null +++ b/tools/build_site.py @@ -0,0 +1,99 @@ +"""Build the static catalog site: the web interface without the server. Every effect shows its preview +and links to its Comfy Cloud App Mode app (the preset's `comfy_app`), so visitors run it with their own +Comfy account. The output goes to Vercel or any static host. + + python tools/build_site.py # writes site/ + python tools/build_site.py --demo path/to/demo.mp4 # with the demo video +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import shutil +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +from vfx4all import __version__ # noqa: E402 +from vfx4all.config import WORKFLOWS_DIR # noqa: E402 +from vfx4all.presets import load_presets, public_view # noqa: E402 + +WEB = ROOT / "web" +APP_MODE = '' +STATIC_MODE = '' +DEMO_MARKER = "" +DEMO_VIDEO = '' + +# The same headers the server sends (vfx4all/server.py), so the site runs under the same rules. +CSP = ("default-src 'self'; script-src 'self'; style-src 'self'; font-src 'self'; img-src 'self' data: blob:; " + "media-src 'self' blob:; connect-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; " + "frame-ancestors 'none'") +VERCEL = { + "cleanUrls": True, + "headers": [ + {"source": "/(.*)", "headers": [ + {"key": "Content-Security-Policy", "value": CSP}, + {"key": "X-Content-Type-Options", "value": "nosniff"}, + {"key": "Referrer-Policy", "value": "strict-origin-when-cross-origin"}, + {"key": "Permissions-Policy", "value": "camera=(), microphone=(), geolocation=()"}, + ]}, + {"source": "/static/(.*)", "headers": [{"key": "Cache-Control", "value": "public, max-age=3600"}]}, + ], +} + + +def asset_version() -> str: + """Same cache-busting tag as the server: the version plus the bytes of app.js and app.css.""" + digest = hashlib.sha256(__version__.encode()) + for name in ("app.js", "app.css"): + digest.update((WEB / name).read_bytes()) + return digest.hexdigest()[:10] + + +def site_presets() -> list[dict]: + """The cards, plus whether the preset can run as a single App Mode graph at all.""" + out = [] + for preset in load_presets(): + view = public_view(preset) + view["app_mode"] = (WORKFLOWS_DIR / "apps" / f"{preset['id']}.json").exists() + out.append(view) + return out + + +def build(out: Path, demo: Path | None = None) -> dict: + if out.exists(): + shutil.rmtree(out) + shutil.copytree(WEB, out / "static", ignore=shutil.ignore_patterns("index.html")) + + html = (WEB / "index.html").read_text(encoding="utf-8") + if APP_MODE not in html: + raise RuntimeError("web/index.html lost its vfx4all-mode meta tag") + html = html.replace(APP_MODE, STATIC_MODE).replace("{{v}}", asset_version()) + if demo: + shutil.copyfile(demo, out / "static" / "media" / "demo.mp4") + html = html.replace(DEMO_MARKER, DEMO_VIDEO) + (out / "index.html").write_text(html, encoding="utf-8") + + presets = site_presets() + (out / "static" / "presets.json").write_text(json.dumps(presets, ensure_ascii=False), encoding="utf-8") + (out / "vercel.json").write_text(json.dumps(VERCEL, indent=2), encoding="utf-8") + return {"presets": len(presets), "apps": sum(bool(p["comfy_app"]) for p in presets), + "app_mode": sum(p["app_mode"] for p in presets), "demo": bool(demo)} + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("--out", type=Path, default=ROOT / "site") + parser.add_argument("--demo", type=Path, help="demo video, copied to static/media/demo.mp4") + args = parser.parse_args() + report = build(args.out, args.demo) + print(f"{args.out}: {report['presets']} effects, {report['apps']} with a Comfy Cloud app link " + f"({report['app_mode']} can run in App Mode), demo {'yes' if report['demo'] else 'no'}") + + +if __name__ == "__main__": + main() diff --git a/web/app.css b/web/app.css index 64566d6..516017d 100644 --- a/web/app.css +++ b/web/app.css @@ -181,7 +181,13 @@ h1 { margin: 0 0 18px; font-size: clamp(38px, 6.4vw, 78px); font-weight: 900; li /* ------------------------------------------------ sections */ section { position: relative; } -.effects, .studio, .how { padding: 70px clamp(16px, 5vw, 64px) 30px; max-width: 1480px; margin: 0 auto; } +.effects, .studio, .how, .try, .demo { padding: 70px clamp(16px, 5vw, 64px) 30px; max-width: 1480px; margin: 0 auto; } + +/* Static catalog site (tools/build_site.py): no server, so the studio and the key controls go away */ +html:has(meta[name="vfx4all-mode"][content="static"]) .app-only { display: none !important; } +.try-preview, .demo-video { display: block; width: 100%; aspect-ratio: 16 / 9; background: #000; object-fit: cover; } +.demo-video { object-fit: contain; } +.try-note { margin: 0; font-size: 13px; line-height: 1.5; } .sec-head { display: flex; align-items: end; justify-content: space-between; gap: 20px; margin-bottom: 22px; } .eyebrow { margin: 0 0 6px; font-size: 13px; font-weight: 800; letter-spacing: 0.2em; text-transform: uppercase; color: var(--text-2); display: flex; align-items: center; gap: 12px; } .eyebrow::after { content: ""; width: 64px; height: 2px; background: linear-gradient(to right, var(--magenta), transparent); } diff --git a/web/app.js b/web/app.js index e6399ed..444971b 100644 --- a/web/app.js +++ b/web/app.js @@ -4,6 +4,8 @@ const $ = (sel, el = document) => el.querySelector(sel); const $$ = (sel, el = document) => [...el.querySelectorAll(sel)]; const TOUCH = matchMedia("(hover: none)").matches; const REDUCED = matchMedia("(prefers-reduced-motion: reduce)").matches; +// "static" on the catalog site built by tools/build_site.py: no server behind it, effects run as Comfy Cloud apps. +const STATIC = document.querySelector('meta[name="vfx4all-mode"]')?.content === "static"; const CATEGORY_COLOR = { Elements: "#ff00ff", Transformations: "#33f5ff", Clones: "#cc66ff", Worlds: "#38b6ff", @@ -211,6 +213,7 @@ function hexGrid(canvas) { /* ---------- effects rail ---------- */ function presetAllowed(p) { + if (STATIC) return true; if (state.tab === "video") return p.modes.includes("video"); if (state.ownImage) return p.modes.includes("own_image"); return p.modes.includes("scene"); @@ -298,7 +301,7 @@ function card(item) { if (!allowed) return toast(whyNot(item)); const again = state.preset === item.id; selectPreset(item.id); - if (again || e.target.closest(".card-use")) $("#studio").scrollIntoView({ behavior: "smooth" }); + if (again || e.target.closest(".card-use")) $(STATIC ? "#try" : "#studio").scrollIntoView({ behavior: "smooth" }); }, onmouseenter: () => { if (!TOUCH) video.play().then(() => node.classList.add("playing")).catch(() => {}); }, onmouseleave: () => { if (!TOUCH) { video.pause(); node.classList.remove("playing"); } }, @@ -315,12 +318,17 @@ function card(item) { el("span", { class: "card-name" }, [icon(item.icon || "sparkles"), document.createTextNode(item.name)]), el("span", { class: "card-desc", text: item.description }), el("span", { class: "card-use" }, [icon(state.preset === item.id ? "check" : "wand", "ic sm"), - document.createTextNode(state.preset === item.id ? "Selected, go to studio" : "Use this effect")]), + document.createTextNode(useLabel(state.preset === item.id))]), ]), ])); return node; } +function useLabel(on) { + if (STATIC) return on ? "Selected, see how to run it" : "Try this effect"; + return on ? "Selected, go to studio" : "Use this effect"; +} + function selectPreset(id) { state.preset = id; const p = state.presets.find((x) => x.id === id); @@ -329,8 +337,9 @@ function selectPreset(id) { c.classList.toggle("selected", on); c.setAttribute("aria-selected", String(on)); const use = c.querySelector(".card-use"); - if (use) use.replaceChildren(icon(on ? "check" : "wand", "ic sm"), document.createTextNode(on ? "Selected, go to studio" : "Use this effect")); + if (use) use.replaceChildren(icon(on ? "check" : "wand", "ic sm"), document.createTextNode(useLabel(on))); }); + if (STATIC) paintTry(p); $("#chosenName").textContent = p ? p.name : "Pick one above"; const thumb = $("#chosenThumb"); if (p) thumb.src = p.thumb; else thumb.removeAttribute("src"); @@ -339,6 +348,26 @@ function selectPreset(id) { if (p?.comfy_app) link.href = p.comfy_app; } +// Static site: the chosen effect, its preview, and its Comfy Cloud app when it has one. +function paintTry(p) { + $("#tryName").textContent = p ? p.name : "Pick one above"; + const thumb = $("#tryThumb"); + if (p) thumb.src = p.thumb; else thumb.removeAttribute("src"); + const preview = $("#tryPreview"); + if (p && preview.getAttribute("src") !== p.preview) { + preview.src = p.preview; + if (!REDUCED) preview.play().catch(() => {}); + } + const run = $("#tryRun"), note = $("#tryNote"); + run.classList.toggle("hidden", !p?.comfy_app); + if (p?.comfy_app) run.href = p.comfy_app; + const why = !p || p.comfy_app ? "" : p.app_mode + ? "Its Comfy Cloud app is on its way." + : `${p.name} needs more than one graph, so it runs only in the full app.`; + note.textContent = why; + note.classList.toggle("hidden", !why); +} + function updateRailBar() { const rail = $("#rail"); const total = rail.scrollWidth || 1; @@ -866,8 +895,14 @@ async function init() { }); paintLimits(); + if (STATIC) { + // the demo section only shows when the site was built with a demo video in it + $$(".static-only").forEach((n) => { if (n.id !== "demo" || $("video", n)) n.hidden = false; }); + $("#heroCta").href = "#try"; + } try { - const [presets, upcoming] = await Promise.all([api("/api/presets"), fetch("/static/upcoming.json").then((r) => r.json()).catch(() => [])]); + const presetList = STATIC ? fetch("/static/presets.json").then((r) => r.json()) : api("/api/presets"); + const [presets, upcoming] = await Promise.all([presetList, fetch("/static/upcoming.json").then((r) => r.json()).catch(() => [])]); state.presets = presets; state.upcoming = upcoming; const total = presets.length + upcoming.length; @@ -879,6 +914,7 @@ async function init() { renderRail(); if (presets[0]) selectPreset(presets[0].id); } catch (err) { toast(`Could not load effects: ${err.message}`, true); } + if (STATIC) return; await refreshKeyStatus(); restore(); } diff --git a/web/index.html b/web/index.html index e587992..0d36749 100644 --- a/web/index.html +++ b/web/index.html @@ -6,6 +6,8 @@ VFX 4 All + + @@ -75,10 +77,12 @@ - + + + + + + -
+

Studio