Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ __pycache__/
.env
runs/
.DS_Store
site/
33 changes: 29 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion presets/walk-of-fire.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
65 changes: 65 additions & 0 deletions tests/test_site.py
Original file line number Diff line number Diff line change
@@ -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 #<uuid> 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()
99 changes: 99 additions & 0 deletions tools/build_site.py
Original file line number Diff line number Diff line change
@@ -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 = '<meta name="vfx4all-mode" content="app">'
STATIC_MODE = '<meta name="vfx4all-mode" content="static">'
DEMO_MARKER = "<!-- demo -->"
DEMO_VIDEO = '<video class="demo-video bevel reveal" src="/static/media/demo.mp4" controls playsinline preload="metadata"></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()
8 changes: 7 additions & 1 deletion web/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -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); }
Expand Down
Loading
Loading