Feat/3d compositor recipe - #27
Conversation
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.
…cipe # Conflicts: # ui/src/features/video-editor/editorDraft.ts
…cipe # Conflicts: # README.md
PR Review — Loreframe StudioRisk: medium Automated review from Findings
Changed files
CONTRIBUTING checklist
Posted by the repo PR review workflow. Re-runs on each push to the PR. |
|
cursor review |
There was a problem hiding this comment.
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.
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.
The peek bookend hid the layer at time 0 after cues were baked, so addFrame overwrote an opening-beat peek with a hidden frame.
|
cursor review |
There was a problem hiding this comment.
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.
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.
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.
|
cursor review |
There was a problem hiding this comment.
✅ 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.
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>
Test/create e2e test

Note
Medium Risk
Touches LLM routing, API key resolution, and large
_launch_runtime.pysurface 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 localcomunicaciones/.LLM and production settings are centralized through
provider_profile: production profile gains 3D defaults and textbase_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.pyanddirector_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/conversationsandGET/PUT /api/v1/wizard/workflowsper workspace (revision conflicts return 409).Videoclips:
alternative_songsservice 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-audioprobes 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.