The seven existing Ideogram nodes remain dual-route. The direct path keeps V4 generate/remix plus the provider's V3 edit surfaces; the FAL path keeps V4 generate and the V3 editing endpoints. FAL now publishes a V4 image-to-image endpoint, but it is not a drop-in replacement for the fixed remix node because it omits the node's V3 style-reference, style, and negative-prompt controls.
Decisions:
- Filter FAL requests through a per-node allowlist on a copied
GraphNode. Dual-route nodes persist defaults for both providers, so forwarding the whole param map could send directrendering_speed=DEFAULTandresolutionto FAL. - Preserve the source node for both endpoint injection and Character-bundle
seed expansion. The previous wrappers wrote
endpoint_idand sometimes a seed into persisted params during execution. - Reject invalid route enums, counts, strengths, upscale controls, resolutions, and character-reference counts before creating a provider client. Both current Character routes consume exactly one identity image; FAL otherwise ignores extras and the direct route rejects them.
- Surface the current FAL V4
accelerationcontrol and no-feeexpansion_model=None, the full current V4 resolution enum, and direct V4 copyright detection. Keep structured palette/style-code/json-prompt fields out of the fixed nodes;fal-universalremains the escape hatch for complex FAL objects. - Freeze one FAL JSON body per node and maintain separate FAL and direct gold exemplars because their auth, request shapes, execution patterns, and result persistence differ.
- Do not run a paid provider smoke in this milestone. The prior 2026-06-10 live results remain empirical evidence; this pass is schema, handler, fixture, and regression validation.
The clean npm audit reported five development-only findings: one low and four
high. npm audit --omit=dev remained at zero. A dry-run audit auto-fix would
have changed dozens of packages and selected Vite 8.1.5, which was only six
days old, so the remediation is deliberately narrower.
Decisions:
- Pin direct Vite to 8.0.16, the first non-vulnerable 8.0.x release, instead of allowing the caret range to select the too-new 8.1.5 release.
- Override only the vulnerable transitive packages:
@babel/core7.29.7,js-yaml4.3.0, andundici7.28.0. - Keep each
brace-expansionmajor compatible with its parent minimatch: 1.1.16 under minimatch 3.1.5 and 5.0.7 under minimatch 10.2.5. - Pin the existing Browserslist toolchain at 4.28.2 as an explicit development constraint and preserve its already-locked data packages. Re-resolving Babel otherwise selected five releases between 0 and 13.6 days old even though none was required for the security fixes.
- All selected versions were at least 14 days old at installation time. No production dependency or Remotion pin changed.
- Regenerate the lockfile with the explicit package policy, then validate from
a clean
npm ci; do not runnpm audit fix.
The repository previously had no configured GitHub checks. The first CI workflow mirrors the local merge gates in three independent jobs.
Decisions:
- Run on pull requests and pushes to
main; do not change branch protection. - Keep backend, frontend, and contract jobs independent so a failure names the affected surface and does not serialize unrelated work.
- Use Python 3.12 and Node.js 22, with dependency caches keyed by the existing requirements and lockfiles.
- Pin official GitHub Actions to immutable commits while documenting their release-major tags in comments.
- Do not upgrade pip or run an audit auto-fix in CI. The workflow installs only repository-pinned Python requirements and the checked-in npm lockfile.
- Treat the contract inventory as an executable inventory gate. Its 48 known exemplar gaps remain visible in logs rather than being reclassified as CI failures by this infrastructure-only change.
Four loose refs had Finder-style duplicate names ending in 2: local and
remote-tracking copies for both Cinema branches. Their alternate tips were
preserved by the existing archive/cinema-per-shot-pre-repair-2026-07-22 and
archive/cinema-variations-pre-repair-2026-07-22 tags before deleting only the
four malformed ref files. The valid Cinema refs remain at d4421a8 and
0a172c9; git fetch --prune, show-ref, and log --all work afterward.
The repository lives in an iCloud/FileProvider-backed folder. A default
git fsck --full blocks when it reaches one of 2,210 dataless loose-object
placeholders. A fresh GitHub mirror restored every remote-reachable object; an
isolated all-ref audit then proved 8,700 branch/tag objects complete and passed
git fsck --full --no-reflogs --no-cache --no-dangling. The remaining nine
unavailable blobs belong only to dangling or reflog-only trees, not any current
branch, tag, or index entry.
Decisions:
- Keep the original FileProvider object store untouched. Do not replace it or discard reflogs merely to make the default scanner quiet.
- Preserve every current ref in the verified 61 MB recovery bundle at
.git/recovery/object-store-2026-07-22/pre-repair-all-refs.bundle. - Treat branch/tag connectivity as green, while retaining the default-fsck FileProvider limitation as an explicit local-environment risk.
The repository documentation cleanup intentionally removes completed historical plans and specs. Review found that a small set of retained backlog and implementation notes still linked to those deleted paths.
Decisions:
- Preserve the historical context in the retained documents, but describe the deleted plans as recoverable from Git history instead of leaving dead links.
- Keep the Quiver placement alternatives as decision history while making clear that the retired master plan is no longer a live integration target.
- Do not restore any retired plan or spec solely to keep a historical link alive.
Google's current Gemini Omni documentation supports stateful video editing via
previous_interaction_id, but explicitly excludes video extension. The live
provider test confirmed that an extension-style follow-up reaches Google and
fails with a paid-request 400, while an in-place edit succeeds.
Decisions:
- Keep one backend capability rule as the source of truth.
validate_graphcatches direct Text Input plus edit-context graphs before execution starts; the Gemini Omni handler repeats the check after inputs resolve so composed or otherwise dynamic prompts cannot slip through to the provider. - Classify only explicit duration-extension language and only when an edit context exists (previous interaction, video input, or explicit Edit task). Ordinary edit prompts and fresh text-to-video prompts remain valid.
- Return structured validation details from both execution endpoints. Create and Canvas already share these endpoints, so both surfaces can show the exact capability guidance instead of overwriting it with a generic validation message.
- Preserve connected
previous_interaction_idprecedence over the manual fallback. The guardrail uses that same resolved value and does not alter the request shape for valid edits.
Validation:
- 1,116 backend tests and 392 frontend tests passed. Frontend lint, production build, and the 142-node contract gate also passed.
- The Create UI was browser-checked with Gemini Omni selected; the capability
note is visible alongside the expected Task, Aspect Ratio, and Delivery
controls. Canvas exact-error behavior is covered at the store layer, and both
/api/executeand/api/execute-nodereturn the identical structured error. - A provider client mock proves the runtime backstop raises before
httpx.AsyncClientis instantiated, so the unsupported request cannot incur a paid submit. - The checked-in
backend/.venvpoints at an old repository location and its pytest launcher is not runnable. Verification used the existing Miniconda Python environment. The managed session also denied Python localhost binds, so a live backend restart was not available for browser QA.
Independently re-verified the Google Gemini image and Omni contracts plus the FAL Nano Banana 2/Pro schemas before promoting the provider-runtime branch.
Decisions and corrections:
- Keep FAL endpoint slugs independent from Google's direct model lifecycle. The legacy FAL
gemini-3-pro-image-previewselector remains valid even though Google shut its direct preview model. - Scope direct Gemini image-size options by model: Flash Lite is 1K-only, Flash supports 512/1K/2K/4K, and Pro supports 1K/2K/4K.
- Scope FAL extreme aspect ratios to Nano Banana 2. Nano Banana Pro supports the common range through 21:9, not 1:4/4:1/1:8/8:1. Web search remains available on both current FAL models.
- Normalize hidden stale values in the execution adapters without mutating saved nodes. Direct Flash Lite falls back to 1K/common aspect ratios; FAL Pro falls back from 0.5K/extreme ratios and drops the Nano Banana 2-only thinking field. Legacy FAL selectors drop modern-only controls.
- Preserve the manual Gemini Omni previous-interaction parameter for pasted IDs, but add a real Text input so the advertised output-to-input chain works in the graph. A connected value takes precedence over the manual fallback.
- Add focused frontend visibility tests and a handler request-shape test to prevent contract drift.
Validation:
- Clean
npm ci, 1084 backend tests, 390 frontend tests, lint, production build, and the 142-node contract gate passed. - Capped live FAL Nano Banana 2 smoke returned one 0.5K image with web search disabled.
- Capped live Gemini Omni smoke exercised URI delivery end to end and saved a 3.008-second, 825417-byte video with an interaction ID.
npm audit --omit=devreports zero production vulnerabilities. The full audit reports five pre-existing development-only findings; this PR does not change the affected toolchain dependencies.- Existing non-blocking warnings remain: two backend test coroutine warnings and Vite's large-chunk/lottie dependency warnings.
- Preserved the inherited mixed worktree before editing. Created
codex/provider-contract-refreshdirectly frommainwith all existing modified, deleted, and untracked files still present. This keepsmainavailable as the clean upstream baseline without stashing or rewriting unattributed work. - Repackaged the checkpoint as a three-level draft PR stack without moving it.
codex/provider-runtime-safetystarts at the refreshedorigin/main;codex/provider-contracts-skillsadds only the portable contracts and repo-backed provider skills;codex/repository-docs-cleanupadds only historical-doc retirement, the local-engine research checkpoint, README updates, and implementation notes. The originalcodex/provider-contract-refreshbranch remains fixed at33f15ccas the recovery checkpoint. - Kept the generated model reference with the runtime level.
docs/MODEL_REFERENCE.mdis generated from the node registry changed by the runtime PR. Moving it to the next PR would make the runtime branch fail its own drift gate, so the generated artifact travels with its source while the portable contract corpus remains isolated in the second level. - Treat provider routing and provider schemas as separate responsibilities. Node-specific model selection belongs in
backend/execution/sync_runner.py;backend/handlers/fal_universal.pymust continue forwarding arbitrary endpoint schemas and therefore cannot reserve the genericmodelkey globally. - Live vendor documentation overrides the bundled provider skills. The checked-in Gemini and FAL skills are useful routing references but contain preview-era model IDs. Production IDs and response-delivery behavior will be pinned to the current Google and FAL primary documentation and covered by regression tests.
- Do not combine historical-doc deletion, provider skills, provider contracts, Gemini/FAL features, and cinema changes in one commit. The inherited worktree mixes these independent concerns. Each scope will be validated and committed separately so it can be reviewed or reverted independently.
- Use
contracts/fixtures/handlers/as the only provider-contract fixture source. The inheritedbackend/tests/fixtures/{fal,google,openai}files were untracked duplicate copies and no test referenced them. Keeping two writable trees already produced a stale Omni fixture, so the duplicates were removed and the contract README now names the portable root as the single golden source. - Pin a compatible model-viewer/Three pair instead of bypassing npm peers. The inherited lock combined
@google/[email protected](three ^0.182.0) with[email protected], so cleannpm cifailed. Updated to@google/[email protected]with[email protected]; both versions are older than 14 days, satisfy model-viewer and every React Three/Remotion peer range, and are pinned exactly to keep future installs reproducible. - Closed the pre-existing brand showcase lint gate while restoring the frontend baseline. Seven fixed palette swatches used static inline backgrounds. They are now named CSS modifier classes with the same RGB values; no visual behavior changed. The Helix mask also dropped an unused angle parameter; callers may still pass the computed angle, and JavaScript ignores the extra argument.
- Reconciled provider skill conflict copies instead of committing both versions. Eleven Finder-style
* 2.mdfiles were either exact duplicates or older copies. The newer skill files retained later cancellation, streaming, model, and caching guidance; corrupted Claude-to-Codex substitutions were repaired against the executable node registry and current Anthropic documentation before the duplicates were removed. - Kept FAL route slugs distinct from direct Google model IDs. FAL still publishes legacy route names containing
gemini-3-pro-image-preview, so those slugs and skill filenames remain intact. Direct Gemini and Vertex examples now use the stablegemini-3-pro-imagemodel ID. - Retired historical milestone plans without breaking live references.
docs/superpowers/is superseded by the portable contract corpus and remains recoverable from Git history. The two remaining live references were made self-contained before deletion: the hackathon evidence guide embeds its demo sequence, and the Remotion schema names its implementation as the current runtime contract. - Integrated PR #5's image-input validation as a fresh commit on the recovered baseline. The branch was 50 commits behind
main, but its single commit transplanted cleanly while retaining the stable Gemini IDs and newer provider routing. The shared loader now fails loudly for missing, unreadable, non-file, and unsupported local image references; remote and data URI behavior remains unchanged. - Kept Cinema PRs #15 and #16 review-only. Both branches have no submitted GitHub review and are eight
maincommits behind. PR #15 needs an explicit terminal error merge when its background task fails, otherwise the final graph sync can restore stale success after the optimistic spinner. PR #16 also lets a promoted thumbnail updateshot.output.imageUrlwithout updating the dynamicshot_<id>port, so the UI and downstream graph can disagree. Neither branch was merged. - Merged the unique local-engine research checkpoint, archived the other stale lines. The ComfyUI/Flora/Weave comparison was documentation-only and applied cleanly. Fifteen fully merged local branches were safely deleted. Six stale non-worktree branches and both stashes were tagged under
archive/before their local refs were removed; the stash tags retain the merge commits and their untracked-file parents. - Preserved two cloud-backed worktrees instead of forcing deletion.
feat/batch-cross-pairhad two dirty dev-routing edits; they were security-scanned and committed as2d7634dbefore its archive tag was created. The old frontend-baseline and batch worktree directories could not be removed because both Git deletion and a Trash move blocked on cloud file hydration. Their branch refs, worktree registrations, and archive tags remain intact. - Final verification is green with known warnings only. Backend: 1,111 passed with the two pre-existing AsyncMock coroutine warnings in
test_async_poll_runner.py. Frontend: 389 tests, lint, inline-style guard, Slava CSS scope guard, and production build passed. Contract parity and generated model-reference checks passed for 142 definitions; the build retained the known large-chunk andlottie-webdirect-eval warnings.
- Preserve the review stack before rewriting it. Tagged the original PR #15 tip
66599b7asarchive/cinema-per-shot-pre-repair-2026-07-22and the original PR #16 tip4468ba1asarchive/cinema-variations-pre-repair-2026-07-22before rebasing either branch. - A completed
execute_graphcall does not prove the Cinema node executed. Normal upstream failures are emitted asErrorEventandexecute_graphreturns without a targetExecutedEvent. The per-shot task now requires that target event, captures the first user-facing error, and persists it on the live target shot instead of reading stale success from the request snapshot. - Failure reconciliation preserves useful state. A terminal error changes only the target shot status and error text. Its last successful image, hash, and dynamic output port remain available, and sibling edits made during the slow pass remain untouched under the existing per-node merge lock.
- Keep the Cinema stack independent from provider work. The original repair did not duplicate the then-unmerged provider fixes into #15. The final restack inherits those merged fixes, CI, and dependency remediation from
mainwhile keeping the Cinema diff reviewable on its own. - Variation promotion is one scene-plus-port transaction. PR #16 now promotes through a Cinema-specific backend endpoint guarded by the same per-node lock as generation merges. It persists
selectedVariation, replaces the canonical shot output without retaining a stale hash/error, updatesshot_<id>, and broadcasts one graph sync. The frontend mirrors both fields after success and uses the same local operation for frontend-only nodes.
2026-06-10 (night) — Full Ideogram direct-API surface: 7 direct-only nodes incl. custom-model training
User wanted EVERYTHING the direct API offers, not just the dual-route nodes. Pulled the full OpenAPI (developer.ideogram.ai/openapi.json), enumerated all 27 endpoints, and wired every current (non-legacy) one. Registry 131 → 138.
New direct-only nodes (envKeyName IDEOGRAM_API_KEY, apiProvider 'ideogram', no FAL fallback):
ideogram-describe(v4): image → readabledescription+ rawjson_prompt(the v4 structured contract incl. bbox layout when include_bbox on). Category analyzer.ideogram-magic-prompt(v4, application/json not multipart): text → expanded json_prompt. Category text-gen.ideogram-transparent(v3 generate-transparent): prompt → real-alpha PNG, upscale_factor X1/X2/X4.ideogram-remove-background: Ideogram's own cutout — deliberately separate node id from the FAL rembgremove-background.ideogram-layerize(layerize-text): returns the clean base plate (text stripped); the response is NOT the standard data[] shape — base_image_url/original_image_url/seed, normalized through_save_first_image({"data":[{"url": base}]}).ideogram-edit-prompt(/v1/edit): MASKLESS prompt-driven edit, 1+ images (singleimageport leads, multiimagesappends), optional transparent_background. Response schema is V1EditImageObject but the endpoint is NOT marked legacy.ideogram-train-model: one node = the whole training pipeline (POST /datasets → upload_assets multipartfilesarray → train-model → poll GET /models/{id} every 30s, ≤3h, statuses CREATING/DRAFT/TRAINING/COMPLETED/ERRORED/ARCHIVED; requires is_available_for_generation). Outputscustom_model_uri+model_idTexts.
Closing the loop: custom_model_uri directParam added to ideogram-character (v3 generate accepts it) → train-model output wires straight into fine-tuned character generation.
Still consciously unexposed (in ideogram.md): raw json_prompt INPUT on v4 generate (we only OUTPUT it from describe/magic-prompt so far), dataset/model list-get endpoints, per-character-ref masks, all 5 legacy endpoints.
980 backend (+10: per-endpoint body shapes incl. the JSON-vs-multipart split, train full-flow ordering + ERRORED failure, custom_model_uri forwarding) + 347 frontend; parity 138. NOTE: npm run lint currently fails on BrandShowcaseView.tsx inline-style violations — that's the user's in-flight brand work, not this session's files (mask-painter/inspector pass the checker).
Three asks in one pass: stop depending on FAL alone for Ideogram, wire Character Studio into ideogram-character, and give masks an in-app painting UI. Registry 130 → 131 (+mask-painter).
1. Ideogram direct API — dual-route conversion (not new nodes). All 7 ideogram nodes now follow the veo-3/meshy dual-route pattern: envKeyName: [IDEOGRAM_API_KEY, FAL_KEY], directKeyName, params split into shared/fal/direct. New backend/handlers/ideogram.py (multipart/form-data, Api-Key header, SYNC endpoints, ephemeral result URLs downloaded immediately). Schemas all fetched from developer.ideogram.ai OpenAPI (.md suffix trick). Notables:
- Direct remix rides V4 (
/v1/ideogram-v4/remix,image_weight1-100) while FAL remix is still v3 — the direct route is a real upgrade, not just margin-avoidance. - Param dialects differ per route and the dual-param architecture was built for exactly this: rendering_speed TURBO/DEFAULT/QUALITY (direct) vs TURBO/BALANCED/QUALITY (FAL); magic_prompt enum vs expand_prompt bool; resolution pixel enums vs image_size presets; v4 generate/remix take no seed/num_images.
- Router fallback nuance: direct reframe REQUIRES
resolution; the router falls back to FAL when it's unset rather than 400ing (require_paramhook in_ideogram_router). - Skipped on direct: v4
json_promptstructured contract, describe/remove-bg/layerize-text/transparent-bg/custom-model training (documented in docs/api-guides/ideogram.md as unwired).
2. Character Studio → ideogram-character. Optional character input port (Character dataType). expand_character_inputs in handlers/ideogram.py reuses cinema.identity.expand_character — the SINGLE identity-correctness implementation (trait string VERBATIM prefix, stored views first, per-use overrides appended, max-refs guardrail at 10) — and the sync_runner wrapper applies it to BOTH routes before delegation. Bundle seed lands in params only when the user left seed unset. reference_images port went required→optional (refs OR character, enforced in the wrapper with a clear error).
3. Mask Painter. New utility node + canvas paint modal:
- Storage contract:
params._maskData= white-on-black PNG data URI (underscore param rides_validate_paramslike_characterId). Polarity applied at EXECUTION by the engine branch (white-editFLUX /black-editIdeogram) so flipping the param never requires repainting. - Engine branch resizes the mask to the source image's EXACT dims (PIL, NEAREST to keep edges hard) — Ideogram 422s on any mismatch.
- Modal exports only the strokes layer (never the photo canvas) → cross-origin upstream images can't taint the export. Paints at natural resolution, CSS-scaled display.
- UI lives in the Inspector, not a custom canvas node — deliberate: Canvas.tsx is mid-flight with the user's uncommitted brand-showcase work, and a custom node type would have forced edits there. Inspector section finds the upstream image via edges→source outputs (falls back to image-input
_previewUrl). - Added to docs/utility-node-test-manifest.json (the manifest gate requires every utility node covered).
Tests: 970 backend (+21: direct body-shapes per endpoint, router direct/fallback/require_param, FAL-route bundle expansion, mask polarity/resize/error) + 347 frontend; contracts parity 131. The earlier FAL-session contract test pinning envKeyName == "FAL_KEY" was updated to pin the dual-route shape instead (intended evolution, not a regression).
User caught that the morning's Ideogram add was text-to-image only while Ideogram's whole pitch includes region editing. Wired the full capability surface as 6 new nodes (registry 124 → 130), all via FAL (FAL_KEY, existing handler):
ideogram-edit(fal-ai/ideogram/v3/edit) — masked inpaint. Mask polarity is BLACK = edit, the inverse of FLUX Fill (verified against developer.ideogram.ai: "Black regions in the mask should match up with the regions of the image that you would like to edit"). Surfaced in the port label ("Mask (black = edit)") and pinned by a registry contract test, since a silent polarity flip would invert every edit.ideogram-remix(v3/remix) — image+prompt restructure withstrength.ideogram-reframe(v3/reframe) — outpaint to a required targetimage_size; schema takes NO prompt (contract-tested).ideogram-replace-background(v3/replace-background).ideogram-character(fal-ai/ideogram/character) — consistent identity from reference photos; needed a NEW handler mappingreference_imagesport →reference_image_urls(style refs ride the existingimages→image_urls). Style enum here is AUTO/REALISTIC/FICTION (different from the v3 AUTO/GENERAL/REALISTIC/DESIGN).ideogram-upscale(fal-ai/ideogram/upscale) —resemblance/detail1-100;expand_promptdefaults FALSE on this endpoint (true elsewhere).
Decisions:
- v3 endpoints for the editing suite — probed FAL's OpenAPI:
ideogram/v4/{edit,remix,reframe,replace-background}all 404; onlyideogram/v4+ideogram/v4/loraexist. The direct Ideogram API has a v4 Remix but FAL doesn't host it yet. Swap slugs when FAL ships v4 editing (node ids stay stable). - Skipped params:
style_codes/style_preset/color_palette(array/object types the param UI can't express — reachable via fal-universal),reference_mask_urlson character (must count-match refs; awkward in graph UI), v4 LoRA node (needs a loras array; fal-universal covers it). - All six endpoints return
{images: [...]}→ existing_parse_fal_outputhandles them; no output changes.
949 backend (+3: edit body-shape, character ref-mapping, 7-node registry contract incl. mask-polarity + reframe-promptless pins) + 347 frontend; contracts parity 130; lint/build green.
2026-06-10 — Gap-audit remediation: dead DALL·E removal, GPT-5.x/Fable-5/Gemini-3.5 refresh, Ideogram 4 + Runway Upscale, doc sync
Full-session remediation of the comprehensive gap review (4 subagent audits + canonical-source verification). Registry: 123 → 124 nodes (−dalle-3-generate, +ideogram-v4, +runway-upscale). Tests: 946 backend + 347 frontend, node-contracts parity green.
Decisions not dictated by the spec:
dalle-3-generateremoved outright (not aliased): OpenAI shut down dall-e-2/3 on 2026-05-12, and gpt-image nodes already cover the category. Saved graphs containing it will fail validation — acceptable since the API itself is dead. Also dropped the dall-e branches fromopenai_image.pyand the deaddall-e-2option fromgpt-image-1-edit's enum (caught by the docs subagent).gpt-4o-chatkeeps its node id (saved-graph compat) but displayName → "OpenAI Chat"; default model gpt-4o → gpt-5.4; newreasoning_effortparam (gpt-5.x only, default medium). Handler omits temperature/top_p/penalties for gpt-5.x (reasoning models reject them) — guarded in code, not just UI visibleWhen.claude-chat: added claude-fable-5 + claude-opus-4-8; default stays sonnet-4-6 (cost/perf for graph work). Handler suppresses the extended-thinking block for fable/mythos models (adaptive thinking is always-on there; the param 400s).gemini-chatdefault 2.5-flash → 3.5-flash (now the stable frontier model).gemini-3.1-flash-lite-preview→ stablegemini-3.1-flash-liteid.thinkingLevelvisibleWhen refreshed to the 3.x set incl. 3.5-flash (was stale → param would never show for new models).- Runway: video options += seedance2/seedance2_fast/happyhorse_1_0 (all text-capable per docs); aleph gets a model param (default stays gen4_aleph, aleph2 opt-in); image += gemini_image3_pro + gpt_image_2; new
runway-upscalenode (magnific_precision_upscaler_v2, schema from runwayml SDK 4.18.0 via opensrc: scaleFactor 2/4/8/16, flavor, sharpen/smartGrain/ultraDetail). gen3a_turbo kept but labeled legacy (dropped from Runway's models table, still priced). - Ideogram 4 wired via FAL (
ideogram/v4, schema fetched from fal.ai OpenAPI) rather than direct api.ideogram.ai — zero new auth, matches the 40+ existing FAL preset nodes. Direct integration (IDEOGRAM_API_KEY) deferred; key removed from .env.example with the other never-wired keys (BFL/BYTEDANCE/RECRAFT). - Sora 2 sunset surfaced in displayName ("Sora 2 (sunsets Sep '26)") since node defs have no description field — OpenAI kills the Videos API 2026-09-24 and the FAL endpoints proxy it. Plan removal mid-September.
- Cancellation: veo.py now schedules a detached
{op_name}:cancel(standard google.longrunning interface, best-effort) on CancelledError; grok_video.py documented local-only (xAI has no cancel endpoint) matching the MiniMax precedent. The 342e86e claim of "all poll-based providers" had skipped both. - Settings UI: added KREA_API_TOKEN (was un-keyable via UI); removed the BFL field + the dead "FLUX Routing" control — the
routingsetting was persisted but no handler ever read it (scaffolding for an unbuilt BFL-direct path; ROUTING_OPTIONS left in place, emptied). - node_definitions.json formatting normalized to plain
json.dump(indent=2)— a few hand-compacted option blocks (seedvr resolution etc.) re-expanded; future scripted edits now produce clean diffs.
Verified-false subagent claims (do NOT "fix" these): anthropic-version: 2023-06-01 is still Anthropic's latest API version; api.dev.runwayml.com IS Runway's production host.
Deferred / follow-ups:
- README still says "seven workspaces" deliberately — brand-showcase is in-flight uncommitted work; its README mention should ride that commit.
- runway-image caps reference images at 3; gemini_image3_pro supports 14 and gpt_image_2 supports 16 — widen when someone needs it.
- Frontend/backend node-def duplication: parity is gated (
npm run check:node-contracts+ backend contract tests) but codegen from the backend JSON is the structural fix — sized as its own session. - minimax handler still on
api.minimaxi.com(works; docs now sayapi.minimax.io) — flip after a live smoke. - Eleven Music (
/v1/music, model music_v1) is API-ready at ElevenLabs but unintegrated — candidate next node.
Three follow-on features after the user asked where generated images live (answer: <repo>/output/<UTC-timestamp>/<uuid>.<ext>, served at /api/outputs/..., never auto-deleted).
- Reveal in Finder —
POST /api/reveal {url}resolves via the existing_output_path_from_ref(containment-checked under the output root), thenopen -R(macOS) /xdg-open(Linux) /explorer /select,(Windows) viaasyncio.create_subprocess_exec. ResultCard gets a FolderOpen button. - Save to a default export folder — new
exportFoldersetting (added toDEFAULT_SETTINGS+ thePUT /api/settingsallowlist + a Settings field).POST /api/export {url}copies the output file intoexportFolder(default~/Downloads), de-duping the filename on collision. ResultCard gets a FolderDown "Save" button. - Configurable output directory — wired the previously-DORMANT
outputPathsetting (the Settings field + persistence already existed but nothing honored it)._resolve_output_root()now setsOUTPUT_ROOTfromNEBULA_OUTPUT_ROOTenv >outputPathsetting > default, at import (applies on restart). Replaced the/api/outputsStaticFiles mount with a dynamic@app.get("/api/outputs/{rel:path}")FileResponse route that serves fromOUTPUT_ROOTand falls back toDEFAULT_OUTPUT_ROOT, so outputs created before a relocation still serve. StarletteFileResponsepreserves content-type + HTTP Range/206 (video seeking) + HEAD — verified live.
Review-caught fixes (opus, on the serving-path change):
- Critical — startup brick: once
outputPathis user-set, the unguardedOUTPUT_ROOT.mkdir()at import could crash uvicorn on a bad/unwritable path. Fixed inside_resolve_output_root(): anchor relative paths to the repo root, thenmkdir-or-fall-back-to-default soOUTPUT_ROOTis always a usable dir. Live-verified:NEBULA_OUTPUT_ROOT=/dev/null/nopeboots via fallback instead of bricking. - False-green traversal test: the original
client.get("/api/outputs/../../etc/passwd")passed only because httpx normalizes../client-side, never hitting the guard. Replaced with a directserve_output("../../../../etc/passwd")test that actually exercises therelative_tocontainment (and a server-sidecurl --path-as-islive check → 404). - Relative
outputPathwas CWD-dependent → anchored to repo root. Dual-root fallback also applied toconvert_to_glb+resolve_moodboard_image_pathfor consistency. Registered.webm/.glbmime types.
Verification: backend pytest 865 passed (twice); frontend tsc clean, 322 vitest, build/eslint/css-scope green. Live (running backend): image 200 (image/png), video full 200 + Range 206 (seek), server-side traversal 404, missing 404, no-brick boot on bad path. Commits: 14a4d83 (reveal+export), a53cbb5 (configurable dir), 782b4ef (review fixes).
Branch feat/create-view-p2-p3 (same branch as P2). A library of named "styles" — prompt fragment + params + optional model — that pre-fill the composer, plus save-current-as-style. The historical implementation plan was retired in the 2026-07-22 documentation cleanup and remains recoverable from Git history.
Decisions / non-obvious calls:
- Typographic preset cards, no shipped thumbnails. Higgsfield merchandises styles with auto-playing video tiles; we ship ALL-CAPS name + category over a deterministic per-id Slava gradient. This sidesteps two real constraints at once: (1) shipping binary PNGs through the toolchain is awkward, and (2) the backend serves only
OUTPUT_ROOT(oneStaticFilesmount) — there's no arbitrary-file route, so a repo-shipped thumbnail isn't directly servable without copying it into the outputs dir at seed time. Real thumbnails are deferred to P4. The one allowed inline style isPresetCard's--preset-huecustom property (a dynamic data value —check:inline-stylesonly flags static visual props). - First-run seeding is a NEW pattern. No seeding existed anywhere in the codebase (verified). Added
seed_presets_if_empty()(idempotent — only when the global store is empty; wrapped in try/except so it can never break boot) reading 12 curated styles frombackend/data/presets/seed.json(JSON only, no binaries). Placed at the END ofmain.py(Python executes top-to-bottom; the fn must be defined first), running once at import.conftest.pysetsNEBULA_PRESET_ROOTto a temp dir at module-eval time so the boot seeder never writes into the user's real~/.nebula/presetsduring tests. - Mirror the store pattern faithfully.
preset_store.pyclonesmoodboard_store.py. Code review caught that the first cut simplified away three guards the moodboard store has — added back:_scope_dir'sresolve().is_relative_to()path-containment check,list's per-filetry/except (JSONDecodeError, OSError), and the route'sValueError → 422. "Reuse verbatim" means including the defensive bits. applyPresetToComposerrebuilds model defaults on a model switch. When a preset hints a different model, we re-seed that model's defaults (buildDefaultParamsForUi) before overlaying the preset's params, so stale params from the previously-selected model don't leak through. Pure function, unit-tested.- Repeated lesson — global-singleton test isolation. Same trap as P2's cluster route: backend tests must pass in the FULL suite, not just isolation. The preset tests use the module-attribute swap (
monkeypatch.setattr(main, "preset_store", ...)) +NEBULA_PRESET_ROOTsandbox + reference the store through the module (never a stalefrom main import preset_store).
Verification: backend pytest 847 passed; frontend tsc clean, 300 vitest tests, vite build, eslint, check:slava-css-scope green. Live (isolated uvicorn, temp dirs, port 8001): boot seeder produced 12 presets (GET /api/presets → 200, 12 files on disk); POST /api/graph/cluster persisted the cluster to state.json (confirming P2 reload-survival) — the user's real ~/.nebula was never touched.
Branch feat/create-view-p2-p3. Five sub-features: results gallery/History, reference-image attach, quantity>1 variations, all output types in the stage, and backend-authored persistence. Built subagent-driven in batches (Phase A persistence → B/C refs+quantity → D/E gallery+output-alignment) with spec + code-quality reviews. The historical implementation plan was retired in the 2026-07-22 documentation cleanup and remains recoverable from Git history.
The big reversal — clusters are now persisted (P1 debt closed). P1 authored clusters client-side (uuid ids, never persisted). P2 flips authorGenerationCluster to backend-first: it POSTs the cluster to a new additive route POST /api/graph/cluster (mirrors /api/graph/import but no clear()), which adds the nodes/edges to cli_graph (assigning n-ids, persisting to state.json via _maybe_persist, normalizing image-input params) and returns them in React Flow shape. The client applies the returned nodes directly + tags model nodes _createOrigin. Switching to canvas shows the cluster; it now survives reload.
Decisions / non-obvious calls:
- Tag race (caught in review). The backend's graphSync WS broadcast usually delivers the new nodes (untagged) to the client store before
authorGenerationCluster's ownsetruns, so an insert-if-absent merge silently dropped_createOrigin. Fixed with an upsert that merges only the tag onto already-present nodes (never clobbering their state/outputs) — and a test that pre-seeds the node to actually exercise the ordering (the unit tests mockwsClient, so the race was previously untested = false green). - image-input accepts
/api/outputs/...URLs. The engine passedfilePathverbatim and FAL's_to_fal_urltreats any non-http(s)/data:value as a local disk path — so a served URL broke. Addedresolve_output_ref(output.py) + an engine_image_input_outputhelper so generated outputs can be reused as references (execution side); the cluster route's_normalize_image_input_paramshandles the persistence side. "Use as input" stores the backend-RELATIVE pathname (not the absolutehttp://localhost:8000/...URL, which would reach external providers unresolved). - Gallery is session-scoped. History is driven by a
generations: {genId, prompt, ts, modelNodeIds}[]array inCreateView(not by scanning_createOrigin), so it's reliable in-session. The spec's "All outputs" tab and reload-repopulation are deferred (the canvas persists; the gallery starts fresh after reload). Shipped Grid/List toggle. deleteGenerationremoves the model node(s) of a generation plus any now-orphanedtext-input/image-input(an input feeding other surviving model nodes is kept). Fires a best-effort backendDELETE /api/graph/node/{id}for every id (harmless 404 for any non-cli id) — diverges from sibling delete paths'CLI_ID_REguard, justified because Create clusters are backend-authored now.- Cluster-route response built directly, not via full re-export. Initial impl re-exported the whole graph and filtered to new ids — which failed in the full pytest suite (a sibling test's autouse fixture swaps
main.cli_graph; root cause proven). Fixed by extracting_cli_node_to_rf(n, position, all_defs)(shared byexport_graph_for_frontendand the route) and looking up the new nodes directly incli_graph.nodes. More robust + avoids export-time normalization side-effects on unrelated nodes.
Verification: backend pytest 839 passed (twice, order-independent); frontend tsc clean, 293 vitest tests, vite build, eslint, check:slava-css-scope all green. Persistence to state.json is structurally guaranteed (route → add_node → _maybe_persist) and covered by the route test. Full browser smoke (gallery progression, persistence-across-reload, use-as-input round-trip) is consolidated with P3 in the verify-and-finish step.
Branch feat/create-view. A new full-screen viewMode: 'create' surface (4th studio) with a Higgsfield-style bottom-floating composer. Each Generate authors a real text-input → model node cluster and runs only that cluster via the existing engine. The historical design spec and implementation plan were retired in the 2026-07-22 documentation cleanup and remain recoverable from Git history.
Architecture — graph-builder (chosen over single-node / hybrid). graphStore.authorGenerationCluster(request) builds nodes/edges and executeCluster(nodeIds) POSTs only the cluster to /api/execute (reusing lib/api.executeGraph). Zero changes to the execution engine, handlers, or node definitions — the surface inherits output rendering, WS streaming, error handling, undo, and canvas editability for free. This is why it's ~640 lines of source for a full creation UI.
Decisions made beyond the spec:
- Local node authoring, not backend-authored.
authorGenerationClustercreates nodes client-side (likeaddDynamicNode/paste) in oneset(), for atomicity + testability + immediate ids. Consequence (verified in live smoke): Create-authored clusters are client-only and do NOT persist to the backend~/.nebula/state.json— after a generation,state.jsonstill held only the pre-existing moodboard node. In-session "fill in" works (nodes are in the client graphStore, render on the canvas, and execute correctly). Cross-reload durability is NOT wired in P1. If we want authored clusters to survive a reload, P2 should author via/api/graph/node-and-connect(backend-authored, persisted) or push the cluster to the backend graph. Spec §4.3 was updated to reflect this. _createOrigintags model nodes only (not thetext-input/image-inputwiring nodes) — from code review; the P2 results gallery filters on this tag, so tagging input nodes would pollute it.executeClusterpre-marks cluster nodesqueued(clearing staleerror/progress/streaming fields) before POSTing — from review; without it the stage flashed the previous result on every 2nd Generate.- Dropped
cinematic+universalfrom the picker'sCREATE_MODEL_CATEGORIES.cinematic= post-process nodes (cinema-color/cinema-look/cinema-scene), not prompt-first generators.universal= the 4 dynamic nodes (OpenRouter/Replicate/FAL) — deferred to P4 (they need a model-schema fetch step). v1 picker =image-gen/video-gen/audio-gen/3d-gen/text-gen. - Accent = Slava orange (
--sr-accent), not Higgsfield lime. Copy the layout/interactions, render in the house skin. model-viewerrendered via the existingtypes/model-viewer.d.tsJSX declaration (no@ts-expect-error— that would be an unused-directive error), mirroringMeshPreview.tsx.
Known debt / deferred:
- DRY:
buildDefaultParams(graphStore) duplicatesbuildDefaultParamsForUi(createParams) and the inline logic inaddNode. Consolidate post-P1 (a shared param-defaults util imported by all three). - Image-required models (e.g.
runway-video,meshy-image-to-3d) appear in the picker but will fail backend validation without a reference image until P2 adds reference attach — acceptable; surfaces the real validation error. - P2: results gallery/History, reference-image attach, quantity>1 UI, all output types in the stage. P3: presets/styles library. P4: universal dynamic nodes, generated preset thumbnails, draw/mask, @-mention elements.
Verification:
- Gates:
tscclean, 288 vitest tests pass (incl. new createCluster/createModels/createParams/uiStore tests),vite buildsucceeds,eslintclean on all new/changed files,check:slava-css-scopepasses. (npm run lintis red onmaindue to pre-existingCrabMark.tsxinline-style debt — unrelated to this branch.) - Live smoke (real browser, not lspace): entered Create, opened the model picker (full catalog), typed a prompt, hit Generate → a real nano-banana image of exactly the prompt was generated end-to-end (
POST /api/execute → [exec] _run completed → output/.../*.jpeg). The prompt-faithful output proves thetext-input → modelwiring carried the prompt. - Environment gotcha: background dev servers get reaped (SIGTERM 143) in this harness; when Vite's dev server dies, its
@vite/clientinterceptsconsole.errorand recursively tries tosendover the dead HMR socket → a multi-million-line "send was called before connect" storm. This is a Vite-client failure mode, NOT app code. The canvas-cluster screenshot wasn't captured for this reason; the cluster's existence is proven by the unit tests + the successful generation.
- Added Codex as a separate chat runtime rather than overloading the Claude runner. Codex has its own JSONL event stream, auth state, and resume command shape, so a dedicated adapter keeps the existing Claude/Daedalus paths untouched.
- Codex uses the local CLI login state (
codex login status) instead of storing ChatGPT credentials in Nebula. This follows OpenAI's documented Codex auth model and avoids copying subscription tokens intosettings.json. - The Codex runner launches with
workspace-write,approval_policy="never", and local network enabled so it can call the Nebula CLI/backend without interactive approval prompts while still avoiding the full--dangerously-bypass-approvals-and-sandboxpath. - GPT Image 2 generation remains on Nebula's existing OpenAI/FAL nodes. ChatGPT-backed Codex auth is only for the Codex agent brain; Image API calls still require
OPENAI_API_KEYorFAL_KEY.
- Added a repo-backed skill bootstrap to the Codex runner instead of relying on private/global agent skill state. The bootstrap indexes
.agents/skills/*/SKILL.md, lists available skills, preloads relevant root skill docs based on the user's message, and points Codex to tracked provider docs for exact node/API details. - Kept preload bounded (
MAX_SKILL_BOOTSTRAP_CHARS,MAX_SKILL_DOC_CHARS) so FAL's large model catalog remains available on disk without bloating every Codex turn. .agents/skillsis not currently committed onorigin/main; it exists locally as an untracked public-safe bundle. It needs to be added to the repo before the GitHub version has the same Codex/Nebula knowledge.
- Added Claude auth status parity with Codex via
/api/agents/claude/status. Nebula still does not collect credentials; it only reports the local CLI's installed/logged-in state. - Added compact connection instructions inside the chat composer for Claude and Codex. They show the relevant local CLI login/status commands and open automatically when the selected CLI is missing, unavailable, or not logged in.
- Creating the announcement as a standalone HyperFrames composition under
hyperframes/codex-chat-announcementso it can be rendered independently from the Vite frontend while still matching the Slava UI. npx hyperframesis not available in the local cache and registry access is blocked in this sandbox, so the work will produce valid composition source and local structural checks rather than a rendered MP4 in this pass.- The video mirrors the actual Slava chat panel affordance: Claude / Codex / Daedalus selector with Codex active,
Codex · ChatGPTstatus copy, dot-matrix canvas, glass panels, orange focus accent, and compact node graph surfaces.
- Added frontend-side Nebula backend discovery instead of assuming every request and WebSocket lives at
localhost:8000. - Discovery checks the same-origin
/api/healthpath first, then cached/local localhost candidates on ports 8000-8010, and caches the working base URL in localStorage. - Kept
VITE_NEBULA_API_BASEandVITE_NEBULA_BACKEND_PORTSas explicit overrides for users who run the backend on a nonstandard fixed port. - Rewrote local
/api/outputs/...asset URLs to the discovered backend origin so images, videos, downloads, and restored graph bundles still work when the backend is not on 8000.
- Fixed backend discovery treating the same-origin Vite proxy candidate (
"") as not found even after/api/healthsucceeded. - Added retry loops for Claude/Codex status checks and the chat WebSocket so a backend that starts after the page loads is picked up without switching tabs or reopening the panel.
- Added a Nebula-launched Codex ChatGPT login path instead of asking users to copy terminal commands first. The backend starts
codex loginand the frontend polls progress/status from the chat panel. - Treat Codex API-key mode as not connected for the ChatGPT subscription path. The button is intentionally labeled
Connect ChatGPT Account; starting it runscodex logoutfirst, thencodex login, so an existing API-key login can switch to ChatGPT OAuth cleanly. - Nebula still does not store OpenAI OAuth tokens. Codex owns the browser/device OAuth flow and caches credentials in its normal local store; Nebula only reads
codex login status. - Added a
Device Codefallback in the UI for machines where the browser callback flow is awkward or blocked.
- Diagnosed the GPT Image 2 "no images" symptom as a backend graph-sync crash before or after the image node, not a Codex auth problem. A long prompt Text output was being probed as if it might be an output file path.
- Hardened output-path normalization so
Path.exists()/ path parsing errors from long plain-text values are treated as "not a file" instead of crashing/api/graph/run. - Verified the regression with the existing long-text output sync test plus the GPT Image 2 handler suite.
- Added an explicit billing acknowledgement guard for OpenAI-direct image nodes (
gpt-image-1-generate,dalle-3-generate,gpt-image-2-generate,gpt-image-2-edit). - Frontend graph and node runs now show a native confirmation explaining that these nodes use
OPENAI_API_KEY, bill the OpenAI API project, and do not use the ChatGPT subscription or Codex ChatGPT login. - Backend
/api/execute,/api/execute-node,/api/graph/run, and/api/quickreject unacknowledged OpenAI-direct image runs with HTTP 409, so CLI/agent-triggered runs cannot silently spend API money. - Human CLI users can deliberately opt in with
NEBULA_ALLOW_OPENAI_API_BILLING=1fornebula run/nebula quick; agents do not get that bypass by default.
- While generating media from long prompt text nodes,
/api/graph/runcrashed before downstream image nodes executed because output normalization treated every string output as a possible file path and calledPath.exists()on the entire prompt. - Fixed output URL normalization to treat filesystem probes as best-effort:
OSErrorfrom impossible path strings now means "not a media asset", preserving text outputs unchanged. - Added a regression test so long text-output values do not break CLI graph execution while real output file paths still normalize to
/api/outputs/....
- Hardened the Nebula Codex runner so
codex execonly starts whencodex login statusreportsLogged in using ChatGPT. - API-key, access-token, unknown, not-installed, and not-logged-in states now return a chat error before any Codex subprocess can run.
- Stripped
OPENAI_API_KEY,OPENAI_ACCESS_TOKEN, andCODEX_ACCESS_TOKENfrom the Nebula-owned Codex subprocess environment so an inherited shell credential cannot silently flip the agent back to API billing.
- Diagnosed a Nebula UI feedback gap after adding the OpenAI API billing guard: if the native confirmation was cancelled or suppressed, the run returned before any visible node state changed.
- Node execution now marks the selected execution scope as
queuedimmediately after confirmation so GPT Image 2 nodes show visible activity even before the first backend websocket event arrives. - Queued nodes now render the loading block, and start/validation failures write a visible node error instead of only logging to the browser console.
- Removed the OpenAI-direct image billing acknowledgement as a run blocker. The user decided the confirmation was slowing down normal workflow and was not needed now that Codex-agent auth is ChatGPT-only.
- Frontend Run / Run This Node no longer opens a native confirmation dialog before GPT Image 2 direct execution.
- Backend
/api/execute,/api/execute-node,/api/graph/run, and/api/quickno longer requireallowOpenAIApiBilling, so CLI and agent-triggered runs use the same normal validation/execution path. - GPT Image 2 direct nodes still use
OPENAI_API_KEYand bill the OpenAI API project; this change only removes the extra acknowledgement gate.
Built overnight via a 7-wave dependency-ordered subagent workflow. The historical design spec was retired in the 2026-07-22 documentation cleanup and remains recoverable from Git history. The framing decision: Higgsfield Soul Cinema is a stack (cinematic base + Soul ID + Soul HEX + film-look + keyframe handoff), which maps 1:1 onto our node graph — so we add the two genuinely-missing pillars as deterministic local nodes and a multi-shot Studio editor, reusing existing models for everything else.
Decisions made beyond the spec (collated from wave reports):
_parse_recraft_colorwas extracted intobackend/cinema/color.pyas the single source of truth and re-imported back intoexecution/sync_runner.py(the old inline def removed), sofrom execution.sync_runner import _parse_recraft_colorstays valid fortest_fal_handler.py. Honors "extract/share, don't duplicate."- sRGB↔CIELAB (D65) implemented in pure numpy rather than adding a color-science dependency (honored "add nothing else but Pillow/numpy", both already pinned in the venv).
- Grain determinism: RNG seed derived from a sha256 of the look params + image dims (no wall-clock), so identical inputs → byte-identical noise. Same idea for the whole pipeline →
ExecutionCache-friendly. lab-transfer(default color method) = Reinhard mean/std match in Lab plus a per-pixel nudge toward the nearest target swatch atstrength·0.5. This is what makes the cool-blue grade keep the forge fires hot orange (visible in the smoke set) instead of a flat global tint.cinema-scenestores its spec exactly likeremotion-nodestores its manifest —params:[]in the catalog, the realCinemaSceneSpeclives ondata.params.sceneat runtime (there is noobjectparam type and adding one was out of scope). The editor writes it viagraphStore.updateScene.cinema-scenebase dispatch reuses the fullget_handler_registrypath (synthesize a GraphNode, invoke the chosen base model's registered closure) rather than thefal_universalfallback — a clean internal base→color→look call. Reference images are fed into BOTHimage(single) andimages(multiple) ports since edit bases differ (flux-kontext vs nano-banana); handlers ignore ports they don't map.- License guard: an empty/missing or FLUX.1-dev base model is substituted with
seedream-4-5(commercial-OK) instead of crashing. - Per-shot output ports use
isDynamic:true+dynamicOutputPortson node data (mirrorsconfigureOpenRouterModel) souseIsValidConnectionresolves them and Send-to-motion can wire a shot into aveo-3first-frame input. - New
cinematiccategory +paletteparam type required extending the contract validators (scripts/check-node-contracts.mjs,backend/tests/test_node_contracts.py) and the model-reference generator (scripts/generate-model-reference.mjs) — extending the allowed sets, not weakening any shape rule.docs/MODEL_REFERENCE.mdregenerated (107 nodes). - Inspector palette extraction is client-side k-means (deterministic seeding, 96px downscale) per spec latitude; no backend
/api/cinema/extract-paletteendpoint was added.
Orchestrator fix after the build (the one bug self-verification couldn't catch):
- Port-id mismatch: frontend
shotPortId()producedshot_<id>while the backend handler producedshot-<id>. Unit tests +tscboth passed because it's a cross-process string contract. Aligned the backend toshot_<id>(backend/handlers/cinema_scene.py_output_port_id+ thetest_cinema_scene.pyassertions) — underscore matches the codebase's port-id convention (video_in,first_frame_image). Re-verified: 55 cinema tests + contract check green.
Known gaps for the next (interactive) session — needs the live app:
- In-Studio preview round-trip (verify live). The handler writes finished shot URLs onto
node.params['scene'].shots[*].outputAND returns per-shot port outputs. Canvas per-shot ports + downstream wiring update via the normal executed-output mechanism; the in-editor preview readsscene.shots[*].output.imageUrl, which only refreshes when the backend re-pushes the node's params viagraphSyncaftercinema-scenecompletes. Confirm the backend emits that graphSync (or have the Studio also read fromdata.outputs[portId]). - Live base→color→look end-to-end was only exercised with a mocked base model. Run a real
seedream-4-5/nano-bananascene with character refs against live keys. - Variations strip is a single-slot placeholder (handler emits one image/shot); populate when multi-output lands.
- Send-to-motion gives only an inline "Sent ✓"; the new
veo-3node isn't visible until you exit the Studio — consider auto-exit or a toast. - Offline node-create falls back to
model-nodetype forcinema-scene(same limitation asremotion-node); online graphSync assignscinemaSceneNodecorrectly. - Pre-existing:
backend/.venvpytestconsole-script has a stale shebang (points at the oldDocuments/Projectspath) — run tests via.venv/bin/python -m pytest.
Out of scope (next spec): Looks/preset library + Studio "Looks" gallery; soul-cinema SKILL.md + Codex/Daedalus agent wiring; ffmpeg video film-look; trained-LoRA / PuLID identity modes.
Smoke proof: docs/soul-cinema-smoke/ — 11 PNGs from a real Athens golden-hour still (color transfers, all 5 film presets, 2 full-pipeline grades). All deterministic, generated with no server.
First live test surfaced a real bug: a cinema-look node with preset='kodak-portra' produced a near-unchanged ("just darker") image. Root cause (found via systematic debugging, evidence-first):
cinema.look._resolve_paramscorrectly lets explicit float sliders override a preset (this is intentional — per-shot Studio overrides rely on it;test_explicit_param_overrides_presetpins it).- BUT the node always carries its slider params (catalog defaults
grain 0.2 / halation 0.2 / vignette 0.25 / contrast 0 / saturation 0 / temperature 0), and the frontend forwards them even thoughvisibleWhenonly shows them forpreset==='custom'. Hiding a control doesn't remove its value. - So
_build_lookforwarded those neutral defaults as if explicit → they overrode kodak-portra's grade (temperature 0.18, contrast 0.12, saturation 0.08) back to 0, leaving only a uniform vignette darken. Channel evidence: buggyR−18 G−17 B−15(uniform darken, no colour) vs correct preset-onlyR−2 G−9 B−19(warm R-vs-B separation).
Fix at the build sites (intent is known there), not the pillar:
backend/handlers/cinema_look.py::_build_look— whenpreset in PRESETS(a real named preset), do not forward the float sliders; the preset's own bundle stands.'custom'/unset still forwards sliders. LUT always honored. (+5 regression tests intest_cinema_look.py, red→green; the existing pillar override test still passes.)frontend/.../CinemaSharedControls.tsx— selecting a preset chip now callsselectPreset()which setslook = { preset: id }(drops neutral sliders);'custom'restores editable sliders. Required making theCinemaSceneSpec.lookslider fields optional (types/index.ts) — semantically correct: a named-preset look omits sliders, matching the backend "missing → use preset" behavior.
Gotcha for re-testing: cinema-color/cinema-look are deterministic and cached (services/cache.py ExecutionCache, in-memory). Re-running a node with identical params returns the cached result — switch the preset (or restart the backend, which clears the cache) to see a fresh render. Also: running a node re-executes its whole subgraph, so re-running a cinema-look wired off a gpt-image-2 node will re-generate the (paid) base image; wire film-look off a static image-input to iterate for free.
Follow-up (not done, user's call): the node/scene default preset is 'custom' (subtle grain+vignette, no colour) — consider defaulting to a named preset like kodak-portra so a freshly-dropped node obviously "does the film thing."
User principle: "what happens in the node view should happen in all views and vice versa." Gap found while testing: an image wired into the cinema-scene node's character_refs port on the canvas was used by generation (the handler reads inputs['character_refs']) but was invisible in the Studio's CHARACTER REFS box (which only rendered uploaded scene.character.refImageUrls).
CinemaStudioViewnow resolves canvas edges into the node'scharacter_refsport to image URLs (robust to both a generated source —data.outputs[handle].value— and a staticimage-input—data.params._previewUrl/filePath, whoseoutputsmay be empty until run) and passes them toCinemaSharedControls.CinemaSharedControlsrenders connected refs read-only with a 🔗 badge (disconnect on the canvas to remove); uploaded refs keep their × remove button.- Reverse direction:
CinemaSceneNode's card summary now shows the uploaded-ref count (2 shots · 1 ref), so refs added inside the Studio are reflected on the canvas (connected refs already show as the edge). - Verified live: constructed
image-input → cinema-scene:character_refsvia the API and ran the exact resolution logic against/api/graph/export→ resolved the ref URL; tsc + vite build clean.
- Created branch
codex/krea-2-direct-providerfrom the current dirtymainstate as requested; unrelated existing worktree changes are being left untouched. - Scope decision: implement Krea direct API only. FAL Krea endpoints are intentionally skipped for this goal even though they exist, because Krea-native style IDs, moodboards, assets, and style training are not covered equivalently by the generic FAL node.
- Node-family decision: add one main
krea-2-generatenode plus Krea resource wrapper/training/search nodes. This keeps simple prompt-to-image easy while allowing graph-native use ofimage_style_references,moodboards, andstyles. - Moodboard limitation: verified Krea docs expose moodboard use by ID but no public create/list moodboard endpoints, so Nebula will model moodboards as existing-ID wrapper nodes and direct fallback params.
- Asset handling decision: local or generated images connected as Krea style references will be uploaded internally via
POST /assets; users should not have to paste URLs for normal graph workflows. - Shipped direct Krea implementation:
krea-2-generate,krea-image-style-reference,krea-style,krea-moodboard,krea-style-search, andkrea-style-train. krea-2-generateaccepts raw image style inputs and typed Krea resource objects together; the handler merges them into Krea's documentedimage_style_references,styles, andmoodboardsrequest fields, enforcing max 10 image refs and max 1 moodboard.- Style training uploads local graph images to Krea assets before calling
/styles/train, polls/jobs/{id}, emits a reusable style object, and optionally shares the style with the API workspace. - Verification: Krea handler tests, node registry/contract tests, Codex skill bootstrap tests, full backend suite (
827 passed), frontend production build, andgit diff --checkall passed. No live Krea smoke was run because noKREA_API_TOKENwas available in this session.
- Added/expanded the project-local Krea agent skill at
.agents/skills/krea/SKILL.mdso Codex/Daedalus agents know the direct-Krea node IDs, graph wiring patterns, resource wrapper shapes, API key naming, and Krea-specific live-test caveats. - Kept the skill as one concise
SKILL.mdrather than adding extra docs, because detailed provider research already lives indocs/model-providers/krea/krea-2.mdand skills should stay lightweight. - Included the
402API-balance caveat from live testing: a valid Krea token can authenticate and still fail generation until the separate API balance is topped up.
- User chose Nebula-native moodboards rather than Krea-owned moodboards. Decision: make Moodboard a first-class Nebula resource and port type; Krea is only a downstream adapter.
- First canvas mirror is a custom
nebula-moodboardnode/card with grouped visual state and multiple outputs (Moodboard, style brief, negative prompt, images, palette). True React Flow parent/child grouping is deferred because the current graph persistence path does not carry parentId/extent/group metadata. - Analysis starts as deterministic local extraction: resolve Nebula-local images, extract palettes with the existing CIELAB k-means code, and produce an editable creative-direction object. The schema is intentionally richer than the local analyzer can fully populate so a future vision-model analyzer can fill materials, motifs, and semantic cues without changing stored resources.
- Krea integration consumes native Moodboards by adapting representative images to
image_style_referencesand appending the extracted style brief to the prompt. We still keep Krea-owned moodboard IDs as their ownkrea-moodboardwrapper because Krea does not expose public create/list moodboard endpoints. - Browser smoke used the existing local backend (
127.0.0.1:8000) and Nebula Vite dev server (127.0.0.1:5180) in Chrome. A temporary "Smoke Moodboard" verified library listing, Studio loading, canvas node mirroring, and Analyze output; the temporary moodboard, canvas nodes, and generated test image were removed afterward. - Follow-up fix: the Studio "Add Images" action no longer relies on a script-triggered
.click()against a hidden file input. It is now a native file input inside the visible label with the input layered above the styled text, so normal mouse clicks hit browser-native file picker behavior. Drag/drop still uses the same upload path.
- Dead code: removed two stale Finder "Add Copy" duplicates —
CinemaStudioView 2.tsxandCinemaSceneNode 2.tsx. Both were untracked, older-and-smaller copies;diffconfirmed each held nothing the real file lacked (CinemaSceneNode 2.tsxpredated therefCountcard summary). Repo now has zero* 2.*artifacts. Untracked → deletion leaves no git diff, so the work shows only as the files' absence. - Real bug surfaced by a lint audit (
TimelineRuler.tsx): a barereact-hooks/exhaustive-depsdisable was masking a defect. The thumbnail effect keyed on[sourceUrl, sourceDuration], but its sample set derives fromstepCount(←totalOutputDuration), andTimeline.tsxmounts the ruler without akey. So editing clips (which changes output duration) shifted the sample times without re-running the effect → placeholder thumbnails. Fix: addstepCountto deps. Safe becausegetThumbnail(lib/editor/thumbnailStrip.ts) caches bysourceUrl|time|width, so a re-sync only fetches genuinely-new times (≤13, capped). Kept the disable (the true dep is thesourceStepTimesarray, fresh each render) but replaced the bare suppression with a justification comment. Shipped as commit5647fb7. - Discipline — distinguish "intentional suppression" from "hidden bug": of the disables a grep flagged as bare, the two
CharacterStudioViewones were already justified,MoodboardStudioView's loader disable was correct-but-undocumented (got a comment mirroring its Character twin), and onlyTimelineRuler's was an actual bug. The right move was reading each effect, not papering every disable with a "looks intentional" comment — that would have buried the real defect. - Cruft: dropped a leftover
console.log('[zoom-manifest] init', data)inuseZoomManifest.ts. Removing only the log line would orphan thedataparam (→no-unused-vars), so the init call was collapsed to fire-and-forget and the now-vestigialcancelledflag removed. - Surface otherwise clean: 0
as any/@ts-ignore/ real: any; the ~55console.*calls are deliberatewarn/errorin catch handlers; gates green (eslint,tsc -b, 322/322 vitest). - Process gotcha (shared working tree): this spanned a live branch. A concurrent session merged
chore/harden-surfaceintomain, deleted the branch, and kept committing tomain— all in the same working tree (it switched this tree's HEAD out from under me). My fix landed onmainbecause I checked the HEAD commit but not the branch name before committing. Kept onmainper user (matches the now-trunk-based flow). Lesson: re-verifygit branch --show-currentimmediately before committing when a tree may be shared, and isolate parallel work in agit worktreerather than switching the shared tree's branch.
Two features + a post-review hardening pass. All on feat/create-p4-polish, not yet merged.
-
Concurrent generations (commit
4b5f08e): the Create view can run up to 2 generations at once. Key decision: the globalisExecutinglock (which Canvas/Toolbar/Inspector rely on for one-at-a-time canvas Run) is left completely untouched. NewexecuteClusterConcurrent(nodeIds)ingraphStore.tsis a sibling ofexecuteClusterthat never reads/setsisExecutingand never calls globalresetExecution— it marks only its own idSetqueued, POSTs the cluster, and errors only its own nodes on failure. The cap is enforced Create-locally (MAX_CONCURRENT = 2) via anactiveCountderived from node states. Layout Y uses a synchronousgenIndexRefcounter (notgenerations.length, which is stale across rapid fires under React batching). -
Real preset thumbnails (commit
81bba74): chose "generate once via the app, commit WebPs" over first-run generation or static placeholders (user's call — it reverses the earlier P3 "no shipped binaries" stance, but the binaries are tiny).scripts/generate_preset_thumbnails.pydogfoodsPOST /api/quick(nano-banana) for each of the 12 seed prompts, downscales to 640px WebP (q82, ~6–81 KB each, ~520 KB total), writesdata/presets/thumbnails/<slug>.webp.slug_for_preset()lives inpreset_store.pyas the single source of truth shared by the generator (filename) and the seeder (URL) so they can't drift. Served by a newGET /api/presets/thumbnails/{slug}(slug regex + resolve/relative_to traversal guard,FileResponse).backfill_preset_thumbnails()runs every boot to fix installs seeded before thumbnails existed.- Gotcha during generation:
/api/quickreturns the output as an absolute filesystem path, not a/api/outputs/...URL — the first run silently 404'd on download because I builthttp://localhost:8000<abspath>. Fix: read absolute-path refs straight from disk. (The 12 generations had actually succeeded; only the download leg failed.)
- Gotcha during generation:
-
Post-review fixes (commit
[pending]): a code-review pass flagged 4 issues; 3 fixed, 1 deferred.- Fixed — thumbnail URL resolution (the important one): generated media render through
backendAssetUrlSync(rewrites/api/outputs/→ the discovered backend origin), but seeded thumbnails were left as relative/api/presets/thumbnails/...and only worked in dev by accident (Vite proxy → :8000). They'd 404 whenever the backend isn't same-origin/on-8000 (packaged shell, alt port), with theonErrorgradient masking the failure. Fix: generalizedbackendAssetUrlSync/isLocalBackendAssetUrlto aBACKEND_ASSET_PREFIXESlist covering both/api/outputs/and/api/presets/thumbnails/;PresetCardnow resolvespreset.thumbnailthrough it. - Fixed — concurrency cap TOCTOU:
activeCountcan't see a new generation's nodes until they're in the store, so a rapid second click could pass the cap during theauthorGenerationClusterawait. Added a synchronouslaunchingRefcounter; the cap gates onactiveCount + launchingRef.current. (Residual: a sub-render-cycle staleness window remains ifactiveCountlags by a full unit with exact timing — bounded and harmless since the backend handles disjoint node sets; the dominant network-RTT window is closed.) - Fixed — backfill clobber: backfill matched seeded presets by name and overwrote any thumbnail that differed from the shipped URL — so a user's global preset that happened to share a seed name + a custom captured thumbnail would be silently overwritten every boot. Fix: only fill empty thumbnails (
if preset.get("thumbnail"): continue). Also naturally idempotent (post-fill it's non-empty → skipped, no version churn). - Deferred —
graphCompleteclears the global lock unconditionally (KNOWN LIMITATION):handleExecutionEvent'sgraphCompletecase doesset({ isExecuting: false })regardless of which run emitted it. SinceexecuteClusterConcurrentnever sets the lock, a concurrent Create generation finishing will now clear a lock that a canvas Run set — but only if the two overlap (start a canvas Run, switch to Create, generate). Consequence is an early lock release (a second canvas Run could overlap), not data corruption (backend runs are independent per disjoint node sets). A correct fix needs backend run-scoped events (run_idon eachExecutionEvent, frontend clears only the matching run) — that's a real change to the engine + event types + WS handling, out of scope for this polish pass. Flagged to the user as a follow-up rather than bolting on a fragile attribution-less counter.
- Fixed — thumbnail URL resolution (the important one): generated media render through
-
Gates: backend
887 passed; frontendtscclean,331vitest (42 files), lint (inline-style + slava-scope + eslint) clean, build clean. Node-def contracts untouched (no node-def changes in P4).
User asked to click a result image and see it fullscreen. Added a Lightbox component to the Create results gallery.
- Component:
Lightbox.tsxrenders intodocument.bodyviacreatePortal(so it sits above the canvas + every panel;bodycarriesapp-slava-restraint, so the scoped CSS still matches). Closes on Esc / backdrop click / X;←/→(and edge chevrons) step through the gallery's viewable items; a "n / N" counter; body scroll locked via alightbox-openclass (not an inline style — keeps thecheck:inline-stylesguard happy). Clicking the media__stagestops propagation so it doesn't close. - What's zoomable:
firstViewableMedia(node)increateGallery.ts(the single source for "what would the lightbox show") returns the completed node's Video (preferred) or Image/SVG, else null — mirroring OutputRenderer's order.ResultsGallerybuilds aviewable[]in card order so a clicked card maps to a lightbox index and the arrows step through exactly what's on screen; switching the Session/Canvas tab closes the lightbox (the viewable set changes). - Affordances (
ResultCard): images get a full-area transparent overlay button (zoom-incursor) so a click anywhere opens the lightbox; video gets only a corner expand button (not a full overlay) so the native video controls stay usable. The overlay sits atz-index:1; the actions bar was bumped toz-index:2so Download/Delete/etc. stay clickable over it. - Tests:
firstViewableMedia(6 cases incl. video-precedence, SVG-as-image, object{url}, not-complete → null);Lightbox(image/video render, Esc/backdrop/stage-click, arrow nav + prev hidden at index 0, scroll-lock on/off);ResultCard(image → overlay+btn, video → btn only, text/incomplete → neither). Frontend now347vitest (44 files); lint + build clean.
Closes the "Replicate real-time streaming (urls.stream)" capability gap. Text/LLM models on Replicate now stream token deltas live in the UI; image/video/audio/mesh models are unchanged (poll as before). Frontend: zero changes — the StreamDeltaEvent → _event_to_camel → streamDelta → node.data.streamingText pipeline already existed for the chat nodes; only the Replicate handler had to learn to consume the stream.
Decisions / non-obvious calls:
- Auto-detect, no user toggle. Replicate's own model is "the prediction response carries
urls.streamiff the model streams" (the legacystream: truerequest flag is deprecated). So the handler streams whenurls.streamis present AND anemitis wired, else polls. No new node, no new param, no node-def change → contract parity untouched. - A dedicated raw-text SSE consumer (
stream_execute_replicate), NOT the existingstream_execute. Replicateoutputevents carry RAW TEXT indata:(a token delta); Anthropic/OpenAI/OpenRouter carry JSON. Reusing the JSON path (json.loads(data)) would silently drop every non-JSON token. The new consumer concatenatesoutputdata verbatim (joining multi-data:-line events with\nper the SSE spec), raises onerror(surfacing JSONdetail), and stops ondone. The existing JSONstream_executeis untouched (chat-stream tests stay green). Regression-guarded by a test that feeds{not valid jsonand asserts it's taken verbatim. - Handler self-submits; extracted
poll_until_terminalfromasync_poll_execute. To inspecturls.streamyou must create the prediction first — but you can't double-submit. Soasync_poll_executewas split into submit + a publicpoll_until_terminal(client, config, task_id, …)(behavior-preserving; guarded by the existing 15 async-poll/Runway/Replicate tests). The handler now POSTs once, then either streams or callspoll_until_terminalon its own client. This rewrote the 5 old replicate tests that mockedasync_poll_execute(they encoded the old delegate-everything design) into httpx-level tests of the real submit→stream/poll flow. Note:patch("handlers.replicate_universal.httpx.AsyncClient")patches the sharedhttpxmodule singleton, so it also covers the runner's client — convenient for the non-stream tests. - Cancel propagation on the streaming branch.
CancelledErrormid-stream schedules a detachedPOST .../predictions/{id}/cancel(reusing_cancel_async_poll+schedule_detached_cancel) before re-raising, matching the polling path's upstream-cancel so a cancelled stream doesn't leak a running (billable) prediction. - Honest scope: this helps only Replicate language-model nodes — a minority of Nebula's Replicate usage. It's UX polish for text gen, not a broad capability. The SSE 30s idle reconnect (
:408keepalive /Last-Event-ID) is NOT implemented; long-idle generations could truncate — documented as a known limitation, deferrable since v1 targets normal-cadence text gen.
Verification: backend pytest 927 passed (was 918 + 9 new replicate/stream-consumer tests, 0 regressions). Live-verified against the real Replicate API (meta/meta-llama-3-8b-instruct, key loaded from settings.json, never printed): a 220-token generation produced 46 delta events over a 2.23s span, time-to-first-token 0.84s, spread across 38 distinct 50ms windows — genuinely incremental token streaming through the real handler, not a buffered burst. Accumulated text verified as a monotonic prefix; final output routed to the text port.
Closes the "Veo extension" half of gap #5. The veo-3 node (Google direct path, backend/handlers/veo.py) gained a video ("Extend Video") input + a source_uri output so a clip can be continued in-graph. Reference images ("ingredients to video") were implemented per the canonical docs, then REMOVED after live testing proved the public API doesn't support them on this key. This entry is mostly a record of how live verification overturned the docs research — twice.
What the docs said vs. what the live API does (key loaded from settings.json, never printed):
- Billing: metadata probe (models.list) only proved the key sees Veo. The first real generation confirmed the project has paid-tier billing — text→video succeeded (32s, 2.5 MB). Not a blocker after all.
- Reference images — docs WRONG twice. (1) The canonical example nests bytes under
image.inlineData; live API → 400 "inlineDataisn't supported by this model." Switched to thebytesBase64Encodedshape the first/last-frame fields already use. (2) That got past the shape error into 400 "Your use case is currently not supported" — on BOTHveo-3.1-generate-previewANDveo-3.1-fast-generate-preview. So "ingredients to video" is simply not enabled on the public Gemini API for this AI-Studio key, regardless of request shape. Decision: remove thereference_imagesport entirely rather than ship a port that always 400s (shipping-broken = worse than not-shipping). A code comment + the gemini skill document the exact shape to re-add if Google enables the use case. - Extension — docs' "biggest risk" materialized, but a working path exists. The realistic in-graph path (upload a local/downloaded clip via the Files API, pass as
video.uri) is rejected: 400 "Input video must be a video that was generated by VEO that has been processed." Veo only accepts the original generation'sfiles/...URI, not re-uploaded bytes — even bytes that Veo itself generated. The Files-API-upload path is dead; removed_upload_video_to_files/_resolve_video_bytes. Passing a base generation's ownvideo.uridirectly works (live-verified).
Design that makes extension usable in-graph: Nebula downloads + discards the Veo files/... URI today, so a downstream node had nothing to extend. Fix: handle_veo now also returns source_uri (the generation's files/... URI) as a second Video-typed output. Wire upstream veo-3.source_uri → downstream veo-3.video to continue a clip. The handler accepts only a files/... URI on the video input (else a ValueError tells the user to wire source_uri, not the downloaded video output). Guards: extension is veo-3.0/3.1 generate+fast only (not lite/veo-2.0), can't combine with image/last_frame, and resolution is force-overridden to 720p (the API locks extension to 720p). Source URI is valid ~2 days (already noted in the skill).
Decisions / non-obvious calls:
source_uriis typedVideo(notText) so the edge validates straight into theExtend Video(Video) input; the value it carries is the URI string, which the handler detects by thegenerativelanguage.googleapis.com/.../files/pattern.- The node now has two
Videooutputs (video= downloaded file for normal downstream use;source_uri= the extend handle). Labels disambiguate; wiring the wrong one fails with a clear message. - Reference-image guards/sets (
VEO_REFERENCE_MODELS,_image_to_inline_data) were added then removed in the same session — the git diff won't show them, but they're why thebytesBase64Encoded-vs-inlineDatalesson is recorded here.
Verification: backend pytest 935 passed (+8 veo tests, 0 regressions); check-node-contracts --check green for 123 node defs (node_definitions.json ↔ frontend nodeDefinitions.ts in sync; MODEL_REFERENCE.md regenerated). Live end-to-end through the SHIPPED handlers: handle_veo(base) returned a clip + source_uri; feeding that source_uri into handle_veo(extend) produced a continued 2.8 MB clip. Reference-image generation confirmed rejected on both 3.1 models (the reason it's not shipped). ~6 paid Veo generations total during verification.
A 16-agent adversarial review (3 dimensions → per-finding verify) surfaced 13 findings, 12 confirmed. Fixed the substantive ones; the trivial/by-design ones are documented. Final: 940 backend tests (+5), contract green (123), frontend tsc clean.
- Replicate SSE abnormal-close robustness (the one that mattered, medium). The consumer returned silently-truncated text as a success if the stream closed before
done(idle timeout / dropped connection), and swallowed a bareerrorevent (nodata:line). Rewrote the dispatch: a buffered event now flushes at every boundary (blank line, nextevent:line, or end-of-stream), a bareerrorraises, and a premature close (nodone) fails loud instead of reporting partial text. Emitted deltas still surface live before the failure. RED→GREEN with 5 new tests (multi-linedata:join,id:/:-comment skip, premature-close-raises, trailing-output-flush, bare-error-raises). - Replicate
task_idfail-fast (low). Self-submit usedsubmit_data.get("id")→"None"on a malformed 2xx submit (the oldasync_poll_executepath raised via_get_nested). Restored fail-fast with an explicit guard. - Cancel-test warning (low).
_cancel_async_pollwas auto-mocked as AsyncMock, so the test's lambda built an un-awaited coroutine (RuntimeWarning). Patched it asMagicMock— warning gone (suite warnings 3→2; the remaining 2 are pre-existing intest_async_poll_runner). - Veo
source_uriordering guard (low). The frontend video preview picks the firstVideo-typed output by insertion order; added a test pinningvideobeforesource_uriso a refactor can't silently make the preview select the auth-gated API URL. - Doc sync —
veo.mdskill topic file (medium). It still showed reference images as usable and extension wiring as "feed via a Video port." Added a⚠️ "NOT EXPOSED IN NEBULA" caveat over the reference-images section and corrected the extension note tosource_uri → Extend Video. Synced both copies (.claude/+.agents/). - By-design, documented (not changed):
source_uriisVideo-typed but carries an auth-gatedfiles/...URI. It must beVideoso the edge validates into theExtend Video(Video) input; wiring it into a non-Veo Video sink fails confusingly (401/file-not-found). Mitigated by the port label ("Source URI") +_note, and the ordering guard keeps the node's own preview on the playablevideooutput. A dedicatedVideoRefport subtype (frontendportCompatibility+ colors) is the clean long-term fix — deferred as it's a deliberate mis-wire of a clearly-labeled port, not a main-path bug. - Deferred (low, documented limitation): no SSE
Last-Event-IDreconnect for Replicate's 30 s idle gap — a long idle now fails loud (above) rather than truncating silently, which is the safe behavior; true reconnect is a follow-up.
- Goal selection: the next autonomous pass is the documented Create/Canvas concurrency race: a
graphCompletefromexecuteClusterConcurrentcan currently clear the global CanvasisExecutinglock and close the wrong run-history record. Finishing the remaining provider audit would require paid calls and additional credentials, so it is not the next fully autonomous goal without a spend decision. - Contract: every frontend-started graph, node, concurrent Create, and Cinema-shot execution gets a UUID
runId. The backend accepts it on the request and includes it on all execution WebSocket events. Events without arunIdremain supported for CLI/legacy callers. - Async propagation: handler-emitted progress/stream events bypass the engine's direct
emitcallback, so tagging only the engine's own event constructors would be incomplete. The backend uses a task-localContextVarwhile an execution is active and resolves the currentrunIdduring WebSocket serialization; explicit validation events carry it directly because they occur beforeexecute_graphstarts. - Frontend ownership: node state updates still apply for all events, but only a lifecycle event whose
runIdmatches the active global Canvas run may clearisExecutingor close its history record. Concurrent Create/Cinema completions can still notify and clean their own error bookkeeping without touching the global lock. MissingrunIdpreserves the prior single-run behavior for backwards compatibility. - Verification: backend
1167 passedwith the same two existing AsyncMock warnings; frontend397 passed, lint, TypeScript, and production build passed. The build retains its existinglottie-webdirect-eval and large-chunk warnings. No paid provider call ran.
- Goal selection: Phase 2 provider auditing remains the repository's explicit next milestone, but the complete remaining matrix includes paid live calls. Hunyuan3D is a bounded two-node FAL slice whose live schemas, request bodies, and output contract can be fully reverified without inference spend.
- Canonical finding: the live FAL v3 schemas still match Nebula's endpoint, port, parameter, and output mappings. They expose
seedin the response only, not as an accepted input. The older provider note and image-to-3D skill incorrectly described it as a missing reproducibility knob; both skill mirrors and the audit note are corrected. - Runtime hardening: the fixed Hunyuan wrappers now route through an isolated
GraphNodecopy instead of mutating persisted params withendpoint_id. Text-to-3D also rejects prompts over FAL's documented 1024-character maximum before queue submission. - Parity scope: add one gold exemplar covering both nodes plus separate text and four-view image golden request fixtures. Fixture capture runs through the real registry wrappers and FAL universal body builder; no provider request or paid generation is made.
- One lifecycle for both editor surfaces: final exports run as cancellable, in-memory jobs with
POSTto start,GET /api/render-jobs/{id}to poll, andDELETEto cancel. This gives the UI truthful progress/cancel/download behavior without pretending the existing fire-and-forget graph execution endpoint can cancel a child encoder. - Video Editor formats: the final renderer reuses
_build_filter_complexfor MP4/H.264, MOV/ProRes 422 HQ, WebM/VP9, and animated GIF. Resolution presets are Source, 1080p, 720p, and 480p; quality presets map to CRF for H.264/VP9 and are intentionally disabled for ProRes/GIF. GIF drops audio at filter construction so it cannot leave an unconnected audio output. - Remotion parity contract: a dedicated Node worker bundles the same
RemotionCompositionthe Player imports. One unchangedinputProps = {manifest}object goes to bothselectComposition()andrenderMedia(), and a sharedcompositionDurationInFrames()function controls Player and renderer duration. Runningremotion-nodenow produces a real H.264 MP4 instead of echoing a null video. - Cancellation: ffmpeg and Remotion subprocess wrappers terminate their child process and delete partial output on
CancelledError. The Remotion worker also connects SIGTERM/SIGINT tomakeCancelSignal(). - Dependency/security decision: enabling server rendering required
@remotion/rendererand@remotion/bundler. The original 4.0.458 family exposed currentws/bundler advisories; 4.0.464 cleared the first range but not later advisories. The full Remotion family was aligned to exact 4.0.479, published 2026-06-17 and therefore outside the 14-day quarantine.npm auditthen reported zero findings. - License boundary: official Remotion material checked 2026-07-23 says individuals and companies up to three people may use the free license; collaborations/companies of four or more require a Company License. Nebula documents but does not enforce or procure that license.
- Empirical proof: a real text-manifest render produced a 5.056 s, 1280×720, 30 fps H.264/AAC MP4. A synthetic one-second source produced valid 854×480 outputs in H.264/AAC MP4, ProRes/PCM MOV, VP9/Opus WebM, and GIF. No provider API or paid call ran.
- Graph persistence fix found during UI QA:
video-editandremotion-nodepreviously keptclips/source metadata andmanifestunder undeclared params, so the standard POST/PUT graph API rejected exact editor state. Those runtime fields are now declared as hidden params, following the existing Cinemascenepattern, with create/update/reload regression tests. - Live app proof on Remotion 4.0.479: drove the real Vite app against the local backend using the already-generated three-second Gemini clip. A 1080p WebM completed with a download action and ffprobe confirmed VP9/Opus at 1920×1080. A repeated 60.2 s local timeline showed 1% progress, cancelled through the UI, and left no partial file. A persisted one-layer text manifest then rendered through the UI to a 5.056 s H.264/AAC MP4 at 1280×720 and 30 fps; a sampled frame visibly contained the exact
NEBULA EXPORT QAmanifest text. Temporary nodes, outputs, and the test edge were removed afterward, restoring the original eight-node canvas. - Review-caught lifecycle fix: the first popover toggle reset its local job reference whenever it closed. Closing during a render could therefore orphan a still-running encoder. Closing now preserves job ownership and polling; completed jobs expose an explicit New export/New render reset action. A toolbar regression test pins the running-close and completed-reset behavior.
- Full validation: backend
1197 passedwith the same two existing AsyncMock warnings; frontend402 passed, lint, TypeScript, production build, 142-definition node contracts, generated reference,git diff --check, andnpm auditpassed. The existing build warnings remain forlottie-webdirectevaland large chunks. - Non-gating harness finding:
scripts/check-utility-nodes.mjsrepeatedly timed out after the backend logged its unrelated pure-utility graph as completed. This slice does not touch the execution-event/store paths that harness exercises, and the backend/frontend unit suites covering those paths pass. The failure remains an explicit follow-up for the run-history slice rather than an export change hidden in this PR.
- Persistence boundary: the newest 100 Canvas graph, single-node, and selection runs are stored in browser
localStorageunder a versioned envelope. Concurrent Create generations and Cinema shot jobs retain their existing independent lifecycle and are not forced into the single global Canvas history lock. - Exact replay contract: every history record stores the JSON-cloned, deeply frozen request graph after network-boundary normalization. This matters for
video-edit, whose derived clip speed is part of the backend request. Single-node records additionally retaintargetNodeId; replay calls/api/execute-nodewith that original target, while graph/selection records call/api/executewith their original snapshot. Replaying never replaces or rewinds the live canvas. - Reload recovery: a page reload cannot reconnect to an in-flight backend task, so persisted
runningrecords recover ascancelled. Malformed individual records are dropped and the remainder is rewritten; invalid JSON or an invalid storage envelope is removed. Storage access, quota, and privacy-mode errors are non-fatal. - Run isolation: replay always receives a new UUID and records
sourceRunIdplusreplayAction. Scoped stale completion events from the source run cannot close the new replay. Matching node IDs on the current canvas still show execution state, but the request payload comes exclusively from saved data. - Actions: complete/cancelled rows show
Rerun, failed rows showRetry failed, and the existing header clear action now removes both in-memory and persisted history. Replay actions are disabled while another global Canvas run owns the execution lock. - Carried utility harness timeout resolved: the browser smoke graph still used nonexistent
/tmp/image-a.pngand/tmp/image-b.pngplaceholders. The newer image-input source guard correctly failed those nodes, leaving downstream image utilities idle while the harness only reported a generic timeout. The harness now uses tracked image/video fixtures, prints node state on timeout, fits all seeded nodes into view, and executes throughgraphStore.executeGraph()instead of bypassing the store with a raw fetch. That live-checks run-ID correlation plus history snapshot open/close, mutates the canvas and reruns the saved 17-node/14-edge snapshot, then reloads the page and confirms both completed records, replay lineage, and originalAlphaparams persist. Nested array outputs retain backend filesystem refs while top-level image outputs are rewritten to/api/outputs/..., so assertions compare output identities by filename across that intentional representation boundary.