Skip to content

Feat/3d compositor recipe - #27

Merged
IAnMove merged 116 commits into
mainfrom
feat/3d-compositor-recipe
Aug 31, 2026
Merged

Feat/3d compositor recipe#27
IAnMove merged 116 commits into
mainfrom
feat/3d-compositor-recipe

Conversation

@IAnMove

@IAnMove IAnMove commented Aug 31, 2026

Copy link
Copy Markdown
Owner

Note

Medium Risk
Touches LLM routing, API key resolution, and large _launch_runtime.py surface area including new persisted Wizard state and job metadata that stores prompts; alternative-song remount and editor export paths add FFmpeg concurrency behavior worth regression testing.

Overview
This PR expands the Ask to the Wizard / 3D compositor line of work with heavy backend and ops changes, plus two large agent handoff/roadmap documents (RHYTHM_AGENT_HANDOFF.md, WIZARD_AUTOMATION_ROADMAP.md) and ignoring local comunicaciones/.

LLM and production settings are centralized through provider_profile: production profile gains 3D defaults and text base_url, routing now covers Grok, DeepSeek, Ollama, and split MiniMax keys (LLM/image/music). Comic/Story writing overrides and Director LLM loading use the shared resolver instead of duplicated logic in _launch_runtime.py and director_pipeline.py.

Activity / queue UX keeps the authored prompt (and optional _initiator) in canonical job details for auditability, with bounded persistence. Default activity titles switch to HocusPocus.

Wizard durability: new CAS-backed GET/PUT /api/v1/wizard/conversations and GET/PUT /api/v1/wizard/workflows per workspace (revision conflicts return 409).

Videoclips: alternative_songs service and REST routes let users attach workspace audio outputs to an assembled mix and remount via FFmpeg only (reuse shot order, random extras when the track is longer), registered on the canonical video-editor job lane.

Video Editor export accepts an optional soundtrack (resolve, probe, mix in render_project); /api/v1/video-editor/probe-audio probes audio without video. 3D scene save passes workspace so saves land in the active workspace.

Song authoring (/api/v1/llm/write-song, Story music-video contracts) pushes STYLE/lyrics in the user-selected language; character auto-describe can use internal LLM when MiniMax is not configured.

Reviewed by Cursor Bugbot for commit 4300fd2. Configure here.

IAnMove added 30 commits August 29, 2026 14:39
Face Rig now saves the mouth box when you stop dragging, keeps open eyes
at rest, and Preview re-reads the workspace library so a later placement
replaces the pose that was baked when the scene was created.
The internal audit tab was visible in every session. Gate it behind a
local Developer mode toggle in Settings so normal use does not show it.
Add a single capability catalog so the HocusPocus wizard can open Story
and Series Lab rooms, create a filled story draft, and create a filled
series episode through real APIs instead of inventing UI clicks.
Give the HocusPocus wizard canonical queue tools so it can open Activity,
report why a job is waiting, and cancel or resume a specific task only
after an explicit confirmed request.
Add prepare_image so the HocusPocus wizard can open Studio Image, fill a
validated form, and start_generation after an explicit image request.
Teach the HocusPocus wizard that Audios is a gallery, add prepare_audio
and queue_sfx_pack for MMAudio one-shots, and stop requiring unused
JSON fields that were collapsing real requests.
Accept collapsed tool keys like queuesfxpack/sfxclips, recover trailing
junk after the JSON object, and render Markdown results instead of the
raw payload so an SFX pack actually reaches Studio Audio.
Add prepare_3d so the HocusPocus wizard opens Hunyuan3D, fills a
text-to-mesh prompt, and start_generation calls the 3D API instead of
the video queue.
Add a canonical capability catalog, internal Story/Series navigation, and persisted filled-draft actions so direct creative requests mutate the real workspace instead of only replying in chat.
Create fully populated comic examples with Director structure and continuity, expose a Generate comic action in the Comics toolbar, and share resumable sequential panel generation between the editor and Wizard. Include the lazy/maximizable Wizard UI and rotating example bank required by the flow.
Add a confirmed page/panel action that targets the shared comic artwork pipeline, replaces only the selected image, preserves other panels, and avoids recovering stale artwork during explicit regeneration.
Expose recent workspace image outputs to the Wizard and attach only backend-verified names as I2V start frames or compatible subject/style references, enforcing model capabilities and reference limits.
Expose the active model's LoRA inventory, validate exact compatible filenames after model selection, apply bounded weights across guidance phases, and support replacing or clearing the current LoRA set.
Add a confirmed canonical retry action that targets exact resumable task IDs or an explicitly requested latest failure, rejects ambiguous retries, and opens Activity with the backend result.
Add verified select/create workspace actions and preserve the in-flight Wizard conversation across active-workspace changes so results remain visible in the destination.
Add a canonical update_story action that patches the active or exactly named story, preserves visual references, versions changed sections, invalidates stale approvals, and saves through the workspace story library.
Connect a confirmed generate_story_section action to the recoverable Story Lab writer, persist its job and proposal with the source project, and refresh the review UI without applying canon changes automatically.
Add a confirmed apply_story_proposal action that consumes the source story's recoverable draft, preserves visual identities, remaps relationships, invalidates changed approvals, and commits the merged canon with library CAS.
Add a confirmed approve_story_section action that mirrors Story Lab completeness and identity gates, seals the exact current section version, and persists approval through canonical library CAS.
Add a confirmed Story-to-Comic staging action that uses the official adaptation builder, records a recoverable production before replacing the comic draft, and opens Comic Director without rendering artwork.
Add an update_series_episode action that resolves unambiguous series and episode targets, preserves downstream production state, saves through the canonical Series store, and verifies every requested field.
Add a confirmed generate_series_plan action that starts the canonical recoverable writer, validates episode prerequisites and job ownership, and hands live progress and review back to Episode room.
Add a confirmed apply_series_plan action that resolves a completed episode-owned recovery job, verifies its proposal, applies through the canonical endpoint, reloads Series Lab, and clears stale review UI.
Add a confirmed render_series_shots action that validates eligible unapproved shots and lip-sync consent, starts the canonical recoverable render with saved settings, and hands live progress to Render & Review.
@github-actions

github-actions Bot commented Aug 31, 2026

Copy link
Copy Markdown

PR Review — Loreframe Studio

Risk: medium
Scope: 174 file(s); +30845/-881; GitHub workflows, Pinokio updater, React UI, backend services, docs, repo scripts / CI

Automated review from scripts/analyze_pr.py. This is a heuristic pass (no LLM) so humans still own the merge decision.

Findings

  • medium — Dangerous dynamic execution (ui/src/lib/characterKit.ts)
    ui/src/lib/characterKit.ts adds exec(). Confirm the input is trusted and sandboxed.
  • medium — Large pull request
    30845 additions / 881 deletions. Reviewers will have an easier time with smaller, focused PRs.
  • medium — Very large file change (RHYTHM_AGENT_HANDOFF.md)
    RHYTHM_AGENT_HANDOFF.md adds 1370 lines. Consider splitting the PR.
  • medium — Very large file change (WIZARD_AUTOMATION_ROADMAP.md)
    WIZARD_AUTOMATION_ROADMAP.md adds 504 lines. Consider splitting the PR.
  • medium — Very large file change (app/_launch_runtime.py)
    app/_launch_runtime.py adds 787 lines. Consider splitting the PR.
  • medium — Very large file change (planmejora.md)
    planmejora.md adds 402 lines. Consider splitting the PR.
  • medium — Very large file change (scripts/nightly_wizard_report.mjs)
    scripts/nightly_wizard_report.mjs adds 646 lines. Consider splitting the PR.
  • medium — Very large file change (ui/src/components/Sidebar/SceneAnimatorPanel.tsx)
    ui/src/components/Sidebar/SceneAnimatorPanel.tsx adds 468 lines. Consider splitting the PR.
  • medium — Very large file change (ui/src/features/agent/AgentAssistantPanel.tsx)
    ui/src/features/agent/AgentAssistantPanel.tsx adds 578 lines. Consider splitting the PR.
  • medium — Very large file change (ui/src/features/agent/agentActions.ts)
    ui/src/features/agent/agentActions.ts adds 3249 lines. Consider splitting the PR.
  • medium — Very large file change (ui/src/features/agent/agentExamples.ts)
    ui/src/features/agent/agentExamples.ts adds 593 lines. Consider splitting the PR.
  • medium — Very large file change (ui/src/features/agent/applicationAdapters.ts)
    ui/src/features/agent/applicationAdapters.ts adds 415 lines. Consider splitting the PR.
  • medium — Very large file change (ui/src/features/agent/capabilityRegistry.ts)
    ui/src/features/agent/capabilityRegistry.ts adds 1075 lines. Consider splitting the PR.
  • medium — Very large file change (ui/src/features/agent/commandContract.ts)
    ui/src/features/agent/commandContract.ts adds 661 lines. Consider splitting the PR.
  • medium — Very large file change (ui/src/features/agent/editorAuxCapabilities.ts)
    ui/src/features/agent/editorAuxCapabilities.ts adds 415 lines. Consider splitting the PR.
  • medium — Very large file change (ui/src/features/agent/labActions.ts)
    ui/src/features/agent/labActions.ts adds 2662 lines. Consider splitting the PR.
  • medium — Very large file change (ui/src/features/agent/navigationQueueCapabilities.ts)
    ui/src/features/agent/navigationQueueCapabilities.ts adds 413 lines. Consider splitting the PR.
  • medium — Very large file change (ui/src/features/agent/studioCapabilities.ts)
    ui/src/features/agent/studioCapabilities.ts adds 401 lines. Consider splitting the PR.
  • medium — Very large file change (ui/src/features/agent/wizardContext.ts)
    ui/src/features/agent/wizardContext.ts adds 1410 lines. Consider splitting the PR.
  • medium — Very large file change (ui/src/features/agent/wizardWorkflowRuntime.ts)
    ui/src/features/agent/wizardWorkflowRuntime.ts adds 883 lines. Consider splitting the PR.
  • medium — Very large file change (ui/src/features/comics/generateArtwork.ts)
    ui/src/features/comics/generateArtwork.ts adds 411 lines. Consider splitting the PR.
  • medium — Very large file change (ui/tests/agentActions.test.mjs)
    ui/tests/agentActions.test.mjs adds 1330 lines. Consider splitting the PR.
  • medium — Very large file change (ui/tests/agentContract.test.mjs)
    ui/tests/agentContract.test.mjs adds 626 lines. Consider splitting the PR.
  • low — UI changed — rebuild before merge
    Run cd ui && npm run build (CI already does this). Pinokio Update rebuilds for end users; keep ui/dist untracked.

Changed files

  • added: RHYTHM_AGENT_HANDOFF.md, WIZARD_AUTOMATION_ROADMAP.md, app/services/alternative_songs.py, app/services/hi3d_service.py, app/services/meshy_3d_service.py, app/services/provider_profile.py, app/services/wizard_conversations.py, app/services/wizard_workflows.py, artifacts/nightly/.gitignore, docs/development/WIZARD_ACTION_RUNNER.md, docs/development/WIZARD_NIGHTLY.md, docs/development/WIZARD_RHYTHMIC_VIDEO3D.md, docs/development/WIZARD_WORKFLOW_RUNTIME.md, planmejora.md, scripts/nightly_baseline.json, scripts/nightly_wizard_report.mjs, scripts/nightly_wizard_smoke.mjs, scripts/nightly_wizard_validation.ps1, scripts/nightly_wizard_validation.sh, scripts/tests/nightly_wizard_report.test.mjs
    … and 75 more
  • modified: .github/workflows/ci.yml, .gitignore, README.md, app/_launch_runtime.py, app/defaults/minimax_h3_legacy.json, app/routers/canonical_tasks.py, app/services/director_pipeline.py, app/services/hunyuan3d/worker.py, app/services/llm_guides/music/song_writer.md, app/services/llm_guides/music/song_writer_instrumental.md, app/services/llm_guides/music/song_writer_minimax.md, app/services/llm_service.py, app/services/minimax_music_service.py, app/services/model3d_service.py, app/services/series_library.py, app/services/video_editor.py, docs/3d-video-compositor/HOWUSEIT.md, scripts/check_brand_contract.py, tests/test_activity_generation_details.py, tests/test_character_sheet.py
    … and 59 more

CONTRIBUTING checklist

  • python scripts/verify_clean_repo.py
  • python -m compileall -q app/services app/launch.py scripts
  • cd ui && npm run build if the UI changed
  • No weights, CivitAI sidecars, or generated guides
  • Stays local-first (no required accounts / telemetry)

Posted by the repo PR review workflow. Re-runs on each push to the PR.

@IAnMove

IAnMove commented Aug 31, 2026

Copy link
Copy Markdown
Owner Author

cursor review

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Peek hides the opening beat
    • Peek no longer writes a hidden t=0 bookend when a cue already occupies that timestamp, so an opening beat stays visible instead of being overwritten.

Create PR

Or push these changes by commenting:

@cursor push e8fd646979
Preview (e8fd646979)
diff --git a/ui/src/lib/sceneRhythm.ts b/ui/src/lib/sceneRhythm.ts
--- a/ui/src/lib/sceneRhythm.ts
+++ b/ui/src/lib/sceneRhythm.ts
@@ -189,7 +189,9 @@
       const direction = point.x <= 50 ? -1 : 1
       return { ...point, x: point.x + direction * (8 + intensity * 18), opacity: 0 }
     }
-    addFrame(frames, rhythmFrame(layer, 0, 'peek-scene-start', hide, 'hold'))
+    if (!frames.has(roundedTime(0))) {
+      addFrame(frames, rhythmFrame(layer, 0, 'peek-scene-start', hide, 'hold'))
+    }
     addFrame(frames, rhythmFrame(layer, duration, 'peek-scene-end', hide, 'hold'))
   }

You can send follow-ups to the cloud agent here.

Comment thread ui/src/lib/sceneRhythm.ts
@IAnMove

IAnMove commented Aug 31, 2026

Copy link
Copy Markdown
Owner Author

cursor review

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 2 potential issues.

Autofix Details

Bugbot Autofix prepared fixes for both issues found in the latest run.

  • ✅ Fixed: Remount stuck after restart
    • Sidecar mounting status is now cleared on every alternative-song API load when the in-memory remount job is missing or already terminal, so remount and delete work after a restart.
  • ✅ Fixed: Remount drops assembled fallback
    • The remount worker now uses the same resolve_mount_sources fallback as the API, so a queued remount loops the assembled videoclip when the original shots are gone.

Create PR

Or push these changes by commenting:

@cursor push bd92f4c944
Preview (bd92f4c944)
diff --git a/app/_launch_runtime.py b/app/_launch_runtime.py
--- a/app/_launch_runtime.py
+++ b/app/_launch_runtime.py
@@ -34522,9 +34522,16 @@
     return {"source": "none", "params": None}
 
 
+def _alternative_song_job_alive(job_id: str) -> bool:
+    job = _video_editor_job_snapshot(job_id)
+    if job is None:
+        return False
+    return str(job.get("status") or "") not in _VIDEO_EDITOR_TERMINAL
+
+
 def _alternative_song_parent(name: str, workspace: str | None = None) -> tuple[str, str, str, dict]:
     """Return workspace dir, video path, basename and sidecar for one mix."""
-    from services.alternative_songs import load_sidecar
+    from services.alternative_songs import load_sidecar, release_stale_mounts, save_sidecar
 
     out_dir = _workspace_dir(workspace)
     filepath = _safe_join(out_dir, name)
@@ -34532,17 +34539,17 @@
         raise HTTPException(status_code=404, detail="Videoclip not found")
     if os.path.splitext(filepath)[1].lower() not in {".mp4", ".mkv", ".webm", ".mov"}:
         raise HTTPException(status_code=400, detail="Alternative songs are for video mixes")
-    return out_dir, filepath, os.path.basename(filepath), load_sidecar(filepath)
+    sidecar = load_sidecar(filepath)
+    if release_stale_mounts(sidecar, job_is_alive=_alternative_song_job_alive):
+        save_sidecar(filepath, sidecar)
+    return out_dir, filepath, os.path.basename(filepath), sidecar
 
 
 def _alternative_song_sources(out_dir: str, video_name: str, sidecar: dict) -> list[dict]:
-    from services.alternative_songs import resolve_existing_files, source_clip_names
+    from services.alternative_songs import resolve_mount_sources
 
-    names = source_clip_names(sidecar, video_name)
-    sources = resolve_existing_files(names, out_dir)
+    sources = resolve_mount_sources(sidecar, video_name, out_dir)
     if not sources:
-        sources = resolve_existing_files([video_name], out_dir)
-    if not sources:
         raise HTTPException(
             status_code=400,
             detail="This videoclip has no reusable shots on disk to remount",
@@ -34567,9 +34574,8 @@
         load_sidecar,
         plan_timeline,
         remount_clips,
+        resolve_mount_sources,
         save_sidecar,
-        source_clip_names,
-        resolve_existing_files,
         write_mounted_sidecar,
     )
 
@@ -34602,7 +34608,7 @@
             acquired_resources=[],
         )
         sidecar = load_sidecar(video_path)
-        sources = resolve_existing_files(source_clip_names(sidecar, video_name), out_dir)
+        sources = resolve_mount_sources(sidecar, video_name, out_dir)
         song = find_song(sidecar, song_id=song_id)
         if song is None:
             raise ValueError("The alternative song disappeared before remount")

diff --git a/app/services/alternative_songs.py b/app/services/alternative_songs.py
--- a/app/services/alternative_songs.py
+++ b/app/services/alternative_songs.py
@@ -109,6 +109,42 @@
     return resolved
 
 
+def resolve_mount_sources(
+    sidecar: dict[str, Any],
+    assembled_name: str,
+    out_dir: str,
+) -> list[dict[str, Any]]:
+    """Resolve authored shots, then fall back to the assembled videoclip."""
+    names = source_clip_names(sidecar, assembled_name)
+    sources = resolve_existing_files(names, out_dir)
+    if not sources:
+        assembled = os.path.basename(assembled_name).strip()
+        if assembled:
+            sources = resolve_existing_files([assembled], out_dir)
+    return sources
+
+
+def release_stale_mounts(
+    sidecar: dict[str, Any],
+    *,
+    job_is_alive: Callable[[str], bool],
+) -> bool:
+    """Clear ``mounting`` when the in-memory remount job is gone (e.g. restart)."""
+    changed = False
+    for record in _song_list(sidecar):
+        if not isinstance(record, dict):
+            continue
+        if str(record.get("status") or "") != "mounting":
+            continue
+        job_id = str(record.get("job_id") or "").strip()
+        if job_id and job_is_alive(job_id):
+            continue
+        record["status"] = "mounted" if record.get("mounted_output") else "attached"
+        record["job_id"] = None
+        changed = True
+    return changed
+
+
 def plan_timeline(
     sources: list[dict[str, Any]],
     target_seconds: float,

diff --git a/tests/test_alternative_songs.py b/tests/test_alternative_songs.py
--- a/tests/test_alternative_songs.py
+++ b/tests/test_alternative_songs.py
@@ -4,6 +4,7 @@
 import tempfile
 import unittest
 from pathlib import Path
+from unittest.mock import patch
 
 _APP_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "app"))
 if _APP_DIR not in sys.path:
@@ -15,7 +16,9 @@
     load_sidecar,
     plan_timeline,
     public_song,
+    release_stale_mounts,
     remove_song,
+    resolve_mount_sources,
     save_sidecar,
     source_clip_names,
     unique_mounted_name,
@@ -75,6 +78,39 @@
         )
         self.assertEqual(source_clip_names({"params": {}}, "final_mv.mp4"), ["final_mv.mp4"])
 
+    def test_resolve_mount_sources_falls_back_to_assembled_when_shots_are_gone(self):
+        sidecar = {"params": {"source_clips": ["shot_a.mp4", "shot_b.mp4"]}}
+        with tempfile.TemporaryDirectory() as tmp:
+            Path(os.path.join(tmp, "final_mv.mp4")).write_bytes(b"mix")
+            with patch("services.alternative_songs.probe_duration_seconds", return_value=3.0):
+                sources = resolve_mount_sources(sidecar, "final_mv.mp4", tmp)
+        self.assertEqual([item["name"] for item in sources], ["final_mv.mp4"])
+
+    def test_release_stale_mounts_unlocks_dead_jobs(self):
+        sidecar = {
+            "params": {
+                "alternative_songs": [
+                    {"id": "song-dead", "status": "mounting", "job_id": "gone", "mounted_output": None},
+                    {"id": "song-prev", "status": "mounting", "job_id": "gone-too", "mounted_output": "prev.mp4"},
+                    {"id": "song-live", "status": "mounting", "job_id": "alive", "mounted_output": None},
+                    {"id": "song-ok", "status": "mounted", "job_id": None, "mounted_output": "ok.mp4"},
+                ]
+            }
+        }
+        changed = release_stale_mounts(sidecar, job_is_alive=lambda job_id: job_id == "alive")
+        self.assertTrue(changed)
+        songs = {item["id"]: item for item in sidecar["params"]["alternative_songs"]}
+        self.assertEqual(songs["song-dead"]["status"], "attached")
+        self.assertIsNone(songs["song-dead"]["job_id"])
+        self.assertEqual(songs["song-prev"]["status"], "mounted")
+        self.assertIsNone(songs["song-prev"]["job_id"])
+        self.assertEqual(songs["song-live"]["status"], "mounting")
+        self.assertEqual(songs["song-ok"]["status"], "mounted")
+        with self.assertRaises(ValueError):
+            remove_song(sidecar, "song-live")
+        removed = remove_song(sidecar, "song-dead")
+        self.assertEqual(removed["id"], "song-dead")
+
     def test_attach_is_idempotent_per_audio_name(self):
         sidecar = {"params": {}}
         first = attach_song(sidecar, audio_name="en.mp3", duration_seconds=12.5)

You can send follow-ups to the cloud agent here.

Comment thread app/_launch_runtime.py
Comment thread app/_launch_runtime.py
The npm test glob skipped tests/*.test.ts, so storyMusicModel,
galleryListQuery and auditDevClipboard never ran in CI.
Host toLocaleString() used en-US grouping in CI and es-ES in Lab,
so the recovery card failed its unit test on this machine.
Playwright builds a fresh dist, serves Vite preview on 4173, and
intercepts /api with page.route so Story Lab mounts without the live Lab.
The ui-e2e GitHub job is kept local until a token with workflow scope
can update ci.yml.
@IAnMove

IAnMove commented Aug 31, 2026

Copy link
Copy Markdown
Owner Author

cursor review

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit 4300fd2. Configure here.

IAnMove and others added 6 commits September 1, 2026 00:25
addInitScript runs before the document body exists, so a synchronous
getElementById('root') never found the mount node. Wait for #root (or
DOMContentLoaded) before inserting the placeholder that keeps the 10s
index.html watchdog from replacing the document on a slow first load.

Co-authored-by: THEINAOG <IAnMove@users.noreply.github.com>
Serializing a helper function through addInitScript pulled in the test
runner's __name transform, and observing documentElement threw because
that node does not exist yet. Load a path-based script that waits for
#root on the document itself so the 10s watchdog stays inert.

Co-authored-by: THEINAOG <IAnMove@users.noreply.github.com>
Playwright resolved ./bootWatchdogPlaceholder to the classic page
script, which has no exports. Give the path helper its own filename.

Co-authored-by: THEINAOG <IAnMove@users.noreply.github.com>
@IAnMove
IAnMove merged commit d4213a3 into main Aug 31, 2026
3 of 4 checks passed
@IAnMove
IAnMove deleted the feat/3d-compositor-recipe branch September 5, 2026 11:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants