diff --git a/.gitignore b/.gitignore index dee8a06d..1216d4b5 100644 --- a/.gitignore +++ b/.gitignore @@ -64,6 +64,8 @@ app/gradio_outputs/ app/storage/ app/samples/ app/settings/ +# Downloaded style references, source media, manifests and lightweight previews. +app/style_library/ app/icons/lora_previews/ app/uploads/ app/wgp_config.json diff --git a/README.md b/README.md index 4c544f21..abd092bc 100644 --- a/README.md +++ b/README.md @@ -120,6 +120,7 @@ For a screenshot-led, end-to-end walkthrough, see **[Maestro X / Experimental: S - Character cards combine role, desire, need, flaw, arc, dialogue voice, wardrobe, visual invariants, negative prompts, multiple references, and a selected primary identity image. - Export/import a `.storypack` with the editable JSON and available visual assets. Each workspace has a multi-story autosaved library; generated plans and local concept jobs can resume from durable checkpoints after interruption. - **Productions** offers a review-first hand-off and a complete one-click generation for both media. Comic opens **Director → Comic** and creates a self-contained chapter rather than retelling the master plot; four pages remain the quick-test default, while page count and panels per page are configurable up to the Director limits. Short Film opens **Director → Short Film → Story** with an editable target duration and independently selectable image and video models, and inherits the Story project's selected writing provider instead of silently falling back to the global LLM. Its shot frames can use a local Maestro image model or the external MiniMax Image-01 API; the latter does not consume local VRAM and is distinct from the local MiniMax H3 video runtime. Both productions receive the full editable canon, structured cast, locations and labelled visual references. Character images remain attached through planning and MiniMax `image-01` uses the visually prioritised character as its single supported identity reference per request. Adaptation history preserves the selected models when reopening the staged target, or can restore its exact source as a new editable copy. +- **Tráiler cinematográfico** is a standalone Story Lab project type beside **Videoclip**, so movie trailers never require a song. Its four-stage planner creates the concept, protagonists, world and a 6–12-beat trailer arc, then opens the dedicated 15–180 second Trailer Creator. It exposes theatrical, teaser and character formats; narration, dialogue or visual-only storytelling; spoiler and intensity controls; optional minimal title cards; and an editable six-part timed arc from cold open to unresolved final hook. Visual generation can create start frames, route approved references directly through H3 Ref2VA, or run as pure text-to-video without generating or sending any image. A trailer can be reviewed in Director or generated as a recoverable ordered pipeline, then replayed, regenerated clip-by-clip and joined from Story Lab's Assembly view. ### 💬 Comic Studio — script, characters, pages, translation and animatics Build a comic as an editable production rather than a single flattened generation. Comic Director creates a causal page structure and a full script that can be revised and approved before image credits are spent. The editor includes varied layouts, restrained automatic lettering, per-panel image regeneration, page navigation, zoom, a full-screen read-only Fit preview, PDF/CBZ/PNG export, and text-only rewriting or translation without changing artwork. diff --git a/app/launch.py b/app/launch.py index 5401056a..ee6ebc55 100644 --- a/app/launch.py +++ b/app/launch.py @@ -42,6 +42,7 @@ import threading import traceback import requests +from contextlib import contextmanager from datetime import datetime, timezone from pathlib import Path, PureWindowsPath from urllib.parse import quote @@ -196,7 +197,7 @@ # --- FastAPI setup --- from fastapi import FastAPI, UploadFile, File, HTTPException, Request, Response from fastapi.staticfiles import StaticFiles -from fastapi.responses import FileResponse, JSONResponse +from fastapi.responses import FileResponse, JSONResponse, StreamingResponse from fastapi.middleware.cors import CORSMiddleware import uvicorn from services.access_log_filter import install_quiet_access_filter @@ -264,6 +265,33 @@ def _safe_join(base: str, *parts: str) -> str | None: ) +@api.middleware("http") +async def protect_cross_origin_mutations(request: Request, call_next): + """Reject browser mutations from a different origin, including on LAN.""" + if request.method in {"POST", "PUT", "PATCH", "DELETE"}: + origin = request.headers.get("origin") + if origin: + from urllib.parse import urlsplit + + try: + origin_host = (urlsplit(origin).netloc or "").casefold() + except ValueError: + origin_host = "" + request_host = str(request.headers.get("host") or "").casefold() + local_hosts = {"localhost", "127.0.0.1", "[::1]"} + origin_name = origin_host.rsplit(":", 1)[0] + request_name = request_host.rsplit(":", 1)[0] + same_origin_host = bool(origin_host and origin_host == request_host) + local_proxy = origin_name in local_hosts and request_name in local_hosts + pinokio_proxy = bool(re.fullmatch(r"\d+\.localhost(?::\d+)?", origin_host)) + if not (same_origin_host or local_proxy or pinokio_proxy): + return JSONResponse( + status_code=403, + content={"detail": "Cross-origin API mutations are not allowed"}, + ) + return await call_next(request) + + @api.middleware("http") async def trace_user_mutations(request: Request, call_next): """Record reconstructible user/API actions while debug tracing is on.""" @@ -285,10 +313,13 @@ async def trace_user_mutations(request: Request, call_next): request_body = {"parse_error": str(exc)} elif content_type: request_body = f"<{content_type}; body omitted>" + from services.task_manager import redact_sensitive_data + debug_trace.trace_event( "user_action", "api_request", event_id=event_id, phase="request", method=request.method, path=request.url.path, - query=dict(request.query_params), body=request_body, + query=redact_sensitive_data(dict(request.query_params)), + body=redact_sensitive_data(request_body), ) try: with debug_trace.context_scope( @@ -318,6 +349,7 @@ async def trace_user_mutations(request: Request, call_next): # --- Generation job tracking --- from services.job_lifecycle import ( GENERATED_MEDIA_EXTENSIONS, + acknowledge_cancel, collect_job_outputs, finish_job, generation_queue_position, @@ -327,15 +359,21 @@ async def trace_user_mutations(request: Request, call_next): register_abort_state, register_generation_job, request_cancel, + set_job_state_observer, snapshot_job, try_requeue, try_start, unregister_abort_state, update_job, ) +from services import resource_scheduler _jobs: dict = {} -_gen_lock = threading.Lock() +_local_gpu_lane = resource_scheduler.local_gpu_lane(0) +# This is the coordinator's physical GPU-0 primitive. The existing generation +# FIFO keeps using its proven job-lifecycle wrapper around the same object, +# while migrated 3D/Rig/audio/LLM workers acquire it through the coordinator. +_gen_lock = resource_scheduler.coordinator.shared_lock(_local_gpu_lane) _active_gen_states: dict = {} # job_id -> wgp gen state dict (for abort signaling) _queue_recovery_lock = threading.Lock() _durable_generation_queue = DurableGenerationQueue( @@ -344,12 +382,76 @@ async def trace_user_mutations(request: Request, call_next): ".maestro_generation_queue.json", ) ) -_audio_analysis_execution_lock = threading.Lock() _H3_IDLE_RELEASE_SECONDS = 10.0 _h3_idle_release_timer: threading.Timer | None = None _h3_idle_release_lock = threading.RLock() +def _prepare_local_gpu_owner( + _lane: resource_scheduler.ResourceLane, + _task_id: str, + description: str, +) -> None: + """Hand GPU 0 to one engine without retaining incompatible runtimes.""" + owner = str(description or "").strip().lower() + is_legacy_h3 = owner.startswith("maestro h3 legacy") + is_wgp_generation = owner.startswith("maestro wgp") + is_local_llm = owner.startswith("local llm") + + if not is_legacy_h3: + minimax_h3_service.cancel_idle_shutdown() + minimax_h3_service.stop_runtime() + if not is_wgp_generation and getattr(wgp, "wan_model", None) is not None: + wgp.release_model() + if not is_local_llm: + try: + from services import llm_service + llm_status = llm_service.get_status() + if ( + llm_status.get("provider") == "local" + and str(llm_status.get("device") or "").startswith("cuda") + ): + llm_service.unload_model() + except Exception as exc: + print(f"[Resources] Local CUDA LLM cleanup skipped: {exc}") + gc.collect() + if torch.cuda.is_available(): + torch.cuda.empty_cache() + + +resource_scheduler.coordinator.set_prepare_hook( + _local_gpu_lane, _prepare_local_gpu_owner, +) + + +@contextmanager +def _coordinated_generation_slot( + job: dict, + *, + description: str | None = None, +): + """Keep the legacy FIFO while exposing its acquired GPU lease centrally.""" + params = job.get("params") if isinstance(job.get("params"), dict) else {} + model_type = str(params.get("model_type") or "").strip() + if not description: + if _is_legacy_h3_model(model_type): + description = "Maestro H3 Legacy generation" + elif model_type: + description = f"Maestro WGP generation · {model_type}" + else: + description = "Maestro GPU generation" + with generation_slot(_gen_lock, job) as acquired: + if not acquired: + yield False + return + with resource_scheduler.coordinator.adopt_acquired( + _local_gpu_lane, + task_id=str(job.get("task_id") or job.get("id") or "generation"), + description=description, + ): + yield True + + def _is_durable_generation_job(job: dict) -> bool: """Only persist ordinary Studio generations, not Director sub-jobs.""" params = job.get("params") if isinstance(job.get("params"), dict) else {} @@ -389,7 +491,39 @@ def _new_generation_job( job_id: str | None = None, created_at: float | None = None, recovered: bool = False, + reserve_generation: bool = True, ) -> dict: + frozen_params = copy.deepcopy(params) + if _is_minimax_h3_model(frozen_params.get("model_type")): + from services.minimax_h3_duration import ( + apply_h3_dialogue_duration, + apply_h3_vocal_timeline, + h3_dialogue_split_error, + ) + + try: + duration_model_def = wgp.get_model_def(frozen_params.get("model_type")) or {} + except Exception: + duration_model_def = {} + contract = apply_h3_dialogue_duration(frozen_params, duration_model_def) + if contract: + if contract.get("requires_split"): + raise ValueError(h3_dialogue_split_error(contract)) + print( + "[MiniMax H3] Mandatory dialogue duration: " + f"{contract['syllable_count']} syllables × " + f"{contract['seconds_per_syllable']:.3f}s -> " + f"{contract['effective_seconds']:.3f}s " + f"({contract['effective_frames']} frames)." + ) + timeline = apply_h3_vocal_timeline(frozen_params, duration_model_def) + if timeline: + print( + "[MiniMax H3] Vocal timeline locked: " + f"{timeline['segment_count']} authored line(s), " + f"{len(timeline['intervals'])} timed interval(s) across " + f"{timeline['duration_seconds']:.3f}s." + ) job = { "id": job_id or uuid.uuid4().hex[:8], "status": "queued", @@ -401,8 +535,9 @@ def _new_generation_job( "created_at": created_at or time.time(), "started_at": None, "finished_at": None, - "params": copy.deepcopy(params), + "params": frozen_params, "output_files": [], + "acquired_resources": [], "error": None, "workspace": workspace, "out_dir": _workspace_dir(workspace), @@ -411,10 +546,59 @@ def _new_generation_job( # Reserve FIFO order synchronously. Starting one thread per request is # still useful for cancellation/recovery, but thread scheduling no longer # decides which submitted generation reaches the GPU first. + if reserve_generation: + register_generation_job(_gen_lock, job) + publisher = globals().get("_publish_generation_task") + if callable(publisher): + try: + task = publisher(job) + if isinstance(task, dict): + job["task_id"] = task.get("id") + job["root_task_id"] = task.get("root_id") + except Exception as exc: + print(f"[Task registry] Could not publish generation {job['id']}: {exc}") + return job + + +def _register_manual_generation_job(job: dict) -> dict: + """Publish older edit/tool jobs before their background thread starts.""" + job_id = str(job.get("id") or "") + if not job_id: + raise ValueError("A manual generation job requires an id") + params = job.get("params") if isinstance(job.get("params"), dict) else {} + if not str(params.get("model_type") or "").strip(): + params["model_type"] = "post_processing" + params.setdefault("generation_mode", "video") + job["params"] = params + job.setdefault("acquired_resources", []) + job.setdefault("started_at", None) + job.setdefault("finished_at", None) + job.setdefault("task_id", f"task-generation-{job_id}") + job.setdefault("root_task_id", job["task_id"]) + _jobs[job_id] = job register_generation_job(_gen_lock, job) + publisher = globals().get("_publish_generation_task") + if callable(publisher): + try: + task = publisher(job) + if isinstance(task, dict): + job["task_id"] = task.get("id") or job["task_id"] + job["root_task_id"] = ( + task.get("root_id") or task.get("id") or job["root_task_id"] + ) + except Exception as exc: + print(f"[Task registry] Could not publish manual job {job_id}: {exc}") return job +def _generation_job_acceptance(job: dict) -> dict: + return { + "task_id": job.get("task_id"), + "root_task_id": job.get("root_task_id") or job.get("task_id"), + "generation_details": _public_generation_details(job.get("params")), + } + + _PUBLIC_MODEL_LABELS = { "minimax:image-01": "MiniMax Image-01", "minimax_h3_legacy": "MiniMax H3 Legacy Quality — ConvRot", @@ -456,6 +640,31 @@ def _public_generation_details(params: dict | None) -> dict: "guidance": params.get("guidance_scale"), "frames": params.get("video_length") or params.get("total_frames"), "duration_seconds": params.get("duration_seconds"), + "dialogue_words": ( + params.get("_h3_dialogue_duration_contract", {}).get("word_count") + if isinstance(params.get("_h3_dialogue_duration_contract"), dict) + else None + ), + "dialogue_syllables": ( + params.get("_h3_dialogue_duration_contract", {}).get("syllable_count") + if isinstance(params.get("_h3_dialogue_duration_contract"), dict) + else None + ), + "dialogue_seconds_per_syllable": ( + params.get("_h3_dialogue_duration_contract", {}).get("seconds_per_syllable") + if isinstance(params.get("_h3_dialogue_duration_contract"), dict) + else None + ), + "dialogue_duration_calculated": ( + params.get("_h3_dialogue_duration_contract", {}).get("estimated_seconds") + if isinstance(params.get("_h3_dialogue_duration_contract"), dict) + else None + ), + "dialogue_duration_minimum_limited": ( + params.get("_h3_dialogue_duration_contract", {}).get("minimum_limited") + if isinstance(params.get("_h3_dialogue_duration_contract"), dict) + else None + ), "repeat": params.get("repeat_generation"), "profile": params.get("h3_model_profile"), "flow_shift": params.get("flow_shift"), @@ -465,6 +674,23 @@ def _public_generation_details(params: dict | None) -> dict: for key, value in public_values.items(): if value is not None and value != "": details[key] = value + + cache_type = str(params.get("skip_steps_cache_type") or "").strip() + if cache_type or model_type.startswith("minimax_h3"): + details["cache"] = bool(cache_type) + if cache_type: + details["cache_type"] = cache_type + + raw_loras = params.get("activated_loras") + selected_loras = raw_loras if isinstance(raw_loras, (list, tuple)) else [] + public_loras = [ + os.path.basename(str(item).replace("\\", "/")) + for item in selected_loras + if str(item or "").strip() + ] + details["lora_count"] = len(public_loras) + if public_loras: + details["loras"] = public_loras return details @@ -602,14 +828,32 @@ def _get_active_workspace() -> str: def _workspace_dir(workspace: str = None) -> str: - """Get the output directory for a workspace. Creates it if needed.""" - ws = workspace or _get_active_workspace() + """Return a contained output directory for one validated workspace.""" + ws = _get_active_workspace() if workspace is None else workspace + if not isinstance(ws, str) or not re.fullmatch( + r"(?:default|[A-Za-z0-9][A-Za-z0-9_-]*)", ws + ): + raise HTTPException( + status_code=400, + detail=( + "Invalid workspace name. Use letters, numbers, hyphens, " + "underscores, without spaces or path separators." + ), + ) + # Always read base from config, never from wgp.save_path (which may already include workspace) - base = wgp.server_config.get("save_path", "outputs") - if ws == "default": - return base - ws_dir = os.path.join(base, ws) - os.makedirs(ws_dir, exist_ok=True) + base = os.path.realpath(os.path.abspath( + wgp.server_config.get("save_path", "outputs") + )) + ws_dir = base if ws == "default" else os.path.realpath(os.path.join(base, ws)) + try: + contained = os.path.commonpath((base, ws_dir)) == base + except (TypeError, ValueError): + contained = False + if not contained: + raise HTTPException(status_code=400, detail="Invalid workspace path.") + if ws != "default": + os.makedirs(ws_dir, exist_ok=True) return ws_dir @@ -688,6 +932,7 @@ def _init_pipeline(): _gen_lock, _request_generation_cancel, _active_gen_states, + state_observer=_publish_director_task, ) _pipeline_initialized = True @@ -1375,14 +1620,49 @@ def debug_model(model_type: str): @api.delete("/api/v1/models/{model_type}") def delete_model(model_type: str): """Delete a model's checkpoint files from disk.""" + active_generation_ids = [ + str(job_id) + for job_id, job in list(_jobs.items()) + if ( + str(job.get("status") or "").lower() + in {"created", "queued", "waiting_resource", "running", "cancelling"} + or job_id in _active_gen_states + ) + and str((job.get("params") or {}).get("model_type") or "") == model_type + ] + if active_generation_ids: + raise HTTPException( + status_code=409, + detail=( + f"Cannot delete {model_type} while generation job(s) " + f"{', '.join(active_generation_ids[:5])} are active or queued. " + "Cancel them and wait for shutdown first." + ), + ) if _is_legacy_h3_model(model_type): deleted = minimax_h3_service.delete_model_cache() return {"deleted": deleted, "model_type": model_type} if model_type == "unirig": from services import rig_service + if rig_service.has_active_unirig_jobs(): + raise HTTPException( + status_code=409, + detail=( + "Cannot delete UniRig while an AI rig job is active, " + "queued, or still shutting down." + ), + ) deleted = rig_service.delete_unirig_cache() return {"deleted": deleted, "model_type": model_type, "affected_models": []} if model_type in model3d_service.MODEL_BY_ID: + if model3d_service.has_active_jobs(model_type): + raise HTTPException( + status_code=409, + detail=( + f"Cannot delete {model_type} while a 3D job using its " + "shared model cache is active, queued, or shutting down." + ), + ) affected = [item["id"] for item in model3d_service.models_sharing_repo(model_type)] deleted = model3d_service.delete_model_cache(model_type) return {"deleted": deleted, "model_type": model_type, "affected_models": affected if deleted else []} @@ -6564,7 +6844,9 @@ def system_release_model(): on the next job. Refuses while anything is generating. """ for j in _jobs.values(): - if j.get("status") in ("queued", "running"): + if j.get("status") in { + "queued", "waiting_resource", "running", "cancelling", + }: raise HTTPException(status_code=409, detail="A generation is in progress — stop it or wait for it to finish first.") try: from services.director_pipeline import _pipelines @@ -6576,6 +6858,10 @@ def system_release_model(): raise HTTPException(status_code=409, detail="A generation is in progress — stop it or wait for it to finish first.") try: released = [] + if minimax_h3_service.is_runtime_running(): + print("[ReleaseModel] Stopping isolated H3 Legacy runtime (user request)") + minimax_h3_service.stop_runtime() + released.append("H3 Legacy runtime") if getattr(wgp, "wan_model", None) is not None or getattr(wgp, "offloadobj", None) is not None: print("[ReleaseModel] Unloading generation model (user request)") wgp.release_model() @@ -7048,7 +7334,7 @@ async def record_debug_user_action(request: Request): # API Routes: native Hunyuan3D generation # ============================================================================ -def _resolve_model3d_input_path(value: str) -> str | None: +def _resolve_model3d_input_path(value: str, workspace: str | None = None) -> str | None: """Resolve a UI-provided upload/output path to a local file.""" if not value: return None @@ -7058,7 +7344,7 @@ def _resolve_model3d_input_path(value: str) -> str | None: return _safe_join(os.path.join(os.getcwd(), "uploads"), value) if value.startswith("/api/v1/file/"): value = value.rsplit("/", 1)[-1] - return _safe_join(_workspace_dir(), value) + return _safe_join(_workspace_dir(workspace), value) if os.path.isabs(value): uploads_root = os.path.realpath(os.path.join(os.getcwd(), "uploads")) outputs_root = os.path.realpath(wgp.server_config.get("save_path", "outputs")) @@ -7069,7 +7355,7 @@ def _resolve_model3d_input_path(value: str) -> str | None: upload_candidate = _safe_join(os.path.join(os.getcwd(), "uploads"), value) if upload_candidate and os.path.isfile(upload_candidate): return upload_candidate - return _safe_join(_workspace_dir(), value) + return _safe_join(_workspace_dir(workspace), value) @api.get("/api/v1/model3d/capabilities") @@ -7082,6 +7368,8 @@ def model3d_capabilities(): async def generate_model3d(request: Request): from services import model3d_service body = await request.json() + workspace = body.get("workspace") if "workspace" in body else _get_active_workspace() + _workspace_dir(workspace) raw_images = body.get("images") or {} if not isinstance(raw_images, dict): raise HTTPException(status_code=400, detail="images must be an object keyed by front/left/right/back") @@ -7092,22 +7380,29 @@ async def generate_model3d(request: Request): for view, value in raw_images.items(): if view not in {"front", "left", "right", "back"} or not value: continue - resolved = _resolve_model3d_input_path(str(value)) + resolved = _resolve_model3d_input_path(str(value), workspace) if not resolved or not os.path.isfile(resolved): raise HTTPException(status_code=400, detail=f"3D {view} image not found") image_paths[view] = resolved source_mesh_path = None if str(body.get("operation") or "generate").lower() == "retexture": - source_mesh_path = _resolve_model3d_input_path(str(body.get("source_model") or "")) + source_mesh_path = _resolve_model3d_input_path( + str(body.get("source_model") or ""), workspace, + ) if not source_mesh_path or not os.path.isfile(source_mesh_path): raise HTTPException(status_code=400, detail="Source GLB not found") try: - return model3d_service.start_job( + job = model3d_service.start_job( body=body, image_paths=image_paths, - output_dir=_workspace_dir(), + output_dir=_workspace_dir(workspace), source_mesh_path=source_mesh_path, + workspace=workspace, ) + publisher = globals().get("_publish_generic_legacy_task") + if callable(publisher): + publisher(job, "model3d") + return job except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) except Exception as e: @@ -7121,6 +7416,9 @@ def model3d_job_status(job_id: str): job = model3d_service.get_job(job_id) if not job: raise HTTPException(status_code=404, detail="3D generation job not found") + publisher = globals().get("_publish_generic_legacy_task") + if callable(publisher): + publisher(job, "model3d") return job @@ -7130,6 +7428,9 @@ def cancel_model3d_job(job_id: str): job = model3d_service.cancel_job(job_id) if not job: raise HTTPException(status_code=404, detail="3D generation job not found") + publisher = globals().get("_publish_generic_legacy_task") + if callable(publisher): + publisher(job, "model3d") return job @@ -7155,23 +7456,30 @@ def rig_capabilities(): async def generate_rig(request: Request): from services import rig_service body = await request.json() + workspace = body.get("workspace") if "workspace" in body else _get_active_workspace() + _workspace_dir(workspace) source_name = str(body.get("source") or "").strip() if not source_name: raise HTTPException(status_code=400, detail="source is required (a generated .glb output name)") if not source_name.lower().endswith(".glb"): raise HTTPException(status_code=400, detail="Rigging currently supports GLB sources only") - source_path = _safe_join(_workspace_dir(), source_name) + source_path = _safe_join(_workspace_dir(workspace), source_name) if not source_path or not os.path.isfile(source_path): # Also accept absolute/upload paths through the shared 3D resolver. - source_path = _resolve_model3d_input_path(source_name) + source_path = _resolve_model3d_input_path(source_name, workspace) if not source_path or not os.path.isfile(source_path): raise HTTPException(status_code=400, detail="Source 3D model not found") try: - return rig_service.start_job( + job = rig_service.start_job( body=body, source_path=source_path, - output_dir=_workspace_dir(), + output_dir=_workspace_dir(workspace), + workspace=workspace, ) + publisher = globals().get("_publish_generic_legacy_task") + if callable(publisher): + publisher(job, "rig") + return job except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) except Exception as e: @@ -7185,6 +7493,9 @@ def rig_job_status(job_id: str): job = rig_service.get_job(job_id) if not job: raise HTTPException(status_code=404, detail="Rig job not found") + publisher = globals().get("_publish_generic_legacy_task") + if callable(publisher): + publisher(job, "rig") return job @@ -7194,6 +7505,9 @@ def cancel_rig_job(job_id: str): job = rig_service.cancel_job(job_id) if not job: raise HTTPException(status_code=404, detail="Rig job not found") + publisher = globals().get("_publish_generic_legacy_task") + if callable(publisher): + publisher(job, "rig") return job @@ -8868,34 +9182,17 @@ async def analyze_audio(request: Request): if not os.path.isfile(audio_path): raise HTTPException(status_code=404, detail=f"Audio file not found: {audio_path}") - # Free the generation model's VRAM before analysis. The Director flow - # runs analysis right after rendering the song, and as of v1.2.0 the - # default music model is much larger (XL SFT, 10GB bf16). On smaller - # cards the resident model + vocal separator + Whisper oversubscribe - # VRAM, and Windows' CUDA sysmem fallback turns that into a silent, - # near-endless crawl instead of a clean OOM ("analyzing never - # finishes"). The song is already saved; wgp reloads the model - # transparently on the next job. Guarded by _gen_lock so an active - # generation is never touched. - if _gen_lock.acquire(blocking=False): - try: - if getattr(wgp, "wan_model", None) is not None: - print("[AudioAnalysis] Releasing generation model VRAM before analysis") - wgp.release_model() - else: - import gc - gc.collect() - if torch.cuda.is_available(): - torch.cuda.empty_cache() - except Exception as e: - print(f"[AudioAnalysis] Pre-analysis VRAM release skipped: {e}") - finally: - _gen_lock.release() - else: - print("[AudioAnalysis] Generation in progress - skipping pre-analysis VRAM release") - + lane = ( + resource_scheduler.local_gpu_lane(0) + if body.get("transcribe", False) + else resource_scheduler.cpu_lane("audio") + ) try: - with _audio_analysis_execution_lock: + with resource_scheduler.coordinator.acquire( + lane, + task_id=f"audio-sync-{uuid.uuid4().hex[:12]}", + description="Audio analysis", + ): result = audio_analysis.analyze( audio_path=audio_path, transcribe=body.get("transcribe", False), @@ -8940,75 +9237,194 @@ def audio_analyze_status(): "identifying_speakers": 8, "finalizing": 9, } +_AUDIO_ANALYSIS_ACTIVE = {"queued", "waiting_resource", "running", "cancelling"} +_AUDIO_ANALYSIS_TERMINAL = {"completed", "failed", "cancelled"} +_audio_analysis_jobs_lock = threading.RLock() + + +def _publish_audio_analysis_job(job: dict) -> dict | None: + publisher = globals().get("_publish_generic_legacy_task") + if not callable(publisher): + return None + try: + return publisher(copy.deepcopy(job), "audio-analysis") + except Exception as exc: + print(f"[Task registry] Could not publish audio analysis {job.get('id')}: {exc}") + return None + + +def _audio_analysis_job_update(job_id: str, **patch) -> dict | None: + with _audio_analysis_jobs_lock: + job = _jobs.get(job_id) + if not job or not job_id.startswith("audio-analysis-"): + return None + current_status = str(job.get("status") or "queued") + if current_status in _AUDIO_ANALYSIS_TERMINAL: + return snapshot_job(job) + requested_status = str(patch.get("status") or "") + if job.get("_cancel_requested") and requested_status not in { + "cancelled", "cancelling", + }: + if requested_status in {"completed", "failed"}: + patch = { + **patch, + "status": "cancelled", + "phase": "cancelled", + "message": "Cancelled", + "error": None, + "result": None, + "progress": 0, + "acquired_resources": [], + "finished_at": patch.get("finished_at") or time.time(), + } + else: + # A late progress callback must not overwrite a visible + # cancelling state or reactivate a task cancelled in queue. + return snapshot_job(job) + job.update(patch) + job["updated_at"] = time.time() + snapshot = snapshot_job(job) + _publish_audio_analysis_job(snapshot) + return snapshot def _run_audio_analysis_job(job_id: str, body: dict) -> None: """Run one serialized audio-analysis job with reconnectable progress.""" - job = _jobs[job_id] + with _audio_analysis_jobs_lock: + job = _jobs.get(job_id) + if not job: + return from services import audio_analysis - with _audio_analysis_execution_lock: - if job.get("_cancel_requested"): - job.update(status="cancelled", message="Cancelled", progress=0) - return - job.update(status="running", phase="loading_audio", message="Loading audio…") + lane = ( + resource_scheduler.local_gpu_lane(0) + if body.get("transcribe", False) + else resource_scheduler.cpu_lane("audio") + ) - def report(step: str, detail: str) -> None: - if job.get("_cancel_requested"): - raise RuntimeError("Audio analysis cancelled") - if not step: + def cancelled() -> bool: + with _audio_analysis_jobs_lock: + return bool((_jobs.get(job_id) or {}).get("_cancel_requested")) + + if cancelled() or str(job.get("status") or "") in _AUDIO_ANALYSIS_TERMINAL: + return + + _audio_analysis_job_update( + job_id, + status="waiting_resource", + phase="waiting_resource", + message=("Waiting for local GPU 0" if body.get("transcribe", False) + else "Waiting for audio CPU worker"), + acquired_resources=[], + ) + try: + lease = resource_scheduler.coordinator.acquire( + lane, + task_id=str(job.get("task_id") or job_id), + description="Audio analysis", + cancelled=cancelled, + ) + with lease: + if cancelled(): + _audio_analysis_job_update( + job_id, status="cancelled", phase="cancelled", + message="Cancelled", progress=0, finished_at=time.time(), + acquired_resources=[], + ) return - current = _AUDIO_ANALYSIS_STEPS.get(step, job.get("step", 1)) - job.update( - phase=step, - message=f"{detail}…" if detail else step.replace("_", " ").capitalize(), - step=current, - total_steps=10, - progress=int((current / 10) * 100), - updated_at=time.time(), + _audio_analysis_job_update( + job_id, status="running", phase="loading_audio", + message="Loading audio…", started_at=time.time(), + acquired_resources=[lane.key], ) - audio_analysis.set_progress_callback(report) - try: - result = audio_analysis.analyze( - audio_path=body["audio_path"], - transcribe=body.get("transcribe", False), - extract_vocals_for_transcription=body.get("extract_vocals", True), - lyrics_hint=body.get("lyrics_hint") or None, - ) - if job.get("_cancel_requested"): - job.update(status="cancelled", message="Cancelled", progress=0, result=None) - else: - job.update( - status="completed", - phase="completed", - message="Audio analysis complete", - progress=100, - step=10, + def report(step: str, detail: str) -> None: + if cancelled(): + raise RuntimeError("Audio analysis cancelled") + if not step: + return + current = _AUDIO_ANALYSIS_STEPS.get(step, job.get("step", 1)) + _audio_analysis_job_update( + job_id, + phase=step, + message=f"{detail}…" if detail else step.replace("_", " ").capitalize(), + step=current, total_steps=10, - result=result, - updated_at=time.time(), - ) - except Exception as exc: - if job.get("_cancel_requested"): - job.update( - status="cancelled", - message="Cancelled", - error=None, - progress=0, - updated_at=time.time(), + progress=int((current / 10) * 100), ) - else: - traceback.print_exc() - job.update( - status="failed", - message=f"Audio analysis failed: {exc}", - error=str(exc), - updated_at=time.time(), + + audio_analysis.set_progress_callback(report) + try: + result = audio_analysis.analyze( + audio_path=body["audio_path"], + transcribe=body.get("transcribe", False), + extract_vocals_for_transcription=body.get("extract_vocals", True), + lyrics_hint=body.get("lyrics_hint") or None, ) - finally: - audio_analysis.set_progress_callback(None) - audio_analysis.clear_progress() + if cancelled(): + _audio_analysis_job_update( + job_id, status="cancelled", phase="cancelled", + message="Cancelled", progress=0, result=None, + finished_at=time.time(), acquired_resources=[], + ) + else: + _audio_analysis_job_update( + job_id, + status="completed", + phase="completed", + message="Audio analysis complete", + progress=100, + step=10, + total_steps=10, + result=result, + finished_at=time.time(), + acquired_resources=[], + ) + except Exception as exc: + if cancelled(): + _audio_analysis_job_update( + job_id, + status="cancelled", + phase="cancelled", + message="Cancelled", + error=None, + progress=0, + finished_at=time.time(), + acquired_resources=[], + ) + else: + traceback.print_exc() + _audio_analysis_job_update( + job_id, + status="failed", + phase="failed", + message=f"Audio analysis failed: {exc}", + error=str(exc), + finished_at=time.time(), + acquired_resources=[], + ) + finally: + audio_analysis.set_progress_callback(None) + audio_analysis.clear_progress() + except resource_scheduler.ResourceAcquireCancelled: + _audio_analysis_job_update( + job_id, status="cancelled", phase="cancelled", + message="Cancelled", progress=0, finished_at=time.time(), + acquired_resources=[], + ) + except Exception as exc: + traceback.print_exc() + _audio_analysis_job_update( + job_id, + status="cancelled" if cancelled() else "failed", + phase="cancelled" if cancelled() else "failed", + message=("Cancelled" if cancelled() + else f"Audio analysis failed before it started: {exc}"), + error=None if cancelled() else str(exc), + result=None, + acquired_resources=[], + finished_at=time.time(), + ) @api.post("/api/v1/audio/analyze/jobs") @@ -9022,8 +9438,31 @@ async def start_audio_analysis_job(request: Request): raise HTTPException(status_code=404, detail=f"Audio file not found: {audio_path}") job_id = f"audio-analysis-{uuid.uuid4().hex[:12]}" - _jobs[job_id] = { + workspace = body.get("workspace") if "workspace" in body else _get_active_workspace() + _workspace_dir(workspace) + supplied_task_id = str(body.get("task_id") or "").strip() + supplied_root_id = str(body.get("root_task_id") or "").strip() + supplied_parent_id = str(body.get("parent_task_id") or "").strip() + for label, value in ( + ("task_id", supplied_task_id), + ("root_task_id", supplied_root_id), + ("parent_task_id", supplied_parent_id), + ): + if value and not re.fullmatch(r"task-[A-Za-z0-9_-]{1,180}", value): + raise HTTPException(status_code=400, detail=f"Invalid {label}") + task_id = supplied_task_id or f"task-audio-analysis-{job_id}" + root_task_id = supplied_root_id or supplied_parent_id or task_id + lane = ( + resource_scheduler.local_gpu_lane(0) + if body.get("transcribe", False) + else resource_scheduler.cpu_lane("audio") + ) + job = { "id": job_id, + "task_id": task_id, + "root_task_id": root_task_id, + "parent_task_id": supplied_parent_id or None, + "workspace": workspace, "status": "queued", "progress": 0, "step": 0, @@ -9036,24 +9475,46 @@ async def start_audio_analysis_job(request: Request): "result": None, "created_at": time.time(), "updated_at": time.time(), + "provider": "local", + "model": "Whisper / pyannote" if body.get("transcribe", False) else "Audio analysis", + "server_origin": "local", + "resource_lane": lane.key, + "acquired_resources": [], + "project_id": str(body.get("project_id") or ""), "_cancel_requested": False, } + with _audio_analysis_jobs_lock: + _jobs[job_id] = job + _publish_audio_analysis_job(job) threading.Thread( target=_run_audio_analysis_job, args=(job_id, dict(body)), name=f"audio-analysis-{job_id[-6:]}", daemon=True, ).start() - return {"job_id": job_id} + return { + "job_id": job_id, + "task_id": task_id, + "root_task_id": root_task_id, + "parent_task_id": supplied_parent_id or None, + "status": "queued", + } @api.get("/api/v1/audio/analyze/jobs/{job_id}") -def get_audio_analysis_job(job_id: str): - job = _jobs.get(job_id) +def get_audio_analysis_job(job_id: str, workspace: str | None = None): + target_workspace = _get_active_workspace() if workspace is None else workspace + _workspace_dir(target_workspace) + with _audio_analysis_jobs_lock: + job = snapshot_job(_jobs.get(job_id)) if _jobs.get(job_id) else None if not job or not job_id.startswith("audio-analysis-"): raise HTTPException(status_code=404, detail="Audio analysis job not found") + if str(job.get("workspace") or "default") != str(target_workspace): + raise HTTPException(status_code=404, detail="Audio analysis job not found") return { "job_id": job_id, + "task_id": job.get("task_id"), + "root_task_id": job.get("root_task_id") or job.get("task_id"), "status": job["status"], "progress": job["progress"], "step": job.get("step", 0), @@ -9066,15 +9527,35 @@ def get_audio_analysis_job(job_id: str): @api.post("/api/v1/audio/analyze/jobs/{job_id}/cancel") -def cancel_audio_analysis_job(job_id: str): - job = _jobs.get(job_id) +def cancel_audio_analysis_job(job_id: str, workspace: str | None = None): + target_workspace = _get_active_workspace() if workspace is None else workspace + _workspace_dir(target_workspace) + with _audio_analysis_jobs_lock: + job = _jobs.get(job_id) if not job or not job_id.startswith("audio-analysis-"): raise HTTPException(status_code=404, detail="Audio analysis job not found") - if job["status"] not in ("queued", "running"): + if str(job.get("workspace") or "default") != str(target_workspace): + raise HTTPException(status_code=404, detail="Audio analysis job not found") + if job["status"] not in _AUDIO_ANALYSIS_ACTIVE: return {"job_id": job_id, "status": job["status"]} - job["_cancel_requested"] = True - job["message"] = "Cancelling after the current analysis phase…" - return {"job_id": job_id, "status": "cancelling"} + with _audio_analysis_jobs_lock: + job["_cancel_requested"] = True + waiting = job["status"] in {"queued", "waiting_resource"} + snapshot = _audio_analysis_job_update( + job_id, + status="cancelled" if waiting else "cancelling", + phase="cancelled" if waiting else "cancelling", + message=( + "Cancelled before analysis started" + if waiting else "Cancelling after the current analysis phase…" + ), + **({"finished_at": time.time()} if waiting else {}), + ) or job + return { + "job_id": job_id, + "task_id": snapshot.get("task_id"), + "status": snapshot.get("status"), + } @api.post("/api/v1/audio/suggest-clips") @@ -9427,13 +9908,20 @@ async def director_pipeline_start(request: Request): Runs entirely server-side so the browser can be closed. """ _init_pipeline() - from services.director_pipeline import start_pipeline + from services.director_pipeline import get_pipeline, start_pipeline body = await request.json() try: pid = start_pipeline(body) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc - return {"pipeline_id": pid} + pipeline = get_pipeline(pid) or {} + return { + "pipeline_id": pid, + "task_id": pipeline.get("task_id"), + "root_task_id": ( + pipeline.get("root_task_id") or pipeline.get("task_id") + ), + } @api.get("/api/v1/director/pipeline/{pid}") @@ -9566,19 +10054,27 @@ async def director_pipeline_update_preview(pid: str, request: Request): @api.post("/api/v1/director/pipeline/{pid}/stop") def director_pipeline_stop(pid: str): - """Cancel a running pipeline.""" + """Request cancellation and return the pipeline's current state.""" from services.director_pipeline import get_pipeline, stop_pipeline - if stop_pipeline(pid): - current = get_pipeline(pid) or {} - return { - "status": "cancelled", - "cancelled": True, - "persisted": current.get("_state_persisted", False), - } + accepted = stop_pipeline(pid) current = get_pipeline(pid) if not current: raise HTTPException(status_code=404, detail="Pipeline not found") - return {"status": current.get("status", "unknown"), "cancelled": False} + status = str(current.get("status") or "unknown") + phase = str(current.get("phase") or status) + cancelled = status == "cancelled" + return { + "accepted": accepted, + "status": status, + "phase": phase, + "cancel_requested": bool( + current.get("_cancel_requested") + or phase == "cancelling" + or cancelled + ), + "cancelled": cancelled, + "persisted": bool(current.get("_state_persisted", False)), + } @api.post("/api/v1/director/pipeline/{pid}/resume") @@ -9590,12 +10086,23 @@ def director_pipeline_resume(pid: str): crash doesn't throw away completed LLM work. """ _init_pipeline() - from services.director_pipeline import resume_pipeline + from services.director_pipeline import get_pipeline, resume_pipeline base = wgp.server_config.get("save_path", "outputs") ok, message = resume_pipeline(pid, base) if not ok: raise HTTPException(status_code=400, detail=message) - return {"status": "resumed", "pipeline_id": pid} + pipeline = get_pipeline(pid) or {} + workspace = str(pipeline.get("workspace") or _get_active_workspace()) + task_id = f"task-director-{pid}" + _reset_canonical_task_for_resume(workspace, task_id) + task = _publish_director_task(pipeline, workspace) + return { + "status": str(pipeline.get("status") or "resumed"), + "phase": str(pipeline.get("phase") or "resumed"), + "pipeline_id": pid, + "task_id": (task or {}).get("id") or task_id, + "root_task_id": (task or {}).get("root_id") or task_id, + } # ── Director Pipeline Dashboard ─────────────────────────────────────────── @@ -9643,6 +10150,29 @@ async def tag_pipeline_clip(pid: str, clip_index: int, request: Request): return {"status": "ok"} +@api.put("/api/v1/director/pipelines/{pid}/clips/{clip_index}/video-selection") +async def select_pipeline_clip_video(pid: str, clip_index: int, request: Request): + """Select one historical/Studio video as the authoritative slot take.""" + + from services.director_pipeline import ( + PipelineBusyError, + select_clip_video_attempt, + ) + body = await request.json() + base = wgp.server_config.get("save_path", "outputs") + try: + return select_clip_video_attempt( + base, + pid, + clip_index, + body.get("filename"), + ) + except PipelineBusyError as exc: + return JSONResponse({"error": str(exc)}, status_code=409) + except ValueError as exc: + return JSONResponse({"error": str(exc)}, status_code=400) + + # ── Director Pipeline Re-run ────────────────────────────────────────────── @api.post("/api/v1/director/pipelines/{pid}/repair") @@ -9891,11 +10421,8 @@ async def director_v2_plan(request: Request): normalized_treatment = normalize_music_video_treatment( body.get("music_video_treatment") ) - direct_video = ( - skill_type == "music_video" - and normalized_treatment.get("generation_mode") == "direct_video" - ) - if skill_type == "music_video": + direct_video = normalized_treatment.get("generation_mode") == "direct_video" + if skill_type == "music_video" or direct_video: body["music_video_treatment"] = normalized_treatment planner_kwargs = _director_v2_planner_kwargs(body) @@ -10065,24 +10592,187 @@ def director_v2_plan_progress(activity_id: str): return state -@api.post("/api/v1/generate") -async def generate(request: Request): - """Submit a generation job. Returns immediately with a job_id.""" - body = await request.json() - h3_window_plan_response = None - - is_sfx = body.get("sfx_mode") - if not body.get("model_type"): - raise HTTPException(status_code=400, detail="model_type is required") - if not is_sfx and not body.get("prompt"): - raise HTTPException(status_code=400, detail="prompt is required") - # SFX virtual models (mmaudio_*) are frontend-only; skip backend model validation - if not is_sfx and wgp.get_model_def(body["model_type"]) is None: - raise HTTPException(status_code=400, detail=f"Unknown model: {body['model_type']}") +def _run_generation_with_preparation(job_id: str) -> bool: + """Finish deferred, non-GPU preparation before entering generation FIFO. - legacy_h3 = _is_legacy_h3_model(body["model_type"]) - try: - _base_model_type = wgp.get_base_model_type(body["model_type"]) + H3 window planning may load an LLM or call a remote provider. It must not + block ``POST /generate`` and it must not reserve the scarce video lane + while doing so. The pending request is persisted inside the job params so + crash recovery can replay this same worker before starting inference. + """ + job = _jobs.get(job_id) + if not isinstance(job, dict): + return False + params = job.get("params") if isinstance(job.get("params"), dict) else {} + pending = params.get("_h3_window_plan_pending") + if not isinstance(pending, dict): + return bool(_run_generation(job_id)) + if is_cancel_requested(job): + _remove_persisted_generation_job(job) + return False + if not try_start( + job, + phase="planning", + message="Planning H3 window-by-window prompts with the writing model…", + acquired_resources=[], + ): + if is_cancel_requested(job): + _remove_persisted_generation_job(job) + return False + + _persist_generation_job(job) + planner_llm = None + try: + from services import llm_service as planner_llm + from services.h3_window_planner import plan_h3_sliding_windows + + def _plan_windows(): + try: + _ensure_llm_loaded() + except Exception as load_error: + # The planner has a deterministic fallback, so a missing + # optional LLM must not make an otherwise valid H3 job fail. + print( + "[MiniMax H3] Planner LLM unavailable; using fallback: " + f"{load_error}" + ) + services = wgp.server_config.get("services", {}) + provider = _effective_llm_routing(services)[0] + nsfw = ( + services.get("nsfw_mode", False) + and provider not in _PUBLIC_LLM_PROVIDERS + ) + expected_count = int(pending.get("expected_count") or 0) + if expected_count <= 0: + raise RuntimeError("H3 window plan has no target windows") + print( + f"[MiniMax H3] Planning {expected_count} window-local prompts " + "after VRAM-safe geometry " + f"({int(pending.get('window_frames') or 0)} frames/window)." + ) + result = plan_h3_sliding_windows( + str(params.get("prompt") or ""), + model_type=str(pending.get("model_type") or ""), + resolution=str(pending.get("resolution") or ""), + total_frames=int(pending.get("total_frames") or 0), + window_frames=int(pending.get("window_frames") or 0), + overlap_frames=int(pending.get("overlap_frames") or 0), + discard_frames=int(pending.get("discard_frames") or 0), + fps=float(pending.get("fps") or 24), + has_start_image=bool(pending.get("has_start_image")), + has_end_image=bool(pending.get("has_end_image")), + image_paths=list(pending.get("image_paths") or []) or None, + nsfw=bool(nsfw), + ) + prompts = result.get("window_prompts") if isinstance(result, dict) else None + if ( + not isinstance(prompts, list) + or len(prompts) != expected_count + or not all(isinstance(item, str) and item.strip() for item in prompts) + ): + raise RuntimeError( + "H3 window planner returned an incomplete prompt plan " + f"({len(prompts) if isinstance(prompts, list) else 0}/" + f"{expected_count})" + ) + return result, list(prompts) + + # Correlate any child LLM call with the visible generation root. The + # fallback keeps model-free tests and older embedded callers working. + try: + from services.task_manager import task_context_scope + except Exception: + planned, window_prompts = _plan_windows() + else: + with task_context_scope( + task_id=job.get("task_id"), + root_id=job.get("root_task_id") or job.get("task_id"), + workspace=job.get("workspace") or "default", + workspace_dir=job.get("out_dir") + or _workspace_dir(job.get("workspace") or "default"), + ): + planned, window_prompts = _plan_windows() + + if is_cancel_requested(job): + _remove_persisted_generation_job(job) + return False + prepared_params = copy.deepcopy(params) + prepared_params.pop("_h3_window_plan_pending", None) + prepared_params["minimax_h3_window_storyboard"] = True + prepared_params["h3_window_prompts"] = window_prompts + prepared_params["h3_window_plan_signature"] = str( + pending.get("signature") or "" + ) + prepared_params["h3_window_plan"] = planned + if not update_job( + job, + params=prepared_params, + phase="planning", + message=( + f"H3 prompt plan ready ({len(window_prompts)} windows); " + "queuing video generation…" + ), + ): + _remove_persisted_generation_job(job) + return False + _persist_generation_job(job) + if not try_requeue( + job, + phase="waiting_resource", + message="H3 prompt plan ready · waiting for the local video GPU…", + started_at=None, + ): + _remove_persisted_generation_job(job) + return False + _persist_generation_job(job) + return bool(_run_generation(job_id)) + except Exception as error: + traceback.print_exc() + if not is_cancel_requested(job): + finish_job( + job, + "failed", + error=str(error), + message=f"H3 window planning failed: {error}", + phase="planning", + ) + _remove_persisted_generation_job(job) + return False + finally: + # A local writing model cannot remain beside H3's 20B/33B runtime. + if planner_llm is not None: + try: + if ( + planner_llm.is_loaded() + and planner_llm.get_status().get("provider") == "local" + ): + planner_llm.unload_model() + except Exception as unload_error: + print(f"[MiniMax H3] Planner LLM unload skipped: {unload_error}") + # Deferred planning has no abort-state registration. Confirm terminal + # cancellation only after the planner call and its model cleanup have + # both returned. + acknowledge_cancel(job) + + +@api.post("/api/v1/generate") +async def generate(request: Request): + """Submit a generation job. Returns immediately with a job_id.""" + body = await request.json() + h3_window_plan_response = None + + is_sfx = body.get("sfx_mode") + if not body.get("model_type"): + raise HTTPException(status_code=400, detail="model_type is required") + if not is_sfx and not body.get("prompt"): + raise HTTPException(status_code=400, detail="prompt is required") + # SFX virtual models (mmaudio_*) are frontend-only; skip backend model validation + if not is_sfx and wgp.get_model_def(body["model_type"]) is None: + raise HTTPException(status_code=400, detail=f"Unknown model: {body['model_type']}") + + legacy_h3 = _is_legacy_h3_model(body["model_type"]) + try: + _base_model_type = wgp.get_base_model_type(body["model_type"]) except Exception: _base_model_type = body.get("model_type") _generation_model_def = wgp.get_model_def(body["model_type"]) or {} @@ -10119,6 +10809,24 @@ async def generate(request: Request): ): body.pop(key, None) + if _is_minimax_h3_model(body.get("model_type")): + from services.minimax_h3_duration import ( + apply_h3_dialogue_duration, + apply_h3_vocal_timeline, + h3_dialogue_split_error, + ) + + dialogue_duration_contract = apply_h3_dialogue_duration( + body, + _generation_model_def, + ) + if dialogue_duration_contract and dialogue_duration_contract.get("requires_split"): + raise HTTPException( + status_code=400, + detail=h3_dialogue_split_error(dialogue_duration_contract), + ) + apply_h3_vocal_timeline(body, _generation_model_def) + if _generation_model_def.get("omni_reference"): from models.minimax_h3.ref2va import validate_reference_manifest @@ -10242,7 +10950,6 @@ async def generate(request: Request): from services.h3_window_planner import ( compute_h3_window_boundaries, h3_window_plan_signature, - plan_h3_sliding_windows, ) h3_fps = float(_generation_model_def.get("fps", 24) or 24) @@ -10284,56 +10991,40 @@ async def generate(request: Request): if isinstance(cached_plan, dict): h3_window_plan_response = cached_plan else: - from services import llm_service - - llm_was_loaded = llm_service.is_loaded() - try: - _ensure_llm_loaded() - except Exception as load_error: - print(f"[MiniMax H3] Planner LLM unavailable; using fallback: {load_error}") - services = wgp.server_config.get("services", {}) - provider = _effective_llm_routing(services)[0] - nsfw = services.get("nsfw_mode", False) and provider not in _PUBLIC_LLM_PROVIDERS h3_images = [] for value in (h3_start_value, h3_end_value): if isinstance(value, (list, tuple)): value = value[0] if value else None if isinstance(value, str) and value and os.path.isfile(value): h3_images.append(value) - print( - f"[MiniMax H3] Planning {h3_expected_count} window-local prompts " - f"after VRAM-safe geometry ({h3_window_frames} frames/window)." - ) - h3_window_plan_response = await asyncio.to_thread( - plan_h3_sliding_windows, - str(body.get("prompt") or ""), - model_type=str(body.get("model_type") or ""), - resolution=str(body.get("resolution") or ""), - total_frames=h3_total_frames, - window_frames=h3_window_frames, - overlap_frames=h3_overlap_frames, - discard_frames=h3_discard_frames, - fps=h3_fps, - has_start_image=h3_has_start, - has_end_image=h3_has_end, - image_paths=h3_images or None, - nsfw=bool(nsfw), - ) - cached_prompts = h3_window_plan_response["window_prompts"] - # A planner loaded only for this request should not compete - # with the 20B/33B video model for VRAM or RAM. - if not llm_was_loaded and llm_service.is_loaded(): - try: - if llm_service.get_status().get("provider") == "local": - llm_service.unload_model() - except Exception as unload_error: - print(f"[MiniMax H3] Planner LLM unload skipped: {unload_error}") + # Planning can involve loading an LLM and a remote completion. + # Persist the exact geometry and perform that work only after + # the API has returned a visible, cancellable canonical task. + body["_h3_window_plan_pending"] = { + "signature": h3_expected_signature, + "expected_count": h3_expected_count, + "model_type": str(body.get("model_type") or ""), + "resolution": str(body.get("resolution") or ""), + "total_frames": h3_total_frames, + "window_frames": h3_window_frames, + "overlap_frames": h3_overlap_frames, + "discard_frames": h3_discard_frames, + "fps": h3_fps, + "has_start_image": h3_has_start, + "has_end_image": h3_has_end, + "image_paths": h3_images, + } + body.pop("h3_window_prompts", None) + body.pop("h3_window_plan_signature", None) + body.pop("h3_window_plan", None) body["minimax_h3_window_storyboard"] = True - body["h3_window_prompts"] = list(cached_prompts) - body["h3_window_plan_signature"] = h3_expected_signature - if h3_window_plan_response is not None: - body["h3_window_plan"] = h3_window_plan_response + if cached_is_valid: + body.pop("_h3_window_plan_pending", None) + body["h3_window_prompts"] = list(cached_prompts) + body["h3_window_plan_signature"] = h3_expected_signature + if h3_window_plan_response is not None: + body["h3_window_plan"] = h3_window_plan_response # An explicitly reviewed plan may have been created moments ago # by the Enhance button, leaving the local planner resident. H3 # inference needs that VRAM; the planner is cheap to reload later. @@ -10348,6 +11039,7 @@ async def generate(request: Request): except Exception as unload_error: print(f"[MiniMax H3] Planner LLM release skipped: {unload_error}") else: + body.pop("_h3_window_plan_pending", None) body.pop("h3_window_prompts", None) body.pop("h3_window_plan_signature", None) body.pop("h3_window_plan", None) @@ -10502,7 +11194,14 @@ async def generate(request: Request): workspace = body.pop("workspace", None) or _get_active_workspace() job_out_dir = _workspace_dir(workspace) - job = _new_generation_job(body, workspace) + h3_preplan_pending = isinstance( + body.get("_h3_window_plan_pending"), dict, + ) + job = _new_generation_job( + body, + workspace, + reserve_generation=not h3_preplan_pending, + ) job_id = job["id"] job["out_dir"] = job_out_dir _jobs[job_id] = job @@ -10516,10 +11215,19 @@ async def generate(request: Request): _cancel_h3_idle_release() # Non-daemon so generation survives browser disconnect during overnight runs - thread = threading.Thread(target=_run_generation, args=(job_id,), daemon=False) + thread = threading.Thread( + target=_run_generation_with_preparation, + args=(job_id,), + daemon=False, + ) thread.start() - response = {"job_id": job_id, "status": "queued"} + response = { + "job_id": job_id, + "task_id": job.get("task_id"), + "root_task_id": job.get("root_task_id") or job.get("task_id"), + "status": "queued", + } if h3_window_plan_response is not None: response["h3_window_plan"] = h3_window_plan_response return response @@ -10616,13 +11324,17 @@ async def retake_video_endpoint(request: Request): "params": gen_params, "output_files": [], "error": None, "workspace": workspace, "out_dir": job_out_dir, } - _jobs[job_id] = job - register_generation_job(_gen_lock, job) + _register_manual_generation_job(job) thread = threading.Thread(target=_run_generation, args=(job_id,), daemon=False) thread.start() - return {"job_id": job_id, "status": "queued", "retake_frames": f"{start_frame}-{end_frame}/{total_frames}"} + return { + "job_id": job_id, + "status": "queued", + "retake_frames": f"{start_frame}-{end_frame}/{total_frames}", + **_generation_job_acceptance(job), + } @api.post("/api/v1/extract-frames") @@ -11181,8 +11893,7 @@ def _resolve_anchor_path(raw): "params": gen_params, "output_files": [], "error": None, "workspace": workspace, "out_dir": job_out_dir, } - _jobs[job_id] = job - register_generation_job(_gen_lock, job) + _register_manual_generation_job(job) thread = threading.Thread(target=_run_generation, args=(job_id,), daemon=False) thread.start() @@ -11191,6 +11902,7 @@ def _resolve_anchor_path(raw): "job_id": job_id, "status": "queued", "edit_range": f"{start_frame}-{end_frame}/{total_frames}", "lora_filename": EDIT_ANYTHING_LORA_FILENAME, + **_generation_job_acceptance(job), } @@ -16877,8 +17589,7 @@ async def repaint_endpoint(request: Request): "workspace": workspace_name, "out_dir": _workspace_dir(workspace_name), } - _jobs[job_id] = job - register_generation_job(_gen_lock, job) + _register_manual_generation_job(job) if not mappings: threading.Thread( @@ -16896,6 +17607,7 @@ async def repaint_endpoint(request: Request): "sliding_window_size": repaint_window_size, "num_inference_steps": inference_steps, "guidance_scale": guidance_scale, + **_generation_job_acceptance(job), } def _run_repaint(): @@ -16903,7 +17615,9 @@ def _run_repaint(): shot_temp_dir = None shot_final_out_dir = None try: - with generation_slot(_gen_lock, job) as acquired: + with _coordinated_generation_slot( + job, description="Maestro GPU preparation · repaint", + ) as acquired: if not acquired: return if not try_start( @@ -17144,6 +17858,7 @@ def _mapping_progress(index, count, done, total): "sliding_window_size": repaint_window_size, "num_inference_steps": inference_steps, "guidance_scale": guidance_scale, + **_generation_job_acceptance(job), } @@ -17836,8 +18551,7 @@ async def recast_endpoint(request: Request): "params": gen_params, "output_files": [], "error": None, "workspace": workspace_name, "out_dir": job_out_dir, } - _jobs[job_id] = job - register_generation_job(_gen_lock, job) + _register_manual_generation_job(job) initial_probe_frame = probe_frame def _run_recast(): @@ -17857,7 +18571,9 @@ def _run_recast(): # is released before _run_generation, which re-acquires it for # the generation phase; a waiting job may slip its detection in # between, but everything stays strictly one-GPU-task-at-a-time. - with generation_slot(_gen_lock, job) as acquired: + with _coordinated_generation_slot( + job, description="Maestro GPU preparation · recast", + ) as acquired: if not acquired: return if not try_start( @@ -18576,6 +19292,7 @@ def _native_people_progress(done, total): "sliding_window_size": recast_window_size, "num_inference_steps": inference_steps, "guidance_scale": guidance_scale, + **_generation_job_acceptance(job), } @@ -19221,7 +19938,9 @@ def _prepare_and_run_outpaint(job_id): # Match Recast/Repaint's two-phase lifecycle. Preparation is short and # CPU-bound, but taking the slot preserves submission order and avoids # stacking ffmpeg decoding on top of another active generation. - with generation_slot(_gen_lock, job) as acquired: + with _coordinated_generation_slot( + job, description="Maestro GPU preparation · outpaint", + ) as acquired: if not acquired: return if not try_start( @@ -19908,8 +20627,7 @@ async def outpaint_endpoint(request: Request): "params": gen_params, "output_files": [], "error": None, "workspace": workspace, "out_dir": job_out_dir, } - _jobs[job_id] = job - register_generation_job(_gen_lock, job) + _register_manual_generation_job(job) worker = ( _prepare_and_run_outpaint @@ -19937,6 +20655,7 @@ async def outpaint_endpoint(request: Request): "total_frames": total_frames, "sliding_window_size": sliding_window_size, "sliding_window_count": _window_count, + **_generation_job_acceptance(job), } @@ -20370,13 +21089,18 @@ def _write_mp4(path: str, frames: list, fps_val: float): "params": gen_params, "output_files": [], "error": None, "workspace": workspace, "out_dir": job_out_dir, } - _jobs[job_id] = job - register_generation_job(_gen_lock, job) + _register_manual_generation_job(job) thread = threading.Thread(target=_run_blend_generation, args=(job_id,), daemon=False) thread.start() - return {"job_id": job_id, "status": "queued", "overlap_sec": overlap_sec_eff, "frames": transition_frames} + return { + "job_id": job_id, + "status": "queued", + "overlap_sec": overlap_sec_eff, + "frames": transition_frames, + **_generation_job_acceptance(job), + } except Exception: # Setup failed before the background thread took ownership of temp_dir. @@ -21001,13 +21725,18 @@ async def inpaint_endpoint(request: Request): "params": gen_params, "output_files": [], "error": None, "workspace": workspace, "out_dir": job_out_dir, } - _jobs[job_id] = job - register_generation_job(_gen_lock, job) + _register_manual_generation_job(job) thread = threading.Thread(target=_run_generation, args=(job_id,), daemon=False) thread.start() - return {"job_id": job_id, "status": "queued", "target": intent["target"], "prompt": intent["prompt"]} + return { + "job_id": job_id, + "status": "queued", + "target": intent["target"], + "prompt": intent["prompt"], + **_generation_job_acceptance(job), + } def _apply_film_grain_to_file(video_path: str, intensity: float, saturation: float): @@ -21999,7 +22728,18 @@ def _resolve_tool_clip_path(raw_path, workspace=None): return raw_path if os.path.isfile(raw_path) else None -def _write_tool_sidecar(out_dir, filename, *, source_name, tool, params, elapsed, job_id): +def _write_tool_sidecar( + out_dir, + filename, + *, + source_name, + tool, + params, + elapsed, + job_id, + task_id=None, + root_task_id=None, +): """Write a .meta.json sidecar so a Tools output shows up in the gallery with the right mode + edit_sub_mode tag (mirrors _run_sfx_generation).""" sidecar = { @@ -22008,6 +22748,8 @@ def _write_tool_sidecar(out_dir, filename, *, source_name, tool, params, elapsed "tool": tool, "tool_source": source_name, "job_id": job_id, + "task_id": task_id, + "root_task_id": root_task_id or task_id, "generation_time": round(elapsed), "created_at": time.time(), } @@ -22027,7 +22769,9 @@ def _run_tool_upscale(job_id: str): start_time = None abort_state = {"abort": False} audio_tracks = [] - with generation_slot(_gen_lock, job) as acquired: + with _coordinated_generation_slot( + job, description="Maestro GPU tool · upscale", + ) as acquired: if not acquired: return False try: @@ -22155,7 +22899,17 @@ def _progress(phase, current_step=None, total_steps=None): if is_cancel_requested(job): return False for fname in new_files: - _write_tool_sidecar(out_dir, fname, source_name=os.path.basename(video_source), tool="upscale", params={"method": method, "model_type": "post_processing"}, elapsed=time.time() - start_time, job_id=job_id) + _write_tool_sidecar( + out_dir, + fname, + source_name=os.path.basename(video_source), + tool="upscale", + params={"method": method, "model_type": "post_processing"}, + elapsed=time.time() - start_time, + job_id=job_id, + task_id=job.get("task_id"), + root_task_id=job.get("root_task_id"), + ) completed = finish_job( job, @@ -22191,7 +22945,9 @@ def _run_tool_revoice(job_id: str): start_time = None abort_state = {"abort": False} final_path = None - with generation_slot(_gen_lock, job) as acquired: + with _coordinated_generation_slot( + job, description="Maestro GPU tool · revoice", + ) as acquired: if not acquired: return False try: @@ -22289,7 +23045,17 @@ def _run_tool_revoice(job_id: str): except OSError: pass return False - _write_tool_sidecar(out_dir, fname, source_name=os.path.basename(video_source), tool="revoice", params={"mode": mode, "model_type": "post_processing"}, elapsed=time.time() - start_time, job_id=job_id) + _write_tool_sidecar( + out_dir, + fname, + source_name=os.path.basename(video_source), + tool="revoice", + params={"mode": mode, "model_type": "post_processing"}, + elapsed=time.time() - start_time, + job_id=job_id, + task_id=job.get("task_id"), + root_task_id=job.get("root_task_id"), + ) completed = finish_job( job, @@ -22326,20 +23092,26 @@ async def tools_upscale(request: Request): raise HTTPException(status_code=400, detail=f"Clip not found: {video_path}") job_id = uuid.uuid4().hex[:8] - _jobs[job_id] = { + job = { "id": job_id, "status": "queued", "progress": 0, "step": 0, "total_steps": 0, "phase": "", "message": "Queued (upscale)", "created_at": time.time(), "params": { "video_path": resolved, "method": body.get("method") or "flashvsr2", "seed": body.get("seed", -1), + "model_type": "post_processing", + "generation_mode": "video", }, "output_files": [], "error": None, "workspace": workspace, "out_dir": _workspace_dir(workspace), } - register_generation_job(_gen_lock, _jobs[job_id]) + _register_manual_generation_job(job) threading.Thread(target=_run_tool_upscale, args=(job_id,), daemon=False).start() - return {"job_id": job_id, "status": "queued"} + return { + "job_id": job_id, + "status": "queued", + **_generation_job_acceptance(job), + } @api.post("/api/v1/tools/revoice") @@ -22370,7 +23142,7 @@ async def tools_revoice(request: Request): mode = "single" job_id = uuid.uuid4().hex[:8] - _jobs[job_id] = { + job = { "id": job_id, "status": "queued", "progress": 0, "step": 0, "total_steps": 0, "phase": "", "message": "Queued (revoice)", "created_at": time.time(), "params": { @@ -22379,13 +23151,19 @@ async def tools_revoice(request: Request): "mode": mode, "diffusion_steps": body.get("diffusion_steps", 25), "cfg_rate": body.get("cfg_rate", 0.5), + "model_type": "post_processing", + "generation_mode": "video", }, "output_files": [], "error": None, "workspace": workspace, "out_dir": _workspace_dir(workspace), } - register_generation_job(_gen_lock, _jobs[job_id]) + _register_manual_generation_job(job) threading.Thread(target=_run_tool_revoice, args=(job_id,), daemon=False).start() - return {"job_id": job_id, "status": "queued"} + return { + "job_id": job_id, + "status": "queued", + **_generation_job_acceptance(job), + } def _run_generation(job_id: str, *, finalize: bool = True) -> bool: @@ -22398,7 +23176,7 @@ def _run_generation(job_id: str, *, finalize: bool = True) -> bool: active_task_timer = None abort_state = None - with generation_slot(_gen_lock, job) as acquired: + with _coordinated_generation_slot(job) as acquired: if not acquired: return False try: @@ -22408,6 +23186,7 @@ def _run_generation(job_id: str, *, finalize: bool = True) -> bool: "Restarting recovered job from the beginning…" if job.get("recovered") else "Preparing..." ), + acquired_resources=[_local_gpu_lane.key], ): return False # Active generation timing begins only after this job owns the GPU @@ -22553,6 +23332,8 @@ def _legacy_h3_progress( "upload_filenames": upload_filenames, "generation_mode": "video", "job_id": job_id, + "task_id": job.get("task_id"), + "root_task_id": job.get("root_task_id") or job.get("task_id"), "generation_time": round(time.time() - start_time), "created_at": time.time(), } @@ -23159,6 +23940,8 @@ def _write_output_sidecars(file_names): "upload_filenames": upload_filenames, "generation_mode": job["params"].get("generation_mode"), "job_id": job_id, + "task_id": job.get("task_id"), + "root_task_id": job.get("root_task_id") or job.get("task_id"), "generation_time": round(time.time() - start_time), "created_at": time.time(), } @@ -24128,24 +24911,25 @@ def _adaptive_protection_progress(done, total): finish_job(job, "failed", **failure_updates) return False finally: - if abort_state is not None: - unregister_abort_state(job_id, _active_gen_states, abort_state) # Restore the persisted base coefficient so the next job # starts from the user's auto-tuned value, not whatever # this job's adjustment left it at. _restore_base_coefficient() + if abort_state is not None: + # Unregistration is the cancellation acknowledgement. Keep it + # after per-job cleanup so `cancelled` never means inference is + # still unwinding. + unregister_abort_state( + job_id, _active_gen_states, abort_state, + ) + else: + acknowledge_cancel(job) # If no other jobs are running, sync save_path to the current active # workspace (which may have changed while this job was running). if not _active_gen_states: active_dir = _workspace_dir() wgp.save_path = active_dir wgp.image_save_path = active_dir - if is_cancel_requested(job) or job.get("_cancel_requested"): - # This is the terminal acknowledgement observed by Director. - # It is intentionally last: until here, the GPU job is still - # considered active and a second PRE launch remains blocked. - job["status"] = "cancelled" - job["message"] = "Cancelled" # The generation itself is terminal now. Remove its request before # optional model-idle cleanup so a shutdown during cleanup cannot # offer a duplicate completed job for recovery. @@ -24937,6 +25721,8 @@ def get_status(job_id: str): ) return { "job_id": j["id"], + "task_id": j.get("task_id"), + "root_task_id": j.get("root_task_id") or j.get("task_id"), "status": j["status"], "progress": j["progress"], "step": j.get("step", 0), @@ -24957,6 +25743,11 @@ def get_status(job_id: str): if j.get("status") == "queued" else None ), "generation_details": _public_generation_details(j.get("params")), + "h3_window_plan": ( + (j.get("params") or {}).get("h3_window_plan") + if isinstance(j.get("params"), dict) + else None + ), # Present only on failed jobs that look like CUDA OOMs. UI # renders the OOM recovery banner when this is non-null. "oom_info": j.get("oom_info"), @@ -24970,11 +25761,7 @@ def cancel_job(job_id: str): raise HTTPException(status_code=404, detail="Job not found") if job_id.startswith("audio-analysis-"): - job = _jobs[job_id] - if job["status"] in ("queued", "running"): - job["_cancel_requested"] = True - job["message"] = "Cancelling after the current analysis phase…" - return {"job_id": job_id, "status": job["status"]} + return cancel_audio_analysis_job(job_id) job = _jobs[job_id] result = request_cancel( @@ -24999,9 +25786,11 @@ def list_jobs(): active = [] for job in list(_jobs.values()): j = snapshot_job(job) - if j["status"] in ("queued", "running"): + if j["status"] in ("queued", "waiting_resource", "running", "cancelling"): active.append({ "job_id": j["id"], + "task_id": j.get("task_id"), + "root_task_id": j.get("root_task_id") or j.get("task_id"), "status": j["status"], "progress": j["progress"], "step": j.get("step", 0), @@ -25076,18 +25865,25 @@ def resume_generation_queue(): _durable_generation_queue.remove(job_id) continue workspace = str(record.get("workspace") or "default") + _reset_canonical_task_for_resume( + workspace, + f"task-generation-{job_id}", + ) job = _new_generation_job( params, workspace, job_id=job_id, created_at=float(record.get("created_at") or time.time()), recovered=True, + reserve_generation=not isinstance( + params.get("_h3_window_plan_pending"), dict, + ), ) _jobs[job_id] = job _persist_generation_job(job) _cancel_h3_idle_release() threads.append(threading.Thread( - target=_run_generation, + target=_run_generation_with_preparation, args=(job_id,), name=f"recovered-generation-{job_id}", daemon=False, @@ -25508,7 +26304,7 @@ def get_comic_output(name: str): raise HTTPException(status_code=400, detail=f"Invalid comic project: {exc}") from exc -def _comic_reference_image_file(source: str) -> str: +def _comic_reference_image_file(source: str, workspace: str | None = None) -> str: """Resolve a local asset to base64, or preserve a validated public URL.""" if source.startswith("data:image/"): if len(source) > 25 * 1024 * 1024: @@ -25534,7 +26330,7 @@ def _comic_reference_image_file(source: str) -> str: if source.startswith("/api/v1/file/"): filename = source.split("/api/v1/file/", 1)[1] from urllib.parse import unquote - path = _safe_join(_workspace_dir(), unquote(filename)) + path = _safe_join(_workspace_dir(workspace), unquote(filename)) elif source.startswith("/api/v1/uploads/"): filename = source.split("/api/v1/uploads/", 1)[1] from urllib.parse import unquote @@ -25549,69 +26345,361 @@ def _comic_reference_image_file(source: str) -> str: return f"data:{mime};base64,{base64.b64encode(handle.read()).decode('ascii')}" -@api.post("/api/v1/comics/generate/minimax") -def generate_comic_minimax(body: dict): - """Generate one comic panel with MiniMax image-01 and persist it.""" - prompt = str(body.get("prompt") or "").strip() - services = wgp.server_config.get("services", {}) - api_key = services.get("minimax_api_key", "") - aspect_ratio = str(body.get("aspect_ratio") or "1:1") - subject = body.get("subject_reference") +_minimax_image_jobs: dict[str, dict] = {} +_minimax_image_jobs_lock = threading.RLock() + + +def _publish_minimax_image_job(job: dict) -> dict | None: + publisher = globals().get("_publish_generic_legacy_task") + if not callable(publisher): + return None try: - generated = minimax_image_service.generate_image( - api_key=api_key, - prompt=prompt, - aspect_ratio=aspect_ratio, - output_dir=_workspace_dir(), - subject_reference=( - _comic_reference_image_file(str(subject)) if subject else "" - ), - filename_prefix="minimax-comic", + return publisher(copy.deepcopy(job), "minimax-image") + except Exception as exc: + print(f"[Task registry] Could not publish MiniMax image {job.get('jobId')}: {exc}") + return None + + +def _minimax_image_job_update(job_id: str, **patch) -> dict | None: + with _minimax_image_jobs_lock: + job = _minimax_image_jobs.get(job_id) + if job is None: + return None + requested_status = str(patch.get("status") or "") + if ( + job.get("_cancel_requested") + and requested_status in { + "created", "queued", "waiting_resource", "running", + } + ): + return copy.deepcopy(job) + if ( + str(job.get("status") or "") in { + "completed", "failed", "cancelled", "interrupted", + } + and requested_status + and requested_status not in { + "completed", "failed", "cancelled", "interrupted", + } + ): + return copy.deepcopy(job) + job.update(patch) + job["updatedAt"] = time.time() + snapshot = copy.deepcopy(job) + _publish_minimax_image_job(snapshot) + return snapshot + + +def _minimax_image_claim_provider(job_id: str, lane_key: str) -> dict | None: + """Atomically cross the last cancellable boundary before a paid call.""" + with _minimax_image_jobs_lock: + job = _minimax_image_jobs.get(job_id) + if ( + job is None + or job.get("_cancel_requested") + or str(job.get("status") or "") in { + "completed", "failed", "cancelled", "interrupted", + } + ): + return None + now = time.time() + job.update( + status="running", + phase="requesting", + message="Generating image with MiniMax Image-01…", + startedAt=job.get("startedAt") or now, + acquired_resources=[lane_key], + updatedAt=now, ) - except minimax_image_service.MiniMaxImageError as exc: - raise HTTPException(status_code=exc.status_code, detail=str(exc)) from exc - name = generated["name"] - return {"asset": { - "id": f"asset-{uuid.uuid4().hex[:12]}", - "name": name, - "kind": "minimax", - "source": f"/api/v1/file/{name}", - "thumbnail": f"/api/v1/file/{name}", - "prompt": generated["prompt"], - "provider": "minimax", - "model": "image-01", - "createdAt": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), - "metadata": { - "subjectReference": generated["subject_reference"], - "aspectRatio": generated["aspect_ratio"], - }, - }} + snapshot = copy.deepcopy(job) + _publish_minimax_image_job(snapshot) + return snapshot -_COMIC_PLAN_SCHEMA = { - "type": "object", - "additionalProperties": False, - "required": [ - "title", - "logline", - "synopsis", - "storyStructure", - "styleBible", - "characters", - "pages", - ], - "properties": { - "title": {"type": "string"}, - "logline": {"type": "string"}, - "synopsis": {"type": "string"}, - "storyStructure": { - "type": "array", - "items": { - "type": "object", - "additionalProperties": False, - "required": [ - "pageNumber", - "stage", +def _public_minimax_image_job(job: dict) -> dict: + return { + key: copy.deepcopy(value) + for key, value in job.items() + if key not in {"request", "_cancel_requested"} + } + + +def _run_minimax_image_job(job_id: str) -> None: + with _minimax_image_jobs_lock: + job = copy.deepcopy(_minimax_image_jobs.get(job_id) or {}) + if not job: + return + + def cancelled() -> bool: + with _minimax_image_jobs_lock: + return bool( + (_minimax_image_jobs.get(job_id) or {}).get("_cancel_requested") + ) + + if cancelled(): + _minimax_image_job_update( + job_id, status="cancelled", phase="cancelled", message="Cancelled", + finishedAt=time.time(), + ) + return + + request_body = job.get("request") if isinstance(job.get("request"), dict) else {} + workspace = str(job.get("workspace") or "default") + lane = resource_scheduler.remote_lane("minimax", minimax_image_service.API_URL) + _minimax_image_job_update( + job_id, + status="waiting_resource", + phase="waiting_resource", + message="Waiting for MiniMax Image-01 API", + acquired_resources=[], + ) + try: + with resource_scheduler.coordinator.acquire( + lane, + task_id=str(job.get("taskId") or job_id), + description="MiniMax Image-01 user request", + cancelled=cancelled, + ): + claimed = _minimax_image_claim_provider(job_id, lane.key) + if claimed is None: + raise resource_scheduler.ResourceAcquireCancelled( + f"MiniMax image job {job_id} was cancelled" + ) + generated = minimax_image_service.generate_image( + api_key=str( + (wgp.server_config.get("services") or {}).get("minimax_api_key") + or "" + ), + prompt=str(request_body.get("prompt") or ""), + aspect_ratio=str(request_body.get("aspect_ratio") or "1:1"), + output_dir=_workspace_dir(workspace), + subject_reference=( + _comic_reference_image_file( + str(request_body.get("subject_reference") or ""), workspace, + ) + if request_body.get("subject_reference") else "" + ), + filename_prefix="minimax-comic", + task_id=str(job.get("taskId") or ""), + root_task_id=str(job.get("rootTaskId") or job.get("taskId") or ""), + ) + name = generated["name"] + asset = { + "id": f"asset-{uuid.uuid4().hex[:12]}", + "name": name, + "kind": "minimax", + "source": f"/api/v1/file/{name}", + "thumbnail": f"/api/v1/file/{name}", + "prompt": generated["prompt"], + "provider": "minimax", + "model": "image-01", + "createdAt": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), + "metadata": { + "jobId": job_id, + "taskId": job.get("taskId"), + "rootTaskId": job.get("rootTaskId") or job.get("taskId"), + "subjectReference": generated["subject_reference"], + "aspectRatio": generated["aspect_ratio"], + }, + } + if cancelled(): + _minimax_image_job_update( + job_id, + status="cancelled", + phase="cancelled", + message="Cancelled after the provider reached a safe boundary", + output_files=[name], + result={"asset": asset}, + finishedAt=time.time(), acquired_resources=[], + ) + else: + _minimax_image_job_update( + job_id, + status="completed", + phase="completed", + message="MiniMax image generated", + current=1, + total=1, + progress=100, + output_files=[name], + result={"asset": asset}, + finishedAt=time.time(), acquired_resources=[], + ) + except resource_scheduler.ResourceAcquireCancelled: + _minimax_image_job_update( + job_id, status="cancelled", phase="cancelled", message="Cancelled", + finishedAt=time.time(), acquired_resources=[], + ) + except minimax_image_service.MiniMaxImageError as exc: + _minimax_image_job_update( + job_id, status="failed", phase="failed", message=str(exc), + error=str(exc), statusCode=exc.status_code, finishedAt=time.time(), + acquired_resources=[], + ) + except Exception as exc: + traceback.print_exc() + _minimax_image_job_update( + job_id, status="failed", phase="failed", + message=f"MiniMax image generation failed: {exc}", error=str(exc), + finishedAt=time.time(), acquired_resources=[], + ) + + +@api.post("/api/v1/comics/generate/minimax/jobs") +def start_comic_minimax_job(body: dict): + """Start one observable, cancellable MiniMax Image-01 request.""" + workspace = body.get("workspace") if "workspace" in body else _get_active_workspace() + _workspace_dir(workspace) + job_id = f"minimax-image-{uuid.uuid4().hex[:12]}" + now = time.time() + job = { + "jobId": job_id, + "workspace": workspace, + "status": "queued", + "phase": "queued", + "message": "MiniMax image request queued", + "current": 0, + "total": 1, + "progress": 0, + "provider": "minimax", + "model": "image-01", + "server_origin": "https://api.minimax.io", + "resource_lane": "remote:https://api.minimax.io", + "acquired_resources": [], + "output_files": [], + "result": None, + "error": None, + "createdAt": now, + "updatedAt": now, + "_cancel_requested": False, + "request": { + "prompt": str(body.get("prompt") or ""), + "aspect_ratio": str(body.get("aspect_ratio") or "1:1"), + "subject_reference": body.get("subject_reference"), + }, + } + # Validate inexpensive request fields before returning a durable job ID. + try: + minimax_image_service.prepare_prompt(job["request"]["prompt"]) + if job["request"]["aspect_ratio"] not in minimax_image_service.SUPPORTED_ASPECT_RATIOS: + raise minimax_image_service.MiniMaxImageError( + "Unsupported MiniMax image aspect ratio", 400, + ) + except minimax_image_service.MiniMaxImageError as exc: + raise HTTPException(status_code=exc.status_code, detail=str(exc)) from exc + with _minimax_image_jobs_lock: + _minimax_image_jobs[job_id] = job + task = _publish_minimax_image_job(job) + if isinstance(task, dict): + with _minimax_image_jobs_lock: + _minimax_image_jobs[job_id]["taskId"] = task.get("id") + _minimax_image_jobs[job_id]["rootTaskId"] = task.get("root_id") or task.get("id") + job = copy.deepcopy(_minimax_image_jobs[job_id]) + threading.Thread( + target=_run_minimax_image_job, + args=(job_id,), + name=f"minimax-image-{job_id[-6:]}", + daemon=True, + ).start() + return _public_minimax_image_job(job) + + +@api.get("/api/v1/comics/generate/minimax/jobs/{job_id}") +def get_comic_minimax_job(job_id: str): + with _minimax_image_jobs_lock: + job = copy.deepcopy(_minimax_image_jobs.get(job_id) or {}) + if not job: + raise HTTPException(status_code=404, detail="MiniMax image job not found") + return _public_minimax_image_job(job) + + +@api.post("/api/v1/comics/generate/minimax/jobs/{job_id}/cancel") +def cancel_comic_minimax_job(job_id: str): + with _minimax_image_jobs_lock: + job = _minimax_image_jobs.get(job_id) + if job is None: + raise HTTPException(status_code=404, detail="MiniMax image job not found") + if job.get("status") in {"completed", "failed", "cancelled"}: + return _public_minimax_image_job(job) + job["_cancel_requested"] = True + waiting = job.get("status") in {"created", "queued", "waiting_resource"} + job["status"] = "cancelled" if waiting else "cancelling" + job["phase"] = job["status"] + job["message"] = ( + "Cancelled before the provider call" + if waiting else "Cancellation requested; waiting for a safe provider boundary…" + ) + job["updatedAt"] = time.time() + if waiting: + job["finishedAt"] = time.time() + snapshot = copy.deepcopy(job) + _publish_minimax_image_job(snapshot) + return _public_minimax_image_job(snapshot) + + +@api.post("/api/v1/comics/generate/minimax") +def generate_comic_minimax(body: dict): + """Generate one comic panel with MiniMax image-01 and persist it.""" + prompt = str(body.get("prompt") or "").strip() + services = wgp.server_config.get("services", {}) + api_key = services.get("minimax_api_key", "") + aspect_ratio = str(body.get("aspect_ratio") or "1:1") + subject = body.get("subject_reference") + try: + generated = minimax_image_service.generate_image( + api_key=api_key, + prompt=prompt, + aspect_ratio=aspect_ratio, + output_dir=_workspace_dir(), + subject_reference=( + _comic_reference_image_file(str(subject)) if subject else "" + ), + filename_prefix="minimax-comic", + ) + except minimax_image_service.MiniMaxImageError as exc: + raise HTTPException(status_code=exc.status_code, detail=str(exc)) from exc + name = generated["name"] + return {"asset": { + "id": f"asset-{uuid.uuid4().hex[:12]}", + "name": name, + "kind": "minimax", + "source": f"/api/v1/file/{name}", + "thumbnail": f"/api/v1/file/{name}", + "prompt": generated["prompt"], + "provider": "minimax", + "model": "image-01", + "createdAt": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), + "metadata": { + "subjectReference": generated["subject_reference"], + "aspectRatio": generated["aspect_ratio"], + }, + }} + + +_COMIC_PLAN_SCHEMA = { + "type": "object", + "additionalProperties": False, + "required": [ + "title", + "logline", + "synopsis", + "storyStructure", + "styleBible", + "characters", + "pages", + ], + "properties": { + "title": {"type": "string"}, + "logline": {"type": "string"}, + "synopsis": {"type": "string"}, + "storyStructure": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": False, + "required": [ + "pageNumber", + "stage", "goal", "turningPoint", ], @@ -26271,7 +27359,7 @@ def _story_lab_schema(scope: str, project_type: str = "full_story") -> dict: "additionalProperties": False, } beat_minimum = 3 if project_type == "quick_video" else 4 if project_type == "music_video" else 6 - beat_maximum = 8 if project_type == "quick_video" else 10 if project_type == "music_video" else 14 + beat_maximum = 8 if project_type == "quick_video" else 10 if project_type == "music_video" else 12 if project_type == "trailer" else 14 music_minimum = 1 if project_type == "music_video" else 4 music_maximum = 1 if project_type == "music_video" else 16 creative_brief = { @@ -26894,6 +27982,7 @@ def _story_stage_problem(result: dict, scope: str, project: dict) -> str | None: "structure": ( (3, 8) if project_type == "quick_video" else (4, 10) if project_type == "music_video" + else (6, 12) if project_type == "trailer" else (6, 14) ), } @@ -26930,7 +28019,8 @@ def _story_stage_problem(result: dict, scope: str, project: dict) -> str | None: def _story_project_prompt_context(project: dict, scope: str) -> str: """Return bounded, valid JSON with editorial facts but no heavy runtime data.""" overview_keys = ( - "title", "projectType", "creativeBrief", "language", "genre", "tone", "audience", "premise", + "title", "projectType", "creativeBrief", "language", "spokenLanguage", "locationVariety", + "protagonistConsistency", "protagonistCharacterId", "genre", "tone", "audience", "premise", "logline", "synopsis", "theme", "ending", "visualStyle", "characterVisualStyle", "enforceVisualStyle", "allowClipText", ) @@ -27159,7 +28249,8 @@ def put_series_project_endpoint(series_id: str, body: dict): ) updated = normalize_series_project({**raw_series, "id": series_id}, series_id, workspace) canon_inputs = ( - "title", "premise", "logline", "format", "language", "genre", "tone", "audience", + "title", "premise", "logline", "format", "language", "spokenLanguage", + "protagonistConsistency", "protagonistCharacterId", "genre", "tone", "audience", "visualStyle", "characterVisualStyle", "cameraLanguage", "sourceMode", "masterUniversePrompt", "characters", "relationships", "locations", "props", ) @@ -27588,10 +28679,21 @@ def _series_plan_update(job_id: str, **patch) -> dict | None: job = _series_plan_jobs.get(job_id) if not job: return None + if ( + job.get("status") == "cancelling" + and patch.get("status") not in {"cancelling", "cancelled"} + ): + return copy.deepcopy(job) job.update(copy.deepcopy(patch)) job["updatedAt"] = time.time() snapshot = copy.deepcopy(job) _series_plan_store(str(job["workspace"])).save(snapshot) + publisher = globals().get("_publish_series_task") + if callable(publisher): + try: + publisher(snapshot, "series-plan") + except Exception as exc: + print(f"[Task registry] Could not publish Series plan {job_id}: {exc}") return snapshot @@ -27646,6 +28748,7 @@ def _run_series_plan_job_inner(job_id: str) -> None: from services.series_planning import ( apply_planning_stage, normalize_planning_result, + planning_output_token_budget, planning_prompt, planning_schema, planning_stages, @@ -27654,7 +28757,7 @@ def _run_series_plan_job_inner(job_id: str) -> None: job = _load_series_plan_job(job_id) if not job: return - if job.get("status") == "cancelled": + if job.get("status") in {"cancelling", "cancelled"}: return request = copy.deepcopy(job.get("request") or {}) series = request.get("seriesSnapshot") if isinstance(request.get("seriesSnapshot"), dict) else {} @@ -27667,7 +28770,7 @@ def _run_series_plan_job_inner(job_id: str) -> None: _ensure_llm_loaded() for index, stage in enumerate(stages): latest = _load_series_plan_job(job_id) - if latest and latest.get("status") == "cancelled": + if latest and latest.get("status") in {"cancelling", "cancelled"}: return if stage in completed: result = normalize_planning_result(stage, completed[stage], series, episode) @@ -27682,20 +28785,88 @@ def _run_series_plan_job_inner(job_id: str) -> None: raw = _generate_comic_director_json( prompt=prompt, system_prompt=system_prompt, - schema=planning_schema(stage), - max_new_tokens=5000 if stage in {"script", "shots"} else 2400, + schema=planning_schema(stage, episode), + # Long episodes need proportionally more independent + # 5/10/15-second clips; keep enough output room for their + # complete structured shot records. + max_new_tokens=planning_output_token_budget(stage, episode), stage=f"Series Lab {stage}", llm_override=llm_override, ) - result = normalize_planning_result(stage, raw, series, episode) + # JSON-schema support differs between providers. A response can + # be valid JSON yet still violate a cross-field production rule + # such as "every speaker is visible" or the single-speaker clip + # limit. Give the writing model two bounded, evidence-rich + # semantic repair passes instead of throwing away all earlier + # durable stages after the first invalid response. + semantic_error = None + for repair_attempt in range(3): + try: + result = normalize_planning_result(stage, raw, series, episode) + semantic_error = None + break + except ValueError as exc: + semantic_error = exc + if repair_attempt >= 2: + raise + previous_response = json.dumps( + raw, ensure_ascii=False, separators=(",", ":"), default=str, + ) + previous_limit = min( + 250000, + planning_output_token_budget(stage, episode) * 4, + ) + if len(previous_response) > previous_limit: + previous_response = ( + previous_response[:previous_limit] + + "\n[previous response truncated for bounded Series Lab repair]" + ) + _series_plan_update( + job_id, + status="running", + stage=stage, + current=index, + total=len(stages), + validationAttempt=repair_attempt + 1, + validationError=str(exc), + message=( + f"Repairing Series Lab {stage.replace('_', ' ')} " + f"({repair_attempt + 1}/2): {exc}" + ), + ) + repair_prompt = ( + f"{prompt}\n\nSERIES STAGE VALIDATION REPAIR: The previous response " + f"failed this production rule: {exc}. Preserve all valid story facts and " + "dialogue, but correct the smallest necessary structure. For a shot with " + "more than one actual dialogue speaker, distribute every speaker turn across " + "separate single-speaker shots. Keep the duration-aware shot count stated above; " + "use only 5, 10, or 15 seconds and never exceed 15 seconds. " + "speakingCharacterIds must " + "equal the unique characterId values in dialogueBeats, not every visible " + "participant. Return exactly one complete JSON object matching the schema.\n" + f"PREVIOUS RESPONSE TO REPAIR:\n{previous_response}" + ) + raw = _generate_comic_director_json( + prompt=repair_prompt, + system_prompt=system_prompt, + schema=planning_schema(stage, episode), + max_new_tokens=planning_output_token_budget(stage, episode), + stage=f"Series Lab {stage} validation repair {repair_attempt + 1}", + llm_override=llm_override, + ) + latest = _load_series_plan_job(job_id) + if latest and latest.get("status") in {"cancelling", "cancelled"}: + return + if semantic_error is not None: # pragma: no cover - loop raises first + raise semantic_error latest = _load_series_plan_job(job_id) - if latest and latest.get("status") == "cancelled": + if latest and latest.get("status") in {"cancelling", "cancelled"}: return completed[stage] = result episode = apply_planning_stage(episode, stage, result) _series_plan_update( job_id, completedStages=completed, episodeResult=episode, - current=index + 1, stage=stage, + current=index + 1, stage=stage, validationAttempt=0, validationError=None, ) _series_plan_update( job_id, status="completed", stage="completed", current=len(stages), total=len(stages), @@ -27703,6 +28874,9 @@ def _run_series_plan_job_inner(job_id: str) -> None: result={"episode": episode}, error=None, finishedAt=time.time(), ) except Exception as exc: + latest = _load_series_plan_job(job_id) + if latest and latest.get("status") in {"cancelling", "cancelled"}: + return detail = exc.detail if isinstance(exc, HTTPException) else str(exc) _series_plan_update( job_id, status="failed", error=str(detail), finishedAt=time.time(), @@ -27749,7 +28923,7 @@ def _run_series_canon_plan_job_inner(job_id: str) -> None: job = _load_series_plan_job(job_id) if not job: return - if job.get("status") == "cancelled": + if job.get("status") in {"cancelling", "cancelled"}: return request = copy.deepcopy(job.get("request") or {}) series = request.get("seriesSnapshot") if isinstance(request.get("seriesSnapshot"), dict) else {} @@ -27786,7 +28960,7 @@ def _run_series_canon_plan_job_inner(job_id: str) -> None: stage=stage_label, llm_override=llm_override, ) latest = _load_series_plan_job(job_id) - if latest and latest.get("status") == "cancelled": + if latest and latest.get("status") in {"cancelling", "cancelled"}: return proposal = ( normalize_known_series_bootstrap(raw, series) @@ -27799,7 +28973,7 @@ def _run_series_canon_plan_job_inner(job_id: str) -> None: message="Known-series bible generated; applying it as an editable draft…", ) latest = _load_series_plan_job(job_id) - if latest and latest.get("status") == "cancelled": + if latest and latest.get("status") in {"cancelling", "cancelled"}: return try: applied = _apply_series_canon_proposal(job, proposal) @@ -27826,6 +29000,9 @@ def _run_series_canon_plan_job_inner(job_id: str) -> None: finishedAt=time.time(), ) except Exception as exc: + latest = _load_series_plan_job(job_id) + if latest and latest.get("status") in {"cancelling", "cancelled"}: + return detail = exc.detail if isinstance(exc, HTTPException) else str(exc) _series_plan_update( job_id, status="failed", error=str(detail), finishedAt=time.time(), @@ -27840,13 +29017,37 @@ def _run_series_plan_job(job_id: str) -> None: _series_plan_active_jobs.add(job_id) try: job = _load_series_plan_job(job_id) - if job and job.get("jobType") == "canon": - _run_series_canon_plan_job_inner(job_id) + task = _publish_series_task(job, "series-plan") if job else None + if task: + from services.task_manager import task_context_scope + scope = task_context_scope( + task_id=task["id"], root_id=task["root_id"], + workspace=str(job.get("workspace") or "default"), + workspace_dir=_workspace_dir(str(job.get("workspace") or "default")), + ) else: - _run_series_plan_job_inner(job_id) + from contextlib import nullcontext + scope = nullcontext() + with scope: + if job and job.get("jobType") == "canon": + _run_series_canon_plan_job_inner(job_id) + else: + _run_series_plan_job_inner(job_id) finally: with _series_plan_jobs_lock: _series_plan_active_jobs.discard(job_id) + settling = _load_series_plan_job(job_id) or {} + if settling.get("status") == "cancelling": + _series_plan_update( + job_id, + status="cancelled", + stage="cancelled", + finishedAt=time.time(), + message=( + "Series planning cancelled after the active LLM call " + "reached a safe boundary. Completed stages remain recoverable." + ), + ) @api.post("/api/v1/series/{series_id}/canon/prepare/start") @@ -27884,6 +29085,7 @@ def start_series_canon_preparation(series_id: str, body: dict): } _comic_writing_llm(request) job_id = f"series-canon-{uuid.uuid4().hex[:12]}" + task_id = f"task-series-plan-{job_id}" now = time.time() job = { "jobId": job_id, "jobType": "canon", "kind": "planning", @@ -27896,14 +29098,25 @@ def start_series_canon_preparation(series_id: str, body: dict): "bootstrapKnownSeries": bootstrap_known_series, "autoApply": request["autoApply"], "autoApplied": False, "createdAt": now, "updatedAt": now, + "taskId": task_id, "rootTaskId": task_id, } with _series_plan_jobs_lock: _series_plan_jobs[job_id] = job _series_plan_store(workspace).save(job) + publisher = globals().get("_publish_series_task") + task = publisher(job, "series-plan") if callable(publisher) else None + if isinstance(task, dict): + job["taskId"] = task.get("id") + job["rootTaskId"] = task.get("root_id") or task.get("id") + with _series_plan_jobs_lock: + _series_plan_jobs[job_id] = copy.deepcopy(job) + _series_plan_store(workspace).save(job) threading.Thread(target=_run_series_plan_job, args=(job_id,), daemon=True).start() return {key: job[key] for key in ( - "jobId", "jobType", "status", "stage", "current", "total", "message", "generateImages", - "bootstrapKnownSeries", "autoApply", "autoApplied", "createdAt", + "jobId", "jobType", "workspace", "seriesId", "episodeId", + "status", "stage", "current", "total", "message", "generateImages", + "bootstrapKnownSeries", "autoApply", "autoApplied", "taskId", + "rootTaskId", "createdAt", )} @@ -27930,6 +29143,7 @@ def start_series_episode_plan(series_id: str, episode_id: str, body: dict): # key does not leave a permanently queued phantom checkpoint. _comic_writing_llm(request) job_id = f"series-plan-{uuid.uuid4().hex[:12]}" + task_id = f"task-series-plan-{job_id}" now = time.time() job = { "jobId": job_id, "kind": "planning", "workspace": workspace, @@ -27940,13 +29154,24 @@ def start_series_episode_plan(series_id: str, episode_id: str, body: dict): "sourceEpisodeUpdatedAt": episode.get("updatedAt"), "completedStages": {}, "episodeResult": None, "result": None, "error": None, "createdAt": now, "updatedAt": now, + "taskId": task_id, "rootTaskId": task_id, } with _series_plan_jobs_lock: _series_plan_jobs[job_id] = job _series_plan_store(workspace).save(job) + publisher = globals().get("_publish_series_task") + task = publisher(job, "series-plan") if callable(publisher) else None + if isinstance(task, dict): + job["taskId"] = task.get("id") + job["rootTaskId"] = task.get("root_id") or task.get("id") + with _series_plan_jobs_lock: + _series_plan_jobs[job_id] = copy.deepcopy(job) + _series_plan_store(workspace).save(job) threading.Thread(target=_run_series_plan_job, args=(job_id,), daemon=True).start() return {key: job[key] for key in ( - "jobId", "status", "stage", "current", "total", "message", "createdAt", + "jobId", "taskId", "rootTaskId", "workspace", "seriesId", "episodeId", + "status", "stage", "current", + "total", "message", "createdAt", )} @@ -27958,8 +29183,10 @@ def get_series_episode_plan(job_id: str): return {key: job.get(key) for key in ( "jobId", "jobType", "workspace", "seriesId", "episodeId", "status", "stage", "current", "total", "message", "completedStages", "episodeResult", "seriesResult", "generateImages", + "validationAttempt", "validationError", "bootstrapKnownSeries", "autoApply", "autoApplied", "appliedSeriesRevision", "applyError", "result", "error", "createdAt", "updatedAt", "finishedAt", "appliedAt", + "taskId", "rootTaskId", )} @@ -27968,13 +29195,27 @@ def cancel_series_episode_plan(job_id: str): job = _load_series_plan_job(job_id) if not job: raise HTTPException(status_code=404, detail="Series planning job not found") - if job.get("status") == "completed": - return {"jobId": job_id, "status": "completed", "message": job.get("message")} + if job.get("status") in {"completed", "failed", "cancelled"}: + return {"jobId": job_id, "status": job.get("status"), "message": job.get("message")} + with _series_plan_jobs_lock: + worker_active = job_id in _series_plan_active_jobs updated = _series_plan_update( - job_id, status="cancelled", finishedAt=time.time(), - message="Episode planning cancelled. Completed stages remain recoverable.", + job_id, + status="cancelling" if worker_active else "cancelled", + stage="cancelling" if worker_active else "cancelled", + finishedAt=None if worker_active else time.time(), + message=( + "Series planning cancellation requested; waiting for the active " + "LLM call to reach a safe boundary." + if worker_active else + "Series planning cancelled before an LLM call started." + ), ) - return {"jobId": job_id, "status": "cancelled", "message": updated.get("message")} + return { + "jobId": job_id, + "status": updated.get("status"), + "message": updated.get("message"), + } @api.post("/api/v1/series/plan/jobs/{job_id}/resume") @@ -27993,6 +29234,10 @@ def resume_series_episode_plan(job_id: str, body: dict | None = None): if key in body: request[key] = body[key] _comic_writing_llm(request) + _reset_canonical_task_for_resume( + str(job.get("workspace") or "default"), + str(job.get("taskId") or f"task-series-plan-{job_id}"), + ) _series_plan_update( job_id, request=request, status="queued", error=None, finishedAt=None, message="Resuming episode planning from the last completed stage…", @@ -28001,8 +29246,151 @@ def resume_series_episode_plan(job_id: str, body: dict | None = None): return {"jobId": job_id, "status": "queued", "message": "Episode planning resumed."} +def _prepare_edited_series_episode_proposal(stored: dict, edited: dict | None, series: dict) -> dict: + """Merge reviewable fields while keeping generated identities and protected state authoritative.""" + if edited is None: + return copy.deepcopy(stored) + if not isinstance(edited, dict): + raise ValueError("Edited episode proposal must be an object") + if edited.get("id") not in {None, "", stored.get("id")}: + raise ValueError("Edited episode proposal id does not match the generated proposal") + + proposed = copy.deepcopy(stored) + outline = edited.get("outline") + beats = outline.get("beats") if isinstance(outline, dict) else None + if not isinstance(beats, list) or any(not isinstance(beat, str) for beat in beats): + raise ValueError("Edited outline beats must be text values") + proposed["outline"] = {"beats": copy.deepcopy(beats)} + + def merge_identified(original_items, edited_items, label, editable_keys, *, ordered=False): + if not isinstance(original_items, list) or not isinstance(edited_items, list): + raise ValueError(f"Edited {label} must be a list") + original_ids = [str(item.get("id") or "") for item in original_items if isinstance(item, dict)] + edited_by_id = { + str(item.get("id") or ""): item for item in edited_items if isinstance(item, dict) + } + if ( + len(original_ids) != len(original_items) + or len(edited_by_id) != len(edited_items) + or set(original_ids) != set(edited_by_id) + ): + raise ValueError(f"Edited {label} cannot add, remove, duplicate, or replace internal IDs") + merged = [] + for index, original in enumerate(original_items): + item = copy.deepcopy(original) + revision = edited_by_id[str(original["id"])] + for key in editable_keys: + if key in revision: + item[key] = copy.deepcopy(revision[key]) + item["id"] = original["id"] + if ordered: + item["order"] = index + 1 + merged.append(item) + return merged + + scene_keys = ( + "locationId", "locationVariantId", "time", "participatingCharacterIds", "purpose", + "entryState", "exitState", "beats", "dialogue", + ) + proposed["script"] = merge_identified( + stored.get("script"), edited.get("script"), "script scenes", scene_keys, ordered=True, + ) + for original_scene, scene in zip(stored.get("script", []), proposed["script"]): + scene["beats"] = merge_identified( + original_scene.get("beats"), scene.get("beats"), "scene beats", ("kind", "summary"), + ) + scene["dialogue"] = merge_identified( + original_scene.get("dialogue"), scene.get("dialogue"), "scene dialogue", ("characterId", "text", "emotion", "delivery"), + ) + + shot_keys = ( + "sceneId", "durationSeconds", "framing", "camera", "action", "dialogueBeats", + "visibleCharacterIds", "locationId", "locationVariantId", "wardrobeByCharacterId", + "propIds", "emotionalStateByCharacterId", "continuityFromShotId", "renderStrategy", + "referencePolicy", "prompt", "negativePrompt", "audioDirection", + ) + proposed["shots"] = merge_identified( + stored.get("shots"), edited.get("shots"), "shots", shot_keys, ordered=True, + ) + for original_shot, shot in zip(stored.get("shots", []), proposed["shots"]): + shot["dialogueBeats"] = merge_identified( + original_shot.get("dialogueBeats"), shot.get("dialogueBeats"), "shot dialogue", ("characterId", "text", "emotion", "delivery"), + ) + + character_ids = {str(item.get("id")) for item in series.get("characters", []) if isinstance(item, dict)} + location_ids = {str(item.get("id")) for item in series.get("locations", []) if isinstance(item, dict)} + scene_ids = {str(item["id"]) for item in proposed["script"]} + shot_ids = {str(item["id"]) for item in proposed["shots"]} + for scene in proposed["script"]: + if scene.get("locationId") not in location_ids: + raise ValueError(f"Scene {scene['id']} uses an unknown location") + dialogue_speakers = [] + for line in scene["dialogue"]: + character_id = str(line.get("characterId") or "") + if character_id not in character_ids: + raise ValueError(f"Scene {scene['id']} dialogue uses an unknown character") + if character_id not in dialogue_speakers: + dialogue_speakers.append(character_id) + scene["participatingCharacterIds"] = list(dict.fromkeys([ + *[str(item) for item in scene.get("participatingCharacterIds", []) if str(item) in character_ids], + *dialogue_speakers, + ])) + for shot in proposed["shots"]: + if shot.get("sceneId") not in scene_ids: + raise ValueError(f"Shot {shot['id']} uses an unknown scene") + if shot.get("locationId") and shot.get("locationId") not in location_ids: + raise ValueError(f"Shot {shot['id']} uses an unknown location") + if shot.get("durationSeconds") not in {5, 10, 15}: + raise ValueError(f"Shot {shot['id']} duration must be 5, 10, or 15 seconds") + continuity_id = str(shot.get("continuityFromShotId") or "") + if continuity_id and continuity_id not in shot_ids: + raise ValueError(f"Shot {shot['id']} references an unknown continuity shot") + visible = list(dict.fromkeys( + str(item) for item in shot.get("visibleCharacterIds", []) if str(item) in character_ids + )) + dialogue_speakers = [] + for line in shot["dialogueBeats"]: + character_id = str(line.get("characterId") or "") + if character_id not in character_ids: + raise ValueError(f"Shot {shot['id']} dialogue uses an unknown character") + if character_id not in visible: + visible.append(character_id) + if character_id not in dialogue_speakers: + dialogue_speakers.append(character_id) + if len(dialogue_speakers) > 1: + raise ValueError(f"Shot {shot['id']} must contain dialogue from only one speaker") + shot["visibleCharacterIds"] = visible + shot["speakingCharacterIds"] = dialogue_speakers + shot["primarySpeakerId"] = dialogue_speakers[0] if dialogue_speakers else "" + + edited_delta = edited.get("proposedCanonDelta") + stored_delta = stored.get("proposedCanonDelta") + if not isinstance(edited_delta, dict) or not isinstance(stored_delta, dict): + raise ValueError("Edited canon delta must be an object") + delta = copy.deepcopy(stored_delta) + for key in ("add", "change"): + delta[key] = merge_identified( + stored_delta.get(key), edited_delta.get(key), f"canon {key}", ("description", "decision"), + ) + # Retire entries use factId rather than id; only their decision is reviewable. + stored_retire = stored_delta.get("retire") if isinstance(stored_delta.get("retire"), list) else [] + edited_retire = edited_delta.get("retire") if isinstance(edited_delta.get("retire"), list) else [] + edited_retire_by_id = {str(item.get("factId") or ""): item for item in edited_retire if isinstance(item, dict)} + if len(edited_retire_by_id) != len(stored_retire) or { + str(item.get("factId") or "") for item in stored_retire + } != set(edited_retire_by_id): + raise ValueError("Edited canon retire list cannot change internal fact IDs") + delta["retire"] = [ + {**copy.deepcopy(item), "decision": edited_retire_by_id[str(item.get("factId") or "")].get("decision", item.get("decision"))} + for item in stored_retire + ] + proposed["proposedCanonDelta"] = delta + proposed["continuityIssues"] = copy.deepcopy(stored.get("continuityIssues")) + return proposed + + @api.post("/api/v1/series/plan/jobs/{job_id}/apply") -def apply_series_episode_plan(job_id: str): +def apply_series_episode_plan(job_id: str, body: dict | None = None): job = _load_series_plan_job(job_id) if not job: raise HTTPException(status_code=404, detail="Series planning job not found") @@ -28024,7 +29412,12 @@ def apply_series_episode_plan(job_id: str): status_code=409, detail="The episode was edited after planning started. Review the saved proposal instead of overwriting it.", ) - proposed = copy.deepcopy(job["episodeResult"]) + try: + proposed = _prepare_edited_series_episode_proposal( + job["episodeResult"], body.get("episodeResult") if isinstance(body, dict) else None, series, + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc proposed["canonSnapshot"] = copy.deepcopy(current.get("canonSnapshot")) proposed["canonRevisionAtCreation"] = current.get("canonRevisionAtCreation") proposed["createdAt"] = current.get("createdAt") @@ -28097,6 +29490,31 @@ def _series_render_store(workspace: str): return SeriesJobStore(_workspace_dir(workspace), "render") +def _active_series_render_for_episode( + workspace: str, series_id: str, episode_id: str, +) -> dict | None: + """Return the one queue that owns an episode, including after restart.""" + active_statuses = {"queued", "running", "cancelling"} + with _series_render_jobs_lock: + cached = [copy.deepcopy(job) for job in _series_render_jobs.values()] + try: + persisted = _series_render_store(workspace).list() + except (OSError, ValueError, json.JSONDecodeError): + persisted = [] + by_id = { + str(job.get("jobId")): job + for job in [*persisted, *cached] + if isinstance(job, dict) and job.get("jobId") + } + return next(( + job for job in by_id.values() + if job.get("workspace") == workspace + and job.get("seriesId") == series_id + and job.get("episodeId") == episode_id + and job.get("status") in active_statuses + ), None) + + def _load_series_render_job(job_id: str) -> dict | None: with _series_render_jobs_lock: cached = _series_render_jobs.get(job_id) @@ -28119,10 +29537,21 @@ def _series_render_update(job_id: str, **patch) -> dict | None: job = _series_render_jobs.get(job_id) if not job: return None + if ( + job.get("status") == "cancelling" + and patch.get("status") not in {"cancelling", "cancelled"} + ): + return copy.deepcopy(job) job.update(copy.deepcopy(patch)) job["updatedAt"] = time.time() snapshot = copy.deepcopy(job) _series_render_store(str(job["workspace"])).save(snapshot) + publisher = globals().get("_publish_series_task") + if callable(publisher): + try: + publisher(snapshot, "series-render") + except Exception as exc: + print(f"[Task registry] Could not publish Series render {job_id}: {exc}") return snapshot @@ -28220,6 +29649,38 @@ def _series_patch_attempt( return stored["seriesById"][series["id"]]["episodesById"][episode["id"]]["shots"][shot_index] +def _series_settle_episode_render_status(job: dict) -> str: + """Leave an episode in a truthful terminal state after a render job settles.""" + workspace = str(job["workspace"]) + with _series_library_lock: + library = _read_series_workspace(workspace) + series = copy.deepcopy(_series_project_or_404(library, str(job["seriesId"]))) + episode = series.get("episodesById", {}).get(str(job["episodeId"])) + if not isinstance(episode, dict): + raise ValueError("Series episode no longer exists") + shots = [item for item in episode.get("shots", []) if isinstance(item, dict)] + all_rendered = bool(shots) and all( + any( + isinstance(attempt, dict) + and attempt.get("status") == "completed" + and bool(attempt.get("outputAssetIds")) + for attempt in shot.get("attempts", []) + ) + for shot in shots + ) + status = "completed" if all_rendered else "shot_plan" + if episode.get("status") == status: + return status + now = _series_iso_now() + episode["status"] = status + episode["updatedAt"] = now + series["updatedAt"] = now + series["revision"] = int(series.get("revision") or 1) + 1 + library["seriesById"][series["id"]] = series + _write_series_workspace(workspace, library) + return status + + def _run_series_render_job_inner(job_id: str) -> None: from services.series_render import build_h3_generation_params @@ -28231,7 +29692,7 @@ def _run_series_render_job_inner(job_id: str) -> None: try: for item_index, item in enumerate(items): latest = _load_series_render_job(job_id) - if not latest or latest.get("status") == "cancelled": + if not latest or latest.get("status") in {"cancelling", "cancelled"}: return current_item = latest.get("items", [])[item_index] if current_item.get("status") == "completed": @@ -28249,6 +29710,26 @@ def _run_series_render_job_inner(job_id: str) -> None: job_id, status="running", stage="rendering", activeShotId=current_item["shotId"], message=f"Rendering shot {item_index + 1}/{len(items)}…", ) + latest = _load_series_render_job(job_id) or latest + if latest.get("status") in {"cancelling", "cancelled"}: + completed_at = _series_iso_now() + _series_patch_attempt( + latest, + current_item, + { + "status": "cancelled", + "completedAt": completed_at, + "error": "Cancelled by user", + }, + ) + _series_render_update_item( + job_id, + item_index, + status="cancelled", + completedAt=completed_at, + error="Cancelled by user", + ) + return started = time.time() child_job_id = "" try: @@ -28264,6 +29745,9 @@ def _run_series_render_job_inner(job_id: str) -> None: resolved[asset_id] = _series_asset_local_path(str(latest["workspace"]), asset) params = build_h3_generation_params(series, shot, attempt, resolved) params["_director_pipeline_id"] = f"series:{job_id}" + cancellation_check = _load_series_render_job(job_id) or latest + if cancellation_check.get("status") in {"cancelling", "cancelled"}: + raise RuntimeError("Cancelled by user before model execution") request_hash = "sha256:" + hashlib.sha256( json.dumps(params, ensure_ascii=False, sort_keys=True, default=str).encode("utf-8") ).hexdigest() @@ -28276,6 +29760,10 @@ def _run_series_render_job_inner(job_id: str) -> None: job_id, item_index, childJobId=child_job_id, requestPayloadHash=request_hash, ) + cancellation_check = _load_series_render_job(job_id) or latest + if cancellation_check.get("status") in {"cancelling", "cancelled"}: + _request_generation_cancel(child_job_id) + raise RuntimeError("Cancelled by user before model execution") _series_patch_attempt( latest, current_item, {"providerTaskId": child_job_id, "requestPayloadHash": request_hash}, @@ -28306,6 +29794,11 @@ def _run_series_render_job_inner(job_id: str) -> None: "createdAt": attempt.get("createdAt"), "submittedAt": submitted_at, "completedAt": completed_at, "elapsedMs": elapsed_ms, "generationJobId": child_job_id, "requestPayloadHash": request_hash, + "taskId": child_result.get("task_id"), + "rootTaskId": ( + child_result.get("root_task_id") + or child_result.get("task_id") + ), }, }) _series_patch_attempt( @@ -28327,7 +29820,7 @@ def _run_series_render_job_inner(job_id: str) -> None: completed_at = _series_iso_now() elapsed_ms = max(0, round((time.time() - started) * 1000)) current = _load_series_render_job(job_id) - if current and current.get("status") == "cancelled": + if current and current.get("status") in {"cancelling", "cancelled"}: try: _series_patch_attempt( current, current["items"][item_index], @@ -28347,8 +29840,10 @@ def _run_series_render_job_inner(job_id: str) -> None: job_id, item_index, status="failed", completedAt=completed_at, elapsedMs=elapsed_ms, error=error, childJobId=child_job_id, ) - finished = time.time() latest = _load_series_render_job(job_id) or job + if latest.get("status") in {"cancelling", "cancelled"}: + return + finished = time.time() failures = [item for item in latest.get("items", []) if item.get("status") == "failed"] _series_render_update( job_id, @@ -28360,11 +29855,16 @@ def _run_series_render_job_inner(job_id: str) -> None: if failures else "All requested Series shots completed." ), ) + _series_settle_episode_render_status(latest) except Exception as exc: _series_render_update( job_id, status="failed", error=str(exc), finishedAt=time.time(), message="Series render stopped. Completed attempts were preserved.", ) + try: + _series_settle_episode_render_status(job) + except Exception: + pass def _run_series_render_job(job_id: str) -> None: @@ -28377,6 +29877,39 @@ def _run_series_render_job(job_id: str) -> None: finally: with _series_render_jobs_lock: _series_render_active_jobs.discard(job_id) + settling = _load_series_render_job(job_id) or {} + if settling.get("status") == "cancelling": + settled_items = copy.deepcopy(settling.get("items") or []) + for item in settled_items: + if item.get("status") in {"queued", "running", "cancelling"}: + item.update({ + "status": "cancelled", + "completedAt": item.get("completedAt") or _series_iso_now(), + "error": item.get("error") or "Cancelled by user", + "updatedAt": time.time(), + }) + _series_render_update( + job_id, + items=settled_items, + status="cancelled", + stage="cancelled", + activeShotId=None, + finishedAt=time.time(), + message=( + "Series render cancelled after the active model thread " + "reached a safe boundary. Completed attempts remain available." + ), + ) + # Series deliberately marks child generations as one Director-like + # group so H3 stays warm between adjacent shots. Once the parent job + # settles, restore the ordinary idle-release policy; otherwise the + # isolated ConvRot process can retain roughly 50 GB of host RAM forever. + finished = _load_series_render_job(job_id) or {} + model_type = str(finished.get("model") or "") + if _is_legacy_h3_model(model_type): + _release_legacy_h3_when_queue_allows(job_id) + elif _is_minimax_h3_model(model_type): + _release_h3_when_queue_allows(job_id) def _series_render_candidates(episode: dict, body: dict) -> list[dict]: @@ -28385,6 +29918,11 @@ def _series_render_candidates(episode: dict, body: dict) -> list[dict]: selected_ids = {str(item) for item in body.get("shotIds", []) if isinstance(item, str)} if mode == "selected": shots = [item for item in shots if item.get("id") in selected_ids] + if not shots: + raise ValueError("Select at least one Series shot") + # Append a new alternative in the same slot while retaining the older + # approved cut until the user explicitly approves the replacement. + return shots elif mode == "failed": shots = [ item for item in shots @@ -28396,8 +29934,8 @@ def _series_render_candidates(episode: dict, body: dict) -> list[dict]: shots = [item for item in shots if not item.get("approvedAttemptId")] else: raise ValueError("Render mode must be selected, failed, missing, or all") - # Approved shots are authoritative in every bulk mode. The user can - # explicitly unapprove first, but bulk generation never overwrites them. + # Bulk generation never spends compute on approved shots. Only explicit + # per-slot regeneration may append an alternative attempt. return [item for item in shots if not item.get("approvedAttemptId")] @@ -28406,8 +29944,8 @@ def start_series_episode_render(series_id: str, episode_id: str, body: dict): from services.series_library import append_shot_render_attempt, series_for_episode_snapshot from services.series_reference_router import route_shot_references from services.series_render import ( - model_for_manifest, normalize_series_resolution, quantize_h3_frames, - shot_generation_prompt, + model_for_manifest, normalize_series_resolution, normalize_series_shot_duration, + quantize_h3_frames, series_dialogue_preflight_issues, shot_generation_prompt, ) workspace = _series_library_workspace(body.get("workspace")) @@ -28417,13 +29955,14 @@ def start_series_episode_render(series_id: str, episode_id: str, body: dict): episode = series.get("episodesById", {}).get(episode_id) if not isinstance(episode, dict): raise HTTPException(status_code=404, detail="Series episode not found") - if any( - isinstance(shot, dict) and bool(shot.get("dialogueBeats")) - for shot in episode.get("shots", []) - ) and not series.get("bestEffortLipSyncAcknowledged"): + active_job = _active_series_render_for_episode(workspace, series_id, episode_id) + if active_job: raise HTTPException( - status_code=400, - detail="Acknowledge best-effort native lip sync in Series setup before rendering dialogue shots", + status_code=409, + detail=( + f"Series episode already has active render {active_job.get('jobId')}; " + "resume or cancel that queue instead of creating duplicate attempts" + ), ) routing_series = series_for_episode_snapshot(series, episode) try: @@ -28432,13 +29971,39 @@ def start_series_episode_render(series_id: str, episode_id: str, body: dict): raise HTTPException(status_code=400, detail=str(exc)) from exc if not candidates: raise HTTPException(status_code=400, detail="No unapproved Series shots match this render request") - provider = routing_series.get("provider") if isinstance(routing_series.get("provider"), dict) else {} + if any(bool(shot.get("dialogueBeats")) for shot in candidates) and not series.get( + "bestEffortLipSyncAcknowledged" + ): + raise HTTPException( + status_code=400, + detail=( + "Dialogue rendering is blocked until best-effort native lip sync is acknowledged. " + "Use ‘I understand · enable dialogue rendering’ in Shots or enable it in Series setup." + ), + ) + dialogue_issues = [ + (shot.get("order"), issue) + for shot in candidates + for issue in series_dialogue_preflight_issues(shot) + ] + if dialogue_issues: + summary = "; ".join( + f"shot {order}: {issue}" for order, issue in dialogue_issues[:8] + ) + if len(dialogue_issues) > 8: + summary += f"; and {len(dialogue_issues) - 8} more" + raise HTTPException( + status_code=400, + detail=f"Fix dialogue timing/format before rendering: {summary}", + ) + provider = routing_series.get("provider") if isinstance(routing_series.get("provider"), dict) else {} provider_settings = provider.get("videoSettings") if isinstance(provider.get("videoSettings"), dict) else {} settings = {**copy.deepcopy(provider_settings), **( copy.deepcopy(body.get("settings")) if isinstance(body.get("settings"), dict) else {} )} resolution, orientation = normalize_series_resolution( settings.get("resolution"), settings.get("orientation"), + provider.get("videoModel") or "minimax_h3", ) settings["resolution"] = resolution settings["orientation"] = orientation @@ -28465,6 +30030,9 @@ def start_series_episode_render(series_id: str, episode_id: str, body: dict): ) model = model_for_manifest(str(provider.get("videoModel") or "minimax_h3"), manifest) retry_count = len(shot.get("attempts", [])) + shot["durationSeconds"] = normalize_series_shot_duration( + shot.get("durationSeconds"), + ) shot_settings = { **settings, "requestedDurationSeconds": float(shot.get("durationSeconds") or 0), @@ -28475,7 +30043,9 @@ def start_series_episode_render(series_id: str, episode_id: str, body: dict): updated_shot, attempt = append_shot_render_attempt( shot, manifest=manifest, model=model, settings=shot_settings, seed=(base_seed + int(shot.get("order") or shot_index)) & 0x7FFFFFFF, - retry_count=retry_count, prompt=shot_generation_prompt(routing_series, shot), + retry_count=retry_count, prompt=shot_generation_prompt( + routing_series, shot, manifest, + ), ) episode["shots"][shot_index] = updated_shot items.append({ @@ -28492,26 +30062,41 @@ def start_series_episode_render(series_id: str, episode_id: str, body: dict): series["updatedAt"] = now_iso library["seriesById"][series_id] = series _write_series_workspace(workspace, library) - job_id = f"series-render-{uuid.uuid4().hex[:12]}" - now = time.time() - job = { - "jobId": job_id, "kind": "render", "workspace": workspace, - "seriesId": series_id, "episodeId": episode_id, "status": "queued", - "stage": "queued", "current": 0, "total": len(items), "items": items, - "activeShotId": None, "message": "Series shot render queued.", - "settings": settings, "model": str(provider.get("videoModel") or "minimax_h3"), - "seed": base_seed, "outputAssetIds": [], "retryCount": 0, - "createdAt": now, "updatedAt": now, "error": None, - } - with _series_render_jobs_lock: - _series_render_jobs[job_id] = job - _series_render_store(workspace).save(job) + # Persist the episode attempts and their owning job before releasing + # the library lock. A concurrent start therefore observes this queue + # and cannot append a second set of attempts for the same episode. + job_id = f"series-render-{uuid.uuid4().hex[:12]}" + task_id = f"task-series-render-{job_id}" + now = time.time() + job = { + "jobId": job_id, "kind": "render", "workspace": workspace, + "seriesId": series_id, "episodeId": episode_id, "status": "queued", + "stage": "queued", "current": 0, "total": len(items), "items": items, + "activeShotId": None, "message": "Series shot render queued.", + "settings": settings, "model": str(provider.get("videoModel") or "minimax_h3"), + "seed": base_seed, "outputAssetIds": [], "retryCount": 0, + "createdAt": now, "updatedAt": now, "error": None, + "taskId": task_id, "rootTaskId": task_id, + } + with _series_render_jobs_lock: + _series_render_jobs[job_id] = job + _series_render_store(workspace).save(job) + publisher = globals().get("_publish_series_task") + task = publisher(job, "series-render") if callable(publisher) else None + if isinstance(task, dict): + job["taskId"] = task.get("id") + job["rootTaskId"] = task.get("root_id") or task.get("id") + with _series_render_jobs_lock: + _series_render_jobs[job_id] = copy.deepcopy(job) + _series_render_store(workspace).save(job) threading.Thread( target=_run_series_render_job, args=(job_id,), name=f"series-render-{job_id[-6:]}", daemon=False, ).start() return {key: job[key] for key in ( - "jobId", "status", "stage", "current", "total", "message", "settings", "seed", "createdAt", + "jobId", "taskId", "rootTaskId", "workspace", "seriesId", "episodeId", + "status", "stage", "current", + "total", "message", "settings", "seed", "createdAt", )} @@ -28524,6 +30109,7 @@ def get_series_render_job(job_id: str): "jobId", "workspace", "seriesId", "episodeId", "status", "stage", "current", "total", "items", "activeShotId", "message", "settings", "model", "seed", "error", "createdAt", "updatedAt", "finishedAt", + "taskId", "rootTaskId", )} @@ -28535,18 +30121,47 @@ def cancel_series_render_job(job_id: str): if job.get("status") in {"completed", "failed", "cancelled"}: return {"jobId": job_id, "status": job.get("status"), "message": job.get("message")} cancelled_at = _series_iso_now() + with _series_render_jobs_lock: + worker_active = job_id in _series_render_active_jobs + active_child = next(( + str(item.get("childJobId")) for item in job.get("items", []) + if item.get("status") == "running" and item.get("childJobId") + ), "") + # A persisted child id can outlive its process after an app restart. Only + # defer cancellation when there is an in-memory worker that can actually + # reach a safe boundary; otherwise settle the orphaned checkpoint now. + active_child_known = bool(active_child and active_child in _jobs) + deferred = bool(worker_active or active_child_known) items = copy.deepcopy(job.get("items") or []) cancellable = {"queued", "running"} for item in items: if item.get("status") in cancellable: item.update({ - "status": "cancelled", "completedAt": cancelled_at, - "error": "Cancelled by user", "updatedAt": time.time(), + "status": ( + "cancelling" + if deferred and item.get("status") == "running" + else "cancelled" + ), + "completedAt": ( + None + if deferred and item.get("status") == "running" + else cancelled_at + ), + "error": "Cancellation requested", "updatedAt": time.time(), }) updated_job = _series_render_update( - job_id, items=items, status="cancelled", stage="cancelled", - activeShotId=None, finishedAt=time.time(), - message="Series render cancellation requested. Completed attempts remain available.", + job_id, + items=items, + status="cancelling" if deferred else "cancelled", + stage="cancelling" if deferred else "cancelled", + activeShotId=job.get("activeShotId") if deferred else None, + finishedAt=None if deferred else time.time(), + message=( + "Series render cancellation requested; waiting for the active " + "model thread to reach a safe boundary." + if deferred else + "Series render cancelled before model execution." + ), ) or job # Persist cancellation for every queued/running attempt too. This keeps # recovery honest and guarantees resume appends a fresh attempt instead @@ -28583,15 +30198,15 @@ def cancel_series_render_job(job_id: str): series["updatedAt"] = cancelled_at library["seriesById"][series["id"]] = series _write_series_workspace(str(job["workspace"]), library) - active_child = next(( - str(item.get("childJobId")) for item in job.get("items", []) - if item.get("status") == "running" and item.get("childJobId") - ), "") if active_child and active_child in _jobs: _request_generation_cancel(active_child) return { - "jobId": job_id, "status": "cancelled", - "message": updated_job.get("message") or "Cancellation requested; the active model thread may take a moment to release.", + "jobId": job_id, + "status": updated_job.get("status"), + "message": updated_job.get("message") or ( + "Cancellation requested; the active model thread may take a " + "moment to release." + ), } @@ -28615,6 +30230,10 @@ def resume_series_render_job(job_id: str): with _series_render_jobs_lock: if job_id in _series_render_active_jobs: return {"jobId": job_id, "status": job.get("status"), "message": "Series render is already running."} + _reset_canonical_task_for_resume( + str(job.get("workspace") or "default"), + str(job.get("taskId") or f"task-series-render-{job_id}"), + ) items = copy.deepcopy(job.get("items") or []) # A local diffusion process cannot resume mid-step. On explicit recovery, # Interrupted/failed/cancelled items get a new append-only attempt; @@ -28733,6 +30352,67 @@ def approve_series_shot_attempt_endpoint( return stored["seriesById"][series_id]["episodesById"][episode_id]["shots"][shot_index] +@api.post("/api/v1/series/{series_id}/episodes/{episode_id}/attempts/approve-bulk") +def approve_series_episode_attempts_endpoint( + series_id: str, episode_id: str, body: dict, +): + from services.series_library import approve_episode_render_attempts + + workspace = _series_library_workspace(body.get("workspace")) + with _series_library_lock: + library = _read_series_workspace(workspace) + series = copy.deepcopy(_series_project_or_404(library, series_id)) + episode = series.get("episodesById", {}).get(episode_id) + if not isinstance(episode, dict): + raise HTTPException(status_code=404, detail="Series episode not found") + try: + episode = approve_episode_render_attempts(episode, body.get("selections")) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + now = _series_iso_now() + if episode.get("shots") and all( + isinstance(shot, dict) and bool(shot.get("approvedAttemptId")) + for shot in episode.get("shots", []) + ): + episode["status"] = "completed" + episode["updatedAt"] = now + series["episodesById"][episode_id] = episode + series["revision"] = int(series.get("revision") or 1) + 1 + series["updatedAt"] = now + library["seriesById"][series_id] = series + stored = _write_series_workspace(workspace, library) + stored_series = stored["seriesById"][series_id] + return { + "seriesId": series_id, + "episodeId": episode_id, + "revision": stored_series["revision"], + "episode": stored_series["episodesById"][episode_id], + } + + +from routers.series_assembly import create_series_assembly_router + +api.include_router(create_series_assembly_router( + resolve_workspace=_series_library_workspace, + workspace_dir=_workspace_dir, + list_workspaces=_list_workspaces, + library_lock=_series_library_lock, + read_library=_read_series_workspace, + write_library=_write_series_workspace, + find_series=_series_project_or_404, + asset_local_path=_series_asset_local_path, + available_filename=wgp.get_available_filename, + concatenate_clips=wgp.concatenate_multi_clip_videos, + iso_now=_series_iso_now, +)) + +from routers.style_library import create_style_library_router +from services.style_library import StyleLibrary + +_style_library = StyleLibrary(os.path.join(os.path.dirname(__file__), "style_library")) +api.include_router(create_style_library_router(_style_library)) + + @api.post("/api/v1/series/{series_id}/episodes/{episode_id}/shots/{shot_id}/attempts/{attempt_id}/reject") def reject_series_shot_attempt_endpoint( series_id: str, episode_id: str, shot_id: str, attempt_id: str, body: dict | None = None, @@ -28933,7 +30613,7 @@ def _generate_story_lab_stage(body: dict, scope: str) -> dict: premise = str(body.get("premise") or "").strip() project = body.get("project") if isinstance(body.get("project"), dict) else {} project_type = str(project.get("projectType") or "full_story").strip().lower() - if project_type not in {"full_story", "music_video", "quick_video"}: + if project_type not in {"full_story", "music_video", "trailer", "quick_video"}: project_type = "full_story" creative_brief = project.get("creativeBrief") if isinstance(project.get("creativeBrief"), dict) else {} general_idea = str(creative_brief.get("generalIdea") or "").strip()[:12000] @@ -29011,6 +30691,17 @@ def _generate_story_lab_stage(body: dict, scope: str) -> dict: This is a compact music-first story, not a complete franchise bible. Build a coherent visual arc around the performer and the song. Context: {str(creative_brief.get('context') or premise)[:3000]}. Use 4–10 beats that can become videoclip shots and keep locations/cast deliberately small. +""" + elif project_type == "trailer": + narrative_direction = f""" +This is a cinematic movie-trailer project, not a videoclip and not a complete short film. +Film context: {str(creative_brief.get('context') or premise)[:3000]}. +Protagonists: {str(creative_brief.get('subjects') or 'not specified')[:1500]}. +World and locations: {str(creative_brief.get('setting') or 'not specified')[:1500]}. +Core conflict and trailer promise: {str(creative_brief.get('action') or premise)[:3000]}. +Target duration: {max(15, min(180, brief_duration or 60))} seconds. Use 6–12 concise, +shootable beats covering cold open, promise, disruption, escalation, a breath and a final +unresolved hook. Never require a song and never reveal or resolve the source story ending. """ elif project_type == "quick_video": narrative_direction = f""" @@ -29207,11 +30898,22 @@ def _persist_story_plan_job(job: dict) -> None: pass -def _story_job_update(job_id: str, **patch) -> None: +def _story_job_update(job_id: str, **patch) -> dict | None: with _story_plan_jobs_lock: job = _story_plan_jobs.get(job_id) if not job: - return + return None + next_status = str(patch.get("status") or job.get("status") or "") + if ( + job.get("status") == "cancelling" + and next_status not in {"cancelling", "cancelled"} + ): + return copy.deepcopy(job) + if ( + job.get("status") == "cancelled" + and next_status != "cancelled" + ): + return copy.deepcopy(job) job.update(patch) job["updatedAt"] = time.time() snapshot = copy.deepcopy(job) @@ -29220,6 +30922,13 @@ def _story_job_update(job_id: str, **patch) -> None: # cancel/resume checkpoint even when both writes were individually # atomic. _persist_story_plan_job(snapshot) + publisher = globals().get("_publish_generic_legacy_task") + if callable(publisher): + try: + publisher(snapshot, "story-plan") + except Exception as exc: + print(f"[Task registry] Could not publish Story plan {job_id}: {exc}") + return snapshot def _load_story_plan_job(job_id: str) -> dict | None: @@ -29258,7 +30967,7 @@ def _run_story_plan_job_inner(job_id: str) -> None: ["overview", "characters", "world", "structure", "music"] if project_type == "music_video" else ["overview", "characters", "world", "structure"] - if project_type == "quick_video" + if project_type in {"trailer", "quick_video"} else ["overview", "characters", "world", "relationships", "structure", "music"] ) stages = all_stages if requested_scope == "all" else [requested_scope] @@ -29269,7 +30978,7 @@ def _run_story_plan_job_inner(job_id: str) -> None: try: for index, stage in enumerate(stages): latest = _load_story_plan_job(job_id) - if latest and latest.get("status") == "cancelled": + if latest and latest.get("status") in {"cancelling", "cancelled"}: return if stage in completed: original_result = completed[stage] @@ -29293,7 +31002,7 @@ def _run_story_plan_job_inner(job_id: str) -> None: } result = _generate_story_lab_stage(stage_body, stage) latest = _load_story_plan_job(job_id) - if latest and latest.get("status") == "cancelled": + if latest and latest.get("status") in {"cancelling", "cancelled"}: return completed[stage] = result _story_job_update(job_id, completedStages=completed) @@ -29323,14 +31032,57 @@ def _run_story_plan_job_inner(job_id: str) -> None: def _run_story_plan_job(job_id: str) -> None: with _story_plan_jobs_lock: - if job_id in _story_plan_active_jobs: - return _story_plan_active_jobs.add(job_id) + job = copy.deepcopy(_story_plan_jobs.get(job_id) or {}) try: - _run_story_plan_job_inner(job_id) + from services.task_manager import task_context_scope + + workspace = str(job.get("workspace") or "default") + task_id = str(job.get("taskId") or f"task-story-plan-{job_id}") + root_task_id = str(job.get("rootTaskId") or task_id) + with task_context_scope( + task_id=task_id, + root_task_id=root_task_id, + workspace=workspace, + workspace_dir=_workspace_dir(workspace), + ): + _run_story_plan_job_inner(job_id) finally: with _story_plan_jobs_lock: _story_plan_active_jobs.discard(job_id) + settling = _load_story_plan_job(job_id) or {} + if settling.get("status") == "cancelling": + _story_job_update( + job_id, + status="cancelled", + stage="cancelled", + message=( + "Story generation cancelled after the active LLM call " + "reached a safe boundary. Completed stages remain recoverable." + ), + finishedAt=time.time(), + ) + + +def _start_story_plan_worker(job_id: str) -> bool: + """Claim a Story planner before Thread.start so resume cannot duplicate it.""" + with _story_plan_jobs_lock: + if job_id in _story_plan_active_jobs: + return False + _story_plan_active_jobs.add(job_id) + thread = threading.Thread( + target=_run_story_plan_job, + args=(job_id,), + name=f"story-plan-{job_id[-6:]}", + daemon=True, + ) + try: + thread.start() + except Exception: + with _story_plan_jobs_lock: + _story_plan_active_jobs.discard(job_id) + raise + return True @api.post("/api/v1/stories/generate") @@ -29348,151 +31100,700 @@ def generate_story_lab_section(body: dict): return {"result": _generate_story_lab_stage(body, scope)} -@api.post("/api/v1/stories/music-candidates") -async def generate_story_music_candidates(body: dict): - """Generate 1–3 durable MiniMax Music candidates from an approved song draft.""" - from services import minimax_music_service +_minimax_music_jobs: dict[str, dict] = {} +_minimax_music_jobs_lock = threading.RLock() +_MINIMAX_MUSIC_TERMINAL = {"completed", "failed", "cancelled", "interrupted"} - services = wgp.server_config.get("services", {}) - workspace = str(body.get("workspace") or _get_active_workspace()) - model = str(body.get("model") or "music-3.0").strip() - reference_audio_path = None - if model in {"music-cover", "music-cover-free"}: - reference_name = os.path.basename(str(body.get("reference_audio_filename") or "").strip()) - upload_root = os.path.realpath(os.path.join(os.getcwd(), "uploads", "audio")) - reference_audio_path = _safe_join(upload_root, reference_name) if reference_name else None - if not reference_audio_path or not os.path.isfile(reference_audio_path): - raise HTTPException(status_code=400, detail="Upload a valid reference song before generating a cover") - try: - candidates = await asyncio.to_thread( - minimax_music_service.generate_candidates, - api_key=str(services.get("minimax_api_key") or ""), - prompt=str(body.get("prompt") or ""), - lyrics=str(body.get("lyrics") or ""), - count=int(body.get("count") or 2), - output_dir=_workspace_dir(workspace), - instrumental=bool(body.get("instrumental")), - model=model, - reference_audio_path=reference_audio_path, - ) - except minimax_music_service.MiniMaxMusicError as exc: - raise HTTPException(status_code=exc.status_code, detail=str(exc)) from exc - return { - "candidates": [ - { - **candidate, - "source": f"/api/v1/file/{candidate['filename']}", - } - for candidate in candidates - ] - } +def _minimax_music_checkpoint_dir(workspace: str | None = None) -> str: + path = os.path.join(_workspace_dir(workspace), ".minimax-music-jobs") + os.makedirs(path, exist_ok=True) + return path -@api.post("/api/v1/stories/translate-lyrics") -async def translate_story_lyrics(body: dict): - """Translate editable song lyrics with the Story Lab writing provider.""" - from services import llm_service - lyrics = str(body.get("lyrics") or "").strip() - target_language = str(body.get("targetLanguage") or "").strip()[:80] - if not lyrics: - raise HTTPException(status_code=400, detail="Lyrics are required") - if not target_language: - raise HTTPException(status_code=400, detail="Choose a target language") - system_prompt = ( - "Translate song lyrics accurately. Return only the translated lyrics, " - "with no explanation, title, markdown or code fence. Translate only the sung " - "lyric lines. Copy every song instruction enclosed in square brackets exactly " - "as written, preserving its English text and capitalization (for example " - "[Verse], [Pre Chorus], [Chorus], [Bridge], [Outro] or [Female vocal])." - ) - prompt = ( - f"Translate the following lyrics into {target_language}. Do not translate or " - "alter any text enclosed in square brackets; copy those instructions verbatim.\n\n" - f"{lyrics}" +def _minimax_music_checkpoint_path(job_id: str, workspace: str) -> str | None: + if not re.fullmatch(r"minimax-music-[a-f0-9]{12}", str(job_id or "")): + return None + return os.path.join(_minimax_music_checkpoint_dir(workspace), f"{job_id}.json") + + +def _persist_minimax_music_job(job: dict) -> None: + path = _minimax_music_checkpoint_path( + str(job.get("jobId") or ""), str(job.get("workspace") or "default"), ) - llm_override = _comic_writing_llm(body) + if not path: + return + temporary = f"{path}.{uuid.uuid4().hex}.tmp" try: - if llm_override: - translated = llm_service.generate_openai_compatible( - prompt=prompt, - system_prompt=system_prompt, - model_id=llm_override["model"], - base_url=llm_override["base_url"], - api_key=llm_override["api_key"], - max_new_tokens=min(3000, max(500, len(lyrics) * 2)), - temperature=0.2, - ) - else: - _ensure_llm_loaded() - translated = llm_service.generate_streaming( - prompt=prompt, - system_prompt=system_prompt, - max_new_tokens=min(3000, max(500, len(lyrics) * 2)), - temperature=0.2, - enable_thinking=False, - thinking_budget=0, - ) - except Exception as exc: - raise HTTPException(status_code=502, detail=f"Lyric translation failed: {exc}") from exc - - translated = str(translated or "").strip() - if translated.startswith("```"): - translated = re.sub(r"^```(?:text|markdown)?\s*|\s*```$", "", translated, flags=re.IGNORECASE).strip() - if not translated: - raise HTTPException(status_code=502, detail="The LLM returned empty translated lyrics") - return {"lyrics": translated, "targetLanguage": target_language} + with open(temporary, "w", encoding="utf-8") as handle: + json.dump(job, handle, ensure_ascii=False) + os.replace(temporary, path) + finally: + try: + if os.path.isfile(temporary): + os.remove(temporary) + except OSError: + pass -@api.post("/api/v1/stories/generate/start") -def start_story_lab_generation(body: dict): - scope = str(body.get("scope") or "all").strip().lower() - allowed = {"all", "overview", "world", "characters", "relationships", "structure", "music"} - if scope not in allowed: - raise HTTPException(status_code=400, detail="Unsupported Story Lab generation scope") - if not str(body.get("premise") or "").strip(): - raise HTTPException(status_code=400, detail="Write a premise before generating the story") - project = body.get("project") if isinstance(body.get("project"), dict) else {} - project_type = str(project.get("projectType") or "full_story").strip().lower() - stage_total = 5 if project_type == "music_video" else 4 if project_type == "quick_video" else 6 - job_id = f"story-plan-{uuid.uuid4().hex[:12]}" - job = { - "jobId": job_id, - "status": "queued", - "message": "Story generation queued.", - "stage": "queued", - "current": 0, - "total": stage_total if scope == "all" else 1, - "request": _story_checkpoint_request(body), - "completedStages": {}, - "result": None, - "error": None, - "createdAt": time.time(), - "updatedAt": time.time(), - "workspace": _get_active_workspace(), - } - with _story_plan_jobs_lock: - _story_plan_jobs[job_id] = job - _persist_story_plan_job(job) - threading.Thread(target=_run_story_plan_job, args=(job_id,), daemon=True).start() +def _public_minimax_music_job(job: dict) -> dict: return { - key: job[key] for key in ( - "jobId", "status", "message", "stage", "current", "total", "createdAt", - ) + key: copy.deepcopy(value) + for key, value in job.items() + if key not in {"request", "_cancel_requested"} } -@api.get("/api/v1/stories/generate/status/{job_id}") -def get_story_lab_generation(job_id: str): - job = _load_story_plan_job(job_id) - if not job: - raise HTTPException(status_code=404, detail="Story generation job not found") - return { - key: job.get(key) for key in ( - "jobId", "status", "message", "stage", "current", "total", - "createdAt", "updatedAt", "finishedAt", "result", "error", - ) - } +def _publish_minimax_music_job(job: dict) -> None: + publisher = globals().get("_publish_generic_legacy_task") + if not callable(publisher): + return + try: + publisher(copy.deepcopy(job), "minimax-music") + for child in job.get("children") or []: + if isinstance(child, dict): + publisher(copy.deepcopy(child), "minimax-music-candidate") + except Exception as exc: + print(f"[Task registry] Could not publish MiniMax Music {job.get('jobId')}: {exc}") + + +def _minimax_music_job_update(job_id: str, **patch) -> dict | None: + with _minimax_music_jobs_lock: + job = _minimax_music_jobs.get(job_id) + if job is None: + return None + requested_status = str(patch.get("status") or "") + if ( + job.get("_cancel_requested") + and requested_status in { + "created", "queued", "waiting_resource", "running", + } + ): + return copy.deepcopy(job) + if ( + str(job.get("status") or "") in _MINIMAX_MUSIC_TERMINAL + and requested_status + and requested_status not in _MINIMAX_MUSIC_TERMINAL + ): + return copy.deepcopy(job) + job.update(patch) + job["updatedAt"] = time.time() + snapshot = copy.deepcopy(job) + _persist_minimax_music_job(snapshot) + _publish_minimax_music_job(snapshot) + return snapshot + + +def _minimax_music_child_update(job_id: str, child_index: int, **patch) -> dict | None: + with _minimax_music_jobs_lock: + job = _minimax_music_jobs.get(job_id) + children = job.get("children") if isinstance(job, dict) else None + if not isinstance(children, list) or not 0 <= child_index < len(children): + return None + child = children[child_index] + requested_status = str(patch.get("status") or "") + if ( + job.get("_cancel_requested") + and requested_status in { + "created", "queued", "waiting_resource", "running", + } + ): + return copy.deepcopy(job) + if ( + str(child.get("status") or "") in _MINIMAX_MUSIC_TERMINAL + and requested_status + and requested_status not in _MINIMAX_MUSIC_TERMINAL + ): + return copy.deepcopy(job) + child.update(patch) + child["updatedAt"] = time.time() + job["updatedAt"] = child["updatedAt"] + snapshot = copy.deepcopy(job) + _persist_minimax_music_job(snapshot) + _publish_minimax_music_job(snapshot) + return snapshot + + +def _minimax_music_claim_candidate( + job_id: str, + child_index: int, + lane_key: str, +) -> dict | None: + """Atomically commit one candidate before entering the provider call.""" + with _minimax_music_jobs_lock: + job = _minimax_music_jobs.get(job_id) + children = job.get("children") if isinstance(job, dict) else None + if ( + not isinstance(children, list) + or not 0 <= child_index < len(children) + or job.get("_cancel_requested") + or str(job.get("status") or "") in _MINIMAX_MUSIC_TERMINAL + ): + return None + now = time.time() + child = children[child_index] + child.update( + status="running", + phase="requesting", + message=( + f"MiniMax is generating candidate {child_index + 1}/" + f"{len(children)}…" + ), + startedAt=child.get("startedAt") or now, + acquired_resources=[lane_key], + updatedAt=now, + ) + job.update( + status="running", + phase="requesting", + message=( + f"MiniMax is generating candidate {child_index + 1}/" + f"{len(children)}…" + ), + startedAt=job.get("startedAt") or now, + acquired_resources=[], + updatedAt=now, + ) + snapshot = copy.deepcopy(job) + _persist_minimax_music_job(snapshot) + _publish_minimax_music_job(snapshot) + return snapshot + + +def _load_minimax_music_job(job_id: str) -> dict | None: + with _minimax_music_jobs_lock: + cached = _minimax_music_jobs.get(job_id) + if cached is not None: + return copy.deepcopy(cached) + for item in _list_workspaces(): + path = _minimax_music_checkpoint_path(job_id, item["name"]) + if not path or not os.path.isfile(path): + continue + try: + with open(path, "r", encoding="utf-8") as handle: + job = json.load(handle) + if not isinstance(job, dict): + continue + if str(job.get("status") or "") not in _MINIMAX_MUSIC_TERMINAL: + now = time.time() + job.update( + status="interrupted", + phase="interrupted", + message=( + "Maestro restarted while MiniMax Music was active. " + "Existing outputs were preserved; start a new request only after checking them." + ), + error="Provider completion is unknown after restart", + finishedAt=now, + updatedAt=now, + ) + for child in job.get("children") or []: + if str(child.get("status") or "") not in _MINIMAX_MUSIC_TERMINAL: + child.update( + status="interrupted", phase="interrupted", + message="Provider completion is unknown after restart", + finishedAt=now, updatedAt=now, + ) + _persist_minimax_music_job(job) + with _minimax_music_jobs_lock: + _minimax_music_jobs[job_id] = job + _publish_minimax_music_job(job) + return copy.deepcopy(job) + except Exception as exc: + print(f"[MiniMax Music] Could not restore {job_id}: {exc}") + return None + + +def _finish_unstarted_music_children(job_id: str, start_index: int, message: str) -> None: + with _minimax_music_jobs_lock: + job = _minimax_music_jobs.get(job_id) + children = job.get("children") if isinstance(job, dict) else [] + indices = [ + index for index in range(start_index, len(children)) + if str(children[index].get("status") or "") not in _MINIMAX_MUSIC_TERMINAL + ] + for index in indices: + _minimax_music_child_update( + job_id, index, status="cancelled", phase="cancelled", + message=message, finishedAt=time.time(), acquired_resources=[], + ) + + +def _run_minimax_music_job(job_id: str) -> None: + from services import minimax_music_service + + with _minimax_music_jobs_lock: + initial = copy.deepcopy(_minimax_music_jobs.get(job_id) or {}) + if not initial: + return + request_body = initial.get("request") if isinstance(initial.get("request"), dict) else {} + workspace = str(initial.get("workspace") or "default") + count = max(1, min(3, int(initial.get("total") or 1))) + lane = resource_scheduler.remote_lane("minimax", minimax_music_service.API_URL) + + def cancelled() -> bool: + with _minimax_music_jobs_lock: + return bool( + (_minimax_music_jobs.get(job_id) or {}).get("_cancel_requested") + ) + + for index in range(count): + if cancelled(): + _finish_unstarted_music_children( + job_id, index, "Cancelled before this candidate started", + ) + _minimax_music_job_update( + job_id, status="cancelled", phase="cancelled", + message="MiniMax Music generation cancelled", + finishedAt=time.time(), acquired_resources=[], + ) + return + child = initial["children"][index] + child_task_id = str(child["taskId"]) + _minimax_music_child_update( + job_id, index, status="waiting_resource", phase="waiting_resource", + message=f"Waiting for MiniMax API · candidate {index + 1}/{count}", + acquired_resources=[], + ) + _minimax_music_job_update( + job_id, status="waiting_resource", phase="waiting_resource", + message=f"Waiting for MiniMax API · candidate {index + 1}/{count}", + acquired_resources=[], + ) + try: + with resource_scheduler.coordinator.acquire( + lane, + task_id=child_task_id, + description=f"MiniMax Music candidate {index + 1}/{count}", + cancelled=cancelled, + ): + claimed = _minimax_music_claim_candidate( + job_id, index, lane.key, + ) + if claimed is None: + raise resource_scheduler.ResourceAcquireCancelled( + f"MiniMax Music job {job_id} was cancelled" + ) + result = minimax_music_service.generate_candidates( + api_key=str( + (wgp.server_config.get("services") or {}).get("minimax_api_key") + or "" + ), + prompt=str(request_body.get("prompt") or ""), + lyrics=str(request_body.get("lyrics") or ""), + count=1, + output_dir=_workspace_dir(workspace), + instrumental=bool(request_body.get("instrumental")), + model=str(request_body.get("model") or "music-3.0"), + reference_audio_path=request_body.get("reference_audio_path"), + task_id=child_task_id, + root_task_id=str(initial.get("rootTaskId") or initial.get("taskId")), + cancelled=cancelled, + )[0] + candidate = { + **result, + "source": f"/api/v1/file/{result['filename']}", + "taskId": result.get("task_id") or child_task_id, + "rootTaskId": ( + result.get("root_task_id") + or initial.get("rootTaskId") + or initial.get("taskId") + ), + } + with _minimax_music_jobs_lock: + live = _minimax_music_jobs.get(job_id) or {} + results = list(live.get("candidates") or []) + outputs = list(live.get("output_files") or []) + results.append(candidate) + if result["filename"] not in outputs: + outputs.append(result["filename"]) + _minimax_music_child_update( + job_id, index, status="completed", phase="completed", + message=f"Candidate {index + 1}/{count} generated", + current=1, total=1, progress=100, + output_files=[result["filename"]], result=candidate, + acquired_resources=[], finishedAt=time.time(), + ) + _minimax_music_job_update( + job_id, current=index + 1, progress=((index + 1) / count) * 100, + candidates=results, output_files=outputs, + ) + if cancelled(): + _finish_unstarted_music_children( + job_id, index + 1, + "Cancelled before this candidate started", + ) + _minimax_music_job_update( + job_id, status="cancelled", phase="cancelled", + message=( + "Cancellation completed at a safe provider boundary; " + f"{len(results)} generated candidate(s) were preserved" + ), + result={"candidates": results}, finishedAt=time.time(), + acquired_resources=[], + ) + return + except resource_scheduler.ResourceAcquireCancelled: + _finish_unstarted_music_children( + job_id, index, "Cancelled before this candidate started", + ) + _minimax_music_job_update( + job_id, status="cancelled", phase="cancelled", + message="MiniMax Music generation cancelled", + finishedAt=time.time(), acquired_resources=[], + ) + return + except minimax_music_service.MiniMaxMusicError as exc: + _minimax_music_child_update( + job_id, index, status="failed", phase="failed", + message=str(exc), error=str(exc), statusCode=exc.status_code, + acquired_resources=[], finishedAt=time.time(), + ) + _finish_unstarted_music_children( + job_id, index + 1, "Not started because an earlier candidate failed", + ) + _minimax_music_job_update( + job_id, status="failed", phase="failed", message=str(exc), + error=str(exc), statusCode=exc.status_code, + finishedAt=time.time(), acquired_resources=[], + ) + return + except Exception as exc: + traceback.print_exc() + _minimax_music_child_update( + job_id, index, status="failed", phase="failed", + message=f"MiniMax Music failed: {exc}", error=str(exc), + acquired_resources=[], finishedAt=time.time(), + ) + _finish_unstarted_music_children( + job_id, index + 1, "Not started because an earlier candidate failed", + ) + _minimax_music_job_update( + job_id, status="failed", phase="failed", + message=f"MiniMax Music failed: {exc}", error=str(exc), + finishedAt=time.time(), acquired_resources=[], + ) + return + + with _minimax_music_jobs_lock: + results = list((_minimax_music_jobs.get(job_id) or {}).get("candidates") or []) + _minimax_music_job_update( + job_id, status="completed", phase="completed", + message=f"Generated {len(results)} MiniMax Music candidate(s)", + current=count, total=count, progress=100, + result={"candidates": results}, finishedAt=time.time(), + acquired_resources=[], + ) + + +@api.post("/api/v1/stories/music-candidates/jobs", status_code=202) +def start_story_music_candidates_job(body: dict): + """Start observable MiniMax Music generation and return immediately.""" + from services import minimax_music_service + + workspace = body.get("workspace") if "workspace" in body else _get_active_workspace() + _workspace_dir(workspace) + model = str(body.get("model") or "music-3.0").strip() + if model not in minimax_music_service.ALLOWED_MODELS: + raise HTTPException(status_code=400, detail=f"Unsupported MiniMax Music model: {model}") + try: + count = max(1, min(3, int(body.get("count") or 2))) + except (TypeError, ValueError, OverflowError) as exc: + raise HTTPException( + status_code=400, + detail="MiniMax Music candidate count must be an integer from 1 to 3", + ) from exc + prompt = str(body.get("prompt") or "").strip()[:300] + lyrics = str(body.get("lyrics") or "").strip()[:3500] + instrumental = bool(body.get("instrumental")) + if not prompt: + raise HTTPException(status_code=400, detail="A music style prompt is required") + if model not in minimax_music_service.COVER_MODELS and not instrumental and not lyrics: + raise HTTPException(status_code=400, detail="Lyrics are required for a vocal song") + reference_audio_path = None + if model in minimax_music_service.COVER_MODELS: + reference_name = os.path.basename( + str(body.get("reference_audio_filename") or "").strip() + ) + upload_root = os.path.realpath(os.path.join(os.getcwd(), "uploads", "audio")) + reference_audio_path = _safe_join(upload_root, reference_name) if reference_name else None + if not reference_audio_path or not os.path.isfile(reference_audio_path): + raise HTTPException( + status_code=400, + detail="Upload a valid reference song before generating a cover", + ) + + job_id = f"minimax-music-{uuid.uuid4().hex[:12]}" + task_id = f"task-minimax-music-{job_id}" + now = time.time() + children = [] + for index in range(count): + child_job_id = f"{job_id}-candidate-{index + 1}" + child_task_id = f"{task_id}-candidate-{index + 1}" + children.append({ + "jobId": child_job_id, + "taskId": child_task_id, + "rootTaskId": task_id, + "parentTaskId": task_id, + "workspace": workspace, + "status": "queued", + "phase": "queued", + "message": f"MiniMax Music candidate {index + 1}/{count} queued", + "current": 0, + "total": 1, + "progress": 0, + "provider": "minimax", + "model": model, + "server_origin": "https://api.minimax.io", + "resource_lane": "remote:https://api.minimax.io", + "acquired_resources": [], + "output_files": [], + "result": None, + "error": None, + "createdAt": now, + "updatedAt": now, + }) + job = { + "jobId": job_id, + "taskId": task_id, + "rootTaskId": task_id, + "workspace": workspace, + "status": "queued", + "phase": "queued", + "message": f"{count} MiniMax Music candidate(s) queued", + "current": 0, + "total": count, + "progress": 0, + "provider": "minimax", + "model": model, + "server_origin": "https://api.minimax.io", + "resource_lane": "remote:https://api.minimax.io", + "acquired_resources": [], + "output_files": [], + "candidates": [], + "result": None, + "error": None, + "children": children, + "createdAt": now, + "updatedAt": now, + "_cancel_requested": False, + "request": { + "prompt": prompt, + "lyrics": lyrics, + "instrumental": instrumental, + "model": model, + "reference_audio_path": reference_audio_path, + }, + } + with _minimax_music_jobs_lock: + _minimax_music_jobs[job_id] = job + _persist_minimax_music_job(job) + _publish_minimax_music_job(job) + threading.Thread( + target=_run_minimax_music_job, + args=(job_id,), + name=f"minimax-music-{job_id[-6:]}", + daemon=True, + ).start() + return _public_minimax_music_job(job) + + +@api.get("/api/v1/stories/music-candidates/jobs/{job_id}") +def get_story_music_candidates_job(job_id: str): + job = _load_minimax_music_job(job_id) + if not job: + raise HTTPException(status_code=404, detail="MiniMax Music job not found") + return _public_minimax_music_job(job) + + +@api.post("/api/v1/stories/music-candidates/jobs/{job_id}/cancel") +def cancel_story_music_candidates_job(job_id: str): + job = _load_minimax_music_job(job_id) + if not job: + raise HTTPException(status_code=404, detail="MiniMax Music job not found") + if str(job.get("status") or "") in _MINIMAX_MUSIC_TERMINAL: + return _public_minimax_music_job(job) + with _minimax_music_jobs_lock: + live = _minimax_music_jobs[job_id] + live["_cancel_requested"] = True + waiting = str(live.get("status") or "") in { + "created", "queued", "waiting_resource", + } + if waiting: + _finish_unstarted_music_children( + job_id, 0, "Cancelled before this candidate started", + ) + updated = _minimax_music_job_update( + job_id, status="cancelled", phase="cancelled", + message="Cancelled before the provider call", finishedAt=time.time(), + acquired_resources=[], + ) + else: + updated = _minimax_music_job_update( + job_id, status="cancelling", phase="cancelling", + message="Cancellation requested; waiting for the active MiniMax request…", + ) + return _public_minimax_music_job(updated or job) + + +@api.post("/api/v1/stories/music-candidates") +async def generate_story_music_candidates(body: dict): + """Compatibility endpoint for older clients; new clients use durable jobs.""" + from services import minimax_music_service + + services = wgp.server_config.get("services", {}) + workspace = str(body.get("workspace") or _get_active_workspace()) + model = str(body.get("model") or "music-3.0").strip() + reference_audio_path = None + if model in {"music-cover", "music-cover-free"}: + reference_name = os.path.basename(str(body.get("reference_audio_filename") or "").strip()) + upload_root = os.path.realpath(os.path.join(os.getcwd(), "uploads", "audio")) + reference_audio_path = _safe_join(upload_root, reference_name) if reference_name else None + if not reference_audio_path or not os.path.isfile(reference_audio_path): + raise HTTPException(status_code=400, detail="Upload a valid reference song before generating a cover") + try: + candidates = await asyncio.to_thread( + minimax_music_service.generate_candidates, + api_key=str(services.get("minimax_api_key") or ""), + prompt=str(body.get("prompt") or ""), + lyrics=str(body.get("lyrics") or ""), + count=int(body.get("count") or 2), + output_dir=_workspace_dir(workspace), + instrumental=bool(body.get("instrumental")), + model=model, + reference_audio_path=reference_audio_path, + ) + except minimax_music_service.MiniMaxMusicError as exc: + raise HTTPException(status_code=exc.status_code, detail=str(exc)) from exc + return { + "candidates": [ + { + **candidate, + "source": f"/api/v1/file/{candidate['filename']}", + } + for candidate in candidates + ] + } + + +@api.post("/api/v1/stories/translate-lyrics") +async def translate_story_lyrics(body: dict): + """Translate editable song lyrics with the Story Lab writing provider.""" + from services import llm_service + + lyrics = str(body.get("lyrics") or "").strip() + target_language = str(body.get("targetLanguage") or "").strip()[:80] + if not lyrics: + raise HTTPException(status_code=400, detail="Lyrics are required") + if not target_language: + raise HTTPException(status_code=400, detail="Choose a target language") + system_prompt = ( + "Translate song lyrics accurately. Return only the translated lyrics, " + "with no explanation, title, markdown or code fence. Translate only the sung " + "lyric lines. Copy every song instruction enclosed in square brackets exactly " + "as written, preserving its English text and capitalization (for example " + "[Verse], [Pre Chorus], [Chorus], [Bridge], [Outro] or [Female vocal])." + ) + prompt = ( + f"Translate the following lyrics into {target_language}. Do not translate or " + "alter any text enclosed in square brackets; copy those instructions verbatim.\n\n" + f"{lyrics}" + ) + llm_override = _comic_writing_llm(body) + try: + if llm_override: + translated = llm_service.generate_openai_compatible( + prompt=prompt, + system_prompt=system_prompt, + model_id=llm_override["model"], + base_url=llm_override["base_url"], + api_key=llm_override["api_key"], + max_new_tokens=min(3000, max(500, len(lyrics) * 2)), + temperature=0.2, + ) + else: + _ensure_llm_loaded() + translated = llm_service.generate_streaming( + prompt=prompt, + system_prompt=system_prompt, + max_new_tokens=min(3000, max(500, len(lyrics) * 2)), + temperature=0.2, + enable_thinking=False, + thinking_budget=0, + ) + except Exception as exc: + raise HTTPException(status_code=502, detail=f"Lyric translation failed: {exc}") from exc + + translated = str(translated or "").strip() + if translated.startswith("```"): + translated = re.sub(r"^```(?:text|markdown)?\s*|\s*```$", "", translated, flags=re.IGNORECASE).strip() + if not translated: + raise HTTPException(status_code=502, detail="The LLM returned empty translated lyrics") + return {"lyrics": translated, "targetLanguage": target_language} + + +@api.post("/api/v1/stories/generate/start") +def start_story_lab_generation(body: dict): + scope = str(body.get("scope") or "all").strip().lower() + allowed = {"all", "overview", "world", "characters", "relationships", "structure", "music"} + if scope not in allowed: + raise HTTPException(status_code=400, detail="Unsupported Story Lab generation scope") + if not str(body.get("premise") or "").strip(): + raise HTTPException(status_code=400, detail="Write a premise before generating the story") + project = body.get("project") if isinstance(body.get("project"), dict) else {} + project_type = str(project.get("projectType") or "full_story").strip().lower() + stage_total = 5 if project_type == "music_video" else 4 if project_type in {"trailer", "quick_video"} else 6 + job_id = f"story-plan-{uuid.uuid4().hex[:12]}" + task_id = f"task-story-plan-{job_id}" + job = { + "jobId": job_id, + "status": "queued", + "message": "Story generation queued.", + "stage": "queued", + "current": 0, + "total": stage_total if scope == "all" else 1, + "request": _story_checkpoint_request(body), + "completedStages": {}, + "result": None, + "error": None, + "createdAt": time.time(), + "updatedAt": time.time(), + "workspace": _get_active_workspace(), + "taskId": task_id, + "rootTaskId": task_id, + } + with _story_plan_jobs_lock: + _story_plan_jobs[job_id] = job + publisher = globals().get("_publish_generic_legacy_task") + if callable(publisher): + task = publisher(job, "story-plan") + if isinstance(task, dict): + job["taskId"] = task.get("id") + job["rootTaskId"] = task.get("root_id") or task.get("id") + with _story_plan_jobs_lock: + _story_plan_jobs[job_id] = copy.deepcopy(job) + _persist_story_plan_job(job) + _start_story_plan_worker(job_id) + return { + key: job[key] for key in ( + "jobId", "taskId", "rootTaskId", "status", "message", "stage", + "current", "total", "createdAt", + ) + } + + +@api.get("/api/v1/stories/generate/status/{job_id}") +def get_story_lab_generation(job_id: str): + job = _load_story_plan_job(job_id) + if not job: + raise HTTPException(status_code=404, detail="Story generation job not found") + return { + key: job.get(key) for key in ( + "jobId", "status", "message", "stage", "current", "total", + "taskId", "rootTaskId", "createdAt", "updatedAt", "finishedAt", + "result", "error", + ) + } @api.post("/api/v1/stories/generate/resume/{job_id}") @@ -29515,6 +31816,10 @@ def resume_story_lab_generation(job_id: str, body: dict | None = None): # model names or missing credentials fail without damaging recovery. if isinstance(body, dict) and str(body.get("writingProvider") or "").strip(): _comic_writing_llm(request) + _reset_canonical_task_for_resume( + str(job.get("workspace") or "default"), + str(job.get("taskId") or f"task-story-plan-{job_id}"), + ) _story_job_update( job_id, request=request, @@ -29523,7 +31828,13 @@ def resume_story_lab_generation(job_id: str, body: dict | None = None): error=None, finishedAt=None, ) - threading.Thread(target=_run_story_plan_job, args=(job_id,), daemon=True).start() + if not _start_story_plan_worker(job_id): + current = _load_story_plan_job(job_id) or job + return { + "jobId": job_id, + "status": current.get("status") or "queued", + "message": "Story generation is already running.", + } return {"jobId": job_id, "status": "queued", "message": "Story generation resumed."} @@ -29534,16 +31845,24 @@ def cancel_story_lab_generation(job_id: str): raise HTTPException(status_code=404, detail="Story generation job not found") if job.get("status") == "completed": return {"jobId": job_id, "status": "completed", "message": job.get("message")} - _story_job_update( + with _story_plan_jobs_lock: + worker_active = job_id in _story_plan_active_jobs + updated = _story_job_update( job_id, - status="cancelled", - message="Story generation cancelled. Completed stages remain recoverable.", - finishedAt=time.time(), + status="cancelling" if worker_active else "cancelled", + stage="cancelling" if worker_active else "cancelled", + message=( + "Story generation cancellation requested; waiting for the active " + "LLM call to reach a safe boundary." + if worker_active else + "Story generation cancelled before an LLM call started." + ), + finishedAt=None if worker_active else time.time(), ) return { "jobId": job_id, - "status": "cancelled", - "message": "Story generation cancelled. Completed stages remain recoverable.", + "status": (updated or job).get("status"), + "message": (updated or job).get("message"), } @@ -30403,6 +32722,7 @@ def director_comic_story_revise(body: dict): # honest server-side state instead of waiting on one opaque HTTP request. _comic_plan_jobs: dict[str, dict] = {} _comic_plan_jobs_lock = threading.Lock() +_comic_plan_active_jobs: set[str] = set() def _comic_plan_checkpoint_dir(workspace: str | None = None) -> str: @@ -30461,9 +32781,15 @@ def _comic_plan_job_update(job_id: str, **patch) -> None: snapshot = copy.deepcopy(job) if snapshot is not None: _persist_comic_plan_job(snapshot) + publisher = globals().get("_publish_generic_legacy_task") + if callable(publisher): + try: + publisher(snapshot, "comic-plan") + except Exception as exc: + print(f"[Task registry] Could not publish comic plan {job_id}: {exc}") -def _run_comic_plan_job(job_id: str, body: dict) -> None: +def _run_comic_plan_job_inner(job_id: str, body: dict) -> None: services = wgp.server_config.get("services", {}) requested_provider = str(body.get("writingProvider") or "maestro").strip().lower() external = requested_provider not in ("", "maestro", "internal", "local") @@ -30524,12 +32850,64 @@ def _run_comic_plan_job(job_id: str, body: dict) -> None: ) +def _run_comic_plan_job(job_id: str, body: dict) -> None: + """Run one claimed Comic planner and restore its canonical task context.""" + with _comic_plan_jobs_lock: + _comic_plan_active_jobs.add(job_id) + job = copy.deepcopy(_comic_plan_jobs.get(job_id) or {}) + try: + from services.task_manager import task_context_scope + + workspace = str(job.get("workspace") or body.get("workspace") or "default") + task_id = str( + job.get("taskId") or f"task-comic-plan-{job_id}" + ) + root_task_id = str(job.get("rootTaskId") or task_id) + with task_context_scope( + task_id=task_id, + root_task_id=root_task_id, + workspace=workspace, + workspace_dir=_workspace_dir(workspace), + ): + _run_comic_plan_job_inner(job_id, body) + finally: + with _comic_plan_jobs_lock: + _comic_plan_active_jobs.discard(job_id) + + +def _start_comic_plan_worker( + job_id: str, + body: dict, + *, + thread_name: str, +) -> bool: + """Atomically prevent duplicate Comic planner threads in this process.""" + with _comic_plan_jobs_lock: + if job_id in _comic_plan_active_jobs: + return False + _comic_plan_active_jobs.add(job_id) + thread = threading.Thread( + target=_run_comic_plan_job, + args=(job_id, dict(body)), + name=thread_name, + daemon=True, + ) + try: + thread.start() + except Exception: + with _comic_plan_jobs_lock: + _comic_plan_active_jobs.discard(job_id) + raise + return True + + @api.post("/api/v1/director/comic/plan/start") def start_director_comic_plan(body: dict): premise = str(body.get("premise") or "").strip() if not premise: raise HTTPException(status_code=400, detail="Comic premise is required") job_id = f"comic-plan-job-{uuid.uuid4().hex[:12]}" + task_id = f"task-comic-plan-{job_id}" now = time.time() workspace = str(body.get("workspace") or _get_active_workspace()) request_body = dict(body) @@ -30552,16 +32930,35 @@ def start_director_comic_plan(body: dict): "updatedAt": now, "workspace": workspace, "request": request_body, + "taskId": task_id, + "rootTaskId": task_id, } initial_job = copy.deepcopy(_comic_plan_jobs[job_id]) _persist_comic_plan_job(initial_job) - threading.Thread( - target=_run_comic_plan_job, - args=(job_id, request_body), - name=f"comic-plan-{job_id[-6:]}", - daemon=True, - ).start() - return {"jobId": job_id, "status": "queued", "message": "Comic Director accepted the request."} + publisher = globals().get("_publish_generic_legacy_task") + if callable(publisher): + task = publisher(initial_job, "comic-plan") + if isinstance(task, dict): + initial_job["taskId"] = task.get("id") + initial_job["rootTaskId"] = task.get("root_id") or task.get("id") + with _comic_plan_jobs_lock: + _comic_plan_jobs[job_id].update({ + "taskId": initial_job["taskId"], + "rootTaskId": initial_job["rootTaskId"], + }) + _persist_comic_plan_job(initial_job) + _start_comic_plan_worker( + job_id, + request_body, + thread_name=f"comic-plan-{job_id[-6:]}", + ) + return { + "jobId": job_id, + "taskId": initial_job.get("taskId"), + "rootTaskId": initial_job.get("rootTaskId"), + "status": "queued", + "message": "Comic Director accepted the request.", + } @api.get("/api/v1/director/comic/plan/status/{job_id}") @@ -30581,11 +32978,17 @@ def get_director_comic_plan_status(job_id: str): @api.post("/api/v1/director/comic/plan/resume/{job_id}") def resume_director_comic_plan(job_id: str): job = get_director_comic_plan_status(job_id) - if job.get("status") in ("queued", "loading_llm", "planning", "planning_bible", "planning_page"): + with _comic_plan_jobs_lock: + active = job_id in _comic_plan_active_jobs + if active: return {"jobId": job_id, "status": job["status"], "message": job.get("message", "Already running")} body = job.get("request") if not isinstance(body, dict): raise HTTPException(status_code=409, detail="This legacy checkpoint has no saved request and cannot resume planning") + _reset_canonical_task_for_resume( + str(job.get("workspace") or "default"), + str(job.get("taskId") or f"task-comic-plan-{job_id}"), + ) _comic_plan_job_update( job_id, status="queued", @@ -30594,12 +32997,17 @@ def resume_director_comic_plan(job_id: str): result=None, finishedAt=None, ) - threading.Thread( - target=_run_comic_plan_job, - args=(job_id, dict(body)), - name=f"comic-plan-resume-{job_id[-6:]}", - daemon=True, - ).start() + if not _start_comic_plan_worker( + job_id, + body, + thread_name=f"comic-plan-resume-{job_id[-6:]}", + ): + current = get_director_comic_plan_status(job_id) + return { + "jobId": job_id, + "status": current.get("status") or "queued", + "message": current.get("message") or "Already running", + } return {"jobId": job_id, "status": "queued", "message": "Resuming from the latest durable checkpoint…"} @@ -30643,26 +33051,11 @@ def get_latest_completed_director_comic_plan(): ) -def _resolve_output_file(filename: str) -> str | None: - """Resolve an output in the active, default, or another workspace.""" - save_root = wgp.server_config.get("save_path", "outputs") - roots = [_workspace_dir(), save_root] - if os.path.isdir(save_root): - roots.extend( - os.path.join(save_root, name) - for name in os.listdir(save_root) - if os.path.isdir(os.path.join(save_root, name)) - ) - seen: set[str] = set() - for root in roots: - root_real = os.path.realpath(root) - if root_real in seen: - continue - seen.add(root_real) - candidate = _safe_join(root_real, filename) - if candidate and os.path.isfile(candidate): - return candidate - return None +def _resolve_output_file(filename: str, workspace: str | None = None) -> str | None: + """Resolve one output only inside its explicit or active workspace.""" + root = _workspace_dir(workspace) + candidate = _safe_join(root, filename) + return candidate if candidate and os.path.isfile(candidate) else None @api.get("/api/v1/outputs") @@ -30683,7 +33076,13 @@ def list_outputs(response: Response, limit: int = 0, offset: int = 0, favorites_ if workspace == "__uploads__": out_dir = os.path.join(os.getcwd(), "uploads") else: - out_dir = _workspace_dir() + out_dir = _workspace_dir(workspace or None) + workspace_suffix = ( + f"?workspace={quote(workspace, safe='')}" if workspace else "" + ) + workspace_extra = ( + f"&workspace={quote(workspace, safe='')}" if workspace else "" + ) if not os.path.isdir(out_dir): return {"outputs": [], "total": 0} @@ -30705,7 +33104,7 @@ def model3d_thumbnail_url(name: str, params: dict) -> str | None: """ preview_name = os.path.splitext(name)[0] + ".preview.png" if os.path.isfile(os.path.join(out_dir, preview_name)): - return f"/api/v1/file/{preview_name}" + return f"/api/v1/file/{preview_name}{workspace_suffix}" images = params.get("images") if not isinstance(images, dict): return None @@ -30722,7 +33121,7 @@ def model3d_thumbnail_url(name: str, params: dict) -> str | None: if source.startswith(uploads_root + os.sep): return f"/api/v1/uploads/{filename}" if source.startswith(outputs_root + os.sep): - return f"/api/v1/file/{filename}" + return f"/api/v1/file/{filename}{workspace_suffix}" except OSError: pass return None @@ -30794,6 +33193,16 @@ def model3d_thumbnail_url(name: str, params: dict) -> str | None: "edit_sub_mode": params.get("edit_sub_mode"), "multi_clip_info": params.get("multi_clip_info"), "thumbnail_url": model3d_thumbnail_url(name, params) if ext in model3d_exts else None, + # Output sidecars are written only after the generated asset + # has been published. Their historical ``created_at`` field + # therefore represents completion time, despite the old name. + # Prefer an explicit completion field when newer producers + # provide one and retain mtime as the legacy/import fallback. + "completed_at": next(( + float(meta[key]) + for key in ("completed_at", "finished_at", "created_at") + if isinstance(meta.get(key), (int, float)) and float(meta[key]) > 0 + ), None), } mci = sidecar_cache[name]["multi_clip_info"] if mci and mci.get("group_id"): @@ -30830,6 +33239,7 @@ def model3d_thumbnail_url(name: str, params: dict) -> str | None: cached = sidecar_cache.get(name) or {} mode = cached.get("mode") edit_sub_mode = cached.get("edit_sub_mode") + metadata_completed_at = cached.get("completed_at") mci = cached.get("multi_clip_info") is_intermediate_clip = False if mci and mci.get("group_id"): @@ -30858,13 +33268,15 @@ def model3d_thumbnail_url(name: str, params: dict) -> str | None: "favorite": name in favs, "size": size, "created_at": mtime, - "url": f"/api/v1/file/{name}", + "completed_at": metadata_completed_at or mtime, + "completion_time_source": "metadata" if metadata_completed_at else "file", + "url": f"/api/v1/file/{name}{workspace_suffix}", "thumbnail_url": ( - f"/api/v1/file/{name[:-len('.comic.json')]}.comic.preview.png" + f"/api/v1/file/{name[:-len('.comic.json')]}.comic.preview.png{workspace_suffix}" if is_comic and os.path.isfile(os.path.join(out_dir, name[:-len(".comic.json")] + ".comic.preview.png")) - else (f"/api/v1/file/{os.path.splitext(name)[0]}.preview.png" + else (f"/api/v1/file/{os.path.splitext(name)[0]}.preview.png{workspace_suffix}" if is_scene and os.path.isfile(os.path.join(out_dir, os.path.splitext(name)[0] + ".preview.png")) - else (f"/api/v1/outputs/thumbnail/{quote(name, safe='')}?v={int(mtime * 1_000_000)}-{size}" + else (f"/api/v1/outputs/thumbnail/{quote(name, safe='')}?v={int(mtime * 1_000_000)}-{size}{workspace_extra}" if ftype in {"image", "video"} else cached.get("thumbnail_url"))) ), @@ -30934,11 +33346,16 @@ def model3d_thumbnail_url(name: str, params: dict) -> str | None: @api.get("/api/v1/outputs/thumbnail/{filename:path}") -def serve_output_thumbnail(filename: str): +def serve_output_thumbnail(filename: str, workspace: str | None = None): """Lazily create one small static preview for an image or video output.""" from services.media_thumbnails import ensure_media_thumbnail - source = _resolve_output_file(filename) + if workspace == "__uploads__": + source = _safe_join(os.path.join(os.getcwd(), "uploads"), filename) + if source and not os.path.isfile(source): + source = None + else: + source = _resolve_output_file(filename, workspace) if not source: raise HTTPException(status_code=404, detail="Output not found") extension = os.path.splitext(source)[1].lower() @@ -30962,8 +33379,8 @@ def serve_output_thumbnail(filename: str): @api.get("/api/v1/file/{filename:path}") -def serve_file(filename: str): - """Serve an output file. Checks active workspace first, then all workspaces. +def serve_file(filename: str, workspace: str | None = None): + """Serve an output file from the explicit or active workspace. Uses share_delete_file_response so that on Windows the file can be deleted/renamed by the gallery delete button even while the browser @@ -30972,7 +33389,7 @@ def serve_file(filename: str): user has to close the entire app to clean up. """ from services.win_safe_files import share_delete_file_response - filepath = _resolve_output_file(filename) + filepath = _resolve_output_file(filename, workspace) if filepath: return share_delete_file_response(filepath) # Uploads folder — the gallery's virtual "Uploads" view lists these @@ -30980,16 +33397,16 @@ def serve_file(filename: str): # builds (thumbnails, playback, send-to-input). Upload names are # hash-uniquified at upload time, and outputs are checked first, so # an output name can never be shadowed by an upload. - filepath = _safe_join(os.path.join(os.getcwd(), "uploads"), filename) + filepath = _safe_join(os.path.join(os.getcwd(), "uploads"), filename) if workspace in {None, "", "__uploads__"} else None if filepath and os.path.isfile(filepath): return share_delete_file_response(filepath) raise HTTPException(status_code=404, detail="File not found") @api.get("/api/v1/outputs/{name}/metadata") -def get_output_metadata(name: str): +def get_output_metadata(name: str, workspace: str | None = None): """Get metadata for an output file. Tries sidecar first, then embedded.""" - out_dir = _workspace_dir() + out_dir = _workspace_dir(workspace) filepath = _safe_join(out_dir, name) if filepath is None or not os.path.isfile(filepath): raise HTTPException(status_code=404, detail="Output file not found") @@ -31367,18 +33784,166 @@ def get_group_clips(group_id: str): # ============================================================================ _video_editor_jobs: dict[str, dict] = {} +_video_editor_jobs_lock = threading.RLock() +_VIDEO_EDITOR_TERMINAL = frozenset({"completed", "failed", "cancelled"}) +_VIDEO_EDITOR_FFMPEG_LANE = resource_scheduler.cpu_lane("ffmpeg") _VIDEO_EDITOR_EXTENSIONS = {".mp4", ".webm", ".mov", ".mkv", ".avi", ".m4v"} _COMIC_ANIMATIC_IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp"} -def _resolve_video_editor_source(source: str) -> str: +def _public_video_editor_job(job: dict) -> dict: + """Return a stable API snapshot without worker-only coordination flags.""" + return { + key: copy.deepcopy(value) + for key, value in job.items() + if not key.startswith("_") + } + + +def _publish_video_editor_job(snapshot: dict) -> dict | None: + """Publish every editor/animatic mutation immediately to task SSE.""" + return _publish_generic_legacy_task(snapshot, "video-editor") + + +def _video_editor_job_snapshot(job_id: str) -> dict | None: + with _video_editor_jobs_lock: + job = _video_editor_jobs.get(job_id) + return copy.deepcopy(job) if job is not None else None + + +def _video_editor_job_update(job_id: str, **changes) -> dict: + """Atomically mutate a job while making cancellation terminally absorbing.""" + should_publish = False + with _video_editor_jobs_lock: + job = _video_editor_jobs.get(job_id) + if job is None: + raise KeyError(job_id) + if str(job.get("status") or "") in _VIDEO_EDITOR_TERMINAL: + snapshot = copy.deepcopy(job) + else: + requested_status = str(changes.get("status") or "") + if job.get("_cancel_requested"): + if requested_status in {"completed", "failed"}: + changes.update({ + "status": "cancelled", + "phase": "cancelled", + "message": "Cancelled at the FFmpeg safe boundary", + "error": None, + "result": None, + "filename": None, + "url": None, + "output_files": [], + "acquired_resources": [], + "cancel_mode": job.get("cancel_mode") or "deferred", + "safe_boundary": ( + job.get("safe_boundary") + or "after_current_ffmpeg_render" + ), + "finished_at": time.time(), + }) + elif requested_status not in {"cancelled", "cancelling"}: + owns_lane = bool( + changes.get( + "acquired_resources", + job.get("acquired_resources") or [], + ) + ) + changes.update({ + "status": "cancelling", + "phase": "cancelling", + "message": ( + "Cancellation deferred to a safe boundary; " + "waiting for FFmpeg to finish…" + if owns_lane else + "FFmpeg safe boundary reached; cleaning up cancellation…" + ), + "cancel_mode": "deferred", + "safe_boundary": "after_current_ffmpeg_render", + }) + job.update(changes) + job["updated_at"] = time.time() + snapshot = copy.deepcopy(job) + should_publish = True + # Keep mutation and publication ordered relative to cancel/progress + # calls from other threads. The registry write is local SQLite only. + if should_publish: + _publish_video_editor_job(snapshot) + return snapshot + + +def _register_video_editor_job(job: dict) -> dict: + """Reserve legacy and canonical identities before starting any worker.""" + job_id = str(job["job_id"]) + with _video_editor_jobs_lock: + _video_editor_jobs[job_id] = job + snapshot = copy.deepcopy(job) + try: + _publish_video_editor_job(snapshot) + except Exception: + if _video_editor_jobs.get(job_id) is job: + _video_editor_jobs.pop(job_id, None) + raise + return snapshot + + +def _video_editor_cancel_requested(job_id: str) -> bool: + with _video_editor_jobs_lock: + job = _video_editor_jobs.get(job_id) + return job is None or bool(job.get("_cancel_requested")) + + +def _remove_video_editor_output_bundle(output_path: str) -> None: + """Remove an incomplete/cancelled MP4 and its metadata sidecar.""" + for candidate in ( + output_path, + os.path.splitext(output_path)[0] + ".meta.json", + ): + try: + if os.path.isfile(candidate): + os.remove(candidate) + except OSError: + pass + + +def _finish_video_editor_cancelled( + job_id: str, + output_path: str, + *, + message: str = "Cancelled before FFmpeg started", + cancel_mode: str = "immediate", + safe_boundary: str = "before_ffmpeg", +) -> dict: + """Finish cancellation only after the worker no longer owns its lane.""" + _remove_video_editor_output_bundle(output_path) + changes = { + "status": "cancelled", + "phase": "cancelled", + "message": message, + "cancel_mode": cancel_mode, + "safe_boundary": safe_boundary, + "error": None, + "result": None, + "filename": None, + "url": None, + "output_files": [], + "acquired_resources": [], + "finished_at": time.time(), + "_worker_active": False, + } + if cancel_mode == "immediate": + changes["progress"] = 0 + changes["current"] = 0 + return _video_editor_job_update(job_id, **changes) + + +def _resolve_video_editor_source(source: str, workspace: str | None = None) -> str: """Resolve an editor reference without allowing access outside Maestro.""" from urllib.parse import unquote if not isinstance(source, str) or not source.strip(): raise ValueError("Video source is missing") decoded = unquote(source.strip()) - resolved = _resolve_model3d_input_path(decoded) + resolved = _resolve_model3d_input_path(decoded, workspace) if not resolved or not os.path.isfile(resolved): raise ValueError(f"Video source could not be found: {os.path.basename(decoded)}") if os.path.splitext(resolved)[1].lower() not in _VIDEO_EDITOR_EXTENSIONS: @@ -31386,12 +33951,12 @@ def _resolve_video_editor_source(source: str) -> str: return resolved -def _resolve_comic_animatic_image(source: str) -> str: +def _resolve_comic_animatic_image(source: str, workspace: str | None = None) -> str: """Resolve a captured panel image using Maestro's existing safe path rules.""" from urllib.parse import unquote decoded = unquote(str(source or "").strip()) - resolved = _resolve_model3d_input_path(decoded) + resolved = _resolve_model3d_input_path(decoded, workspace) if not resolved or not os.path.isfile(resolved): raise ValueError(f"Comic panel image could not be found: {os.path.basename(decoded)}") if os.path.splitext(resolved)[1].lower() not in _COMIC_ANIMATIC_IMAGE_EXTENSIONS: @@ -31504,35 +34069,173 @@ def capture_video_editor_frame(body: dict): ) from exc +def _video_editor_task_identity(body: dict, job_id: str) -> tuple[str, str, str | None]: + """Accept an optional caller hierarchy without allowing malformed task IDs.""" + supplied_task_id = str(body.get("task_id") or "").strip() + supplied_root_id = str(body.get("root_task_id") or "").strip() + supplied_parent_id = str(body.get("parent_task_id") or "").strip() + for label, value in ( + ("task_id", supplied_task_id), + ("root_task_id", supplied_root_id), + ("parent_task_id", supplied_parent_id), + ): + if value and not re.fullmatch(r"task-[A-Za-z0-9_-]{1,180}", value): + raise HTTPException(status_code=400, detail=f"Invalid {label}") + task_id = supplied_task_id or f"task-video-editor-{job_id}" + root_task_id = supplied_root_id or supplied_parent_id or task_id + return task_id, root_task_id, supplied_parent_id or None + + def _run_video_editor_export(job_id: str, body: dict, out_dir: str, output_path: str) -> None: from services.video_editor import render_project - job = _video_editor_jobs[job_id] + job = _video_editor_job_snapshot(job_id) + if job is None or str(job.get("status") or "") in _VIDEO_EDITOR_TERMINAL: + return + workspace = str(job["workspace"]) + task_id = str(job["task_id"]) def report(progress: int, message: str) -> None: - job["progress"] = max(0, min(progress, 100)) - job["message"] = message - job["updated_at"] = time.time() + bounded = max(0, min(int(progress), 100)) + if _video_editor_cancel_requested(job_id): + _video_editor_job_update( + job_id, + status="cancelling", + phase="cancelling", + progress=bounded, + current=bounded, + message=( + "Cancellation deferred to a safe boundary; " + f"FFmpeg is finishing: {message}" + ), + cancel_mode="deferred", + safe_boundary="after_current_ffmpeg_render", + ) + # render_project calls progress only between blocking FFmpeg + # subprocesses. Raising here stops before the next subprocess, + # after the current one has reached a safe boundary. + raise resource_scheduler.ResourceAcquireCancelled( + f"Video editor export {job_id} reached an FFmpeg safe boundary" + ) + else: + _video_editor_job_update( + job_id, + status="running", + phase="rendering", + progress=bounded, + current=bounded, + message=message, + ) try: - job["status"] = "running" - report(1, "Validating source clips…") + _video_editor_job_update( + job_id, + status="queued", + phase="validating_sources", + progress=1, + current=1, + message="Validating source clips…", + ) resolved_clips = [] for clip in body["clips"]: + if _video_editor_cancel_requested(job_id): + _finish_video_editor_cancelled(job_id, output_path) + return if not isinstance(clip, dict): raise ValueError("Every timeline entry must be a clip object") resolved = dict(clip) - resolved["resolved_path"] = _resolve_video_editor_source(str(clip.get("source") or "")) + resolved["resolved_path"] = _resolve_video_editor_source( + str(clip.get("source") or ""), workspace, + ) resolved_clips.append(resolved) - result = render_project( - resolved_clips, - output_path, - width=int(body["width"]), - height=int(body["height"]), - fps=int(body["fps"]), - progress=report, + if _video_editor_cancel_requested(job_id): + _finish_video_editor_cancelled(job_id, output_path) + return + _video_editor_job_update( + job_id, + status="waiting_resource", + phase="waiting_resource", + message="Waiting for the local FFmpeg lane…", + acquired_resources=[], + ) + try: + with resource_scheduler.coordinator.acquire( + _VIDEO_EDITOR_FFMPEG_LANE, + task_id=task_id, + description="Video editor export", + cancelled=lambda: _video_editor_cancel_requested(job_id), + ): + started = _video_editor_job_update( + job_id, + status="running", + phase="rendering", + message="Preparing video export with FFmpeg…", + started_at=time.time(), + acquired_resources=[_VIDEO_EDITOR_FFMPEG_LANE.key], + _resource_acquired=True, + ) + if ( + _video_editor_cancel_requested(job_id) + or str(started.get("status") or "") in _VIDEO_EDITOR_TERMINAL + ): + raise resource_scheduler.ResourceAcquireCancelled( + f"Video editor export {job_id} was cancelled before FFmpeg started" + ) + result = render_project( + resolved_clips, + output_path, + width=int(body["width"]), + height=int(body["height"]), + fps=int(body["fps"]), + progress=report, + ) + except resource_scheduler.ResourceAcquireCancelled: + current = _video_editor_job_snapshot(job_id) or {} + deferred = bool(current.get("started_at")) + _finish_video_editor_cancelled( + job_id, + output_path, + message=( + "Cancelled after FFmpeg reached a safe boundary" + if deferred else "Cancelled before FFmpeg started" + ), + cancel_mode="deferred" if deferred else "immediate", + safe_boundary=( + "after_current_ffmpeg_render" if deferred else "before_ffmpeg" + ), + ) + return + + if _video_editor_cancel_requested(job_id): + _finish_video_editor_cancelled( + job_id, + output_path, + message="Cancelled after FFmpeg reached a safe boundary", + cancel_mode="deferred", + safe_boundary="after_current_ffmpeg_render", + ) + return + saving = _video_editor_job_update( + job_id, + status="running", + phase="saving", + message="Saving video metadata…", + acquired_resources=[], + _resource_acquired=False, ) + if ( + _video_editor_cancel_requested(job_id) + or str(saving.get("status") or "") == "cancelling" + ): + _finish_video_editor_cancelled( + job_id, + output_path, + message="Cancelled after FFmpeg reached a safe boundary", + cancel_mode="deferred", + safe_boundary="after_current_ffmpeg_render", + ) + return output_name = os.path.basename(output_path) sidecar = { @@ -31567,41 +34270,67 @@ def report(progress: int, message: str) -> None: }, "generation_mode": "video", "job_id": job_id, + "task_id": task_id, + "root_task_id": str(job["root_task_id"]), + "workspace": workspace, "created_at": time.time(), } meta_path = os.path.join(out_dir, os.path.splitext(output_name)[0] + ".meta.json") with open(meta_path, "w", encoding="utf-8") as handle: json.dump(sidecar, handle, indent=2, ensure_ascii=False) - job.update( - { - "status": "completed", - "progress": 100, - "message": "Video export complete", - "filename": output_name, - "url": f"/api/v1/file/{output_name}", - "result": result, - "updated_at": time.time(), - } + completed = _video_editor_job_update( + job_id, + status="completed", + phase="completed", + progress=100, + current=100, + message="Video export complete", + filename=output_name, + url=f"/api/v1/file/{output_name}", + output_files=[output_name], + result=result, + error=None, + acquired_resources=[], + finished_at=time.time(), + _worker_active=False, ) + if str(completed.get("status") or "") == "cancelled": + _remove_video_editor_output_bundle(output_path) except Exception as exc: - traceback.print_exc() - try: - if os.path.isfile(output_path): - os.remove(output_path) - except OSError: - pass - job.update( - { - "status": "failed", - "error": str(exc), - "message": f"Export failed: {exc}", - "updated_at": time.time(), - } + if _video_editor_cancel_requested(job_id): + current = _video_editor_job_snapshot(job_id) or {} + deferred = bool(current.get("started_at")) + _finish_video_editor_cancelled( + job_id, + output_path, + message=( + "Cancelled after FFmpeg reached a safe boundary" + if deferred else "Cancelled before FFmpeg started" + ), + cancel_mode="deferred" if deferred else "immediate", + safe_boundary=( + "after_current_ffmpeg_render" if deferred else "before_ffmpeg" + ), + ) + return + traceback.print_exception(type(exc), exc, exc.__traceback__) + _remove_video_editor_output_bundle(output_path) + _video_editor_job_update( + job_id, + status="failed", + phase="failed", + error=str(exc), + message=f"Export failed: {exc}", + output_files=[], + acquired_resources=[], + finished_at=time.time(), + _resource_acquired=False, + _worker_active=False, ) -@api.post("/api/v1/video-editor/export") +@api.post("/api/v1/video-editor/export", status_code=202) def start_video_editor_export(body: dict): """Queue a non-blocking FFmpeg export for uploaded and/or Maestro clips.""" from services.video_editor import normalise_time_card_text @@ -31665,10 +34394,11 @@ def start_video_editor_export(body: dict): }) clean_clips.append(clean_clip) + workspace = body.get("workspace") if body.get("workspace") is not None else _get_active_workspace() + out_dir = _workspace_dir(workspace) safe_project_name = re.sub(r"[^A-Za-z0-9_-]+", "_", str(body.get("name") or "edited_video")).strip("_") safe_project_name = safe_project_name[:60] or "edited_video" timestamp = time.strftime("%Y-%m-%d-%Hh%Mm%Ss") - out_dir = _workspace_dir() os.makedirs(out_dir, exist_ok=True) output_name = f"{timestamp}_{safe_project_name}.mp4" output_path = os.path.join(out_dir, output_name) @@ -31681,51 +34411,216 @@ def start_video_editor_export(body: dict): clean_body = dict(body) clean_body.update({"width": width, "height": height, "fps": fps, "clips": clean_clips}) job_id = f"video-edit-{uuid.uuid4().hex[:12]}" - _video_editor_jobs[job_id] = { + task_id, root_task_id, parent_task_id = _video_editor_task_identity(body, job_id) + now = time.time() + job = { "job_id": job_id, + "task_id": task_id, + "root_task_id": root_task_id, + "parent_task_id": parent_task_id, + "workspace": workspace, "status": "queued", + "phase": "queued", "progress": 0, + "current": 0, + "total": 100, "message": "Waiting to export…", "filename": None, "url": None, + "output_files": [], + "result": None, "error": None, - "created_at": time.time(), - "updated_at": time.time(), + "provider": "local", + "model": "FFmpeg", + "server_origin": "local", + "resource_lane": _VIDEO_EDITOR_FFMPEG_LANE.key, + "resource_requirements": [_VIDEO_EDITOR_FFMPEG_LANE.key], + "acquired_resources": [], + "created_at": now, + "queued_at": now, + "updated_at": now, + "_cancel_requested": False, + "_resource_acquired": False, + "_worker_active": True, } - threading.Thread( + try: + snapshot = _register_video_editor_job(job) + except Exception as exc: + raise HTTPException(status_code=500, detail=f"Could not queue video export: {exc}") from exc + worker = threading.Thread( target=_run_video_editor_export, args=(job_id, clean_body, out_dir, output_path), daemon=True, name=f"maestro-{job_id}", - ).start() - return {"job_id": job_id} + ) + try: + worker.start() + except Exception as exc: + _video_editor_job_update( + job_id, + status="failed", + phase="failed", + error=str(exc), + message=f"Could not start video export worker: {exc}", + acquired_resources=[], + finished_at=time.time(), + _worker_active=False, + ) + raise HTTPException(status_code=500, detail=f"Could not start video export: {exc}") from exc + return _public_video_editor_job(snapshot) def _run_comic_animatic(job_id: str, body: dict, output_path: str) -> None: from services.video_editor import render_comic_animatic - job = _video_editor_jobs[job_id] + job = _video_editor_job_snapshot(job_id) + if job is None or str(job.get("status") or "") in _VIDEO_EDITOR_TERMINAL: + return + workspace = str(job["workspace"]) + task_id = str(job["task_id"]) def report(progress: int, message: str) -> None: - job.update(progress=max(0, min(progress, 100)), message=message, updated_at=time.time()) + bounded = max(0, min(int(progress), 100)) + if _video_editor_cancel_requested(job_id): + _video_editor_job_update( + job_id, + status="cancelling", + phase="cancelling", + progress=bounded, + current=bounded, + message=( + "Cancellation deferred to a safe boundary; " + f"FFmpeg is finishing: {message}" + ), + cancel_mode="deferred", + safe_boundary="after_current_ffmpeg_render", + ) + # The callback runs only between blocking FFmpeg subprocesses. + raise resource_scheduler.ResourceAcquireCancelled( + f"Comic animatic {job_id} reached an FFmpeg safe boundary" + ) + else: + _video_editor_job_update( + job_id, + status="running", + phase="rendering", + progress=bounded, + current=bounded, + message=message, + ) try: - job["status"] = "running" + _video_editor_job_update( + job_id, + status="queued", + phase="validating_sources", + progress=1, + current=1, + message="Validating comic panels…", + ) panels = [] for panel in body["panels"]: + if _video_editor_cancel_requested(job_id): + _finish_video_editor_cancelled(job_id, output_path) + return + if not isinstance(panel, dict): + raise ValueError("Every animatic panel must be an object") resolved = dict(panel) - resolved["resolved_path"] = _resolve_comic_animatic_image(panel.get("source", "")) + resolved["resolved_path"] = _resolve_comic_animatic_image( + panel.get("source", ""), workspace, + ) panels.append(resolved) - result = render_comic_animatic( - panels, - output_path, - width=body["width"], - height=body["height"], - fps=body["fps"], - transition=body["transition"], - transition_duration=body["transition_duration"], - progress=report, + + if _video_editor_cancel_requested(job_id): + _finish_video_editor_cancelled(job_id, output_path) + return + _video_editor_job_update( + job_id, + status="waiting_resource", + phase="waiting_resource", + message="Waiting for the local FFmpeg lane…", + acquired_resources=[], + ) + try: + with resource_scheduler.coordinator.acquire( + _VIDEO_EDITOR_FFMPEG_LANE, + task_id=task_id, + description="Comic animatic render", + cancelled=lambda: _video_editor_cancel_requested(job_id), + ): + started = _video_editor_job_update( + job_id, + status="running", + phase="rendering", + message="Animating comic panels with FFmpeg…", + started_at=time.time(), + acquired_resources=[_VIDEO_EDITOR_FFMPEG_LANE.key], + _resource_acquired=True, + ) + if ( + _video_editor_cancel_requested(job_id) + or str(started.get("status") or "") in _VIDEO_EDITOR_TERMINAL + ): + raise resource_scheduler.ResourceAcquireCancelled( + f"Comic animatic {job_id} was cancelled before FFmpeg started" + ) + result = render_comic_animatic( + panels, + output_path, + width=body["width"], + height=body["height"], + fps=body["fps"], + transition=body["transition"], + transition_duration=body["transition_duration"], + progress=report, + ) + except resource_scheduler.ResourceAcquireCancelled: + current = _video_editor_job_snapshot(job_id) or {} + deferred = bool(current.get("started_at")) + _finish_video_editor_cancelled( + job_id, + output_path, + message=( + "Cancelled after FFmpeg reached a safe boundary" + if deferred else "Cancelled before FFmpeg started" + ), + cancel_mode="deferred" if deferred else "immediate", + safe_boundary=( + "after_current_ffmpeg_render" if deferred else "before_ffmpeg" + ), + ) + return + + if _video_editor_cancel_requested(job_id): + _finish_video_editor_cancelled( + job_id, + output_path, + message="Cancelled after FFmpeg reached a safe boundary", + cancel_mode="deferred", + safe_boundary="after_current_ffmpeg_render", + ) + return + saving = _video_editor_job_update( + job_id, + status="running", + phase="saving", + message="Saving animatic metadata…", + acquired_resources=[], + _resource_acquired=False, ) + if ( + _video_editor_cancel_requested(job_id) + or str(saving.get("status") or "") == "cancelling" + ): + _finish_video_editor_cancelled( + job_id, + output_path, + message="Cancelled after FFmpeg reached a safe boundary", + cancel_mode="deferred", + safe_boundary="after_current_ffmpeg_render", + ) + return + output_name = os.path.basename(output_path) with open(os.path.splitext(output_path)[0] + ".meta.json", "w", encoding="utf-8") as handle: json.dump({ @@ -31743,20 +34638,63 @@ def report(progress: int, message: str) -> None: }, "generation_mode": "video", "job_id": job_id, + "task_id": task_id, + "root_task_id": str(job["root_task_id"]), + "workspace": workspace, "created_at": time.time(), }, handle, indent=2, ensure_ascii=False) - job.update(status="completed", progress=100, message="Comic animatic complete", filename=output_name, url=f"/api/v1/file/{output_name}", result=result, updated_at=time.time()) + completed = _video_editor_job_update( + job_id, + status="completed", + phase="completed", + progress=100, + current=100, + message="Comic animatic complete", + filename=output_name, + url=f"/api/v1/file/{output_name}", + output_files=[output_name], + result=result, + error=None, + acquired_resources=[], + finished_at=time.time(), + _worker_active=False, + ) + if str(completed.get("status") or "") == "cancelled": + _remove_video_editor_output_bundle(output_path) except Exception as exc: - traceback.print_exc() - try: - if os.path.isfile(output_path): - os.remove(output_path) - except OSError: - pass - job.update(status="failed", error=str(exc), message=f"Animatic failed: {exc}", updated_at=time.time()) + if _video_editor_cancel_requested(job_id): + current = _video_editor_job_snapshot(job_id) or {} + deferred = bool(current.get("started_at")) + _finish_video_editor_cancelled( + job_id, + output_path, + message=( + "Cancelled after FFmpeg reached a safe boundary" + if deferred else "Cancelled before FFmpeg started" + ), + cancel_mode="deferred" if deferred else "immediate", + safe_boundary=( + "after_current_ffmpeg_render" if deferred else "before_ffmpeg" + ), + ) + return + traceback.print_exception(type(exc), exc, exc.__traceback__) + _remove_video_editor_output_bundle(output_path) + _video_editor_job_update( + job_id, + status="failed", + phase="failed", + error=str(exc), + message=f"Animatic failed: {exc}", + output_files=[], + acquired_resources=[], + finished_at=time.time(), + _resource_acquired=False, + _worker_active=False, + ) -@api.post("/api/v1/comics/animatic") +@api.post("/api/v1/comics/animatic", status_code=202) def start_comic_animatic(body: dict): """Create a video storyboard from the comic's final, lettered panels.""" panels = body.get("panels") @@ -31778,26 +34716,136 @@ def start_comic_animatic(body: dict): transition = str(body.get("transition") or "none") if transition not in {"none", "crossfade", "fade-black", "wipe-left", "slide-left", "slide-right", "circle-open", "dissolve", "pixelize", "blur", "zoom-in"}: raise HTTPException(status_code=400, detail="Unsupported animatic transition") + workspace = body.get("workspace") if body.get("workspace") is not None else _get_active_workspace() + out_dir = _workspace_dir(workspace) + os.makedirs(out_dir, exist_ok=True) safe_name = re.sub(r"[^A-Za-z0-9_-]+", "_", str(body.get("comic_title") or "comic")).strip("_")[:60] or "comic" output_name = f"{time.strftime('%Y-%m-%d-%Hh%Mm%Ss')}_{safe_name}_animatic.mp4" - output_path = os.path.join(_workspace_dir(), output_name) + output_path = os.path.join(out_dir, output_name) + suffix = 2 + while os.path.exists(output_path): + output_name = f"{time.strftime('%Y-%m-%d-%Hh%Mm%Ss')}_{safe_name}_animatic_{suffix}.mp4" + output_path = os.path.join(out_dir, output_name) + suffix += 1 job_id = f"video-edit-{uuid.uuid4().hex[:12]}" clean = dict(body, width=width, height=height, fps=fps, transition=transition, transition_duration=transition_duration) - _video_editor_jobs[job_id] = { - "job_id": job_id, "status": "queued", "progress": 0, + task_id, root_task_id, parent_task_id = _video_editor_task_identity(body, job_id) + now = time.time() + job = { + "job_id": job_id, + "task_id": task_id, + "root_task_id": root_task_id, + "parent_task_id": parent_task_id, + "workspace": workspace, + "status": "queued", + "phase": "queued", + "progress": 0, + "current": 0, + "total": 100, "message": "Capturing comic panels…", "filename": None, "url": None, - "error": None, "created_at": time.time(), "updated_at": time.time(), + "output_files": [], + "result": None, + "error": None, + "provider": "local", + "model": "FFmpeg", + "server_origin": "local", + "resource_lane": _VIDEO_EDITOR_FFMPEG_LANE.key, + "resource_requirements": [_VIDEO_EDITOR_FFMPEG_LANE.key], + "acquired_resources": [], + "created_at": now, + "queued_at": now, + "updated_at": now, + "project_id": str(body.get("comic_id") or ""), + "_cancel_requested": False, + "_resource_acquired": False, + "_worker_active": True, } - threading.Thread(target=_run_comic_animatic, args=(job_id, clean, output_path), daemon=True, name=f"maestro-{job_id}").start() - return {"job_id": job_id} + try: + snapshot = _register_video_editor_job(job) + except Exception as exc: + raise HTTPException(status_code=500, detail=f"Could not queue comic animatic: {exc}") from exc + worker = threading.Thread( + target=_run_comic_animatic, + args=(job_id, clean, output_path), + daemon=True, + name=f"maestro-{job_id}", + ) + try: + worker.start() + except Exception as exc: + _video_editor_job_update( + job_id, + status="failed", + phase="failed", + error=str(exc), + message=f"Could not start comic animatic worker: {exc}", + acquired_resources=[], + finished_at=time.time(), + _worker_active=False, + ) + raise HTTPException(status_code=500, detail=f"Could not start comic animatic: {exc}") from exc + return _public_video_editor_job(snapshot) @api.get("/api/v1/video-editor/export/{job_id}") def get_video_editor_export(job_id: str): - job = _video_editor_jobs.get(job_id) + job = _video_editor_job_snapshot(job_id) if not job: raise HTTPException(status_code=404, detail="Video editor export job not found") - return job + return _public_video_editor_job(job) + + +@api.post("/api/v1/video-editor/export/{job_id}/cancel") +def cancel_video_editor_export(job_id: str): + """Cancel before FFmpeg, or defer cancellation to its safe boundary.""" + now = time.time() + with _video_editor_jobs_lock: + job = _video_editor_jobs.get(job_id) + if job is None: + raise HTTPException(status_code=404, detail="Video editor export job not found") + if str(job.get("status") or "") in _VIDEO_EDITOR_TERMINAL: + snapshot = copy.deepcopy(job) + should_publish = False + else: + job["_cancel_requested"] = True + job["cancel_requested_at"] = now + if str(job.get("status") or "") in {"queued", "waiting_resource"}: + job.update({ + "status": "cancelled", + "phase": "cancelled", + "message": "Cancelled before FFmpeg started", + "cancel_mode": "immediate", + "safe_boundary": "before_ffmpeg", + "error": None, + "result": None, + "filename": None, + "url": None, + "output_files": [], + "acquired_resources": [], + "progress": 0, + "current": 0, + "finished_at": now, + }) + else: + owns_lane = bool(job.get("acquired_resources")) + job.update({ + "status": "cancelling", + "phase": "cancelling", + "message": ( + "Cancellation deferred to a safe boundary; " + "waiting for FFmpeg to finish…" + if owns_lane else + "FFmpeg safe boundary reached; cleaning up cancellation…" + ), + "cancel_mode": "deferred", + "safe_boundary": "after_current_ffmpeg_render", + }) + job["updated_at"] = now + snapshot = copy.deepcopy(job) + should_publish = True + if should_publish: + _publish_video_editor_job(snapshot) + return _public_video_editor_job(snapshot) @api.post("/api/v1/outputs/{name:path}/move") @@ -32063,6 +35111,677 @@ def serve_upload(filename: str): return serve_file(filename) +# ============================================================================ +# Canonical task registry and compatibility adapters +# ============================================================================ + +def _task_registry(workspace: str | None = None): + from services.task_manager import get_task_registry + + target = _get_active_workspace() if workspace is None else workspace + return get_task_registry(_workspace_dir(target)) + + +def _reset_canonical_task_for_resume(workspace: str, task_id: str) -> None: + """Explicitly reopen a terminal adapter task before its worker publishes.""" + if not task_id: + return + try: + registry = _task_registry(workspace) + existing = registry.get(task_id) + if existing and str(existing.get("status") or "") in { + "failed", "cancelled", "interrupted", + }: + registry.update( + task_id, + status="queued", + phase="queued", + message="Resume requested", + error=None, + completed_at=None, + event_type="task.resume_requested", + force=True, + ) + except Exception as exc: + print(f"[Task registry] Could not reopen {task_id}: {exc}") + + +def _task_legacy_id(record: dict) -> str: + return str(record.get("jobId") or record.get("job_id") or record.get("id") or "") + + +def _task_status(value: object) -> str: + raw = str(value or "queued").lower() + if raw in {"completed", "failed", "cancelled", "interrupted"}: + return raw + if raw in {"created", "queued"}: + return raw + if raw in {"paused", "waiting", "waiting_resource"}: + return "waiting_resource" + return "running" + + +def _task_timestamp(record: dict, *keys: str) -> float | None: + for key in keys: + value = record.get(key) + if isinstance(value, (int, float)) and value > 0: + return float(value) + if isinstance(value, str) and value: + try: + return datetime.fromisoformat(value.replace("Z", "+00:00")).timestamp() + except ValueError: + continue + return None + + +def _canonical_legacy_progress(current: object, total: object, progress: object) -> float: + """Convert legacy counters/percentages to the canonical 0..1 scale.""" + try: + total_value = float(total) + except (TypeError, ValueError): + total_value = 0.0 + if math.isfinite(total_value) and total_value > 0: + try: + current_value = float(current) + except (TypeError, ValueError): + current_value = 0.0 + value = current_value / total_value if math.isfinite(current_value) else 0.0 + else: + try: + percent_value = float(progress) + except (TypeError, ValueError): + percent_value = 0.0 + value = percent_value / 100.0 if math.isfinite(percent_value) else 0.0 + return max(0.0, min(1.0, value)) + + +def _upsert_canonical_task(workspace: str, task_id: str, **fields) -> dict: + registry = _task_registry(workspace) + existing = registry.get(task_id) + if existing is None: + return registry.create(id=task_id, workspace=workspace, **fields) + from services.task_manager import ACTIVE_STATUSES, TERMINAL_STATUSES + existing_status = str(existing.get("status") or "") + incoming_status = str(fields.get("status") or existing_status) + if existing_status in TERMINAL_STATUSES and incoming_status in ACTIVE_STATUSES: + # A lagging compatibility snapshot must never resurrect a task after + # cancellation/completion won. Canonical resume explicitly transitions + # the registry before the next active adapter snapshot arrives. + return existing + mutable = { + key: value for key, value in fields.items() + if key not in {"id", "created_at"} + and existing.get(key) != value + } + if mutable: + return registry.update(task_id, force=True, event_type="adapter.synced", **mutable) + return existing + + +def _publish_generation_task(job: dict) -> dict: + legacy_id = str(job.get("id") or "") + workspace = str(job.get("workspace") or "default") + details = _public_generation_details(job.get("params")) + mode = str(details.get("generation_mode") or "generation") + model_type = str(details.get("model_type") or "") + is_remote = model_type.startswith("minimax:") + task_id = f"task-generation-{legacy_id}" + params = job.get("params") if isinstance(job.get("params"), dict) else {} + owner_id = str(params.get("_director_pipeline_id") or "") + if owner_id.startswith("series:"): + series_job_id = owner_id.split(":", 1)[1] + parent_task_id = f"task-series-render-{series_job_id}" + elif owner_id: + parent_task_id = f"task-director-{owner_id}" + else: + parent_task_id = None + root_task_id = parent_task_id or task_id + status = _task_status(job.get("status")) + current = int(job.get("step") or 0) + total = int(job.get("total_steps") or 0) + error = job.get("error") + if status in {"completed", "failed", "cancelled", "interrupted"}: + acquired_resources = [] + elif "acquired_resources" in job: + acquired_resources = list(job.get("acquired_resources") or []) + else: + # Compatibility for older in-memory tool jobs. Newly-created jobs + # always carry the field, which lets planning remain visibly distinct + # from actual GPU ownership. + acquired_resources = ( + [] if status in {"created", "queued", "waiting_resource"} + else ["remote:https://api.minimax.io" if is_remote else _local_gpu_lane.key] + ) + return _upsert_canonical_task( + workspace, + task_id, + root_id=root_task_id, + parent_id=parent_task_id, + kind=mode, + workflow="generation", + title={ + "image": "Image generation", "video": "Video generation", + "audio": "Audio generation", "music": "Music generation", + "model3d": "3D generation", "avatar": "Video edit", + }.get(mode, "Generation job"), + status=status, + phase=str(job.get("phase") or status), + message=str(job.get("message") or status.replace("_", " ").title()), + current=current, + total=total, + progress=_canonical_legacy_progress(current, total, job.get("progress")), + created_at=_task_timestamp(job, "created_at") or time.time(), + started_at=_task_timestamp(job, "started_at"), + completed_at=_task_timestamp(job, "finished_at"), + provider="minimax" if is_remote else "local", + model=str(details.get("model_name") or model_type), + server_origin="https://api.minimax.io" if is_remote else "local", + resource_requirements=["remote:https://api.minimax.io" if is_remote else "local_gpu:0"], + acquired_resources=acquired_resources, + backend_job_id=legacy_id, + cancelable=( + status in {"created", "queued", "waiting_resource", "running"} + and str(job.get("status") or "").lower() != "cancelling" + ), + recoverable=_is_durable_generation_job(job), + error=({"message": str(error), "retryable": True} if error else None), + result_refs=list(job.get("output_files") or []), + metadata={ + "adapter": "generation", "generation_details": details, + "owner_pipeline_id": owner_id, + }, + ) + + +def _observe_generation_job_state(record: dict) -> None: + """Forward atomic lifecycle changes to the canonical task event stream.""" + job = dict(record) + params = job.get("params") if isinstance(job.get("params"), dict) else {} + if not str(params.get("model_type") or "").strip(): + return + _publish_generation_task(job) + + +set_job_state_observer(_observe_generation_job_state) + + +def _publish_series_task(job: dict, adapter: str) -> dict: + legacy_id = _task_legacy_id(job) + workspace = str(job.get("workspace") or "default") + is_render = adapter == "series-render" + is_known = job.get("bootstrapKnownSeries") is True + status = _task_status(job.get("status")) + provider = job.get("provider") if isinstance(job.get("provider"), dict) else {} + request_body = job.get("request") if isinstance(job.get("request"), dict) else {} + model = str(job.get("model") or request_body.get("writingModel") or provider.get("videoModel") or "") + provider_name = str(request_body.get("writingProvider") or "local") + server = "local" if is_render else str(request_body.get("writingBaseUrl") or "") + lane = ( + resource_scheduler.local_gpu_lane(0) + if is_render + else resource_scheduler.llm_lane( + provider_name, + base_url=server, + device=str(request_body.get("writingDevice") or "cpu"), + ) + ) + task_id = f"task-{adapter}-{legacy_id}" + return _upsert_canonical_task( + workspace, + task_id, + root_id=task_id, + kind="video" if is_render else "llm-planning", + workflow=adapter, + title=("Series Lab · Video generation" if is_render else + "Series Lab · Known-series bible" if is_known else + "Series Lab · Canon" if job.get("jobType") == "canon" else + "Series Lab · Episode planning"), + status=status, + phase=str(job.get("stage") or status), + message=str(job.get("message") or "Series Lab is working…"), + detail=str(job.get("validationError") or ""), + current=int(job.get("current") or 0), + total=int(job.get("total") or 0), + created_at=_task_timestamp(job, "createdAt") or time.time(), + completed_at=_task_timestamp(job, "finishedAt"), + provider="local" if is_render else provider_name, + model=model, + server_origin=server, + resource_requirements=[lane.key], + # Parent workflows span parsing/checkpoint phases as well as provider + # calls. Only observable child operations publish an acquired lease; + # inferring one from the parent's broad "running" state is misleading. + acquired_resources=[], + attempt=max(1, int(job.get("retryCount") or job.get("validationAttempt") or 0) + 1), + project_id=str(job.get("seriesId") or ""), + entity_type="episode", + entity_id=str(job.get("episodeId") or ""), + backend_job_id=legacy_id, + cancelable=status in {"created", "queued", "waiting_resource", "running"}, + resumable=True, + recoverable=True, + error=({"message": str(job.get("error")), "retryable": True} if job.get("error") else None), + result_refs=list(job.get("outputAssetIds") or []), + metadata={"adapter": adapter, "settings": job.get("settings") or {}}, + ) + + +_GENERIC_TASK_CONFIG = { + "story-plan": ("Story Lab planning", "llm-planning", True), + "comic-plan": ("Comic planning", "llm-planning", True), + "video-editor": ("Video editor", "ffmpeg", False), + "model3d": ("3D generation", "model3d", False), + "rig": ("Character rigging", "rig", False), + "minimax-image": ("MiniMax Image-01", "image", False), + "audio-analysis": ("Audio analysis", "audio-analysis", False), + "minimax-music": ("MiniMax Music", "music", False), + "minimax-music-candidate": ("MiniMax Music candidate", "music", False), +} + + +def _publish_generic_legacy_task(record: dict, adapter: str) -> dict | None: + legacy_id = _task_legacy_id(record) + if not legacy_id: + return None + workspace = str(record.get("workspace") or "default") + title, kind, resumable = _GENERIC_TASK_CONFIG.get(adapter, (adapter.replace("-", " ").title(), adapter, False)) + status = _task_status(record.get("status")) + phase = str(record.get("stage") or record.get("phase") or record.get("status") or status) + task_id = str( + record.get("task_id") + or record.get("taskId") + or f"task-{adapter}-{legacy_id}" + ) + root_task_id = str( + record.get("root_task_id") + or record.get("rootTaskId") + or task_id + ) + parent_task_id = str( + record.get("parent_task_id") + or record.get("parentTaskId") + or "" + ) or None + request_body = record.get("request") if isinstance(record.get("request"), dict) else {} + provider = str( + record.get("provider") + or request_body.get("writingProvider") + or "local" + ) + model = str(record.get("model") or record.get("model_id") or record.get("modelId") or "") + explicit_lane = str(record.get("resource_lane") or "").strip() + if explicit_lane: + lane_key = explicit_lane + elif adapter == "model3d": + lane_key = resource_scheduler.local_gpu_lane(0).key + elif adapter == "rig": + lane_key = ( + resource_scheduler.local_gpu_lane(0).key + if str(record.get("engine") or request_body.get("engine") or "").lower() == "unirig" + else resource_scheduler.cpu_lane("rig").key + ) + elif adapter in {"story-plan", "comic-plan"}: + lane_key = resource_scheduler.llm_lane( + provider, + base_url=str( + record.get("writingBaseUrl") + or request_body.get("writingBaseUrl") + or "" + ), + device=str( + record.get("writingDevice") + or request_body.get("writingDevice") + or "cpu" + ), + ).key + elif adapter == "video-editor": + lane_key = resource_scheduler.cpu_lane("ffmpeg").key + else: + lane_key = resource_scheduler.cpu_lane(adapter).key + current = int(record.get("current") or record.get("step") or 0) + total = int(record.get("total") or record.get("total_steps") or 0) + return _upsert_canonical_task( + workspace, task_id, + root_id=root_task_id, parent_id=parent_task_id, + kind=kind, workflow=adapter, title=title, + status=status, phase=phase, + message=str(record.get("message") or phase.replace("_", " ").title()), + current=current, + total=total, + progress=_canonical_legacy_progress(current, total, record.get("progress")), + detail_current=int(record.get("detailCurrent") or 0), + detail_total=int(record.get("detailTotal") or 0), + created_at=_task_timestamp(record, "createdAt", "created_at") or time.time(), + started_at=_task_timestamp(record, "startedAt", "started_at"), + completed_at=_task_timestamp(record, "finishedAt", "finished_at"), + provider=provider, model=model, + server_origin=str(record.get("server_origin") or record.get("writingBaseUrl") or "local"), + resource_requirements=[lane_key], + acquired_resources=list(record.get("acquired_resources") or []), + backend_job_id=legacy_id, + cancelable=(status in {"created", "queued", "waiting_resource", "running"} + and str(record.get("status") or "").lower() != "cancelling" + and adapter != "comic-plan"), + resumable=resumable, recoverable=resumable, + error=({"message": str(record.get("error")), "retryable": resumable} if record.get("error") else None), + result_refs=list(record.get("output_files") or ([record["output"]] if record.get("output") else [])), + metadata={ + "adapter": adapter, + "cancel_mode": record.get("cancel_mode"), + "safe_boundary": record.get("safe_boundary"), + }, + ) + + +def _publish_director_task(pipeline: dict, workspace: str) -> dict | None: + pipeline_id = str(pipeline.get("id") or pipeline.get("pipeline_id") or "") + if not pipeline_id: + return None + progress = pipeline.get("progress") if isinstance(pipeline.get("progress"), dict) else {} + details = pipeline.get("generation_details") if isinstance(pipeline.get("generation_details"), dict) else {} + schedule = pipeline.get("resource_schedule") if isinstance(pipeline.get("resource_schedule"), dict) else {} + schedule_lanes = schedule.get("lanes") if isinstance(schedule.get("lanes"), dict) else {} + resource_requirements = list(dict.fromkeys( + str(lane.get("key") or "") + for lane in schedule_lanes.values() + if isinstance(lane, dict) and str(lane.get("key") or "") + )) + raw_status = str(pipeline.get("status") or "").strip().lower() + status = { + "crashed": "interrupted", + "preview_ready": "completed", + }.get(raw_status, _task_status(raw_status)) + task_id = f"task-director-{pipeline_id}" + completed_at = _task_timestamp( + pipeline, + "finished_at", + "_completed_at", + "completed_at", + ) + if completed_at is None and status in { + "completed", "failed", "cancelled", "interrupted", + }: + completed_at = _task_timestamp(pipeline, "updated_at") + return _upsert_canonical_task( + workspace, task_id, + root_id=task_id, kind="director", workflow="director", + title="Music video" if pipeline.get("pipeline_type") == "music_video" else "Director pipeline", + status=status, phase=str(pipeline.get("phase") or status), + message=str(pipeline.get("error") or progress.get("message") or "Director is working…"), + current=int(progress.get("current") or progress.get("step") or 0), + total=int(progress.get("total") or progress.get("total_steps") or 0), + detail_current=int(progress.get("detail_current") or 0), + detail_total=int(progress.get("detail_total") or 0), + created_at=_task_timestamp(pipeline, "created_at") or time.time(), + started_at=_task_timestamp(pipeline, "phase_started_at", "created_at"), + completed_at=completed_at, + provider=str(details.get("text_provider") or ""), + model=str(details.get("text_model") or details.get("video_model_name") or ""), + server_origin=str(details.get("text_server") or ""), + resource_requirements=resource_requirements, + pipeline_id=pipeline_id, backend_job_id=pipeline_id, + cancelable=status in {"created", "queued", "waiting_resource", "running"}, + resumable=True, recoverable=True, + error=({"message": str(pipeline.get("error")), "retryable": True} if pipeline.get("error") else None), + result_refs=list(pipeline.get("output_files") or []), + metadata={"adapter": "director", "generation_details": details, "resource_schedule": schedule}, + ) + + +def _sync_canonical_tasks(workspace: str) -> None: + for job in list(_jobs.values()): + if str(job.get("workspace") or "default") == workspace: + snapshot = snapshot_job(job) + if str(job.get("id") or "").startswith("audio-analysis-"): + _publish_generic_legacy_task(snapshot, "audio-analysis") + else: + _publish_generation_task(snapshot) + try: + from services.director_pipeline import list_recent_pipelines + director_base = wgp.server_config.get("save_path", "outputs") + for pipeline in list_recent_pipelines(director_base, workspace): + _publish_director_task(pipeline, workspace) + except Exception: + pass + with _series_plan_jobs_lock: + series_plans = [copy.deepcopy(job) for job in _series_plan_jobs.values()] + with _series_render_jobs_lock: + series_renders = [copy.deepcopy(job) for job in _series_render_jobs.values()] + for job in series_plans: + if str(job.get("workspace") or "default") == workspace: + _publish_series_task(job, "series-plan") + for job in series_renders: + if str(job.get("workspace") or "default") == workspace: + _publish_series_task(job, "series-render") + with _story_plan_jobs_lock: + story_jobs = [copy.deepcopy(job) for job in _story_plan_jobs.values()] + with _comic_plan_jobs_lock: + comic_jobs = [copy.deepcopy(job) for job in _comic_plan_jobs.values()] + for job in story_jobs: + if str(job.get("workspace") or "default") == workspace: + _publish_generic_legacy_task(job, "story-plan") + for job in comic_jobs: + if str(job.get("workspace") or "default") == workspace: + _publish_generic_legacy_task(job, "comic-plan") + with _video_editor_jobs_lock: + video_editor_jobs = [copy.deepcopy(job) for job in _video_editor_jobs.values()] + for job in video_editor_jobs: + if str(job.get("workspace") or "default") == workspace: + _publish_generic_legacy_task(job, "video-editor") + with _minimax_image_jobs_lock: + minimax_image_jobs = [copy.deepcopy(job) for job in _minimax_image_jobs.values()] + for job in minimax_image_jobs: + if str(job.get("workspace") or "default") == workspace: + _publish_generic_legacy_task(job, "minimax-image") + with _minimax_music_jobs_lock: + minimax_music_jobs = [copy.deepcopy(job) for job in _minimax_music_jobs.values()] + for job in minimax_music_jobs: + if str(job.get("workspace") or "default") == workspace: + _publish_minimax_music_job(job) + try: + with model3d_service._lock: + model3d_jobs = [copy.deepcopy(job) for job in model3d_service._jobs.values()] + for job in model3d_jobs: + if str(job.get("workspace") or "default") == workspace: + _publish_generic_legacy_task(job, "model3d") + except Exception: + pass + try: + from services import rig_service + with rig_service._lock: + rig_jobs = [copy.deepcopy(job) for job in rig_service._jobs.values()] + for job in rig_jobs: + if str(job.get("workspace") or "default") == workspace: + _publish_generic_legacy_task(job, "rig") + except Exception: + pass + + +@api.get("/api/v1/tasks") +def list_canonical_tasks( + workspace: str | None = None, + status: str = "active", + root_id: str = "", + limit: int = 200, +): + target = _get_active_workspace() if workspace is None else workspace + _sync_canonical_tasks(target) + from services.task_manager import ACTIVE_STATUSES, ALL_STATUSES + statuses = set(ACTIVE_STATUSES) if status == "active" else ( + set(ALL_STATUSES) if status in {"", "all"} else {item.strip() for item in status.split(",")} + ) + return {"workspace": target, "tasks": _task_registry(target).list( + statuses=statuses, root_id=root_id, limit=limit, + )} + + +@api.post("/api/v1/tasks/upsert") +def upsert_client_task(body: dict): + raw = body.get("task") if isinstance(body.get("task"), dict) else body + workspace = raw.get("workspace") if "workspace" in raw else _get_active_workspace() + _workspace_dir(workspace) + client_id = re.sub(r"[^A-Za-z0-9_-]+", "-", str(raw.get("id") or uuid.uuid4().hex))[:160] + task_id = client_id if client_id.startswith("task-") else f"task-client-{client_id}" + status = _task_status(raw.get("status")) + task = _upsert_canonical_task( + workspace, task_id, root_id=task_id, + kind=str(raw.get("kind") or "foreground"), workflow="frontend", + title=str(raw.get("title") or "Maestro activity"), status=status, + phase=str(raw.get("phase") or status), + message=str(raw.get("error") or raw.get("message") or "Working…"), + detail=str(raw.get("detailMessage") or ""), + current=int(raw.get("current") or 0), total=int(raw.get("total") or 0), + detail_current=int(raw.get("detailCurrent") or 0), detail_total=int(raw.get("detailTotal") or 0), + created_at=(float(raw.get("startedAt")) / 1000 if float(raw.get("startedAt") or 0) > 1e12 else + float(raw.get("startedAt") or time.time())), + cancelable=False, + error=({"message": str(raw.get("error")), "retryable": False} if raw.get("error") else None), + metadata={"adapter": "frontend", "client_activity_id": client_id, + "generation_details": raw.get("generationDetails") or {}, + "token_usage": raw.get("tokenUsage") or {}}, + ) + return task + + +def _task_event_cursor(after: object, last_event_id: object) -> int: + """Resolve an SSE cursor from query state and the reconnect header.""" + values = [] + for value in (after, last_event_id): + try: + values.append(max(0, int(value or 0))) + except (TypeError, ValueError, OverflowError): + continue + return max(values, default=0) + + +@api.get("/api/v1/resources") +def list_resource_lanes(): + """Expose the coordinator's real active and waiting leases for the UI.""" + return {"lanes": resource_scheduler.coordinator.snapshot()} + + +@api.get("/api/v1/tasks/events") +async def stream_canonical_task_events( + request: Request, + workspace: str | None = None, + after: int = 0, +): + target = _get_active_workspace() if workspace is None else workspace + registry = _task_registry(target) + + async def event_stream(): + cursor = _task_event_cursor(after, request.headers.get("last-event-id")) + yield "retry: 2000\n\n" + while True: + events = await asyncio.to_thread(registry.wait_for_events, cursor, 15.0) + if not events: + yield ": keepalive\n\n" + continue + for event in events: + cursor = max(cursor, int(event["event_id"])) + yield f"id: {cursor}\nevent: task\ndata: {json.dumps(event, ensure_ascii=False)}\n\n" + + return StreamingResponse(event_stream(), media_type="text/event-stream", headers={ + "Cache-Control": "no-cache", "X-Accel-Buffering": "no", + }) + + +@api.get("/api/v1/tasks/{task_id}/events") +def get_canonical_task_events(task_id: str, workspace: str | None = None, after: int = 0): + target = _get_active_workspace() if workspace is None else workspace + return {"events": _task_registry(target).events(task_id, after=after)} + + +@api.get("/api/v1/tasks/{task_id}") +def get_canonical_task(task_id: str, workspace: str | None = None): + target = _get_active_workspace() if workspace is None else workspace + _sync_canonical_tasks(target) + task = _task_registry(target).get(task_id) + if not task: + raise HTTPException(status_code=404, detail="Task not found") + children = [item for item in _task_registry(target).list(root_id=task["root_id"], limit=500) + if item.get("parent_id") == task_id] + return {"task": task, "children": children} + + +def _control_canonical_task(task: dict, action: str): + adapter = str((task.get("metadata") or {}).get("adapter") or "") + legacy_id = str(task.get("backend_job_id") or task.get("pipeline_id") or "") + if action == "cancel": + if adapter == "generation": return cancel_job(legacy_id) + if adapter == "director": return director_pipeline_stop(legacy_id) + if adapter == "series-plan": return cancel_series_episode_plan(legacy_id) + if adapter == "series-render": return cancel_series_render_job(legacy_id) + if adapter == "story-plan": return cancel_story_lab_generation(legacy_id) + if adapter == "video-editor": return cancel_video_editor_export(legacy_id) + if adapter == "model3d": return cancel_model3d_job(legacy_id) + if adapter == "rig": return cancel_rig_job(legacy_id) + if adapter == "minimax-image": return cancel_comic_minimax_job(legacy_id) + if adapter == "audio-analysis": return cancel_audio_analysis_job(legacy_id) + if adapter == "minimax-music": return cancel_story_music_candidates_job(legacy_id) + if adapter == "minimax-music-candidate": + root_backend_id = str(task.get("root_id") or "").removeprefix( + "task-minimax-music-" + ) + return cancel_story_music_candidates_job(root_backend_id) + if action in {"retry", "resume"}: + if adapter == "director": return director_pipeline_resume(legacy_id) + if adapter == "series-plan": return resume_series_episode_plan(legacy_id) + if adapter == "series-render": return resume_series_render_job(legacy_id) + if adapter == "story-plan": return resume_story_lab_generation(legacy_id) + if adapter == "comic-plan": return resume_director_comic_plan(legacy_id) + raise HTTPException(status_code=409, detail=f"Task does not support {action}") + + +@api.post("/api/v1/tasks/{task_id}/cancel") +def cancel_canonical_task(task_id: str, workspace: str | None = None): + target = _get_active_workspace() if workspace is None else workspace + task = _task_registry(target).get(task_id) + if not task: + raise HTTPException(status_code=404, detail="Task not found") + result = _control_canonical_task(task, "cancel") + _sync_canonical_tasks(target) + return {"task": _task_registry(target).get(task_id), "result": result} + + +@api.post("/api/v1/tasks/{task_id}/retry") +def retry_canonical_task(task_id: str, workspace: str | None = None): + target = _get_active_workspace() if workspace is None else workspace + task = _task_registry(target).get(task_id) + if not task: + raise HTTPException(status_code=404, detail="Task not found") + result = _control_canonical_task(task, "retry") + if str(task.get("status") or "") in {"failed", "interrupted", "cancelled"}: + _task_registry(target).update( + task_id, + status="queued", + phase="queued", + message="Resume requested", + error=None, + completed_at=None, + event_type="task.resume_requested", + ) + _sync_canonical_tasks(target) + return {"task": _task_registry(target).get(task_id), "result": result} + + +@api.post("/api/v1/tasks/{task_id}/resume") +def resume_canonical_task(task_id: str, workspace: str | None = None): + return retry_canonical_task(task_id, workspace) + + +@api.delete("/api/v1/tasks/{task_id}") +def dismiss_canonical_task(task_id: str, workspace: str | None = None): + target = _get_active_workspace() if workspace is None else workspace + try: + deleted = _task_registry(target).delete(task_id) + except ValueError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + if not deleted: + raise HTTPException(status_code=404, detail="Task not found") + return {"deleted": True, "task_id": task_id} + + # ============================================================================ # Mount Gradio classic UI at /classic # ============================================================================ diff --git a/app/routers/__init__.py b/app/routers/__init__.py new file mode 100644 index 00000000..c27bd9f8 --- /dev/null +++ b/app/routers/__init__.py @@ -0,0 +1 @@ +"""FastAPI routers kept separate from the legacy launch module.""" diff --git a/app/routers/series_assembly.py b/app/routers/series_assembly.py new file mode 100644 index 00000000..d409b5f2 --- /dev/null +++ b/app/routers/series_assembly.py @@ -0,0 +1,320 @@ +"""Ordered Series episode assembly API. + +The launcher supplies its workspace and media primitives so this module can +own the job lifecycle without importing the large ``launch`` module or WanGP. +""" + +from __future__ import annotations + +import copy +import json +import os +import threading +import time +import uuid +from collections.abc import Callable, Iterable +from typing import Any + +from fastapi import APIRouter, HTTPException + +from services.series_assembly import episode_assembly_plan +from services.series_jobs import SeriesJobStore + + +PUBLIC_JOB_KEYS = ( + "jobId", + "workspace", + "seriesId", + "episodeId", + "status", + "stage", + "current", + "total", + "message", + "error", + "assetId", + "filename", + "createdAt", + "updatedAt", + "finishedAt", +) + + +def _public_job(job: dict[str, Any]) -> dict[str, Any]: + return {key: job.get(key) for key in PUBLIC_JOB_KEYS} + + +def create_series_assembly_router( + *, + resolve_workspace: Callable[[Any], str], + workspace_dir: Callable[[str], str], + list_workspaces: Callable[[], Iterable[dict[str, Any]]], + library_lock: threading.RLock, + read_library: Callable[[str], dict[str, Any]], + write_library: Callable[[str, dict[str, Any]], dict[str, Any]], + find_series: Callable[[dict[str, Any], str], dict[str, Any]], + asset_local_path: Callable[[str, dict[str, Any]], str], + available_filename: Callable[[str, str], str], + concatenate_clips: Callable[..., bool], + iso_now: Callable[[], str], +) -> APIRouter: + """Build the router while keeping launcher-specific dependencies explicit.""" + + router = APIRouter() + jobs: dict[str, dict[str, Any]] = {} + active_job_ids: set[str] = set() + jobs_lock = threading.RLock() + + def store(workspace: str) -> SeriesJobStore: + return SeriesJobStore(workspace_dir(workspace), "assembly") + + def load(job_id: str) -> dict[str, Any] | None: + with jobs_lock: + cached = jobs.get(job_id) + if cached: + return copy.deepcopy(cached) + for workspace in list_workspaces(): + name = workspace.get("name") if isinstance(workspace, dict) else None + if not isinstance(name, str) or not name: + continue + try: + saved = store(name).load(job_id) + except (OSError, ValueError, json.JSONDecodeError): + continue + if saved: + with jobs_lock: + jobs[job_id] = saved + return copy.deepcopy(saved) + return None + + def update(job_id: str, **patch: Any) -> dict[str, Any] | None: + with jobs_lock: + job = jobs.get(job_id) + if not job: + return None + job.update(copy.deepcopy(patch)) + job["updatedAt"] = time.time() + snapshot = copy.deepcopy(job) + store(str(job["workspace"])).save(snapshot) + return snapshot + + def persisted_active_job(workspace: str, series_id: str, episode_id: str) -> dict[str, Any] | None: + active_statuses = {"queued", "running"} + with jobs_lock: + cached = list(jobs.values()) + try: + saved = store(workspace).list() + except (OSError, ValueError, json.JSONDecodeError): + saved = [] + by_id = { + str(job.get("jobId")): job + for job in [*saved, *cached] + if isinstance(job, dict) and job.get("jobId") + } + return next(( + job + for job in by_id.values() + if job.get("workspace") == workspace + and job.get("seriesId") == series_id + and job.get("episodeId") == episode_id + and job.get("status") in active_statuses + ), None) + + def mark_interrupted(active: dict[str, Any]) -> None: + """Release a queued/running checkpoint left by a previous process.""" + + job_id = str(active.get("jobId") or "") + with jobs_lock: + if job_id in active_job_ids: + return + stale = copy.deepcopy(active) + stale.update({ + "status": "failed", + "stage": "failed", + "message": "The previous assembly process was interrupted; it can be started again.", + "error": "Assembly process interrupted before completion", + "updatedAt": time.time(), + "finishedAt": time.time(), + }) + store(str(stale["workspace"])).save(stale) + + def run(job_id: str) -> None: + job = update( + job_id, + status="running", + stage="joining", + message="Joining approved clips in shot order…", + ) + if not job: + with jobs_lock: + active_job_ids.discard(job_id) + return + output_path = "" + try: + clip_paths = [ + asset_local_path(str(job["workspace"]), { + "id": item.get("assetId"), + "uri": item.get("uri"), + }) + for item in job.get("clips", []) + ] + output_directory = workspace_dir(str(job["workspace"])) + timestamp = time.strftime("%Y-%m-%d-%Hh%Mm%Ss") + output_path = available_filename( + output_directory, + f"{timestamp}_{job['episodeId']}_series_assembly.mp4", + ) + if not concatenate_clips(clip_paths, output_path): + raise RuntimeError("ffmpeg could not join the approved Series clips") + if not os.path.isfile(output_path): + raise RuntimeError("Series assembly finished without an output file") + + asset_id = f"asset_assembly_{uuid.uuid4().hex}" + completed_at = iso_now() + with library_lock: + library = read_library(str(job["workspace"])) + series = copy.deepcopy(find_series(library, str(job["seriesId"]))) + episode = series.get("episodesById", {}).get(str(job["episodeId"])) + if not isinstance(episode, dict): + raise ValueError("Series episode no longer exists") + series.setdefault("assets", {})[asset_id] = { + "id": asset_id, + "workspaceId": job["workspace"], + "kind": "video", + "uri": f"outputs/{os.path.basename(output_path)}", + "ownerType": "episode", + "ownerId": job["episodeId"], + "isDerivedThumbnail": False, + "metadata": { + "seriesId": job["seriesId"], + "episodeId": job["episodeId"], + "assemblyJobId": job_id, + "clipCount": len(clip_paths), + "orderedClipAssetIds": [ + item.get("assetId") for item in job.get("clips", []) + ], + "createdAt": completed_at, + }, + } + assembly_ids = [ + str(value) + for value in episode.get("assemblyAssetIds", []) + if isinstance(value, str) and value + ] + assembly_ids.append(asset_id) + episode["assemblyAssetIds"] = list(dict.fromkeys(assembly_ids)) + episode["latestAssemblyAssetId"] = asset_id + episode["updatedAt"] = completed_at + series["episodesById"][episode["id"]] = episode + series["revision"] = int(series.get("revision") or 1) + 1 + series["updatedAt"] = completed_at + library["seriesById"][series["id"]] = series + write_library(str(job["workspace"]), library) + update( + job_id, + status="completed", + stage="completed", + current=len(clip_paths), + assetId=asset_id, + filename=os.path.basename(output_path), + finishedAt=time.time(), + message=f"Joined {len(clip_paths)} approved clips in episode order.", + ) + except Exception as exc: + if output_path and os.path.isfile(output_path): + try: + os.remove(output_path) + except OSError: + pass + update( + job_id, + status="failed", + stage="failed", + error=str(exc), + finishedAt=time.time(), + message="Series episode assembly failed; approved clips were not changed.", + ) + finally: + with jobs_lock: + active_job_ids.discard(job_id) + + @router.post("/api/v1/series/{series_id}/episodes/{episode_id}/assembly/start") + def start(series_id: str, episode_id: str, body: dict[str, Any]): + workspace = resolve_workspace(body.get("workspace")) + with library_lock: + library = read_library(workspace) + series = copy.deepcopy(find_series(library, series_id)) + episode = series.get("episodesById", {}).get(episode_id) + if not isinstance(episode, dict): + raise HTTPException(status_code=404, detail="Series episode not found") + try: + clips = episode_assembly_plan(series, episode) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + active = persisted_active_job(workspace, series_id, episode_id) + if active: + with jobs_lock: + active_here = str(active.get("jobId")) in active_job_ids + if active_here: + raise HTTPException( + status_code=409, + detail=f"Episode assembly {active['jobId']} is already running", + ) + mark_interrupted(active) + + with jobs_lock: + # Re-check under the mutation lock so simultaneous requests cannot + # enqueue two assemblers for the same episode. + active_here = next(( + value + for value in jobs.values() + if value.get("workspace") == workspace + and value.get("seriesId") == series_id + and value.get("episodeId") == episode_id + and value.get("status") in {"queued", "running"} + ), None) + if active_here: + raise HTTPException( + status_code=409, + detail=f"Episode assembly {active_here['jobId']} is already running", + ) + job_id = f"series-assembly-{uuid.uuid4().hex[:12]}" + now = time.time() + job = { + "jobId": job_id, + "kind": "assembly", + "workspace": workspace, + "seriesId": series_id, + "episodeId": episode_id, + "status": "queued", + "stage": "queued", + "current": 0, + "total": len(clips), + "clips": clips, + "message": "Episode assembly queued.", + "error": None, + "assetId": None, + "filename": None, + "createdAt": now, + "updatedAt": now, + } + jobs[job_id] = job + active_job_ids.add(job_id) + store(workspace).save(job) + threading.Thread( + target=run, + args=(job_id,), + name=f"series-assembly-{job_id[-6:]}", + daemon=True, + ).start() + return _public_job(job) + + @router.get("/api/v1/series/assembly/jobs/{job_id}") + def status(job_id: str): + job = load(job_id) + if not job: + raise HTTPException(status_code=404, detail="Series assembly job not found") + return _public_job(job) + + return router diff --git a/app/routers/style_library.py b/app/routers/style_library.py new file mode 100644 index 00000000..c8f36569 --- /dev/null +++ b/app/routers/style_library.py @@ -0,0 +1,78 @@ +"""HTTP surface for the persistent style library.""" + +from __future__ import annotations + +from fastapi import APIRouter, HTTPException, Query +from fastapi.responses import FileResponse + +from services.style_library import StyleLibrary + + +def create_style_library_router(library: StyleLibrary) -> APIRouter: + router = APIRouter(prefix="/api/v1/style-library", tags=["style-library"]) + + @router.get("/sources") + def list_sources(): + return {"sources": library.source_status()} + + @router.get("/styles") + def list_styles( + model_family: str = "minimax", + source_id: str = "", + collection: str = "", + group: str = "", + q: str = "", + sort: str = "source_order", + offset: int = 0, + limit: int = 60, + ): + return library.list_styles( + model_family=model_family, + source_id=source_id, + collection=collection, + group=group, + query=q, + sort=sort, + offset=offset, + limit=limit, + ) + + @router.post("/imports/minimax-h3-1k") + def import_minimax_h3_1k(): + return library.start_minimax_import() + + @router.get("/imports/{job_id}") + def import_status(job_id: str): + job = library.import_status(job_id) + if not job: + raise HTTPException(status_code=404, detail="Style import job not found") + return job + + @router.get("/styles/{style_id}/preview") + def style_preview(style_id: str): + try: + path = library.preview_path(style_id) + except (KeyError, FileNotFoundError): + raise HTTPException(status_code=404, detail="Style preview not found") + except RuntimeError as exc: + raise HTTPException(status_code=500, detail=str(exc)) from exc + return FileResponse(path, media_type="image/jpeg", headers={"Cache-Control": "public, max-age=31536000, immutable"}) + + @router.get("/styles/{style_id}/video") + def style_video(style_id: str): + try: + path = library.video_path(style_id) + except (KeyError, FileNotFoundError): + raise HTTPException(status_code=404, detail="Style video not found") + return FileResponse(path, media_type="video/mp4") + + @router.delete("/styles/{style_id}") + def delete_style(style_id: str, confirm: bool = Query(False)): + if not confirm: + raise HTTPException(status_code=400, detail="Deletion requires confirm=true") + try: + return library.delete_style(style_id) + except KeyError: + raise HTTPException(status_code=404, detail="Style not found") + + return router diff --git a/app/services/audio_analysis.py b/app/services/audio_analysis.py index cdf02806..635e2aaf 100644 --- a/app/services/audio_analysis.py +++ b/app/services/audio_analysis.py @@ -789,12 +789,33 @@ def analyze( result.warnings.append( "Speaker identification is unavailable; continuing without singer labels." ) - unload_diarizer() # Free VRAM immediately - unload_whisper() # Free Whisper VRAM before LLM loads except ImportError as e: print(f"[AudioAnalysis] Transcription skipped (faster-whisper not installed): {e}") except Exception as e: print(f"[AudioAnalysis] Transcription failed, continuing without lyrics: {e}") + finally: + # Whisper and pyannote are both optional, lazy-loaded GPU models. + # Always drop them before the next pipeline phase, including when + # transcription degrades after an error or the progress callback + # aborts the worker because the job was cancelled. Keep the two + # cleanups independent so a secondary cleanup failure cannot keep + # the other model resident or mask the original analysis outcome. + try: + unload_diarizer() + except Exception as cleanup_error: + logger.warning( + "Could not fully unload the diarization model: %s", + cleanup_error, + exc_info=True, + ) + try: + unload_whisper() + except Exception as cleanup_error: + logger.warning( + "Could not fully unload the transcription model: %s", + cleanup_error, + exc_info=True, + ) _set_progress("finalizing", "Finalizing") print(f"[AudioAnalysis] Done: {bpm:.1f} BPM, {len(beats)} beats, {len(sections)} sections") diff --git a/app/services/director/h3_dialogue.py b/app/services/director/h3_dialogue.py index 5fd8cc85..eb18b6ca 100644 --- a/app/services/director/h3_dialogue.py +++ b/app/services/director/h3_dialogue.py @@ -50,6 +50,21 @@ class H3DialogueContractError(ValueError): ), "nonverbal room tone", ), + ( + re.compile( + r"clear foreground voices with precise lip sync and " + r"natural delivery(?:[.;]|$)", + re.IGNORECASE, + ), + "", + ), + ( + re.compile( + r"vocal delivery\s*:[^.;]*(?:[.;]|$)", + re.IGNORECASE, + ), + "", + ), ) _H3_BASE_FIELDS = ( @@ -181,10 +196,12 @@ def _dialogue_payload(value: Any) -> tuple[str, str]: return language, text -def h3_dialogue_tag(spoken_text: Any) -> str: +def h3_dialogue_tag(spoken_text: Any, forced_language: str = "") -> str: """Build exactly one canonical H3 dialogue block.""" language, words = _dialogue_payload(spoken_text) + if forced_language: + language = forced_language return f"[{language}] {words}" @@ -234,12 +251,13 @@ def _sanitize_scripted_ambience(prompt: str) -> str: def sanitize(segment: str) -> str: for pattern, replacement in _H3_SPEECHLIKE_AMBIENCE_REPLACEMENTS: segment = pattern.sub(replacement, segment) - return re.sub( + segment = re.sub( r"\bnonverbal room tone(?:\s*(?:,|and)\s*nonverbal room tone)+\b", "nonverbal room tone", segment, flags=re.IGNORECASE, ) + return re.sub(r"\s*;\s*(?=;|$)", "", segment).strip(" ;") return _rewrite_outside_dialogue(prompt, sanitize) @@ -792,6 +810,7 @@ def _compile_official_dialogue( existing_blocks: Sequence[str], *, has_driving_audio: bool = False, + forced_language: str = "", ) -> tuple[str, str]: """Place exact tagged lines and stable speaker IDs in the visual field.""" @@ -800,7 +819,8 @@ def _compile_official_dialogue( spoken = normalize_h3_text(_field(beat, "spoken_text", "")) if not _normalized_space(spoken): continue - _, words = _dialogue_payload(spoken) + authored_language, words = _dialogue_payload(spoken) + carries_language = bool(re.search(r"(?:\s*)?\[[^\]]+\]", spoken, re.I)) speaker_key = _normalized_space(_field(beat, "speaker_id", "")) entry = _speaker_registry_entry(registry, speaker_key) if entry: @@ -810,7 +830,8 @@ def _compile_official_dialogue( stable_id = f"(S{len(valid_beats) + 1})" valid_beats.append({ "words": normalize_h3_text(words), - "tag": h3_dialogue_tag(spoken), + "tag": "", + "authored_language": authored_language if carries_language else "", "stable_id": stable_id, "speaker_name": speaker_name, "delivery": _normalized_space(_field(beat, "delivery", "")), @@ -824,6 +845,26 @@ def _compile_official_dialogue( "MiniMax H3 dialogue tags are unbalanced and cannot be repaired safely." ) + # An explicit language in the reviewed/source prompt is authoritative. + # Previously, rebuilding from plain dialogue_beats silently reverted such + # lines to English when no project-wide spoken-language contract existed. + from .spoken_language import infer_h3_spoken_language + + source_languages = [ + _dialogue_payload(body[start:end])[0] + for start, end in spans + ] + for index, beat in enumerate(valid_beats): + language = ( + forced_language + or (source_languages[index] if index < len(source_languages) else "") + or beat["authored_language"] + or infer_h3_spoken_language(beat["words"]) + ) + beat["tag"] = h3_dialogue_tag( + f"[{language}] {beat['words']}", + ) + if valid_beats: replacements = [ valid_beats[index]["tag"] if index < len(valid_beats) else "" @@ -869,9 +910,9 @@ def _compile_official_dialogue( for beat in valid_beats ) else: - canonical_blocks = [h3_dialogue_tag(block) for block in existing_blocks] + canonical_blocks = [h3_dialogue_tag(block, forced_language) for block in existing_blocks] if spans: - canonical_blocks = [h3_dialogue_tag(body[start:end]) for start, end in spans] + canonical_blocks = [h3_dialogue_tag(body[start:end], forced_language) for start, end in spans] body = _replace_spans(body, spans, canonical_blocks) elif canonical_blocks: additions = " ".join( @@ -1239,6 +1280,11 @@ def compile_h3_official_prompt( closing_blocking=closing_blocking, audio_plan=audio_plan, ) + from .spoken_language import h3_language_tag + forced_language = h3_language_tag( + audio_plan.get("spoken_language") + if isinstance(audio_plan, Mapping) else "" + ) body, vocal_contract = _compile_official_dialogue( body, subjects or [], @@ -1246,7 +1292,18 @@ def compile_h3_official_prompt( registry, existing_blocks, has_driving_audio=has_driving_audio, + forced_language=forced_language, ) + # H3 has a native minimum duration, so a short authored line may leave + # several seconds that the audiovisual model otherwise fills with invented + # speech. Bind every tagged line to a concrete interval and assign the + # remaining time to closed-mouth action and production sound. The job + # boundary reapplies this after frame-lattice rounding with the exact + # physical duration. + if duration_seconds: + from ..minimax_h3_duration import inject_h3_vocal_timeline + + body, _ = inject_h3_vocal_timeline(body, duration_seconds) body = re.sub(r"^\s*\[Shot\s+1\]\s*", "", body, flags=re.IGNORECASE) body = f"[Shot 1] {body}".strip() diff --git a/app/services/director/minimax_h3_prompting.py b/app/services/director/minimax_h3_prompting.py index 91efd665..f627bff4 100644 --- a/app/services/director/minimax_h3_prompting.py +++ b/app/services/director/minimax_h3_prompting.py @@ -12,6 +12,8 @@ import re from typing import Any +from .spoken_language import infer_h3_spoken_language + FIRST_FRAME_REFERENCE = ( "For the target video, at 0.00 seconds into the target video, " @@ -37,10 +39,13 @@ def normalize_reference_mode(value: str | None) -> str: mode = str(value or "first_frame").strip().lower() mode = { "fl2va": "first_frame", + "text": "direct", + "text_to_video": "direct", + "t2v": "direct", "ref2va": "references", "reference": "references", }.get(mode, mode) - return mode if mode in {"first_frame", "references"} else "first_frame" + return mode if mode in {"direct", "first_frame", "references"} else "first_frame" def is_structured_h3_prompt(prompt: str, reference_mode: str | None = None) -> bool: @@ -49,6 +54,8 @@ def is_structured_h3_prompt(prompt: str, reference_mode: str | None = None) -> b fields = _REFERENCE_FIELDS if mode == "references" else _FIRST_FRAME_FIELDS if mode == "first_frame" and FIRST_FRAME_REFERENCE not in text: return False + if mode == "direct" and FIRST_FRAME_REFERENCE in text: + return False return all(field in text for field in fields) @@ -97,23 +104,6 @@ def _strip_legacy_contracts(prompt: str) -> str: return text -def _language_name(text: str) -> str: - if re.search(r"[\u3040-\u30ff\u3400-\u9fff]", text): - return "Japanese" - if re.search(r"[\u0400-\u04ff]", text): - return "Russian" - if re.search(r"[\u0600-\u06ff]", text): - return "Arabic" - if re.search(r"[\uac00-\ud7af]", text): - return "Korean" - lower = f" {_clean(text).casefold()} " - if re.search(r"[¿¡ñáéíóúü]", lower) or any( - token in lower for token in (" que ", " por ", " para ", " una ", " el ", " la ") - ): - return "Spanish" - return "English" - - def _subject_definitions(plan: dict) -> tuple[list[str], list[str]]: definitions: list[str] = [] subject_ids: list[str] = [] @@ -162,6 +152,8 @@ def _camera_sentence(plan: dict) -> str: def _dialogue_sentences(plan: dict, subject_ids: list[str]) -> list[str]: sentences: list[str] = [] + audio = plan.get("audio_plan") if isinstance(plan.get("audio_plan"), dict) else {} + default_delivery = _clean(audio.get("vocal_style")) for index, raw in enumerate(_items(plan.get("dialogue_beats"))): if not isinstance(raw, dict): continue @@ -170,11 +162,11 @@ def _dialogue_sentences(plan: dict, subject_ids: list[str]) -> list[str]: continue speaker = _clean(raw.get("speaker_name") or raw.get("speaker_id")) speaker_id = subject_ids[min(index, len(subject_ids) - 1)] if subject_ids else "S1" - delivery = _clean(raw.get("delivery")) + delivery = _clean(raw.get("delivery")) or default_delivery cue = f"({speaker_id})" if speaker: cue += f" {speaker}" - cue += f" says [{_language_name(spoken)}] {spoken}" + cue += f" says [{infer_h3_spoken_language(spoken)}] {spoken}" if delivery: cue += f" with {delivery} delivery" sentences.append(cue + ".") @@ -233,11 +225,10 @@ def _sound_fields(plan: dict, audio_direction: str) -> tuple[str, str]: soundscape_parts.append(ambience) if effects: soundscape_parts.append("Synchronized effects: " + ", ".join(effects)) - if audio.get("lip_sync_critical"): - soundscape_parts.append("Clear foreground voices with precise lip sync and natural delivery") - vocal_style = _clean(audio.get("vocal_style")) - if vocal_style: - soundscape_parts.append(f"Vocal delivery: {vocal_style}") + # Dialogue, language, delivery and lip sync belong beside the exact + # block in integrated_multimodal_description. Keeping a generic voice cue + # in the full-clip soundscape can make H3 extend a short line with invented + # speech before or after the authored words. direction = _clean(audio_direction) if direction: soundscape_parts.append(direction) @@ -248,7 +239,7 @@ def _sound_fields(plan: dict, audio_direction: str) -> tuple[str, str]: if mode in {"music_driven", "audio_driven"}: music = "The selected song segment remains the timing and editorial anchor; do not invent a competing melody." else: - music = "None unless explicitly motivated by the scene." + music = "N/A" return "; ".join(soundscape_parts).rstrip("."), music @@ -292,15 +283,25 @@ def format_minimax_h3_prompt( f"non_diegetic_music: {music}", )) - return "\n".join(( - FIRST_FRAME_REFERENCE, - ( - "integrated_multimodal_description: The referenced picture is the exact opening frame. " - "Its visible composition, identity, wardrobe, environment, colors and proportions are " - f"authoritative and must not be stretched or redesigned. {description}" - ), + integrated = ( + description + if mode == "direct" + else ( + "The referenced picture is the exact opening frame. Its visible composition, " + "identity, wardrobe, environment, colors and proportions are authoritative and " + f"must not be stretched or redesigned. {description}" + ) + ) + fields = ( + f"integrated_multimodal_description: {integrated}", f"overall_soundscape: {soundscape}.", f"non_diegetic_music: {music}", + ) + if mode == "direct": + return "\n".join(fields) + return "\n".join(( + FIRST_FRAME_REFERENCE, + *fields, )) diff --git a/app/services/director/planners/music_video.py b/app/services/director/planners/music_video.py index e1560c23..6c67e866 100644 --- a/app/services/director/planners/music_video.py +++ b/app/services/director/planners/music_video.py @@ -995,6 +995,9 @@ def _plan_with_llm( - Instrumental = environment, textures. Bridge = contrasting, unexpected. - Use controlled recurrence. Revisit the same chorus set, wardrobe and visual motif. - Vary framing and camera coverage inside recurring sets; do not invent a new world for every lyric. +- Unless the Scene Concept explicitly requests a single-location video, distribute the clips across at least three visually distinct settings. A setting or prop mentioned in the global brief is an available anchor, not a requirement for every clip. +- Never repeat the same location-plus-action combination (for example, sitting at a computer in a cafe) across most clips. Keep visual style global, but vary situation, action, scale, time of day, and environment across verses and bridge. +- Treat visual-style text as medium, palette, lighting and design language only. Do not turn an incidental action, prop or location embedded in style text into a repeated scene template. - Performer visibility and lip-sync follow the editable treatment and each clip's planned role. EDITABLE MUSIC-VIDEO TREATMENT: diff --git a/app/services/director/planners/short_film.py b/app/services/director/planners/short_film.py index 8ce6d665..29d1ad88 100644 --- a/app/services/director/planners/short_film.py +++ b/app/services/director/planners/short_film.py @@ -5350,7 +5350,7 @@ def _plan_story_h3_native( - Each screenplay event and each spoken line appears in exactly one shot. Do not duplicate dialogue across adjacent shots. Preserve scripted dialogue verbatim. - CONVERSATION PACKING IS REQUIRED: a change of speaker is not by itself a reason to start another array item. Within the same uninterrupted location and story beat, prefer one native clip ({preferred_duration_text}) containing 2-4 alternating dialogue turns when their combined total is no more than {maximum_dialogue_words} words. Keep a brief reaction such as "What?", a gasp, or a one-line reply in the surrounding exchange instead of wasting a separate minimum-length clip. - INTERNAL CAMERA EDITING IS SUPPORTED: inside one bounded H3 clip, the camera may begin on an ensemble frame, cut or reframe to each current speaker before their tagged line, hold their unobstructed face and mouth through the complete line, capture reactions, and finish on a new composition. Describe that chronological coverage in camera_plan and action_beats. Prefer the lower end of the requested shot-count range for a continuous dialogue scene. -- DIALOGUE MUST NOT LIVE ONLY IN dialogue_beats. Every dialogue_beats[].spoken_text must also appear exactly once in the same shot's video_prompt as [English] Exact words, with the speaker ID/name, delivery, and physical cue outside the tag. If dialogue_beats is empty, explicitly state that no one speaks, mouths remain closed, and no muttering, gibberish, or speech-like vocalization occurs. +- DIALOGUE MUST NOT LIVE ONLY IN dialogue_beats. Every dialogue_beats[].spoken_text must also appear exactly once in the same shot's video_prompt as [Language] Exact words, using the broad language named by any SPOKEN LANGUAGE CONTRACT in the project source (for example, Español de España uses [Spanish]). Keep accent/locale instructions outside the tag. If no contract exists, infer the language from the exact words. If dialogue_beats is empty, explicitly state that no one speaks, mouths remain closed, and no muttering, gibberish, or speech-like vocalization occurs. - SPEAKER VISIBILITY IS REQUIRED: every person who delivers a line must have a complete subjects_on_screen entry and remain visibly framed with an unobstructed face and mouth for the full line. Reframe to the current speaker before speech; reaction framing may follow only after the spoken line is complete. - CAST LIST CONSISTENCY IS REQUIRED: every person mentioned in spatial_setup, action_beats, dialogue_beats, ending_beat, closing_blocking, or video_prompt must appear in subjects_on_screen. Do not mention a bystander in blocking while omitting that person from the visible cast. - A shot may follow another in the finished edit, but its prompt must describe its own opening state instead of saying "continue", "as before", "the push-in continues", or similar. @@ -5425,7 +5425,7 @@ def _plan_story_h3_native( 2. Compare every shot's closing_blocking with the next shot's spatial_setup. 3. If the same-scene positions differ, put the required movement in the earlier shot's action_beats and video_prompt so the next opening is earned on screen. 4. Use extend_previous only for a literal seamless continuation with unchanged camera composition. Use continuous for ordinary same-scene cuts. -5. Cross-check every dialogue_beats entry against video_prompt. Copy each spoken_text verbatim into one [English] ... tag. For a silent shot, forbid invented speech and gibberish explicitly. +5. Cross-check every dialogue_beats entry against video_prompt. Copy each spoken_text verbatim into one [Language] ... tag using the requested/inferred language; never default to English when the words are in another language. For a silent shot, forbid invented speech and gibberish explicitly. SCREENPLAY: {screenplay}""" diff --git a/app/services/director/policies.py b/app/services/director/policies.py index bfa9238b..263cb4e3 100644 --- a/app/services/director/policies.py +++ b/app/services/director/policies.py @@ -6,6 +6,7 @@ """ from __future__ import annotations +import json import re from dataclasses import dataclass from typing import Optional @@ -45,6 +46,47 @@ class PromptPolicy: DIRECT_VIDEO_MUSIC_MARKER = "non_diegetic_music:" +def _direct_video_json_field(prompt: object, field: str) -> str: + """Read a JSON string field embedded in an LLM-authored H3 prompt.""" + match = re.search( + rf'"{re.escape(field)}"\s*:\s*("(?:\\.|[^"\\])*")', + str(prompt or ""), + flags=re.I, + ) + if not match: + return "" + try: + value = json.loads(match.group(1)) + except (TypeError, json.JSONDecodeError): + return "" + return " ".join(str(value or "").split()).strip() + + +def _direct_video_audio_fields(prompt: object) -> tuple[str, str]: + """Recover authored H3 sound fields before the visual prompt is segmented.""" + text = str(prompt or "") + json_sound = _direct_video_json_field(text, "overall_soundscape") + json_music = _direct_video_json_field(text, "non_diegetic_music") + if json_sound or json_music: + return json_sound, json_music + + def official(field: str, following: str | None = None) -> str: + boundary = ( + rf"(?=\n\s*{re.escape(following)}\s*:|\Z)" + if following else r"\Z" + ) + match = re.search( + rf"(?ims)^\s*{re.escape(field)}\s*:\s*(.*?){boundary}", + text, + ) + return " ".join(match.group(1).split()).strip(" .") if match else "" + + return ( + official("overall_soundscape", "non_diegetic_music"), + official("non_diegetic_music"), + ) + + # ── Anti-Pattern Definitions ───────────────────────────────────────── # Words/phrases that should never appear in single-shot video prompts @@ -709,7 +751,12 @@ def direct_video_situation(prompt: object) -> str: text = str(prompt or "").strip() if not text: return "" - if DIRECT_VIDEO_SHOT_MARKER.casefold() in text.casefold(): + embedded_description = _direct_video_json_field( + text, "integrated_multimodal_description", + ) or _direct_video_json_field(text, "detailed_description") + if embedded_description: + text = embedded_description + elif DIRECT_VIDEO_SHOT_MARKER.casefold() in text.casefold(): text = re.split( re.escape(DIRECT_VIDEO_SHOT_MARKER), text, @@ -759,6 +806,7 @@ def compose_direct_video_prompt( plan: Optional[dict] = None, audio_direction: object = "", allow_clip_text: bool = True, + audio_source_prompt: object | None = None, ) -> str: """Compose one self-contained text-only video prompt. @@ -768,6 +816,9 @@ def compose_direct_video_prompt( conditioning at any stage. """ master = " ".join(str(master_prompt or "").split()).strip() + authored_soundscape, authored_music = _direct_video_audio_fields( + situation_prompt if audio_source_prompt is None else audio_source_prompt, + ) situation = direct_video_situation(situation_prompt) shot = plan if isinstance(plan, dict) else {} @@ -792,29 +843,47 @@ def compose_direct_video_prompt( if not allow_clip_text: situation = apply_no_visible_text_lock(situation, mode="video") - audio = shot.get("audio_plan") if isinstance(shot.get("audio_plan"), dict) else {} + audio = ( + shot.get("_director_audio_plan") + if isinstance(shot.get("_director_audio_plan"), dict) + else shot.get("audio_plan") + if isinstance(shot.get("audio_plan"), dict) + else {} + ) sound_parts: list[str] = [] - ambience = " ".join(str(audio.get("ambience") or "").split()).strip(" .") - if ambience: - sound_parts.append(ambience) - effects = audio.get("effects") - if isinstance(effects, list): - clean_effects = [" ".join(str(item).split()).strip(" .") for item in effects] - clean_effects = [item for item in clean_effects if item] - if clean_effects: - sound_parts.append("Synchronized effects: " + ", ".join(clean_effects)) + if authored_soundscape: + sound_parts.append(authored_soundscape.strip(" .")) + else: + ambience = " ".join(str(audio.get("ambience") or "").split()).strip(" .") + if ambience: + sound_parts.append(ambience) + effects = audio.get("effects") + if isinstance(effects, list): + clean_effects = [" ".join(str(item).split()).strip(" .") for item in effects] + clean_effects = [item for item in clean_effects if item] + if clean_effects: + sound_parts.append("Synchronized effects: " + ", ".join(clean_effects)) direction = " ".join(str(audio_direction or "").split()).strip(" .") - if direction: + if direction and not authored_soundscape: sound_parts.append(direction) if not sound_parts: sound_parts.append("Natural synchronized ambience and effects matching the visible action") + music = authored_music.strip(" .") + if not music: + music = " ".join(str(audio.get("music") or "").split()).strip(" .") + if not music and str(audio.get("mode") or "").strip().lower() in { + "music_driven", "audio_driven", + }: + music = "Follow the selected song section as the musical and timing anchor" + if not music or music.casefold() in {"none", "n/a", "no music"}: + music = "N/A" return "\n".join(( master, f"{DIRECT_VIDEO_SCENE_MARKER} {overview}.", f"{DIRECT_VIDEO_SHOT_MARKER} {situation}.", f"{DIRECT_VIDEO_SOUND_MARKER} {'; '.join(sound_parts)}.", - f"{DIRECT_VIDEO_MUSIC_MARKER} none", + f"{DIRECT_VIDEO_MUSIC_MARKER} {music}", )).strip() @@ -840,6 +909,11 @@ def enforce_direct_video_on_clip_plans( audio_direction=audio_direction, allow_clip_text=allow_clip_text, ) + # Direct T2VA has no later visual-conditioning conversion. Make the + # composed prompt the immutable H3 source so official preflight keeps + # its authored soundscape/music instead of recompiling stale planner + # JSON into generic ambience plus N/A. + plan["_director_h3_source_prompt"] = plan["video_prompt"] windows = plan.get("window_prompts") if isinstance(windows, list): plan["window_prompts"] = [ diff --git a/app/services/director/spoken_language.py b/app/services/director/spoken_language.py new file mode 100644 index 00000000..021b78aa --- /dev/null +++ b/app/services/director/spoken_language.py @@ -0,0 +1,140 @@ +"""Shared spoken-language contracts for every Director video workflow.""" + +from __future__ import annotations + +import re +from typing import Any, MutableMapping, Sequence + + +_SPANISH_RE = re.compile(r"\b(?:español|spanish|castellano)\b", re.IGNORECASE) +_LANGUAGE_CONTRACT_RE = re.compile( + r"(?:^|\n)SPOKEN LANGUAGE CONTRACT[^\n]*", + re.IGNORECASE, +) + + +def normalize_spoken_language(value: Any) -> str: + return " ".join(str(value or "").split())[:120] + + +def extract_spoken_language(text: Any) -> str: + match = re.search( + r"SPOKEN LANGUAGE CONTRACT:\s*Every generated spoken word must be only in\s+([^\.\n]+)", + str(text or ""), + re.IGNORECASE, + ) + return normalize_spoken_language(match.group(1)) if match else "" + + +def h3_language_tag(value: Any) -> str: + """Return a broad H3 label instead of inventing a regional tag.""" + language = normalize_spoken_language(value) + if not language: + return "" + if _SPANISH_RE.search(language): + return "Spanish" + folded = language.casefold() + aliases = { + "inglés": "English", "english": "English", + "francés": "French", "french": "French", + "italiano": "Italian", "italian": "Italian", + "alemán": "German", "german": "German", + "portugués": "Portuguese", "portuguese": "Portuguese", + "japonés": "Japanese", "japanese": "Japanese", + "coreano": "Korean", "korean": "Korean", + "chino": "Chinese", "chinese": "Chinese", + } + for needle, label in aliases.items(): + if needle in folded: + return label + return language + + +def infer_h3_spoken_language(text: Any) -> str: + """Infer a broad H3 language tag only when no authored tag exists.""" + + source = str(text or "") + if re.search(r"[\u3040-\u30ff]", source): + return "Japanese" + if re.search(r"[\uac00-\ud7af]", source): + return "Korean" + if re.search(r"[\u0400-\u04ff]", source): + return "Russian" + if re.search(r"[\u0600-\u06ff]", source): + return "Arabic" + if re.search(r"[\u3400-\u9fff]", source): + return "Chinese" + + folded = source.casefold() + words = set(re.findall(r"[^\W_]+", folded, flags=re.UNICODE)) + scores = { + "Spanish": ( + 3 * len(re.findall(r"[¿¡ñ]", folded)) + + len(words & {"que", "por", "para", "una", "está", "nadie", "aquí", "pero"}) + ), + "French": ( + 3 * len(re.findall(r"[œêëÿ]", folded)) + + len(words & {"je", "vous", "avec", "une", "est", "pas", "mais", "ici"}) + ), + "Portuguese": ( + 3 * len(re.findall(r"[ãõ]", folded)) + + len(words & {"você", "não", "uma", "está", "mas", "aqui"}) + ), + "German": ( + 3 * len(re.findall(r"[äöß]", folded)) + + len(words & {"ich", "nicht", "und", "ist", "aber", "hier"}) + ), + "Italian": len(words & {"io", "non", "una", "sono", "che", "ma", "qui"}), + } + language, score = max(scores.items(), key=lambda item: item[1]) + if score: + return language + return "English" + + +def spoken_language_contract(value: Any) -> str: + language = normalize_spoken_language(value) + if not language: + return "" + regional = ( + " Use a native Spain/Castilian accent and vocabulary; never use Latin-American " + "Spanish, Italian, or another language." + if _SPANISH_RE.search(language) + and any(token in language.casefold() for token in ("españa", "castellano", "spain")) + else " Never switch to another language or accent." + ) + return ( + f"SPOKEN LANGUAGE CONTRACT: Every generated spoken word must be only in {language}." + f"{regional} Preserve supplied dialogue verbatim; do not translate or invent speech." + ) + + +def append_spoken_language_contract(text: Any, language: Any) -> str: + source = str(text or "").strip() + contract = spoken_language_contract(language) + if not contract: + return source + source = _LANGUAGE_CONTRACT_RE.sub("", source).strip() + return f"{contract}\n{source}" if source else contract + + +def apply_spoken_language_to_plans( + plans: Sequence[MutableMapping[str, Any]], language: Any, +) -> None: + normalized = normalize_spoken_language(language) + if not normalized: + return + for plan in plans: + source = plan.get("_director_h3_source_prompt") + if source: + plan["_director_h3_source_prompt"] = append_spoken_language_contract( + source, normalized, + ) + else: + plan["video_prompt"] = append_spoken_language_contract( + plan.get("video_prompt"), normalized, + ) + audio_plan = plan.get("_director_audio_plan") + audio_plan = dict(audio_plan) if isinstance(audio_plan, dict) else {} + audio_plan["spoken_language"] = normalized + plan["_director_audio_plan"] = audio_plan diff --git a/app/services/director_pipeline.py b/app/services/director_pipeline.py index 91db096d..d93762f9 100644 --- a/app/services/director_pipeline.py +++ b/app/services/director_pipeline.py @@ -23,11 +23,13 @@ import subprocess import unicodedata import traceback +from contextlib import nullcontext from functools import wraps from typing import Callable, Optional from services.job_lifecycle import ( GENERATED_MEDIA_EXTENSIONS, + acknowledge_cancel, register_generation_job, request_cancel, snapshot_job, @@ -60,6 +62,7 @@ _wgp = None # reference to wgp module _gen_lock = None # reference to launch._gen_lock _active_gen_states = None # reference to launch._active_gen_states (abort signaling) +_pipeline_state_observer: Optional[Callable[[dict, str], Optional[dict]]] = None _pipelines: dict = {} _pipeline_lock = threading.Lock() @@ -399,13 +402,7 @@ def _saved_pipeline_shot_image_policy(state: dict) -> str: """Read a persisted policy; pre-feature projects required start images.""" snapshot = state.get("_params_snapshot") or {} - if ( - ( - state.get("pipeline_type") == "music_video" - and state.get("generation_mode") == "direct_video" - ) - or _direct_video_settings(snapshot)[0] - ): + if state.get("generation_mode") == "direct_video" or _direct_video_settings(snapshot)[0]: return SHOT_IMAGE_PROMPT_ONLY saved = str(state.get("shot_image_policy") or "").strip() @@ -1254,6 +1251,7 @@ def _save_pipeline_state_locked(pid: str) -> bool: clips = [] for i, plan in enumerate(clip_plans): + clip_video = clip_videos[i] if i < len(clip_videos) else None clip_state = { "index": i, "planned_clip": p.get("_planned_clips", [{}] * (i + 1))[i] if i < len(p.get("_planned_clips", [])) else None, @@ -1306,7 +1304,27 @@ def _save_pipeline_state_locked(pid: str) -> bool: ), "end_image_filename": clip_end_images[i] if i < len(clip_end_images) else None, "keyframe_filenames": (p.get("_clip_keyframes", []) or [])[i] if i < len(p.get("_clip_keyframes", [])) else [], - "video_filename": clip_videos[i] if i < len(clip_videos) else None, + "video_filename": clip_video, + # Attempts are append-only. ``video_filename`` remains the active + # compatibility field, while this list lets Montage expose every + # version ever rendered for the same ordered slot. + "video_attempts": ([{ + "id": clip_video, + "filename": clip_video, + "created_at": p.get("_completed_at") or time.time(), + "seed": _comic_shot_seed(params, i, plan), + "prompt": plan.get("video_prompt", ""), + "model_type": params.get("video_model", ""), + "resolution": (params.get("video_params") or {}).get( + "resolution", "", + ), + "video_length": plan.get("duration_frames"), + "source": "original", + }] if clip_video else []), + # This is intentionally empty for legacy checkpoints. Their + # current video_filename is the implicit selection; the explicit + # field is written only after the user chooses a historical take. + "selected_video_filename": None, "video_stale": False, "tag": (p.get("_clip_tags", []) or [])[i] if i < len(p.get("_clip_tags", [])) else None, "image_gen_time_sec": clip_timings.get(f"image_{i}"), @@ -1350,7 +1368,11 @@ def _save_pipeline_state_locked(pid: str) -> bool: "completed_at": p.get("_completed_at"), "status": p.get("status", "unknown"), "phase": p.get("phase"), + "error": p.get("error"), "progress": copy.deepcopy(p.get("progress") or {}), + "resource_schedule": copy.deepcopy( + p.get("resource_schedule") or {} + ), "pipeline_type": params.get("pipeline_type", "music_video"), "generation_mode": ( "direct_video" if _direct_video_settings(params)[0] else "image_guided" @@ -1767,17 +1789,30 @@ def list_pipeline_states(out_dir: str, workspace: Optional[str] = None) -> list[ results.append({ "id": pid, "status": status, + "phase": data.get("phase") or status, "pipeline_type": data.get("pipeline_type", ""), "generation_mode": data.get("generation_mode", "image_guided"), "created_at": data.get("created_at"), + "updated_at": data.get("updated_at"), + "completed_at": data.get("completed_at"), + "progress": copy.deepcopy(data.get("progress") or {}), + "error": data.get("error"), "clip_count": len(data.get("clips", [])), "output_count": len(data.get("output_files", [])), + "output_files": list(data.get("output_files", []) or []), "scene_description": (data.get("scene_description", "") or "")[:100], "comic_id": data.get("comic_id"), "preview_fingerprint": data.get("preview_fingerprint"), "preview_revision": data.get("preview_revision", 1), "workspace": os.path.basename(scan_dir) if scan_dir != out_dir else "default", "repair_status": (data.get("repair") or {}).get("status"), + "generation_details": _public_pipeline_generation_details( + _director_params_from_saved_state(data), + len(data.get("clips", [])), + ), + "resource_schedule": copy.deepcopy( + data.get("resource_schedule") or {} + ), "_filepath": filepath, }) except Exception: @@ -1804,18 +1839,236 @@ def _backfill_clip_video_filenames(state: dict, state_dir: str) -> dict: filename for filename in (state.get("output_files") or []) if "_multiclip" not in os.path.splitext(filename)[0].lower() ] - if not clips or len(outputs) != len(clips): - return state - for i, clip in enumerate(clips): - if not clip.get("video_filename") and os.path.isfile(os.path.join(state_dir, outputs[i])): - clip["video_filename"] = outputs[i] - return state + if clips and len(outputs) == len(clips): + for i, clip in enumerate(clips): + if not clip.get("video_filename") and os.path.isfile(os.path.join(state_dir, outputs[i])): + clip["video_filename"] = outputs[i] + return _backfill_clip_video_attempts(state, state_dir) _SAVED_MEDIA_EXTENSIONS = { "image": {".jpg", ".jpeg", ".png", ".webp"}, "video": {".mkv", ".mov", ".mp4", ".webm"}, } +_clip_attempt_sidecar_cache: dict[ + str, + tuple[int, dict[str, list[tuple[str, dict]]]], +] = {} + + +def _backfill_clip_video_attempts(state: dict, state_dir: str) -> dict: + """Recover append-only video histories from state and owned sidecars. + + Old checkpoints retained superseded media in ``output_files`` and on disk, + but only remembered the latest filename per clip. Modern sidecars carry a + stable ``director_clip_index``. Reconcile both forms without guessing from + file order, and keep explicitly persisted Studio selections authoritative. + """ + + clips = state.get("clips") if isinstance(state.get("clips"), list) else [] + if not clips: + return state + pid = str(state.get("pipeline_id") or "") + video_extensions = _SAVED_MEDIA_EXTENSIONS["video"] + + attempts_by_clip: list[dict[str, dict]] = [] + for clip in clips: + attempts: dict[str, dict] = {} + raw_attempts = ( + clip.get("video_attempts") + if isinstance(clip.get("video_attempts"), list) + else [] + ) + for raw in raw_attempts: + if not isinstance(raw, dict): + continue + filename = str(raw.get("filename") or "").strip() + if ( + not filename + or os.path.basename(filename) != filename + or os.path.splitext(filename)[1].lower() not in video_extensions + or not os.path.isfile(os.path.join(state_dir, filename)) + ): + continue + attempts[filename] = { + **raw, + "id": str(raw.get("id") or filename), + "filename": filename, + } + + current = str(clip.get("video_filename") or "").strip() + segments = clip.get("h3_segments") if isinstance(clip.get("h3_segments"), list) else [] + if not current and len(segments) == 1: + current = str((segments[0] or {}).get("filename") or "").strip() + if current: + clip["video_filename"] = current + if ( + current + and os.path.basename(current) == current + and os.path.isfile(os.path.join(state_dir, current)) + and current not in attempts + ): + try: + created_at = os.path.getmtime(os.path.join(state_dir, current)) + except OSError: + created_at = 0 + segment = next( + ( + item for item in segments + if isinstance(item, dict) and item.get("filename") == current + ), + {}, + ) + attempts[current] = { + "id": current, + "filename": current, + "created_at": created_at, + "seed": segment.get("seed", clip.get("seed")), + "prompt": segment.get("prompt") or clip.get("video_prompt", ""), + "model_type": state.get("video_model", ""), + "resolution": (state.get("video_params") or {}).get( + "resolution", "", + ), + "video_length": segment.get("frames"), + "source": "recovered", + } + attempts_by_clip.append(attempts) + + cache_key = os.path.realpath(state_dir) + try: + directory_revision = os.stat(state_dir).st_mtime_ns + except OSError: + directory_revision = 0 + cached = _clip_attempt_sidecar_cache.get(cache_key) + if cached and cached[0] == directory_revision: + sidecars = cached[1].get(pid, []) + else: + sidecars_by_pipeline: dict[str, list[tuple[str, dict]]] = {} + try: + sidecar_names = [ + name for name in os.listdir(state_dir) if name.endswith(".meta.json") + ] + except OSError: + sidecar_names = [] + for sidecar_name in sidecar_names: + sidecar_path = os.path.join(state_dir, sidecar_name) + try: + with open(sidecar_path, "r", encoding="utf-8") as handle: + metadata = json.load(handle) + except Exception: + continue + if not isinstance(metadata, dict): + continue + sidecar_params = metadata.get("params") if isinstance(metadata.get("params"), dict) else {} + owner = str( + metadata.get("director_pipeline_id") + or sidecar_params.get("_director_pipeline_id") + or "" + ) + if owner: + sidecars_by_pipeline.setdefault(owner, []).append( + (sidecar_name, metadata) + ) + _clip_attempt_sidecar_cache[cache_key] = ( + directory_revision, + sidecars_by_pipeline, + ) + sidecars = sidecars_by_pipeline.get(pid, []) + + for sidecar_name, metadata in sidecars: + params = metadata.get("params") if isinstance(metadata.get("params"), dict) else {} + raw_index = metadata.get("director_clip_index") + if raw_index is None: + raw_index = params.get("_director_clip_index") + if raw_index is None: + # Older H3 outputs predate the explicit sidecar index, but their + # durable progress label still names the ordered slot. Recover it + # so pre-upgrade projects also receive their existing histories. + legacy_label = str(params.get("_director_progress_label") or "") + legacy_match = re.search(r"\bclip\s+(\d+)(?:/\d+)?\b", legacy_label, re.I) + if legacy_match: + raw_index = int(legacy_match.group(1)) - 1 + try: + clip_index = int(raw_index) + except (TypeError, ValueError): + continue + if clip_index < 0 or clip_index >= len(clips): + continue + + filename = str(metadata.get("output_filename") or "").strip() + if not filename: + media_stem = sidecar_name[: -len(".meta.json")] + matches = [ + media_stem + extension + for extension in video_extensions + if os.path.isfile(os.path.join(state_dir, media_stem + extension)) + ] + if len(matches) == 1: + filename = matches[0] + if ( + not filename + or os.path.basename(filename) != filename + or os.path.splitext(filename)[1].lower() not in video_extensions + or not os.path.isfile(os.path.join(state_dir, filename)) + ): + continue + + clip = clips[clip_index] + segments = clip.get("h3_segments") if isinstance(clip.get("h3_segments"), list) else [] + segment = next( + ( + item for item in segments + if isinstance(item, dict) and item.get("filename") == filename + ), + {}, + ) + # A multi-segment H3 shot is one Montage slot but each sidecar is only + # a continuation fragment, not a complete alternative for that slot. + # Whole-shot Studio selections are persisted explicitly by the API. + if len(segments) > 1 and ( + params.get("_director_h3_segment_index") is not None or segment + ): + continue + standalone = params.get("_director_clip_index") is not None + attempts_by_clip[clip_index][filename] = { + **attempts_by_clip[clip_index].get(filename, {}), + "id": filename, + "filename": filename, + "created_at": metadata.get("created_at") + or attempts_by_clip[clip_index].get(filename, {}).get("created_at") + or 0, + "seed": params.get("seed", segment.get("seed", clip.get("seed"))), + "prompt": ( + params.get("prompt") + if standalone and params.get("prompt") + else segment.get("prompt") or clip.get("video_prompt", "") + ), + "model_type": params.get("model_type") or state.get("video_model", ""), + "resolution": params.get("resolution") + or (state.get("video_params") or {}).get("resolution", ""), + "video_length": segment.get("frames") + or (params.get("video_length") if standalone else None), + "source": ( + "studio" + if params.get("_director_external_selection") + else "regenerated" if params.get("_director_detached_operation") + else "original" + ), + } + + for index, clip in enumerate(clips): + selected = str(clip.get("selected_video_filename") or "").strip() + if selected and selected not in attempts_by_clip[index]: + selected = "" + clip["selected_video_filename"] = selected or None + if selected: + clip["video_filename"] = selected + clip["video_stale"] = False + clip["video_attempts"] = sorted( + attempts_by_clip[index].values(), + key=lambda item: (float(item.get("created_at") or 0), item["filename"]), + ) + return state def _invalid_saved_media_numbers( @@ -1937,6 +2190,103 @@ def _update_clip_tag_locked(out_dir: str, pid: str, clip_index: int, tag: Option return False +@_exclusive_pipeline_operation +def select_clip_video_attempt( + out_dir: str, + pid: str, + clip_index: int, + filename: str, +) -> dict: + """Make one historical/rendered video authoritative for an ordered slot.""" + + pipeline_file = _find_pipeline_file(out_dir, pid) + if not pipeline_file: + raise ValueError(f"Pipeline {pid} not found") + pipeline_dir = os.path.dirname(pipeline_file) + filename = str(filename or "").strip() + if ( + not filename + or os.path.basename(filename) != filename + or os.path.splitext(filename)[1].lower() + not in _SAVED_MEDIA_EXTENSIONS["video"] + ): + raise ValueError("Select one valid video filename from this workspace") + if _invalid_saved_media_numbers([filename], 1, pipeline_dir, "video"): + raise ValueError("The selected video is missing, empty, or outside this workspace") + + state = load_pipeline_state(out_dir, pid) + clips = state.get("clips", []) if state else [] + if clip_index < 0 or clip_index >= len(clips): + raise ValueError( + f"Clip index {clip_index} out of range (0-{len(clips) - 1})" + ) + clip = clips[clip_index] + attempt = next( + ( + item for item in (clip.get("video_attempts") or []) + if isinstance(item, dict) and item.get("filename") == filename + ), + None, + ) + if attempt is None: + metadata = {} + meta_path = os.path.join( + pipeline_dir, + os.path.splitext(filename)[0] + ".meta.json", + ) + if os.path.isfile(meta_path): + try: + with open(meta_path, "r", encoding="utf-8") as handle: + loaded = json.load(handle) + if isinstance(loaded, dict): + metadata = loaded + except Exception: + metadata = {} + params = metadata.get("params") if isinstance(metadata.get("params"), dict) else {} + try: + created_at = float( + metadata.get("created_at") + or os.path.getmtime(os.path.join(pipeline_dir, filename)) + ) + except (OSError, TypeError, ValueError): + created_at = time.time() + attempt = { + "id": filename, + "filename": filename, + "created_at": created_at, + "seed": params.get("seed", clip.get("seed")), + "prompt": params.get("prompt") or clip.get("video_prompt", ""), + "model_type": params.get("model_type") or state.get("video_model", ""), + "resolution": params.get("resolution") + or (state.get("video_params") or {}).get("resolution", ""), + "video_length": params.get("video_length"), + "source": "studio", + } + clip.setdefault("video_attempts", []).append(attempt) + + clip["selected_video_filename"] = filename + clip["video_filename"] = filename + clip["video_stale"] = False + segments = clip.get("h3_segments") if isinstance(clip.get("h3_segments"), list) else [] + if len(segments) == 1: + segments[0]["filename"] = filename + segments[0]["prompt"] = attempt.get("prompt") or segments[0].get("prompt", "") + segments[0]["seed"] = attempt.get("seed", segments[0].get("seed")) + if attempt.get("video_length"): + segments[0]["frames"] = attempt["video_length"] + segments[0]["stale"] = False + segments[0]["updated_at"] = time.time() + if filename not in state.get("output_files", []): + state.setdefault("output_files", []).append(filename) + _replace_saved_pipeline(out_dir, pid, state) + return { + "pipeline_id": pid, + "clip_index": clip_index, + "filename": filename, + "attempt": attempt, + } + + def _find_pipeline_file(out_dir: str, pid: str) -> Optional[str]: """Find the JSON file path for a saved pipeline.""" target = f"{_PIPELINE_FILE_PREFIX}{pid}.json" @@ -3078,6 +3428,9 @@ def _rerun_clip_video_impl(out_dir: str, pid: str, clip_index: int, prompt_overr m.split(";")[0] for m in (video_loras.get("loras_multipliers", "") or "").split(" ") if m ), "_director_pipeline_id": pid, + # Persisted into the output sidecar so superseded reruns can always be + # recovered into the correct Montage slot after a restart. + "_director_clip_index": clip_index, "_director_detached_operation": True, "_director_video_execution_profile": execution_profile, "minimax_h3_turbo_mode": bool( @@ -3282,10 +3635,28 @@ def _rerun_clip_video_impl(out_dir: str, pid: str, clip_index: int, prompt_overr ) def _update(s): - s["clips"][clip_index]["video_filename"] = new_filename - s["clips"][clip_index]["video_stale"] = False - s["clips"][clip_index]["video_prompt"] = prompt - s["clips"][clip_index]["_director_vocal_contract"] = ( + saved_clip = s["clips"][clip_index] + saved_clip["video_filename"] = new_filename + saved_clip["selected_video_filename"] = new_filename + saved_clip["video_stale"] = False + saved_clip["video_prompt"] = prompt + attempts = [ + item for item in (saved_clip.get("video_attempts") or []) + if isinstance(item, dict) and item.get("filename") != new_filename + ] + attempts.append({ + "id": new_filename, + "filename": new_filename, + "created_at": time.time(), + "seed": gen_params.get("seed"), + "prompt": prompt, + "model_type": video_model, + "resolution": gen_params.get("resolution"), + "video_length": gen_params.get("video_length"), + "source": "regenerated", + }) + saved_clip["video_attempts"] = attempts + saved_clip["_director_vocal_contract"] = ( prompt_plan.get("_director_vocal_contract") ) for key in ( @@ -3300,7 +3671,7 @@ def _update(s): "_director_audio_plan", ): if prompt_plan.get(key) is not None: - s["clips"][clip_index][key] = prompt_plan.get(key) + saved_clip[key] = prompt_plan.get(key) if new_filename not in s.get("output_files", []): s.setdefault("output_files", []).append(new_filename) s["video_execution_profile"] = execution_profile @@ -3404,6 +3775,11 @@ def rerun_h3_segment( segment_index if direct_video else len(segments) - 1 if cascade else segment_index ) + if len(segments) > 1: + # Editing one native segment opts back into the segment sequence; a + # previously selected whole-shot Studio override must no longer mask + # the newly regenerated continuation chain during rejoin. + clip["selected_video_filename"] = None for record in segments[segment_index:]: record["stale"] = True _replace_saved_pipeline(out_dir, pid, state) @@ -3464,6 +3840,10 @@ def rerun_h3_segment( audio_direction=str(video_params.get("h3_audio_prompt") or ""), ) prompt = _saved_prompt_contract(state, prompt, "video") + prompt = _h3_apply_portrait_composition_contract( + prompt, + str(video_params.get("resolution") or "960x544"), + ) gen_params = { "model_type": str(state.get("video_model") or "minimax_h3"), "prompt": prompt, @@ -3485,6 +3865,9 @@ def rerun_h3_segment( "h3_model_profile": video_params.get("h3_model_profile", "quality"), "h3_reference_mode": segment_mode, "_director_pipeline_id": pid, + "_director_clip_index": clip_index, + "_director_h3_segment_index": index, + "_director_detached_operation": True, } if not direct_video and segment_mode == "references": gen_params["image_refs"] = [start_path, *image_refs] @@ -3518,6 +3901,26 @@ def rerun_h3_segment( output_files.append(new_name) state["output_files"] = output_files regenerated.append(new_name) + if len(segments) == 1: + clip["video_filename"] = new_name + clip["selected_video_filename"] = new_name + clip["video_stale"] = False + attempts = [ + item for item in (clip.get("video_attempts") or []) + if isinstance(item, dict) and item.get("filename") != new_name + ] + attempts.append({ + "id": new_name, + "filename": new_name, + "created_at": time.time(), + "seed": gen_params.get("seed"), + "prompt": prompt, + "model_type": gen_params.get("model_type"), + "resolution": gen_params.get("resolution"), + "video_length": gen_params.get("video_length"), + "source": "regenerated", + }) + clip["video_attempts"] = attempts _replace_saved_pipeline(out_dir, pid, state) finally: for path in temporary_frames: @@ -3559,6 +3962,23 @@ def _rejoin_clips_impl(out_dir: str, pid: str) -> dict: if legacy_h3_segments: stale = [] for clip in clips: + selected_override = str( + clip.get("selected_video_filename") or "" + ).strip() + if selected_override: + if _invalid_saved_media_numbers( + [selected_override], 1, clip_out_dir, "video", + ): + raise ValueError( + "The selected historical video for clip " + f"{int(clip.get('index', 0)) + 1} is missing or invalid." + ) + # An explicit whole-slot selection is authoritative. Ignore + # every older H3 segment version for this position. + video_files.append( + os.path.join(clip_out_dir, selected_override) + ) + continue for segment in (clip.get("h3_segments") or []): if segment.get("stale"): stale.append((clip.get("index", 0), segment.get("index", 0))) @@ -4220,6 +4640,79 @@ def cancel_pipeline_repair(out_dir: str, pid: str) -> Optional[dict]: return snapshot +def _pipeline_observer_snapshot(pipeline: dict) -> dict: + """Build one immutable, non-sensitive snapshot for task publication.""" + snapshot = copy.deepcopy(pipeline) + params = snapshot.pop("params", None) + snapshot.pop("_llm_passes", None) + snapshot["pipeline_type"] = snapshot.get("pipeline_type") or ( + params or {} + ).get("pipeline_type", "") + details = _public_pipeline_generation_details( + params, + len(snapshot.get("clip_plans") or []), + ) + if details: + snapshot["generation_details"] = details + return snapshot + + +def _observer_task_ids(result) -> tuple[Optional[str], Optional[str]]: + """Accept both TaskRegistry records and explicit observer ID payloads.""" + if not isinstance(result, dict): + return None, None + task_id = str( + result.get("task_id") or result.get("id") or "" + ).strip() + root_task_id = str( + result.get("root_task_id") + or result.get("root_id") + or task_id + or "" + ).strip() + return task_id or None, root_task_id or None + + +def _notify_pipeline_snapshot( + pid: str, + snapshot: Optional[dict] = None, +) -> Optional[dict]: + """Publish a pipeline snapshot without holding Director's registry lock. + + The callback is deliberately best-effort: task observability must never + stop a render. A callback may return either the canonical TaskRegistry + record (``id``/``root_id``) or explicit ``task_id``/``root_task_id`` + fields. Retaining those IDs on the pipeline lets nested LLM calls attach + themselves to the Director root from inside the background worker. + """ + with _pipeline_lock: + observer = _pipeline_state_observer + if snapshot is None: + pipeline = _pipelines.get(pid) + snapshot = copy.deepcopy(pipeline) if pipeline else None + if observer is None or snapshot is None: + return None + + public_snapshot = _pipeline_observer_snapshot(snapshot) + workspace = str(public_snapshot.get("workspace") or "default") + try: + result = observer(public_snapshot, workspace) + except Exception as exc: + print(f"[Pipeline {pid}] State observer warning (non-fatal): {exc}") + return None + + task_id, root_task_id = _observer_task_ids(result) + if task_id or root_task_id: + with _pipeline_lock: + pipeline = _pipelines.get(pid) + if pipeline is not None: + if task_id: + pipeline["task_id"] = task_id + if root_task_id: + pipeline["root_task_id"] = root_task_id + return result if isinstance(result, dict) else None + + def init( jobs_dict, run_gen_fn, @@ -4227,16 +4720,18 @@ def init( gen_lock=None, cancel_gen_fn=None, active_gen_states=None, + state_observer=None, ): """Called by launch.py to wire up shared references.""" global _jobs, _run_generation, _cancel_generation, _wgp, _gen_lock - global _active_gen_states + global _active_gen_states, _pipeline_state_observer _jobs = jobs_dict _run_generation = run_gen_fn _cancel_generation = cancel_gen_fn _wgp = wgp_module _gen_lock = gen_lock _active_gen_states = active_gen_states + _pipeline_state_observer = state_observer class _DirectorOutputs(list): @@ -4314,6 +4809,48 @@ def _clear_active_generation_job(pid: Optional[str], job_id: str) -> None: pipeline.pop("_active_generation_job_id", None) +def _pipeline_has_active_work_locked(pid: str) -> bool: + return bool( + pid in _pipeline_threads + or _pipeline_child_jobs.get(pid) + or pid in _pipeline_starting + ) + + +def _acknowledge_pipeline_cancel(pid: str, *, force: bool = False) -> bool: + """Publish Director cancellation only after all owned work has settled.""" + snapshot = None + with _pipeline_lock: + pipeline = _pipelines.get(pid) + if not pipeline or not pipeline.get("_cancel_requested"): + return False + if pipeline.get("status") == "cancelled": + return True + if not force and _pipeline_has_active_work_locked(pid): + return False + now = time.time() + pipeline["status"] = "cancelled" + pipeline["phase"] = "cancelled" + pipeline["pause_reason"] = None + pipeline["_completed_at"] = now + pipeline["updated_at"] = now + pipeline["progress"] = { + "current": 0, + "total": 0, + "message": "Cancelled", + "step": 0, + "total_steps": 0, + } + snapshot = copy.deepcopy(pipeline) + _notify_pipeline_snapshot(pid, snapshot) + persisted = _save_pipeline_state(pid) + with _pipeline_lock: + current = _pipelines.get(pid) + if current is not None: + current["_state_persisted"] = persisted + return True + + def _submit_and_wait(params: dict, timeout_s: float = 600, workspace: str = None, out_dir: str = None) -> list[str]: """Submit a generation job and block until it completes. @@ -4357,13 +4894,26 @@ def _run_tracked_generation() -> None: return _run_generation(job_id) finally: + # `_run_generation` is synchronous: returning here means its + # generation slot/model invocation has fully unwound. A legacy or + # mocked worker may have observed the abort flag and returned + # before Director dispatched its compatibility callback; settle + # that sticky request here so the waiter cannot remain in + # `cancelling` forever. + acknowledge_cancel(job) if _dir_pid: + should_acknowledge_parent = False with _pipeline_lock: child_jobs = _pipeline_child_jobs.get(_dir_pid) if child_jobs is not None: child_jobs.discard(job_id) if not child_jobs: _pipeline_child_jobs.pop(_dir_pid, None) + should_acknowledge_parent = not _pipeline_has_active_work_locked( + _dir_pid, + ) + if should_acknowledge_parent: + _acknowledge_pipeline_cancel(_dir_pid) # Run generation in a separate thread (it acquires _gen_lock internally). # The child lease outlives this waiter if cancellation cannot settle @@ -4393,9 +4943,11 @@ def _run_tracked_generation() -> None: request_cancel(job) _skip_generation = True elif not _detached_operation: - pipeline_cancelled = ( - _pipelines.get(_dir_pid, {}).get("status") - == "cancelled" + current_pipeline = _pipelines.get(_dir_pid, {}) + pipeline_cancelled = bool( + current_pipeline.get("_cancel_requested") + or current_pipeline.get("status") == "cancelled" + or current_pipeline.get("phase") == "cancelling" ) if pipeline_cancelled: request_cancel(job) @@ -4508,11 +5060,6 @@ def _run_tracked_generation() -> None: if j["status"] == "completed": _clear_active_generation_job(_dir_pid, job_id) if not _detached_operation and _pipeline_cancel_requested(_dir_pid): - _update_pipeline( - _dir_pid, - status="cancelled", - phase="cancelled", - ) _save_pipeline_state(_dir_pid) raise PipelineCancelled("Director pipeline was cancelled.") return _director_job_outputs(j) @@ -4545,9 +5092,7 @@ def _run_tracked_generation() -> None: # while this job runs (e.g. the job was submitted in the window # after the stop endpoint scanned _jobs), signal abort from here. if _dir_pid and not _detached_operation and not _abort_signalled: - with _pipeline_lock: - _cancelled = _pipelines.get(_dir_pid, {}).get("status") == "cancelled" - if _cancelled: + if _pipeline_cancel_requested(_dir_pid): _abort_pipeline_jobs(_dir_pid) _abort_signalled = True # Mirror denoising step progress to pipeline status @@ -4586,11 +5131,19 @@ def _run_tracked_generation() -> None: def _update_pipeline(pid: str, **kwargs): """Thread-safe update; cancellation is an absorbing terminal state.""" + snapshot = None with _pipeline_lock: pipeline = _pipelines.get(pid) if not pipeline: return False - if pipeline.get("status") == "cancelled": + cancellation_ack = ( + kwargs.get("status") == "cancelled" + and kwargs.get("phase", "cancelled") == "cancelled" + ) + if pipeline.get("_cancel_requested") and not cancellation_ack: + if set(kwargs) - _CANCELLED_ARTIFACT_FIELDS: + return False + if pipeline.get("status") == "cancelled" and not cancellation_ack: # Finished clips may still be reported after an in-flight abort, # but no later phase, completion, or failure may replace Stop. if set(kwargs) - _CANCELLED_ARTIFACT_FIELDS: @@ -4604,13 +5157,53 @@ def _update_pipeline(pid: str, **kwargs): pipeline["phase_started_at"] = now pipeline.update(kwargs) pipeline["updated_at"] = now - return True + snapshot = copy.deepcopy(pipeline) + _notify_pipeline_snapshot(pid, snapshot) + return True + + +def _director_worker_task_context(pid: str): + """Return the canonical task scope for one Director worker, if present.""" + with _pipeline_lock: + pipeline = _pipelines.get(pid) or {} + task_id = str(pipeline.get("task_id") or "").strip() + root_task_id = str( + pipeline.get("root_task_id") or task_id or "" + ).strip() + workspace = str(pipeline.get("workspace") or "default") + out_dir = pipeline.get("out_dir") + if not task_id: + return nullcontext() + try: + from .task_manager import task_context_scope + except Exception: + try: + from services.task_manager import task_context_scope + except Exception: + return nullcontext() + try: + return task_context_scope( + task_id=task_id, + root_task_id=root_task_id, + root_id=root_task_id, + workspace=workspace, + workspace_dir=out_dir, + out_dir=out_dir, + ) + except Exception: + return nullcontext() + + +def _run_pipeline_worker(pid: str, *, resume: bool = False) -> None: + """Run Director with canonical context inherited by nested LLM calls.""" + with _director_worker_task_context(pid): + _run_pipeline(pid, resume=resume) def _start_pipeline_worker(pid: str, *, resume: bool = False) -> None: """Start and track a Director worker until its ``finally`` completes.""" thread = threading.Thread( - target=_run_pipeline, + target=_run_pipeline_worker, args=(pid,), kwargs={"resume": resume}, daemon=False, @@ -4626,6 +5219,7 @@ def _start_pipeline_worker(pid: str, *, resume: bool = False) -> None: try: thread.start() except BaseException as exc: + should_publish_failure = False with _pipeline_lock: if _pipeline_threads.get(pid) is thread: _pipeline_threads.pop(pid, None) @@ -4633,23 +5227,29 @@ def _start_pipeline_worker(pid: str, *, resume: bool = False) -> None: if pipeline and pipeline.get("status") not in { "completed", "failed", "cancelled", }: - pipeline["status"] = "failed" - pipeline["phase"] = "failed" - pipeline["error"] = f"Could not start pipeline worker: {exc}" - pipeline["_completed_at"] = time.time() - pipeline["progress"] = { + should_publish_failure = True + if should_publish_failure: + _update_pipeline( + pid, + status="failed", + phase="failed", + error=f"Could not start pipeline worker: {exc}", + _completed_at=time.time(), + progress={ "current": 0, "total": 0, "message": "Could not start pipeline worker", "step": 0, "total_steps": 0, - } + }, + ) _save_pipeline_state(pid) raise def _accumulate_pipeline_time(pid: str, key: str, elapsed_sec: float) -> None: """Atomically add a wall-clock stage duration to a live pipeline.""" + snapshot = None with _pipeline_lock: pipeline = _pipelines.get(pid) if not pipeline: @@ -4659,6 +5259,8 @@ def _accumulate_pipeline_time(pid: str, key: str, elapsed_sec: float) -> None: 2, ) pipeline["updated_at"] = time.time() + snapshot = copy.deepcopy(pipeline) + _notify_pipeline_snapshot(pid, snapshot) @@ -4756,6 +5358,11 @@ def start_pipeline(params: dict) -> str: with _pipeline_lock: _pipelines[pid] = pipeline + # Publish only after the pipeline is discoverable, but before its worker + # can advance. The observer's canonical IDs are stored synchronously so + # the very first LLM call inherits the Director parent context. + _notify_pipeline_snapshot(pid) + # Non-daemon so pipeline survives browser disconnect during overnight runs. _start_pipeline_worker(pid) @@ -4880,6 +5487,98 @@ def list_active_pipelines(workspace: Optional[str] = None) -> list[dict]: results.sort(key=lambda item: item.get("created_at") or 0, reverse=True) return results + +_RECENT_PIPELINE_TERMINAL_STATUSES = frozenset({ + "completed", + "failed", + "cancelled", + "crashed", + "preview_ready", +}) + + +def list_recent_pipelines( + out_dir: str, + workspace: Optional[str] = None, + *, + terminal_limit: int = 50, +) -> list[dict]: + """Return active runs plus recent terminal snapshots for task syncing. + + ``list_active_pipelines`` intentionally powers the Dashboard's live-run + surface and therefore drops a pipeline at the instant it becomes + terminal. The canonical task registry needs the opposite guarantee: it + must observe that final transition even when no poll happened between the + last running update and completion. Merge sanitized in-memory snapshots + with durable checkpoints so this also works after a Maestro restart or a + failed final checkpoint write. + """ + normalized_workspace = workspace or "default" + active_statuses = {"running", "queued", "paused"} + with _pipeline_lock: + memory = [dict(p) for p in _pipelines.values()] + + by_id: dict[str, dict] = {} + for pipeline in memory: + pipeline_workspace = pipeline.get("workspace") or "default" + status = str(pipeline.get("status") or "").strip().lower() + if pipeline_workspace != normalized_workspace: + continue + if ( + status not in active_statuses + and status not in _RECENT_PIPELINE_TERMINAL_STATUSES + ): + continue + pipeline_type = pipeline.get("pipeline_type") or ( + pipeline.get("params") or {} + ).get("pipeline_type", "") + details = _public_pipeline_generation_details( + pipeline.get("params"), + len(pipeline.get("clip_plans") or []), + ) + pipeline.pop("params", None) + pipeline.pop("_llm_passes", None) + pipeline["pipeline_type"] = pipeline_type + if details: + pipeline["generation_details"] = details + pipeline_id = str(pipeline.get("id") or "") + if pipeline_id: + by_id[pipeline_id] = pipeline + + # Checkpoints cover terminal transitions missed by a poll and survive a + # backend restart. Live memory wins for duplicate IDs because it may be + # newer than the most recent phase-boundary checkpoint. + for pipeline in list_pipeline_states(out_dir, normalized_workspace): + pipeline_id = str(pipeline.get("id") or "") + status = str(pipeline.get("status") or "").strip().lower() + if ( + pipeline_id + and pipeline_id not in by_id + and status in _RECENT_PIPELINE_TERMINAL_STATUSES + ): + by_id[pipeline_id] = pipeline + + active = [ + pipeline for pipeline in by_id.values() + if str(pipeline.get("status") or "").strip().lower() + in active_statuses + ] + terminal = [ + pipeline for pipeline in by_id.values() + if str(pipeline.get("status") or "").strip().lower() + in _RECENT_PIPELINE_TERMINAL_STATUSES + ] + sort_key = lambda item: ( + item.get("completed_at") + or item.get("_completed_at") + or item.get("updated_at") + or item.get("created_at") + or 0 + ) + active.sort(key=sort_key, reverse=True) + terminal.sort(key=sort_key, reverse=True) + return active + terminal[:max(0, int(terminal_limit))] + def get_pipeline_status(pid: str, out_dir: str) -> Optional[dict]: """Return live status or a terminal disk snapshot after a UI reconnect. @@ -4948,6 +5647,7 @@ def get_pipeline_status(pid: str, out_dir: str) -> Optional[dict]: def continue_pipeline(pid: str, updates: Optional[dict] = None): """Resume a paused pipeline, optionally with updated clip_plans.""" + snapshot = None with _pipeline_lock: p = _pipelines.get(pid) if not p or p["status"] != "paused": @@ -4957,6 +5657,9 @@ def continue_pipeline(pid: str, updates: Optional[dict] = None): p["clip_plans"] = updates["clip_plans"] p["status"] = "running" p["pause_reason"] = None + p["updated_at"] = time.time() + snapshot = copy.deepcopy(p) + _notify_pipeline_snapshot(pid, snapshot) return True @@ -6389,6 +7092,10 @@ def _resume_pipeline_reserved(pid: str, out_dir: str) -> tuple[bool, str]: } with _pipeline_lock: _pipelines[pid] = pipeline + # Re-publish rehydrated state before either returning a recovered terminal + # checkpoint or launching the resumed worker. This also restores the + # canonical task IDs needed by nested remote LLM operations. + _notify_pipeline_snapshot(pid) if data.get("status") == "preview_ready" and data.get("preview_clips"): _update_pipeline( @@ -6454,28 +7161,40 @@ def _abort_pipeline_jobs(pid: str): def stop_pipeline(pid: str) -> bool: + snapshot = None with _pipeline_lock: p = _pipelines.get(pid) if not p or p.get("status") in ("completed", "failed", "cancelled"): return False - p["status"] = "cancelled" - p["phase"] = "cancelled" + has_active_work = _pipeline_has_active_work_locked(pid) p["_cancel_requested"] = True p["pause_reason"] = None - p["_completed_at"] = time.time() - p["progress"] = { - "current": 0, - "total": 0, - "message": "Cancelled", - "step": 0, - "total_steps": 0, - } + if has_active_work: + # Keep the root active until its worker and all child leases have + # actually unwound. Canonical observers expose the phase in the + # footer while cancellation proceeds at a safe boundary. + p["status"] = "running" + p["phase"] = "cancelling" + p.pop("_completed_at", None) + p["progress"] = { + "current": 0, + "total": 0, + "message": "Cancelling at a safe boundary…", + "step": 0, + "total_steps": 0, + } + p["updated_at"] = time.time() + snapshot = copy.deepcopy(p) + _notify_pipeline_snapshot(pid, snapshot) _abort_pipeline_jobs(pid) - persisted = _save_pipeline_state(pid) - with _pipeline_lock: - current = _pipelines.get(pid) - if current is not None: - current["_state_persisted"] = persisted + if has_active_work: + persisted = _save_pipeline_state(pid) + with _pipeline_lock: + current = _pipelines.get(pid) + if current is not None: + current["_state_persisted"] = persisted + else: + _acknowledge_pipeline_cancel(pid) return True @@ -6500,12 +7219,27 @@ def _run_pipeline(pid: str, resume: bool = False): generation phase re-runs — so a crash 2 hours into a run doesn't throw away the LLM planning that already succeeded. """ + planning_resource_context = None + parallel_video_thread: Optional[threading.Thread] = None + parallel_image_failed = threading.Event() + parallel_clip_events: list[threading.Event] = [] try: with _pipeline_lock: p = _pipelines.get(pid) - if not p or p.get("status") == "cancelled": + if not p or p.get("_cancel_requested") or p.get("status") == "cancelled": return params = p["params"] + from services.director.spoken_language import ( + append_spoken_language_contract, + extract_spoken_language, + ) + if not params.get("spoken_language"): + params["spoken_language"] = extract_spoken_language( + params.get("scene_description", "") + ) + params["scene_description"] = append_spoken_language_contract( + params.get("scene_description", ""), params.get("spoken_language", ""), + ) pipeline_out_dir = p.get("out_dir") or _wgp.save_path pipeline_workspace = p.get("workspace") direct_video, direct_video_master_prompt = _direct_video_settings(params) @@ -6564,12 +7298,39 @@ def _run_pipeline(pid: str, resume: bool = False): resource_schedule=_resource_schedule_payload(resource_lanes), ) - # Only a local GPU planner must wait for the generation queue. Remote - # providers and a CPU LLM are independent resources and can plan while - # another workflow renders locally. + # A local CUDA planner owns the same physical GPU-0 semaphore as Studio, + # Series, H3, 3D and UniRig. Keep the lease through planning, prompt + # polish and optional vision style detection; polling alone has a race + # where a generation can start between the check and the LLM request. if resource_lanes["planning"].key.startswith("local_gpu:"): - if not _wait_for_gpu(pid): - return # cancelled while waiting + try: + from services import resource_scheduler + except ImportError: # pragma: no cover - package import mode + from app.services import resource_scheduler + + _update_pipeline( + pid, + phase="waiting_resource", + progress={ + "current": 0, + "total": 1, + "message": "Waiting for local GPU 0 for LLM planning…", + "step": 0, + "total_steps": 0, + }, + ) + planning_resource_context = resource_scheduler.coordinator.acquire( + resource_lanes["planning"], + task_id=f"director-planning-{pid}", + description="Local LLM Director planning", + cancelled=lambda: _pipeline_cancel_requested(pid), + ) + try: + planning_resource_context.__enter__() + except resource_scheduler.ResourceAcquireCancelled: + planning_resource_context = None + return + params["_planning_gpu_lease_held"] = True else: print( f"[Pipeline {pid}] Planning on {resource_lanes['planning'].label}; " @@ -6864,6 +7625,10 @@ def _run_pipeline(pid: str, resume: bool = False): # any LLM polish so a rewrite cannot reduce a recognizable set to # a generic room. clip_plans = apply_independent_shot_context(clip_plans) + from services.director.spoken_language import apply_spoken_language_to_plans + apply_spoken_language_to_plans( + clip_plans, params.get("spoken_language", ""), + ) _preflight_h3_director_prompts( params.get("video_model", ""), clip_plans, @@ -6873,7 +7638,7 @@ def _run_pipeline(pid: str, resume: bool = False): _save_pipeline_state(pid) # Save after planning # Check cancellation - if _pipelines[pid]["status"] == "cancelled": + if _pipeline_cancel_requested(pid): return # In non-auto mode, pause for user review after planning @@ -6892,7 +7657,7 @@ def _run_pipeline(pid: str, resume: bool = False): ) _save_pipeline_state(pid) # Save paused state so Dashboard shows it _wait_for_resume(pid) - if _pipelines[pid]["status"] == "cancelled": + if _pipeline_cancel_requested(pid): return # Reload clip_plans in case user edited them clip_plans = _pipelines[pid]["clip_plans"] @@ -7097,11 +7862,13 @@ def _run_pipeline(pid: str, resume: bool = False): llm_service.unload_model() except Exception as e: print(f"[Pipeline] LLM unload warning (non-fatal): {e}") + finally: + if planning_resource_context is not None: + planning_resource_context.__exit__(None, None, None) + planning_resource_context = None + params.pop("_planning_gpu_lease_held", None) - parallel_video_thread: Optional[threading.Thread] = None parallel_video_result: dict = {"outputs": None, "error": None} - parallel_image_failed = threading.Event() - parallel_clip_events: list[threading.Event] = [] parallel_clip_images: list[str] = [] parallel_clip_keyframes: list[list[str]] = [] can_pipeline_remote_images = bool( @@ -7109,6 +7876,7 @@ def _run_pipeline(pid: str, resume: bool = False): "workflow_parallelism_enabled", True ) and auto_mode + and requires_shot_images and not direct_video and not resume_images and not provided_clip_image_paths @@ -7308,7 +8076,7 @@ def _publish_parallel_clip(index: int, image_name: str, keyframes: list[str]) -> _update_pipeline(pid, clip_images=clip_images, _clip_keyframes=clip_keyframes) _save_pipeline_state(pid) # Save after image generation - if _pipelines[pid]["status"] == "cancelled": + if _pipeline_cancel_requested(pid): return if params.get("comic_preflight_only"): @@ -7347,7 +8115,7 @@ def _publish_parallel_clip(index: int, image_name: str, keyframes: list[str]) -> _update_pipeline(pid, status="paused", pause_reason="review_images", progress={"current": 2, "total": 3, "message": "Review images", "step": 0, "total_steps": 0}) _wait_for_resume(pid) - if _pipelines[pid]["status"] == "cancelled": + if _pipeline_cancel_requested(pid): return # Review can be open for hours; a gallery cleanup or manual rename @@ -7391,7 +8159,7 @@ def _publish_parallel_clip(index: int, image_name: str, keyframes: list[str]) -> # A Stop during the video phase lands here after the abort. Record # whatever clips finished (the Dashboard can rerun/rejoin them), # but don't overwrite the cancelled status with "completed". - if _pipelines[pid]["status"] == "cancelled": + if _pipeline_cancel_requested(pid): print(f"[Pipeline {pid}] Cancelled during video generation — keeping {len(output_files or [])} finished clip(s)") artifacts = {"output_files": output_files or []} if not params.get("seamless", True): @@ -7443,19 +8211,6 @@ def _publish_parallel_clip(index: int, image_name: str, keyframes: list[str]) -> _save_pipeline_state(pid) # Save on completion except PipelineCancelled: - _update_pipeline( - pid, - status="cancelled", - phase="cancelled", - _completed_at=time.time(), - progress={ - "current": 0, - "total": 0, - "message": "Cancelled", - "step": 0, - "total_steps": 0, - }, - ) _save_pipeline_state(pid) return except Exception as e: @@ -7555,6 +8310,27 @@ def _publish_parallel_clip(index: int, image_name: str, keyframes: list[str]) -> ) _save_pipeline_state(pid) # Save on failure too finally: + if ( + parallel_video_thread is not None + and parallel_video_thread.is_alive() + ): + parallel_image_failed.set() + for event in parallel_clip_events: + event.set() + with _pipeline_lock: + active_job_id = (_pipelines.get(pid) or {}).get( + "_active_generation_job_id" + ) + if active_job_id and _cancel_generation is not None: + _cancel_generation(active_job_id) + parallel_video_thread.join() + if planning_resource_context is not None: + planning_resource_context.__exit__(None, None, None) + planning_resource_context = None + try: + params.pop("_planning_gpu_lease_held", None) + except Exception: + pass # H3 has two mutually exclusive owners: WGP for native variants and # isolated ComfyUI for Legacy ConvRot. Keep only a queued job that can # reuse the same owner; otherwise release before the next FIFO item. @@ -7618,6 +8394,8 @@ def _publish_parallel_clip(index: int, image_name: str, keyframes: list[str]) -> ) finally: _gen_lock.release() + if _pipeline_cancel_requested(pid): + _acknowledge_pipeline_cancel(pid, force=True) with _pipeline_lock: current = _pipeline_threads.get(pid) if current is threading.current_thread(): @@ -7649,7 +8427,7 @@ def _wait_for_gpu(pid: str, poll_interval: float = 2.0): }) while True: - if _pipelines.get(pid, {}).get("status") == "cancelled": + if _pipeline_cancel_requested(pid): return False # Check if any jobs are currently running @@ -7770,7 +8548,12 @@ def _ensure_llm_loaded(params: dict): # identical request verified fine on a free GPU. Guarded by _gen_lock # so an active generation is never released mid-run; wgp reloads the # gen model transparently on its next job (reload_needed). - if desired_provider == "local" and desired_device == "cuda" and _wgp is not None: + if ( + desired_provider == "local" + and desired_device == "cuda" + and _wgp is not None + and not params.get("_planning_gpu_lease_held") + ): acquired = _gen_lock.acquire(blocking=False) if _gen_lock is not None else True if acquired: try: @@ -7968,7 +8751,9 @@ def _has_visual_references(params: dict) -> bool: def _direct_video_settings(params: dict) -> tuple[bool, str]: """Return the normalized direct-video flag and immutable master prompt.""" - if str(params.get("pipeline_type") or "music_video") != "music_video": + if str(params.get("pipeline_type") or "music_video") not in { + "music_video", "short_film_story", + }: return False, "" try: from services.director.planners.music_video import normalize_music_video_treatment @@ -9499,7 +10284,7 @@ def _gen_image( print(f"[Pipeline {pid}] Adopted establishing image as shared reference: {anchor_file}") for i, plan in enumerate(clip_plans): - if _pipelines[pid]["status"] == "cancelled": + if _pipeline_cancel_requested(pid): return clip_images, clip_keyframes # ── Determine image source: original reference or previous scene's output ── @@ -9570,7 +10355,7 @@ def _gen_image( chain_ref = os.path.join(out_dir, clip_images[-1]) # start from the start image for ki, kf_prompt in enumerate(keyframe_prompts): - if _pipelines[pid]["status"] == "cancelled": + if _pipeline_cancel_requested(pid): break # Ensure kf_prompt is a string (LLM may return dicts or other types) @@ -11371,6 +12156,35 @@ def _h3_apply_identity_contract(prompt: str) -> str: return f"{text} {identity}".strip() +def _h3_apply_portrait_composition_contract(prompt: str, resolution: str) -> str: + """Tell H3 to compose for the actual tall canvas instead of letterboxing.""" + text = str(prompt or "").strip() + try: + width, height = ( + int(value) for value in str(resolution or "").lower().split("x", 1) + ) + except (TypeError, ValueError): + return text + marker = "PORTRAIT COMPOSITION LOCK:" + if height <= width or marker.casefold() in text.casefold(): + return text + contract = ( + f"{marker} Compose natively for the full {width}x{height} vertical portrait " + "canvas. Stage subjects and camera movement for the tall frame; never place " + "a horizontal landscape frame, letterbox bars, rotated image, or sideways " + "composition inside it." + ) + parts = re.split( + r"(?im)^\s*overall_soundscape\s*:", text, maxsplit=1, + ) + if len(parts) == 2: + return ( + f"{parts[0].rstrip()} {contract}\n\n" + f"overall_soundscape: {parts[1].lstrip()}" + ) + return f"{text} {contract}".strip() + + def _h3_authored_segment_windows(prompts: list[str], segment_count: int) -> list[str]: """Split authored windows across H3 segments without replaying whole windows.""" if not prompts: @@ -11444,6 +12258,7 @@ def _minimax_h3_segment_prompt( plan=plan, audio_direction=global_audio_direction, allow_clip_text=allow_clip_text, + audio_source_prompt=plan.get("video_prompt"), ) try: @@ -11717,6 +12532,8 @@ def _run_minimax_h3_story_video( fps = 24 video_model = str(params.get("video_model") or "minimax_h3") direct_video, direct_video_master_prompt = _direct_video_settings(params) + shot_image_policy = _director_effective_shot_image_policy(params) + uses_shot_images = shot_images_required(shot_image_policy) reference_mode = str( video_params.get("h3_reference_mode") or "first_frame" ).strip().lower() @@ -11903,7 +12720,7 @@ def _run_minimax_h3_story_video( raise PipelineCancelled("Director pipeline was cancelled.") if shot_index != current_shot: - if clip_ready_events and shot_index < len(clip_ready_events): + if uses_shot_images and clip_ready_events and shot_index < len(clip_ready_events): _update_pipeline( pid, progress={ @@ -11980,9 +12797,15 @@ def _run_minimax_h3_story_video( continue reuse_prefix = False + render_prompt = ( + prompt if direct_video else _h3_apply_identity_contract(prompt) + ) + render_prompt = _h3_apply_portrait_composition_contract( + render_prompt, resolution, + ) gen_params: dict = { "model_type": video_model, - "prompt": prompt if direct_video else _h3_apply_identity_contract(prompt), + "prompt": render_prompt, "image_mode": 0, "image_prompt_type": "" if direct_video else "S" if segment_start else "", "num_inference_steps": video_params.get("num_inference_steps", 20), @@ -12167,6 +12990,15 @@ def _preflight_h3_director_prompts( if not str(video_model or "").lower().startswith("minimax_h3"): return clip_plans from services.director.h3_dialogue import compile_h3_clip_plans + from services.director.spoken_language import apply_spoken_language_to_plans + + language = "" + for plan in clip_plans: + audio_plan = plan.get("_director_audio_plan") or {} + if isinstance(audio_plan, dict) and audio_plan.get("spoken_language"): + language = audio_plan["spoken_language"] + break + apply_spoken_language_to_plans(clip_plans, language) if ( prompt_modes is None @@ -12227,6 +13059,8 @@ def _run_video_generation(pid: str, params: dict, clip_plans: list[dict], ) _validate_director_models(params, stages=("video",)) video_model = params.get("video_model") or "ltx2_22B_distilled_1_1" + from services.director.spoken_language import apply_spoken_language_to_plans + apply_spoken_language_to_plans(clip_plans, params.get("spoken_language", "")) _preflight_h3_director_prompts(video_model, clip_plans, pid=pid) video_params = params.get("video_params", {}) video_loras = params.get("video_loras", {}) diff --git a/app/services/h3_window_planner.py b/app/services/h3_window_planner.py index fff80bc1..c11c71d6 100644 --- a/app/services/h3_window_planner.py +++ b/app/services/h3_window_planner.py @@ -240,6 +240,12 @@ def compile_h3_window_prompts( f"non_diegetic_music: {music_value}", ] prompt = "\n\n".join(prompt_parts) + # Each sliding-window pass has its own local clock. Rebuild the vocal + # schedule against that exact pass duration so a short line cannot + # leak gibberish into the unused part of the window. + from .minimax_h3_duration import inject_h3_vocal_timeline + + prompt, _ = inject_h3_vocal_timeline(prompt, duration) compiled.append( { **span, diff --git a/app/services/job_lifecycle.py b/app/services/job_lifecycle.py index 3e1e73ab..43104527 100644 --- a/app/services/job_lifecycle.py +++ b/app/services/job_lifecycle.py @@ -36,6 +36,41 @@ deque[tuple[int, object, MutableMapping[str, Any]]], ] = {} _generation_queue_locks: dict[int, Any] = {} +_job_state_observer: Callable[[Mapping[str, Any]], None] | None = None + + +def set_job_state_observer( + observer: Callable[[Mapping[str, Any]], None] | None, +) -> None: + """Register one best-effort observer for externally visible job changes. + + The lifecycle layer stays independent from the canonical task registry. A + host such as ``launch.py`` can subscribe after startup and translate the + already-atomic job snapshot into SSE/task updates. Observer failures never + interfere with generation and callbacks run after lifecycle locks release. + """ + global _job_state_observer + with _lifecycle_lock: + _job_state_observer = observer + + +def _notify_job_state(job: MutableMapping[str, Any]) -> None: + with _lifecycle_lock: + observer = _job_state_observer + snapshot = dict(job) + if isinstance(snapshot.get("output_files"), list): + snapshot["output_files"] = list(snapshot["output_files"]) + if isinstance(snapshot.get("clip_output_files"), dict): + snapshot["clip_output_files"] = dict(snapshot["clip_output_files"]) + if observer is None: + return + try: + observer(snapshot) + except Exception: + # Observability must never turn a successful model transition into a + # failed generation. The periodic canonical reconciler remains the + # recovery path if a registry write is temporarily unavailable. + pass @dataclass(frozen=True) @@ -193,7 +228,9 @@ def _relay() -> None: def is_cancel_requested(job: MutableMapping[str, Any]) -> bool: """Return whether cancellation is durable for ``job``.""" with _lifecycle_lock: - return bool(job.get("cancel_requested")) or job.get("status") == "cancelled" + return bool(job.get("cancel_requested")) or job.get("status") in { + "cancelling", "cancelled", + } def snapshot_job(job: MutableMapping[str, Any]) -> dict[str, Any]: @@ -248,52 +285,63 @@ def record_job_outputs( job["clip_output_files"] = clip_outputs if join_output_file: job["join_output_file"] = join_output_file - return list(merged) + result = list(merged) + _notify_job_state(job) + return result def try_start(job: MutableMapping[str, Any], **updates: Any) -> bool: """Atomically move a queued job to running unless it was cancelled.""" if "status" in updates: raise ValueError("status must be changed through a lifecycle transition") + changed = False + started = False with _lifecycle_lock: if is_cancel_requested(job): - job["status"] = "cancelled" - job["message"] = "Cancelled" - job["finished_at"] = job.get("finished_at") or time.time() - return False - if job.get("status") != "queued": - return False - job.update(updates) - job["started_at"] = job.get("started_at") or time.time() - job["status"] = "running" - return True + changed = _acknowledge_cancel_locked(job) + elif job.get("status") == "queued": + job.update(updates) + job["started_at"] = job.get("started_at") or time.time() + job["status"] = "running" + changed = True + started = True + if changed: + _notify_job_state(job) + return started def try_requeue(job: MutableMapping[str, Any], **updates: Any) -> bool: """Return a multi-phase job to queued unless cancellation won first.""" if "status" in updates: raise ValueError("status must be changed through a lifecycle transition") + changed = False + requeued = False with _lifecycle_lock: if is_cancel_requested(job): - job["status"] = "cancelled" - job["message"] = "Cancelled" - return False - if job.get("status") != "running": - return False - job.update(updates) - job["status"] = "queued" - return True + changed = _acknowledge_cancel_locked(job) + elif job.get("status") == "running": + job.update(updates) + job["status"] = "queued" + changed = True + requeued = True + if changed: + _notify_job_state(job) + return requeued def update_job(job: MutableMapping[str, Any], **updates: Any) -> bool: """Update a live job without replacing a terminal/cancelled message.""" if "status" in updates: raise ValueError("status must be changed through a lifecycle transition") + updated = False with _lifecycle_lock: if is_cancel_requested(job) or job.get("status") != "running": return False job.update(updates) - return True + updated = True + if updated: + _notify_job_state(job) + return updated def register_abort_state( @@ -325,16 +373,83 @@ def unregister_abort_state( active_states: MutableMapping[str, MutableMapping[str, Any]], state: MutableMapping[str, Any] | None = None, ) -> None: - """Remove only the abort state owned by the finishing worker.""" + """Remove only the abort state owned by the finishing worker. + + Releasing the matching state is also the worker's cancellation + acknowledgement. A stale worker must never settle a newer phase, so the + terminal transition requires ownership of both the public active state and + the private registration. + """ + acknowledged_job: MutableMapping[str, Any] | None = None with _lifecycle_lock: current = active_states.get(job_id) - if current is not None and (state is None or current is state): + owns_active_state = current is not None and ( + state is None or current is state + ) + if owns_active_state: active_states.pop(job_id, None) registration = _registrations.get(job_id) - if registration is not None and ( + owns_registration = registration is not None and ( state is None or registration[1] is state - ): + ) + if owns_registration: _registrations.pop(job_id, None) + if ( + owns_active_state + and owns_registration + and registration is not None + and current is registration[1] + and _acknowledge_cancel_locked(registration[0]) + ): + acknowledged_job = registration[0] + if acknowledged_job is not None: + _notify_job_state(acknowledged_job) + + +def _acknowledge_cancel_locked( + job: MutableMapping[str, Any], + updates: Mapping[str, Any] | None = None, +) -> bool: + """Settle a requested cancellation while ``_lifecycle_lock`` is held.""" + if job.get("status") in TERMINAL_STATUSES or not is_cancel_requested(job): + return False + if updates: + job.update(updates) + # Cancellation is absorbing. Neutral settlement metadata is allowed, but + # no caller can turn an acknowledged cancellation into completed/failed or + # replace the stable terminal message. + job["cancel_requested"] = True + job["status"] = "cancelled" + job["phase"] = "cancelled" + job["message"] = "Cancelled" + job["finished_at"] = job.get("finished_at") or time.time() + return True + + +def _has_active_registration_locked( + job: MutableMapping[str, Any], +) -> bool: + """Return whether a worker still owns a registered abort state.""" + return any(registration[0] is job for registration in _registrations.values()) + + +def acknowledge_cancel( + job: MutableMapping[str, Any], + **updates: Any, +) -> bool: + """Acknowledge that a cancelling worker has stopped and released work. + + This is the explicit settlement path for workers that do not own an abort + state and exit without a normal ``finish_job`` call. It is idempotent and + refuses to cancel a job unless a durable cancellation request already won. + """ + if "status" in updates: + raise ValueError("status must be changed through a lifecycle transition") + with _lifecycle_lock: + changed = _acknowledge_cancel_locked(job, updates) + if changed: + _notify_job_state(job) + return changed def request_cancel( @@ -344,14 +459,16 @@ def request_cancel( active_states: MutableMapping[str, MutableMapping[str, Any]] | None = None, ) -> CancelResult: """Atomically request cancellation and signal the matching active state.""" + result: CancelResult with _lifecycle_lock: status = job.get("status") if status in TERMINAL_STATUSES: return CancelResult(False, False, False) + if status == "cancelling": + return CancelResult(False, False, False) was_running = status == "running" job["cancel_requested"] = True - job["message"] = "Cancelled" abort_signalled = False state = active_states.get(job_id) if active_states is not None and job_id else None @@ -371,10 +488,22 @@ def request_cancel( except Exception: pass - job["status"] = "cancelled" - job["finished_at"] = job.get("finished_at") or time.time() + if was_running: + # The request is durable and inference has been signalled, but the + # worker still owns its resource until finish/unregister/explicit + # acknowledgement. Do not publish a terminal timestamp early. + job["status"] = "cancelling" + job["phase"] = "cancelling" + job["message"] = "Cancelling…" + job["finished_at"] = None + else: + # Queued/waiting jobs own no active model invocation and can settle + # synchronously without a worker acknowledgement. + _acknowledge_cancel_locked(job) - return CancelResult(True, was_running, abort_signalled) + result = CancelResult(True, was_running, abort_signalled) + _notify_job_state(job) + return result def finish_job( @@ -387,18 +516,26 @@ def finish_job( raise ValueError(f"Invalid terminal job status: {status}") if "status" in updates: raise ValueError("status must be changed through a lifecycle transition") + changed = False + published = False with _lifecycle_lock: if is_cancel_requested(job): - job["status"] = "cancelled" - job["message"] = "Cancelled" + # A late completed/failed result is only an acknowledgement that + # the cancelling worker reached its terminal boundary. If it still + # owns an abort-state registration, keep `cancelling` until + # unregister_abort_state confirms the worker has released it. + # Workers without a registration settle directly here. + if not _has_active_registration_locked(job): + changed = _acknowledge_cancel_locked(job) + elif job.get("status") == "running": + job.update(updates) job["finished_at"] = job.get("finished_at") or time.time() - return False - if job.get("status") != "running": - return False - job.update(updates) - job["finished_at"] = job.get("finished_at") or time.time() - job["status"] = status - return True + job["status"] = status + changed = True + published = True + if changed: + _notify_job_state(job) + return published def register_generation_job( diff --git a/app/services/llm_service.py b/app/services/llm_service.py index c7b4f6ae..c48540d1 100644 --- a/app/services/llm_service.py +++ b/app/services/llm_service.py @@ -14,6 +14,8 @@ import threading import logging import requests +from contextlib import contextmanager +from functools import wraps from typing import Optional from . import debug_trace from .debug_trace import trace_llm_call @@ -22,7 +24,7 @@ # Singleton state _process: Optional[subprocess.Popen] = None -_lock = threading.Lock() +_lock = threading.RLock() _model_id: str = "" _device: str = "" _server_port: int = 0 @@ -1286,6 +1288,45 @@ def _get_server_exe() -> str: return os.path.join(bin_dir, "llama-server") +def _scheduled_llm_load(function): + """Serialize local model loading on the same lane used for inference.""" + @wraps(function) + def wrapped(*args, **kwargs): + model_id = kwargs.get("model_id", args[0] if len(args) > 0 else "") + device = kwargs.get("device", args[1] if len(args) > 1 else "cpu") + provider = kwargs.get("provider", args[3] if len(args) > 3 else "local") + remote_url = kwargs.get("remote_url", args[4] if len(args) > 4 else "") + if str(provider or "local").lower() in { + "remote", "openai", "anthropic", "minimax", + }: + return function(*args, **kwargs) + from . import resource_scheduler + lane = resource_scheduler.llm_lane( + str(provider or "local"), + base_url=str(remote_url or ""), + device=str(device or "cpu"), + ) + task_id = f"llm-load-{threading.get_ident()}-{time.time_ns()}" + cancelled = _current_task_cancel_callback() + try: + with resource_scheduler.coordinator.acquire( + lane, + task_id=task_id, + description=f"Local LLM load · {model_id or 'default'}", + cancelled=cancelled, + ): + with _cancelable_singleton_lock( + cancelled, + task_id=task_id, + operation="loading the local LLM", + ): + return function(*args, **kwargs) + except resource_scheduler.ResourceAcquireCancelled as exc: + raise InterruptedError(str(exc)) from exc + return wrapped + + +@_scheduled_llm_load def load_model( model_id: str = "", device: str = "cpu", @@ -1326,11 +1367,12 @@ def load_model( return repo_id = model_id or DEFAULT_HF_REPO - _provider = "local" - _remote_url = "" - _api_key = "" - with _lock: + # Keep every singleton routing mutation under the same lock used by + # scheduled requests when they validate their routing snapshot. + _provider = "local" + _remote_url = "" + _api_key = "" if is_loaded() and _model_id == repo_id and not force_reload: return @@ -1709,7 +1751,135 @@ def _image_to_data_url(image_path: str, max_size: int = 768) -> Optional[str]: return f"data:{mime};base64,{data}" +def _current_task_cancel_callback(): + """Return a cheap cancellation probe for the active canonical task.""" + try: + from .task_manager import current_task_context, get_task_registry + context = current_task_context() + parent_task_id = str(context.get("task_id") or "") + workspace_dir = str(context.get("workspace_dir") or "") + if not parent_task_id or not workspace_dir: + return None + operation_registry = get_task_registry(workspace_dir) + except Exception: + return None + + def cancelled() -> bool: + try: + parent = operation_registry.get(parent_task_id) + except Exception: + return False + return bool( + parent and ( + parent.get("status") in {"cancelled", "interrupted"} + or parent.get("phase") == "cancelling" + ) + ) + + return cancelled + + +@contextmanager +def _cancelable_singleton_lock( + cancelled=None, + *, + task_id: str = "llm", + operation: str = "using the LLM", +): + """Acquire the mutable singleton lock without making cancel wait forever.""" + acquired = False + try: + if cancelled is None: + _lock.acquire() + acquired = True + else: + while True: + if cancelled(): + from .resource_scheduler import ResourceAcquireCancelled + raise ResourceAcquireCancelled( + f"Task {task_id} was cancelled while {operation}" + ) + if _lock.acquire(timeout=0.1): + acquired = True + break + yield + finally: + if acquired: + _lock.release() + + +def _singleton_routing_snapshot(cancelled=None) -> tuple: + """Read all globals a singleton completion uses while holding its lock.""" + with _cancelable_singleton_lock( + cancelled, + operation="waiting for the LLM configuration", + ): + return ( + _provider, + _remote_url, + _model_id, + _device, + _api_key, + _vision_available, + ) + + +def _scheduled_llm_request(function): + """Acquire the concrete local/remote LLM lane for singleton requests.""" + @wraps(function) + def wrapped(*args, **kwargs): + from . import resource_scheduler + cancelled = _current_task_cancel_callback() + try: + while True: + routing = _singleton_routing_snapshot(cancelled) + provider, remote_url, model_id, device, _key, _vision = routing + provider = str(provider or "local") + lane = resource_scheduler.llm_lane( + provider, + base_url=(remote_url if provider != "local" else ""), + device=device or "cpu", + ) + task_id = f"llm-{threading.get_ident()}-{time.time_ns()}" + # The GPU hand-off hook identifies an intentional CUDA LLM + # owner by this prefix. A generic label made it unload the + # very model this request was about to use. + owner_label = ( + "Local LLM" if provider == "local" else "Remote LLM" + ) + with resource_scheduler.coordinator.acquire( + lane, + task_id=task_id, + description=f"{owner_label} completion · {model_id or 'default'}", + cancelled=cancelled, + ): + # The provider may have changed while this request waited + # for its physical lane. Revalidate under the singleton + # lock; if it moved, release the stale lane and retry. The + # lock then prevents load/unload from mutating routing or + # killing llama-server during the active HTTP request. + with _cancelable_singleton_lock( + cancelled, + task_id=task_id, + operation="waiting to start the LLM request", + ): + if routing != ( + _provider, + _remote_url, + _model_id, + _device, + _api_key, + _vision_available, + ): + continue + return function(*args, **kwargs) + except resource_scheduler.ResourceAcquireCancelled as exc: + raise InterruptedError(str(exc)) from exc + return wrapped + + @trace_llm_call("generate", context=lambda: {"provider": _provider, "model_id": _model_id}) +@_scheduled_llm_request def generate( prompt: str, system_prompt: str = "", @@ -2013,6 +2183,64 @@ def generate_openai_compatible( attempts = 2 if is_minimax or (json_schema is not None and is_deepseek) else 1 from . import resource_scheduler request_lane = resource_scheduler.remote_lane(model_id, base_url) + operation_registry = None + operation_id = "" + parent_task_id = "" + try: + from .task_manager import current_task_context, get_task_registry, new_task_id + task_context = current_task_context() + parent_task_id = task_context.get("task_id", "") + workspace_dir = task_context.get("workspace_dir", "") + if parent_task_id and workspace_dir: + operation_registry = get_task_registry(workspace_dir) + parent_task = operation_registry.get(parent_task_id) + if parent_task: + operation_id = new_task_id("llm-call") + operation_registry.create( + id=operation_id, root_id=parent_task["root_id"], parent_id=parent_task_id, + workspace=parent_task.get("workspace") or "default", + kind="llm-call", workflow=parent_task.get("workflow") or "llm", + title=f"{model_id} completion", status="waiting_resource", + phase="waiting_resource", message=f"Waiting to call {provider_name}", + provider=provider_name, model=model_id, server_origin=base_url, + resource_requirements=[request_lane.key], attempt=1, max_attempts=attempts, + cancelable=False, recoverable=False, + metadata={"operation": "generate_openai_compatible"}, + ) + except Exception as exc: + logger.debug("Could not publish LLM child operation: %s", exc) + + def finish_operation(status: str, message: str, usage: Optional[dict] = None, error: str = "") -> None: + if not operation_registry or not operation_id: + return + normalized = usage or {} + prompt_count = int(normalized.get("prompt_tokens", normalized.get("input_tokens", 0)) or 0) + completion_count = int(normalized.get("completion_tokens", normalized.get("output_tokens", 0)) or 0) + total_count = int(normalized.get("total_tokens") or prompt_count + completion_count) + try: + operation_registry.update( + operation_id, status=status, phase=status, message=message, + token_usage={"prompt": prompt_count, "completion": completion_count, + "total": total_count, "calls": 1}, + error=({"message": error, "retryable": True} if error else None), + event_type=f"operation.{status}", force=True, + ) + parent = operation_registry.get(parent_task_id) + if parent and total_count: + previous = parent.get("token_usage") or {} + operation_registry.update( + parent_task_id, + token_usage={ + "prompt": int(previous.get("prompt") or 0) + prompt_count, + "completion": int(previous.get("completion") or 0) + completion_count, + "total": int(previous.get("total") or 0) + total_count, + "calls": int(previous.get("calls") or 0) + 1, + }, + event_type="task.tokens", + ) + except Exception as exc: + logger.debug("Could not finish LLM child operation: %s", exc) + for content_attempt in range(attempts): request_payload = dict(payload) if is_minimax and content_attempt > 0: @@ -2022,11 +2250,31 @@ def generate_openai_compatible( ) try: task_id = f"llm-{threading.get_ident()}-{time.time_ns()}" + + def request_cancelled() -> bool: + if not operation_registry: + return False + operation = operation_registry.get(operation_id) if operation_id else None + parent = operation_registry.get(parent_task_id) if parent_task_id else None + return any( + task and task.get("status") in {"cancelled", "interrupted"} + for task in (operation, parent) + ) + with resource_scheduler.coordinator.acquire( request_lane, task_id=task_id, description=f"{model_id} completion", + cancelled=request_cancelled, ): + if operation_registry and operation_id: + operation_registry.update( + operation_id, status="running", phase="requesting", + message=f"Calling {provider_name} · attempt {content_attempt + 1}/{attempts}", + attempt=content_attempt + 1, + acquired_resources=[request_lane.key], + event_type="operation.started", force=True, + ) response = requests.post( endpoint, json=request_payload, headers=headers, timeout=(10, 600), ) @@ -2040,11 +2288,15 @@ def generate_openai_compatible( endpoint, json=fallback_payload, headers=headers, timeout=(10, 600), ) response.raise_for_status() + except resource_scheduler.ResourceAcquireCancelled as exc: + finish_operation("cancelled", "Provider call cancelled before it started") + raise InterruptedError(str(exc)) from exc except requests.exceptions.RequestException as exc: detail = "" if getattr(exc, "response", None) is not None: detail = str(exc.response.text or "")[:500] suffix = f": {detail}" if detail else "" + finish_operation("failed", "Provider request failed", error=f"OpenAI-compatible request failed{suffix}") raise RuntimeError(f"OpenAI-compatible request failed{suffix}") from exc try: @@ -2052,14 +2304,18 @@ def generate_openai_compatible( base_response = response_data.get("base_resp") or {} if base_response.get("status_code") not in (None, 0): detail = str(base_response.get("status_msg") or "MiniMax returned an error") + finish_operation("failed", "Provider returned an error", error=detail) raise RuntimeError(detail) choice = response_data["choices"][0] message = choice["message"] content = _strip_thinking_tags(str(message.get("content") or "")).strip() except (KeyError, IndexError, TypeError, ValueError) as exc: + finish_operation("failed", "Provider response was invalid", error="OpenAI-compatible provider returned an invalid response") raise RuntimeError("OpenAI-compatible provider returned an invalid response") from exc if content: - _record_activity_usage(response_data.get("usage") or {}) + response_usage = response_data.get("usage") or {} + _record_activity_usage(response_usage) + finish_operation("completed", "Provider response received and parsed", response_usage) return content usage = response_data.get("usage") or {} token_details = usage.get("completion_tokens_details") or {} @@ -2075,6 +2331,7 @@ def generate_openai_compatible( ) if content_attempt + 1 < attempts: time.sleep(0.4) + finish_operation("failed", "Provider returned empty content", error=f"{provider_name} returned empty content") raise RuntimeError( f"{provider_name} returned empty content after {attempts} " f"{'attempts' if attempts != 1 else 'attempt'}" @@ -2088,6 +2345,7 @@ def get_stream_status() -> dict: @trace_llm_call("generate_streaming", context=lambda: {"provider": _provider, "model_id": _model_id}) +@_scheduled_llm_request def generate_streaming( prompt: str, system_prompt: str = "", diff --git a/app/services/minimax_h3_duration.py b/app/services/minimax_h3_duration.py new file mode 100644 index 00000000..78342caf --- /dev/null +++ b/app/services/minimax_h3_duration.py @@ -0,0 +1,460 @@ +"""Mandatory dialogue-to-duration contract for every MiniMax H3 clip.""" + +from __future__ import annotations + +import math +import re +from typing import Any, Mapping, MutableMapping + + +_DIALOGUE_BLOCK = re.compile( + r"\s*\[([^\]\r\n]+)\]\s*(.*?)\s*", + flags=re.IGNORECASE | re.DOTALL, +) +_PLAIN_SPEECH = re.compile( + r"\b(?:says?|asks?|shouts?|whispers?|replies|dice|pregunta|grita|susurra|responde)" + r"(?:\s+exactly)?\s*[:,]?\s*[\"“«](.*?)[\"”»]", + flags=re.IGNORECASE | re.DOTALL, +) +_VOCAL_TIMELINE_BLOCK = re.compile( + r"\s*VOCAL TIMELINE LOCK:\s*.*?" + r"(?=\s+(?:overall_soundscape|non_diegetic_music)\s*:|$)", + flags=re.IGNORECASE | re.DOTALL, +) +_SOUND_FIELD = re.compile( + r"\b(?:overall_soundscape|non_diegetic_music)\s*:", + flags=re.IGNORECASE, +) +_WORD = re.compile(r"[^\W_]+(?:[’'-][^\W_]+)*", flags=re.UNICODE) +_SPANISH_LANGUAGES = {"castilian", "es", "es-es", "español", "spanish"} +_SPANISH_VOWEL_RUN = re.compile(r"[aeiouáéíóúü]+", flags=re.IGNORECASE) +_GENERIC_VOWEL_RUN = re.compile( + r"[aeiouyáéíóúüàèìòùâêîôûäëïöü]+", + flags=re.IGNORECASE, +) +_SPANISH_STRONG_VOWELS = frozenset("aeoáéóíú") +_SPANISH_STRESSED_WEAK_VOWELS = frozenset("íú") +DEFAULT_SECONDS_PER_SYLLABLE = 0.22 + + +def _h3_timestamp(seconds: float) -> str: + milliseconds = max(0, int(round(float(seconds or 0.0) * 1000))) + minutes, remainder = divmod(milliseconds, 60_000) + whole_seconds, millis = divmod(remainder, 1000) + return f"{minutes:02d}:{whole_seconds:02d}.{millis:03d}" + + +def _ordinal_line(index: int) -> str: + words = ( + "first", "second", "third", "fourth", "fifth", "sixth", + "seventh", "eighth", "ninth", "tenth", + ) + return words[index] if index < len(words) else f"number {index + 1}" + + +def _spanish_word_syllables(word: str) -> int: + normalized = word.casefold() + # In que/qui and gue/gui, an unmarked "u" is orthographic rather than + # spoken. The diaeresis in güe/güi deliberately remains vocalic. + normalized = re.sub(r"(?<=[gq])u(?=[eiéí])", "", normalized) + runs = _SPANISH_VOWEL_RUN.findall(normalized) + if not runs: + return 1 + + count = 0 + for run in runs: + count += 1 + for left, right in zip(run, run[1:]): + hiatus = ( + left in _SPANISH_STRESSED_WEAK_VOWELS + or right in _SPANISH_STRESSED_WEAK_VOWELS + or ( + left in _SPANISH_STRONG_VOWELS + and right in _SPANISH_STRONG_VOWELS + ) + ) + if hiatus: + count += 1 + return count + + +def count_spoken_syllables(text: Any, language: Any = "") -> int: + """Count spoken syllables, with Castilian-aware diphthong handling.""" + + words = _WORD.findall(str(text or "")) + language_key = str(language or "").strip().casefold() + is_spanish = ( + language_key in _SPANISH_LANGUAGES + or language_key.startswith("es-") + or "spanish" in language_key + or "español" in language_key + or "castilian" in language_key + ) + if is_spanish: + return sum(_spanish_word_syllables(word) for word in words) + + # Other H3 languages use a conservative vowel-nucleus fallback. Keeping + # this centralized means a language-specific counter can replace it later + # without allowing any generation path to bypass the duration contract. + return sum(max(1, len(_GENERIC_VOWEL_RUN.findall(word))) for word in words) + + +def extract_h3_dialogue(prompt: Any) -> list[dict[str, str]]: + """Return authored spoken segments without treating visible quoted text as speech.""" + + text = str(prompt or "") + tagged = [ + {"language": match.group(1).strip(), "text": match.group(2).strip()} + for match in _DIALOGUE_BLOCK.finditer(text) + if match.group(2).strip() + ] + if tagged: + return tagged + return [ + {"language": "", "text": match.group(1).strip()} + for match in _PLAIN_SPEECH.finditer(text) + if match.group(1).strip() + ] + + +def estimate_h3_dialogue_seconds( + segments: list[Mapping[str, str]], + *, + seconds_per_syllable: float = DEFAULT_SECONDS_PER_SYLLABLE, +) -> dict[str, float | int]: + """Estimate speech from syllables, plus authored pauses and small edge room.""" + + cleaned = [ + segment + for segment in segments + if str(segment.get("text") or "").strip() + ] + texts = [str(segment.get("text") or "").strip() for segment in cleaned] + word_count = sum(len(_WORD.findall(text)) for text in texts) + syllable_count = sum( + count_spoken_syllables( + segment.get("text"), + segment.get("language"), + ) + for segment in cleaned + ) + comma_pauses = sum(len(re.findall(r"[,;:]", text)) for text in texts) * 0.12 + terminal_pauses = sum(len(re.findall(r"(? dict[str, Any]: + """Allocate every H3 second to authored speech or explicit silence. + + H3's native minimum can leave several seconds around a short line. A + generic "do not improvise" instruction does not tell the audiovisual model + what occupies that time, so it may extend the vocal texture with invented + syllables. This deterministic schedule gives each authored line a bounded + local interval and assigns all remaining time to closed-mouth action, + ambience, and physical effects. + """ + + duration = round(max(0.0, float(duration_seconds or 0.0)), 3) + cleaned = [ + { + "language": str(segment.get("language") or "").strip(), + "text": str(segment.get("text") or "").strip(), + } + for segment in segments + if str(segment.get("text") or "").strip() + ] + if duration <= 0: + return { + "duration_seconds": duration, + "segment_count": len(cleaned), + "estimated_dialogue_seconds": 0.0, + "intervals": [], + "text": "", + } + + end_stamp = _h3_timestamp(duration) + if not cleaned: + if mapped_driving_audio: + text = ( + f"From 00:00.000 to {end_stamp}, audible voice or vocals come " + "only from the mapped driving audio and remain synchronized to " + "it; H3 generates no additional dialogue, muttering, gibberish, " + "or speech-like vocalization." + ) + kind = "mapped_audio" + else: + text = ( + f"From 00:00.000 to {end_stamp}, all characters remain silent " + "with mouths closed while only the described ambience, physical " + "actions, and non-verbal sounds continue; no voice, muttering, " + "gibberish, or speech-like vocalization occurs." + ) + kind = "silence" + return { + "duration_seconds": duration, + "segment_count": 0, + "estimated_dialogue_seconds": 0.0, + "intervals": [{ + "kind": kind, + "start_seconds": 0.0, + "end_seconds": duration, + }], + "text": text, + } + + combined = estimate_h3_dialogue_seconds(cleaned) + estimated = min(duration, float(combined["estimated_seconds"])) + free_seconds = max(0.0, duration - estimated) + if free_seconds >= 1.0: + lead_silence = min(1.25, max(0.45, free_seconds * 0.35)) + else: + lead_silence = free_seconds * 0.35 + tail_silence = max(0.0, free_seconds - lead_silence) + + gap_count = max(0, len(cleaned) - 1) + interline_gap = min(0.15, estimated * 0.08 / max(1, gap_count)) if gap_count else 0.0 + dialogue_budget = max(0.0, estimated - interline_gap * gap_count) + weights = [ + max( + 0.25, + float(estimate_h3_dialogue_seconds([segment])["estimated_seconds"]), + ) + for segment in cleaned + ] + weight_total = sum(weights) or float(len(weights)) + line_durations = [dialogue_budget * weight / weight_total for weight in weights] + + intervals: list[dict[str, Any]] = [] + sentences: list[str] = [] + cursor = 0.0 + + def add_silence(start: float, end: float) -> None: + if end - start < 0.025: + return + start = round(start, 3) + end = round(end, 3) + intervals.append({ + "kind": "silence", + "start_seconds": start, + "end_seconds": end, + }) + sentences.append( + f"From {_h3_timestamp(start)} to {_h3_timestamp(end)}, all " + "characters remain silent with mouths closed while only the " + "described ambience, physical actions, and non-verbal sounds continue." + ) + + add_silence(cursor, lead_silence) + cursor = lead_silence + for index, line_duration in enumerate(line_durations): + start = round(cursor, 3) + end = round(min(duration, cursor + line_duration), 3) + intervals.append({ + "kind": "dialogue", + "line": index + 1, + "start_seconds": start, + "end_seconds": end, + }) + sentences.append( + f"From {_h3_timestamp(start)} to {_h3_timestamp(end)}, the " + f"{_ordinal_line(index)} tagged line is spoken exactly once by its " + "assigned speaker; every other character remains silent with their " + "mouth closed." + ) + cursor = end + if index < len(line_durations) - 1: + next_start = min(duration, cursor + interline_gap) + add_silence(cursor, next_start) + cursor = next_start + + # Use the exact physical clip boundary for the final closed-mouth interval; + # rounding the per-line schedule must never leave an undescribed tail. + add_silence(cursor, duration) + sentences.append( + "Outside the assigned dialogue intervals, no tagged line starts, " + "continues, repeats, or is replaced by other speech, muttering, " + "gibberish, background voices, or speech-like vocalization." + ) + return { + "duration_seconds": duration, + "segment_count": len(cleaned), + "estimated_dialogue_seconds": round(estimated, 3), + "leading_silence_seconds": round(lead_silence, 3), + "trailing_silence_seconds": round(tail_silence, 3), + "intervals": intervals, + "text": " ".join(sentences), + } + + +def inject_h3_vocal_timeline(prompt: Any, duration_seconds: float) -> tuple[str, dict[str, Any]]: + """Insert one idempotent vocal schedule before H3's sound fields.""" + + source = str(prompt or "").strip() + if not source or float(duration_seconds or 0.0) <= 0: + return source, {} + clean = _VOCAL_TIMELINE_BLOCK.sub(" ", source).strip() + segments = extract_h3_dialogue(clean) + mapped_driving_audio = not segments and "mapped driving audio" in clean.casefold() + explicit_silence = bool(re.search( + r"\b(?:no (?:one|character) speaks|all (?:visible )?(?:people|characters) " + r"remain silent|everyone remains silent)\b", + clean, + flags=re.IGNORECASE, + )) + # Do not reinterpret an unstructured Studio prompt such as "a woman + # sings" as silence merely because it contains no canonical block. + # Director and the window planner already emit an explicit silence + # contract whenever no vocal performance is authored. + if not segments and not mapped_driving_audio and not explicit_silence: + return clean, {} + timeline = plan_h3_vocal_timeline( + segments, + duration_seconds, + mapped_driving_audio=mapped_driving_audio, + ) + statement = f"VOCAL TIMELINE LOCK: {timeline['text']}" + boundary = _SOUND_FIELD.search(clean) + if boundary: + updated = ( + f"{clean[:boundary.start()].rstrip()} {statement}\n\n" + f"{clean[boundary.start():].lstrip()}" + ) + else: + updated = f"{clean} {statement}" + return re.sub(r"[ \t]+", " ", updated).strip(), timeline + + +def apply_h3_vocal_timeline( + params: MutableMapping[str, Any], + model_def: Mapping[str, Any] | None = None, +) -> dict[str, Any] | None: + """Bind a generation job's final prompt to its effective H3 duration.""" + + definition = model_def if isinstance(model_def, Mapping) else {} + fps = float(definition.get("fps") or 24.0) + try: + duration = float( + params.get("duration_seconds") + or params.get("_duration_seconds") + or float(params.get("video_length") or 0) / max(0.1, fps) + ) + except (TypeError, ValueError): + return None + if duration <= 0 or not str(params.get("prompt") or "").strip(): + return None + prompt, contract = inject_h3_vocal_timeline(params.get("prompt"), duration) + params["prompt"] = prompt + if contract: + params["_h3_vocal_timeline_contract"] = contract + return contract + params.pop("_h3_vocal_timeline_contract", None) + return None + + +def _positive_int(value: Any, fallback: int) -> int: + try: + parsed = int(value) + except (TypeError, ValueError): + return fallback + return parsed if parsed > 0 else fallback + + +def _align_up(frames: int, modulus: int, remainder: int) -> int: + if modulus <= 1: + return frames + if frames <= remainder: + return remainder + return remainder + math.ceil((frames - remainder) / modulus) * modulus + + +def apply_h3_dialogue_duration( + params: MutableMapping[str, Any], + model_def: Mapping[str, Any] | None = None, +) -> dict[str, Any] | None: + """Replace an H3 job's clip length with its calculated dialogue length. + + The H3 frame lattice and minimum are physical model constraints. The + contract therefore rounds upward and records when that minimum leaves + unavoidable edge room, instead of pretending an unsupported duration was + requested. + """ + + existing = params.get("_h3_dialogue_duration_contract") + if ( + isinstance(existing, dict) + and existing.get("effective_frames") == params.get("video_length") + ): + return existing + + segments = extract_h3_dialogue(params.get("prompt")) + if not segments: + params.pop("_h3_dialogue_duration_contract", None) + return None + + definition = model_def if isinstance(model_def, Mapping) else {} + fps = float(definition.get("fps") or 24.0) + minimum = _positive_int(definition.get("frames_minimum"), 124) + maximum = _positive_int(definition.get("frames_maximum"), 345) + modulus = _positive_int( + definition.get("frame_alignment_modulus") or definition.get("frames_steps"), + 17, + ) + try: + remainder = int(definition.get("frame_alignment_remainder", 5)) + except (TypeError, ValueError): + remainder = 5 + + estimate = estimate_h3_dialogue_seconds(segments) + raw_frames = max(1, math.ceil(float(estimate["estimated_seconds"]) * fps)) + aligned_frames = _align_up(raw_frames, modulus, remainder) + effective_frames = max(minimum, min(maximum, aligned_frames)) + effective_seconds = round(effective_frames / fps, 3) + requested_before = params.get("video_length") + overflow = aligned_frames > maximum + minimum_limited = aligned_frames < minimum + + contract: dict[str, Any] = { + **estimate, + "fps": fps, + "requested_frames_before": requested_before, + "calculated_frames": aligned_frames, + "effective_frames": effective_frames, + "effective_seconds": effective_seconds, + "minimum_limited": minimum_limited, + "requires_split": overflow, + "model_minimum_frames": minimum, + "model_maximum_frames": maximum, + "frame_lattice": f"{modulus}n+{remainder}", + } + params["video_length"] = effective_frames + params["duration_seconds"] = effective_seconds + params["_duration_seconds"] = effective_seconds + params["_h3_dialogue_duration_contract"] = contract + return contract + + +def h3_dialogue_split_error(contract: Mapping[str, Any]) -> str: + return ( + f"MiniMax H3 dialogue needs about {contract.get('estimated_seconds')} seconds " + f"for {contract.get('syllable_count')} syllables, beyond this model's " + f"{contract.get('effective_seconds')}-second single-clip limit. " + "Split the dialogue across multiple clips; it will not be truncated." + ) diff --git a/app/services/minimax_h3_service.py b/app/services/minimax_h3_service.py index 76027336..b6153d6f 100644 --- a/app/services/minimax_h3_service.py +++ b/app/services/minimax_h3_service.py @@ -116,6 +116,20 @@ } +def _local_http_request(method: str, url: str, **kwargs) -> requests.Response: + """Call the local H3 sidecar without consulting ambient proxy state. + + The ComfyUI runtime always binds to loopback. Letting Requests merge + process-wide proxy settings is unnecessary and can also fail when another + library temporarily exposes malformed proxy state while a long Director + batch is running. A short-lived session keeps the request isolated and + the response body is already buffered before the session closes. + """ + with requests.Session() as session: + session.trust_env = False + return session.request(method, url, **kwargs) + + def prepare_extend_anchor( params: dict, job_id: str, @@ -462,7 +476,7 @@ def ensure_runtime( if _process.poll() is not None: raise RuntimeError(f"MiniMax H3 runtime exited with code {_process.returncode}") try: - if requests.get(f"{base_url}/system_stats", timeout=2).ok: + if _local_http_request("GET", f"{base_url}/system_stats", timeout=2).ok: return base_url except requests.RequestException: pass @@ -544,7 +558,7 @@ def cancel() -> None: if _port is None: return try: - requests.post(f"http://127.0.0.1:{_port}/interrupt", timeout=3) + _local_http_request("POST", f"http://127.0.0.1:{_port}/interrupt", timeout=3) except requests.RequestException: pass @@ -746,10 +760,17 @@ def build_workflow(params: dict, job_id: str) -> tuple[dict, str]: from .director.minimax_h3_prompting import format_minimax_h3_prompt except ImportError: from services.director.minimax_h3_prompting import format_minimax_h3_prompt + prompt_mode = ( + "references" + if pipeline == "ref2va" + else "first_frame" + if params.get("image_start") + else "direct" + ) prompt = format_minimax_h3_prompt( {}, raw_prompt, - reference_mode="references" if pipeline == "ref2va" else "first_frame", + reference_mode=prompt_mode, audio_direction=audio_direction, ) copy_index = 0 @@ -863,7 +884,8 @@ def _generate_impl(params: dict, job_id: str, out_dir: str, progress: Callable[[ ) progress("Loading MiniMax H3 and generating native video + stereo audio…", 10, 0, 0) client_id = f"maestro-{job_id}" - response = requests.post( + response = _local_http_request( + "POST", f"{base_url}/prompt", json={"prompt": workflow, "client_id": client_id}, timeout=30 ) response.raise_for_status() @@ -913,7 +935,9 @@ def _generate_impl(params: dict, job_id: str, out_dir: str, progress: Callable[[ # when ComfyUI receives this status request. Ten seconds is # too short on large local renders and incorrectly reports a # completed prompt as failed. - result = requests.get(f"{base_url}/history/{prompt_id}", timeout=60).json() + result = _local_http_request( + "GET", f"{base_url}/history/{prompt_id}", timeout=60, + ).json() last_history_poll = now if prompt_id in result: history = result[prompt_id] diff --git a/app/services/minimax_image_service.py b/app/services/minimax_image_service.py index 391d292b..d8253f84 100644 --- a/app/services/minimax_image_service.py +++ b/app/services/minimax_image_service.py @@ -14,10 +14,13 @@ import os import re import time +import threading import uuid import requests +from . import resource_scheduler + MODEL_ID = "minimax:image-01" API_MODEL = "image-01" @@ -89,6 +92,8 @@ def generate_image( output_dir: str, subject_reference: str = "", filename_prefix: str = "minimax-image-01", + task_id: str = "", + root_task_id: str = "", ) -> dict: """Generate and persist one Image-01 image plus secret-free metadata.""" if not str(api_key or "").strip(): @@ -113,15 +118,21 @@ def generate_image( response = None try: - response = requests.post( - API_URL, - json=request_body, - headers={ - "Authorization": f"Bearer {api_key}", - "Content-Type": "application/json", - }, - timeout=(15, 300), - ) + lane = resource_scheduler.remote_lane("minimax", API_URL) + with resource_scheduler.coordinator.acquire( + lane, + task_id=f"minimax-image-{threading.get_ident()}-{time.time_ns()}", + description="MiniMax Image-01 request", + ): + response = requests.post( + API_URL, + json=request_body, + headers={ + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + }, + timeout=(15, 300), + ) response.raise_for_status() payload = response.json() except requests.RequestException as exc: @@ -157,6 +168,8 @@ def generate_image( with open(meta_path + ".tmp", "w", encoding="utf-8") as handle: json.dump({ "generation_mode": "image", + "task_id": str(task_id or "") or None, + "root_task_id": str(root_task_id or task_id or "") or None, "params": { "prompt": clean_prompt, "provider": "minimax", diff --git a/app/services/minimax_music_service.py b/app/services/minimax_music_service.py index acbb7bee..06681175 100644 --- a/app/services/minimax_music_service.py +++ b/app/services/minimax_music_service.py @@ -5,12 +5,15 @@ import base64 import json import os +import threading import time import uuid -from typing import Any +from typing import Any, Callable import requests +from . import resource_scheduler + API_URL = "https://api.minimax.io/v1/music_generation" MODEL = "music-3.0" @@ -57,6 +60,9 @@ def generate_candidates( model: str = MODEL, reference_audio_path: str | None = None, session: requests.Session | None = None, + task_id: str | None = None, + root_task_id: str | None = None, + cancelled: Callable[[], bool] | None = None, ) -> list[dict[str, Any]]: """Generate and persist 1–3 independently sampled song candidates.""" if not str(api_key).strip(): @@ -85,6 +91,8 @@ def generate_candidates( reference_audio = base64.b64encode(handle.read()).decode("ascii") lyrics = lyrics[:1000] count = max(1, min(3, int(count or 1))) + task_id = str(task_id or "").strip()[:200] or None + root_task_id = str(root_task_id or task_id or "").strip()[:200] or None os.makedirs(output_dir, exist_ok=True) client = session or requests.Session() headers = { @@ -106,8 +114,26 @@ def generate_candidates( payload["is_instrumental"] = bool(instrumental) results: list[dict[str, Any]] = [] for index in range(count): + candidate_task_id = ( + task_id + if task_id and count == 1 + else f"{task_id}-candidate-{index + 1}" if task_id + else ( + f"minimax-music-{threading.get_ident()}-" + f"{time.time_ns()}-{index + 1}" + ) + ) try: - raw = client.post(API_URL, headers=headers, json=payload, timeout=(20, 600)) + lane = resource_scheduler.remote_lane("minimax", API_URL) + with resource_scheduler.coordinator.acquire( + lane, + task_id=candidate_task_id, + description=f"MiniMax Music candidate {index + 1}/{count}", + cancelled=cancelled, + ): + raw = client.post( + API_URL, headers=headers, json=payload, timeout=(20, 600), + ) except requests.RequestException as exc: raise MiniMaxMusicError(f"MiniMax Music request failed: {exc}") from exc try: @@ -137,6 +163,9 @@ def generate_candidates( "trace_id": response.get("trace_id"), "created_at": time.time(), } + if task_id: + metadata["task_id"] = candidate_task_id + metadata["root_task_id"] = root_task_id or candidate_task_id with open(f"{path}.json", "w", encoding="utf-8") as handle: json.dump(metadata, handle, ensure_ascii=False, indent=2) results.append({ @@ -145,5 +174,9 @@ def generate_candidates( "duration_seconds": metadata["duration_seconds"], "provider": "minimax", "model": model, + **({ + "task_id": candidate_task_id, + "root_task_id": root_task_id or candidate_task_id, + } if task_id else {}), }) return results diff --git a/app/services/model3d_service.py b/app/services/model3d_service.py index b8be5cc9..31ac0907 100644 --- a/app/services/model3d_service.py +++ b/app/services/model3d_service.py @@ -22,6 +22,8 @@ from pathlib import Path from typing import Any +from . import resource_scheduler + SERVICE_DIR = Path(__file__).resolve().parent / "hunyuan3d" ENV_DIR = SERVICE_DIR / "env" @@ -223,13 +225,21 @@ _jobs: dict[str, dict[str, Any]] = {} _processes: dict[str, subprocess.Popen] = {} _lock = threading.RLock() -_generation_slot = threading.Semaphore(1) -# Public alias: any Maestro service that runs its own GPU-heavy worker -# (e.g. rig_service's UniRig jobs) must hold this slot too, so 3D -# generation and AI rigging never compete for the same VRAM. -GPU_SLOT = _generation_slot +# Backward-compatible alias for callers that still need the physical +# primitive. New work must use ResourceCoordinator.acquire so waiting and +# cancellation are observable. +GPU_SLOT = resource_scheduler.coordinator.shared_lock( + resource_scheduler.local_gpu_lane(0) +) _TERMINAL_STATES = {"completed", "failed", "cancelled"} +_ACTIVE_JOB_STATES = frozenset({ + "queued", + "waiting", + "waiting_resource", + "running", + "cancelling", +}) # Job-registry hygiene: keep a short history of finished jobs for status # polling, but never let the in-memory dict grow with server uptime. _MAX_FINISHED_JOBS = 20 @@ -294,6 +304,46 @@ def models_sharing_repo(model_id: str) -> list[dict[str, Any]]: return [item for item in MODELS if item["repo"] == model["repo"] and item["id"] != model_id] +def has_active_jobs(model_id: str | None = None) -> bool: + """Return whether a job can still be using the selected model cache. + + Hunyuan3D variants in the same Hugging Face repository share one cache, + so filtering by ``model_id`` intentionally includes active sibling + variants from that repository. A registered worker process also counts + as active even if cancellation has already changed the public job status; + this closes the short race while that process is still shutting down. + Passing no model returns whether any Hunyuan3D job is active. + """ + cache_model_ids: set[str] | None = None + if model_id is not None: + requested_id = str(model_id) + model = MODEL_BY_ID.get(requested_id) + if model is None: + cache_model_ids = {requested_id} + else: + cache_model_ids = { + item["id"] for item in MODELS if item["repo"] == model["repo"] + } + + with _lock: + for job_id, job in _jobs.items(): + job_model_id = str(job.get("model_id") or "") + if not job_model_id: + request_model = (job.get("request") or {}).get("model") or {} + if isinstance(request_model, dict): + job_model_id = str(request_model.get("id") or "") + if cache_model_ids is not None and job_model_id not in cache_model_ids: + continue + if str(job.get("status") or "").lower() in _ACTIVE_JOB_STATES: + return True + # A registered process remains authoritative while cancellation + # unwinds. The cache must stay untouched until the worker removes + # this handle in _run_job_serialized's finally block. + if job_id in _processes: + return True + return False + + def delete_model_cache(model_id: str) -> list[str]: """Remove the upstream repository cache used by a Hunyuan3D variant. @@ -312,7 +362,7 @@ def delete_model_cache(model_id: str) -> list[str]: def capabilities() -> dict[str, Any]: with _lock: - active = sum(1 for job in _jobs.values() if job["status"] in {"queued", "running"}) + active = sum(1 for job in _jobs.values() if job["status"] in _ACTIVE_JOB_STATES) return { "runtime": installation_status(), "models": MODELS, @@ -421,7 +471,16 @@ def _prepare_request( def _public_job(job: dict[str, Any]) -> dict[str, Any]: - return {key: value for key, value in job.items() if key not in {"request", "process"}} + return { + key: value + for key, value in job.items() + if key not in {"request", "process", "cancel_requested"} + } + + +def _canonical_task_id(job_id: str) -> str: + """Return the durable task identity used by the canonical task adapter.""" + return f"task-model3d-{job_id}" def _prune_finished_jobs_locked() -> None: @@ -443,6 +502,7 @@ def start_job( image_paths: dict[str, str], output_dir: str, source_mesh_path: str | None = None, + workspace: str = "default", ) -> dict[str, Any]: runtime = installation_status() if not runtime["installed"]: @@ -450,14 +510,17 @@ def start_job( with _lock: _prune_finished_jobs_locked() - active = sum(1 for job in _jobs.values() if job["status"] in {"queued", "running"}) + active = sum(1 for job in _jobs.values() if job["status"] in _ACTIVE_JOB_STATES) if active >= _MAX_ACTIVE_JOBS: raise ValueError("Too many queued 3D jobs; wait for the current ones to finish or cancel them") request_data = _prepare_request(body, image_paths, source_mesh_path) job_id = uuid.uuid4().hex + task_id = _canonical_task_id(job_id) job = { "job_id": job_id, + "task_id": task_id, + "root_task_id": task_id, "status": "queued", "progress": 0.0, "phase": "queued", @@ -467,6 +530,7 @@ def start_job( "url": None, "operation": request_data["operation"], "model_id": request_data["model"]["id"], + "workspace": str(workspace or "default"), "created_at": time.time(), "updated_at": time.time(), "request": request_data, @@ -479,39 +543,145 @@ def start_job( return initial_response -def _update_job(job_id: str, **updates: Any) -> None: +def _update_job(job_id: str, **updates: Any) -> bool: with _lock: job = _jobs.get(job_id) if not job: - return + return False + # Terminal cancellation is absorbing, and a running worker that is + # already unwinding must not be resurrected by a late progress or + # completion update. + if ( + job.get("status") in _TERMINAL_STATES + or job.get("status") == "cancelling" + or job.get("cancel_requested") + ): + return False job.update(updates) job["updated_at"] = time.time() # The request payload (settings + image paths) is only needed while # the job runs; keeping it on finished jobs just bloats the registry. if job["status"] in _TERMINAL_STATES: job.pop("request", None) + return True + + +def _settle_cancelled_job(job_id: str) -> bool: + """Publish terminal cancellation after the worker has released its lane.""" + with _lock: + job = _jobs.get(job_id) + if ( + not job + or job.get("status") in _TERMINAL_STATES + or not ( + job.get("cancel_requested") + or job.get("status") == "cancelling" + ) + ): + return False + job.update({ + "status": "cancelled", + "phase": "cancelled", + "message": ( + "3D retexture cancelled" + if job.get("operation") == "retexture" + else "3D generation cancelled" + ), + "updated_at": time.time(), + }) + job.pop("request", None) + return True def _run_job(job_id: str, output_dir: str) -> None: - _generation_slot.acquire() - try: + def cancelled() -> bool: with _lock: - if _jobs.get(job_id, {}).get("status") == "cancelled": + job = _jobs.get(job_id, {}) + return bool(job.get("cancel_requested")) or job.get("status") in { + "cancelling", + "cancelled", + } + + _update_job( + job_id, + phase="waiting_resource", + message="Waiting for local GPU 0", + ) + try: + with resource_scheduler.coordinator.acquire( + resource_scheduler.local_gpu_lane(0), + task_id=_canonical_task_id(job_id), + description="Hunyuan3D generation", + cancelled=cancelled, + ): + if cancelled(): return - _run_job_serialized(job_id, output_dir) + _run_job_serialized(job_id, output_dir) + except resource_scheduler.ResourceAcquireCancelled: + return finally: - _generation_slot.release() + # The coordinator context has exited here, so a running cancellation + # becomes terminal only after the GPU lane is actually available. + _settle_cancelled_job(job_id) def _cleanup_partial_output(output_path: Path) -> None: """Remove a failed/cancelled job's half-written export and its preview.""" - for stale in (output_path, output_path.with_suffix(".preview.png")): + for stale in ( + output_path, + output_path.with_suffix(".preview.png"), + output_path.with_suffix(".meta.json"), + ): try: stale.unlink(missing_ok=True) except OSError: pass +def _spawn_worker_if_active( + job_id: str, + command: list[str], + *, + cwd: str, + env: dict[str, str], + message: str, +) -> subprocess.Popen | None: + """Atomically transition an active job and register its subprocess. + + Holding ``_lock`` across the short ``Popen`` call gives cancellation one + linearization point: it either wins before this block (no process starts), + or it runs afterward with a registered, terminable process handle. + """ + with _lock: + job = _jobs.get(job_id) + if ( + not job + or job.get("status") not in {"queued", "waiting", "waiting_resource"} + or job.get("cancel_requested") + or not job.get("request") + or job_id in _processes + ): + return None + job.update({ + "status": "running", + "phase": "starting", + "message": message, + "progress": 0.02, + "updated_at": time.time(), + }) + process = subprocess.Popen( + command, + cwd=cwd, + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1, + ) + _processes[job_id] = process + return process + + def _run_job_serialized(job_id: str, output_dir: str) -> None: python_path = _python_path() if not python_path: @@ -569,24 +739,20 @@ def _run_job_serialized(job_id: str, output_dir: str) -> None: }) lines: list[str] = [] try: - _update_job( + process = _spawn_worker_if_active( job_id, - status="running", - phase="starting", - message=("Starting isolated Hunyuan3D retexture worker" if operation == "retexture" else "Starting isolated Hunyuan3D worker"), - progress=0.02, - ) - process = subprocess.Popen( command, cwd=str(SERVICE_DIR), env=env, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - text=True, - bufsize=1, + message=( + "Starting isolated Hunyuan3D retexture worker" + if operation == "retexture" + else "Starting isolated Hunyuan3D worker" + ), ) - with _lock: - _processes[job_id] = process + if process is None: + _cleanup_partial_output(output_path) + return # Record the worker PID on disk so a hard-killed Maestro (SIGKILL, # OOM, reload) can reap the orphan on the next startup instead of # leaving it holding VRAM forever. @@ -641,8 +807,10 @@ def _watchdog() -> None: exit_code = process.wait() with _lock: - status = _jobs.get(job_id, {}).get("status") - if status == "cancelled": + current_job = _jobs.get(job_id, {}) + status = current_job.get("status") + cancellation_pending = bool(current_job.get("cancel_requested")) + if cancellation_pending or status in {"cancelling", "cancelled"}: _cleanup_partial_output(output_path) return if timeout_reason: @@ -658,6 +826,8 @@ def _watchdog() -> None: "generation_mode": "model3d", "mode": "model3d", "job_id": job_id, + "task_id": _canonical_task_id(job_id), + "root_task_id": _canonical_task_id(job_id), "created_at": time.time(), "params": { **request_data["settings"], @@ -697,6 +867,13 @@ def _watchdog() -> None: finally: with _lock: _processes.pop(job_id, None) + current_job = _jobs.get(job_id, {}) + cancellation_pending = ( + bool(current_job.get("cancel_requested")) + or current_job.get("status") in {"cancelling", "cancelled"} + ) + if cancellation_pending: + _cleanup_partial_output(output_path) for stale_path in (request_path, pid_path): try: stale_path.unlink(missing_ok=True) @@ -718,25 +895,45 @@ def cancel_job(job_id: str) -> dict[str, Any] | None: return None if job["status"] in {"completed", "failed", "cancelled"}: return _public_job(dict(job)) - job.update({ - "status": "cancelled", - "phase": "cancelled", - "message": "3D retexture cancelled" if job.get("operation") == "retexture" else "3D generation cancelled", - "updated_at": time.time(), - }) - job.pop("request", None) + spawned = process is not None + if spawned: + job.update({ + "cancel_requested": True, + "phase": "cancelling", + "message": "Stopping the 3D worker at a safe boundary", + "updated_at": time.time(), + }) + else: + job.update({ + "status": "cancelled", + "cancel_requested": True, + "phase": "cancelled", + "message": ( + "3D retexture cancelled" + if job.get("operation") == "retexture" + else "3D generation cancelled" + ), + "updated_at": time.time(), + }) + job.pop("request", None) if process and process.poll() is None: - process.terminate() + try: + process.terminate() + except OSError: + pass try: process.wait(timeout=10) except subprocess.TimeoutExpired: - process.kill() + try: + process.kill() + except OSError: + pass return get_job(job_id) def cancel_all_jobs() -> int: with _lock: - active_ids = [job_id for job_id, job in _jobs.items() if job["status"] in {"queued", "running"}] + active_ids = [job_id for job_id, job in _jobs.items() if job["status"] in _ACTIVE_JOB_STATES] for job_id in active_ids: cancel_job(job_id) return len(active_ids) diff --git a/app/services/resource_scheduler.py b/app/services/resource_scheduler.py index 5a83053a..6684f517 100644 --- a/app/services/resource_scheduler.py +++ b/app/services/resource_scheduler.py @@ -13,7 +13,7 @@ from dataclasses import dataclass import threading import time -from typing import Iterator +from typing import Callable, Iterator from urllib.parse import urlparse @@ -40,6 +40,10 @@ class ResourceLane: capacity: int = 1 +class ResourceAcquireCancelled(RuntimeError): + """Raised when a task is cancelled before its resource lease begins.""" + + def local_gpu_lane(gpu_index: int = 0) -> ResourceLane: index = max(0, int(gpu_index or 0)) return ResourceLane(f"local_gpu:{index}", f"Local GPU {index}", "local") @@ -102,8 +106,12 @@ class ResourceCoordinator: def __init__(self) -> None: self._guard = threading.Lock() + self._thread_state = threading.local() self._slots: dict[str, threading.BoundedSemaphore] = {} self._state: dict[str, dict] = {} + self._prepare_hooks: dict[ + str, Callable[[ResourceLane, str, str], None] + ] = {} def _slot(self, lane: ResourceLane) -> threading.BoundedSemaphore: with self._guard: @@ -118,9 +126,98 @@ def _slot(self, lane: ResourceLane) -> threading.BoundedSemaphore: "active": 0, "waiting": 0, "tasks": [], + "waiters": [], } return slot + def shared_lock(self, lane: ResourceLane) -> threading.BoundedSemaphore: + """Return the coordinator-owned primitive for legacy FIFO adapters. + + The main Maestro generation queue already has tested FIFO and abort + semantics around a lock. Giving it this exact semaphore lets migrated + engines acquire through :meth:`acquire` without introducing a second + GPU lock or allowing two owners of the same physical device. + """ + return self._slot(lane) + + def set_prepare_hook( + self, + lane: ResourceLane, + hook: Callable[[ResourceLane, str, str], None] | None, + ) -> None: + """Install a post-acquisition runtime handoff hook for one lane.""" + self._slot(lane) + with self._guard: + if hook is None: + self._prepare_hooks.pop(lane.key, None) + else: + self._prepare_hooks[lane.key] = hook + + @contextmanager + def adopt_acquired( + self, + lane: ResourceLane, + *, + task_id: str, + description: str = "", + ) -> Iterator[ResourceLane]: + """Observe a lease whose coordinator semaphore is already held. + + Maestro's original video queue owns FIFO/cancellation semantics around + the same physical semaphore returned by :meth:`shared_lock`. Once + that queue has acquired it, this adapter performs the normal runtime + hand-off and makes the owner visible in :meth:`snapshot` without + attempting to acquire the semaphore a second time. + + Callers must hold ``shared_lock(lane)`` for the whole context. + """ + self._slot(lane) + held = getattr(self._thread_state, "held", None) + if held is None: + held = {} + self._thread_state.held = held + if held.get(lane.key, 0) > 0: + held[lane.key] += 1 + try: + yield lane + finally: + held[lane.key] -= 1 + if held[lane.key] <= 0: + held.pop(lane.key, None) + return + + held[lane.key] = 1 + active = False + try: + with self._guard: + prepare_hook = self._prepare_hooks.get(lane.key) + if prepare_hook is not None: + prepare_hook(lane, task_id, description) + + started_at = time.time() + with self._guard: + state = self._state[lane.key] + state["active"] += 1 + state["tasks"].append({ + "id": task_id, + "description": description, + "started_at": started_at, + }) + active = True + yield lane + finally: + held[lane.key] = max(0, held.get(lane.key, 1) - 1) + if held[lane.key] <= 0: + held.pop(lane.key, None) + if active: + with self._guard: + state = self._state[lane.key] + state["active"] = max(0, state["active"] - 1) + state["tasks"] = [ + task for task in state["tasks"] + if task["id"] != task_id + ] + @contextmanager def acquire( self, @@ -128,35 +225,120 @@ def acquire( *, task_id: str, description: str = "", + cancelled: Callable[[], bool] | None = None, + poll_interval: float = 0.1, ) -> Iterator[ResourceLane]: + held = getattr(self._thread_state, "held", None) + if held is None: + held = {} + self._thread_state.held = held + if held.get(lane.key, 0) > 0: + # A parent operation may deliberately retain a CUDA lease across + # several observable child calls. Re-entering that same physical + # lane on the same thread must not deadlock on its semaphore. + if cancelled is not None and cancelled(): + raise ResourceAcquireCancelled( + f"Task {task_id} was cancelled before re-entering {lane.key}" + ) + held[lane.key] += 1 + try: + yield lane + finally: + held[lane.key] -= 1 + if held[lane.key] <= 0: + held.pop(lane.key, None) + return + slot = self._slot(lane) + queued_at = time.time() + waiter = { + "id": task_id, + "description": description, + "queued_at": queued_at, + } with self._guard: state = self._state[lane.key] state["waiting"] += 1 - slot.acquire() - started_at = time.time() - with self._guard: - state = self._state[lane.key] - state["waiting"] -= 1 - state["active"] += 1 - state["tasks"].append({ - "id": task_id, - "description": description, - "started_at": started_at, - }) + state["waiters"].append(waiter) + acquired = False + active = False + waiting = True + held_registered = False try: + interval = max(0.01, float(poll_interval or 0.1)) + while True: + if cancelled is not None and cancelled(): + raise ResourceAcquireCancelled( + f"Task {task_id} was cancelled while waiting for {lane.key}" + ) + if slot.acquire(timeout=interval): + acquired = True + break + # Close the small race between the final cancellation check and + # semaphore acquisition. A cancelled waiter must never start a + # provider call merely because the previous owner just released. + if cancelled is not None and cancelled(): + raise ResourceAcquireCancelled( + f"Task {task_id} was cancelled while acquiring {lane.key}" + ) + # Register same-thread ownership before the hand-off callback. A + # cleanup hook may itself call a migrated service on this lane; + # treating that call as reentrant avoids deadlocking on the + # semaphore that this hook already owns. + held[lane.key] = held.get(lane.key, 0) + 1 + held_registered = True + with self._guard: + prepare_hook = self._prepare_hooks.get(lane.key) + if prepare_hook is not None: + prepare_hook(lane, task_id, description) + if cancelled is not None and cancelled(): + raise ResourceAcquireCancelled( + f"Task {task_id} was cancelled while preparing {lane.key}" + ) + started_at = time.time() + with self._guard: + state = self._state[lane.key] + state["waiting"] = max(0, state["waiting"] - 1) + state["waiters"] = [ + value for value in state["waiters"] if value["id"] != task_id + ] + waiting = False + state["active"] += 1 + state["tasks"].append({ + "id": task_id, + "description": description, + "started_at": started_at, + }) + active = True yield lane finally: + if held_registered: + held[lane.key] = max(0, held.get(lane.key, 1) - 1) + if held[lane.key] <= 0: + held.pop(lane.key, None) with self._guard: state = self._state[lane.key] - state["active"] = max(0, state["active"] - 1) - state["tasks"] = [task for task in state["tasks"] if task["id"] != task_id] - slot.release() + if waiting: + state["waiting"] = max(0, state["waiting"] - 1) + state["waiters"] = [ + value for value in state["waiters"] if value["id"] != task_id + ] + if active: + state["active"] = max(0, state["active"] - 1) + state["tasks"] = [ + task for task in state["tasks"] if task["id"] != task_id + ] + if acquired: + slot.release() def snapshot(self) -> list[dict]: with self._guard: return [ - {**state, "tasks": [dict(task) for task in state["tasks"]]} + { + **state, + "tasks": [dict(task) for task in state["tasks"]], + "waiters": [dict(task) for task in state["waiters"]], + } for state in self._state.values() ] diff --git a/app/services/rig_service.py b/app/services/rig_service.py index 9c8f717e..b5234bf5 100644 --- a/app/services/rig_service.py +++ b/app/services/rig_service.py @@ -22,6 +22,8 @@ from pathlib import Path from typing import Any +from . import resource_scheduler + SERVICE_DIR = Path(__file__).resolve().parent / "hunyuan3d" ENV_DIR = SERVICE_DIR / "env" INSTALL_MARKER = ENV_DIR / ".maestro_hunyuan3d_v1.installed" @@ -147,9 +149,15 @@ _jobs: dict[str, dict[str, Any]] = {} _processes: dict[str, subprocess.Popen] = {} _lock = threading.RLock() -_rig_slot = threading.Semaphore(1) _TERMINAL_STATES = {"completed", "failed", "cancelled"} +_ACTIVE_JOB_STATES = frozenset({ + "queued", + "waiting", + "waiting_resource", + "running", + "cancelling", +}) _MAX_FINISHED_JOBS = 20 _FINISHED_JOB_TTL_SECONDS = 3600 _MAX_ACTIVE_JOBS = 4 @@ -221,6 +229,36 @@ def delete_unirig_cache() -> list[str]: return [UNIRIG_REPO] +def has_active_jobs(engine: str | None = None) -> bool: + """Return whether a rig job for ``engine`` can still be using resources. + + Waiting, queued and running work is active. A registered subprocess also + remains active while cancellation unwinds, which lets cache-deletion + endpoints avoid racing a UniRig worker. + Passing no engine checks every rig job. + """ + requested_engine = str(engine).strip().lower() if engine is not None else None + with _lock: + for job_id, job in _jobs.items(): + job_engine = str( + job.get("engine") + or (job.get("request") or {}).get("engine") + or "procedural" + ).strip().lower() + if requested_engine is not None and job_engine != requested_engine: + continue + if str(job.get("status") or "").lower() in _ACTIVE_JOB_STATES: + return True + if job_id in _processes: + return True + return False + + +def has_active_unirig_jobs() -> bool: + """Return whether deleting the shared UniRig weights would be unsafe.""" + return has_active_jobs("unirig") + + def unirig_installation_status() -> dict[str, Any]: python_path = _unirig_python_path() installed = bool(python_path and RIGGING_MARKER.is_file() and UNIRIG_WORKER_PATH.is_file() and UNIRIG_VENDOR_DIR.is_dir()) @@ -232,7 +270,7 @@ def unirig_installation_status() -> dict[str, Any]: def capabilities() -> dict[str, Any]: with _lock: - active = sum(1 for job in _jobs.values() if job["status"] in {"queued", "running"}) + active = sum(1 for job in _jobs.values() if job["status"] in _ACTIVE_JOB_STATES) status = installation_status() unirig_status = unirig_installation_status() return { @@ -261,7 +299,16 @@ def capabilities() -> dict[str, Any]: def _public_job(job: dict[str, Any]) -> dict[str, Any]: - return {key: value for key, value in job.items() if key not in {"request", "process"}} + return { + key: value + for key, value in job.items() + if key not in {"request", "process", "cancel_requested"} + } + + +def _canonical_task_id(job_id: str) -> str: + """Return the durable task identity used by the canonical task adapter.""" + return f"task-rig-{job_id}" def _prune_finished_jobs_locked() -> None: @@ -276,7 +323,13 @@ def _prune_finished_jobs_locked() -> None: _jobs.pop(job_id, None) -def start_job(*, body: dict[str, Any], source_path: str, output_dir: str) -> dict[str, Any]: +def start_job( + *, + body: dict[str, Any], + source_path: str, + output_dir: str, + workspace: str = "default", +) -> dict[str, Any]: # Each engine has its own runtime; gate on the one actually requested. if str(body.get("engine") or "procedural") != "unirig": runtime = installation_status() @@ -285,7 +338,7 @@ def start_job(*, body: dict[str, Any], source_path: str, output_dir: str) -> dic with _lock: _prune_finished_jobs_locked() - active = sum(1 for job in _jobs.values() if job["status"] in {"queued", "running"}) + active = sum(1 for job in _jobs.values() if job["status"] in _ACTIVE_JOB_STATES) if active >= _MAX_ACTIVE_JOBS: raise ValueError("Too many queued rig jobs; wait for the current ones to finish or cancel them") @@ -352,6 +405,7 @@ def start_job(*, body: dict[str, Any], source_path: str, output_dir: str) -> dic request_data = { "engine": engine, + "workspace": str(workspace or "default"), "source": os.path.abspath(source_path), "rig_profile": rig_profile, "animations": [str(item) for item in animations], @@ -360,8 +414,11 @@ def start_job(*, body: dict[str, Any], source_path: str, output_dir: str) -> dic "weight_falloff": weight_falloff, } job_id = uuid.uuid4().hex + task_id = _canonical_task_id(job_id) job = { "job_id": job_id, + "task_id": task_id, + "root_task_id": task_id, "status": "queued", "progress": 0.0, "phase": "queued", @@ -370,6 +427,7 @@ def start_job(*, body: dict[str, Any], source_path: str, output_dir: str) -> dic "filename": None, "url": None, "engine": engine, + "workspace": str(workspace or "default"), "rig_profile": rig_profile, **({"spine_joints": spine_joints} if engine == "procedural" else {}), "axis_mode": axis_mode, @@ -386,57 +444,141 @@ def start_job(*, body: dict[str, Any], source_path: str, output_dir: str) -> dic return initial_response -def _update_job(job_id: str, **updates: Any) -> None: +def _update_job(job_id: str, **updates: Any) -> bool: with _lock: job = _jobs.get(job_id) if not job: - return + return False + # Cancellation is absorbing. In particular, late worker events must + # never turn a cancelling/cancelled rig back into a running job. + if ( + job.get("status") in _TERMINAL_STATES + or job.get("status") == "cancelling" + or job.get("cancel_requested") + ): + return False job.update(updates) job["updated_at"] = time.time() if job["status"] in _TERMINAL_STATES: job.pop("request", None) + return True + + +def _settle_cancelled_job(job_id: str) -> bool: + """Publish terminal cancellation after the worker has released its lane.""" + with _lock: + job = _jobs.get(job_id) + if ( + not job + or job.get("status") in _TERMINAL_STATES + or not ( + job.get("cancel_requested") + or job.get("status") == "cancelling" + ) + ): + return False + job.update({ + "status": "cancelled", + "phase": "cancelled", + "message": "Rig job cancelled", + "updated_at": time.time(), + }) + job.pop("request", None) + return True def _run_job(job_id: str, output_dir: str) -> None: - _rig_slot.acquire() - gpu_slot: threading.Semaphore | None = None - try: + def cancelled() -> bool: with _lock: job = _jobs.get(job_id, {}) - if job.get("status") == "cancelled": + return bool(job.get("cancel_requested")) or job.get("status") in { + "cancelling", + "cancelled", + } + + with _lock: + request_data = (_jobs.get(job_id, {}).get("request") or {}).copy() + if not request_data or cancelled(): + return + is_unirig = request_data.get("engine") == "unirig" + lane = ( + resource_scheduler.local_gpu_lane(0) + if is_unirig else resource_scheduler.cpu_lane("rig") + ) + _update_job( + job_id, + phase="waiting_resource", + message=("Waiting for local GPU 0" if is_unirig else "Waiting for rig CPU worker"), + ) + try: + with resource_scheduler.coordinator.acquire( + lane, + task_id=_canonical_task_id(job_id), + description=("UniRig AI rigging" if is_unirig else "Procedural rigging"), + cancelled=cancelled, + ): + if cancelled(): return - request_data = job.get("request") or {} - if request_data.get("engine") == "unirig": - # Import lazily to avoid coupling service initialization. Hunyuan - # generation and UniRig now share one GPU slot and cannot contend - # for VRAM. - from services import model3d_service - - gpu_slot = model3d_service.GPU_SLOT - _update_job( - job_id, - phase="queued", - message="Waiting for the shared 3D GPU", - ) - gpu_slot.acquire() - with _lock: - if _jobs.get(job_id, {}).get("status") == "cancelled": - return - _run_job_serialized(job_id, output_dir) + _run_job_serialized(job_id, output_dir) + except resource_scheduler.ResourceAcquireCancelled: + return finally: - if gpu_slot is not None: - gpu_slot.release() - _rig_slot.release() + # This runs after the coordinator context exits, so `cancelled` means + # the CPU/GPU lane and subprocess have both reached a safe boundary. + _settle_cancelled_job(job_id) def _cleanup_partial_output(output_path: Path) -> None: - for stale in (output_path, output_path.with_suffix(".preview.png")): + for stale in ( + output_path, + output_path.with_suffix(".preview.png"), + output_path.with_suffix(".meta.json"), + ): try: stale.unlink(missing_ok=True) except OSError: pass +def _spawn_worker_if_active( + job_id: str, + command: list[str], + *, + cwd: str, + env: dict[str, str], + message: str, +) -> subprocess.Popen | None: + """Atomically transition an active rig job and register its subprocess.""" + with _lock: + job = _jobs.get(job_id) + if ( + not job + or job.get("status") not in {"queued", "waiting", "waiting_resource"} + or job.get("cancel_requested") + or not job.get("request") + or job_id in _processes + ): + return None + job.update({ + "status": "running", + "phase": "starting", + "message": message, + "progress": 0.02, + "updated_at": time.time(), + }) + process = subprocess.Popen( + command, + cwd=cwd, + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1, + ) + _processes[job_id] = process + return process + + def _run_job_serialized(job_id: str, output_dir: str) -> None: with _lock: job = _jobs.get(job_id) @@ -496,18 +638,16 @@ def _run_job_serialized(job_id: str, output_dir: str) -> None: lines: list[str] = [] result_summary: dict[str, Any] = {} try: - _update_job(job_id, status="running", phase="starting", message="Starting rig worker", progress=0.02) - process = subprocess.Popen( + process = _spawn_worker_if_active( + job_id, command, cwd=str(worker_cwd), env=env, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - text=True, - bufsize=1, + message="Starting rig worker", ) - with _lock: - _processes[job_id] = process + if process is None: + _cleanup_partial_output(output_path) + return try: pid_path.write_text(str(process.pid), encoding="utf-8") except OSError: @@ -560,8 +700,10 @@ def _watchdog() -> None: exit_code = process.wait() with _lock: - status = _jobs.get(job_id, {}).get("status") - if status == "cancelled": + current_job = _jobs.get(job_id, {}) + status = current_job.get("status") + cancellation_pending = bool(current_job.get("cancel_requested")) + if cancellation_pending or status in {"cancelling", "cancelled"}: _cleanup_partial_output(output_path) return if timeout_reason: @@ -585,6 +727,8 @@ def _watchdog() -> None: "generation_mode": "model3d", "mode": "model3d", "job_id": job_id, + "task_id": _canonical_task_id(job_id), + "root_task_id": _canonical_task_id(job_id), "created_at": time.time(), "params": { "model_type": f"rig-{request_data['engine']}", @@ -634,6 +778,13 @@ def _watchdog() -> None: finally: with _lock: _processes.pop(job_id, None) + current_job = _jobs.get(job_id, {}) + cancellation_pending = ( + bool(current_job.get("cancel_requested")) + or current_job.get("status") in {"cancelling", "cancelled"} + ) + if cancellation_pending: + _cleanup_partial_output(output_path) for stale_path in (request_path, pid_path): try: stale_path.unlink(missing_ok=True) @@ -655,25 +806,41 @@ def cancel_job(job_id: str) -> dict[str, Any] | None: return None if job["status"] in _TERMINAL_STATES: return _public_job(dict(job)) - job.update({ - "status": "cancelled", - "phase": "cancelled", - "message": "Rig job cancelled", - "updated_at": time.time(), - }) - job.pop("request", None) + spawned = process is not None + if spawned: + job.update({ + "cancel_requested": True, + "phase": "cancelling", + "message": "Stopping the rig worker at a safe boundary", + "updated_at": time.time(), + }) + else: + job.update({ + "status": "cancelled", + "cancel_requested": True, + "phase": "cancelled", + "message": "Rig job cancelled", + "updated_at": time.time(), + }) + job.pop("request", None) if process and process.poll() is None: - process.terminate() + try: + process.terminate() + except OSError: + pass try: process.wait(timeout=10) except subprocess.TimeoutExpired: - process.kill() + try: + process.kill() + except OSError: + pass return get_job(job_id) def cancel_all_jobs() -> int: with _lock: - active_ids = [job_id for job_id, job in _jobs.items() if job["status"] in {"queued", "running"}] + active_ids = [job_id for job_id, job in _jobs.items() if job["status"] in _ACTIVE_JOB_STATES] for job_id in active_ids: cancel_job(job_id) return len(active_ids) diff --git a/app/services/series_assembly.py b/app/services/series_assembly.py new file mode 100644 index 00000000..579237b1 --- /dev/null +++ b/app/services/series_assembly.py @@ -0,0 +1,40 @@ +"""Ordered Series Lab episode assembly helpers.""" + +from __future__ import annotations + +import copy +from typing import Any + + +def episode_assembly_plan(series: dict[str, Any], episode: dict[str, Any]) -> list[dict[str, Any]]: + """Return one approved video per shot in deterministic episode order.""" + assets = series.get("assets") if isinstance(series.get("assets"), dict) else {} + shots = [item for item in episode.get("shots", []) if isinstance(item, dict)] + if not shots: + raise ValueError("The episode has no shots to join") + plan: list[dict[str, Any]] = [] + for shot in sorted(shots, key=lambda item: (int(item.get("order") or 0), str(item.get("id") or ""))): + approved_id = str(shot.get("approvedAttemptId") or "") + if not approved_id: + raise ValueError(f"Approve shot {shot.get('order')} before joining the episode") + attempt = next(( + value for value in shot.get("attempts", []) + if isinstance(value, dict) and str(value.get("id") or "") == approved_id + ), None) + if not attempt or attempt.get("status") != "completed": + raise ValueError(f"Shot {shot.get('order')} does not have a completed approved attempt") + asset = next(( + assets.get(str(asset_id)) for asset_id in attempt.get("outputAssetIds", []) + if isinstance(assets.get(str(asset_id)), dict) + and assets[str(asset_id)].get("kind") == "video" + ), None) + if not asset: + raise ValueError(f"Shot {shot.get('order')} approved attempt has no video asset") + plan.append({ + "shotId": str(shot.get("id") or ""), + "shotOrder": int(shot.get("order") or 0), + "attemptId": approved_id, + "assetId": str(asset.get("id") or ""), + "uri": str(asset.get("uri") or ""), + }) + return copy.deepcopy(plan) diff --git a/app/services/series_jobs.py b/app/services/series_jobs.py index 029caa8d..e1d16b30 100644 --- a/app/services/series_jobs.py +++ b/app/services/series_jobs.py @@ -10,7 +10,7 @@ SERIES_JOBS_DIR = ".series-jobs-v1" -KINDS = {"planning", "render"} +KINDS = {"planning", "render", "assembly"} class SeriesJobStore: diff --git a/app/services/series_library.py b/app/services/series_library.py index 3b92b3c0..f935db43 100644 --- a/app/services/series_library.py +++ b/app/services/series_library.py @@ -19,6 +19,7 @@ SERIES_LIBRARY_FILENAME = ".series-library-v1.json" MAX_SERIES_PROJECTS = 100 MAX_SERIES_LIBRARY_BYTES = 100 * 1024 * 1024 +MAX_BULK_ATTEMPT_APPROVALS = 500 _WORKSPACE_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]*$") _ASSET_PATH = re.compile(r"^(assets|outputs)/[A-Za-z0-9._/-]+$") @@ -31,6 +32,10 @@ def _now() -> str: return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") +def _new_uid(prefix: str) -> str: + return f"{prefix}_{uuid.uuid4().hex}" + + def _text(value: Any, fallback: str = "") -> str: return value if isinstance(value, str) else fallback @@ -112,13 +117,15 @@ def create_series_project( ) -> dict: workspace_id = validate_workspace_id(workspace_id) now = _now() - suffix = uuid.uuid4().hex[:10] + suffix = uuid.uuid4().hex series_id = f"series_{suffix}" value = { "version": 1, "id": series_id, "revision": 1, "title": title.strip() or "Untitled series", "logline": "", "premise": "", "format": "episodic", "defaultEpisodeDurationSeconds": 75, - "language": "Español", "genre": "", "tone": "Cinematic", "audience": "General", + "language": "Español", "spokenLanguage": "Español de España", + "protagonistConsistency": False, "protagonistCharacterId": "", + "genre": "", "tone": "Cinematic", "audience": "General", "visualStyle": "", "characterVisualStyle": "", "cameraLanguage": "", "allowClipText": False, "sourceMode": "original", "masterUniversePrompt": "", "rightsNote": "", "bestEffortLipSyncAcknowledged": False, @@ -225,17 +232,73 @@ def _normalize_canon(value: Any) -> dict: return canon +def _normalize_dialogue_beat(value: dict, fallback_id: str) -> dict: + beat = copy.deepcopy(value) + beat.update({ + "id": _id(beat.get("id"), fallback_id), + "characterId": _id(beat.get("characterId"), "character_unknown"), + "text": _text(beat.get("text")), + "emotion": _text(beat.get("emotion"), "natural"), + "delivery": _text(beat.get("delivery"), "natural delivery"), + }) + return beat + + +def _normalize_attempt(value: dict, shot_id: str, index: int) -> dict: + attempt = copy.deepcopy(value) + seed = attempt.get("seed") + try: + seed = int(seed) if seed is not None else None + except (TypeError, ValueError, OverflowError): + seed = None + status = str(attempt.get("status") or "queued") + if status not in {"queued", "running", "cancelling", "completed", "failed", "cancelled"}: + status = "failed" + attempt.update({ + "id": _id(attempt.get("id"), f"{shot_id}_attempt_{index + 1}"), + "status": status, + "prompt": _text(attempt.get("prompt")), + "negativePrompt": _text(attempt.get("negativePrompt")), + "model": _text(attempt.get("model"), "minimax_h3"), + "referenceManifest": copy.deepcopy(attempt.get("referenceManifest")) + if isinstance(attempt.get("referenceManifest"), dict) else {}, + "seed": seed, + "settings": copy.deepcopy(attempt.get("settings")) + if isinstance(attempt.get("settings"), dict) else {}, + "startTimeSeconds": _number(attempt.get("startTimeSeconds"), 0, 0), + "endTimeSeconds": _number(attempt.get("endTimeSeconds"), 0, 0), + "createdAt": _text(attempt.get("createdAt"), _now()), + "elapsedMs": _integer(attempt.get("elapsedMs"), 0, 0), + "outputAssetIds": _unique_ids(attempt.get("outputAssetIds")), + "retryCount": _integer(attempt.get("retryCount"), 0, 0), + }) + if attempt.get("reviewDecision") not in {"approved", "rejected"}: + attempt.pop("reviewDecision", None) + return attempt + + def _normalize_shot(value: dict, index: int) -> dict: + from .series_render import normalize_series_shot_duration + shot = copy.deepcopy(value) + shot_id = _id(shot.get("id"), f"shot_{index + 1}") + dialogue = [ + _normalize_dialogue_beat(item, f"{shot_id}_dialogue_{dialogue_index + 1}") + for dialogue_index, item in enumerate(_objects(shot.get("dialogueBeats"))) + ] + attempts = [ + _normalize_attempt(item, shot_id, attempt_index) + for attempt_index, item in enumerate(_objects(shot.get("attempts"))) + ] shot.update({ - "id": _id(shot.get("id"), f"shot_{index + 1}"), + "id": shot_id, "sceneId": _id(shot.get("sceneId"), "scene_1"), "order": _integer(shot.get("order"), index + 1, 1), - "durationSeconds": max(1.0, min(30.0, _number(shot.get("durationSeconds"), 8, 1))), + "durationSeconds": float(normalize_series_shot_duration(shot.get("durationSeconds"))), "framing": _text(shot.get("framing")), "camera": _text(shot.get("camera")), "action": _text(shot.get("action")), - "dialogueBeats": _objects(shot.get("dialogueBeats")), + "dialogueBeats": dialogue, "visibleCharacterIds": _unique_ids(shot.get("visibleCharacterIds")), "speakingCharacterIds": _unique_ids(shot.get("speakingCharacterIds")), "wardrobeByCharacterId": copy.deepcopy(shot.get("wardrobeByCharacterId")) @@ -252,7 +315,7 @@ def _normalize_shot(value: dict, index: int) -> dict: }, "prompt": _text(shot.get("prompt")), "negativePrompt": _text(shot.get("negativePrompt")), - "attempts": _objects(shot.get("attempts")), + "attempts": attempts, }) policy = shot["referencePolicy"] policy["mode"] = "manual" if policy.get("mode") == "manual" else "automatic" @@ -261,6 +324,46 @@ def _normalize_shot(value: dict, index: int) -> dict: return shot +def _normalize_scene(value: dict, episode_id: str, index: int) -> dict: + scene = copy.deepcopy(value) + scene_id = _id(scene.get("id"), f"{episode_id}_scene_{index + 1}") + scene.update({ + "id": scene_id, + "order": _integer(scene.get("order"), index + 1, 1), + "locationId": _text(scene.get("locationId")), + "time": _text(scene.get("time")), + "participatingCharacterIds": _unique_ids(scene.get("participatingCharacterIds")), + "purpose": _text(scene.get("purpose")), + "entryState": _text(scene.get("entryState")), + "exitState": _text(scene.get("exitState")), + "beats": _objects(scene.get("beats")), + "dialogue": [ + _normalize_dialogue_beat(item, f"{scene_id}_dialogue_{dialogue_index + 1}") + for dialogue_index, item in enumerate(_objects(scene.get("dialogue"))) + ], + }) + for beat_index, beat in enumerate(scene["beats"]): + beat["id"] = _id(beat.get("id"), f"{scene_id}_beat_{beat_index + 1}") + beat["kind"] = "dialogue" if beat.get("kind") == "dialogue" else "action" + beat["summary"] = _text(beat.get("summary")) + return scene + + +def _unique_runtime_id(candidate: str, used: set[str], fallback: str) -> str: + """Repair legacy copied dialogue IDs without creating unstable random IDs.""" + value = candidate.strip() if isinstance(candidate, str) else "" + if value and value not in used: + used.add(value) + return value + value = fallback + suffix = 2 + while value in used: + value = f"{fallback}_{suffix}" + suffix += 1 + used.add(value) + return value + + def _normalize_episode(value: dict, key: str, index: int, season_id: str, canon: dict) -> dict: now = _now() episode = copy.deepcopy(value) @@ -273,6 +376,70 @@ def _normalize_episode(value: dict, key: str, index: int, season_id: str, canon: snapshot.setdefault("currentFacts", copy.deepcopy(canon["currentFacts"])) for state_key in ("characterStates", "relationshipStates", "locationStates", "propStates"): snapshot.setdefault(state_key, {}) + script = [ + _normalize_scene(item, episode_id, scene_index) + for scene_index, item in enumerate(_objects(episode.get("script"))) + ] + shots = [ + _normalize_shot(item, shot_index) + for shot_index, item in enumerate(_objects(episode.get("shots"))) + ] + # Older planner responses sometimes copied IDs when a scene was expanded or + # repeated. Repair those IDs in document order so the first occurrence keeps + # its stable identifier and every later occurrence gets an episode-scoped ID. + # This makes loading old libraries safe while the graph validator below still + # rejects ambiguous IDs in every other live collection. + used_runtime_ids: set[str] = set() + scene_id_remap: dict[str, str] = {} + for scene_index, scene in enumerate(script): + original_scene_id = str(scene.get("id") or "") + scene["id"] = _unique_runtime_id( + original_scene_id, used_runtime_ids, + f"{episode_id}_scene_{scene_index + 1}", + ) + scene_id_remap.setdefault(original_scene_id, scene["id"]) + for beat_index, beat in enumerate(scene.get("beats", [])): + beat["id"] = _unique_runtime_id( + str(beat.get("id") or ""), used_runtime_ids, + f"{scene['id']}_beat_{beat_index + 1}", + ) + for dialogue_index, beat in enumerate(scene.get("dialogue", [])): + beat["id"] = _unique_runtime_id( + str(beat.get("id") or ""), used_runtime_ids, + f"{scene['id']}_dialogue_{dialogue_index + 1}", + ) + + shot_id_remap: dict[str, str] = {} + for shot_index, shot in enumerate(shots): + original_shot_id = str(shot.get("id") or "") + shot["id"] = _unique_runtime_id( + original_shot_id, used_runtime_ids, + f"{episode_id}_shot_{shot_index + 1}", + ) + shot_id_remap.setdefault(original_shot_id, shot["id"]) + original_scene_id = str(shot.get("sceneId") or "") + shot["sceneId"] = scene_id_remap.get(original_scene_id, original_scene_id) + for dialogue_index, beat in enumerate(shot.get("dialogueBeats", [])): + beat["id"] = _unique_runtime_id( + str(beat.get("id") or ""), used_runtime_ids, + f"{shot['id']}_dialogue_{dialogue_index + 1}", + ) + approved_attempt_id = str(shot.get("approvedAttemptId") or "") + approved_replacement = "" + for attempt_index, attempt in enumerate(shot.get("attempts", [])): + original_attempt_id = str(attempt.get("id") or "") + attempt["id"] = _unique_runtime_id( + original_attempt_id, used_runtime_ids, + f"{shot['id']}_attempt_{attempt_index + 1}", + ) + if original_attempt_id == approved_attempt_id and not approved_replacement: + approved_replacement = attempt["id"] + if approved_attempt_id: + shot["approvedAttemptId"] = approved_replacement or approved_attempt_id + for shot in shots: + continuity_id = str(shot.get("continuityFromShotId") or "") + if continuity_id: + shot["continuityFromShotId"] = shot_id_remap.get(continuity_id, continuity_id) episode.update({ "id": episode_id, "seasonId": _id(episode.get("seasonId"), season_id), @@ -290,10 +457,8 @@ def _normalize_episode(value: dict, key: str, index: int, season_id: str, canon: "canonSnapshot": snapshot, "outline": copy.deepcopy(episode.get("outline")) if isinstance(episode.get("outline"), dict) else {"beats": []}, - "script": _objects(episode.get("script")), - "shots": [_normalize_shot(item, shot_index) for shot_index, item in enumerate( - _objects(episode.get("shots")) - )], + "script": script, + "shots": shots, "proposedCanonDelta": copy.deepcopy(episode.get("proposedCanonDelta")) if isinstance(episode.get("proposedCanonDelta"), dict) else { "baseRevision": snapshot["revision"], "sourceEpisodeId": episode_id, @@ -306,6 +471,216 @@ def _normalize_episode(value: dict, key: str, index: int, season_id: str, canon: return episode +def _validate_project_graph_ids(project: dict) -> None: + """Reject ambiguous IDs and references that would corrupt the live graph.""" + seen: dict[str, str] = {} + + def register(item: dict, path: str) -> None: + item_id = _id(item.get("id")) + previous = seen.get(item_id) + if previous is not None: + raise ValueError( + f"Series Lab contains duplicate id {item_id} at {previous} and {path}" + ) + seen[item_id] = path + + def require_known(value: Any, known: set[str], path: str, kind: str, *, optional: bool = False) -> None: + item_id = str(value or "").strip() + if optional and not item_id: + return + if item_id not in known: + raise ValueError(f"{path} references unknown {kind} {item_id or ''}") + + register(project, "series") + for collection in ("characters", "relationships", "locations", "props", "seasons"): + for index, item in enumerate(_objects(project.get(collection))): + register(item, f"{collection}[{index}]") + for variants_key in ("wardrobeVariants", "variants"): + for variant_index, variant in enumerate(_objects(item.get(variants_key))): + register(variant, f"{collection}[{index}].{variants_key}[{variant_index}]") + character_ids = {str(item["id"]) for item in _objects(project.get("characters"))} + location_ids = {str(item["id"]) for item in _objects(project.get("locations"))} + prop_ids = {str(item["id"]) for item in _objects(project.get("props"))} + season_ids = {str(item["id"]) for item in _objects(project.get("seasons"))} + for index, relationship in enumerate(_objects(project.get("relationships"))): + require_known( + relationship.get("fromCharacterId"), character_ids, + f"relationships[{index}].fromCharacterId", "character", + ) + require_known( + relationship.get("toCharacterId"), character_ids, + f"relationships[{index}].toCharacterId", "character", + ) + for index, prop in enumerate(_objects(project.get("props"))): + require_known( + prop.get("ownerCharacterId"), character_ids, + f"props[{index}].ownerCharacterId", "character", optional=True, + ) + canon = project.get("canon") if isinstance(project.get("canon"), dict) else {} + for collection in ("immutableRules", "currentFacts", "longArcs", "timeline"): + for index, item in enumerate(_objects(canon.get(collection))): + register(item, f"canon.{collection}[{index}]") + for episode_index, episode in enumerate( + item for item in project.get("episodesById", {}).values() if isinstance(item, dict) + ): + register(episode, f"episodes[{episode_index}]") + require_known( + episode.get("seasonId"), season_ids, + f"episodes[{episode_index}].seasonId", "season", + ) + scene_ids: set[str] = set() + for scene_index, scene in enumerate(_objects(episode.get("script"))): + register(scene, f"episodes[{episode_index}].script[{scene_index}]") + scene_ids.add(str(scene["id"])) + require_known( + scene.get("locationId"), location_ids, + f"episodes[{episode_index}].script[{scene_index}].locationId", + "location", optional=True, + ) + for participant_index, character_id in enumerate(scene.get("participatingCharacterIds", [])): + require_known( + character_id, character_ids, + f"episodes[{episode_index}].script[{scene_index}].participatingCharacterIds[{participant_index}]", + "character", + ) + for beat_index, beat in enumerate(_objects(scene.get("beats"))): + register(beat, f"episodes[{episode_index}].script[{scene_index}].beats[{beat_index}]") + for line_index, line in enumerate(_objects(scene.get("dialogue"))): + register(line, f"episodes[{episode_index}].script[{scene_index}].dialogue[{line_index}]") + require_known( + line.get("characterId"), character_ids, + f"episodes[{episode_index}].script[{scene_index}].dialogue[{line_index}].characterId", + "character", + ) + shot_ids = { + str(shot.get("id")) for shot in _objects(episode.get("shots")) + if shot.get("id") + } + for shot_index, shot in enumerate(_objects(episode.get("shots"))): + register(shot, f"episodes[{episode_index}].shots[{shot_index}]") + if shot.get("sceneId") not in scene_ids: + raise ValueError( + f"Shot {shot.get('id')} uses unknown scene {shot.get('sceneId')}" + ) + for key in ("visibleCharacterIds", "speakingCharacterIds"): + for character_index, character_id in enumerate(shot.get(key, [])): + require_known( + character_id, character_ids, + f"episodes[{episode_index}].shots[{shot_index}].{key}[{character_index}]", + "character", + ) + require_known( + shot.get("primarySpeakerId"), character_ids, + f"episodes[{episode_index}].shots[{shot_index}].primarySpeakerId", + "character", optional=True, + ) + require_known( + shot.get("locationId"), location_ids, + f"episodes[{episode_index}].shots[{shot_index}].locationId", + "location", optional=True, + ) + for prop_index, prop_id in enumerate(shot.get("propIds", [])): + require_known( + prop_id, prop_ids, + f"episodes[{episode_index}].shots[{shot_index}].propIds[{prop_index}]", + "prop", + ) + for key in ("wardrobeByCharacterId", "emotionalStateByCharacterId"): + values = shot.get(key) if isinstance(shot.get(key), dict) else {} + for character_id in values: + require_known( + character_id, character_ids, + f"episodes[{episode_index}].shots[{shot_index}].{key}", + "character", + ) + require_known( + shot.get("continuityFromShotId"), shot_ids, + f"episodes[{episode_index}].shots[{shot_index}].continuityFromShotId", + "shot", optional=True, + ) + attempt_ids: set[str] = set() + for line_index, line in enumerate(_objects(shot.get("dialogueBeats"))): + register(line, f"episodes[{episode_index}].shots[{shot_index}].dialogue[{line_index}]") + require_known( + line.get("characterId"), character_ids, + f"episodes[{episode_index}].shots[{shot_index}].dialogue[{line_index}].characterId", + "character", + ) + for attempt_index, attempt in enumerate(_objects(shot.get("attempts"))): + register(attempt, f"episodes[{episode_index}].shots[{shot_index}].attempts[{attempt_index}]") + attempt_ids.add(str(attempt["id"])) + approved_attempt_id = str(shot.get("approvedAttemptId") or "") + if approved_attempt_id and approved_attempt_id not in attempt_ids: + raise ValueError( + f"Shot {shot.get('id')} approves unknown attempt {approved_attempt_id}" + ) + for asset_index, asset in enumerate( + item for item in project.get("assets", {}).values() if isinstance(item, dict) + ): + register(asset, f"assets[{asset_index}]") + asset_ids = { + str(asset["id"]) + for asset in project.get("assets", {}).values() + if isinstance(asset, dict) + } + owner_ids = { + "series": {str(project["id"])}, + "character": character_ids, + "location": location_ids, + "prop": prop_ids, + "episode": { + str(episode["id"]) for episode in project.get("episodesById", {}).values() + if isinstance(episode, dict) + }, + "shot": { + str(shot["id"]) + for episode in project.get("episodesById", {}).values() if isinstance(episode, dict) + for shot in _objects(episode.get("shots")) + }, + "attempt": { + str(attempt["id"]) + for episode in project.get("episodesById", {}).values() if isinstance(episode, dict) + for shot in _objects(episode.get("shots")) + for attempt in _objects(shot.get("attempts")) + }, + } + for asset_index, asset in enumerate( + item for item in project.get("assets", {}).values() if isinstance(item, dict) + ): + owner_type = str(asset.get("ownerType") or "series") + require_known( + asset.get("ownerId"), owner_ids.get(owner_type, set()), + f"assets[{asset_index}].ownerId", owner_type, + ) + for collection in ("characters", "locations", "props"): + for entity_index, entity in enumerate(_objects(project.get(collection))): + for reference_index, asset_id in enumerate(entity.get("referenceAssetIds", [])): + require_known( + asset_id, asset_ids, + f"{collection}[{entity_index}].referenceAssetIds[{reference_index}]", + "asset", + ) + for episode_index, episode in enumerate( + item for item in project.get("episodesById", {}).values() if isinstance(item, dict) + ): + for shot_index, shot in enumerate(_objects(episode.get("shots"))): + policy = shot.get("referencePolicy") if isinstance(shot.get("referencePolicy"), dict) else {} + for key in ("manualIncludeAssetIds", "manualExcludeAssetIds"): + for reference_index, asset_id in enumerate(policy.get(key, [])): + require_known( + asset_id, asset_ids, + f"episodes[{episode_index}].shots[{shot_index}].referencePolicy.{key}[{reference_index}]", + "asset", + ) + for attempt_index, attempt in enumerate(_objects(shot.get("attempts"))): + for output_index, asset_id in enumerate(attempt.get("outputAssetIds", [])): + require_known( + asset_id, asset_ids, + f"episodes[{episode_index}].shots[{shot_index}].attempts[{attempt_index}].outputAssetIds[{output_index}]", + "asset", + ) + + def normalize_series_project(value: Any, key: str, workspace_id: str) -> dict: if not isinstance(value, dict): raise ValueError("Every Series Lab project must be a JSON object") @@ -437,6 +812,15 @@ def normalize_series_project(value: Any, key: str, workspace_id: str) -> dict: project.get("defaultEpisodeDurationSeconds"), 75, 15 ))), "language": _text(project.get("language"), "Español"), + "spokenLanguage": _text( + project.get("spokenLanguage"), _text(project.get("language"), "Español de España") + ), + "protagonistConsistency": project.get("protagonistConsistency") is True, + "protagonistCharacterId": ( + _text(project.get("protagonistCharacterId")) + if any(item.get("id") == project.get("protagonistCharacterId") for item in characters) + else "" + ), "genre": _text(project.get("genre")), "tone": _text(project.get("tone")), "audience": _text(project.get("audience"), "General"), @@ -463,6 +847,7 @@ def normalize_series_project(value: Any, key: str, workspace_id: str) -> dict: "createdAt": _text(project.get("createdAt"), now), "updatedAt": _text(project.get("updatedAt"), now), }) + _validate_project_graph_ids(project) return project @@ -482,6 +867,8 @@ def normalize_series_library(value: Any, workspace_id: str | None = None) -> dic projects: dict[str, dict] = {} for key, raw_project in raw_projects.items(): project = normalize_series_project(raw_project, str(key), authoritative_workspace) + if project["id"] in projects: + raise ValueError(f"Series library contains duplicate project id {project['id']}") projects[project["id"]] = project order = [item for item in _unique_ids(value.get("seriesOrder")) if item in projects] order.extend(item for item in projects if item not in order) @@ -598,6 +985,9 @@ def create_episode_canon_snapshot(series: dict) -> dict: "visualStyle": _text(series.get("visualStyle")), "characterVisualStyle": _text(series.get("characterVisualStyle")), "cameraLanguage": _text(series.get("cameraLanguage")), + "spokenLanguage": _text(series.get("spokenLanguage"), _text(series.get("language"))), + "protagonistConsistency": series.get("protagonistConsistency") is True, + "protagonistCharacterId": _text(series.get("protagonistCharacterId")), "allowClipText": series.get("allowClipText") is True, "provider": provider, "capabilitySnapshot": capability, @@ -668,7 +1058,7 @@ def create_series_episode(series: dict, season_id: str | None = None, **override if isinstance(item, dict) and item.get("seasonId") == season.get("id") ] now = _now() - episode_id = f"episode_{uuid.uuid4().hex[:10]}" + episode_id = _new_uid("episode") snapshot = create_episode_canon_snapshot(series) episode = { "id": episode_id, "seasonId": str(season["id"]), @@ -697,7 +1087,7 @@ def import_story_project(story: dict, workspace_id: str = "default") -> dict: if not isinstance(story, dict): raise ValueError("Story import requires one Story Lab project") now = _now() - suffix = uuid.uuid4().hex[:10] + suffix = uuid.uuid4().hex series_id = f"series_{suffix}" story_assets = story.get("assets") if isinstance(story.get("assets"), dict) else {} assets: dict[str, dict] = {} @@ -832,7 +1222,7 @@ def duplicate_series_project(series: dict) -> dict: duplicate = copy.deepcopy(series) now = _now() old_id = _id(duplicate.get("id")) - new_id = f"series_{uuid.uuid4().hex[:10]}" + new_id = _new_uid("series") duplicate.update({ "id": new_id, "title": f"{_text(duplicate.get('title'), 'Untitled series')} (copy)", "revision": 1, "createdAt": now, "updatedAt": now, @@ -938,7 +1328,7 @@ def append_shot_render_attempt( updated = copy.deepcopy(shot) now = _now() attempt = { - "id": f"attempt_{uuid.uuid4().hex[:12]}", "status": "queued", + "id": _new_uid("attempt"), "status": "queued", "prompt": prompt if isinstance(prompt, str) else _text(updated.get("prompt")), "negativePrompt": _text(updated.get("negativePrompt")), "model": str(model), "referenceManifest": copy.deepcopy(manifest), "seed": seed, @@ -984,6 +1374,34 @@ def approve_shot_render_attempt(shot: dict, attempt_id: str) -> dict: return updated +def approve_episode_render_attempts(episode: dict, selections: Any) -> dict: + """Approve a reviewed episode selection atomically on a detached copy.""" + if not isinstance(selections, list) or not selections: + raise ValueError("Select at least one completed Series shot attempt") + if len(selections) > MAX_BULK_ATTEMPT_APPROVALS: + raise ValueError(f"Bulk approval is limited to {MAX_BULK_ATTEMPT_APPROVALS} shots") + updated = copy.deepcopy(episode) + shots = _objects(updated.get("shots")) + shot_indexes = { + str(shot.get("id")): index for index, shot in enumerate(shots) if shot.get("id") + } + selected_shots: set[str] = set() + for selection in selections: + if not isinstance(selection, dict): + raise ValueError("Every bulk approval selection must identify a shot and attempt") + shot_id = _id(selection.get("shotId")) + attempt_id = _id(selection.get("attemptId")) + if shot_id in selected_shots: + raise ValueError(f"Shot {shot_id} appears more than once in bulk approval") + selected_shots.add(shot_id) + shot_index = shot_indexes.get(shot_id) + if shot_index is None: + raise ValueError(f"Series shot {shot_id} not found") + shots[shot_index] = approve_shot_render_attempt(shots[shot_index], attempt_id) + updated["shots"] = shots + return updated + + def reject_shot_render_attempt(shot: dict, attempt_id: str) -> dict: updated = copy.deepcopy(shot) attempt = next(( diff --git a/app/services/series_planning.py b/app/services/series_planning.py index fd514aad..94cb4f70 100644 --- a/app/services/series_planning.py +++ b/app/services/series_planning.py @@ -4,12 +4,57 @@ import copy import json +import math import re +import uuid from typing import Any ALL_PLANNING_STAGES = ["outline", "script", "shots", "canon_validation", "canon_delta"] +SERIES_SHOT_DURATIONS = (5, 10, 15) +SERIES_SHOT_IDEAL_SECONDS = 10 +SERIES_SHOT_MAX_ITEMS = 720 + + +def _planning_uid(prefix: str) -> str: + """Create a server-owned, globally unique Series planning identifier.""" + return f"{prefix}_{uuid.uuid4().hex}" + + +def series_shot_count_profile(episode: dict | None = None) -> dict[str, int | float]: + """Return duration-aware shot bounds using only 5/10/15-second clips.""" + raw_target = (episode or {}).get("targetDurationSeconds", 75) + try: + target = max(5.0, min(3600.0, float(raw_target or 75))) + except (TypeError, ValueError): + target = 75.0 + minimum = max(1, math.ceil(target / max(SERIES_SHOT_DURATIONS))) + maximum = min( + SERIES_SHOT_MAX_ITEMS, + max(minimum, math.ceil(target / min(SERIES_SHOT_DURATIONS))), + ) + ideal = max( + minimum, + min(maximum, int(math.floor(target / SERIES_SHOT_IDEAL_SECONDS + 0.5))), + ) + return { + "target": target, + "minimum": minimum, + "ideal": ideal, + "maximum": maximum, + } + + +def planning_output_token_budget(stage: str, episode: dict | None = None) -> int: + """Scale long-form shot JSON without inflating small planning stages.""" + if stage == "shots": + ideal = int(series_shot_count_profile(episode)["ideal"]) + return min(64000, max(9000, 2000 + ideal * 600)) + if stage == "script": + return 6000 + return 2400 + def planning_stages(scope: str) -> list[str]: if scope == "outline": @@ -31,7 +76,7 @@ def _string_array(max_items: int = 24) -> dict: return {"type": "array", "items": _string(), "maxItems": max_items} -def planning_schema(stage: str) -> dict: +def planning_schema(stage: str, episode: dict | None = None) -> dict: string = _string() dialogue = { "type": "object", @@ -81,14 +126,16 @@ def planning_schema(stage: str) -> dict: }}, "required": ["script"], "additionalProperties": False, } if stage == "shots": + profile = series_shot_count_profile(episode) shot = { "type": "object", "properties": { "id": string, "sceneId": string, "order": {"type": "integer"}, - "durationSeconds": {"type": "number"}, "framing": string, "camera": string, + "durationSeconds": {"type": "number", "enum": list(SERIES_SHOT_DURATIONS)}, + "framing": string, "camera": string, "action": string, "dialogueBeats": {"type": "array", "items": dialogue, "maxItems": 4}, - "visibleCharacterIds": _string_array(4), "speakingCharacterIds": _string_array(2), + "visibleCharacterIds": _string_array(4), "speakingCharacterIds": _string_array(1), "primarySpeakerId": string, "locationId": string, "locationVariantId": string, "wardrobeByCharacterId": {"type": "object"}, "propIds": _string_array(6), "emotionalStateByCharacterId": {"type": "object"}, @@ -107,7 +154,9 @@ def planning_schema(stage: str) -> dict: } return { "type": "object", "properties": {"shots": { - "type": "array", "items": shot, "minItems": 8, "maxItems": 12, + "type": "array", "items": shot, + "minItems": int(profile["minimum"]), + "maxItems": int(profile["maximum"]), }}, "required": ["shots"], "additionalProperties": False, } if stage == "canon_validation": @@ -240,7 +289,7 @@ def canon_preparation_prompt(series: dict, instruction: str = "") -> tuple[str, "Do not claim legal rights, create copyrighted-franchise defaults, or create episode events." ) prompt = ( - "Prepare a persistent series canon for a 60–90 second, 8–12 shot pilot. " + "Prepare a compact persistent series canon that can support short or long episodes. " "Include immutable rules, visual identity locks, named wardrobe/location variants, relationships and long arcs.\n" f"USER DIRECTION: {instruction.strip() or 'Use the saved setup and improve any existing draft canon.'}\n\n" f"SAVED SETUP AND DRAFT CANON:\n{json.dumps(_bounded(context), ensure_ascii=False)}" @@ -736,11 +785,16 @@ def _bounded(value: Any, depth: int = 0) -> Any: def planning_prompt(stage: str, series: dict, episode: dict, instruction: str = "") -> tuple[str, str]: + shot_profile = series_shot_count_profile(episode) + script_scene_ids = [ + str(item.get("id")) for item in episode.get("script", []) + if isinstance(item, dict) and item.get("id") + ] canon_snapshot = episode.get("canonSnapshot") if isinstance(episode.get("canonSnapshot"), dict) else {} context = { "series": { key: series.get(key) for key in ( - "title", "logline", "premise", "format", "language", "genre", "tone", + "title", "logline", "premise", "format", "language", "spokenLanguage", "genre", "tone", "audience", "visualStyle", "characterVisualStyle", "cameraLanguage", "allowClipText", "sourceMode", "masterUniversePrompt", ) @@ -760,7 +814,14 @@ def planning_prompt(stage: str, series: dict, episode: dict, instruction: str = "You are the Series Lab planning engine. Return exactly one JSON object matching the schema. " "CanonSnapshot is immutable evidence, never rewrite it. Use entity IDs exactly as supplied. " "Every speaking character must also be visible. Never invent a reference asset or entity ID. " - "Keep at most two speakers per shot and write short dialogue suitable for best-effort native lip sync. " + "Each shot may contain dialogue from only one character; split every speaker change into a separate shot. " + "Write short dialogue suitable for best-effort native lip sync. " + "Write all generation-facing visual shot fields (prompt, action, framing, camera, and negativePrompt) " + "in English. Keep only dialogueBeats.text in the series spokenLanguage, with natural regional wording. " + "Never put quoted dialogue or instructions to speak in prompt or action; dialogueBeats is the sole speech source. " + "For shots, speakingCharacterIds must be exactly the unique characterId values used by dialogueBeats; " + "never copy every visible character into speakingCharacterIds. If a scene needs three people to speak, " + "cover them across separate single-speaker shots in conversational order. " "Do not mutate canon; canon changes are proposals for later human review." ) requirements = { @@ -770,10 +831,18 @@ def planning_prompt(stage: str, series: dict, episode: dict, instruction: str = "emotion and delivery. Use only supplied location/character IDs." ), "shots": ( - "Create exactly 8–12 shots totaling close to targetDurationSeconds. Assign visibleCharacterIds, " + f"Create about {int(shot_profile['ideal'])} shots (valid range " + f"{int(shot_profile['minimum'])}–{int(shot_profile['maximum'])}) for the " + f"{float(shot_profile['target']):g}-second target. Use only 5, 10, or 15 seconds per shot: " + "prefer 10 seconds, use 5 when the visible action or spoken line comfortably fits, and never exceed " + "15 seconds. Add shots to cover runtime; never make a clip longer to fill the episode. " + "Set every sceneId by copying one exact ID from episode.script; never rename or describe a scene. " + f"The only valid scene IDs are {json.dumps(script_scene_ids, ensure_ascii=False)}. " + "Assign visibleCharacterIds, " "speakingCharacterIds, location/variant, wardrobe and props by ID. Keep renderStrategy auto unless " "a clear explicit strategy is essential. Prompts describe only the shot; do not claim loose portraits " - "are exact first frames." + "are exact first frames. Describe visible action rather than saying that a character talks, asks, replies, " + "sings, mutters, or shouts; put every spoken word only in dialogueBeats.text." ), "canon_validation": ( "Report structured contradictions or continuity risks. Do not rewrite the episode and return an empty " @@ -813,6 +882,112 @@ def _resolve(value: Any, valid_ids: set[str], lookup: dict[str, str]) -> str: return result if result in valid_ids else lookup.get(_token(result), result) +def _split_dialogue_speaker_turns( + shots: list[Any], + character_ids: set[str], + character_lookup: dict[str, str], + character_names: dict[str, str], +) -> list[Any]: + """Split provider-combined conversations into ordered single-speaker clips.""" + expanded: list[Any] = [] + for raw in shots: + if not isinstance(raw, dict): + expanded.append(raw) + continue + dialogue = raw.get("dialogueBeats") if isinstance(raw.get("dialogueBeats"), list) else [] + groups: list[tuple[str, list[Any]]] = [] + for beat in dialogue: + if not isinstance(beat, dict): + # Keep malformed data in place so the authoritative validator + # below returns its precise error instead of silently dropping it. + speaker = "" + else: + speaker = _resolve(beat.get("characterId"), character_ids, character_lookup) + if groups and groups[-1][0] == speaker: + groups[-1][1].append(beat) + else: + groups.append((speaker, [beat])) + distinct = {speaker for speaker, _beats in groups if speaker} + if len(distinct) <= 1: + expanded.append(raw) + continue + + base_id = str(raw.get("id") or f"shot_{len(expanded) + 1}").strip() + previous_id = str(raw.get("continuityFromShotId") or "") + for group_index, (speaker, beats) in enumerate(groups, start=1): + clone = copy.deepcopy(raw) + clone_id = base_id if group_index == 1 else f"{base_id}_turn_{group_index}" + clone["id"] = clone_id + clone["dialogueBeats"] = beats + clone["speakingCharacterIds"] = [speaker] if speaker else [] + clone["primarySpeakerId"] = speaker + clone["continuityFromShotId"] = previous_id + if group_index > 1: + label = character_names.get(speaker, speaker or "the next speaker") + clone["action"] = ( + f"{str(raw.get('action') or '').strip()} Conversational coverage shifts to {label}." + ).strip() + clone["prompt"] = ( + f"{str(raw.get('prompt') or '').strip()} Single-speaker coverage on {label}; " + "the other visible characters listen without speaking." + ).strip() + expanded.append(clone) + previous_id = clone_id + return expanded + + +def _shot_complexity(shot: dict) -> tuple[int, int, int]: + dialogue = shot.get("dialogueBeats") if isinstance(shot.get("dialogueBeats"), list) else [] + dialogue_words = sum( + len(str(beat.get("text") or "").split()) + for beat in dialogue if isinstance(beat, dict) + ) + action_words = len(str(shot.get("action") or "").split()) + return dialogue_words * 2 + action_words, dialogue_words, action_words + + +def _assign_series_shot_durations(shots: list[dict], target: float) -> None: + """Allocate a near-target runtime with deterministic 5/10/15-second clips.""" + if not shots: + return + durations = [SERIES_SHOT_IDEAL_SECONDS for _shot in shots] + target_units = max(len(shots), min(len(shots) * 3, int(math.floor(target / 5.0 + 0.5)))) + current_units = len(shots) * 2 + complexity = [_shot_complexity(shot) for shot in shots] + + if target_units < current_units: + for index in sorted(range(len(shots)), key=lambda value: (complexity[value], value)): + if current_units <= target_units: + break + durations[index] = 5 + current_units -= 1 + elif target_units > current_units: + for index in sorted(range(len(shots)), key=lambda value: (complexity[value], -value), reverse=True): + if current_units >= target_units: + break + durations[index] = 15 + current_units += 1 + + # At an approximately ten-second average, exchange concise coverage for + # longer dialogue/action beats without changing the episode total. + short = [ + index for index, (_score, dialogue_words, action_words) in enumerate(complexity) + if durations[index] == 10 and dialogue_words <= 8 and action_words <= 14 + ] + long = [ + index for index, (_score, dialogue_words, action_words) in enumerate(complexity) + if durations[index] == 10 and (dialogue_words >= 18 or action_words >= 24) + ] + for short_index, long_index in zip(short[:max(1, len(shots) // 4)], reversed(long)): + if short_index == long_index: + continue + durations[short_index] = 5 + durations[long_index] = 15 + + for shot, duration in zip(shots, durations): + shot["durationSeconds"] = duration + + def normalize_planning_result(stage: str, result: Any, series: dict, episode: dict) -> dict: if not isinstance(result, dict): raise ValueError(f"Series Lab {stage} response is not an object") @@ -838,14 +1013,12 @@ def normalize_planning_result(stage: str, result: Any, series: dict, episode: di scenes = normalized.get("script") if not isinstance(scenes, list) or not scenes: raise ValueError("Series script has no scenes") - used: set[str] = set() for index, scene in enumerate(scenes): if not isinstance(scene, dict): raise ValueError(f"Series scene {index + 1} is invalid") - scene_id = str(scene.get("id") or f"scene_{index + 1}").strip() - if scene_id in used: - scene_id = f"scene_{index + 1}" - used.add(scene_id) + # Provider IDs are untrusted aliases. Assign the persisted UID here + # so later stages can only reference one canonical scene identity. + scene_id = _planning_uid("scene") scene["id"] = scene_id scene["order"] = index + 1 scene["locationId"] = _resolve(scene.get("locationId"), location_ids, location_lookup) @@ -859,14 +1032,20 @@ def normalize_planning_result(stage: str, result: Any, series: dict, episode: di if resolved not in participants: participants.append(resolved) scene["participatingCharacterIds"] = participants + beats = scene.get("beats") if isinstance(scene.get("beats"), list) else [] + for beat_index, beat in enumerate(beats): + if not isinstance(beat, dict): + raise ValueError(f"Scene {scene_id} has invalid beat {beat_index + 1}") + beat["id"] = _planning_uid("scene_beat") + scene["beats"] = beats dialogue = scene.get("dialogue") if isinstance(scene.get("dialogue"), list) else [] - for dialogue_index, beat in enumerate(dialogue): + for beat in dialogue: if not isinstance(beat, dict): raise ValueError(f"Scene {scene_id} has invalid dialogue") character_id = _resolve(beat.get("characterId"), character_ids, character_lookup) if character_id not in character_ids: raise ValueError(f"Scene {scene_id} dialogue uses unknown character {character_id}") - beat["id"] = str(beat.get("id") or f"dialogue_{index + 1}_{dialogue_index + 1}") + beat["id"] = _planning_uid("dialogue") beat["characterId"] = character_id if character_id not in participants: participants.append(character_id) @@ -874,25 +1053,77 @@ def normalize_planning_result(stage: str, result: Any, series: dict, episode: di return {"script": scenes} if stage == "shots": shots = normalized.get("shots") - if not isinstance(shots, list) or not 8 <= len(shots) <= 12: - raise ValueError("Complete Series Lab shot plans require 8–12 shots") - scene_ids = { - str(item.get("id")) for item in episode.get("script", []) + if not isinstance(shots, list): + raise ValueError("Complete Series Lab shot plans require a shots array") + character_names = { + str(item.get("id")): str(item.get("name") or item.get("id")) + for item in series.get("characters", []) if isinstance(item, dict) and item.get("id") } - used: set[str] = set() + shots = _split_dialogue_speaker_turns( + shots, character_ids, character_lookup, character_names, + ) + profile = series_shot_count_profile(episode) + if not int(profile["minimum"]) <= len(shots) <= int(profile["maximum"]): + raise ValueError( + f"Series shot plan for {float(profile['target']):g}s requires " + f"{int(profile['minimum'])}–{int(profile['maximum'])} clips at 5–15 seconds each " + f"(ideal about {int(profile['ideal'])}); received {len(shots)}" + ) + provider_shot_ids: dict[str, str] = {} + for index, shot in enumerate(shots): + if not isinstance(shot, dict): + raise ValueError(f"Series shot {index + 1} is invalid") + provider_id = str(shot.get("id") or f"provider_shot_{index + 1}").strip() + if provider_id in provider_shot_ids: + raise ValueError( + f"Series shot plan repeats provider shot ID {provider_id}; every shot alias must be unique" + ) + provider_shot_ids[provider_id] = _planning_uid("shot") + script_scenes = [ + item for item in episode.get("script", []) + if isinstance(item, dict) and item.get("id") + ] + scene_ids = {str(item.get("id")) for item in script_scenes} + scenes_by_dialogue_id: dict[str, set[str]] = {} + scenes_by_location_id: dict[str, list[str]] = {} + for scene in script_scenes: + scene_id = str(scene.get("id")) + location_id = str(scene.get("locationId") or "") + if location_id: + scenes_by_location_id.setdefault(location_id, []).append(scene_id) + for beat in scene.get("dialogue", []): + if isinstance(beat, dict) and beat.get("id"): + scenes_by_dialogue_id.setdefault(str(beat["id"]), set()).add(scene_id) for index, shot in enumerate(shots): if not isinstance(shot, dict): raise ValueError(f"Series shot {index + 1} is invalid") - shot_id = str(shot.get("id") or f"shot_{index + 1}").strip() - if shot_id in used: - shot_id = f"shot_{index + 1}" - used.add(shot_id) + provider_id = str(shot.get("id") or f"provider_shot_{index + 1}").strip() + shot_id = provider_shot_ids[provider_id] shot["id"] = shot_id shot["order"] = index + 1 - shot["sceneId"] = str(shot.get("sceneId") or "") + shot["sceneId"] = str(shot.get("sceneId") or "").strip() + if shot["sceneId"] not in scene_ids: + dialogue_scene_ids: set[str] = set() + for beat in shot.get("dialogueBeats", []): + if not isinstance(beat, dict) or not beat.get("id"): + continue + dialogue_scene_ids.update(scenes_by_dialogue_id.get(str(beat["id"]), set())) + if len(dialogue_scene_ids) == 1: + shot["sceneId"] = next(iter(dialogue_scene_ids)) + else: + resolved_location = _resolve( + shot.get("locationId"), location_ids, location_lookup, + ) + location_scenes = scenes_by_location_id.get(resolved_location, []) + if len(location_scenes) == 1: + shot["sceneId"] = location_scenes[0] if shot["sceneId"] not in scene_ids: - raise ValueError(f"Shot {shot_id} uses unknown scene {shot['sceneId']}") + valid_scene_ids = ", ".join(sorted(scene_ids)) or "(none)" + raise ValueError( + f"Shot {shot_id} uses unknown scene {shot['sceneId']}; " + f"valid scene IDs: {valid_scene_ids}" + ) visible = [] for raw in shot.get("visibleCharacterIds", []): resolved = _resolve(raw, character_ids, character_lookup) @@ -900,15 +1131,59 @@ def normalize_planning_result(stage: str, result: Any, series: dict, episode: di raise ValueError(f"Shot {shot_id} uses unknown visible character {resolved}") if resolved not in visible: visible.append(resolved) - speakers = [] + raw_continuity_id = str(shot.get("continuityFromShotId") or "").strip() + if raw_continuity_id and raw_continuity_id not in provider_shot_ids: + raise ValueError( + f"Shot {shot_id} references unknown continuity shot {raw_continuity_id}" + ) + shot["continuityFromShotId"] = ( + provider_shot_ids[raw_continuity_id] if raw_continuity_id else "" + ) + dialogue = shot.get("dialogueBeats") if isinstance(shot.get("dialogueBeats"), list) else [] + dialogue_speakers = [] + for dialogue_index, beat in enumerate(dialogue): + if not isinstance(beat, dict): + raise ValueError(f"Shot {shot_id} has invalid dialogue") + resolved = _resolve(beat.get("characterId"), character_ids, character_lookup) + if resolved not in character_ids: + raise ValueError(f"Shot {shot_id} dialogue uses unknown character {resolved}") + if resolved not in visible: + raise ValueError(f"Shot {shot_id} speaker {resolved} is not visible") + # Shot dialogue IDs are runtime-local identifiers. Providers + # often copy the source scene beat ID into a later shot, which + # makes canon validation report a false cross-shot reference. + # Re-key them deterministically to the owning shot while + # preserving the actual line, speaker and delivery. + beat["id"] = _planning_uid("shot_dialogue") + beat["characterId"] = resolved + if resolved not in dialogue_speakers: + dialogue_speakers.append(resolved) + if len(dialogue_speakers) > 1: + raise ValueError( + f"Shot {shot_id} contains dialogue from {len(dialogue_speakers)} speakers; " + "split every speaker turn across separate shots" + ) + shot["dialogueBeats"] = dialogue + + # Providers occasionally populate speakingCharacterIds with every + # visible participant. Dialogue beats are the authoritative proof + # of who actually speaks, so repair that harmless schema drift + # without dropping any spoken line. Explicit speakers only fill an + # otherwise silent/underspecified shot, and never exceed the + # single-speaker Series clip contract. + declared_speakers = [] for raw in shot.get("speakingCharacterIds", []): resolved = _resolve(raw, character_ids, character_lookup) if resolved not in visible: raise ValueError(f"Shot {shot_id} speaker {resolved} is not visible") + if resolved not in declared_speakers: + declared_speakers.append(resolved) + speakers = list(dialogue_speakers) + for resolved in declared_speakers: + if len(speakers) >= 1: + break if resolved not in speakers: speakers.append(resolved) - if len(speakers) > 2: - raise ValueError(f"Shot {shot_id} exceeds the two-speaker MVP limit") shot["visibleCharacterIds"] = visible shot["speakingCharacterIds"] = speakers primary = _resolve(shot.get("primarySpeakerId"), character_ids, character_lookup) @@ -925,7 +1200,6 @@ def normalize_planning_result(stage: str, result: Any, series: dict, episode: di if resolved not in resolved_props: resolved_props.append(resolved) shot["propIds"] = resolved_props - shot["durationSeconds"] = max(1, min(30, float(shot.get("durationSeconds") or 8))) shot["renderStrategy"] = shot.get("renderStrategy") if shot.get("renderStrategy") in { "auto", "direct", "first_frame", "references", "first_last" } else "auto" @@ -933,36 +1207,19 @@ def normalize_planning_result(stage: str, result: Any, series: dict, episode: di "mode": "automatic", "manualIncludeAssetIds": [], "manualExcludeAssetIds": [], } shot["attempts"] = [] - target = max(1.0, float(episode.get("targetDurationSeconds") or 75)) - total = sum(float(shot["durationSeconds"]) for shot in shots) - tolerance = max(10.0, target * 0.2) - if total and abs(total - target) > tolerance: - scale = target / total - for shot in shots: - shot["durationSeconds"] = max( - 1.0, min(30.0, round(float(shot["durationSeconds"]) * scale * 2) / 2), - ) - adjusted_total = sum(float(shot["durationSeconds"]) for shot in shots) - remaining = round((target - adjusted_total) * 2) / 2 - for shot in reversed(shots): - if abs(remaining) < 0.25: - break - available = (30.0 - float(shot["durationSeconds"])) if remaining > 0 else ( - float(shot["durationSeconds"]) - 1.0 - ) - delta = min(abs(remaining), available) * (1 if remaining > 0 else -1) - shot["durationSeconds"] = round((float(shot["durationSeconds"]) + delta) * 2) / 2 - remaining = round((remaining - delta) * 2) / 2 - if abs(sum(float(shot["durationSeconds"]) for shot in shots) - target) > tolerance: - raise ValueError( - f"Series shot durations cannot fit the {target:g}s episode target within MVP limits" - ) + _assign_series_shot_durations(shots, float(profile["target"])) return {"shots": shots} if stage == "canon_validation": issues = normalized.get("issues") if not isinstance(issues, list): raise ValueError("Canon validation response has no issues array") - return {"issues": [item for item in issues if isinstance(item, dict)][:30]} + normalized_issues = [] + for item in issues[:30]: + if not isinstance(item, dict): + continue + item["id"] = _planning_uid("continuity_issue") + normalized_issues.append(item) + return {"issues": normalized_issues} if stage == "canon_delta": result_delta = {} existing_ids = { @@ -977,7 +1234,8 @@ def normalize_planning_result(stage: str, result: Any, series: dict, episode: di for index, item in enumerate(items[:12]): if not isinstance(item, dict): continue - item_id = str(item.get("id") or f"fact_{episode['id']}_{index + 1}") + provider_item_id = str(item.get("id") or "") + item_id = _planning_uid("fact") if group == "add" else provider_item_id if group == "change" and item_id not in existing_ids: raise ValueError(f"Canon change references unknown fact {item_id}") result_delta[group].append({ diff --git a/app/services/series_reference_router.py b/app/services/series_reference_router.py index de2b73ab..84f016f7 100644 --- a/app/services/series_reference_router.py +++ b/app/services/series_reference_router.py @@ -80,6 +80,16 @@ def _candidate( } if variant_id: result["variantId"] = variant_id + metadata = asset.get("metadata") if isinstance(asset.get("metadata"), dict) else {} + if result["mediaType"] == "video": + # Identity/location footage often carries unrelated speech. Reference + # video sound is therefore explicit opt-in, never an implicit input. + result["includeAudio"] = metadata.get("includeAudio") is True + elif result["mediaType"] == "audio": + intent = str(metadata.get("audioIntent") or "").strip().lower() + result["audioIntent"] = intent if intent in {"voice", "drive", "style"} else ( + "voice" if entity_type == "character" else "style" + ) return result @@ -139,7 +149,9 @@ def _auto_strategy( if source_mode == "known_universe_experimental" and visible_count == 0: return "direct" if shot.get("continuityFromShotId") and capabilities.get("supportsContinuation"): - return "first_frame" if has_exact_start else "references" + if has_exact_start: + return "first_frame" + return "references" if candidates else "direct" if has_exact_start and capabilities.get("supportsFirstFrame"): return "first_frame" if candidates: @@ -227,16 +239,24 @@ def route_shot_references( break ordered_character_ids: list[tuple[str, int, str, str]] = [] + locked_protagonist = str(series.get("protagonistCharacterId") or "") \ + if series.get("protagonistConsistency") else "" + if locked_protagonist and locked_protagonist in visible: + ordered_character_ids.append(( + locked_protagonist, 1, "recurring_protagonist_identity", + "Optional protagonist identity lock is enabled", + )) if primary: - ordered_character_ids.append((primary, 2, "primary_speaker_identity", "Primary speaker is visible")) + if primary != locked_protagonist: + ordered_character_ids.append((primary, 2, "primary_speaker_identity", "Primary speaker is visible")) for character_id in speaking: - if character_id != primary: + if character_id not in {primary, locked_protagonist}: ordered_character_ids.append(( character_id, 3, "visible_speaking_character_identity", "Speaking character is visible", )) for character_id in visible: - if character_id not in speaking: + if character_id not in speaking and character_id != locked_protagonist: ordered_character_ids.append(( character_id, 4, "visible_character_identity", "Recurring reaction/listening character is visible", @@ -252,7 +272,11 @@ def route_shot_references( if isinstance(shot.get("wardrobeByCharacterId"), dict) else {} entity_refs = _entity_assets(character, assets, str(wardrobe_map.get(character_id) or "") or None) if not entity_refs: - warnings.append(f"Visible character {character.get('name', character_id)} has no approved reference.") + message = f"Visible character {character.get('name', character_id)} has no approved reference." + if character_id == locked_protagonist: + errors.append(message + " The fixed-protagonist mode blocks rendering until its primary portrait is approved.") + else: + warnings.append(message) for asset in entity_refs: candidates.append(_candidate( asset, entity_type="character", entity_id=character_id, @@ -342,12 +366,18 @@ def route_shot_references( deduplicated = [] if len(visible) >= 3 and not exact_start_ids: - warnings.append( - "This crowd cannot be blocked reliably with loose portraits; approve a composed start frame." - ) - errors.append("Approve a composed start frame before rendering this crowd shot.") - if capabilities.get("supportsFirstFrame"): - strategy = "first_frame" + if strategy == "direct": + warnings.append( + "Direct text-to-video will improvise this crowd composition because no approved " + "composed start frame is available." + ) + else: + warnings.append( + "This crowd cannot be blocked reliably with loose portraits; approve a composed start frame." + ) + errors.append("Approve a composed start frame before rendering this crowd shot.") + if capabilities.get("supportsFirstFrame"): + strategy = "first_frame" elif len(visible) == 2 and not exact_start_ids: warnings.append( "Two-character blocking from loose portraits is approximate; use an approved composed start frame when composition matters." @@ -382,8 +412,22 @@ def route_shot_references( media_counts[media_type] += 1 if omitted: warnings.append(f"{len(omitted)} reference(s) were omitted by model or manual limits.") + if strategy == "references" and not any( + item["mediaType"] in {"image", "video"} for item in selected + ): + for item in selected: + omitted.append({ + key: value for key, value in item.items() if key != "priority" + } | {"reason": "Audio cannot be the only Ref2VA reference"}) + selected = [] + warnings.append("Audio-only reference routing is invalid for H3; using direct generation.") if strategy in {"references", "first_frame", "first_last"} and not selected: - errors.append("The selected render strategy has no usable routed references.") + if requested_strategy == "auto": + warnings.append( + "Auto routing found no usable reference file; using direct text-to-video generation." + ) + else: + errors.append("The selected render strategy has no usable routed references.") strategy = "direct" warnings.append("Falling back to direct generation because no usable reference remains.") if strategy in {"first_frame", "first_last"} and not any( @@ -391,6 +435,15 @@ def route_shot_references( ): warnings.append("No exact start image survived reference routing; using reference generation.") strategy = "references" if selected else "direct" + if strategy in {"first_frame", "first_last"}: + allowed_roles = {"composed_start_frame"} + if strategy == "first_last": + allowed_roles.add("composed_end_frame") + unused = [item for item in selected if item.get("referenceRole") not in allowed_roles] + selected = [item for item in selected if item.get("referenceRole") in allowed_roles] + omitted.extend({ + key: value for key, value in item.items() if key != "priority" + } | {"reason": "First-frame mode submits only exact composed frame assets"} for item in unused) first_frame_role = "none" if strategy in {"first_frame", "first_last"}: diff --git a/app/services/series_render.py b/app/services/series_render.py index de94d8da..481ac268 100644 --- a/app/services/series_render.py +++ b/app/services/series_render.py @@ -3,6 +3,7 @@ from __future__ import annotations import copy +import re from typing import Any @@ -17,6 +18,24 @@ ("portrait", "768p"): "768x1344", } +SERIES_SHOT_DURATIONS = (5, 10, 15) + + +def normalize_series_shot_duration(value: Any) -> int: + """Quantize Series clips to the supported 5/10/15-second contract.""" + try: + requested = float(value) + except (TypeError, ValueError): + requested = 10.0 + return min( + SERIES_SHOT_DURATIONS, + key=lambda duration: ( + abs(float(duration) - requested), + 0 if duration == 10 else 1, + duration, + ), + ) + def normalize_series_resolution( value: Any, @@ -28,9 +47,18 @@ def normalize_series_resolution( legacy = str(requested_model or "") == "minimax_h3_legacy" raw = str(value or ("540p" if legacy else "480p")).strip().lower() if legacy: - quality = "768p" if raw in { - "720", "720p", "768", "768p", "1280x704", "1344x768", "768x1344", - } else "540p" + # Keep all four H3 Legacy tiers distinct. This function is called when + # an attempt is frozen and again when its generation payload is built, + # so exact model-aligned canvases must be idempotent: 1280x704 is the + # 720p tier, not a signal to promote the job to the 1344x768 maximum. + if raw in {"480", "480p", "864x480", "480x864"}: + quality = "480p" + elif raw in {"720", "720p", "1280x704", "704x1280"}: + quality = "720p" + elif raw in {"768", "768p", "1344x768", "768x1344"}: + quality = "768p" + else: + quality = "540p" else: quality = "720p" if raw in { "540", "540p", "720", "720p", "768", "768p", "1280x720", @@ -40,53 +68,238 @@ def normalize_series_resolution( def quantize_h3_frames(duration_seconds: Any, *, reference_mode: bool) -> int: - try: - requested = round(max(1.0, float(duration_seconds)) * 24) - except (TypeError, ValueError): - requested = 124 + requested = round(normalize_series_shot_duration(duration_seconds) * 24) # H3 pixel frames use 17*n+5. FL2VA can continue through sliding windows; # Omni is one native request and therefore caps at its 345-frame window. + # Apply the same ceiling to every Series path: the next lattice point is + # 362 frames (15.08s at 24fps), which would violate the hard 15-second + # per-video contract even though the requested duration was nominally 15. requested = min(requested, 345) if reference_mode else requested - return max(107, round((requested - 5) / 17) * 17 + 5) + return min(345, max(107, round((requested - 5) / 17) * 17 + 5)) + + +def _h3_spoken_language(series: dict) -> tuple[str, str]: + from .director.spoken_language import h3_language_tag, normalize_spoken_language + + language = normalize_spoken_language( + series.get("spokenLanguage") or series.get("language") + ) + tag = h3_language_tag(language) or "English" + folded = language.casefold() + if tag == "Spanish": + regional = ( + "Latin American Spanish." + if any(token in folded for token in ("latino", "latin american", "latinoamericano")) + else "Castilian Spanish." + ) + else: + regional = f"Native {language or tag}." + return tag, regional + + +def _h3_scene_description(series: dict, shot: dict) -> str: + parts = [] + if series.get("protagonistConsistency") and series.get("protagonistCharacterId"): + protagonist = next(( + character for character in series.get("characters", []) + if character.get("id") == series.get("protagonistCharacterId") + ), None) + if protagonist: + parts.append( + "Identity lock: " + f"{protagonist.get('name') or 'the protagonist'} matches the approved primary " + "character portrait exactly, preserving face, body design, hair, proportions, " + "and canonical wardrobe" + ) + authored_prompt = " ".join(str(shot.get("prompt") or "").split()).strip() + action = " ".join(str(shot.get("action") or "").split()).strip() + values = [authored_prompt] + if action and action.casefold() not in authored_prompt.casefold(): + values.append(f"Action: {action}") + values.append( + f"Camera: {shot.get('framing')}; {shot.get('camera')}" + if shot.get("framing") or shot.get("camera") else "" + ) + for value in values: + text = " ".join(str(value or "").split()).strip() + if text: + parts.append(text) + if not series.get("allowClipText"): + parts.append( + "No captions, subtitles, signs, interface text, or floating words are visible" + ) + return ". ".join(part.rstrip(". ") for part in parts if part) + "." + + +def _h3_dialogue_timing_hint(word_count: int, duration_seconds: float) -> str: + """Anchor sparse speech so H3 does not treat the whole clip as vocal time.""" + duration = max(0.0, float(duration_seconds or 0)) + spoken_duration = max(0.8, word_count / 2.1) + if not word_count or duration <= 0 or spoken_duration >= duration * 0.6: + return "" + start = min(1.0, max(0.0, duration * 0.1)) + end = min(duration - 0.25, start + spoken_duration) + return f"From {start:.2f} to {end:.2f} seconds," + + +def _h3_dialogue_description(series: dict, shot: dict, character_names: dict[str, str]) -> str: + language_tag, accent_direction = _h3_spoken_language(series) + beats = [ + beat for beat in ( + shot.get("dialogueBeats", []) if isinstance(shot.get("dialogueBeats"), list) else [] + ) + if isinstance(beat, dict) and str(beat.get("text") or "").strip() + ] + if not beats: + return "" + + duration = normalize_series_shot_duration(shot.get("durationSeconds")) + word_count = sum( + len(re.findall(r"\b[\w’'-]+\b", str(beat.get("text") or ""), flags=re.UNICODE)) + for beat in beats + ) + timing_hint = _h3_dialogue_timing_hint(word_count, duration) + speaker_ids: dict[str, str] = {} + lines = [accent_direction] + if timing_hint: + lines.append(timing_hint) + for beat in beats: + character_id = str(beat.get("characterId") or beat.get("speaker") or "Speaker") + speaker_ids.setdefault(character_id, f"S{len(speaker_ids) + 1}") + speaker = character_names.get(character_id, str(beat.get("speaker") or character_id)) + emotion = " ".join(str(beat.get("emotion") or "natural").split()) + delivery = " ".join(str(beat.get("delivery") or "natural delivery").split()) + dialogue = str(beat.get("text") or "").strip() + lines.append( + f"{speaker} ({speaker_ids[character_id]}), {emotion}, {delivery}: " + f"[{language_tag}] {dialogue}" + ) + return " ".join(lines) + + +def series_dialogue_preflight_issues(shot: dict) -> list[str]: + """Return deterministic issues that would make native H3 speech unreliable.""" + beats = [ + beat for beat in ( + shot.get("dialogueBeats", []) if isinstance(shot.get("dialogueBeats"), list) else [] + ) + if isinstance(beat, dict) and str(beat.get("text") or "").strip() + ] + issues: list[str] = [] + words = 0 + for beat in beats: + dialogue = str(beat.get("text") or "").strip() + if re.search(r")", dialogue, flags=re.IGNORECASE): + issues.append("dialogue contains the reserved control tag") + words += len(re.findall(r"\b[\w’'-]+\b", dialogue, flags=re.UNICODE)) + duration = normalize_series_shot_duration(shot.get("durationSeconds")) + budget = duration * 2 + if words > budget: + issues.append( + f"dialogue has {words} words but a {duration}s H3 shot supports about {budget}" + ) + return issues -def shot_generation_prompt(series: dict, shot: dict) -> str: +def _h3_reference_sections(manifest: dict, character_names: dict[str, str]) -> tuple[str, str, str]: + selected = manifest.get("selected") if isinstance(manifest.get("selected"), list) else [] + definitions = [] + retention = [] + picture_index = 0 + video_index = 0 + audio_index = 0 + for subject_index, item in enumerate((item for item in selected if isinstance(item, dict)), start=1): + media_type = str(item.get("mediaType") or "image") + if media_type == "video": + paired_audio = "" + if item.get("includeAudio") is True: + audio_index += 1 + paired_audio = f" and its synchronized