Skip to content

Let the signed-out sample reach a video result - #376

Merged
mikkel merged 3 commits into
mainfrom
feat/demo-covers-video
Jul 30, 2026
Merged

Let the signed-out sample reach a video result#376
mikkel merged 3 commits into
mainfrom
feat/demo-covers-video

Conversation

@mikkel

@mikkel mikkel commented Jul 29, 2026

Copy link
Copy Markdown
Member

What breaks today

A signed-out visitor can Run the starter graph and see real canned results. The demo covers text
and an image. It stops there. genVideo, genAudio and transcribe are all wired to demoDeny.
Video is the most impressive thing nanoodle does, so the first-run demo ends one step before the
payoff.

The real path is worse than the deny message suggests. demoMode() returns null as soon as the
topology changes. A visitor who ADDS an Image→Video node does not get demoDeny. That visitor
loses demo mode: runGroup opens the sign-in wall and returns before any node runs. The reward for
exploration is an unchanged canvas and a login prompt.

This was confirmed in a real browser before the change.

What this changes

One covered extension: the starter, plus an Image→Video node fed from the image output, with
nothing after it. That shape serves demo-sample.mp4. Each other shape gets the sign-in wall.

  • demoVideoNode() matches the shape structurally, not with a second baked signature. The id of
    the added node depends on what the visitor added and removed first. An id-for-id comparison would
    miss the path that this change covers.
  • DEMO_CTX.genVideo serves the clip only when opts.runKey is the id of that node. A Text→Video
    node or a Video-edit node still gets the wall, also in a covered canvas.
  • The clip is read into a data URL, in the same way as demo-sample.jpg. Thus ⬇ save and ↗ open
    behave as they do for a real result.
  • demoMode() gets a video mode. The pill text for that mode says that the clip is a canned
    example, and that the clip ignores the motion prompt of the visitor. That warning is always true
    here: the motion prompt on an added node is always the prompt of the visitor.
  • The result keeps the badge of markDemoResult: "✨ sample result — sign in to run it for real".
  • A missing response, or a response that is not a video, gives the sign-in wall. It never gives a
    broken player. The content-type test is necessary: a static host answers an absent path with the
    SPA shell at status 200.
  • The deny text no longer says that samples cover "text and images only". It now also goes through
    t(). Its 5 translations were already in the maps, but no code applied them.

The price chip stays visible (review finding, now fixed)

Review found that refreshRunEstimate() hid the #runest price chip whenever inDemoRun() was
true. Because this change makes inDemoRun() true for the video extension, the price disclosure
disappeared on the most expensive node that a visitor can add. That is now fixed instead of
documented.

The chip stays visible in each demo mode. It is relabelled to run for real, and its tooltip says
"The sample results are canned, so this run isn’t billed. This is what running the graph on your own
key costs." Both strings are translated into all 5 languages.

The old behaviour looked safe, because nothing is billed in a demo run and therefore nothing is
concealed. It was not safe. A price that disappears when it becomes large reads as a dark pattern,
whatever the cause. The tooltip removes the other risk: the visitor must not think that this click
is billed.

Measured in the browser, signed out, with prices from the real catalog:

canvas chip
the starter ▶ ~$0.04 to run for real
plus the Image→Video node ▶ ~$1.08 to run for real

The video node also shows its own price on the node, ~$1.04/clip. updateNodePrice() is not
gated on demo mode, so that label was never hidden.

The asset is real

scripts/gen-demo-clip.mjs renders the clip from the SHIPPED demo-sample.jpg with a real
image-to-video model. It is hand-run, it is a dry run by default, it refuses to write a file larger
than 2 MB, and it holds the provenance in its header.

model bytedance-seedance-v1-pro-fast
settings 720p, 5 s, 16:9
cost 0.12 USD, metered by NanoGPT on 2026-07-28, run once (runId vid_ms5jr1es67cfaa050a06)
committed file 228,408 bytes (223 KB), 640x360, 24 fps, 121 frames, 5.041667 s, H.264

The file holds one stream. There is no audio track. moov is at byte 36 and mdat is at byte
2306, therefore the file is faststart. Each figure in the table was measured on the committed file,
except the cost: only the header and the NanoGPT meter record that.

Frame 0 of the clip is the shipped still. The camera moves in, the steam moves, and the rain runs
down the glass. The clip is not a pan over the still.

Engine scope

index.html only. play.html holds no demo-mode code. DEMO_CTX replaces CTX at the runGroup
boundary of the editor, and it never changes a request body. Therefore the dual-engine payload rule
does not apply. check-js-parity and check-run-compat both pass.

sw.js gets a comment only. The clip is deliberately not precached: 223 KB at each install, for a
screen that most visitors never open. The fetch handler caches it at first use.

How this was verified

The branch was merged with origin/main at fd9b459 (after #372, #373 and #374). The merge was
clean: #374 touches the same two files, but different regions. Each result below is from the merged
tree.

Guards — 53 of 53 pass. Each scripts/check-*.mjs passes, and this includes the two guards that
#372 and #373 added (check-llms-full.mjs and check-launch-facts.mjs). The extra CI step
node scripts/gen-llms-full.mjs --check also passes. check-js-parity really ran; it did not skip.
Both sibling repositories were exported fresh at their origin/main, as CI does.

Browser — 36 of 36 assertions pass. This PR now commits its verification, at
proof/demo-video/. Run it with node proof/demo-video/verify-demo-video.mjs. It serves the
worktree, starts headless Microsoft Edge, and drives the real index.html over CDP. The whole
nano-gpt.com origin is blocked, and each request that the page tries to make is recorded. Zero
spend is therefore proven, not assumed. The check covers:

  • The starter is mode exact. The starter plus the added node is mode video, and
    demoVideoNode() returns that node.
  • All 4 nodes reach done, with nano-gpt.com unreachable.
  • The output of the video node is a data:video/mp4 URL of 304,566 characters. The <video> src
    equals it. readyState is 4, duration is 5.041667 s, and the size is 640x360.
  • The "✨ sample result" badge is present, and so are the ⬇ save and ↗ open buttons. All 3 output
    nodes carry the badge.
  • The pill says that the clip is a canned example and that it ignores the prompt of the visitor.
  • The price chip stays visible and reads to run for real, for the starter and after the video node
    is added. The figure increases.
  • sw.js does not precache the clip. After the run, the fetch handler has put it in the
    nanoodle-v6 cache, and the cached blob is 228,408 bytes of video/mp4 — the shipped file.
  • Near-miss shapes still get the wall: a second video node, and a node after the clip. The canvas
    returns to mode video when the shape is correct again.
  • No billable endpoint was called. The only nano-gpt.com URLs that the page tried were the 4 free
    model-list reads of the boot revalidate, and the block stopped all of them.

proof/ is gitignored, so these 3 files were added with git add -f. proof/prompt-caps/ from
#369 is the precedent.

catalog-fixture.json holds 3 normalized catalog entries. The check puts them in localStorage,
because the price chip needs prices and the network is off. --capture records the fixture again
from the free public model lists. That mode never runs a graph and it spends nothing.

Two figures in an earlier version of this description were wrong and are corrected above: the guard
count was 51 before #372 and #373 added theirs, and the description claimed a 22-assertion
node:vm harness that was never committed. The committed browser check replaces it.

A signed-out visitor could Run the starter graph and see real canned results,
but only text and an image. genVideo was wired to demoDeny. Video is the most
impressive thing nanoodle does, so the first-run demo stopped one step before
the payoff.

It was worse than the deny message suggested. demoMode() returns null the
moment the topology changes, so a visitor who ADDED a video node lost demo mode
altogether: runGroup opened the sign-in wall and returned before any node ran.
The reward for exploring was an empty canvas and a login prompt.

This adds ONE covered extension: the starter plus an Image→Video node fed from
the image output, with nothing downstream of it. That shape now serves
demo-sample.mp4. Everything else still gets the sign-in wall.

- demoVideoNode() matches the shape structurally, not by a baked signature. The
  appended node's id depends on what the visitor added and removed first.
- DEMO_CTX.genVideo serves the clip only when opts.runKey is that node's id, so
  a Text→Video or Video-edit node still lands on the wall.
- The clip is read into a data URL like demo-sample.jpg, so save and open
  behave like a real result. It carries the same sample badge, and the pill says
  outright that the clip ignores the visitor's motion prompt.
- A missing or non-video response degrades to the wall, never a broken player.
  The content-type test catches a static host answering 200 with the SPA shell.
- The deny copy no longer claims samples cover "text and images only", and it
  now runs through t() — the 5 translations of it were dead before.

The asset is real. scripts/gen-demo-clip.mjs renders it from the shipped
demo-sample.jpg with bytedance-seedance-v1-pro-fast (720p, 5s, 0.12 USD), then
encodes it to 640x360 H.264, no audio, 223 KB. Frame 0 of the clip is the
still. The script is hand-run, refuses to write over 2 MB, and records the
provenance in its header.

index.html only. play.html carries no demo-mode code: DEMO_CTX substitutes for
CTX at the editor's runGroup boundary and never changes a request body, so the
dual-engine payload rule does not reach it. check-js-parity and check-run-compat
both pass unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Preview URL Updated (UTC)
✅ Deployment successful!
View logs
nanoodle e88ee5e Commit Preview URL

Branch Preview URL
Jul 30 2026, 12:46 AM

mikkel and others added 2 commits July 29, 2026 18:31
* origin/main:
  Link the built-with-nanoodle showcase from the product (#374)
  Drop the retired draw node from the agent-facing docs (#372)
  growth: refresh the 4 slipped launches, remove the stale r/mcp gate, add a launch checklist (#373)
…deo proof

The chip used to hide whenever a signed-out visitor could free-sample Run. That looked harmless
— nothing is billed, so nothing is concealed — and it was not. The one thing a visitor can add to
the starter and still keep the sample is an Image→Video node, the priciest node on the canvas, so
the cost figure vanished at exactly the moment it became worth reading. A price that disappears
when it gets big reads as a dark pattern however it got there.

The chip now stays up in every demo mode, relabelled "to run for real", with a title that says
this run is not billed and that the figure prices running the graph on your own key. Measured in
the browser: the starter quotes ~$0.04, and appending the Image→Video node takes it to ~$1.08.

Adds proof/demo-video/ — the verification this branch was missing. It drives the real index.html
in headless Edge over CDP with the whole nano-gpt.com origin blocked, and records every request
the page attempts, so zero spend is proven and not assumed. 36 assertions, all passing: the
covered shape samples, the near-miss shapes still hit the sign-in wall, the clip decodes to
640x360 / 5.04 s, the ✨ sample result badge and the ⬇ save / ↗ open buttons are present, and
sw.js caches the clip on first use rather than precaching it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@mikkel
mikkel merged commit c53a90d into main Jul 30, 2026
3 checks passed
@mikkel
mikkel deleted the feat/demo-covers-video branch July 30, 2026 00:48
mikkel added a commit that referenced this pull request Jul 30, 2026
…e moved surfaces

#374 and #376 both changed index.html and play.html, so the two artifacts this guard pins had to be
re-derived rather than merged. Nothing here loosens a rule.

Baseline. The guard reported exactly one change and it is #376's: `async genVideo(model, prompt,
opts, imageDataUrl, onProgress){` gained a second copy in index.html at 9046, the DEMO_CTX override
that lets a signed-out sample reach a video result. The distinct count held at 893, nothing entered
the shared set and nothing left it, so the only ratchet movement is index.html occurrences 957 -> 958
(and multi-occurrence lines 74 -> 75). The refresh also recorded 2 new look-alike hashes, both from
the same commit: index.html:9022 `if(!r.ok) throw new Error(String(r.status));` and index.html:9025's
FileReader line. 796 -> 798 hashes over 219 lines. Every delta is attributable to #376; none is a
one-sided edit.

Sandbox matrix. Its ranges are absolute and #376 pushed index.html down by up to 111 lines and #374
pushed play.html down by 19, so all 25 ranges moved. Each was re-derived and checked byte for byte
against its pre-merge content. Left stale, 9 of the 16 cases return the wrong verdict — measured, not
assumed. All 16 now pass, row 8 still reporting the same 5 one-sided deletions and 1 occurrence drift
at their new positions. docs/twin-drift.md and scripts/twin-drift-worklist.mjs carry the same ranges,
so all 216 file:line references in them moved too; the work-list table is unchanged (893, and rows 1
to 6 still free 255 lines).

Hook. #372 landed touches_llmsfull and #373 landed touches_launchfacts on the same early-exit line
this PR adds touches_twindrift and touches_twindriftcases to. All 4 are unioned in, and each was
proved to fire by staging one file for it and watching which check ran; an unrelated file still
early-exits. The 2 new checks are renumbered 43 and 44, main's launch-facts and llms-full keeping 41
and 42.

Re-measured, and figures corrected where the merge moved them: timings (30 samples per case, load
average 3.19-5.68), the baseline byte sizes, the raw unblanked occurrence count, and the guard-suite
total, now 55 of 55 passing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant