diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f06a7fbb6..be8584d46 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,7 +30,7 @@ jobs: guard: name: Clean-repo guard + Python checks runs-on: ubuntu-24.04 - timeout-minutes: 20 + timeout-minutes: 10 steps: - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 @@ -48,47 +48,112 @@ jobs: # compileall only compiles (never imports), so it needs no deps and # skips heavy vendored model code. Covers the surface we edit. run: python -m compileall -q app/services app/launch.py scripts + + python-tests-a: + name: Python tests A + runs-on: ubuntu-24.04 + timeout-minutes: 20 + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + with: + python-version: "3.10" + cache: pip + cache-dependency-path: | + scripts/ci-python-requirements.txt + scripts/ci-python-torch-cpu.txt + app/requirements.txt + app/runtime/locks/*.txt + - name: Install lightweight test dependencies + run: python -m pip install -r scripts/ci-python-requirements.txt + - name: Install CPU tensor test runtime + run: python -m pip install -r scripts/ci-python-torch-cpu.txt + - name: Cache apt archives + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ~/.cache/hocus-apt-archives + key: ${{ runner.os }}-${{ runner.arch }}-ubuntu-24.04-ffmpeg + restore-keys: | + ${{ runner.os }}-${{ runner.arch }}-ubuntu-24.04-ffmpeg + ${{ runner.os }}-${{ runner.arch }}-apt-ffmpeg- + - name: Install ffmpeg for media tests + run: | + set -euo pipefail + cache_dir="${HOME}/.cache/hocus-apt-archives" + mkdir -p "${cache_dir}" + sudo mkdir -p /var/cache/apt/archives/partial + if ls "${cache_dir}"/*.deb >/dev/null 2>&1; then + sudo cp "${cache_dir}"/*.deb /var/cache/apt/archives/ || true + fi + sudo apt-get update + sudo DEBIAN_FRONTEND=noninteractive apt-get install -y ffmpeg + cp /var/cache/apt/archives/*.deb "${cache_dir}/" 2>/dev/null || true + - name: Python test suite (shard A) + run: | + set -euo pipefail + python scripts/select_local_tests.py --group python-a > "${RUNNER_TEMP}/shard-a.txt" + test -s "${RUNNER_TEMP}/shard-a.txt" + python -m pytest -q --junitxml=pytest-shard-a.xml $(cat "${RUNNER_TEMP}/shard-a.txt") + - name: Upload shard A report + if: always() + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + with: + name: pytest-shard-a + path: pytest-shard-a.xml + if-no-files-found: ignore + + python-tests-b: + name: Python tests B + runs-on: ubuntu-24.04 + timeout-minutes: 20 + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + with: + python-version: "3.10" + cache: pip + cache-dependency-path: | + scripts/ci-python-requirements.txt + scripts/ci-python-torch-cpu.txt + app/requirements.txt + app/runtime/locks/*.txt - name: Install lightweight test dependencies - run: >- - python -m pip install - "starlette==0.46.1" - "soundfile==0.13.1" - "numpy==2.2.6" - "opencv-python-headless==4.12.0.88" - "Pillow==11.3.0" - "requests==2.32.4" - "accelerate==1.12.0" - "av==16.1.0" - "diffusers==0.36.0" - "decord==0.6.0" - "einops==0.8.2" - "fastapi==0.115.12" - "ffmpeg-python==0.2.0" - "imageio==2.37.2" - "imageio-ffmpeg==0.6.0" - "json_repair==0.59.5" - "mmgp==3.7.6" - "onnxruntime==1.23.2" - "pydantic==2.10.6" - "psutil==7.2.2" - "pytest==8.3.5" - "rembg==2.0.65" - "tqdm==4.67.3" - "transformers==4.57.1" - "websocket-client==1.9.0" + run: python -m pip install -r scripts/ci-python-requirements.txt - name: Install CPU tensor test runtime - run: >- - python -m pip install - "torch==2.7.0+cpu" - "torchaudio==2.7.0+cpu" - "torchvision==0.22.0+cpu" - --extra-index-url https://download.pytorch.org/whl/cpu + run: python -m pip install -r scripts/ci-python-torch-cpu.txt + - name: Cache apt archives + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ~/.cache/hocus-apt-archives + key: ${{ runner.os }}-${{ runner.arch }}-ubuntu-24.04-ffmpeg + restore-keys: | + ${{ runner.os }}-${{ runner.arch }}-ubuntu-24.04-ffmpeg + ${{ runner.os }}-${{ runner.arch }}-apt-ffmpeg- - name: Install ffmpeg for media tests - run: sudo apt-get update && sudo apt-get install -y ffmpeg - - name: Pytest collection - run: python -m pytest --collect-only -q - - name: Python test suite - run: python -m pytest -q + run: | + set -euo pipefail + cache_dir="${HOME}/.cache/hocus-apt-archives" + mkdir -p "${cache_dir}" + sudo mkdir -p /var/cache/apt/archives/partial + if ls "${cache_dir}"/*.deb >/dev/null 2>&1; then + sudo cp "${cache_dir}"/*.deb /var/cache/apt/archives/ || true + fi + sudo apt-get update + sudo DEBIAN_FRONTEND=noninteractive apt-get install -y ffmpeg + cp /var/cache/apt/archives/*.deb "${cache_dir}/" 2>/dev/null || true + - name: Python test suite (shard B) + run: | + set -euo pipefail + python scripts/select_local_tests.py --group python-b > "${RUNNER_TEMP}/shard-b.txt" + test -s "${RUNNER_TEMP}/shard-b.txt" + python -m pytest -q --junitxml=pytest-shard-b.xml $(cat "${RUNNER_TEMP}/shard-b.txt") + - name: Upload shard B report + if: always() + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + with: + name: pytest-shard-b + path: pytest-shard-b.xml + if-no-files-found: ignore ui-check: name: UI tests + lint + type-check + build @@ -103,6 +168,7 @@ jobs: cache: npm cache-dependency-path: ui/package-lock.json - name: Install UI deps + id: ui-deps working-directory: ui run: npm ci - name: First-party LOC and complexity ratchet @@ -111,6 +177,12 @@ jobs: # with the previous tip (github.event.before). Never fall back to # the historical dashboard: that paints merge commits red. BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }} + BASE_BRANCH: ${{ github.event.pull_request.base.ref }} + SOURCE_BRANCH: ${{ github.event.pull_request.head.ref }} + SOURCE_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + BASE_REPOSITORY: ${{ github.event.pull_request.base.repo.full_name }} + SOURCE_REPOSITORY: ${{ github.event.pull_request.head.repo.full_name }} + EVENT_REPOSITORY: ${{ github.event.repository.full_name }} run: | set -euo pipefail if [ -z "${BASE_SHA:-}" ] || [ "${BASE_SHA}" = "0000000000000000000000000000000000000000" ]; then @@ -130,17 +202,24 @@ jobs: uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: code-health-report - path: code-health.md + path: | + code-health.md + code-health-integration.json if-no-files-found: ignore # Split `npm run check` so a hung runner is visible in the failing step # instead of one opaque 6-hour job (GitHub's default timeout). + # Collect all validation results even when the ratchet or an earlier + # check fails. A failed step still fails this job and CI required. - name: UI tests + if: ${{ !cancelled() && steps.ui-deps.outcome == 'success' }} working-directory: ui run: npm test - name: Lint with zero warnings + if: ${{ !cancelled() && steps.ui-deps.outcome == 'success' }} working-directory: ui run: npm run lint -- --max-warnings=0 - name: Type-check, build and bundle budget + if: ${{ !cancelled() && steps.ui-deps.outcome == 'success' }} working-directory: ui run: python ../scripts/build_ui.py && python ../scripts/build_ui.py --check && npm run budget @@ -167,8 +246,26 @@ jobs: - name: Install Chrome for native H.264 and AAC speech export checks working-directory: ui run: npx playwright install chrome + - name: Cache apt archives + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ~/.cache/hocus-apt-archives + key: ${{ runner.os }}-${{ runner.arch }}-ubuntu-24.04-ffmpeg + restore-keys: | + ${{ runner.os }}-${{ runner.arch }}-ubuntu-24.04-ffmpeg + ${{ runner.os }}-${{ runner.arch }}-apt-ffmpeg- - name: Install FFmpeg for Linux scene audio finalization - run: sudo apt-get update && sudo apt-get install -y ffmpeg + run: | + set -euo pipefail + cache_dir="${HOME}/.cache/hocus-apt-archives" + mkdir -p "${cache_dir}" + sudo mkdir -p /var/cache/apt/archives/partial + if ls "${cache_dir}"/*.deb >/dev/null 2>&1; then + sudo cp "${cache_dir}"/*.deb /var/cache/apt/archives/ || true + fi + sudo apt-get update + sudo DEBIAN_FRONTEND=noninteractive apt-get install -y ffmpeg + cp /var/cache/apt/archives/*.deb "${cache_dir}/" 2>/dev/null || true - name: UI E2E working-directory: ui run: npm run test:e2e @@ -195,9 +292,14 @@ jobs: - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: python-version: "3.10" + cache: pip + cache-dependency-path: | + scripts/ci-python-windows-requirements.txt + app/requirements.txt + app/runtime/locks/*.txt - name: Runtime isolation and native Windows launcher shell checks run: | - python -m pip install "pytest==8.3.5" "setuptools==80.9.0" + python -m pip install -r scripts/ci-python-windows-requirements.txt python -m pytest tests/test_runtime_profiles.py tests/test_launcher_compatibility.py tests/test_ui_distribution.py -q - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 with: @@ -256,7 +358,7 @@ jobs: ci-required: name: CI required if: always() - needs: [guard, ui-check, ui-e2e, ui-speech-windows] + needs: [guard, python-tests-a, python-tests-b, ui-check, ui-e2e, ui-speech-windows] runs-on: ubuntu-24.04 timeout-minutes: 5 steps: @@ -265,6 +367,8 @@ jobs: run: | python3 scripts/ci_required.py \ "Clean-repo guard + Python checks=${{ needs.guard.result }}" \ + "Python tests A=${{ needs.python-tests-a.result }}" \ + "Python tests B=${{ needs.python-tests-b.result }}" \ "UI tests + lint + type-check + build=${{ needs.ui-check.result }}" \ "UI E2E boot (Chromium + simulated API)=${{ needs.ui-e2e.result }}" \ "Speech E2E Windows (real H.264 + AAC)=${{ needs.ui-speech-windows.result }}" diff --git a/.gitignore b/.gitignore index 91d73a207..8f0e4bae3 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,12 @@ ui/blob-report/ # Private planning notes and local-only development artefacts. /internal/ /comunicaciones/ +# Temporary agent handoffs and unapplied integration instructions. +/H[0-9][0-9]_HANDOFF.md +/INTEGRATION*.patch +/PR_BODY.md +/ui/src/features/diagnostics/INTEGRATION.patch +/code-health-integration.json # Pinokio runtime logs/ diff --git a/README.md b/README.md index c571f344d..19e452c29 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,8 @@ HocusPocus is an experimental, **non-commercial** fork of [Blizaine/Maestro](htt The **HocusPocus** mark is a quill shaping a cube: imagination becoming a buildable world. The UI is English and Spanish. +Open **Help / Ayuda** next to Settings for the in-app tutorial, with screenshots of the layout, generation, studios and queue. Its ES/EN selector changes the UI language. Use Tab and Shift+Tab to navigate the dialog; Escape closes it and returns focus to Help. +

Gandalf and Tentri in a HocusPocus Video 3D scene

@@ -96,12 +98,34 @@ Gandalf speaking in that world (image lips on the mesh, not a baked video): The **Wizard** is an in-app director: “open the concert scene”, “prepare a 3D showcase”, “make a 5-second clip of the cube in the rain”. **MCP** exposes the same jobs to external agents (image, video, SFX, scenes, receipts). Switching the footer workspace while a Wizard scene is still loading will **not** stomp the compositor or wipe undo. +Connect through **Settings → Integrations → Hocuspocus MCP**, using the app's address plus `/api/v1/mcp` and the MCP Bearer token. This is Hocuspocus's shared tool server, including generation, assets, collections and scenes supported by the installation. The historical `/api/v1/wangp/mcp` URL remains an alias for existing clients. See the [MCP connection guide](docs/development/SCENE_EFFECTS_AND_MCP.md#enable-and-connect-mcp). + **Example.** In 3D Video, ask the Wizard to open a saved scene by name and select a layer. If you change output folder mid-load, it aborts instead of importing into the wrong world. +Wizard interprets your intended outcome using the conversation and current project. Describe what you want in your own words: it can explain, ask for essential missing context, or plan supported actions. Questions remain visible even when it also opens a lab. Once a series request has creative direction, Wizard can propose missing titles and plot details and save a first episode draft without another interview. Its receipt includes the saved series and episode premises. Opening Series Lab alone does not generate an episode's media. + +In **Series Lab → Canon**, generate reference images from each character or location's description and the series style. Use **Shots → Generate all missing references** to prepare the episode's characters and environments in a batch. Approve the images with the canon, then click **Use approved references in this episode** directly in Shots. Existing series images are reused without another generation. **Setup → Allowed production methods** lets you combine AI video, 2D animation, 3D scenes and imported clips; each shot has its own method and production controls. See [Series production and references](docs/series-lab/IMPLEMENTATION.md#reference-images-and-mixed-production). + +For 2D/3D animation, prepare both environments and characters. Each shot shows its environment selector and reference previews, and opens the editor once the episode has approved images for the environment and every visible character. Preparation shortcuts lead directly to the corresponding Bible cards. An establishing shot can use just its environment. + +**Generate all / Regenerate all** in **Series Lab → Shots** prepares editable 2D scenes and MP4 takes. Each shot also has **Regenerate this shot**. The app removes character backgrounds, uses saved voices and synchronizes mouths to each isolated recording with the offline Rhubarb engine. English recordings also use the script; other languages use phonetic recognition. New speaking-shot preparation requires all nine mouth positions; previously rendered clips and imported four-mouth scenes remain usable. The **20 mouth styles** provide complete nine-position packs, and Character Creator shows missing slots before generation. Download individual styles or all 20 as PNG packs from Character Creator. Saving a character creates a reusable resting still with the selected mouth while retaining the mouthless animation base. Configured listeners and silent shots use that resting mouth too. + +Regeneration preserves saved motion and audio and appends unapproved versions; approved takes remain available. Save the character workshop, return to Shots and click **Regenerate all** to update existing scenes. Missing setup links directly to the character. Keep the tab open during the batch. Completed shots release their temporary recovery copies after the editable scene and video are saved; unsaved editor changes and failed preparations retain their backups. Install/Update prepares the pinned offline engine; **Pinokio → Advanced → Repair offline lip sync** repairs it separately. See [2D speech quality and mouth packs](docs/character-kits/SPEECH_QUALITY.md). + +**Results** separates approved references from pending video takes and links to each incomplete item. **Generate AI draft takes** leaves its outputs awaiting review; 2D/3D and imported shots have their own production shortcuts. + +You can also enable production methods directly in **Series Lab → Shots**. For an existing episode, select an enabled method and use **Apply to shots without a take** to assign it across unfinished shots; completed and active takes are preserved. + +Location image prompts describe empty environments. Series Lab separates the physical setting and rendering style from character design and narrative occupants before generating. Use **Prepare environment prompt** in the location card to review the exact prompt first. + +Each **Canon → Characters** card also shows voice, 2D lip-sync and 3D lip-sync readiness. **Configure in Character Creator** opens a dedicated view for that exact character, carries over its reference image, and links the saved configuration by ID. The Series card keeps its library selector. **Save everything and return to Series Lab** saves voice, mouth images and placement together, then returns to the source character. A failed save retains the draft and keeps the editor open; a fully saved session can yield to the next character even after switching tabs. A voice can be saved without a 3D model. Save the character before opening its 2D mouth workshop or 3D face calibration. The mouth workshop has a rectangle whose width and height can be adjusted independently, a visible mouth-pack catalog with previews, explicit AI generation buttons, and a one-click prerecorded English voice sample for previewing mouth movement without generating speech. **Apply placement to all mouths** copies the current position, scale and rotation to all nine mouth shapes. **Try with their voice** previews the full isolated recording with the same phonetic analyzer as native shots, including pauses and resting-mouth closure; the separate quick text preview is approximate. Eyes and blinking are optional: keep the original drawing unless you want to add overlays. Review the cleaned base and mouth variants, then save the speech character. Dialogue shots open Character Creator directly; the advanced voice table links to the character card. AI video with native audio continues to use its generator's voice; the reusable TTS preset is used in the speech editor. + ### Finish without regenerating **Video Editor** trims, splits and reorders clips you already like (H3 MP4s, compositor exports, series handoffs). Export is a queued FFmpeg job. Guide: [Video Editor](docs/video-editor/HOWUSEIT.md). +**Studio Tools** post-process an existing image or clip (FlashVSR/Lanczos upscale, SeedVC revoice, rembg) and always write a new file. Guide: [Studio Tools](docs/tools/HOWUSEIT.md). + **Edits** (experimental): retake a section, outpaint a frame, prompt-driven replace. **Multi-clip** is for longer prompt-by-prompt sequences with overlapping continuity. ### Housekeeping that actually matters @@ -111,7 +135,7 @@ The **Wizard** is an in-app director: “open the concert scene”, “prepare a - **CivitAI LoRA browser** with one-click install, update badges, and auto-written prompting guides from CivitAI / Hugging Face cards. - **Local LLM** (Gemma 4 / Qwen GGUF via llama.cpp) or external OpenAI / Anthropic / compatible endpoints. Unloads after idle so VRAM goes back to generation. - **Themes:** Golden Hour, Classic, Onyx. -- **LAN:** optional share on the local network; optional token auth (`LOREFRAME_LAN_AUTH`). +- **LAN:** optional share on the local network; optional token auth (`LOREFRAME_LAN_AUTH`). Creating series drafts, characters and speech clips also works from plain HTTP network URLs. After updating, reload the browser; Wizard can continue an empty series draft with the same title after a failed creation attempt. - **NSFW** and experimental gates are opt-in. Operator index: [docs/HOWUSEIT.md](docs/HOWUSEIT.md). diff --git a/app/_launch_runtime.py b/app/_launch_runtime.py index 3b0d0ea3b..4c0bb3ae9 100644 --- a/app/_launch_runtime.py +++ b/app/_launch_runtime.py @@ -9043,6 +9043,7 @@ def audio_analyze_status(): "extracting_vocals": 5, "loading_transcription_model": 5, "transcribing": 6, + "aligning_lyrics": 7, "loading_diarization_model": 7, "identifying_speakers": 8, "finalizing": 9, @@ -9511,6 +9512,16 @@ async def director_classify_sections(request: Request): return {"sections": sections, "method": "heuristic"} try: + timed_structure = audio_analysis.structure_from_aligned_lyrics( + analysis.get("lyric_timeline") or [] + ) + if timed_structure: + updated = audio_analysis.replace_sections_with_structure(analysis, timed_structure) + return { + "sections": updated["sections"], + "song_structure": timed_structure, + "method": "lyrics_timeline", + } tagged_structure = llm_service.structure_from_tagged_lyrics(lyrics_hint, duration) if tagged_structure: updated = audio_analysis.replace_sections_with_structure(analysis, tagged_structure) @@ -9936,6 +9947,9 @@ def director_pipeline_resume(pid: str): # ── Director Pipeline Dashboard ─────────────────────────────────────────── +from routers.director_review import create_director_review_router +api.include_router(create_director_review_router(_workspace_dir)) + @api.get("/api/v1/director/pipelines") def list_saved_pipelines(limit: int = 0, offset: int = 0): """List saved pipeline states for the active workspace. @@ -28546,6 +28560,7 @@ def delete_series_episode_endpoint(series_id: str, episode_id: str, workspace: s def import_series_asset_endpoint(series_id: str, body: dict): """Copy a Maestro upload into the authoritative workspace asset tree.""" import shutil + from services.series_production import attach_series_import, existing_generated_reference workspace = _series_library_workspace(body.get("workspace")) source = _story_import_upload_path(str(body.get("uploadPath") or "")) @@ -28569,6 +28584,9 @@ def import_series_asset_endpoint(series_id: str, body: dict): library = _read_series_workspace(workspace) series = copy.deepcopy(_series_project_or_404(library, series_id)) entity = None + existing = existing_generated_reference(series, owner_type, owner_id, extra_metadata) + if existing and body.get("asTake") is not True: + return {"asset": existing, "series": series} collection_name = { "character": "characters", "location": "locations", "prop": "props", }.get(owner_type) @@ -28586,8 +28604,6 @@ def import_series_asset_endpoint(series_id: str, body: dict): for shot in episode.get("shots", []) if isinstance(shot, dict) ): raise HTTPException(status_code=404, detail="Series shot not found") - os.makedirs(os.path.dirname(destination), exist_ok=True) - shutil.copy2(source, destination) asset = { "id": asset_id, "workspaceId": workspace, "kind": kind, "uri": relative, "ownerType": owner_type, "ownerId": owner_id, @@ -28603,15 +28619,12 @@ def import_series_asset_endpoint(series_id: str, body: dict): }), }, } - series.setdefault("assets", {})[asset_id] = asset - if entity is not None: - refs = entity.get("referenceAssetIds") if isinstance(entity.get("referenceAssetIds"), list) else [] - entity["referenceAssetIds"] = [*refs, asset_id] - if owner_type == "character" and not entity.get("primaryReferenceAssetId"): - entity["primaryReferenceAssetId"] = asset_id - entity["approval"] = "draft" - series.setdefault("canon", {})["approval"] = "draft" - series["canon"]["approvedAt"] = "" + try: + attach_series_import(series, asset, as_take=body.get("asTake") is True, source_path=source) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + os.makedirs(os.path.dirname(destination), exist_ok=True) + shutil.copy2(source, destination) now = _series_iso_now() series["revision"] = int(series.get("revision") or 1) + 1 series["updatedAt"] = now @@ -28660,6 +28673,26 @@ def commit_series_canon_endpoint(series_id: str, episode_id: str, body: dict): raise HTTPException(status_code=400, detail=str(exc)) from exc +@api.post("/api/v1/series/{series_id}/episodes/{episode_id}/references/refresh") +def refresh_series_episode_references(series_id: str, episode_id: str, body: dict): + from services.series_library import SeriesConflictError + from services.series_production import refresh_episode_references + workspace = _series_library_workspace(body.get("workspace")) + with _series_library_lock: + library = _read_series_workspace(workspace) + try: + series = refresh_episode_references(_series_project_or_404(library, series_id), episode_id, int(body.get("baseRevision", -1))) + except SeriesConflictError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + except (TypeError, ValueError) as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + series["updatedAt"] = _series_iso_now() + series["episodesById"][episode_id]["updatedAt"] = series["updatedAt"] + library["seriesById"][series_id] = series + stored = _write_series_workspace(workspace, library) + return stored["seriesById"][series_id] + + @api.post("/api/v1/series/{series_id}/canon/approve") def approve_series_canon_endpoint(series_id: str, body: dict): workspace = _series_library_workspace(body.get("workspace")) @@ -29745,6 +29778,7 @@ def _series_asset_local_path(workspace: str, asset: dict) -> str: def _series_render_context(job: dict, item: dict) -> tuple[dict, dict, dict, dict]: from services.series_library import series_for_episode_snapshot + from services.series_production import series_shot_method workspace = str(job["workspace"]) with _series_library_lock: library = _read_series_workspace(workspace) @@ -29758,6 +29792,8 @@ def _series_render_context(job: dict, item: dict) -> tuple[dict, dict, dict, dic ), None) if not isinstance(shot, dict): raise ValueError("Series shot no longer exists") + if series_shot_method(series, shot) != "generated_video": + raise ValueError("This shot no longer permits model video generation") attempt = next(( value for value in shot.get("attempts", []) if isinstance(value, dict) and value.get("id") == item.get("attemptId") @@ -30096,6 +30132,7 @@ def _series_render_candidates(episode: dict, body: dict) -> list[dict]: @api.post("/api/v1/series/{series_id}/episodes/{episode_id}/render/start") def start_series_episode_render(series_id: str, episode_id: str, body: dict): + from services.series_production import series_shot_method 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 ( @@ -30123,10 +30160,11 @@ def start_series_episode_render(series_id: str, episode_id: str, body: dict): routing_series = series_for_episode_snapshot(series, episode) try: candidates = _series_render_candidates(episode, body) + candidates = [shot for shot in candidates if series_shot_method(series, shot) == "generated_video"] except ValueError as exc: 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") + raise HTTPException(status_code=400, detail="No permitted unapproved generated-video shots match this request. Prepare animation shots in their editor or import a completed take.") if any(bool(shot.get("dialogueBeats")) for shot in candidates) and not series.get( "bestEffortLipSyncAcknowledged" ): @@ -36913,6 +36951,15 @@ def _classic_redirect(): from routers.scene_commands import create_scene_commands_router _scene_commands = SceneCommands(_workspace_dir) api.include_router(create_scene_commands_router(_scene_commands)) +from routers.world3d_export import create_world3d_export_router, bind_world3d_renderer_origin +from services.world3d_export import World3DExportService, command_catalog as world3d_export_catalog, command_handlers as world3d_export_handlers +_world3d_export = World3DExportService( + workspace_dir=_workspace_dir, + registry_for=_task_registry, + app_url=os.environ.get("HOCUS_APP_URL", ""), +) +bind_world3d_renderer_origin(api, _world3d_export) +api.include_router(create_world3d_export_router(_world3d_export)) from services.mcp_access import McpAccess from routers.mcp_access import create_mcp_access_router @@ -36921,15 +36968,30 @@ def _classic_redirect(): _image_generation_commands = create_image_generation_commands(globals()) api.include_router(create_image_generation_commands_router(_image_generation_commands)) +from routers.wizard_workflow_executor import create_wizard_workflow_executor_router +from services.wizard_workflow_executor import WizardWorkflowExecutor, catalog as wizard_workflow_catalog, command_handlers as wizard_workflow_command_handlers +_wizard_workflow_executor = WizardWorkflowExecutor( + workspace_dir=_workspace_dir, + submit_command=_image_generation_commands.submit, + command_receipt=_image_generation_commands.receipt, + get_task=lambda workspace, task_id: _task_registry(workspace).get(task_id), +) +api.include_router(create_wizard_workflow_executor_router(_wizard_workflow_executor, list_workspaces=_list_workspaces)) api.include_router(create_wangp_mcp_router( token_getter=_mcp_access.token, handlers={"models": lambda args: get_model_options(args['model_type']) if args.get('model_type') else list_models(), "processors": wangp_capabilities, "status": get_status, "generate": generate, "recast": recast_endpoint, "upscale": tools_upscale, - **wangp_agent_handlers(api), **image_command_handlers(_image_generation_commands), **_scene_commands.handlers()}, + **wangp_agent_handlers(api), **image_command_handlers(_image_generation_commands), **wizard_workflow_command_handlers(_wizard_workflow_executor), **world3d_export_handlers(_world3d_export), **_scene_commands.handlers()}, journal_path=os.path.join(os.path.dirname(__file__), "settings", "wangp-mcp-requests.sqlite3"), command_operations=[*scene_command_catalog(), *workspace_command_catalog()["operations"], *image_command_catalog( - adapter.catalog for adapter in _image_generation_commands.operations.values())], + adapter.catalog for adapter in _image_generation_commands.operations.values()), *wizard_workflow_catalog(), *world3d_export_catalog()], )) +from routers.system_capabilities import create_system_capabilities_router +api.include_router(create_system_capabilities_router()) + +# Optional production renderer: pass a callable that drives the existing +# Video 3D exportFlow through a process-owned headless browser. Closing a +# user tab must not join or kill that worker. # ============================================================================ # Serve React build at / diff --git a/app/character_kit_presets/mouths/STUDIO-20.txt b/app/character_kit_presets/mouths/STUDIO-20.txt new file mode 100644 index 000000000..0bb66a6bb --- /dev/null +++ b/app/character_kit_presets/mouths/STUDIO-20.txt @@ -0,0 +1,26 @@ +HocusPocus Studio 20 — reusable mouth collection + +20 original generated styles; 9 transparent PNG sprites per style, 512 x 512. +Created with OpenAI imagegen for HocusPocus. You may use, modify and redistribute +these new Studio 20 assets in personal and commercial animation projects. +This permission covers the Studio 20 artwork, not third-party character likenesses. + +Slot / Rhubarb / sound +closed X relaxed resting mouth (use also for listeners and silent scenes) +pressed A M/B/P, lips pressed together +small B EE and narrow consonants +medium C EH, moderate opening / transition +wide D AH, open jaw +round E O, rounded opening +pucker F OO/W, narrow rounded lips +bite G F/V, upper teeth on lower lip +tongue H L, tongue raised behind teeth + +All nine images use a shared square frame and centered pivot. Keep that frame: +do not trim each sprite independently or stretch a closed mouth to the height +of an open one. Calibrate one mouth box on the character, then apply placement +to all states. The base used for animation must have its old mouth removed. +Saving the character also creates a resting still, leaving the rig base intact. + +The six older four-state packs are retained for compatibility and are outside +this new Studio 20 collection. Four-state rigs still work through phonetic fallbacks. diff --git a/app/character_kit_presets/mouths/cardboard-cut/bite.png b/app/character_kit_presets/mouths/cardboard-cut/bite.png new file mode 100644 index 000000000..ef784d161 Binary files /dev/null and b/app/character_kit_presets/mouths/cardboard-cut/bite.png differ diff --git a/app/character_kit_presets/mouths/cardboard-cut/closed.png b/app/character_kit_presets/mouths/cardboard-cut/closed.png new file mode 100644 index 000000000..0bb2abeb5 Binary files /dev/null and b/app/character_kit_presets/mouths/cardboard-cut/closed.png differ diff --git a/app/character_kit_presets/mouths/cardboard-cut/medium.png b/app/character_kit_presets/mouths/cardboard-cut/medium.png new file mode 100644 index 000000000..d27a7cdb2 Binary files /dev/null and b/app/character_kit_presets/mouths/cardboard-cut/medium.png differ diff --git a/app/character_kit_presets/mouths/cardboard-cut/pressed.png b/app/character_kit_presets/mouths/cardboard-cut/pressed.png new file mode 100644 index 000000000..2e4864003 Binary files /dev/null and b/app/character_kit_presets/mouths/cardboard-cut/pressed.png differ diff --git a/app/character_kit_presets/mouths/cardboard-cut/pucker.png b/app/character_kit_presets/mouths/cardboard-cut/pucker.png new file mode 100644 index 000000000..4de999c82 Binary files /dev/null and b/app/character_kit_presets/mouths/cardboard-cut/pucker.png differ diff --git a/app/character_kit_presets/mouths/cardboard-cut/round.png b/app/character_kit_presets/mouths/cardboard-cut/round.png new file mode 100644 index 000000000..e2e11ebb5 Binary files /dev/null and b/app/character_kit_presets/mouths/cardboard-cut/round.png differ diff --git a/app/character_kit_presets/mouths/cardboard-cut/small.png b/app/character_kit_presets/mouths/cardboard-cut/small.png new file mode 100644 index 000000000..49a26980a Binary files /dev/null and b/app/character_kit_presets/mouths/cardboard-cut/small.png differ diff --git a/app/character_kit_presets/mouths/cardboard-cut/tongue.png b/app/character_kit_presets/mouths/cardboard-cut/tongue.png new file mode 100644 index 000000000..ea739cad2 Binary files /dev/null and b/app/character_kit_presets/mouths/cardboard-cut/tongue.png differ diff --git a/app/character_kit_presets/mouths/cardboard-cut/wide.png b/app/character_kit_presets/mouths/cardboard-cut/wide.png new file mode 100644 index 000000000..6b398e56b Binary files /dev/null and b/app/character_kit_presets/mouths/cardboard-cut/wide.png differ diff --git a/app/character_kit_presets/mouths/cel-anime/bite.png b/app/character_kit_presets/mouths/cel-anime/bite.png new file mode 100644 index 000000000..2b21e2395 Binary files /dev/null and b/app/character_kit_presets/mouths/cel-anime/bite.png differ diff --git a/app/character_kit_presets/mouths/cel-anime/closed.png b/app/character_kit_presets/mouths/cel-anime/closed.png new file mode 100644 index 000000000..9b3151bd6 Binary files /dev/null and b/app/character_kit_presets/mouths/cel-anime/closed.png differ diff --git a/app/character_kit_presets/mouths/cel-anime/medium.png b/app/character_kit_presets/mouths/cel-anime/medium.png new file mode 100644 index 000000000..acdc656b6 Binary files /dev/null and b/app/character_kit_presets/mouths/cel-anime/medium.png differ diff --git a/app/character_kit_presets/mouths/cel-anime/pressed.png b/app/character_kit_presets/mouths/cel-anime/pressed.png new file mode 100644 index 000000000..9c60d3302 Binary files /dev/null and b/app/character_kit_presets/mouths/cel-anime/pressed.png differ diff --git a/app/character_kit_presets/mouths/cel-anime/pucker.png b/app/character_kit_presets/mouths/cel-anime/pucker.png new file mode 100644 index 000000000..097e7c935 Binary files /dev/null and b/app/character_kit_presets/mouths/cel-anime/pucker.png differ diff --git a/app/character_kit_presets/mouths/cel-anime/round.png b/app/character_kit_presets/mouths/cel-anime/round.png new file mode 100644 index 000000000..ada758285 Binary files /dev/null and b/app/character_kit_presets/mouths/cel-anime/round.png differ diff --git a/app/character_kit_presets/mouths/cel-anime/small.png b/app/character_kit_presets/mouths/cel-anime/small.png new file mode 100644 index 000000000..7cbf60b16 Binary files /dev/null and b/app/character_kit_presets/mouths/cel-anime/small.png differ diff --git a/app/character_kit_presets/mouths/cel-anime/tongue.png b/app/character_kit_presets/mouths/cel-anime/tongue.png new file mode 100644 index 000000000..4866409e9 Binary files /dev/null and b/app/character_kit_presets/mouths/cel-anime/tongue.png differ diff --git a/app/character_kit_presets/mouths/cel-anime/wide.png b/app/character_kit_presets/mouths/cel-anime/wide.png new file mode 100644 index 000000000..7be027780 Binary files /dev/null and b/app/character_kit_presets/mouths/cel-anime/wide.png differ diff --git a/app/character_kit_presets/mouths/chalk-doodle/bite.png b/app/character_kit_presets/mouths/chalk-doodle/bite.png new file mode 100644 index 000000000..c4e001800 Binary files /dev/null and b/app/character_kit_presets/mouths/chalk-doodle/bite.png differ diff --git a/app/character_kit_presets/mouths/chalk-doodle/closed.png b/app/character_kit_presets/mouths/chalk-doodle/closed.png new file mode 100644 index 000000000..2187df205 Binary files /dev/null and b/app/character_kit_presets/mouths/chalk-doodle/closed.png differ diff --git a/app/character_kit_presets/mouths/chalk-doodle/medium.png b/app/character_kit_presets/mouths/chalk-doodle/medium.png new file mode 100644 index 000000000..46b57b24b Binary files /dev/null and b/app/character_kit_presets/mouths/chalk-doodle/medium.png differ diff --git a/app/character_kit_presets/mouths/chalk-doodle/pressed.png b/app/character_kit_presets/mouths/chalk-doodle/pressed.png new file mode 100644 index 000000000..8ec2af25a Binary files /dev/null and b/app/character_kit_presets/mouths/chalk-doodle/pressed.png differ diff --git a/app/character_kit_presets/mouths/chalk-doodle/pucker.png b/app/character_kit_presets/mouths/chalk-doodle/pucker.png new file mode 100644 index 000000000..16f6230c4 Binary files /dev/null and b/app/character_kit_presets/mouths/chalk-doodle/pucker.png differ diff --git a/app/character_kit_presets/mouths/chalk-doodle/round.png b/app/character_kit_presets/mouths/chalk-doodle/round.png new file mode 100644 index 000000000..fbcd2d1b9 Binary files /dev/null and b/app/character_kit_presets/mouths/chalk-doodle/round.png differ diff --git a/app/character_kit_presets/mouths/chalk-doodle/small.png b/app/character_kit_presets/mouths/chalk-doodle/small.png new file mode 100644 index 000000000..9277caa4a Binary files /dev/null and b/app/character_kit_presets/mouths/chalk-doodle/small.png differ diff --git a/app/character_kit_presets/mouths/chalk-doodle/tongue.png b/app/character_kit_presets/mouths/chalk-doodle/tongue.png new file mode 100644 index 000000000..a4bc1aca5 Binary files /dev/null and b/app/character_kit_presets/mouths/chalk-doodle/tongue.png differ diff --git a/app/character_kit_presets/mouths/chalk-doodle/wide.png b/app/character_kit_presets/mouths/chalk-doodle/wide.png new file mode 100644 index 000000000..9f0c67a8f Binary files /dev/null and b/app/character_kit_presets/mouths/chalk-doodle/wide.png differ diff --git a/app/character_kit_presets/mouths/clay-puppet/bite.png b/app/character_kit_presets/mouths/clay-puppet/bite.png new file mode 100644 index 000000000..eae7ac4a7 Binary files /dev/null and b/app/character_kit_presets/mouths/clay-puppet/bite.png differ diff --git a/app/character_kit_presets/mouths/clay-puppet/closed.png b/app/character_kit_presets/mouths/clay-puppet/closed.png new file mode 100644 index 000000000..4e7f48df9 Binary files /dev/null and b/app/character_kit_presets/mouths/clay-puppet/closed.png differ diff --git a/app/character_kit_presets/mouths/clay-puppet/medium.png b/app/character_kit_presets/mouths/clay-puppet/medium.png new file mode 100644 index 000000000..328bb554a Binary files /dev/null and b/app/character_kit_presets/mouths/clay-puppet/medium.png differ diff --git a/app/character_kit_presets/mouths/clay-puppet/pressed.png b/app/character_kit_presets/mouths/clay-puppet/pressed.png new file mode 100644 index 000000000..7bd96bad9 Binary files /dev/null and b/app/character_kit_presets/mouths/clay-puppet/pressed.png differ diff --git a/app/character_kit_presets/mouths/clay-puppet/pucker.png b/app/character_kit_presets/mouths/clay-puppet/pucker.png new file mode 100644 index 000000000..b608d910c Binary files /dev/null and b/app/character_kit_presets/mouths/clay-puppet/pucker.png differ diff --git a/app/character_kit_presets/mouths/clay-puppet/round.png b/app/character_kit_presets/mouths/clay-puppet/round.png new file mode 100644 index 000000000..2f16c9ae0 Binary files /dev/null and b/app/character_kit_presets/mouths/clay-puppet/round.png differ diff --git a/app/character_kit_presets/mouths/clay-puppet/small.png b/app/character_kit_presets/mouths/clay-puppet/small.png new file mode 100644 index 000000000..1582b8690 Binary files /dev/null and b/app/character_kit_presets/mouths/clay-puppet/small.png differ diff --git a/app/character_kit_presets/mouths/clay-puppet/tongue.png b/app/character_kit_presets/mouths/clay-puppet/tongue.png new file mode 100644 index 000000000..4ad0d6081 Binary files /dev/null and b/app/character_kit_presets/mouths/clay-puppet/tongue.png differ diff --git a/app/character_kit_presets/mouths/clay-puppet/wide.png b/app/character_kit_presets/mouths/clay-puppet/wide.png new file mode 100644 index 000000000..69c6e93b6 Binary files /dev/null and b/app/character_kit_presets/mouths/clay-puppet/wide.png differ diff --git a/app/character_kit_presets/mouths/comic-halftone/bite.png b/app/character_kit_presets/mouths/comic-halftone/bite.png new file mode 100644 index 000000000..6848bf17a Binary files /dev/null and b/app/character_kit_presets/mouths/comic-halftone/bite.png differ diff --git a/app/character_kit_presets/mouths/comic-halftone/closed.png b/app/character_kit_presets/mouths/comic-halftone/closed.png new file mode 100644 index 000000000..43eda3cce Binary files /dev/null and b/app/character_kit_presets/mouths/comic-halftone/closed.png differ diff --git a/app/character_kit_presets/mouths/comic-halftone/medium.png b/app/character_kit_presets/mouths/comic-halftone/medium.png new file mode 100644 index 000000000..25be08c18 Binary files /dev/null and b/app/character_kit_presets/mouths/comic-halftone/medium.png differ diff --git a/app/character_kit_presets/mouths/comic-halftone/pressed.png b/app/character_kit_presets/mouths/comic-halftone/pressed.png new file mode 100644 index 000000000..70a418067 Binary files /dev/null and b/app/character_kit_presets/mouths/comic-halftone/pressed.png differ diff --git a/app/character_kit_presets/mouths/comic-halftone/pucker.png b/app/character_kit_presets/mouths/comic-halftone/pucker.png new file mode 100644 index 000000000..a8ba1a378 Binary files /dev/null and b/app/character_kit_presets/mouths/comic-halftone/pucker.png differ diff --git a/app/character_kit_presets/mouths/comic-halftone/round.png b/app/character_kit_presets/mouths/comic-halftone/round.png new file mode 100644 index 000000000..73cf38652 Binary files /dev/null and b/app/character_kit_presets/mouths/comic-halftone/round.png differ diff --git a/app/character_kit_presets/mouths/comic-halftone/small.png b/app/character_kit_presets/mouths/comic-halftone/small.png new file mode 100644 index 000000000..0a851d93d Binary files /dev/null and b/app/character_kit_presets/mouths/comic-halftone/small.png differ diff --git a/app/character_kit_presets/mouths/comic-halftone/tongue.png b/app/character_kit_presets/mouths/comic-halftone/tongue.png new file mode 100644 index 000000000..afd9fbe31 Binary files /dev/null and b/app/character_kit_presets/mouths/comic-halftone/tongue.png differ diff --git a/app/character_kit_presets/mouths/comic-halftone/wide.png b/app/character_kit_presets/mouths/comic-halftone/wide.png new file mode 100644 index 000000000..02a5685de Binary files /dev/null and b/app/character_kit_presets/mouths/comic-halftone/wide.png differ diff --git a/app/character_kit_presets/mouths/flat-geometric/bite.png b/app/character_kit_presets/mouths/flat-geometric/bite.png new file mode 100644 index 000000000..167cdb1f9 Binary files /dev/null and b/app/character_kit_presets/mouths/flat-geometric/bite.png differ diff --git a/app/character_kit_presets/mouths/flat-geometric/closed.png b/app/character_kit_presets/mouths/flat-geometric/closed.png new file mode 100644 index 000000000..94bbfefb1 Binary files /dev/null and b/app/character_kit_presets/mouths/flat-geometric/closed.png differ diff --git a/app/character_kit_presets/mouths/flat-geometric/medium.png b/app/character_kit_presets/mouths/flat-geometric/medium.png new file mode 100644 index 000000000..f01b049b1 Binary files /dev/null and b/app/character_kit_presets/mouths/flat-geometric/medium.png differ diff --git a/app/character_kit_presets/mouths/flat-geometric/pressed.png b/app/character_kit_presets/mouths/flat-geometric/pressed.png new file mode 100644 index 000000000..0c91be079 Binary files /dev/null and b/app/character_kit_presets/mouths/flat-geometric/pressed.png differ diff --git a/app/character_kit_presets/mouths/flat-geometric/pucker.png b/app/character_kit_presets/mouths/flat-geometric/pucker.png new file mode 100644 index 000000000..6f9d48ff8 Binary files /dev/null and b/app/character_kit_presets/mouths/flat-geometric/pucker.png differ diff --git a/app/character_kit_presets/mouths/flat-geometric/round.png b/app/character_kit_presets/mouths/flat-geometric/round.png new file mode 100644 index 000000000..212d35373 Binary files /dev/null and b/app/character_kit_presets/mouths/flat-geometric/round.png differ diff --git a/app/character_kit_presets/mouths/flat-geometric/small.png b/app/character_kit_presets/mouths/flat-geometric/small.png new file mode 100644 index 000000000..a97686ff0 Binary files /dev/null and b/app/character_kit_presets/mouths/flat-geometric/small.png differ diff --git a/app/character_kit_presets/mouths/flat-geometric/tongue.png b/app/character_kit_presets/mouths/flat-geometric/tongue.png new file mode 100644 index 000000000..3a93e182c Binary files /dev/null and b/app/character_kit_presets/mouths/flat-geometric/tongue.png differ diff --git a/app/character_kit_presets/mouths/flat-geometric/wide.png b/app/character_kit_presets/mouths/flat-geometric/wide.png new file mode 100644 index 000000000..66dee6a92 Binary files /dev/null and b/app/character_kit_presets/mouths/flat-geometric/wide.png differ diff --git a/app/character_kit_presets/mouths/manifest.json b/app/character_kit_presets/mouths/manifest.json index 59b1e04d6..d0f489809 100644 --- a/app/character_kit_presets/mouths/manifest.json +++ b/app/character_kit_presets/mouths/manifest.json @@ -4,7 +4,12 @@ "closed", "small", "wide", - "round" + "round", + "pressed", + "medium", + "pucker", + "bite", + "tongue" ], "packs": [ { @@ -174,6 +179,1166 @@ "height": 855 } } + }, + { + "id": "ruby-ink", + "label": "Ruby ink", + "style": "cutout", + "collection": "studio-20", + "notes": "Clean bold ink cartoon with ruby-red sculpted lips, ivory teeth, coral tongue. Nine phonetic positions, transparent PNG, shared 512px frame and pivot.", + "provenance": { + "tool": "OpenAI imagegen", + "atlasSha256": "ba3f5858fbbf56c00e513195663e4fe37e2d9530bc863b14abd0a7c3b48b4f4d" + }, + "states": { + "closed": { + "file": "ruby-ink/closed.png", + "width": 512, + "height": 512 + }, + "small": { + "file": "ruby-ink/small.png", + "width": 512, + "height": 512 + }, + "wide": { + "file": "ruby-ink/wide.png", + "width": 512, + "height": 512 + }, + "round": { + "file": "ruby-ink/round.png", + "width": 512, + "height": 512 + }, + "pressed": { + "file": "ruby-ink/pressed.png", + "width": 512, + "height": 512 + }, + "medium": { + "file": "ruby-ink/medium.png", + "width": 512, + "height": 512 + }, + "pucker": { + "file": "ruby-ink/pucker.png", + "width": 512, + "height": 512 + }, + "bite": { + "file": "ruby-ink/bite.png", + "width": 512, + "height": 512 + }, + "tongue": { + "file": "ruby-ink/tongue.png", + "width": 512, + "height": 512 + } + } + }, + { + "id": "minimal-line", + "label": "Minimal line", + "style": "cutout", + "collection": "studio-20", + "notes": "Minimal flat cartoon: very thin black outline, NO outer fleshy lips, dark maroon interior, single ivory tooth band, coral tongue. Nine phonetic positions, transparent PNG, shared 512px frame and pivot.", + "provenance": { + "tool": "OpenAI imagegen", + "atlasSha256": "4b9d2efa283721d871ae86a4e262563c924cc7d6c3a6d2766360cf5a075ff686" + }, + "states": { + "closed": { + "file": "minimal-line/closed.png", + "width": 512, + "height": 512 + }, + "small": { + "file": "minimal-line/small.png", + "width": 512, + "height": 512 + }, + "wide": { + "file": "minimal-line/wide.png", + "width": 512, + "height": 512 + }, + "round": { + "file": "minimal-line/round.png", + "width": 512, + "height": 512 + }, + "pressed": { + "file": "minimal-line/pressed.png", + "width": 512, + "height": 512 + }, + "medium": { + "file": "minimal-line/medium.png", + "width": 512, + "height": 512 + }, + "pucker": { + "file": "minimal-line/pucker.png", + "width": 512, + "height": 512 + }, + "bite": { + "file": "minimal-line/bite.png", + "width": 512, + "height": 512 + }, + "tongue": { + "file": "minimal-line/tongue.png", + "width": 512, + "height": 512 + } + } + }, + { + "id": "storybook-gouache", + "label": "Storybook gouache", + "style": "cutout", + "collection": "studio-20", + "notes": "Children's picture book gouache, soft terracotta lip edge with subtle brush texture, aubergine interior, ivory teeth. Nine phonetic positions, transparent PNG, shared 512px frame and pivot.", + "provenance": { + "tool": "OpenAI imagegen", + "atlasSha256": "bc47e82c820b0394db29d3503e959e259d633dab66a5d3f1e556b486b01f40ea" + }, + "states": { + "closed": { + "file": "storybook-gouache/closed.png", + "width": 512, + "height": 512 + }, + "small": { + "file": "storybook-gouache/small.png", + "width": 512, + "height": 512 + }, + "wide": { + "file": "storybook-gouache/wide.png", + "width": 512, + "height": 512 + }, + "round": { + "file": "storybook-gouache/round.png", + "width": 512, + "height": 512 + }, + "pressed": { + "file": "storybook-gouache/pressed.png", + "width": 512, + "height": 512 + }, + "medium": { + "file": "storybook-gouache/medium.png", + "width": 512, + "height": 512 + }, + "pucker": { + "file": "storybook-gouache/pucker.png", + "width": 512, + "height": 512 + }, + "bite": { + "file": "storybook-gouache/bite.png", + "width": 512, + "height": 512 + }, + "tongue": { + "file": "storybook-gouache/tongue.png", + "width": 512, + "height": 512 + } + } + }, + { + "id": "cardboard-cut", + "label": "Cardboard cut", + "style": "cutout", + "collection": "studio-20", + "notes": "Layered colored cardstock, rough black cut-paper edges, flat wine interior, ivory paper teeth and pink paper tongue, NO skin. Nine phonetic positions, transparent PNG, shared 512px frame and pivot.", + "provenance": { + "tool": "OpenAI imagegen", + "atlasSha256": "5db9cba8b6926435a2915cc1ed52790d72655f36c876b89178b403905e06b707" + }, + "states": { + "closed": { + "file": "cardboard-cut/closed.png", + "width": 512, + "height": 512 + }, + "small": { + "file": "cardboard-cut/small.png", + "width": 512, + "height": 512 + }, + "wide": { + "file": "cardboard-cut/wide.png", + "width": 512, + "height": 512 + }, + "round": { + "file": "cardboard-cut/round.png", + "width": 512, + "height": 512 + }, + "pressed": { + "file": "cardboard-cut/pressed.png", + "width": 512, + "height": 512 + }, + "medium": { + "file": "cardboard-cut/medium.png", + "width": 512, + "height": 512 + }, + "pucker": { + "file": "cardboard-cut/pucker.png", + "width": 512, + "height": 512 + }, + "bite": { + "file": "cardboard-cut/bite.png", + "width": 512, + "height": 512 + }, + "tongue": { + "file": "cardboard-cut/tongue.png", + "width": 512, + "height": 512 + } + } + }, + { + "id": "velvet-puppet", + "label": "Velvet puppet", + "style": "cutout", + "collection": "studio-20", + "notes": "Handmade dark plum velvet felt puppet mouth, softly fuzzy fabric edges, pink felt tongue, cream felt teeth. Nine phonetic positions, transparent PNG, shared 512px frame and pivot.", + "provenance": { + "tool": "OpenAI imagegen", + "atlasSha256": "01caba99f968551c1a53bc932258ecef64aa311867fd9e1ca0caecc2218f9973" + }, + "states": { + "closed": { + "file": "velvet-puppet/closed.png", + "width": 512, + "height": 512 + }, + "small": { + "file": "velvet-puppet/small.png", + "width": 512, + "height": 512 + }, + "wide": { + "file": "velvet-puppet/wide.png", + "width": 512, + "height": 512 + }, + "round": { + "file": "velvet-puppet/round.png", + "width": 512, + "height": 512 + }, + "pressed": { + "file": "velvet-puppet/pressed.png", + "width": 512, + "height": 512 + }, + "medium": { + "file": "velvet-puppet/medium.png", + "width": 512, + "height": 512 + }, + "pucker": { + "file": "velvet-puppet/pucker.png", + "width": 512, + "height": 512 + }, + "bite": { + "file": "velvet-puppet/bite.png", + "width": 512, + "height": 512 + }, + "tongue": { + "file": "velvet-puppet/tongue.png", + "width": 512, + "height": 512 + } + } + }, + { + "id": "cel-anime", + "label": "Cel anime", + "style": "cutout", + "collection": "studio-20", + "notes": "Elegant restrained anime cel animation mouth, fine dark reddish outline, no external fleshy lips, burgundy cavity, subtle pink tongue, clean cream tooth band. Nine phonetic positions, transparent PNG, shared 512px frame and pivot.", + "provenance": { + "tool": "OpenAI imagegen", + "atlasSha256": "004e03f92895ef6afdc3d0c840a75c1fa54e7296651466060049b95c71776922" + }, + "states": { + "closed": { + "file": "cel-anime/closed.png", + "width": 512, + "height": 512 + }, + "small": { + "file": "cel-anime/small.png", + "width": 512, + "height": 512 + }, + "wide": { + "file": "cel-anime/wide.png", + "width": 512, + "height": 512 + }, + "round": { + "file": "cel-anime/round.png", + "width": 512, + "height": 512 + }, + "pressed": { + "file": "cel-anime/pressed.png", + "width": 512, + "height": 512 + }, + "medium": { + "file": "cel-anime/medium.png", + "width": 512, + "height": 512 + }, + "pucker": { + "file": "cel-anime/pucker.png", + "width": 512, + "height": 512 + }, + "bite": { + "file": "cel-anime/bite.png", + "width": 512, + "height": 512 + }, + "tongue": { + "file": "cel-anime/tongue.png", + "width": 512, + "height": 512 + } + } + }, + { + "id": "rubber-hose", + "label": "Rubber hose", + "style": "cutout", + "collection": "studio-20", + "notes": "1930s rubber-hose cartoon mouth, bold pure black forms, warm ivory teeth, gray-pink tongue, pie-cut rubbery expressive shapes. Nine phonetic positions, transparent PNG, shared 512px frame and pivot.", + "provenance": { + "tool": "OpenAI imagegen", + "atlasSha256": "de4a72802aae79dca7684e5ed7f54d23b430ef0c0909963149ece3d07190e611" + }, + "states": { + "closed": { + "file": "rubber-hose/closed.png", + "width": 512, + "height": 512 + }, + "small": { + "file": "rubber-hose/small.png", + "width": 512, + "height": 512 + }, + "wide": { + "file": "rubber-hose/wide.png", + "width": 512, + "height": 512 + }, + "round": { + "file": "rubber-hose/round.png", + "width": 512, + "height": 512 + }, + "pressed": { + "file": "rubber-hose/pressed.png", + "width": 512, + "height": 512 + }, + "medium": { + "file": "rubber-hose/medium.png", + "width": 512, + "height": 512 + }, + "pucker": { + "file": "rubber-hose/pucker.png", + "width": 512, + "height": 512 + }, + "bite": { + "file": "rubber-hose/bite.png", + "width": 512, + "height": 512 + }, + "tongue": { + "file": "rubber-hose/tongue.png", + "width": 512, + "height": 512 + } + } + }, + { + "id": "comic-halftone", + "label": "Comic halftone", + "style": "cutout", + "collection": "studio-20", + "notes": "Pop comic mouth with bold navy ink outline, coral lip edge, magenta halftone dot shading, ivory teeth and salmon tongue. Nine phonetic positions, transparent PNG, shared 512px frame and pivot.", + "provenance": { + "tool": "OpenAI imagegen", + "atlasSha256": "2f72a9d425c51dac4da51e21de8563abc48e2c07405261bf95645cff3ee437d9" + }, + "states": { + "closed": { + "file": "comic-halftone/closed.png", + "width": 512, + "height": 512 + }, + "small": { + "file": "comic-halftone/small.png", + "width": 512, + "height": 512 + }, + "wide": { + "file": "comic-halftone/wide.png", + "width": 512, + "height": 512 + }, + "round": { + "file": "comic-halftone/round.png", + "width": 512, + "height": 512 + }, + "pressed": { + "file": "comic-halftone/pressed.png", + "width": 512, + "height": 512 + }, + "medium": { + "file": "comic-halftone/medium.png", + "width": 512, + "height": 512 + }, + "pucker": { + "file": "comic-halftone/pucker.png", + "width": 512, + "height": 512 + }, + "bite": { + "file": "comic-halftone/bite.png", + "width": 512, + "height": 512 + }, + "tongue": { + "file": "comic-halftone/tongue.png", + "width": 512, + "height": 512 + } + } + }, + { + "id": "clay-puppet", + "label": "Clay puppet", + "style": "cutout", + "collection": "studio-20", + "notes": "Hand sculpted clay animation mouth parts, matte plum outer edge, deep burgundy clay interior, softly rounded ivory clay teeth and pink clay tongue, front view no shadows. Nine phonetic positions, transparent PNG, shared 512px frame and pivot.", + "provenance": { + "tool": "OpenAI imagegen", + "atlasSha256": "69bced323083d14f3d7142f3d700caeae84f4c0719afbba4737bb0fffc34b07c" + }, + "states": { + "closed": { + "file": "clay-puppet/closed.png", + "width": 512, + "height": 512 + }, + "small": { + "file": "clay-puppet/small.png", + "width": 512, + "height": 512 + }, + "wide": { + "file": "clay-puppet/wide.png", + "width": 512, + "height": 512 + }, + "round": { + "file": "clay-puppet/round.png", + "width": 512, + "height": 512 + }, + "pressed": { + "file": "clay-puppet/pressed.png", + "width": 512, + "height": 512 + }, + "medium": { + "file": "clay-puppet/medium.png", + "width": 512, + "height": 512 + }, + "pucker": { + "file": "clay-puppet/pucker.png", + "width": 512, + "height": 512 + }, + "bite": { + "file": "clay-puppet/bite.png", + "width": 512, + "height": 512 + }, + "tongue": { + "file": "clay-puppet/tongue.png", + "width": 512, + "height": 512 + } + } + }, + { + "id": "pixel-arcade", + "label": "Pixel arcade", + "style": "cutout", + "collection": "studio-20", + "notes": "Crisp low-resolution 16-bit pixel art mouth with black stepped outline and angular shapes, purple-black cavity, square ivory teeth and vivid pink tongue, no antialiasing aesthetic. Nine phonetic positions, transparent PNG, shared 512px frame and pivot.", + "provenance": { + "tool": "OpenAI imagegen", + "atlasSha256": "0fb47097f4442fca933472ed641aecc1849bbc07bb9db950f22008e7699e5089" + }, + "states": { + "closed": { + "file": "pixel-arcade/closed.png", + "width": 512, + "height": 512 + }, + "small": { + "file": "pixel-arcade/small.png", + "width": 512, + "height": 512 + }, + "wide": { + "file": "pixel-arcade/wide.png", + "width": 512, + "height": 512 + }, + "round": { + "file": "pixel-arcade/round.png", + "width": 512, + "height": 512 + }, + "pressed": { + "file": "pixel-arcade/pressed.png", + "width": 512, + "height": 512 + }, + "medium": { + "file": "pixel-arcade/medium.png", + "width": 512, + "height": 512 + }, + "pucker": { + "file": "pixel-arcade/pucker.png", + "width": 512, + "height": 512 + }, + "bite": { + "file": "pixel-arcade/bite.png", + "width": 512, + "height": 512 + }, + "tongue": { + "file": "pixel-arcade/tongue.png", + "width": 512, + "height": 512 + } + } + }, + { + "id": "chalk-doodle", + "label": "Chalk doodle", + "style": "cutout", + "collection": "studio-20", + "notes": "Chalk pastel doodle mouth, dark charcoal uneven outline, muted berry opening, off-white chalk tooth strip, pink chalk tongue, no external skin. Nine phonetic positions, transparent PNG, shared 512px frame and pivot.", + "provenance": { + "tool": "OpenAI imagegen", + "atlasSha256": "9f69b1242021ab7d8f10a39fe9d566f98a94e53cb9230a4b649e3a3effc3eaff" + }, + "states": { + "closed": { + "file": "chalk-doodle/closed.png", + "width": 512, + "height": 512 + }, + "small": { + "file": "chalk-doodle/small.png", + "width": 512, + "height": 512 + }, + "wide": { + "file": "chalk-doodle/wide.png", + "width": 512, + "height": 512 + }, + "round": { + "file": "chalk-doodle/round.png", + "width": 512, + "height": 512 + }, + "pressed": { + "file": "chalk-doodle/pressed.png", + "width": 512, + "height": 512 + }, + "medium": { + "file": "chalk-doodle/medium.png", + "width": 512, + "height": 512 + }, + "pucker": { + "file": "chalk-doodle/pucker.png", + "width": 512, + "height": 512 + }, + "bite": { + "file": "chalk-doodle/bite.png", + "width": 512, + "height": 512 + }, + "tongue": { + "file": "chalk-doodle/tongue.png", + "width": 512, + "height": 512 + } + } + }, + { + "id": "soft-manga", + "label": "Soft manga", + "style": "cutout", + "collection": "studio-20", + "notes": "Charming manga chibi mouth, no thick lips, delicate ink contour, rich reddish brown opening, tiny ivory tooth band, simple salmon tongue. Nine phonetic positions, transparent PNG, shared 512px frame and pivot.", + "provenance": { + "tool": "OpenAI imagegen", + "atlasSha256": "6e1cc52150455c748dd8c26c4d7ee3cf51bf6f8c8d0b0b27ef14afa04038d9c6" + }, + "states": { + "closed": { + "file": "soft-manga/closed.png", + "width": 512, + "height": 512 + }, + "small": { + "file": "soft-manga/small.png", + "width": 512, + "height": 512 + }, + "wide": { + "file": "soft-manga/wide.png", + "width": 512, + "height": 512 + }, + "round": { + "file": "soft-manga/round.png", + "width": 512, + "height": 512 + }, + "pressed": { + "file": "soft-manga/pressed.png", + "width": 512, + "height": 512 + }, + "medium": { + "file": "soft-manga/medium.png", + "width": 512, + "height": 512 + }, + "pucker": { + "file": "soft-manga/pucker.png", + "width": 512, + "height": 512 + }, + "bite": { + "file": "soft-manga/bite.png", + "width": 512, + "height": 512 + }, + "tongue": { + "file": "soft-manga/tongue.png", + "width": 512, + "height": 512 + } + } + }, + { + "id": "flat-geometric", + "label": "Flat geometric", + "style": "cutout", + "collection": "studio-20", + "notes": "Modern flat graphic animation, smoothly geometric mouth shape with uniform charcoal stroke, brick red inner rim, creamy tooth band, geometric coral tongue. Nine phonetic positions, transparent PNG, shared 512px frame and pivot.", + "provenance": { + "tool": "OpenAI imagegen", + "atlasSha256": "619d67618faeea51063c8a943a48832f8dddb8bad685c2a9ca4523434749ad42" + }, + "states": { + "closed": { + "file": "flat-geometric/closed.png", + "width": 512, + "height": 512 + }, + "small": { + "file": "flat-geometric/small.png", + "width": 512, + "height": 512 + }, + "wide": { + "file": "flat-geometric/wide.png", + "width": 512, + "height": 512 + }, + "round": { + "file": "flat-geometric/round.png", + "width": 512, + "height": 512 + }, + "pressed": { + "file": "flat-geometric/pressed.png", + "width": 512, + "height": 512 + }, + "medium": { + "file": "flat-geometric/medium.png", + "width": 512, + "height": 512 + }, + "pucker": { + "file": "flat-geometric/pucker.png", + "width": 512, + "height": 512 + }, + "bite": { + "file": "flat-geometric/bite.png", + "width": 512, + "height": 512 + }, + "tongue": { + "file": "flat-geometric/tongue.png", + "width": 512, + "height": 512 + } + } + }, + { + "id": "plush-stitch", + "label": "Plush stitch", + "style": "cutout", + "collection": "studio-20", + "notes": "Soft sewn plush toy mouth applique, burgundy cloth with clear cream stitched edge, pink cloth tongue, simple ivory fabric teeth. Nine phonetic positions, transparent PNG, shared 512px frame and pivot.", + "provenance": { + "tool": "OpenAI imagegen", + "atlasSha256": "1ba7ea4662c25ed9604861d3a6e48cbb5675ac3e002c9390ba1441ab80c672bb" + }, + "states": { + "closed": { + "file": "plush-stitch/closed.png", + "width": 512, + "height": 512 + }, + "small": { + "file": "plush-stitch/small.png", + "width": 512, + "height": 512 + }, + "wide": { + "file": "plush-stitch/wide.png", + "width": 512, + "height": 512 + }, + "round": { + "file": "plush-stitch/round.png", + "width": 512, + "height": 512 + }, + "pressed": { + "file": "plush-stitch/pressed.png", + "width": 512, + "height": 512 + }, + "medium": { + "file": "plush-stitch/medium.png", + "width": 512, + "height": 512 + }, + "pucker": { + "file": "plush-stitch/pucker.png", + "width": 512, + "height": 512 + }, + "bite": { + "file": "plush-stitch/bite.png", + "width": 512, + "height": 512 + }, + "tongue": { + "file": "plush-stitch/tongue.png", + "width": 512, + "height": 512 + } + } + }, + { + "id": "woodblock", + "label": "Woodblock", + "style": "cutout", + "collection": "studio-20", + "notes": "Vintage woodblock printed mouth, irregular dark indigo carved outlines, muted vermilion inner edge, cream paper tooth band and ochre pink tongue. Nine phonetic positions, transparent PNG, shared 512px frame and pivot.", + "provenance": { + "tool": "OpenAI imagegen", + "atlasSha256": "f10ef446de11880bf4e5fe9839e4a0775d209c35de78065246220b983ec573df" + }, + "states": { + "closed": { + "file": "woodblock/closed.png", + "width": 512, + "height": 512 + }, + "small": { + "file": "woodblock/small.png", + "width": 512, + "height": 512 + }, + "wide": { + "file": "woodblock/wide.png", + "width": 512, + "height": 512 + }, + "round": { + "file": "woodblock/round.png", + "width": 512, + "height": 512 + }, + "pressed": { + "file": "woodblock/pressed.png", + "width": 512, + "height": 512 + }, + "medium": { + "file": "woodblock/medium.png", + "width": 512, + "height": 512 + }, + "pucker": { + "file": "woodblock/pucker.png", + "width": 512, + "height": 512 + }, + "bite": { + "file": "woodblock/bite.png", + "width": 512, + "height": 512 + }, + "tongue": { + "file": "woodblock/tongue.png", + "width": 512, + "height": 512 + } + } + }, + { + "id": "neon-toon", + "label": "Neon toon", + "style": "cutout", + "collection": "studio-20", + "notes": "Cyber cartoon mouth, crisp midnight-purple outline, vivid magenta lip rim with thin cyan graphic accents, lavender-white teeth and pink tongue, NO glow outside mouth. Nine phonetic positions, transparent PNG, shared 512px frame and pivot.", + "provenance": { + "tool": "OpenAI imagegen", + "atlasSha256": "9086626caee3f349ed6f64f6c5af5cd5aa7bb12e396ce290242496bf3eb549a6" + }, + "states": { + "closed": { + "file": "neon-toon/closed.png", + "width": 512, + "height": 512 + }, + "small": { + "file": "neon-toon/small.png", + "width": 512, + "height": 512 + }, + "wide": { + "file": "neon-toon/wide.png", + "width": 512, + "height": 512 + }, + "round": { + "file": "neon-toon/round.png", + "width": 512, + "height": 512 + }, + "pressed": { + "file": "neon-toon/pressed.png", + "width": 512, + "height": 512 + }, + "medium": { + "file": "neon-toon/medium.png", + "width": 512, + "height": 512 + }, + "pucker": { + "file": "neon-toon/pucker.png", + "width": 512, + "height": 512 + }, + "bite": { + "file": "neon-toon/bite.png", + "width": 512, + "height": 512 + }, + "tongue": { + "file": "neon-toon/tongue.png", + "width": 512, + "height": 512 + } + } + }, + { + "id": "monochrome-ink", + "label": "Monochrome ink", + "style": "cutout", + "collection": "studio-20", + "notes": "Classic monochrome black-and-white cartoon mouth, expressive solid black cavity, grayscale lip edging, white teeth and mid-gray tongue, NO skin. Nine phonetic positions, transparent PNG, shared 512px frame and pivot.", + "provenance": { + "tool": "OpenAI imagegen", + "atlasSha256": "747540017cc1e373e9d26b1e28707004b08344c9ab773497ddec25a6faac95c7" + }, + "states": { + "closed": { + "file": "monochrome-ink/closed.png", + "width": 512, + "height": 512 + }, + "small": { + "file": "monochrome-ink/small.png", + "width": 512, + "height": 512 + }, + "wide": { + "file": "monochrome-ink/wide.png", + "width": 512, + "height": 512 + }, + "round": { + "file": "monochrome-ink/round.png", + "width": 512, + "height": 512 + }, + "pressed": { + "file": "monochrome-ink/pressed.png", + "width": 512, + "height": 512 + }, + "medium": { + "file": "monochrome-ink/medium.png", + "width": 512, + "height": 512 + }, + "pucker": { + "file": "monochrome-ink/pucker.png", + "width": 512, + "height": 512 + }, + "bite": { + "file": "monochrome-ink/bite.png", + "width": 512, + "height": 512 + }, + "tongue": { + "file": "monochrome-ink/tongue.png", + "width": 512, + "height": 512 + } + } + }, + { + "id": "warm-pastel", + "label": "Warm pastel", + "style": "cutout", + "collection": "studio-20", + "notes": "Friendly preschool flat pastel cartoon mouth, warm cocoa outline, peach pink thin lip edge, deep raspberry cavity, creamy rounded teeth and apricot tongue. Nine phonetic positions, transparent PNG, shared 512px frame and pivot.", + "provenance": { + "tool": "OpenAI imagegen", + "atlasSha256": "d715430ac1f7e246f22a104d90c49435faff2ad8fbe90b01ec7896c8cdd7850f" + }, + "states": { + "closed": { + "file": "warm-pastel/closed.png", + "width": 512, + "height": 512 + }, + "small": { + "file": "warm-pastel/small.png", + "width": 512, + "height": 512 + }, + "wide": { + "file": "warm-pastel/wide.png", + "width": 512, + "height": 512 + }, + "round": { + "file": "warm-pastel/round.png", + "width": 512, + "height": 512 + }, + "pressed": { + "file": "warm-pastel/pressed.png", + "width": 512, + "height": 512 + }, + "medium": { + "file": "warm-pastel/medium.png", + "width": 512, + "height": 512 + }, + "pucker": { + "file": "warm-pastel/pucker.png", + "width": 512, + "height": 512 + }, + "bite": { + "file": "warm-pastel/bite.png", + "width": 512, + "height": 512 + }, + "tongue": { + "file": "warm-pastel/tongue.png", + "width": 512, + "height": 512 + } + } + }, + { + "id": "sticker-bold", + "label": "Sticker bold", + "style": "cutout", + "collection": "studio-20", + "notes": "Bold sticker cartoon mouth with thick charcoal perimeter and small white outer keyline restricted to mouth, burnt-orange thin lip rim, egg-white teeth and rose tongue. Nine phonetic positions, transparent PNG, shared 512px frame and pivot.", + "provenance": { + "tool": "OpenAI imagegen", + "atlasSha256": "c741f1dd5863994483dbad70225fed54e91cd4b81b05da6910922a9a461898d6" + }, + "states": { + "closed": { + "file": "sticker-bold/closed.png", + "width": 512, + "height": 512 + }, + "small": { + "file": "sticker-bold/small.png", + "width": 512, + "height": 512 + }, + "wide": { + "file": "sticker-bold/wide.png", + "width": 512, + "height": 512 + }, + "round": { + "file": "sticker-bold/round.png", + "width": 512, + "height": 512 + }, + "pressed": { + "file": "sticker-bold/pressed.png", + "width": 512, + "height": 512 + }, + "medium": { + "file": "sticker-bold/medium.png", + "width": 512, + "height": 512 + }, + "pucker": { + "file": "sticker-bold/pucker.png", + "width": 512, + "height": 512 + }, + "bite": { + "file": "sticker-bold/bite.png", + "width": 512, + "height": 512 + }, + "tongue": { + "file": "sticker-bold/tongue.png", + "width": 512, + "height": 512 + } + } + }, + { + "id": "watercolor-rose", + "label": "Watercolor rose", + "style": "cutout", + "collection": "studio-20", + "notes": "Delicate rose watercolor mouth, wine outline with natural watercolor pigment variation INSIDE shapes only, pale ivory teeth and muted rose tongue, no surrounding skin wash. Nine phonetic positions, transparent PNG, shared 512px frame and pivot.", + "provenance": { + "tool": "OpenAI imagegen", + "atlasSha256": "0ac2bc3246eec54f2547239cad0136d3e90312684a20775edc639893a99418d4" + }, + "states": { + "closed": { + "file": "watercolor-rose/closed.png", + "width": 512, + "height": 512 + }, + "small": { + "file": "watercolor-rose/small.png", + "width": 512, + "height": 512 + }, + "wide": { + "file": "watercolor-rose/wide.png", + "width": 512, + "height": 512 + }, + "round": { + "file": "watercolor-rose/round.png", + "width": 512, + "height": 512 + }, + "pressed": { + "file": "watercolor-rose/pressed.png", + "width": 512, + "height": 512 + }, + "medium": { + "file": "watercolor-rose/medium.png", + "width": 512, + "height": 512 + }, + "pucker": { + "file": "watercolor-rose/pucker.png", + "width": 512, + "height": 512 + }, + "bite": { + "file": "watercolor-rose/bite.png", + "width": 512, + "height": 512 + }, + "tongue": { + "file": "watercolor-rose/tongue.png", + "width": 512, + "height": 512 + } + } } ] } diff --git a/app/character_kit_presets/mouths/minimal-line/bite.png b/app/character_kit_presets/mouths/minimal-line/bite.png new file mode 100644 index 000000000..66b4ae68f Binary files /dev/null and b/app/character_kit_presets/mouths/minimal-line/bite.png differ diff --git a/app/character_kit_presets/mouths/minimal-line/closed.png b/app/character_kit_presets/mouths/minimal-line/closed.png new file mode 100644 index 000000000..b86130d71 Binary files /dev/null and b/app/character_kit_presets/mouths/minimal-line/closed.png differ diff --git a/app/character_kit_presets/mouths/minimal-line/medium.png b/app/character_kit_presets/mouths/minimal-line/medium.png new file mode 100644 index 000000000..6a20220c6 Binary files /dev/null and b/app/character_kit_presets/mouths/minimal-line/medium.png differ diff --git a/app/character_kit_presets/mouths/minimal-line/pressed.png b/app/character_kit_presets/mouths/minimal-line/pressed.png new file mode 100644 index 000000000..987e98d90 Binary files /dev/null and b/app/character_kit_presets/mouths/minimal-line/pressed.png differ diff --git a/app/character_kit_presets/mouths/minimal-line/pucker.png b/app/character_kit_presets/mouths/minimal-line/pucker.png new file mode 100644 index 000000000..ea7561b6a Binary files /dev/null and b/app/character_kit_presets/mouths/minimal-line/pucker.png differ diff --git a/app/character_kit_presets/mouths/minimal-line/round.png b/app/character_kit_presets/mouths/minimal-line/round.png new file mode 100644 index 000000000..c6f4879f2 Binary files /dev/null and b/app/character_kit_presets/mouths/minimal-line/round.png differ diff --git a/app/character_kit_presets/mouths/minimal-line/small.png b/app/character_kit_presets/mouths/minimal-line/small.png new file mode 100644 index 000000000..77021da42 Binary files /dev/null and b/app/character_kit_presets/mouths/minimal-line/small.png differ diff --git a/app/character_kit_presets/mouths/minimal-line/tongue.png b/app/character_kit_presets/mouths/minimal-line/tongue.png new file mode 100644 index 000000000..761780add Binary files /dev/null and b/app/character_kit_presets/mouths/minimal-line/tongue.png differ diff --git a/app/character_kit_presets/mouths/minimal-line/wide.png b/app/character_kit_presets/mouths/minimal-line/wide.png new file mode 100644 index 000000000..0f76182be Binary files /dev/null and b/app/character_kit_presets/mouths/minimal-line/wide.png differ diff --git a/app/character_kit_presets/mouths/monochrome-ink/bite.png b/app/character_kit_presets/mouths/monochrome-ink/bite.png new file mode 100644 index 000000000..7bd358dac Binary files /dev/null and b/app/character_kit_presets/mouths/monochrome-ink/bite.png differ diff --git a/app/character_kit_presets/mouths/monochrome-ink/closed.png b/app/character_kit_presets/mouths/monochrome-ink/closed.png new file mode 100644 index 000000000..15b799a9a Binary files /dev/null and b/app/character_kit_presets/mouths/monochrome-ink/closed.png differ diff --git a/app/character_kit_presets/mouths/monochrome-ink/medium.png b/app/character_kit_presets/mouths/monochrome-ink/medium.png new file mode 100644 index 000000000..8dbabf793 Binary files /dev/null and b/app/character_kit_presets/mouths/monochrome-ink/medium.png differ diff --git a/app/character_kit_presets/mouths/monochrome-ink/pressed.png b/app/character_kit_presets/mouths/monochrome-ink/pressed.png new file mode 100644 index 000000000..f7da4bf35 Binary files /dev/null and b/app/character_kit_presets/mouths/monochrome-ink/pressed.png differ diff --git a/app/character_kit_presets/mouths/monochrome-ink/pucker.png b/app/character_kit_presets/mouths/monochrome-ink/pucker.png new file mode 100644 index 000000000..5329d0fa9 Binary files /dev/null and b/app/character_kit_presets/mouths/monochrome-ink/pucker.png differ diff --git a/app/character_kit_presets/mouths/monochrome-ink/round.png b/app/character_kit_presets/mouths/monochrome-ink/round.png new file mode 100644 index 000000000..7a5156f92 Binary files /dev/null and b/app/character_kit_presets/mouths/monochrome-ink/round.png differ diff --git a/app/character_kit_presets/mouths/monochrome-ink/small.png b/app/character_kit_presets/mouths/monochrome-ink/small.png new file mode 100644 index 000000000..b07d86921 Binary files /dev/null and b/app/character_kit_presets/mouths/monochrome-ink/small.png differ diff --git a/app/character_kit_presets/mouths/monochrome-ink/tongue.png b/app/character_kit_presets/mouths/monochrome-ink/tongue.png new file mode 100644 index 000000000..afecffa41 Binary files /dev/null and b/app/character_kit_presets/mouths/monochrome-ink/tongue.png differ diff --git a/app/character_kit_presets/mouths/monochrome-ink/wide.png b/app/character_kit_presets/mouths/monochrome-ink/wide.png new file mode 100644 index 000000000..104d09f44 Binary files /dev/null and b/app/character_kit_presets/mouths/monochrome-ink/wide.png differ diff --git a/app/character_kit_presets/mouths/neon-toon/bite.png b/app/character_kit_presets/mouths/neon-toon/bite.png new file mode 100644 index 000000000..65dd03f21 Binary files /dev/null and b/app/character_kit_presets/mouths/neon-toon/bite.png differ diff --git a/app/character_kit_presets/mouths/neon-toon/closed.png b/app/character_kit_presets/mouths/neon-toon/closed.png new file mode 100644 index 000000000..3c5f2c147 Binary files /dev/null and b/app/character_kit_presets/mouths/neon-toon/closed.png differ diff --git a/app/character_kit_presets/mouths/neon-toon/medium.png b/app/character_kit_presets/mouths/neon-toon/medium.png new file mode 100644 index 000000000..6511ccd2a Binary files /dev/null and b/app/character_kit_presets/mouths/neon-toon/medium.png differ diff --git a/app/character_kit_presets/mouths/neon-toon/pressed.png b/app/character_kit_presets/mouths/neon-toon/pressed.png new file mode 100644 index 000000000..fccba55b6 Binary files /dev/null and b/app/character_kit_presets/mouths/neon-toon/pressed.png differ diff --git a/app/character_kit_presets/mouths/neon-toon/pucker.png b/app/character_kit_presets/mouths/neon-toon/pucker.png new file mode 100644 index 000000000..b075808f2 Binary files /dev/null and b/app/character_kit_presets/mouths/neon-toon/pucker.png differ diff --git a/app/character_kit_presets/mouths/neon-toon/round.png b/app/character_kit_presets/mouths/neon-toon/round.png new file mode 100644 index 000000000..524709e32 Binary files /dev/null and b/app/character_kit_presets/mouths/neon-toon/round.png differ diff --git a/app/character_kit_presets/mouths/neon-toon/small.png b/app/character_kit_presets/mouths/neon-toon/small.png new file mode 100644 index 000000000..df8cf80ca Binary files /dev/null and b/app/character_kit_presets/mouths/neon-toon/small.png differ diff --git a/app/character_kit_presets/mouths/neon-toon/tongue.png b/app/character_kit_presets/mouths/neon-toon/tongue.png new file mode 100644 index 000000000..49e90b2f9 Binary files /dev/null and b/app/character_kit_presets/mouths/neon-toon/tongue.png differ diff --git a/app/character_kit_presets/mouths/neon-toon/wide.png b/app/character_kit_presets/mouths/neon-toon/wide.png new file mode 100644 index 000000000..12df2ca7a Binary files /dev/null and b/app/character_kit_presets/mouths/neon-toon/wide.png differ diff --git a/app/character_kit_presets/mouths/pixel-arcade/bite.png b/app/character_kit_presets/mouths/pixel-arcade/bite.png new file mode 100644 index 000000000..eadf77ee4 Binary files /dev/null and b/app/character_kit_presets/mouths/pixel-arcade/bite.png differ diff --git a/app/character_kit_presets/mouths/pixel-arcade/closed.png b/app/character_kit_presets/mouths/pixel-arcade/closed.png new file mode 100644 index 000000000..f08ec6f2b Binary files /dev/null and b/app/character_kit_presets/mouths/pixel-arcade/closed.png differ diff --git a/app/character_kit_presets/mouths/pixel-arcade/medium.png b/app/character_kit_presets/mouths/pixel-arcade/medium.png new file mode 100644 index 000000000..8793380f6 Binary files /dev/null and b/app/character_kit_presets/mouths/pixel-arcade/medium.png differ diff --git a/app/character_kit_presets/mouths/pixel-arcade/pressed.png b/app/character_kit_presets/mouths/pixel-arcade/pressed.png new file mode 100644 index 000000000..0492ec0cc Binary files /dev/null and b/app/character_kit_presets/mouths/pixel-arcade/pressed.png differ diff --git a/app/character_kit_presets/mouths/pixel-arcade/pucker.png b/app/character_kit_presets/mouths/pixel-arcade/pucker.png new file mode 100644 index 000000000..fb0fe7aab Binary files /dev/null and b/app/character_kit_presets/mouths/pixel-arcade/pucker.png differ diff --git a/app/character_kit_presets/mouths/pixel-arcade/round.png b/app/character_kit_presets/mouths/pixel-arcade/round.png new file mode 100644 index 000000000..18c659ef3 Binary files /dev/null and b/app/character_kit_presets/mouths/pixel-arcade/round.png differ diff --git a/app/character_kit_presets/mouths/pixel-arcade/small.png b/app/character_kit_presets/mouths/pixel-arcade/small.png new file mode 100644 index 000000000..90cb4bd68 Binary files /dev/null and b/app/character_kit_presets/mouths/pixel-arcade/small.png differ diff --git a/app/character_kit_presets/mouths/pixel-arcade/tongue.png b/app/character_kit_presets/mouths/pixel-arcade/tongue.png new file mode 100644 index 000000000..bb43fdf6a Binary files /dev/null and b/app/character_kit_presets/mouths/pixel-arcade/tongue.png differ diff --git a/app/character_kit_presets/mouths/pixel-arcade/wide.png b/app/character_kit_presets/mouths/pixel-arcade/wide.png new file mode 100644 index 000000000..210dfd862 Binary files /dev/null and b/app/character_kit_presets/mouths/pixel-arcade/wide.png differ diff --git a/app/character_kit_presets/mouths/plush-stitch/bite.png b/app/character_kit_presets/mouths/plush-stitch/bite.png new file mode 100644 index 000000000..a428fbdb0 Binary files /dev/null and b/app/character_kit_presets/mouths/plush-stitch/bite.png differ diff --git a/app/character_kit_presets/mouths/plush-stitch/closed.png b/app/character_kit_presets/mouths/plush-stitch/closed.png new file mode 100644 index 000000000..3d33f0c84 Binary files /dev/null and b/app/character_kit_presets/mouths/plush-stitch/closed.png differ diff --git a/app/character_kit_presets/mouths/plush-stitch/medium.png b/app/character_kit_presets/mouths/plush-stitch/medium.png new file mode 100644 index 000000000..a4374c0d0 Binary files /dev/null and b/app/character_kit_presets/mouths/plush-stitch/medium.png differ diff --git a/app/character_kit_presets/mouths/plush-stitch/pressed.png b/app/character_kit_presets/mouths/plush-stitch/pressed.png new file mode 100644 index 000000000..842720509 Binary files /dev/null and b/app/character_kit_presets/mouths/plush-stitch/pressed.png differ diff --git a/app/character_kit_presets/mouths/plush-stitch/pucker.png b/app/character_kit_presets/mouths/plush-stitch/pucker.png new file mode 100644 index 000000000..48b8b6625 Binary files /dev/null and b/app/character_kit_presets/mouths/plush-stitch/pucker.png differ diff --git a/app/character_kit_presets/mouths/plush-stitch/round.png b/app/character_kit_presets/mouths/plush-stitch/round.png new file mode 100644 index 000000000..1e9697cf0 Binary files /dev/null and b/app/character_kit_presets/mouths/plush-stitch/round.png differ diff --git a/app/character_kit_presets/mouths/plush-stitch/small.png b/app/character_kit_presets/mouths/plush-stitch/small.png new file mode 100644 index 000000000..3815e61fc Binary files /dev/null and b/app/character_kit_presets/mouths/plush-stitch/small.png differ diff --git a/app/character_kit_presets/mouths/plush-stitch/tongue.png b/app/character_kit_presets/mouths/plush-stitch/tongue.png new file mode 100644 index 000000000..f859fab9c Binary files /dev/null and b/app/character_kit_presets/mouths/plush-stitch/tongue.png differ diff --git a/app/character_kit_presets/mouths/plush-stitch/wide.png b/app/character_kit_presets/mouths/plush-stitch/wide.png new file mode 100644 index 000000000..6a93abbe3 Binary files /dev/null and b/app/character_kit_presets/mouths/plush-stitch/wide.png differ diff --git a/app/character_kit_presets/mouths/rubber-hose/bite.png b/app/character_kit_presets/mouths/rubber-hose/bite.png new file mode 100644 index 000000000..f978895a7 Binary files /dev/null and b/app/character_kit_presets/mouths/rubber-hose/bite.png differ diff --git a/app/character_kit_presets/mouths/rubber-hose/closed.png b/app/character_kit_presets/mouths/rubber-hose/closed.png new file mode 100644 index 000000000..99e8bbf17 Binary files /dev/null and b/app/character_kit_presets/mouths/rubber-hose/closed.png differ diff --git a/app/character_kit_presets/mouths/rubber-hose/medium.png b/app/character_kit_presets/mouths/rubber-hose/medium.png new file mode 100644 index 000000000..e0472d39d Binary files /dev/null and b/app/character_kit_presets/mouths/rubber-hose/medium.png differ diff --git a/app/character_kit_presets/mouths/rubber-hose/pressed.png b/app/character_kit_presets/mouths/rubber-hose/pressed.png new file mode 100644 index 000000000..da9df7716 Binary files /dev/null and b/app/character_kit_presets/mouths/rubber-hose/pressed.png differ diff --git a/app/character_kit_presets/mouths/rubber-hose/pucker.png b/app/character_kit_presets/mouths/rubber-hose/pucker.png new file mode 100644 index 000000000..0fa0ae54c Binary files /dev/null and b/app/character_kit_presets/mouths/rubber-hose/pucker.png differ diff --git a/app/character_kit_presets/mouths/rubber-hose/round.png b/app/character_kit_presets/mouths/rubber-hose/round.png new file mode 100644 index 000000000..c9aef6098 Binary files /dev/null and b/app/character_kit_presets/mouths/rubber-hose/round.png differ diff --git a/app/character_kit_presets/mouths/rubber-hose/small.png b/app/character_kit_presets/mouths/rubber-hose/small.png new file mode 100644 index 000000000..588990918 Binary files /dev/null and b/app/character_kit_presets/mouths/rubber-hose/small.png differ diff --git a/app/character_kit_presets/mouths/rubber-hose/tongue.png b/app/character_kit_presets/mouths/rubber-hose/tongue.png new file mode 100644 index 000000000..fc9739544 Binary files /dev/null and b/app/character_kit_presets/mouths/rubber-hose/tongue.png differ diff --git a/app/character_kit_presets/mouths/rubber-hose/wide.png b/app/character_kit_presets/mouths/rubber-hose/wide.png new file mode 100644 index 000000000..14d32cd59 Binary files /dev/null and b/app/character_kit_presets/mouths/rubber-hose/wide.png differ diff --git a/app/character_kit_presets/mouths/ruby-ink/bite.png b/app/character_kit_presets/mouths/ruby-ink/bite.png new file mode 100644 index 000000000..f7120da4a Binary files /dev/null and b/app/character_kit_presets/mouths/ruby-ink/bite.png differ diff --git a/app/character_kit_presets/mouths/ruby-ink/closed.png b/app/character_kit_presets/mouths/ruby-ink/closed.png new file mode 100644 index 000000000..8d74d1d36 Binary files /dev/null and b/app/character_kit_presets/mouths/ruby-ink/closed.png differ diff --git a/app/character_kit_presets/mouths/ruby-ink/medium.png b/app/character_kit_presets/mouths/ruby-ink/medium.png new file mode 100644 index 000000000..6ae73aaee Binary files /dev/null and b/app/character_kit_presets/mouths/ruby-ink/medium.png differ diff --git a/app/character_kit_presets/mouths/ruby-ink/pressed.png b/app/character_kit_presets/mouths/ruby-ink/pressed.png new file mode 100644 index 000000000..0dbc5fc08 Binary files /dev/null and b/app/character_kit_presets/mouths/ruby-ink/pressed.png differ diff --git a/app/character_kit_presets/mouths/ruby-ink/pucker.png b/app/character_kit_presets/mouths/ruby-ink/pucker.png new file mode 100644 index 000000000..17996876e Binary files /dev/null and b/app/character_kit_presets/mouths/ruby-ink/pucker.png differ diff --git a/app/character_kit_presets/mouths/ruby-ink/round.png b/app/character_kit_presets/mouths/ruby-ink/round.png new file mode 100644 index 000000000..725e8eea2 Binary files /dev/null and b/app/character_kit_presets/mouths/ruby-ink/round.png differ diff --git a/app/character_kit_presets/mouths/ruby-ink/small.png b/app/character_kit_presets/mouths/ruby-ink/small.png new file mode 100644 index 000000000..20c464285 Binary files /dev/null and b/app/character_kit_presets/mouths/ruby-ink/small.png differ diff --git a/app/character_kit_presets/mouths/ruby-ink/tongue.png b/app/character_kit_presets/mouths/ruby-ink/tongue.png new file mode 100644 index 000000000..c4c675c2c Binary files /dev/null and b/app/character_kit_presets/mouths/ruby-ink/tongue.png differ diff --git a/app/character_kit_presets/mouths/ruby-ink/wide.png b/app/character_kit_presets/mouths/ruby-ink/wide.png new file mode 100644 index 000000000..799bdf990 Binary files /dev/null and b/app/character_kit_presets/mouths/ruby-ink/wide.png differ diff --git a/app/character_kit_presets/mouths/soft-manga/bite.png b/app/character_kit_presets/mouths/soft-manga/bite.png new file mode 100644 index 000000000..598cb85e5 Binary files /dev/null and b/app/character_kit_presets/mouths/soft-manga/bite.png differ diff --git a/app/character_kit_presets/mouths/soft-manga/closed.png b/app/character_kit_presets/mouths/soft-manga/closed.png new file mode 100644 index 000000000..83357fb3a Binary files /dev/null and b/app/character_kit_presets/mouths/soft-manga/closed.png differ diff --git a/app/character_kit_presets/mouths/soft-manga/medium.png b/app/character_kit_presets/mouths/soft-manga/medium.png new file mode 100644 index 000000000..a171af9a2 Binary files /dev/null and b/app/character_kit_presets/mouths/soft-manga/medium.png differ diff --git a/app/character_kit_presets/mouths/soft-manga/pressed.png b/app/character_kit_presets/mouths/soft-manga/pressed.png new file mode 100644 index 000000000..8b6670555 Binary files /dev/null and b/app/character_kit_presets/mouths/soft-manga/pressed.png differ diff --git a/app/character_kit_presets/mouths/soft-manga/pucker.png b/app/character_kit_presets/mouths/soft-manga/pucker.png new file mode 100644 index 000000000..78916f315 Binary files /dev/null and b/app/character_kit_presets/mouths/soft-manga/pucker.png differ diff --git a/app/character_kit_presets/mouths/soft-manga/round.png b/app/character_kit_presets/mouths/soft-manga/round.png new file mode 100644 index 000000000..c77b3bcaa Binary files /dev/null and b/app/character_kit_presets/mouths/soft-manga/round.png differ diff --git a/app/character_kit_presets/mouths/soft-manga/small.png b/app/character_kit_presets/mouths/soft-manga/small.png new file mode 100644 index 000000000..971ae6e94 Binary files /dev/null and b/app/character_kit_presets/mouths/soft-manga/small.png differ diff --git a/app/character_kit_presets/mouths/soft-manga/tongue.png b/app/character_kit_presets/mouths/soft-manga/tongue.png new file mode 100644 index 000000000..6e6cbf126 Binary files /dev/null and b/app/character_kit_presets/mouths/soft-manga/tongue.png differ diff --git a/app/character_kit_presets/mouths/soft-manga/wide.png b/app/character_kit_presets/mouths/soft-manga/wide.png new file mode 100644 index 000000000..320b2d7ae Binary files /dev/null and b/app/character_kit_presets/mouths/soft-manga/wide.png differ diff --git a/app/character_kit_presets/mouths/sticker-bold/bite.png b/app/character_kit_presets/mouths/sticker-bold/bite.png new file mode 100644 index 000000000..684a5b864 Binary files /dev/null and b/app/character_kit_presets/mouths/sticker-bold/bite.png differ diff --git a/app/character_kit_presets/mouths/sticker-bold/closed.png b/app/character_kit_presets/mouths/sticker-bold/closed.png new file mode 100644 index 000000000..75c08148b Binary files /dev/null and b/app/character_kit_presets/mouths/sticker-bold/closed.png differ diff --git a/app/character_kit_presets/mouths/sticker-bold/medium.png b/app/character_kit_presets/mouths/sticker-bold/medium.png new file mode 100644 index 000000000..c28440836 Binary files /dev/null and b/app/character_kit_presets/mouths/sticker-bold/medium.png differ diff --git a/app/character_kit_presets/mouths/sticker-bold/pressed.png b/app/character_kit_presets/mouths/sticker-bold/pressed.png new file mode 100644 index 000000000..533b6267b Binary files /dev/null and b/app/character_kit_presets/mouths/sticker-bold/pressed.png differ diff --git a/app/character_kit_presets/mouths/sticker-bold/pucker.png b/app/character_kit_presets/mouths/sticker-bold/pucker.png new file mode 100644 index 000000000..eafbd9beb Binary files /dev/null and b/app/character_kit_presets/mouths/sticker-bold/pucker.png differ diff --git a/app/character_kit_presets/mouths/sticker-bold/round.png b/app/character_kit_presets/mouths/sticker-bold/round.png new file mode 100644 index 000000000..790255067 Binary files /dev/null and b/app/character_kit_presets/mouths/sticker-bold/round.png differ diff --git a/app/character_kit_presets/mouths/sticker-bold/small.png b/app/character_kit_presets/mouths/sticker-bold/small.png new file mode 100644 index 000000000..3b9b40fba Binary files /dev/null and b/app/character_kit_presets/mouths/sticker-bold/small.png differ diff --git a/app/character_kit_presets/mouths/sticker-bold/tongue.png b/app/character_kit_presets/mouths/sticker-bold/tongue.png new file mode 100644 index 000000000..d7a763d9e Binary files /dev/null and b/app/character_kit_presets/mouths/sticker-bold/tongue.png differ diff --git a/app/character_kit_presets/mouths/sticker-bold/wide.png b/app/character_kit_presets/mouths/sticker-bold/wide.png new file mode 100644 index 000000000..ede89d0e7 Binary files /dev/null and b/app/character_kit_presets/mouths/sticker-bold/wide.png differ diff --git a/app/character_kit_presets/mouths/storybook-gouache/bite.png b/app/character_kit_presets/mouths/storybook-gouache/bite.png new file mode 100644 index 000000000..c42901bd2 Binary files /dev/null and b/app/character_kit_presets/mouths/storybook-gouache/bite.png differ diff --git a/app/character_kit_presets/mouths/storybook-gouache/closed.png b/app/character_kit_presets/mouths/storybook-gouache/closed.png new file mode 100644 index 000000000..a0978e913 Binary files /dev/null and b/app/character_kit_presets/mouths/storybook-gouache/closed.png differ diff --git a/app/character_kit_presets/mouths/storybook-gouache/medium.png b/app/character_kit_presets/mouths/storybook-gouache/medium.png new file mode 100644 index 000000000..9a14aabfe Binary files /dev/null and b/app/character_kit_presets/mouths/storybook-gouache/medium.png differ diff --git a/app/character_kit_presets/mouths/storybook-gouache/pressed.png b/app/character_kit_presets/mouths/storybook-gouache/pressed.png new file mode 100644 index 000000000..9615d2838 Binary files /dev/null and b/app/character_kit_presets/mouths/storybook-gouache/pressed.png differ diff --git a/app/character_kit_presets/mouths/storybook-gouache/pucker.png b/app/character_kit_presets/mouths/storybook-gouache/pucker.png new file mode 100644 index 000000000..182117afc Binary files /dev/null and b/app/character_kit_presets/mouths/storybook-gouache/pucker.png differ diff --git a/app/character_kit_presets/mouths/storybook-gouache/round.png b/app/character_kit_presets/mouths/storybook-gouache/round.png new file mode 100644 index 000000000..001b5ad25 Binary files /dev/null and b/app/character_kit_presets/mouths/storybook-gouache/round.png differ diff --git a/app/character_kit_presets/mouths/storybook-gouache/small.png b/app/character_kit_presets/mouths/storybook-gouache/small.png new file mode 100644 index 000000000..b67943337 Binary files /dev/null and b/app/character_kit_presets/mouths/storybook-gouache/small.png differ diff --git a/app/character_kit_presets/mouths/storybook-gouache/tongue.png b/app/character_kit_presets/mouths/storybook-gouache/tongue.png new file mode 100644 index 000000000..7a51882bf Binary files /dev/null and b/app/character_kit_presets/mouths/storybook-gouache/tongue.png differ diff --git a/app/character_kit_presets/mouths/storybook-gouache/wide.png b/app/character_kit_presets/mouths/storybook-gouache/wide.png new file mode 100644 index 000000000..b59afcb2f Binary files /dev/null and b/app/character_kit_presets/mouths/storybook-gouache/wide.png differ diff --git a/app/character_kit_presets/mouths/velvet-puppet/bite.png b/app/character_kit_presets/mouths/velvet-puppet/bite.png new file mode 100644 index 000000000..1b3e1ce08 Binary files /dev/null and b/app/character_kit_presets/mouths/velvet-puppet/bite.png differ diff --git a/app/character_kit_presets/mouths/velvet-puppet/closed.png b/app/character_kit_presets/mouths/velvet-puppet/closed.png new file mode 100644 index 000000000..5c8824282 Binary files /dev/null and b/app/character_kit_presets/mouths/velvet-puppet/closed.png differ diff --git a/app/character_kit_presets/mouths/velvet-puppet/medium.png b/app/character_kit_presets/mouths/velvet-puppet/medium.png new file mode 100644 index 000000000..ca9d33fd3 Binary files /dev/null and b/app/character_kit_presets/mouths/velvet-puppet/medium.png differ diff --git a/app/character_kit_presets/mouths/velvet-puppet/pressed.png b/app/character_kit_presets/mouths/velvet-puppet/pressed.png new file mode 100644 index 000000000..8f69f801c Binary files /dev/null and b/app/character_kit_presets/mouths/velvet-puppet/pressed.png differ diff --git a/app/character_kit_presets/mouths/velvet-puppet/pucker.png b/app/character_kit_presets/mouths/velvet-puppet/pucker.png new file mode 100644 index 000000000..ec582e35e Binary files /dev/null and b/app/character_kit_presets/mouths/velvet-puppet/pucker.png differ diff --git a/app/character_kit_presets/mouths/velvet-puppet/round.png b/app/character_kit_presets/mouths/velvet-puppet/round.png new file mode 100644 index 000000000..29866db43 Binary files /dev/null and b/app/character_kit_presets/mouths/velvet-puppet/round.png differ diff --git a/app/character_kit_presets/mouths/velvet-puppet/small.png b/app/character_kit_presets/mouths/velvet-puppet/small.png new file mode 100644 index 000000000..fb2616e1c Binary files /dev/null and b/app/character_kit_presets/mouths/velvet-puppet/small.png differ diff --git a/app/character_kit_presets/mouths/velvet-puppet/tongue.png b/app/character_kit_presets/mouths/velvet-puppet/tongue.png new file mode 100644 index 000000000..3acd18e3f Binary files /dev/null and b/app/character_kit_presets/mouths/velvet-puppet/tongue.png differ diff --git a/app/character_kit_presets/mouths/velvet-puppet/wide.png b/app/character_kit_presets/mouths/velvet-puppet/wide.png new file mode 100644 index 000000000..7e338a577 Binary files /dev/null and b/app/character_kit_presets/mouths/velvet-puppet/wide.png differ diff --git a/app/character_kit_presets/mouths/warm-pastel/bite.png b/app/character_kit_presets/mouths/warm-pastel/bite.png new file mode 100644 index 000000000..8faca4f42 Binary files /dev/null and b/app/character_kit_presets/mouths/warm-pastel/bite.png differ diff --git a/app/character_kit_presets/mouths/warm-pastel/closed.png b/app/character_kit_presets/mouths/warm-pastel/closed.png new file mode 100644 index 000000000..78fd774ef Binary files /dev/null and b/app/character_kit_presets/mouths/warm-pastel/closed.png differ diff --git a/app/character_kit_presets/mouths/warm-pastel/medium.png b/app/character_kit_presets/mouths/warm-pastel/medium.png new file mode 100644 index 000000000..1641d3b49 Binary files /dev/null and b/app/character_kit_presets/mouths/warm-pastel/medium.png differ diff --git a/app/character_kit_presets/mouths/warm-pastel/pressed.png b/app/character_kit_presets/mouths/warm-pastel/pressed.png new file mode 100644 index 000000000..f6bddf4af Binary files /dev/null and b/app/character_kit_presets/mouths/warm-pastel/pressed.png differ diff --git a/app/character_kit_presets/mouths/warm-pastel/pucker.png b/app/character_kit_presets/mouths/warm-pastel/pucker.png new file mode 100644 index 000000000..08f548213 Binary files /dev/null and b/app/character_kit_presets/mouths/warm-pastel/pucker.png differ diff --git a/app/character_kit_presets/mouths/warm-pastel/round.png b/app/character_kit_presets/mouths/warm-pastel/round.png new file mode 100644 index 000000000..eca51dd6e Binary files /dev/null and b/app/character_kit_presets/mouths/warm-pastel/round.png differ diff --git a/app/character_kit_presets/mouths/warm-pastel/small.png b/app/character_kit_presets/mouths/warm-pastel/small.png new file mode 100644 index 000000000..c3908f7f4 Binary files /dev/null and b/app/character_kit_presets/mouths/warm-pastel/small.png differ diff --git a/app/character_kit_presets/mouths/warm-pastel/tongue.png b/app/character_kit_presets/mouths/warm-pastel/tongue.png new file mode 100644 index 000000000..16e7d3a6b Binary files /dev/null and b/app/character_kit_presets/mouths/warm-pastel/tongue.png differ diff --git a/app/character_kit_presets/mouths/warm-pastel/wide.png b/app/character_kit_presets/mouths/warm-pastel/wide.png new file mode 100644 index 000000000..b96a6950f Binary files /dev/null and b/app/character_kit_presets/mouths/warm-pastel/wide.png differ diff --git a/app/character_kit_presets/mouths/watercolor-rose/bite.png b/app/character_kit_presets/mouths/watercolor-rose/bite.png new file mode 100644 index 000000000..ba5cb445b Binary files /dev/null and b/app/character_kit_presets/mouths/watercolor-rose/bite.png differ diff --git a/app/character_kit_presets/mouths/watercolor-rose/closed.png b/app/character_kit_presets/mouths/watercolor-rose/closed.png new file mode 100644 index 000000000..aac0f5e03 Binary files /dev/null and b/app/character_kit_presets/mouths/watercolor-rose/closed.png differ diff --git a/app/character_kit_presets/mouths/watercolor-rose/medium.png b/app/character_kit_presets/mouths/watercolor-rose/medium.png new file mode 100644 index 000000000..233b9eeb2 Binary files /dev/null and b/app/character_kit_presets/mouths/watercolor-rose/medium.png differ diff --git a/app/character_kit_presets/mouths/watercolor-rose/pressed.png b/app/character_kit_presets/mouths/watercolor-rose/pressed.png new file mode 100644 index 000000000..8dcd62ff5 Binary files /dev/null and b/app/character_kit_presets/mouths/watercolor-rose/pressed.png differ diff --git a/app/character_kit_presets/mouths/watercolor-rose/pucker.png b/app/character_kit_presets/mouths/watercolor-rose/pucker.png new file mode 100644 index 000000000..d25e7e39c Binary files /dev/null and b/app/character_kit_presets/mouths/watercolor-rose/pucker.png differ diff --git a/app/character_kit_presets/mouths/watercolor-rose/round.png b/app/character_kit_presets/mouths/watercolor-rose/round.png new file mode 100644 index 000000000..2814a88f7 Binary files /dev/null and b/app/character_kit_presets/mouths/watercolor-rose/round.png differ diff --git a/app/character_kit_presets/mouths/watercolor-rose/small.png b/app/character_kit_presets/mouths/watercolor-rose/small.png new file mode 100644 index 000000000..dbaa59924 Binary files /dev/null and b/app/character_kit_presets/mouths/watercolor-rose/small.png differ diff --git a/app/character_kit_presets/mouths/watercolor-rose/tongue.png b/app/character_kit_presets/mouths/watercolor-rose/tongue.png new file mode 100644 index 000000000..81c119ce2 Binary files /dev/null and b/app/character_kit_presets/mouths/watercolor-rose/tongue.png differ diff --git a/app/character_kit_presets/mouths/watercolor-rose/wide.png b/app/character_kit_presets/mouths/watercolor-rose/wide.png new file mode 100644 index 000000000..12b74571c Binary files /dev/null and b/app/character_kit_presets/mouths/watercolor-rose/wide.png differ diff --git a/app/character_kit_presets/mouths/woodblock/bite.png b/app/character_kit_presets/mouths/woodblock/bite.png new file mode 100644 index 000000000..406499bc4 Binary files /dev/null and b/app/character_kit_presets/mouths/woodblock/bite.png differ diff --git a/app/character_kit_presets/mouths/woodblock/closed.png b/app/character_kit_presets/mouths/woodblock/closed.png new file mode 100644 index 000000000..a1bfd38cf Binary files /dev/null and b/app/character_kit_presets/mouths/woodblock/closed.png differ diff --git a/app/character_kit_presets/mouths/woodblock/medium.png b/app/character_kit_presets/mouths/woodblock/medium.png new file mode 100644 index 000000000..3847b9f6d Binary files /dev/null and b/app/character_kit_presets/mouths/woodblock/medium.png differ diff --git a/app/character_kit_presets/mouths/woodblock/pressed.png b/app/character_kit_presets/mouths/woodblock/pressed.png new file mode 100644 index 000000000..b6b260b18 Binary files /dev/null and b/app/character_kit_presets/mouths/woodblock/pressed.png differ diff --git a/app/character_kit_presets/mouths/woodblock/pucker.png b/app/character_kit_presets/mouths/woodblock/pucker.png new file mode 100644 index 000000000..b14447298 Binary files /dev/null and b/app/character_kit_presets/mouths/woodblock/pucker.png differ diff --git a/app/character_kit_presets/mouths/woodblock/round.png b/app/character_kit_presets/mouths/woodblock/round.png new file mode 100644 index 000000000..052373d4c Binary files /dev/null and b/app/character_kit_presets/mouths/woodblock/round.png differ diff --git a/app/character_kit_presets/mouths/woodblock/small.png b/app/character_kit_presets/mouths/woodblock/small.png new file mode 100644 index 000000000..ee87621c2 Binary files /dev/null and b/app/character_kit_presets/mouths/woodblock/small.png differ diff --git a/app/character_kit_presets/mouths/woodblock/tongue.png b/app/character_kit_presets/mouths/woodblock/tongue.png new file mode 100644 index 000000000..f6ed0e94e Binary files /dev/null and b/app/character_kit_presets/mouths/woodblock/tongue.png differ diff --git a/app/character_kit_presets/mouths/woodblock/wide.png b/app/character_kit_presets/mouths/woodblock/wide.png new file mode 100644 index 000000000..c1454f926 Binary files /dev/null and b/app/character_kit_presets/mouths/woodblock/wide.png differ diff --git a/app/core_runtime.py b/app/core_runtime.py new file mode 100644 index 000000000..21dd78f03 --- /dev/null +++ b/app/core_runtime.py @@ -0,0 +1,700 @@ +"""Apple Silicon core/remote server: editors, projects and remote APIs without Torch.""" +from __future__ import annotations + +import json +import os +import shutil +import sys +from fastapi import FastAPI, HTTPException, Request +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import FileResponse +from fastapi.staticfiles import StaticFiles + +from routers.assets import create_assets_router +from routers.canonical_tasks import create_canonical_tasks_router +from routers.character_kit_face import create_character_kit_face_router +from routers.comics import create_comics_router +from routers import core_labs as labs +from routers.core_labs import create_core_labs_router +from routers.core_mcp import create_core_mcp_router +from routers.core_remote import create_core_remote_router +from routers.core_series_plan import create_core_series_plan_router +from routers.image_generation_commands import create_image_generation_commands_router +from routers.lan_auth import create_lan_auth_router +from routers.llm import create_llm_prompt_router, create_llm_router +from routers.mcp_access import create_mcp_access_router +from routers.projects import create_projects_router +from routers.productions import create_productions_router +from routers.recipes import create_recipes_router +from routers.scene_commands import create_scene_commands_router +from routers.scene_packages import create_scene_packages_router +from routers.series_assembly import create_series_assembly_router +from routers.style_library import create_style_library_router +from routers.system_capabilities import create_system_capabilities_router, require_capability_http +from routers.user_diagnostics import create_user_diagnostics_router +from routers.wizard_workflow_executor import create_wizard_workflow_executor_router +from routers.world3d_export import create_world3d_export_router, bind_world3d_renderer_origin +from routers.workspace_collections import create_workspace_collections_router +from services import ( + core_canonical_tasks, + core_editor, + core_generation_commands, + core_production, + core_remote_image, + core_scene_recording, + core_series_assembly, + core_upload, + core_workspace as core, +) +from services.mcp_access import McpAccess +from services.platform_capabilities import platform_capabilities +from services.scene_commands import SceneCommands +from services.style_library import StyleLibrary +from services.ui_distribution import build_status, recovery_html, report_identity +from services.wizard_conversations import ( + WizardConversationRevisionConflict, + read_conversation, + write_conversation, +) +from services.wizard_workflows import ( + WizardWorkflowRevisionConflict, + read_workflows, + write_workflows, +) +from services.wizard_workflow_executor import WizardWorkflowExecutor +from services.world3d_export import World3DExportService +from services.workspace_registry import WorkspaceRegistry + +api = FastAPI(title="HocusPocus core") +api.add_middleware( + CORSMiddleware, + allow_origin_regex=r"^https?://(127\.0\.0\.1|localhost|\d+\.localhost)(:\d+)?$", + allow_methods=["*"], + allow_headers=["*"], +) +api.include_router(create_lan_auth_router()) +api.include_router(create_system_capabilities_router()) +api.include_router(create_user_diagnostics_router( + load_receipt=lambda workspace, intent_id: core_generation_commands.service().receipt(workspace, intent_id), +)) +api.include_router(create_projects_router(list_workspaces=core.list_workspaces, workspace_dir=core.workspace_dir)) +api.include_router(create_assets_router( + list_workspaces=core.list_workspaces, + workspace_dir=core.workspace_dir, + uploads_dir=core.uploads_dir, +)) +api.include_router(create_recipes_router( + workspace_dir=core.workspace_dir, + nsfw_allowed=lambda: False, + get_model_def=lambda _name: None, + safe_join=core.safe_join, +)) +api.include_router(create_productions_router( + list_workspaces=core.list_workspaces, + list_pipelines=lambda _workspace: [], +)) +api.include_router(create_workspace_collections_router( + registry=lambda: WorkspaceRegistry(os.path.join(str(core.outputs_root()), "_hocuspocus", "workspaces-v1.json")), +)) +api.include_router(create_style_library_router(StyleLibrary(str(core.outputs_root())))) +api.include_router(create_comics_router( + workspace_dir=core.workspace_dir, + get_active_workspace=core.active_workspace, + safe_join=core.safe_join, + get_services_config=core.services_raw, + publish_legacy_task=None, +)) +api.include_router(create_scene_commands_router(SceneCommands(core.workspace_dir))) +api.include_router(create_scene_packages_router( + workspace_dir=core.workspace_dir, + uploads_dir=core.uploads_dir, + list_workspaces=core.list_workspaces, +)) +_world3d_export = World3DExportService( + workspace_dir=core.workspace_dir, + registry_for=core_generation_commands.registry_for, + app_url=os.environ.get("HOCUS_APP_URL", ""), +) +bind_world3d_renderer_origin(api, _world3d_export) +api.include_router(create_world3d_export_router(_world3d_export)) +api.include_router(create_core_labs_router()) +api.include_router(create_series_assembly_router( + resolve_workspace=labs._series_workspace, + workspace_dir=core.workspace_dir, + list_workspaces=core.list_workspaces, + library_lock=labs._LOCK, + read_library=labs._read_series, + write_library=labs._write_series, + find_series=labs._series_or_404, + asset_local_path=core_series_assembly.asset_local_path, + available_filename=core_series_assembly.available_filename, + concatenate_clips=lambda *args, **kwargs: core_series_assembly.concatenate_clips(*args, **kwargs), + iso_now=labs._iso_now, +)) +api.include_router(create_core_series_plan_router()) +api.include_router(create_core_remote_router()) +_mcp_access = McpAccess( + os.path.join(os.path.dirname(__file__), "settings", "mcp-access.json"), +) +api.include_router(create_core_mcp_router(_mcp_access)) +api.include_router(create_mcp_access_router(_mcp_access)) +_core_image_commands = core_generation_commands.service() +api.include_router(create_image_generation_commands_router(_core_image_commands)) +api.include_router(create_wizard_workflow_executor_router(WizardWorkflowExecutor( + workspace_dir=core.workspace_dir, + submit_command=_core_image_commands.submit, + command_receipt=_core_image_commands.receipt, + get_task=core_generation_commands.get_task, +), list_workspaces=core.list_workspaces)) +api.include_router(create_character_kit_face_router( + workspace_dir=core.workspace_dir, + uploads_root=core.uploads_dir, +)) +api.include_router(create_canonical_tasks_router( + get_active_workspace=core.active_workspace, + validate_workspace=core.workspace_dir, + registry_for_workspace=core_generation_commands.registry_for, + sync_tasks=core_canonical_tasks.sync_tasks, + task_status=core_canonical_tasks.task_status, + upsert_task=core_canonical_tasks.upsert_task, + control_task=core_canonical_tasks.control_task, +)) + +BLOCKED = ( + ("POST", "/api/v1/recast", "wangp_local"), + ("POST", "/api/v1/tools/upscale", "wangp_local"), + ("POST", "/api/v1/rig/generate", "unirig_ai"), + ("POST", "/api/v1/tools/remove-background", "sam_inpaint"), + ("POST", "/api/v1/tools/revoice", "local_audio_ai"), + ("POST", "/api/v1/retake", "wangp_local"), + ("POST", "/api/v1/audio/analyze", "whisper_local"), + ("POST", "/api/v1/audio/analyze/jobs", "whisper_local"), + ("POST", "/api/v1/director/pipelines/{pid}/clips/{clip_index}/rerun-video", "wangp_local"), + ("POST", "/api/v1/director/pipelines/{pid}/repair", "wangp_local"), + ("POST", "/api/v1/llm/plan-h3-windows", "wangp_local"), +) + + +def _block(capability: str): + def endpoint(): + require_capability_http(capability) + return {"status": "ok"} + return endpoint + + +async def _hidden_wangp_enhance(*_args, **_kwargs): + raise RuntimeError("WanGP enhancer is hidden on the core profile") + + +for method, path, capability in BLOCKED: + api.add_api_route(path, _block(capability), methods=[method]) + + +@api.get("/api/v1/system/preflight") +def system_preflight(): + checks = [] + if shutil.which("ffmpeg") is None: + checks.append({"id": "ffmpeg", "level": "error", + "message": "ffmpeg was not found on PATH. Video and audio export will fail."}) + return {"ok": not any(item["level"] == "error" for item in checks), "checks": checks} + + +@api.get("/api/v1/models") +def list_models(): + model = core_remote_image.catalog_entry() + return { + "families": [{"id": "minimax", "label": "MiniMax", "order": 10}], + "models": [model], + } + + +@api.get("/api/v1/defaults/{model_type}") +def get_defaults(model_type: str): + if str(model_type).startswith("minimax:"): + return core_remote_image.defaults() + raise HTTPException(status_code=404, detail=f"Unknown model: {model_type}") + + +@api.get("/api/v1/model-options/{model_type}") +def get_model_options(model_type: str): + if str(model_type).startswith("minimax:"): + return core_remote_image.model_options() + raise HTTPException(status_code=404, detail=f"Unknown model: {model_type}") + + +@api.post("/api/v1/generate") +async def generate(request: Request): + try: + body = await request.json() + except Exception: + body = {} + if not isinstance(body, dict): + body = {} + if core_remote_image.is_minimax_image_request(body): + workspace = str(body.get("workspace") or core.active_workspace() or "default") + try: + return core_remote_image.start_job(body, workspace=workspace) + except Exception as error: + from services.minimax_image_service import MiniMaxImageError + if isinstance(error, MiniMaxImageError): + raise HTTPException(status_code=error.status_code, detail=str(error)) from error + raise HTTPException(status_code=400, detail=str(error)) from error + require_capability_http("wangp_local") + return {"status": "ok"} + + +@api.get("/api/v1/status/{job_id}") +def get_status(job_id: str): + job = core_remote_image.get_job(job_id) + if not job: + raise HTTPException(status_code=404, detail="Job not found") + return job + + +@api.post("/api/v1/cancel/{job_id}") +def cancel_job(job_id: str): + job = core_remote_image.cancel_job(job_id) + if not job: + raise HTTPException(status_code=404, detail="Job not found") + return job + + +@api.get("/api/v1/workspaces") +def list_workspaces_endpoint(): + return {"workspaces": core.list_workspaces(), "active": core.active_workspace()} + + +@api.put("/api/v1/workspaces/active") +async def set_active_workspace(request: Request): + body = await request.json() + name = str(body.get("name") or "default") + try: + path = core.workspace_dir(name) + except ValueError as error: + raise HTTPException(status_code=400, detail=str(error)) from error + data = core.load_config() + data.setdefault("services", {})["active_workspace"] = name + core.save_config(data) + return {"status": "ok", "active": name, "path": path} + + +@api.post("/api/v1/workspaces") +async def create_workspace(request: Request): + body = await request.json() + name = str(body.get("name") or "").strip() + try: + path = core.workspace_dir(name) + except ValueError as error: + raise HTTPException(status_code=400, detail=str(error)) from error + return {"status": "ok", "name": name, "path": path} + + +@api.get("/api/v1/outputs") +def list_outputs(workspace: str = "", media_type: str = "", limit: int = 0, offset: int = 0): + try: + return core.list_outputs(workspace, media_type=media_type, limit=limit, offset=offset) + except ValueError as error: + raise HTTPException(status_code=400, detail=str(error)) from error + + +@api.get("/api/v1/file/{filename:path}") +def serve_file(filename: str, workspace: str | None = None): + folder = core.uploads_dir() if workspace == "__uploads__" else core.workspace_dir(workspace) + path = core.safe_join(folder, filename) + if not path or not os.path.isfile(path): + raise HTTPException(status_code=404, detail="Output file not found") + return FileResponse(path) + + +@api.get("/api/v1/system-config") +def get_system_config(): + return core.system_config() + + +@api.put("/api/v1/system-config") +async def put_system_config(request: Request): + body = await request.json() + data = core.load_config() + for key in ("video_output_codec", "image_output_codec"): + if key in body: + data[key] = body[key] + core.save_config(data) + return {"status": "ok", "updated": body} + + +@api.get("/api/v1/services-config") +def get_services_config(): + return core.services_config() + + +@api.put("/api/v1/services-config") +async def put_services_config(request: Request): + body = await request.json() + if not isinstance(body, dict): + raise HTTPException(status_code=400, detail="Invalid services config") + return core.merge_services(body) + + +@api.get("/api/v1/jobs") +@api.get("/api/v1/jobs/recovery") +def list_jobs(): + jobs = core_remote_image.list_active() + return {"jobs": jobs, "pipelines": [], "total": len(jobs)} + + +@api.get("/api/v1/system-stats") +def system_stats(): + import psutil + vm = psutil.virtual_memory() + return { + "cpu": {"percent": psutil.cpu_percent(interval=None)}, + "ram": {"percent": vm.percent, "used_gb": round((vm.total - vm.available) / 1024 ** 3, 1), + "total_gb": round(vm.total / 1024 ** 3, 1)}, + "gpu": {"available": False, "percent": 0, "vram_used_gb": 0, "vram_total_gb": 0, "vram_percent": 0}, + "model": {"name": None, "model_type": None, "loaded": False}, + } + + +@api.get("/api/v1/system-detect") +def system_detect(): + return { + "auto_enabled": False, + "hardware": { + "cuda_available": False, "gpu_name": "", "gpu_vram_gb": 0, "gpu_capability": "", + "ram_gb": 0, "cpu_count": os.cpu_count() or 0, "ram_tier": "high", "vram_tier": "none", + "supports_fp8": False, "supports_sage": False, "supports_sage2": False, + "supports_flash": False, "supports_triton": False, "supports_nvfp4": False, + }, + "recommended": { + "video_profile": 4, "image_profile": 4, "audio_profile": 4, + "transformer_quantization": "int8", "vae_config": 0, "vram_safety_coefficient": 0.8, + "attention_mode": "auto", "compile": "", + }, + } + + +@api.get("/api/v1/downloads/active") +def downloads_active(): + return {"downloads": []} + + +@api.get("/api/v1/loras/installed") +def loras_installed(): + return {"loras": [], "manifest_last_check_at": None} + + +def _wizard_workspace(value: object) -> str: + name = value if isinstance(value, str) and value.strip() else None + return core.workspace_dir(name) + + +@api.get("/api/v1/wizard/conversations") +def get_wizard_conversation(workspace: str | None = None): + try: + return read_conversation(_wizard_workspace(workspace)) + except ValueError as error: + raise HTTPException(status_code=400, detail=str(error)) from error + except (OSError, json.JSONDecodeError) as error: + raise HTTPException(status_code=500, detail=f"Could not read the Wizard conversation: {error}") from error + + +@api.put("/api/v1/wizard/conversations") +async def put_wizard_conversation(request: Request): + body = await request.json() + if not isinstance(body, dict): + raise HTTPException(status_code=400, detail="Wizard conversation must be a JSON object") + try: + base_revision = body.get("baseRevision") + if type(base_revision) is not int: + base_revision = 0 + return write_conversation( + _wizard_workspace(body.get("workspace")), + body.get("conversation"), + base_revision=base_revision, + ) + except WizardConversationRevisionConflict as error: + raise HTTPException( + status_code=409, + detail={ + "code": "wizard_conversation_revision_conflict", + "message": str(error), + "expectedRevision": error.expected, + "currentRevision": error.current, + }, + ) from error + except ValueError as error: + raise HTTPException(status_code=400, detail=str(error)) from error + except OSError as error: + raise HTTPException(status_code=500, detail=f"Could not save the Wizard conversation: {error}") from error + + +@api.get("/api/v1/wizard/workflows") +def get_wizard_workflows(workspace: str | None = None): + try: + return read_workflows(_wizard_workspace(workspace)) + except ValueError as error: + raise HTTPException(status_code=400, detail=str(error)) from error + except (OSError, json.JSONDecodeError) as error: + raise HTTPException(status_code=500, detail=f"Could not read Wizard workflows: {error}") from error + + +@api.put("/api/v1/wizard/workflows") +async def put_wizard_workflows(request: Request): + body = await request.json() + if not isinstance(body, dict): + raise HTTPException(status_code=400, detail="Wizard workflows must be a JSON object") + try: + base_revision = body.get("baseRevision") + if type(base_revision) is not int: + base_revision = 0 + return write_workflows( + _wizard_workspace(body.get("workspace")), + body.get("collection"), + base_revision=base_revision, + ) + except WizardWorkflowRevisionConflict as error: + raise HTTPException( + status_code=409, + detail={ + "code": "wizard_workflow_revision_conflict", + "message": str(error), + "expectedRevision": error.expected, + "currentRevision": error.current, + }, + ) from error + except ValueError as error: + raise HTTPException(status_code=400, detail=str(error)) from error + except OSError as error: + raise HTTPException(status_code=500, detail=f"Could not save Wizard workflows: {error}") from error + + +@api.get("/api/v1/resolutions") +def resolutions(): + return {"resolutions": []} + + +@api.get("/api/v1/presets") +def presets(): + return {"presets": []} + + +@api.get("/api/v1/model-visibility") +def model_visibility(): + return { + "configured": True, + "enabled_models": [core_remote_image.MODEL_ID], + "initialized_mature_models": [], + "defaults_version": 1, + } + + +@api.get("/api/v1/model-selections") +def model_selections(): + return {"configured": True, "selected_models": {}, "sources": {}} + + +@api.get("/api/v1/wangp/capabilities") +def wangp_capabilities(): + return {"processors": []} + + +@api.get("/api/v1/rig/capabilities") +def rig_capabilities(): + return {"engines": [{"id": "procedural", "label": "Procedural (fast)"}]} + + +@api.post("/api/v1/scenes/recordings") +async def save_scene_recording(request: Request): + try: + return await core_scene_recording.publish_from_form(await request.form()) + except HTTPException: + raise + except Exception as error: + raise HTTPException( + status_code=core_scene_recording.http_error_status(error), + detail=core_scene_recording.http_error_detail(error), + ) from error + + +@api.post("/api/v1/scenes") +async def save_scene(request: Request): + body = await request.json() + scene = body.get("scene") if isinstance(body, dict) else None + if not isinstance(scene, dict): + raise HTTPException(status_code=400, detail="A version 1 scene is required") + workspace = body.get("workspace") + if workspace is None: + workspace = core.active_workspace() + folder = core.workspace_dir(workspace) + os.makedirs(folder, exist_ok=True) + import json + import time + import uuid + name = f"{time.strftime('%Y-%m-%d-%Hh%Mm%Ss')}_scene_{uuid.uuid4().hex[:6]}.scene.json" + path = os.path.join(folder, name) + Path_write = path + with open(Path_write, "w", encoding="utf-8") as handle: + json.dump(scene, handle) + return {"name": name, "type": "scene", "url": f"/api/v1/file/{name}?workspace={workspace}"} + + +@api.post("/api/v1/video-editor/probe") +def probe_video(body: dict): + try: + return core_editor.probe_video(str((body or {}).get("source") or ""), (body or {}).get("workspace")) + except ValueError as error: + raise HTTPException(status_code=400, detail=str(error)) from error + + +@api.post("/api/v1/video-editor/probe-audio") +def probe_audio_route(body: dict): + try: + return core_editor.probe_soundtrack(str((body or {}).get("source") or ""), (body or {}).get("workspace")) + except ValueError as error: + raise HTTPException(status_code=400, detail=str(error)) from error + + +@api.get("/api/v1/video-editor/thumbnail") +def video_thumbnail(source: str): + try: + path = core_editor.thumbnail_path(source) + except Exception as error: + raise HTTPException(status_code=400, detail=str(error)) from error + return FileResponse(path, media_type="image/jpeg") + + +@api.post("/api/v1/video-editor/screenshot") +def video_screenshot(body: dict): + try: + return core_editor.screenshot( + str((body or {}).get("source") or ""), + float((body or {}).get("time") or 0), + str((body or {}).get("name") or "frame"), + (body or {}).get("workspace"), + ) + except (ValueError, RuntimeError) as error: + raise HTTPException(status_code=400, detail=str(error)) from error + + +@api.post("/api/v1/video-editor/export", status_code=202) +def video_export(body: dict): + try: + return core_editor.start_export(body or {}) + except ValueError as error: + raise HTTPException(status_code=400, detail=str(error)) from error + + +@api.get("/api/v1/video-editor/export/{job_id}") +def video_export_status(job_id: str): + job = core_editor.get_job(job_id) + if not job: + raise HTTPException(status_code=404, detail="Export job not found") + return job + + +@api.post("/api/v1/upload") +async def upload_file(request: Request, filename: str = "upload.bin"): + chunks: list[bytes] = [] + total = 0 + async for chunk in request.stream(): + total += len(chunk) + if total > core_upload.MAX_UPLOAD_BYTES: + raise HTTPException(status_code=413, detail="File too large (max 500 MB)") + chunks.append(chunk) + try: + data, original = core_upload.extract_upload( + b"".join(chunks), + request.headers.get("content-type") or "", + filename, + ) + return core_upload.save_upload(core.uploads_dir(), data, original) + except ValueError as error: + status = 413 if "too large" in str(error).lower() else 400 + raise HTTPException(status_code=status, detail=str(error)) from error + + +@api.get("/api/v1/uploads/{filename:path}") +def serve_upload(filename: str): + path = core.safe_join(core.uploads_dir(), filename) + if not path or not os.path.isfile(path): + raise HTTPException(status_code=404, detail="Upload not found") + return FileResponse(path) + + +@api.post("/api/v1/llm/load") +async def llm_load(request: Request): + body = await request.json() + provider = str((body or {}).get("provider") or core.services_config().get("llm_provider") or "local") + if provider == "local": + require_capability_http("local_llm") + from services import llm_service + llm_service.load_model( + model_id=str((body or {}).get("model_id") or ""), + provider=provider, + remote_url=str((body or {}).get("remote_url") or ""), + api_key=str((body or {}).get("api_key") or ""), + ) + return {"status": "ok", **llm_service.get_status()} + + +api.include_router(create_llm_router( + get_services_config=core.services_raw, + effective_llm_routing=core_production.effective_llm_routing, + llm_provider_credentials=core_production.llm_provider_credentials, + llm_default_device=lambda: "cpu", + default_llm_repo="MiniMax-M3", + ensure_llm_loaded=core_production.ensure_llm_loaded, + comic_writing_llm=core_production.comic_writing_llm, + resolve_visual_media=core_production.resolve_visual_media, +)) +api.include_router(create_llm_prompt_router( + get_services_config=core.services_raw, + effective_llm_routing=core_production.effective_llm_routing, + public_llm_providers=frozenset({"openai", "anthropic", "minimax", "grok", "deepseek"}), + ensure_llm_loaded=core_production.ensure_llm_loaded, + get_model_def=lambda _name: None, + get_lora_dir=lambda _name: "", + get_cached_hardware=lambda: {}, + get_enhancer_enabled=lambda: 0, + enhance_with_wangp=_hidden_wangp_enhance, +)) + + +_app_dir = os.path.dirname(os.path.abspath(__file__)) +_ui_dist = os.path.normpath(os.path.join(_app_dir, "..", "ui", "dist")) +_ui_ready = build_status()["ready"] +if _ui_ready: + api.mount("/", StaticFiles(directory=_ui_dist, html=True)) +else: + @api.get("/") + def index(): + from fastapi.responses import HTMLResponse + return HTMLResponse(recovery_html(), status_code=503, headers={"Cache-Control": "no-store"}) + + +def run_server() -> None: + import uvicorn + + report_identity() + snapshot = platform_capabilities() + print("[HocusPocus] capabilities") + for name, entry in snapshot["capabilities"].items(): + print(f" {name}: {entry['state']}") + print(f"[HocusPocus] profile {snapshot['profile']}") + port = int(os.environ.get("SERVER_PORT", "7860")) + pinokio_share = (os.environ.get("PINOKIO_SHARE_LOCAL") or "").strip().lower() + if pinokio_share == "true": + host = "0.0.0.0" + elif pinokio_share == "false": + host = "127.0.0.1" + else: + host = os.environ.get("SERVER_NAME", "127.0.0.1") + display_host = "127.0.0.1" if host == "0.0.0.0" else host + print(f"HocusPocus Lab UI: http://{display_host}:{port}/") + uvicorn.run(api, host=host, port=port) + + +if __name__ == "__main__": + if _app_dir not in sys.path: + sys.path.insert(0, _app_dir) + run_server() diff --git a/app/docs/API.md b/app/docs/API.md index e3d2a20e0..f1e4c6a71 100644 --- a/app/docs/API.md +++ b/app/docs/API.md @@ -699,15 +699,19 @@ status = requests.get(f"{base}/api/v1/video-editor/export/{job['job_id']}").json ## Image background removal +Operator workflow: [`docs/tools/HOWUSEIT.md`](../../docs/tools/HOWUSEIT.md). + `POST /api/v1/tools/remove-background` queues a standalone image tool job. It uses the shared rembg U2Net adapter, never overwrites the source, and publishes the transparent PNG plus a canonical `.meta.json` asset manifest in the -destination workspace. Use an exact `asset_id` from `GET /api/v1/assets?kind=image` -whenever possible; `source` may be the exact filename, an `/api/v1/file/...` -URL, or an absolute path already inside the selected uploads/workspace root. -`source_workspace` is required when the source belongs to another output -folder. Poll `GET /api/v1/status/{job_id}` and cancel with -`POST /api/v1/cancel/{job_id}`. +destination workspace. Accepted image extensions are `.png`, `.jpg`, `.jpeg`, +and `.webp` (narrower than Tools upscale). Use an exact `asset_id` from +`GET /api/v1/assets?kind=image` whenever possible; `source` may be the exact +filename, an `/api/v1/file/...` URL, or an absolute path already inside the +selected uploads/workspace root. For a source in another output folder, +preserve its file-URL `?workspace=` or provide `source_workspace`; an explicit +scope must agree with the URL. Optional `instruction` (max 2 000 chars) is stored on the job and sidecar; U2Net does not consume it. Poll +`GET /api/v1/status/{job_id}` and cancel with `POST /api/v1/cancel/{job_id}`. ```bash curl -X POST "$HOCUSPOCUS_URL/api/v1/tools/remove-background" \ @@ -715,7 +719,6 @@ curl -X POST "$HOCUSPOCUS_URL/api/v1/tools/remove-background" \ -d '{ "asset_id": "asset_image_123", "workspace": "default", - "instruction": "preserve the hair edges", "provenance": {"actor": "user"} }' ``` @@ -728,22 +731,51 @@ transparent-PNG technical metadata. ## Tools upscale -`POST /api/v1/tools/upscale` is one shared post-processing action for either a -still image or a video. Send `{ "source": "image.png", "source_kind": +Studio and Wizard upscale now submit version 2 `tools.upscale` through +`POST /api/v1/generation/commands`; MCP exposes the same operation by name. +The shared contract requires an explicit method, source kind, output workspace, +and an asset ID or canonical local media URL as `input.params.source`. +It rejects host paths and bare filenames. See the +[shared command guide](../../docs/development/SHARED_NATIVE_COMMANDS.md) for +the envelope, receipts and replay, and +[Tools commands](../../docs/development/TOOLS_COMMANDS.md) for source identity. + +The native legacy `POST /api/v1/tools/upscale` handles either a still image +or a video. Send `{ "source": "image.png", "source_kind": "image", "asset_id": "...", "source_workspace": "...", "method": "flashvsr2", "workspace": "default" }` for an image, or keep the legacy -`video_path` field with `source_kind: "video"` for a clip. Supported image +`video_path` field with `source_kind: "video"` for a clip. Built-in spatial +methods include `flashvsr2` (native default), `flashvsr3`, `flashvsr4`, +`flashvsr2pass2`, `flashvsr2pass4`, `lanczos1.5`, and `lanczos2`. Additional +native processors have media/platform constraints; discover the current schema +through `GET /api/v1/generation/commands` and processor availability in the +Tools panel. Conflicting `source` / `source_path` / `video_path` values return +`409`. Supported image formats are `.bmp`, `.gif`, `.jpeg`, `.jpg`, `.png`, `.tif`, `.tiff`, and `.webp`; supported video formats are `.avi`, `.m4v`, `.mkv`, `.mov`, `.mp4`, `.mpeg`, `.mpg`, `.webm`, and `.wmv`. The source must be an exact asset, upload, or file inside the selected workspace roots; path traversal and mismatched asset IDs/kinds are rejected. Images use the existing spatial upsampler in still -mode and produce a new PNG beside the source. Videos retain the existing +mode and produce a new PNG in the destination workspace. Videos retain the existing audio-preserving pipeline and produce a new video. Neither path overwrites its source. Poll the returned job with `GET /api/v1/status/{job_id}` and cancel it with `POST /api/v1/cancel/{job_id}`. Activity and the canonical asset manifest retain the source lineage, method, workspace, provenance, and execution mode. +## Tools revoice + +`POST /api/v1/tools/revoice` replaces voices on an existing **video** with +SeedVC. Body: `{ "video_path": "take.mp4", "voice_ref_paths": ["ref.wav"], +"mode": "single"|"two", "diffusion_steps": 25, "cfg_rate": 0.5, +"workspace": "default" }`. At least one and at most two reference paths are +required (audio or video). Supply two references for `mode: "two"`; with one, +the worker falls back to single-voice conversion. `mode` defaults to `single` +and any other string is coerced to `single`. The worker copies the source to a new `_revoiced` +file, then converts the copy; the original clip is never mutated. Failure +when the clip has no audio or SeedVC is unavailable. Same poll/cancel +endpoints as the other Tools jobs. See +[`docs/tools/HOWUSEIT.md`](../../docs/tools/HOWUSEIT.md). + ## Gallery mix kinds `GET /api/v1/outputs` accepts `result_kind=music_video|trailer|series_episode` (plus the existing `media_type`, `multiclip_only`, `favorites_only`, `search`, `workspace`, `limit`, `offset`). Classification lives in `services.output_result_kind` and applies only to **assembled** filenames (`multiclip`, `_mv.mp4`, `_movie.mp4`, `_rejoin_multiclip.mp4`, `_series_assembly`). Requesting `series_episode` also matches `chapter`. When `result_kind` is set, pagination is bypassed and every match is returned. @@ -801,9 +833,9 @@ export HOCUSPOCUS_URL=http://127.0.0.1:7860 folder; disallowed/missing images return `400`/`404`. The output-folder token is `default` or `[A-Za-z0-9][A-Za-z0-9_-]*`. Kit mouth -keys are `closed`, `small`, `wide`, and `round`; eye keys are `open` and -`blink`. `blob:` sources are rejected, and the UI-only `lookNotes` field is -stripped when the kit is normalized for persistence. +keys are `closed`, `small`, `wide`, `round`, `pressed`, `medium`, `pucker`, `bite`, and `tongue`; eye keys are `open` and +`blink`. `blob:` sources are rejected. Optional `lookNotes` (max 4000 +characters) and `voice` (local Qwen3 CustomVoice) persist on the kit. ```bash curl "$HOCUSPOCUS_URL/api/v1/character-kits/library?workspace=default" @@ -835,4 +867,6 @@ These routes always use the server active output folder. They do not accept `?wo - `POST /api/v1/director/pipeline/{pid}/resume` and `POST /api/v1/director/pipeline/{pid}/continue` use the singular `pipeline` path. - Batch prompt rewrite is UI-only: loop `POST /api/v1/llm/generate` (local LLM) then PUT the chosen prompts. -Operator notes: `docs/video-editor/HOWUSEIT.md`, `docs/workspaces/HOWUSEIT.md`, and `docs/character-kits/HOWUSEIT.md`. +Operator notes: `docs/tools/HOWUSEIT.md`, `docs/video-editor/HOWUSEIT.md`, `docs/workspaces/HOWUSEIT.md`, and `docs/character-kits/HOWUSEIT.md`. + +Character Kit `restPose` optionally contains `{asset, fingerprint}`: a derived resting still for reference consumers. The original `base` remains the animation rig source. Speech analysis accepts both PCM WAV bytes and a bounded JSON envelope with `wavBase64`, `dialogue`, and `language`; see [2D speech quality](../../docs/character-kits/SPEECH_QUALITY.md). diff --git a/app/launch.py b/app/launch.py index a7acef323..b949d49c3 100644 --- a/app/launch.py +++ b/app/launch.py @@ -63,8 +63,17 @@ def run_server() -> None: app_dir = _app_directory() if app_dir not in sys.path: sys.path.insert(0, app_dir) + from services.platform_capabilities import ( + PROFILE_MACOS_ARM64, + host_machine, + host_platform, + resolve_profile, + ) from services.ui_distribution import report_identity report_identity() + if resolve_profile(host_platform(), host_machine()) == PROFILE_MACOS_ARM64: + runpy.run_module("core_runtime", run_name="__main__") + return runpy.run_module("_launch_runtime", run_name="__main__") finally: sys.path[:] = previous_path diff --git a/app/routers/core_labs.py b/app/routers/core_labs.py new file mode 100644 index 000000000..f94b1c74d --- /dev/null +++ b/app/routers/core_labs.py @@ -0,0 +1,541 @@ +"""Story, Character Kit and Series filesystem persistence for core/remote.""" +from __future__ import annotations + +import copy +import json +import os +import shutil +import threading +import uuid +from datetime import datetime, timezone +from typing import Any +from urllib.parse import unquote + +from fastapi import APIRouter, HTTPException + +from services import core_workspace as core +from services.character_kit_library import ( + CharacterKitRevisionConflict, + delete_character_kit, + patch_character_kit, + read_character_kit_library, +) +from services.series_library import ( + create_series_episode, + create_series_project, + duplicate_series_project, + import_story_project, + normalize_series_project, + read_series_library, + series_canon_inputs_changed, + validate_workspace_id, + write_series_library, +) +from services.story_library import ( + StoryLibraryRevisionConflict, + delete_story_project, + patch_story_project, + read_story_library, + write_story_library, +) +_LOCK = threading.RLock() + + +def _iso_now() -> str: + return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + + +def _workspace(value: Any) -> str: + name = str(value or core.active_workspace() or "default").strip() + try: + core.workspace_dir(name) + except ValueError as error: + raise HTTPException(status_code=400, detail=str(error)) from error + return name + + +def _dir(value: Any) -> str: + return core.workspace_dir(_workspace(value)) + + +def _conflict(code: str, exc: Any) -> HTTPException: + return HTTPException( + status_code=409, + detail={ + "code": code, + "message": str(exc), + "expectedRevision": getattr(exc, "expected", None), + "currentRevision": getattr(exc, "current", None), + }, + ) + + +def _series_workspace(value: Any) -> str: + try: + name = validate_workspace_id(value or core.active_workspace()) + core.workspace_dir(name) + return name + except ValueError as error: + raise HTTPException(status_code=400, detail=str(error)) from error + + +def _read_series(workspace: str) -> dict: + return read_series_library(_dir(workspace), workspace) + + +def _write_series(workspace: str, library: dict) -> dict: + return write_series_library(_dir(workspace), library, workspace) + + +def _series_or_404(library: dict, series_id: str) -> dict: + series = library.get("seriesById", {}).get(series_id) + if not isinstance(series, dict): + raise HTTPException(status_code=404, detail="Series Lab project not found") + return series + + +def _story_import_upload_path(value: str) -> str: + upload_dir = os.path.realpath(core.uploads_dir()) + candidate = os.path.realpath(str(value or "")) + if candidate != upload_dir and not candidate.startswith(upload_dir + os.sep): + raise HTTPException(status_code=400, detail="Imported Story assets must come from HocusPocus Lab uploads") + if not os.path.isfile(candidate): + raise HTTPException(status_code=400, detail="One imported asset is no longer available") + return candidate + + +def _prepare_story_uploads_for_series_import(story: dict, workspace: str) -> dict: + """Copy Story Lab upload files into the workspace so import_story_project keeps them.""" + prepared = copy.deepcopy(story) + upload_sources: dict[str, str] = {} + assets = prepared.get("assets") if isinstance(prepared.get("assets"), dict) else {} + for asset_key, asset in assets.items(): + if not isinstance(asset, dict): + continue + source = str(asset.get("source") or "") + local_source = None + if source.startswith("/api/v1/uploads/"): + upload_name = unquote(source.split("/api/v1/uploads/", 1)[1]) + local_candidate = core.safe_join(core.uploads_dir(), upload_name) + local_source = _story_import_upload_path(local_candidate or "") + elif os.path.isabs(source): + try: + local_source = _story_import_upload_path(source) + except HTTPException: + continue + if not local_source: + continue + asset_id = str(asset.get("id") or asset_key) + upload_sources[asset_id] = local_source + # A valid workspace placeholder lets the pure importer retain entity links. + asset["source"] = f"assets/story-import/{uuid.uuid4().hex}.bin" + imported = import_story_project(prepared, workspace) + for asset_id, local_source in upload_sources.items(): + imported_asset = imported.get("assets", {}).get(asset_id) + if not isinstance(imported_asset, dict): + continue + extension = os.path.splitext(local_source)[1].lower()[:12] + relative = f"assets/{imported['id']}/{uuid.uuid4().hex[:16]}{extension}" + destination = core.safe_join(_dir(workspace), relative) + if not destination: + raise ValueError("Invalid imported Story asset destination") + os.makedirs(os.path.dirname(destination), exist_ok=True) + shutil.copy2(local_source, destination) + imported_asset["uri"] = relative + return imported + + +def create_core_labs_router() -> APIRouter: + router = APIRouter() + + @router.get("/api/v1/stories/library") + def get_story_library(workspace: str | None = None): + try: + with _LOCK: + return read_story_library(_dir(workspace)) + except (OSError, ValueError) as error: + raise HTTPException(status_code=500, detail=f"Could not read the Story Lab library: {error}") from error + + @router.put("/api/v1/stories/library") + def put_story_library(body: dict): + try: + with _LOCK: + return write_story_library( + _dir(body.get("workspace")), + body.get("library"), + base_revision=body.get("baseRevision"), + ) + except StoryLibraryRevisionConflict as error: + raise _conflict("story_library_revision_conflict", error) from error + except ValueError as error: + raise HTTPException(status_code=400, detail=str(error)) from error + + @router.patch("/api/v1/stories/library/projects/{project_id}") + def patch_story(project_id: str, body: dict): + try: + with _LOCK: + return patch_story_project( + _dir(body.get("workspace")), + project_id, + body.get("project"), + base_revision=body.get("baseRevision"), + make_active=body.get("makeActive") is True, + ) + except StoryLibraryRevisionConflict as error: + raise _conflict("story_library_revision_conflict", error) from error + except ValueError as error: + raise HTTPException(status_code=400, detail=str(error)) from error + + @router.delete("/api/v1/stories/library/projects/{project_id}") + def delete_story(project_id: str, body: dict): + try: + with _LOCK: + return delete_story_project( + _dir(body.get("workspace")), + project_id, + base_revision=body.get("baseRevision"), + ) + except StoryLibraryRevisionConflict as error: + raise _conflict("story_library_revision_conflict", error) from error + except KeyError as error: + raise HTTPException(status_code=404, detail="Story project not found") from error + except ValueError as error: + raise HTTPException(status_code=400, detail=str(error)) from error + + @router.get("/api/v1/character-kits/library") + def get_kits(workspace: str | None = None): + try: + return read_character_kit_library(_dir(workspace)) + except (OSError, ValueError) as error: + raise HTTPException(status_code=500, detail=f"Could not read Character Kits: {error}") from error + + @router.patch("/api/v1/character-kits/library/kits/{kit_id}") + def patch_kit(kit_id: str, body: dict): + try: + return patch_character_kit( + _dir(body.get("workspace")), + kit_id, + body.get("kit"), + base_revision=body.get("baseRevision"), + make_active=body.get("makeActive") is not False, + ) + except CharacterKitRevisionConflict as error: + raise _conflict("character_kit_revision_conflict", error) from error + except ValueError as error: + raise HTTPException(status_code=400, detail=str(error)) from error + + @router.delete("/api/v1/character-kits/library/kits/{kit_id}") + def delete_kit(kit_id: str, body: dict): + try: + return delete_character_kit( + _dir(body.get("workspace")), + kit_id, + base_revision=body.get("baseRevision"), + ) + except CharacterKitRevisionConflict as error: + raise _conflict("character_kit_revision_conflict", error) from error + except KeyError as error: + raise HTTPException(status_code=404, detail="Character Kit not found") from error + except ValueError as error: + raise HTTPException(status_code=400, detail=str(error)) from error + + @router.get("/api/v1/series/library") + def get_series_library(workspace: str | None = None): + target = _series_workspace(workspace) + with _LOCK: + return _read_series(target) + + @router.put("/api/v1/series/library") + def put_series_library(body: dict): + target = _series_workspace(body.get("workspace")) + try: + with _LOCK: + return _write_series(target, body.get("library")) + except ValueError as error: + raise HTTPException(status_code=400, detail=str(error)) from error + + @router.get("/api/v1/series") + def list_series(workspace: str | None = None): + target = _series_workspace(workspace) + with _LOCK: + library = _read_series(target) + return { + "workspaceId": target, + "seriesOrder": library["seriesOrder"], + "series": [library["seriesById"][item] for item in library["seriesOrder"]], + } + + @router.post("/api/v1/series") + def create_series(body: dict): + workspace = _series_workspace(body.get("workspace")) + try: + with _LOCK: + library = _read_series(workspace) + raw = body.get("series") + series = ( + normalize_series_project(raw, str(raw.get("id") or ""), workspace) + if isinstance(raw, dict) + else create_series_project(workspace, title=str(body.get("title") or "Untitled series")) + ) + if series["id"] in library["seriesById"]: + raise HTTPException(status_code=409, detail="A Series Lab project with this id already exists") + library["seriesById"][series["id"]] = series + library["seriesOrder"].append(series["id"]) + stored = _write_series(workspace, library) + return stored["seriesById"][series["id"]] + except ValueError as error: + raise HTTPException(status_code=400, detail=str(error)) from error + + @router.post("/api/v1/series/import-story") + def import_story(body: dict): + workspace = _series_workspace(body.get("workspace")) + story = body.get("story") if isinstance(body.get("story"), dict) else None + if story is None: + story_id = str(body.get("storyId") or "").strip() + if not story_id: + raise HTTPException(status_code=400, detail="Choose a Story Lab project to import") + story = read_story_library(_dir(workspace)).get("projects", {}).get(story_id) + if not isinstance(story, dict): + raise HTTPException(status_code=404, detail="Story Lab source project not found") + try: + imported = _prepare_story_uploads_for_series_import(story, workspace) + with _LOCK: + library = _read_series(workspace) + library["seriesById"][imported["id"]] = imported + library["seriesOrder"].append(imported["id"]) + stored = _write_series(workspace, library) + return stored["seriesById"][imported["id"]] + except ValueError as error: + raise HTTPException(status_code=400, detail=str(error)) from error + + @router.get("/api/v1/series/{series_id}") + def get_series(series_id: str, workspace: str | None = None): + with _LOCK: + return _series_or_404(_read_series(_series_workspace(workspace)), series_id) + + @router.put("/api/v1/series/{series_id}") + def put_series(series_id: str, body: dict): + workspace = _series_workspace(body.get("workspace")) + raw = body.get("series") + if not isinstance(raw, dict): + raise HTTPException(status_code=400, detail="Series project is required") + if raw.get("id") not in {None, "", series_id}: + raise HTTPException(status_code=400, detail="Series project id does not match the route") + try: + with _LOCK: + library = _read_series(workspace) + current = _series_or_404(library, series_id) + base_revision = body.get("baseRevision") + if base_revision is not None and int(base_revision) != int(current.get("revision") or 1): + raise HTTPException( + status_code=409, + detail=f"Series revision changed to {current.get('revision')}; reload before saving", + ) + updated = normalize_series_project({**raw, "id": series_id}, series_id, workspace) + if series_canon_inputs_changed(current, updated): + updated["canon"]["approval"] = "draft" + updated["canon"]["approvedAt"] = "" + updated["revision"] = int(current.get("revision") or 1) + 1 + updated["createdAt"] = current.get("createdAt") or updated["createdAt"] + updated["updatedAt"] = _iso_now() + library["seriesById"][series_id] = updated + return _write_series(workspace, library)["seriesById"][series_id] + except ValueError as error: + raise HTTPException(status_code=400, detail=str(error)) from error + + @router.delete("/api/v1/series/{series_id}") + def delete_series(series_id: str, workspace: str | None = None): + target = _series_workspace(workspace) + with _LOCK: + library = _read_series(target) + _series_or_404(library, series_id) + del library["seriesById"][series_id] + library["seriesOrder"] = [item for item in library["seriesOrder"] if item != series_id] + _write_series(target, library) + return {"deleted": True, "seriesId": series_id, "outputsPreserved": True} + + @router.post("/api/v1/series/{series_id}/duplicate") + def duplicate_series(series_id: str, body: dict | None = None): + body = body if isinstance(body, dict) else {} + workspace = _series_workspace(body.get("workspace")) + with _LOCK: + library = _read_series(workspace) + source = _series_or_404(library, series_id) + duplicate = duplicate_series_project(source) + duplicate = normalize_series_project(duplicate, duplicate["id"], workspace) + library["seriesById"][duplicate["id"]] = duplicate + source_index = library["seriesOrder"].index(series_id) + library["seriesOrder"].insert(source_index + 1, duplicate["id"]) + stored = _write_series(workspace, library) + return stored["seriesById"][duplicate["id"]] + + @router.post("/api/v1/series/{series_id}/episodes") + def create_episode(series_id: str, body: dict): + workspace = _series_workspace(body.get("workspace")) + with _LOCK: + library = _read_series(workspace) + series = copy.deepcopy(_series_or_404(library, series_id)) + if series.get("canon", {}).get("approval") != "approved": + raise HTTPException(status_code=400, detail="Approve the reviewed Series canon before creating an episode") + episode = create_series_episode( + series, str(body.get("seasonId") or "") or None, + **(body.get("episode") if isinstance(body.get("episode"), dict) else {}), + ) + series["episodesById"][episode["id"]] = episode + season = next(item for item in series["seasons"] if item["id"] == episode["seasonId"]) + season["episodeOrder"].append(episode["id"]) + series["revision"] = int(series.get("revision") or 1) + 1 + series["updatedAt"] = episode["updatedAt"] + library["seriesById"][series_id] = series + stored = _write_series(workspace, library) + return stored["seriesById"][series_id]["episodesById"][episode["id"]] + + @router.get("/api/v1/series/{series_id}/episodes") + def list_episodes(series_id: str, workspace: str | None = None): + with _LOCK: + series = _series_or_404(_read_series(_series_workspace(workspace)), series_id) + ordered, seen = [], set() + for season in series.get("seasons", []): + if not isinstance(season, dict): + continue + for episode_id in season.get("episodeOrder", []): + episode = series.get("episodesById", {}).get(episode_id) + if isinstance(episode, dict) and episode_id not in seen: + ordered.append(copy.deepcopy(episode)) + seen.add(episode_id) + return {"episodes": ordered} + + @router.get("/api/v1/series/{series_id}/episodes/{episode_id}") + def get_episode(series_id: str, episode_id: str, workspace: str | None = None): + with _LOCK: + series = _series_or_404(_read_series(_series_workspace(workspace)), series_id) + episode = series.get("episodesById", {}).get(episode_id) + if not isinstance(episode, dict): + raise HTTPException(status_code=404, detail="Series episode not found") + return copy.deepcopy(episode) + + @router.put("/api/v1/series/{series_id}/episodes/{episode_id}") + def put_episode(series_id: str, episode_id: str, body: dict): + from routers.series_episode import apply_series_episode_update + + workspace = _series_workspace(body.get("workspace")) + with _LOCK: + library = _read_series(workspace) + series = _series_or_404(library, series_id) + updated = apply_series_episode_update(series_id, episode_id, body, series, updated_at=_iso_now()) + library["seriesById"][series_id] = updated + stored = _write_series(workspace, library) + return stored["seriesById"][series_id]["episodesById"][episode_id] + + @router.delete("/api/v1/series/{series_id}/episodes/{episode_id}") + def delete_episode(series_id: str, episode_id: str, workspace: str | None = None): + target = _series_workspace(workspace) + with _LOCK: + library = _read_series(target) + series = copy.deepcopy(_series_or_404(library, series_id)) + if episode_id not in series.get("episodesById", {}): + raise HTTPException(status_code=404, detail="Series episode not found") + del series["episodesById"][episode_id] + for season in series.get("seasons", []): + if isinstance(season, dict): + season["episodeOrder"] = [item for item in season.get("episodeOrder", []) if item != episode_id] + series["revision"] = int(series.get("revision") or 1) + 1 + series["updatedAt"] = _iso_now() + library["seriesById"][series_id] = series + _write_series(target, library) + return {"deleted": True, "episodeId": episode_id, "outputsPreserved": True} + + @router.post("/api/v1/series/{series_id}/canon/approve") + def approve_canon(series_id: str, body: dict): + workspace = _series_workspace(body.get("workspace")) + try: + base_revision = int(body.get("baseRevision")) + except (TypeError, ValueError) as exc: + raise HTTPException(status_code=400, detail="baseRevision is required") from exc + with _LOCK: + library = _read_series(workspace) + series = copy.deepcopy(_series_or_404(library, series_id)) + canon = series["canon"] + if int(canon.get("revision") or 1) != base_revision: + raise HTTPException(status_code=409, detail="Canon revision changed; reload before approval") + if not canon.get("worldSummary", "").strip() or not series.get("characters") or not series.get("locations"): + raise HTTPException(status_code=400, detail="Complete the world, characters and locations before approving canon") + if canon.get("approval") != "approved": + canon["revision"] = base_revision + 1 + canon.update(approval="approved", approvedAt=_iso_now()) + for collection in ("characters", "locations", "props"): + for entity in series.get(collection, []): + entity["approval"] = "approved" + series["revision"] += 1 + series["updatedAt"] = _iso_now() + library["seriesById"][series_id] = series + stored = _write_series(workspace, library) + return stored["seriesById"][series_id] + + @router.post("/api/v1/series/{series_id}/episodes/{episode_id}/references/refresh") + def refresh_references(series_id: str, episode_id: str, body: dict): + from services.series_production import refresh_episode_references + from services.series_library import SeriesConflictError + workspace = _series_workspace(body.get("workspace")) + with _LOCK: + library = _read_series(workspace) + try: + series = refresh_episode_references(_series_or_404(library, series_id), episode_id, int(body.get("baseRevision", -1))) + except SeriesConflictError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + except (TypeError, ValueError) as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + series["updatedAt"] = _iso_now() + series["episodesById"][episode_id]["updatedAt"] = series["updatedAt"] + library["seriesById"][series_id] = series + stored = _write_series(workspace, library) + return stored["seriesById"][series_id] + + @router.post("/api/v1/series/{series_id}/assets/import") + def import_asset(series_id: str, body: dict): + import shutil + from services.series_production import attach_series_import, existing_generated_reference + + workspace = _series_workspace(body.get("workspace")) + source_name = str(body.get("uploadPath") or "") + source = core.safe_join(core.uploads_dir(), os.path.basename(source_name)) + if not source or not os.path.isfile(source): + raise HTTPException(status_code=400, detail="Upload a file into HocusPocus before importing it") + owner_type = str(body.get("ownerType") or "series") + owner_id = str(body.get("ownerId") or series_id).strip() + kind = str(body.get("kind") or "image") + asset_id = f"asset_{os.urandom(6).hex()}" + extension = os.path.splitext(source)[1].lower()[:12] + relative = f"assets/{series_id}/{asset_id}{extension}" + destination = core.safe_join(_dir(workspace), relative) + if not destination: + raise HTTPException(status_code=400, detail="Invalid Series asset destination") + with _LOCK: + library = _read_series(workspace) + series = copy.deepcopy(_series_or_404(library, series_id)) + metadata = copy.deepcopy(body.get("metadata")) if isinstance(body.get("metadata"), dict) else {} + if len(json.dumps(metadata, ensure_ascii=False).encode()) > 32 * 1024: + raise HTTPException(status_code=413, detail="Series asset metadata is too large") + existing = existing_generated_reference(series, owner_type, owner_id, metadata) + if existing and body.get("asTake") is not True: + return {"asset": existing, "series": series} + asset = { + "id": asset_id, "workspaceId": workspace, "kind": kind, + "uri": relative, "ownerType": owner_type, "ownerId": owner_id, + "isDerivedThumbnail": False, + "metadata": {**metadata, "name": str(body.get("name") or os.path.basename(source))[:300], + "referenceRole": str(body.get("referenceRole") or "reference")[:100], "importedAt": _iso_now()}, + } + try: + attach_series_import(series, asset, as_take=body.get("asTake") is True, source_path=source) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + os.makedirs(os.path.dirname(destination), exist_ok=True) + shutil.copy2(source, destination) + series["revision"] = int(series.get("revision") or 1) + 1 + series["updatedAt"] = _iso_now() + library["seriesById"][series_id] = series + stored = _write_series(workspace, library) + return {"asset": stored["seriesById"][series_id]["assets"][asset_id], "series": stored["seriesById"][series_id]} + + return router diff --git a/app/routers/core_mcp.py b/app/routers/core_mcp.py new file mode 100644 index 000000000..4b2277aec --- /dev/null +++ b/app/routers/core_mcp.py @@ -0,0 +1,108 @@ +"""MCP surface for the core/remote profile: advertise reads, 409 local engines.""" +from __future__ import annotations + +import secrets + +from fastapi import APIRouter, HTTPException, Request +from fastapi.responses import JSONResponse + +from routers.system_capabilities import require_capability_http +from routers.wangp_mcp import PROTOCOL, tool_definitions + +LOCAL_MUTATIONS = frozenset({"generate", "recast", "upscale"}) +READ_TOOLS = frozenset({"models", "processors", "status", "assets", "collections"}) + + +def _tool_name(body: dict) -> str: + name = str(body.get("method") or body.get("name") or "") + params = body.get("params") if isinstance(body.get("params"), dict) else body + if isinstance(params, dict) and params.get("name"): + name = str(params.get("name") or name) + if name == "tools/call" and isinstance(body.get("params"), dict): + name = str(body["params"].get("name") or name) + return name + + +def _read_result(name: str, arguments: dict) -> dict: + from services import core_workspace as core + + if name == "models": + return {"models": []} + if name == "processors": + return {"processors": []} + if name == "status": + return {"jobs": [], "job_id": arguments.get("job_id")} + if name == "assets": + listed = core.list_outputs(str(arguments.get("workspace") or "") or "") + return {"assets": listed.get("outputs") or [], "total": listed.get("total") or 0} + if name == "collections": + return {"collections": []} + raise HTTPException(status_code=400, detail="Unknown MCP tool") + + +def _jsonrpc(body: dict) -> dict: + request_id = body.get("id") + method = body.get("method") + if method == "initialize": + result = { + "protocolVersion": PROTOCOL, + "capabilities": {"tools": {}}, + "serverInfo": {"name": "hocuspocus-core", "version": "1"}, + "instructions": "Local NVIDIA engines are hidden. Use remote MiniMax/Meshy tools and filesystem reads.", + } + elif method == "ping": + result = {} + elif method == "tools/list": + tools = [ + tool for tool in tool_definitions(READ_TOOLS, command_operations=[]) + if tool["name"] not in LOCAL_MUTATIONS + ] + result = {"tools": tools} + elif method == "tools/call": + params = body.get("params") or {} + name = str(params.get("name") or "") + arguments = params.get("arguments") if isinstance(params.get("arguments"), dict) else {} + if name in LOCAL_MUTATIONS: + require_capability_http("wangp_local") + value = _read_result(name, arguments) + result = { + "content": [{"type": "text", "text": str(value)}], + "isError": False, + } + else: + return {"jsonrpc": "2.0", "id": request_id, "error": {"code": -32601, "message": "Method not found"}} + return {"jsonrpc": "2.0", "id": request_id, "result": result} + + +def _require_mcp_bearer(request: Request, token: str) -> None: + if not token: + raise HTTPException(503, "External agent access is disabled; configure HOCUS_MCP_TOKEN") + if not secrets.compare_digest(request.headers.get("authorization", ""), f"Bearer {token}"): + raise HTTPException(401, "Invalid MCP credentials") + origin = request.headers.get("origin") + if origin and origin != f"{request.url.scheme}://{request.url.netloc}": + raise HTTPException(403, "Origin is not permitted") + + +def create_core_mcp_router(access) -> APIRouter: + router = APIRouter() + + @router.post("/api/v1/wangp/mcp", include_in_schema=False) + @router.post("/api/v1/mcp") + async def wangp_mcp(request: Request): + _require_mcp_bearer(request, access.token()) + body = await request.json() + if isinstance(body, dict) and body.get("jsonrpc") == "2.0": + try: + return JSONResponse(_jsonrpc(body)) + except HTTPException: + raise + name = _tool_name(body if isinstance(body, dict) else {}) + if name in LOCAL_MUTATIONS: + require_capability_http("wangp_local") + if name in READ_TOOLS: + arguments = body.get("params") if isinstance(body, dict) and isinstance(body.get("params"), dict) else {} + return _read_result(name, arguments if isinstance(arguments, dict) else {}) + raise HTTPException(status_code=400, detail="Unknown MCP tool") + + return router diff --git a/app/routers/core_remote.py b/app/routers/core_remote.py new file mode 100644 index 000000000..d56c4129f --- /dev/null +++ b/app/routers/core_remote.py @@ -0,0 +1,213 @@ +"""Remote providers, production profile and Director planning for core/remote.""" +from __future__ import annotations + +import json +import os +from typing import Any + +from fastapi import APIRouter, HTTPException, Request + +from services import core_remote_3d, core_remote_music, core_workspace as core +from services.core_production import ( + ensure_llm_loaded, + production_profile_response, + save_production_profile, +) +from services.director_pipeline_state import ( + _find_pipeline_file, + _iter_pipeline_state_files, + count_pipeline_states, +) +from routers.system_capabilities import require_capability_http + + +def _workspace(value: Any) -> str: + name = str(value or core.active_workspace() or "default").strip() + try: + core.workspace_dir(name) + except ValueError as error: + raise HTTPException(status_code=400, detail=str(error)) from error + return name + + +def _summarize_pipeline(filepath: str, workspace_name: str) -> dict[str, Any] | None: + try: + with open(filepath, encoding="utf-8") as handle: + data = json.load(handle) + except (OSError, json.JSONDecodeError, ValueError): + return None + if not isinstance(data, dict): + return None + clips = data.get("clips") or data.get("clip_plans") or data.get("planned_clips") or [] + return { + "id": data.get("pipeline_id", ""), + "status": data.get("status", "unknown"), + "phase": data.get("phase") or data.get("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": dict(data.get("progress") or {}), + "error": data.get("error"), + "clip_count": len(clips) if isinstance(clips, list) else 0, + "output_count": len(data.get("output_files") or []), + "output_files": list(data.get("output_files") or []), + "scene_description": (data.get("scene_description") or "")[:100], + "workspace": workspace_name, + } + + +def create_core_remote_router() -> APIRouter: + router = APIRouter() + + @router.get("/api/v1/production-profile") + def get_production_profile(): + return production_profile_response() + + @router.put("/api/v1/production-profile") + async def put_production_profile(request: Request): + body = await request.json() + try: + return save_production_profile(body.get("profile") if isinstance(body, dict) else None) + except ValueError as error: + raise HTTPException(status_code=400, detail=str(error)) from error + + @router.get("/api/v1/model3d/capabilities") + def model3d_capabilities(): + return core_remote_3d.capabilities() + + @router.post("/api/v1/model3d/generate") + async def generate_model3d(request: Request): + try: + body = await request.json() + except Exception: + body = {} + if not isinstance(body, dict): + body = {} + workspace = _workspace(body.get("workspace")) + try: + return core_remote_3d.start_job(body, workspace=workspace, output_dir=core.workspace_dir(workspace)) + except HTTPException: + raise + except ValueError as error: + raise HTTPException(status_code=400, detail=str(error)) from error + + @router.get("/api/v1/model3d/status/{job_id}") + def model3d_status(job_id: str): + job = core_remote_3d.get_job(job_id) + if not job: + raise HTTPException(status_code=404, detail="3D generation job not found") + return job + + @router.post("/api/v1/model3d/jobs/{job_id}/cancel") + def model3d_cancel(job_id: str): + job = core_remote_3d.cancel_job(job_id) + if not job: + raise HTTPException(status_code=404, detail="3D generation job not found") + return job + + @router.post("/api/v1/stories/music-candidates/jobs", status_code=202) + def start_music_job(body: dict): + workspace = _workspace(body.get("workspace") if isinstance(body, dict) else None) + try: + return core_remote_music.start_job(body or {}, workspace=workspace) + except HTTPException: + raise + except ValueError as error: + raise HTTPException(status_code=400, detail=str(error)) from error + + @router.get("/api/v1/stories/music-candidates/jobs/{job_id}") + def music_job_status(job_id: str): + job = core_remote_music.get_job(job_id) + if not job: + raise HTTPException(status_code=404, detail="MiniMax Music job not found") + return job + + @router.post("/api/v1/stories/music-candidates/jobs/{job_id}/cancel") + def music_job_cancel(job_id: str): + job = core_remote_music.cancel_job(job_id) + if not job: + raise HTTPException(status_code=404, detail="MiniMax Music job not found") + return job + + @router.get("/api/v1/director/pipelines") + def list_pipelines(limit: int = 0, offset: int = 0): + workspace = core.active_workspace() + base = str(core.outputs_root()) + files = _iter_pipeline_state_files(base, workspace) + if offset < 0: + offset = 0 + selected = files[offset:offset + limit] if limit and limit > 0 else files[offset:] + pipelines = [row for path in selected if (row := _summarize_pipeline(path[1], path[2]))] + return {"pipelines": pipelines, "total": count_pipeline_states(base, workspace), "limit": limit, "offset": offset} + + @router.get("/api/v1/director/pipelines/active") + def active_pipelines(): + return {"pipelines": []} + + @router.get("/api/v1/director/pipelines/{pid}") + def get_pipeline(pid: str): + path = _find_pipeline_file(str(core.outputs_root()), pid) + if not path or not os.path.isfile(path): + raise HTTPException(status_code=404, detail="Pipeline not found") + try: + with open(path, encoding="utf-8") as handle: + data = json.load(handle) + except (OSError, json.JSONDecodeError) as error: + raise HTTPException(status_code=404, detail="Pipeline not found") from error + if isinstance(data, dict): + data.pop("params", None) + return data + + @router.get("/api/v1/director/pipeline/{pid}") + def pipeline_status(pid: str): + return get_pipeline(pid) + + @router.post("/api/v1/director/plan-prompts") + async def plan_prompts(request: Request): + from services import llm_service + + body = await request.json() + clips = body.get("clips") + style_prompt = body.get("style_prompt", "") + if not clips: + raise HTTPException(status_code=400, detail="clips is required") + if not style_prompt: + raise HTTPException(status_code=400, detail="style_prompt is required") + ensure_llm_loaded() + try: + return {"prompts": llm_service.plan_clip_prompts( + clips=clips, style_prompt=style_prompt, + lyrics=body.get("lyrics"), bpm=body.get("bpm", 120.0), + )} + except Exception as error: + raise HTTPException(status_code=500, detail=str(error)) from error + + @router.post("/api/v1/director/plan-angle-prompts") + async def plan_angle_prompts(request: Request): + from services import llm_service + + body = await request.json() + style_prompt = body.get("style_prompt", "") + if not style_prompt: + raise HTTPException(status_code=400, detail="style_prompt is required") + ensure_llm_loaded() + try: + return {"prompts": llm_service.plan_angle_prompts( + style_prompt=style_prompt, num_angles=body.get("num_angles", 4), + )} + except Exception as error: + raise HTTPException(status_code=500, detail=str(error)) from error + + @router.post("/api/v1/director/pipeline/start") + def director_start(): + require_capability_http("wangp_local") + return {"status": "ok"} + + @router.post("/api/v1/director/pipeline/{pid}/continue") + def director_continue(pid: str): + require_capability_http("wangp_local") + return {"status": "ok", "pipeline_id": pid} + + return router diff --git a/app/routers/core_series_plan.py b/app/routers/core_series_plan.py new file mode 100644 index 000000000..db990c893 --- /dev/null +++ b/app/routers/core_series_plan.py @@ -0,0 +1,70 @@ +"""HTTP surface for Series Lab planning on the core/remote profile.""" +from __future__ import annotations + +from fastapi import APIRouter, HTTPException + +from services import core_series_plan as plans + + +def _http(exc: Exception) -> HTTPException: + if isinstance(exc, KeyError): + return HTTPException(status_code=404, detail=str(exc) or "Not found") + if isinstance(exc, PermissionError): + return HTTPException(status_code=409, detail=str(exc)) + return HTTPException(status_code=400, detail=str(exc)) + + +def create_core_series_plan_router() -> APIRouter: + router = APIRouter() + + @router.post("/api/v1/series/{series_id}/episodes/{episode_id}/plan/start") + def start_episode(series_id: str, episode_id: str, body: dict): + try: + return plans.start_episode_plan(series_id, episode_id, body or {}) + except (KeyError, ValueError, PermissionError) as error: + raise _http(error) from error + + @router.post("/api/v1/series/{series_id}/canon/prepare/start") + def start_canon(series_id: str, body: dict): + try: + return plans.start_canon_plan(series_id, body or {}) + except (KeyError, ValueError, PermissionError) as error: + raise _http(error) from error + + @router.get("/api/v1/series/plan/jobs/{job_id}") + def get_job(job_id: str): + job = plans.load_job(job_id) + if not job: + raise HTTPException(status_code=404, detail="Series planning job not found") + return plans._public(job) + + @router.post("/api/v1/series/plan/jobs/{job_id}/cancel") + def cancel_job(job_id: str): + try: + return plans.cancel_job(job_id) + except KeyError as error: + raise _http(error) from error + + @router.post("/api/v1/series/plan/jobs/{job_id}/resume") + def resume_job(job_id: str): + try: + return plans.resume_job(job_id) + except KeyError as error: + raise _http(error) from error + + @router.post("/api/v1/series/plan/jobs/{job_id}/apply") + def apply_episode(job_id: str, body: dict | None = None): + try: + edited = body.get("episodeResult") if isinstance(body, dict) else None + return plans.apply_episode(job_id, edited) + except (KeyError, ValueError, PermissionError) as error: + raise _http(error) from error + + @router.post("/api/v1/series/plan/jobs/{job_id}/apply-canon") + def apply_canon(job_id: str): + try: + return plans.apply_canon(job_id) + except (KeyError, ValueError, PermissionError) as error: + raise _http(error) from error + + return router diff --git a/app/routers/director_review.py b/app/routers/director_review.py new file mode 100644 index 000000000..62af75001 --- /dev/null +++ b/app/routers/director_review.py @@ -0,0 +1,22 @@ +from fastapi import APIRouter, HTTPException, Request +from starlette.concurrency import run_in_threadpool + + +def create_director_review_router(workspace_dir) -> APIRouter: + router = APIRouter() + + @router.put("/api/v1/director/pipelines/{pid}/review") + async def save(pid: str, request: Request): + from services.director_pipeline import PipelineBusyError + from services.director_review import save_review + try: + body = await request.json() + if not isinstance(body, dict) or not isinstance(body.get("workspace"), str): + raise ValueError("Use an explicit review workspace") + return await run_in_threadpool(save_review, workspace_dir(body["workspace"]), pid, body.get("commands")) + except PipelineBusyError as error: + raise HTTPException(409, str(error)) from error + except ValueError as error: + raise HTTPException(422, str(error)) from error + + return router diff --git a/app/routers/image_generation_commands.py b/app/routers/image_generation_commands.py index 99409b15b..c8ab9cb5b 100644 --- a/app/routers/image_generation_commands.py +++ b/app/routers/image_generation_commands.py @@ -12,7 +12,7 @@ class ReferenceResolutionInput(BaseModel): model_config = ConfigDict(extra="forbid", strict=True) references: list[StrictStr] = Field(min_length=1, max_length=64) - media_kind: Literal["image", "audio", "video"] = "image" + media_kind: Literal["image", "audio", "video", "studio_video"] = "image" class UISubmissionContext(BaseModel): diff --git a/app/routers/llm.py b/app/routers/llm.py index 4d09ff588..50c473ccb 100644 --- a/app/routers/llm.py +++ b/app/routers/llm.py @@ -340,13 +340,13 @@ def llm_unload(): return {"status": "ok"} @router.get("/api/v1/llm/models") - def list_llm_models(provider: str = ""): - """Return available LLM model options. Pass provider to include remote models.""" + def list_llm_models(provider: str = "", url: str = ""): + """Return available LLM model options. Pass provider and optional url to query that server (Ollama / OpenAI-compatible) without waiting for a saved profile.""" from services import llm_service services = get_services_config() profile_provider, _profile_model, profile_remote_url = effective_llm_routing(services) p = provider or profile_provider - api_key, remote_url = llm_provider_credentials(p, services, profile_remote_url) + api_key, remote_url = llm_provider_credentials(p, services, url.strip() or profile_remote_url) return {"models": llm_service.get_available_models(provider=p, remote_url=remote_url, api_key=api_key)} @router.get("/api/v1/llm/stream-status") @@ -372,8 +372,9 @@ async def llm_generate(request: Request): if len(json.dumps(json_schema, ensure_ascii=False)) > 100_000: raise HTTPException(status_code=400, detail="json_schema is too large") - ensure_llm_loaded() - + llm_override = comic_writing_llm(body) if body.get("writingProvider") else None + if not llm_override: + ensure_llm_loaded() try: from services.wangp_analysis import generate_with_media arguments = dict( @@ -387,6 +388,13 @@ async def llm_generate(request: Request): seed=body.get("seed"), json_schema=json_schema, ) + if llm_override: + if body.get("media"): + raise ValueError("Scoped writing requests support text only; use the configured vision model for media") + arguments.pop("seed", None) + text = await asyncio.to_thread(llm_service.generate_openai_compatible, + **arguments, model_id=llm_override["model"], base_url=llm_override["base_url"], api_key=llm_override["api_key"]) + return {"text": text} if body.get("media") and resolve_visual_media is None: raise ValueError("Visual input resolver is unavailable") return await asyncio.to_thread(generate_with_media, llm_service, arguments, body.get("media"), diff --git a/app/routers/scene3d_profiles.py b/app/routers/scene3d_profiles.py index 7a1f15aae..a2656df09 100644 --- a/app/routers/scene3d_profiles.py +++ b/app/routers/scene3d_profiles.py @@ -1,7 +1,9 @@ """Workspace-scoped, content-addressed face calibration. No audio or model bytes.""" from __future__ import annotations +import hashlib import json import os +import re import threading import uuid from pathlib import Path @@ -10,6 +12,9 @@ from services.character_speech_definition import face_settings _lock = threading.Lock() +MAX_MODEL_BYTES = 64 * 1024 * 1024 +_WORKSPACE = re.compile(r"^[A-Za-z0-9_. -]{1,120}$") +_DIGEST = re.compile(r"^[a-f0-9]{64}$") class ProfileWrite(BaseModel): model_config = ConfigDict(extra="forbid") @@ -29,18 +34,52 @@ def workspace_name(cls, value): def face_only(cls, value): return face_settings(value) +def _contained(path: str, root: str) -> bool: + try: + return os.path.normcase(os.path.commonpath((path, root))) == os.path.normcase(root) + except (TypeError, ValueError, OSError): + return False + + +def stored_glb_digest(workspace_dir, workspace: str, filename: str) -> tuple[str, int]: + if not _WORKSPACE.fullmatch(workspace) or workspace in {".", ".."}: + raise HTTPException(400, "Invalid profile scope.") + if not isinstance(filename, str) or not filename or filename != os.path.basename(filename) or "/" in filename or "\\" in filename: + raise HTTPException(400, "Invalid model filename.") + if not filename.lower().endswith(".glb"): + raise HTTPException(400, "Only GLB models have a face calibration identity.") + root = os.path.realpath(os.path.abspath(workspace_dir(workspace))) + path = os.path.realpath(os.path.abspath(os.path.join(root, filename))) + if path == root or not _contained(path, root): + raise HTTPException(400, "Invalid model filename.") + if not os.path.isfile(path): + raise HTTPException(404, "Model file was not found in this workspace.") + size = os.path.getsize(path) + if size > MAX_MODEL_BYTES: + raise HTTPException(413, "Model exceeds 64 MB.") + digest = hashlib.sha256() + with open(path, "rb") as handle: + while chunk := handle.read(1024 * 1024): + digest.update(chunk) + return digest.hexdigest(), size + + def create_scene3d_profiles_router(workspace_dir): router = APIRouter() def target(workspace: str, digest: str): - import re - if not re.fullmatch(r"[a-f0-9]{64}", digest) or not re.fullmatch(r"[A-Za-z0-9_. -]{1,120}", workspace) or workspace in {".", ".."}: + if not _DIGEST.fullmatch(digest) or not _WORKSPACE.fullmatch(workspace) or workspace in {".", ".."}: raise HTTPException(400, "Invalid profile scope.") return Path(workspace_dir(workspace)).resolve() / ".speech3d-profiles" / (digest + ".json") def read(path): return json.loads(path.read_text(encoding="utf-8")) if path.is_file() else None + @router.get("/speech/digest") + def digest_model(workspace: str, filename: str): + digest, size = stored_glb_digest(workspace_dir, workspace, filename) + return {"digest": digest, "bytes": size} + @router.get("/speech/profiles/{digest}") def get_profile(digest: str, workspace: str): with _lock: diff --git a/app/routers/scene3d_speech.py b/app/routers/scene3d_speech.py index fbf2ea8de..0d7a48db9 100644 --- a/app/routers/scene3d_speech.py +++ b/app/routers/scene3d_speech.py @@ -4,6 +4,7 @@ from starlette.concurrency import run_in_threadpool from services.scene3d_speech import MAX_BYTES, SpeechAnalysisError, SpeechAnalysisUnavailable, analyze_voice +from services.speech_analysis_request import MAX_REQUEST_BYTES, speech_request class SpeechMouthCue(BaseModel): @@ -30,15 +31,18 @@ def capabilities(): @router.post("/speech/analyze", response_model=SpeechAnalysisResponse) async def analyze(request: Request, isolate_vocals: bool = False): - if request.headers.get("content-type", "").split(";")[0] != "audio/wav": - raise HTTPException(415, "Expected audio/wav.") + content_type = request.headers.get("content-type", "").split(";")[0] + if content_type not in {"audio/wav", "application/json"}: + raise HTTPException(415, "Expected audio/wav or application/json.") + maximum = MAX_BYTES if content_type == "audio/wav" else MAX_REQUEST_BYTES data = bytearray() async for chunk in request.stream(): - if len(data) + len(chunk) > MAX_BYTES: + if len(data) + len(chunk) > maximum: raise HTTPException(413, "Voice clip exceeds the 90-second limit.") data.extend(chunk) try: - return await run_in_threadpool(analyze_voice, bytes(data), isolate_vocals=isolate_vocals) + audio, options = speech_request(bytes(data), content_type) + return await run_in_threadpool(analyze_voice, audio, isolate_vocals=isolate_vocals, **options) except SpeechAnalysisError as exc: raise HTTPException(400, str(exc)) from exc except SpeechAnalysisUnavailable as exc: diff --git a/app/routers/scene_packages.py b/app/routers/scene_packages.py new file mode 100644 index 000000000..6bea41fb3 --- /dev/null +++ b/app/routers/scene_packages.py @@ -0,0 +1,217 @@ +"""HTTP surface for portable Video3D scene packages. Independent of Gradio.""" +from __future__ import annotations + +import json +import re +import tempfile +from collections.abc import Callable, Iterable, Mapping +from pathlib import Path +from typing import Any + +from fastapi import APIRouter, HTTPException, Request +from fastapi.responses import Response + +from services.scene_packages import ( + MAX_EXPORT_BODY, + MAX_ZIP_BYTES, + ScenePackageError, + ScenePackageTooLarge, + format_contract, + import_package, + make_workspace_reader, + preflight_package, + require_workspace, + write_package_zip, +) + +_SLUG = re.compile(r"[^A-Za-z0-9._-]+") +WorkspaceDir = Callable[[str], str] +WorkspaceList = Callable[[], Iterable[Mapping[str, Any]]] | None + + +def _slug(value: str) -> str: + text = _SLUG.sub("-", str(value or "scene-package").strip()).strip("-._")[:80] + return text or "scene-package" + + +def _raise(error: Exception) -> None: + if isinstance(error, ScenePackageError): + raise HTTPException(error.status, str(error)) from error + if isinstance(error, ScenePackageTooLarge): + raise HTTPException(413, str(error)) from error + raise HTTPException(422, str(error)) from error + + +def _parse_reassign(raw: str) -> list[dict[str, Any]]: + text = str(raw or "").strip() or "[]" + try: + value = json.loads(text) + except json.JSONDecodeError as exc: + raise HTTPException(422, "Reassignment list must be JSON") from exc + if isinstance(value, dict): + value = [value] + if not isinstance(value, list): + raise HTTPException(422, "Reassignment list must be JSON") + return [item for item in value if isinstance(item, Mapping)] + + +async def _spool_request(request: Request, directory: Path) -> Path: + destination = directory / "upload.scene-package.zip" + written = 0 + with destination.open("wb") as handle: + async for chunk in request.stream(): + written += len(chunk) + if written > MAX_ZIP_BYTES: + raise HTTPException(413, "Package zip exceeds the size limit") + handle.write(chunk) + if written <= 0: + raise HTTPException(422, "Package zip is empty") + return destination + + +def _known_workspace(name: str, list_workspaces: WorkspaceList) -> str: + workspace = require_workspace(name) + if list_workspaces is None: + return workspace + names = { + str(item.get("name") or "").strip() + for item in list_workspaces() + if isinstance(item, Mapping) + } + if names and workspace not in names: + raise HTTPException(404, "Workspace not found") + return workspace + + +def _load_named_documents(names: list[Any], folder: Path) -> list[Any]: + loaded = [] + for name in names: + filename = Path(str(name or "")).name + path = folder / filename + if not path.is_file(): + raise ScenePackageError(f"Scene not found: {filename}") + loaded.append(json.loads(path.read_text(encoding="utf-8"))) + return loaded + + +def _export_documents(body: Mapping[str, Any], workspace: str, workspace_dir: WorkspaceDir) -> list[Any]: + documents = body.get("documents") + if isinstance(documents, list) and documents: + return documents + names = body.get("names") + if not isinstance(names, list) or not names: + raise ScenePackageError("Export at least one scene document") + return _load_named_documents(names, Path(workspace_dir(workspace))) + + +def _read_export_body(payload: bytes) -> dict[str, Any]: + try: + body = json.loads(payload or b"{}") + except json.JSONDecodeError as exc: + raise HTTPException(422, "Expected a JSON export request") from exc + if not isinstance(body, dict): + raise HTTPException(422, "Expected a JSON export request") + return body + + +async def _read_limited_json(request: Request) -> bytes: + payload = bytearray() + async for chunk in request.stream(): + payload.extend(chunk) + if len(payload) > MAX_EXPORT_BODY: + raise HTTPException(413, "Export request exceeds 8 MB") + return bytes(payload) + + +def _export_zip_response(archive: bytes, title: Any) -> Response: + filename = _slug(str(title or "scene-package")) + ".scene-package.zip" + return Response( + content=archive, + media_type="application/zip", + headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + ) + + +async def _handle_export( + request: Request, + workspace_dir: WorkspaceDir, + reader: Callable[[str, str], bytes | None], + list_workspaces: WorkspaceList, +) -> Response: + body = _read_export_body(await _read_limited_json(request)) + try: + workspace = _known_workspace(str(body.get("workspace") or ""), list_workspaces) + documents = _export_documents(body, workspace, workspace_dir) + archive = write_package_zip( + documents, reader, title=str(body.get("title") or ""), workspace=workspace, + ) + except (ScenePackageError, OSError, json.JSONDecodeError, ValueError) as error: + _raise(error) + raise + return _export_zip_response(archive, body.get("title")) + + +async def _handle_preflight(request: Request) -> dict[str, Any]: + with tempfile.TemporaryDirectory(prefix="scene-package-") as tmp: + try: + return preflight_package(await _spool_request(request, Path(tmp))) + except (ScenePackageError, ScenePackageTooLarge, OSError, ValueError) as error: + _raise(error) + raise + + +async def _handle_import( + request: Request, + workspace: str, + reassign: str, + workspace_dir: WorkspaceDir, + reader: Callable[[str, str], bytes | None], + list_workspaces: WorkspaceList, +) -> dict[str, Any]: + replacements = _parse_reassign(reassign) + with tempfile.TemporaryDirectory(prefix="scene-package-") as tmp: + try: + stored = await _spool_request(request, Path(tmp)) + return import_package( + stored, + workspace=_known_workspace(workspace, list_workspaces), + workspace_dir=workspace_dir, + reader=reader, + reassign=replacements, + ) + except (ScenePackageError, ScenePackageTooLarge, OSError, ValueError) as error: + _raise(error) + raise + + +def create_scene_packages_router( + *, + workspace_dir: WorkspaceDir, + uploads_dir: Callable[[], str] | None = None, + list_workspaces: WorkspaceList = None, +) -> APIRouter: + router = APIRouter() + reader = make_workspace_reader(workspace_dir, uploads_dir) + + @router.get("/api/v1/scene-packages/format") + def package_format(): + return format_contract() + + @router.post("/api/v1/scene-packages/export") + async def export_package(request: Request): + return await _handle_export(request, workspace_dir, reader, list_workspaces) + + @router.post("/api/v1/scene-packages/preflight") + async def preflight(request: Request): + return await _handle_preflight(request) + + @router.post("/api/v1/scene-packages/import") + async def import_scene_package(request: Request, workspace: str, reassign: str = "[]"): + return await _handle_import( + request, workspace, reassign, workspace_dir, reader, list_workspaces, + ) + + return router + + +__all__ = ["create_scene_packages_router"] diff --git a/app/routers/studio_video_commands.py b/app/routers/studio_video_commands.py new file mode 100644 index 000000000..3a6a5d8f0 --- /dev/null +++ b/app/routers/studio_video_commands.py @@ -0,0 +1,39 @@ +"""Executable generation.video schema shared by local HTTP and external MCP.""" + +from services.video_generation_spec import video_generation_schema + + +def video_command_catalog(): + schema = video_generation_schema() + input_schema = dict(schema["input"]) + definitions = input_schema.pop("$defs", {}) + return { + "name": "generation.video", + "version": 2, + "supportedVersions": [2], + "domain": "studio", + "mutation": True, + "description": ( + "Admit one Wan 2.1 Text2Video generation with a literal prompt through " + "the canonical generation queue. The selected t2v or t2v_1.3B model must " + "already be installed. Preserve the original prompt and reuse intent_id " + "only to recover an existing admission; follow its task for completion." + ), + "videoModelFamily": schema["video_model_family"], + "videoModelTypes": list(schema["video_model_types"]), + "inputSchema": { + "type": "object", + "additionalProperties": False, + "$defs": definitions, + "properties": { + "version": {"type": "integer", "const": 2}, + "operation": {"const": "generation.video"}, + "intent_id": schema["intent_id"], + "input": input_schema, + }, + "required": ["version", "operation", "intent_id", "input"], + }, + } + + +__all__ = ["video_command_catalog"] diff --git a/app/routers/system_capabilities.py b/app/routers/system_capabilities.py new file mode 100644 index 000000000..c8b596dec --- /dev/null +++ b/app/routers/system_capabilities.py @@ -0,0 +1,28 @@ +"""HTTP surface for the platform capability authority.""" +from __future__ import annotations + +from fastapi import APIRouter, HTTPException + +from services.platform_capabilities import ( + CapabilityDenied, + platform_capabilities, + require_capability, +) + + +def create_system_capabilities_router() -> APIRouter: + router = APIRouter(tags=["system-capabilities"]) + + @router.get("/api/v1/system/capabilities") + def get_capabilities(): + return platform_capabilities() + + return router + + +def require_capability_http(capability: str) -> None: + """Raise 409 feature_unavailable when a local NVIDIA engine is not available.""" + try: + require_capability(capability) + except CapabilityDenied as error: + raise HTTPException(status_code=409, detail=error.as_detail()) from error diff --git a/app/routers/user_diagnostics.py b/app/routers/user_diagnostics.py new file mode 100644 index 000000000..3bb3f890f --- /dev/null +++ b/app/routers/user_diagnostics.py @@ -0,0 +1,62 @@ +"""HTTP boundary for user-facing install and generation diagnostics. + +Importable without the application launcher, CUDA or heavy engines. +Mount with create_user_diagnostics_router() in either application runtime. +""" +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + +from fastapi import APIRouter +from pydantic import BaseModel, ConfigDict, Field + +from services.user_diagnostics import collect_report + + +class ReportRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + task_id: str | None = Field(default=None, max_length=200) + intent_id: str | None = Field(default=None, max_length=200) + workspace: str | None = Field(default=None, max_length=200) + task: dict[str, Any] | None = None + receipt: dict[str, Any] | None = None + error: dict[str, Any] | None = None + + +def _load_optional(loader: Callable[..., dict | None] | None, *args: str) -> dict | None: + if loader is None: + return None + try: + loaded = loader(*args) + except Exception: + return None + return loaded if isinstance(loaded, dict) else None + + +def create_user_diagnostics_router( + *, + collect: Callable[..., dict] | None = None, + load_task: Callable[[str], dict | None] | None = None, + load_receipt: Callable[[str, str], dict | None] | None = None, +) -> APIRouter: + """Build the diagnostics router. Collectors default to the lightweight service.""" + router = APIRouter() + produce = collect or collect_report + + @router.get("/api/v1/diagnostics") + def get_diagnostics(): + return produce() + + @router.post("/api/v1/diagnostics/report") + def post_report(body: ReportRequest | None = None): + payload = body or ReportRequest() + task = payload.task + receipt = payload.receipt + if task is None and payload.task_id: + task = _load_optional(load_task, payload.task_id) + if receipt is None and payload.intent_id and payload.workspace: + receipt = _load_optional(load_receipt, payload.workspace, payload.intent_id) + return produce(task=task, receipt=receipt, error=payload.error) + + return router diff --git a/app/routers/video_editor.py b/app/routers/video_editor.py new file mode 100644 index 000000000..e27afbaf1 --- /dev/null +++ b/app/routers/video_editor.py @@ -0,0 +1,1015 @@ +"""Video Editor HTTP surface extracted from the launch runtime. + +The launcher injects workspace and path primitives so this module never +imports WanGP, Gradio, model weights, or ``_launch_runtime``. +""" + +from __future__ import annotations + +import copy +import os +import re +import threading +import time +import traceback +import uuid +from collections.abc import Callable +from typing import Any + +from fastapi import APIRouter, HTTPException +from fastapi.responses import FileResponse + +from services import resource_scheduler +from services.asset_manifest import publish_generation_sidecar +from services.media_refs import parse_media_ref +from services.media_thumbnails import ensure_media_thumbnail +from services.video_editor import ( + build_source_provenance_manifest, + extract_frame, + normalise_time_card_text, + probe_audio, + probe_media, + render_project, +) + + +_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"} +_VIDEO_EDITOR_AUDIO_EXTENSIONS = {".wav", ".mp3", ".flac", ".ogg", ".m4a", ".aac"} +SUPPORTED_TRANSITIONS = frozenset({ + "none", + "crossfade", + "fade-black", + "wipe-left", + "slide-left", + "slide-right", + "circle-open", + "dissolve", + "pixelize", + "blur", + "zoom-in", + "later-clock", + "later-tropical", + "later-cinematic", +}) +CLIP_SIDECAR_KEYS = frozenset({ + "name", + "source", + "trim_start", + "trim_end", + "volume", + "muted", + "fit", + "transition", + "transition_duration", + "transition_text", + "transition_text_size", +}) +SOUNDTRACK_SIDECAR_KEYS = frozenset({ + "name", "source", "trim_start", "trim_end", "volume", "loop", +}) +TASK_ID_PATTERN = re.compile(r"task-[A-Za-z0-9_-]{1,180}") + +_video_editor_jobs: dict[str, dict] = {} +_video_editor_jobs_lock = threading.RLock() + + +class _Runtime: + workspace_dir: Callable[..., str] | None = None + get_active_workspace: Callable[[], str] | None = None + resolve_input_path: Callable[..., str | None] | None = None + publish_legacy_task: Callable[..., Any] | None = None + thumbnail_cache_dir: str = "" + + +_runtime = _Runtime() + + +def _bind_video_editor_runtime( + *, + workspace_dir: Callable[..., str], + get_active_workspace: Callable[[], str], + resolve_input_path: Callable[..., str | None], + publish_legacy_task: Callable[..., Any] | None, + thumbnail_cache_dir: str, +) -> None: + _runtime.workspace_dir = workspace_dir + _runtime.get_active_workspace = get_active_workspace + _runtime.resolve_input_path = resolve_input_path + _runtime.publish_legacy_task = publish_legacy_task + _runtime.thumbnail_cache_dir = thumbnail_cache_dir + + +def reset_video_editor_jobs() -> None: + with _video_editor_jobs_lock: + _video_editor_jobs.clear() + + +def list_video_editor_jobs() -> list[dict]: + """Snapshot editor jobs for Activity's canonical sync.""" + with _video_editor_jobs_lock: + return [copy.deepcopy(job) for job in _video_editor_jobs.values()] + + +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 mutation immediately to task SSE.""" + publisher = _runtime.publish_legacy_task + if not callable(publisher): + return None + return publisher(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 _cancelled_status_patch(job: dict) -> dict[str, Any]: + return { + "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(), + } + + +def _cancelling_status_patch(owns_lane: bool) -> dict[str, Any]: + return { + "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", + } + + +def _absorb_cancel_into_changes(job: dict, changes: dict[str, Any]) -> dict[str, Any]: + """Make cancellation terminally absorbing for in-flight mutations.""" + if not job.get("_cancel_requested"): + return changes + requested_status = str(changes.get("status") or "") + if requested_status in {"completed", "failed"}: + changes.update(_cancelled_status_patch(job)) + return changes + if requested_status in {"cancelled", "cancelling"}: + return changes + owns_lane = bool(changes.get("acquired_resources", job.get("acquired_resources") or [])) + changes.update(_cancelling_status_patch(owns_lane)) + return changes + + +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: + job.update(_absorb_cancel_into_changes(job, changes)) + job["updated_at"] = time.time() + snapshot = copy.deepcopy(job) + should_publish = True + 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.""" + if not isinstance(source, str) or not source.strip(): + raise ValueError("Video source is missing") + path, workspace = parse_media_ref(source, workspace) + resolved = _runtime.resolve_input_path(path, workspace) + if not resolved or not os.path.isfile(resolved): + raise ValueError(f"Video source could not be found: {os.path.basename(path) or path}") + suffix = os.path.splitext(resolved)[1].lower() + if suffix not in _VIDEO_EDITOR_EXTENSIONS: + raise ValueError(f"Unsupported video format: {suffix or 'unknown'}") + return resolved + + +def _resolve_video_editor_audio_source(source: str, workspace: str | None = None) -> str: + """Resolve a soundtrack reference using the same workspace boundary.""" + if not isinstance(source, str) or not source.strip(): + raise ValueError("Audio source is missing") + path, workspace = parse_media_ref(source, workspace) + resolved = _runtime.resolve_input_path(path, workspace) + if not resolved or not os.path.isfile(resolved): + raise ValueError(f"Audio source could not be found: {os.path.basename(path) or path}") + suffix = os.path.splitext(resolved)[1].lower() + if suffix not in _VIDEO_EDITOR_AUDIO_EXTENSIONS: + raise ValueError(f"Unsupported audio format: {suffix or 'unknown'}") + return resolved + + +def _raise_http(exc: Exception, *, failed: str): + if isinstance(exc, HTTPException): + raise exc + if isinstance(exc, ValueError): + raise HTTPException(status_code=400, detail=str(exc)) from exc + raise HTTPException(status_code=500, detail=f"{failed}: {exc}") from exc + + +def _probe_video_editor_source(body: dict) -> dict: + try: + resolved = _resolve_video_editor_source(body.get("source", ""), body.get("workspace")) + return probe_media(resolved) + except Exception as exc: + _raise_http(exc, failed="Could not inspect video") + raise + + +def _probe_video_editor_audio_source(body: dict) -> dict: + try: + resolved = _resolve_video_editor_audio_source( + body.get("source", ""), body.get("workspace"), + ) + return probe_audio(resolved) + except Exception as exc: + _raise_http(exc, failed="Could not inspect audio") + raise + + +def _serve_video_editor_thumbnail(source: str) -> FileResponse: + try: + resolved = _resolve_video_editor_source(source) + thumbnail = ensure_media_thumbnail( + resolved, _runtime.thumbnail_cache_dir, is_video=True, + ) + except Exception as exc: + _raise_http(exc, failed="Could not create thumbnail") + raise + return FileResponse( + thumbnail, + media_type="image/jpeg", + headers={"Cache-Control": "public, max-age=31536000, immutable"}, + ) + + +def _write_video_editor_screenshot_sidecar(output_path, sidecar, workspace_id): + publish_generation_sidecar( + output_path, sidecar, workspace_id=workspace_id, tool="video-editor-screenshot", + ) + + +def _write_video_editor_export_sidecar(output_path, sidecar, workspace_id): + publish_generation_sidecar( + output_path, sidecar, workspace_id=workspace_id, tool="video-editor", + ) + + +def _safe_media_stem(value: Any, fallback: str) -> str: + safe = re.sub(r"[^A-Za-z0-9_-]+", "_", str(value or fallback)).strip("_") + return safe[:60] or fallback + + +def _unique_workspace_file(out_dir: str, stem: str, ext: str) -> tuple[str, str]: + output_name = f"{stem}{ext}" + output_path = os.path.join(out_dir, output_name) + suffix = 2 + while os.path.exists(output_path): + output_name = f"{stem}_{suffix}{ext}" + output_path = os.path.join(out_dir, output_name) + suffix += 1 + return output_name, output_path + + +def _capture_video_editor_frame(body: dict) -> dict: + try: + resolved = _resolve_video_editor_source(body.get("source", ""), body.get("workspace")) + requested_time = float(body.get("time") or 0) + except HTTPException: + raise + except (TypeError, ValueError) as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + safe_name = _safe_media_stem(body.get("name") or "video_frame", "video_frame") + timestamp = time.strftime("%Y-%m-%d-%Hh%Mm%Ss") + out_dir = _runtime.workspace_dir() + os.makedirs(out_dir, exist_ok=True) + output_name, output_path = _unique_workspace_file( + out_dir, f"{timestamp}_{safe_name}_frame", ".png", + ) + try: + result = extract_frame(resolved, output_path, requested_time) + sidecar = { + "params": { + "video_editor_screenshot": { + "version": 1, + "source": str(body.get("source") or ""), + "source_name": os.path.basename(resolved), + "time": result["time"], + "width": result["width"], + "height": result["height"], + }, + "source": "video_editor_screenshot", + }, + "generation_mode": "image", + "created_at": time.time(), + } + _write_video_editor_screenshot_sidecar(output_path, sidecar, body.get("workspace")) + return {"filename": output_name, "url": f"/api/v1/file/{output_name}", **result} + except Exception as exc: + try: + if os.path.isfile(output_path): + os.remove(output_path) + except OSError: + pass + raise HTTPException( + status_code=500, detail=f"Could not capture video frame: {exc}", + ) 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 TASK_ID_PATTERN.fullmatch(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 _parse_export_geometry(body: dict) -> tuple[int, int, int]: + try: + width = int(body.get("width") or 1280) + height = int(body.get("height") or 720) + fps = int(body.get("fps") or 30) + except (TypeError, ValueError) as exc: + raise HTTPException(status_code=400, detail="Invalid export settings") from exc + odd_or_tiny = width < 240 or height < 240 or width % 2 or height % 2 + if odd_or_tiny or width > 3840 or height > 3840: + raise HTTPException(status_code=400, detail="Invalid output resolution") + if fps not in (24, 25, 30, 50, 60): + raise HTTPException(status_code=400, detail="Unsupported frame rate") + return width, height, fps + + +def _clean_export_clip(index: int, clip: Any) -> dict: + if not isinstance(clip, dict): + raise HTTPException(status_code=400, detail=f"Clip {index + 1} is invalid") + transition = str(clip.get("transition") or "none") + if transition not in SUPPORTED_TRANSITIONS: + raise HTTPException( + status_code=400, detail=f"Clip {index + 1} has an unsupported transition", + ) + try: + transition_duration = float(clip.get("transition_duration") or 0.4) + transition_text_size = float(clip.get("transition_text_size") or 100) + except (TypeError, ValueError) as exc: + raise HTTPException( + status_code=400, detail=f"Clip {index + 1} has invalid transition settings", + ) from exc + if transition_duration < 0.05 or transition_duration > 5: + raise HTTPException( + status_code=400, detail="Transition duration must be between 0.05 and 5 seconds", + ) + if transition_text_size < 50 or transition_text_size > 160: + raise HTTPException( + status_code=400, detail="Transition text size must be between 50% and 160%", + ) + clean_clip = dict(clip) + clean_clip.update({ + "transition": transition, + "transition_duration": transition_duration, + "transition_text": normalise_time_card_text(clip.get("transition_text")), + "transition_text_size": transition_text_size, + }) + return clean_clip + + +def _parse_soundtrack_levels(soundtrack: dict) -> tuple[float, float, float]: + try: + trim_start = max(0.0, float(soundtrack.get("trim_start") or 0)) + trim_end = max(0.0, float(soundtrack.get("trim_end") or 0)) + raw_volume = soundtrack.get("volume") + volume = float(raw_volume if raw_volume is not None else 1) + except (TypeError, ValueError) as exc: + raise HTTPException(status_code=400, detail="Invalid soundtrack settings") from exc + return trim_start, trim_end, volume + + +def _clean_export_soundtrack(soundtrack: Any) -> dict | None: + if soundtrack is None: + return None + if not isinstance(soundtrack, dict): + raise HTTPException(status_code=400, detail="The soundtrack must be an object") + trim_start, trim_end, volume = _parse_soundtrack_levels(soundtrack) + if trim_end and trim_end <= trim_start: + raise HTTPException(status_code=400, detail="Soundtrack trim_end must be after trim_start") + if volume < 0 or volume > 2: + raise HTTPException(status_code=400, detail="Soundtrack volume must be between 0 and 2") + clean = { + "name": str(soundtrack.get("name") or "soundtrack")[:300], + "source": str(soundtrack.get("source") or ""), + "trim_start": trim_start, + "trim_end": trim_end, + "volume": volume, + "loop": bool(soundtrack.get("loop")), + } + if not clean["source"].strip(): + raise HTTPException(status_code=400, detail="Soundtrack source is missing") + return clean + + +def _allocate_export_output(body: dict) -> tuple[Any, str, str]: + workspace = body.get("workspace") if body.get("workspace") is not None else _runtime.get_active_workspace() + out_dir = _runtime.workspace_dir(workspace) + safe_name = _safe_media_stem(body.get("name"), "edited_video") + timestamp = time.strftime("%Y-%m-%d-%Hh%Mm%Ss") + os.makedirs(out_dir, exist_ok=True) + _output_name, output_path = _unique_workspace_file( + out_dir, f"{timestamp}_{safe_name}", ".mp4", + ) + return workspace, out_dir, output_path + + +def _queued_export_job( + job_id: str, + task_id: str, + root_task_id: str, + parent_task_id: str | None, + workspace: Any, +) -> dict: + now = time.time() + lane = _VIDEO_EDITOR_FFMPEG_LANE.key + return { + "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, + "provider": "local", + "model": "FFmpeg", + "server_origin": "local", + "resource_lane": lane, + "resource_requirements": [lane], + "acquired_resources": [], + "created_at": now, + "queued_at": now, + "updated_at": now, + "_cancel_requested": False, + "_resource_acquired": False, + "_worker_active": True, + } + + +def _clip_sidecar(clip: dict) -> dict: + return {key: value for key, value in clip.items() if key in CLIP_SIDECAR_KEYS} + + +def _soundtrack_sidecar(soundtrack: Any) -> dict | None: + if not isinstance(soundtrack, dict): + return None + payload = { + key: value for key, value in soundtrack.items() if key in SOUNDTRACK_SIDECAR_KEYS + } + return payload or None + + +def _export_sidecar_payload( + body: dict, + resolved_clips: list[dict], + job_id: str, + task_id: str, + root_task_id: str, + workspace: str, +) -> dict: + return { + "params": { + "video_editor": { + "version": 2, + "width": int(body["width"]), + "height": int(body["height"]), + "fps": int(body["fps"]), + "clips": [_clip_sidecar(clip) for clip in body["clips"]], + "source_manifest": build_source_provenance_manifest(resolved_clips), + "soundtrack": _soundtrack_sidecar(body.get("soundtrack")), + }, + "source": "video_editor", + }, + "generation_mode": "video", + "job_id": job_id, + "task_id": task_id, + "root_task_id": root_task_id, + "workspace": workspace, + "created_at": time.time(), + } + + +def _deferred_cancel_finish(job_id: str, output_path: str, started: bool) -> dict: + return _finish_video_editor_cancelled( + job_id, + output_path, + message=( + "Cancelled after FFmpeg reached a safe boundary" + if started else "Cancelled before FFmpeg started" + ), + cancel_mode="deferred" if started else "immediate", + safe_boundary=( + "after_current_ffmpeg_render" if started else "before_ffmpeg" + ), + ) + + +def _make_export_report(job_id: str): + def report(progress: int, message: str) -> None: + 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", + ) + raise resource_scheduler.ResourceAcquireCancelled( + f"Video editor export {job_id} reached an FFmpeg safe boundary" + ) + _video_editor_job_update( + job_id, + status="running", + phase="rendering", + progress=bounded, + current=bounded, + message=message, + ) + + return report + + +def _resolve_export_media(job_id: str, body: dict, workspace: str): + resolved_clips = [] + for clip in body["clips"]: + if _video_editor_cancel_requested(job_id): + return None + 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 ""), workspace, + ) + resolved_clips.append(resolved) + + resolved_soundtrack = None + soundtrack = body.get("soundtrack") + if soundtrack is not None: + if not isinstance(soundtrack, dict): + raise ValueError("The soundtrack must be an object") + resolved_soundtrack = dict(soundtrack) + resolved_soundtrack["resolved_path"] = _resolve_video_editor_audio_source( + str(soundtrack.get("source") or ""), workspace, + ) + if _video_editor_cancel_requested(job_id): + return None + return resolved_clips, resolved_soundtrack + + +def _render_export_on_lane( + job_id: str, + task_id: str, + body: dict, + resolved_clips: list[dict], + resolved_soundtrack: dict | None, + output_path: str, + report, +): + _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, + ) + started_status = str(started.get("status") or "") + if _video_editor_cancel_requested(job_id) or started_status in _VIDEO_EDITOR_TERMINAL: + raise resource_scheduler.ResourceAcquireCancelled( + f"Video editor export {job_id} was cancelled before FFmpeg started" + ) + return render_project( + resolved_clips, + output_path, + width=int(body["width"]), + height=int(body["height"]), + fps=int(body["fps"]), + soundtrack=resolved_soundtrack, + progress=report, + ) + except resource_scheduler.ResourceAcquireCancelled: + current = _video_editor_job_snapshot(job_id) or {} + _deferred_cancel_finish(job_id, output_path, bool(current.get("started_at"))) + return None + + +def _finalize_completed_export( + job_id: str, + body: dict, + resolved_clips: list[dict], + output_path: str, + result: dict, + workspace: str, + task_id: str, + root_task_id: str, +) -> None: + if _video_editor_cancel_requested(job_id): + _deferred_cancel_finish(job_id, output_path, True) + 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": + _deferred_cancel_finish(job_id, output_path, True) + return + output_name = os.path.basename(output_path) + sidecar = _export_sidecar_payload( + body, resolved_clips, job_id, task_id, root_task_id, workspace, + ) + _write_video_editor_export_sidecar(output_path, sidecar, workspace) + 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) + + +def _fail_export_worker(job_id: str, output_path: str, exc: Exception) -> None: + if _video_editor_cancel_requested(job_id): + current = _video_editor_job_snapshot(job_id) or {} + _deferred_cancel_finish(job_id, output_path, bool(current.get("started_at"))) + 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, + ) + + +def _run_video_editor_export(job_id: str, body: dict, out_dir: str, output_path: str) -> None: + 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"]) + try: + _video_editor_job_update( + job_id, + status="queued", + phase="validating_sources", + progress=1, + current=1, + message="Validating source clips…", + ) + resolved = _resolve_export_media(job_id, body, workspace) + if resolved is None: + _finish_video_editor_cancelled(job_id, output_path) + return + resolved_clips, resolved_soundtrack = resolved + result = _render_export_on_lane( + job_id, task_id, body, resolved_clips, resolved_soundtrack, + output_path, _make_export_report(job_id), + ) + if result is None: + return + _finalize_completed_export( + job_id, body, resolved_clips, output_path, result, + workspace, task_id, str(job["root_task_id"]), + ) + except Exception as exc: + _fail_export_worker(job_id, output_path, exc) + + +def _start_video_editor_export(body: dict) -> dict: + clips = body.get("clips") + if not isinstance(clips, list) or not clips: + raise HTTPException(status_code=400, detail="Add at least one video clip") + if len(clips) > 100: + raise HTTPException(status_code=400, detail="A project can contain at most 100 clips") + width, height, fps = _parse_export_geometry(body) + clean_clips = [_clean_export_clip(index, clip) for index, clip in enumerate(clips)] + clean_soundtrack = _clean_export_soundtrack(body.get("soundtrack")) + workspace, out_dir, output_path = _allocate_export_output(body) + clean_body = dict(body) + clean_body.update({ + "width": width, "height": height, "fps": fps, + "clips": clean_clips, "soundtrack": clean_soundtrack, + }) + job_id = f"video-edit-{uuid.uuid4().hex[:12]}" + task_id, root_task_id, parent_task_id = _video_editor_task_identity(body, job_id) + job = _queued_export_job(job_id, task_id, root_task_id, parent_task_id, workspace) + 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}", + ) + 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 get_video_editor_export(job_id: str) -> dict: + job = _video_editor_job_snapshot(job_id) + if not job: + raise HTTPException(status_code=404, detail="Video editor export job not found") + return _public_video_editor_job(job) + + +_lookup_export_job = get_video_editor_export + + +def cancel_video_editor_export(job_id: str) -> dict: + """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(_cancelling_status_patch(owns_lane)) + 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) + + +_cancel_export_job = cancel_video_editor_export + + +def create_video_editor_router( + *, + workspace_dir: Callable[..., str], + get_active_workspace: Callable[[], str], + resolve_input_path: Callable[..., str | None], + publish_legacy_task: Callable[..., Any] | None, + thumbnail_cache_dir: str, +) -> APIRouter: + """Probe, thumbnail, screenshot and export queue at the original ordinals.""" + _bind_video_editor_runtime( + workspace_dir=workspace_dir, + get_active_workspace=get_active_workspace, + resolve_input_path=resolve_input_path, + publish_legacy_task=publish_legacy_task, + thumbnail_cache_dir=thumbnail_cache_dir, + ) + router = APIRouter() + + @router.post("/api/v1/video-editor/probe") + def probe_video_editor_source(body: dict): + """Read duration, dimensions, frame rate and audio presence for one clip.""" + return _probe_video_editor_source(body) + + @router.post("/api/v1/video-editor/probe-audio") + def probe_video_editor_audio_source(body: dict): + """Read duration for one workspace soundtrack without requiring video.""" + return _probe_video_editor_audio_source(body) + + @router.get("/api/v1/video-editor/thumbnail") + def serve_video_editor_thumbnail(source: str): + """Return a static preview for an uploaded or workspace editor source.""" + return _serve_video_editor_thumbnail(source) + + @router.post("/api/v1/video-editor/screenshot") + def capture_video_editor_frame(body: dict): + """Save the current source-video frame as a reusable Maestro image output.""" + return _capture_video_editor_frame(body) + + @router.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.""" + return _start_video_editor_export(body) + + return router + + +def create_video_editor_jobs_router() -> APIRouter: + """Status and cancel routes that follow the comic animatic ordinal.""" + router = APIRouter() + + @router.get("/api/v1/video-editor/export/{job_id}") + def get_video_editor_export(job_id: str): + return _lookup_export_job(job_id) + + @router.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.""" + return _cancel_export_job(job_id) + + return router + + +__all__ = [ + "cancel_video_editor_export", + "create_video_editor_jobs_router", + "create_video_editor_router", + "get_video_editor_export", + "list_video_editor_jobs", + "reset_video_editor_jobs", + "_VIDEO_EDITOR_FFMPEG_LANE", + "_VIDEO_EDITOR_TERMINAL", + "_finish_video_editor_cancelled", + "_public_video_editor_job", + "_register_video_editor_job", + "_remove_video_editor_output_bundle", + "_video_editor_cancel_requested", + "_video_editor_job_snapshot", + "_video_editor_job_update", + "_video_editor_task_identity", +] diff --git a/app/routers/wangp_mcp.py b/app/routers/wangp_mcp.py index 524d7d94a..2ea57e3ef 100644 --- a/app/routers/wangp_mcp.py +++ b/app/routers/wangp_mcp.py @@ -281,7 +281,8 @@ async def dispatch(message): }} return {'jsonrpc': '2.0', 'id': request_id, 'result': {'isError': True, 'content': [{'type': 'text', 'text': str(detail)}]}} - @router.post('/api/v1/wangp/mcp') + @router.post('/api/v1/wangp/mcp', include_in_schema=False) + @router.post('/api/v1/mcp') async def mcp(request: Request): token = token_getter() if not token: @@ -303,7 +304,8 @@ async def mcp(request: Request): return Response(status_code=202) return JSONResponse(results if isinstance(payload, list) else results[0]) - @router.get('/api/v1/wangp/mcp') + @router.get('/api/v1/wangp/mcp', include_in_schema=False) + @router.get('/api/v1/mcp') async def no_stream(): return Response(status_code=405, headers={'Allow': 'POST'}) diff --git a/app/routers/wizard_workflow_executor.py b/app/routers/wizard_workflow_executor.py new file mode 100644 index 000000000..51c21a955 --- /dev/null +++ b/app/routers/wizard_workflow_executor.py @@ -0,0 +1,101 @@ +"""HTTP and MCP projections for the server-owned image → upscale workflow. + +The router is testable without importing ``_launch_runtime``. Mounting it on +the live API is a separate runtime-owner change; see +``INTEGRATION_LAUNCH_RUNTIME.patch``. +""" +from __future__ import annotations + +from fastapi import APIRouter, HTTPException, Request + +from services.image_generation_commands import command_error +from services.wizard_workflow_executor import catalog, command_handlers +from services.wizard_workflows import WizardWorkflowRevisionConflict +from services.wizard_workflow_supervisor import workflow_lifespan + + +def create_wizard_workflow_executor_router(executor, *, list_workspaces=None, interval: float = 1.0) -> APIRouter: + """Build the isolated executor router with an injected service.""" + router = APIRouter(lifespan=workflow_lifespan(executor, list_workspaces, interval) if list_workspaces else None) + + def _translate(error: Exception) -> HTTPException: + if isinstance(error, HTTPException): + return error + if isinstance(error, WizardWorkflowRevisionConflict): + return HTTPException(409, { + "code": "wizard_workflow_revision_conflict", + "message": str(error), + "expectedRevision": error.expected, + "currentRevision": error.current, + "retryable": False, + "recoverable": True, + }) + return command_error(500, "workflow_executor_failed", str(error)) + + @router.get("/api/v1/wizard/workflows/executor/commands") + def commands(): + return {"version": 1, "operations": catalog()} + + @router.get("/api/v1/wizard/workflows/executor") + def list_workflows(workspace: str): + return executor.list(workspace) + + @router.get("/api/v1/wizard/workflows/executor/{workflow_id}") + def get_workflow(workflow_id: str, workspace: str): + return executor.get(workspace, workflow_id) + + @router.post("/api/v1/wizard/workflows/executor") + async def start(request: Request): + try: + body = await request.json() + except ValueError as error: + raise command_error(422, "invalid_command", "Command must be valid JSON") from error + try: + return await executor.start(body) + except (HTTPException, WizardWorkflowRevisionConflict, OSError, ValueError) as error: + raise _translate(error) from error + + @router.post("/api/v1/wizard/workflows/executor/answer") + async def answer(request: Request): + try: + body = await request.json() + except ValueError as error: + raise command_error(422, "invalid_command", "Command must be valid JSON") from error + try: + return await executor.answer(body) + except (HTTPException, WizardWorkflowRevisionConflict, OSError, ValueError) as error: + raise _translate(error) from error + + @router.post("/api/v1/wizard/workflows/executor/resume") + async def resume(request: Request): + try: + body = await request.json() + except ValueError as error: + raise command_error(422, "invalid_command", "Command must be valid JSON") from error + try: + return await executor.resume(body) + except (HTTPException, WizardWorkflowRevisionConflict, OSError, ValueError) as error: + raise _translate(error) from error + + @router.post("/api/v1/wizard/workflows/executor/reconcile") + async def reconcile(request: Request): + try: + body = await request.json() + except ValueError as error: + raise command_error(422, "invalid_command", "Command must be valid JSON") from error + workspace = str((body or {}).get("workspace") or "").strip() + if not workspace: + raise command_error(422, "invalid_workspace", "Use an explicit valid output workspace") + try: + return {"results": await executor.reconcile(workspace)} + except (HTTPException, WizardWorkflowRevisionConflict, OSError, ValueError) as error: + raise _translate(error) from error + + return router + + +__all__ = [ + "catalog", + "command_handlers", + "create_wizard_workflow_executor_router", +] diff --git a/app/routers/world3d_export.py b/app/routers/world3d_export.py new file mode 100644 index 000000000..4f85e4480 --- /dev/null +++ b/app/routers/world3d_export.py @@ -0,0 +1,78 @@ +"""HTTP and MCP projections for recoverable World3D export. + +The router is testable without importing ``_launch_runtime``. The live API +mounts it from ``_launch_runtime`` after the scene commands router. +""" +from __future__ import annotations + +import json + +from fastapi import APIRouter, HTTPException, Request +from starlette.concurrency import run_in_threadpool + +from services.world3d_export import ( + CANCEL_OPERATION, + OPERATION, + RECEIPT_OPERATION, + command_catalog, + command_handlers, + http_error, +) + + +def create_world3d_export_router(service) -> APIRouter: + router = APIRouter() + + @router.get("/api/v1/scenes/world3d/export/commands") + def commands(): + return {"version": 1, "operations": command_catalog()} + + @router.get("/api/v1/scenes/world3d/export/capabilities") + def capabilities(): + return service.capabilities() + + @router.post("/api/v1/scenes/world3d/export") + async def submit(request: Request): + data = await request.body() + if len(data) > 3 * 1024 * 1024: + raise HTTPException(413, "World3D export command exceeds 3 MB") + try: + command = json.loads(data) + except ValueError as error: + raise http_error(422, "invalid_command", "Command must be valid JSON") from error + return await run_in_threadpool(service.submit, command) + + @router.get("/api/v1/scenes/world3d/export/receipt") + def receipt(workspace: str, intent_id: str): + return service.receipt(workspace, intent_id) + + @router.post("/api/v1/scenes/world3d/export/cancel") + async def cancel(request: Request): + try: + body = await request.json() + except ValueError as error: + raise http_error(422, "invalid_command", "Command must be valid JSON") from error + if not isinstance(body, dict): + raise http_error(422, "invalid_command", "Use workspace and intent_id") + return await run_in_threadpool(service.cancel, body.get("workspace"), body.get("intent_id")) + + return router + + +def bind_world3d_renderer_origin(api, service) -> None: + @api.middleware("http") + async def bind_renderer_origin(request: Request, call_next): + # Use the listening socket, never a client-controlled Host header. + server = request.scope.get("server") + if not service.app_url and server: + service.app_url = f"http://127.0.0.1:{server[1]}" + return await call_next(request) + +__all__ = [ + "CANCEL_OPERATION", + "OPERATION", + "RECEIPT_OPERATION", + "command_catalog", + "command_handlers", + "create_world3d_export_router", +] diff --git a/app/runtime/constraints/darwin-core.txt b/app/runtime/constraints/darwin-core.txt new file mode 100644 index 000000000..3984dd688 --- /dev/null +++ b/app/runtime/constraints/darwin-core.txt @@ -0,0 +1,9 @@ +# Runtime ABI constraints; generated from app/runtime/profiles.json. +fastapi==0.141.1 +psutil==7.2.2 +pillow==11.3.0 +pydantic==2.10.6 +python-multipart==0.0.32 +requests==2.34.2 +starlette==0.52.1 +uvicorn==0.52.4 diff --git a/app/runtime/locks/darwin-core.txt b/app/runtime/locks/darwin-core.txt new file mode 100644 index 000000000..69d8d424c --- /dev/null +++ b/app/runtime/locks/darwin-core.txt @@ -0,0 +1,8 @@ +fastapi==0.141.1 +psutil==7.2.2 +requests==2.34.2 +pillow==11.3.0 +pydantic==2.10.6 +python-multipart==0.0.32 +starlette==0.52.1 +uvicorn==0.52.4 diff --git a/app/runtime/profiles.json b/app/runtime/profiles.json index afa829a47..0656c57c0 100644 --- a/app/runtime/profiles.json +++ b/app/runtime/profiles.json @@ -1,6 +1,6 @@ { "version": 1, - "revision": "runtime-profiles-v1", + "revision": "runtime-profiles-v1-macos-core", "platforms": [ "linux", "win32" @@ -32,6 +32,28 @@ } }, "engines": { + "core": { + "label": "HocusPocus core", + "env": "app/env", + "environment": "venv", + "python": "3.10", + "required": true, + "platforms": [ + "darwin" + ], + "requirements": "app/runtime/requirements-core.txt", + "constraints": { + "fastapi": "0.141.1", + "psutil": "7.2.2", + "pydantic": "2.10.6", + "pillow": "11.3.0", + "python-multipart": "0.0.32", + "requests": "2.34.2", + "starlette": "0.52.1", + "uvicorn": "0.52.4" + }, + "defaultInstall": true + }, "wangp": { "label": "HocusPocus / WanGP", "env": "app/env", diff --git a/app/runtime/requirements-core.txt b/app/runtime/requirements-core.txt new file mode 100644 index 000000000..7fd970067 --- /dev/null +++ b/app/runtime/requirements-core.txt @@ -0,0 +1,9 @@ +# Apple Silicon core/remote: FastAPI, UI and editors. No Torch/CUDA. +fastapi==0.141.1 +starlette==0.52.1 +uvicorn==0.52.4 +pillow==11.3.0 +pydantic==2.10.6 +python-multipart==0.0.32 +psutil==7.2.2 +requests==2.34.2 diff --git a/app/services/audio_analysis.py b/app/services/audio_analysis.py index 20e4168ff..d6d3e9cf4 100644 --- a/app/services/audio_analysis.py +++ b/app/services/audio_analysis.py @@ -16,6 +16,17 @@ import numpy as np from typing import Optional, List, Tuple from dataclasses import dataclass, field, asdict +from services.lyric_timeline import ( + LyricSegment, + LyricWord, + align_authoritative_lyrics, + attach_timing_to_clips, + build_visual_events, + build_timing_bundle, + has_authoritative_lyrics, + lyrics_to_srt, + structure_from_aligned_lyrics, +) logger = logging.getLogger(__name__) @@ -91,22 +102,6 @@ class Section: label: str energy: float -@dataclass -class LyricWord: - """A word aligned to the source audio, independent of Whisper's API.""" - start: float - end: float - text: str - - -@dataclass -class LyricSegment: - start: float - end: float - text: str - speaker: Optional[str] = None - words: Optional[List[LyricWord]] = None - @dataclass class AudioAnalysis: duration: float @@ -119,6 +114,11 @@ class AudioAnalysis: lyrics: Optional[List[LyricSegment]] = None vocals_path: Optional[str] = None warnings: Optional[List[str]] = None + transcript: Optional[List[LyricSegment]] = None + lyric_timeline: Optional[List[LyricSegment]] = None + lyrics_srt: Optional[str] = None + lyric_timing: Optional[dict] = None + visual_events: Optional[List[dict]] = None # --------------------------------------------------------------------------- @@ -316,16 +316,24 @@ def _transcribe(audio_path: str, lyrics_hint: Optional[str] = None) -> List[Lyri initial_prompt = _clean_lyrics_hint(lyrics_hint) if initial_prompt: print(f"[AudioAnalysis] Seeding transcription with known lyrics ({len(initial_prompt)} chars)") - segments, info = model.transcribe( - audio_path, + options = dict( beam_size=5, # Segment timing can span a whole sentence. The cutout animator uses # these word boundaries to make mouth beats at real speech points. word_timestamps=True, language=None, - vad_filter=True, + # VAD can discard a quiet spoken or sung intro. With known lyrics we + # inspect the complete waveform and use the text only as a prior. + vad_filter=not bool(initial_prompt), initial_prompt=initial_prompt, ) + if initial_prompt: + options.update( + condition_on_previous_text=False, + no_speech_threshold=1.0, + max_initial_timestamp=30.0, + ) + segments, info = model.transcribe(audio_path, **options) lyrics = [] for seg in segments: @@ -796,6 +804,16 @@ def analyze( _set_progress("transcribing", "Transcribing audio") result.lyrics = _transcribe(transcription_path, lyrics_hint=lyrics_hint) + result.transcript = result.lyrics or [] + _set_progress("aligning_lyrics", "Aligning lyrics to the source audio") + timing_bundle = build_timing_bundle(lyrics_hint or "", result.transcript, duration) + result.lyric_timeline = timing_bundle["timeline"] + result.lyric_timing = timing_bundle["timing"] + result.lyrics_srt = timing_bundle["srt"] + result.visual_events = timing_bundle["visual_events"] + result.warnings.extend(timing_bundle["warnings"]) + result.lyrics = result.lyric_timeline + # Run speaker diarization on the original mix (needs both voices) if result.lyrics: # _diarize loads pyannote on first call (~100MB cached). @@ -834,6 +852,21 @@ def analyze( exc_info=True, ) + # A missing model or empty ASR result must not make supplied lyrics + # disappear. Keep a clearly approximate timeline so the user can edit + # it and the planner never falls back to unrelated free text. + if lyrics_hint and not result.lyric_timeline and has_authoritative_lyrics(lyrics_hint): + timeline, timing = align_authoritative_lyrics(lyrics_hint, [], duration) + result.transcript = result.transcript or [] + result.lyric_timeline = timeline + result.lyric_timing = timing + result.lyrics_srt = lyrics_to_srt(timeline) + result.visual_events = build_visual_events(timeline) + result.lyrics = timeline + result.warnings.append( + "Transcription timing was unavailable; written lyrics use an approximate editable timeline." + ) + _set_progress("finalizing", "Finalizing") print(f"[AudioAnalysis] Done: {bpm:.1f} BPM, {len(beats)} beats, {len(sections)} sections") # Clear progress so subsequent /status polls don't show stale state. @@ -1183,6 +1216,8 @@ def _section_at(t: float) -> tuple: prev["duration_frames"] = _snap_to_valid_frames(prev_dur, fps, frames_steps, frames_minimum) clips.pop() + attach_timing_to_clips(clips, analysis) + return clips diff --git a/app/services/character_kit_library.py b/app/services/character_kit_library.py index 2e11109fb..6ab86489c 100644 --- a/app/services/character_kit_library.py +++ b/app/services/character_kit_library.py @@ -26,6 +26,7 @@ _STYLES = {"cutout", "children-illustration", "anime-2d"} _REVIEW_STATES = {"pending", "approved", "rejected"} _ALPHA_STATES = {"unknown", "transparent", "opaque"} +_MOUTH_STATES = {"closed", "small", "wide", "round", "pressed", "medium", "pucker", "bite", "tongue"} class CharacterKitRevisionConflict(ValueError): @@ -122,7 +123,7 @@ def normalize_character_kit(value: Any, fallback_id: str = "") -> dict[str, Any] poses = {_token(key, "Pose"): _asset(asset, f"Pose {key}") for key, asset in poses_raw.items()} mouth_raw = value.get("mouth") or {} - if not isinstance(mouth_raw, dict) or any(key not in {"closed", "small", "wide", "round"} for key in mouth_raw): + if not isinstance(mouth_raw, dict) or any(key not in _MOUTH_STATES for key in mouth_raw): raise ValueError("Character Kit mouth states are invalid") mouth = {key: _asset(asset, f"Mouth {key}") for key, asset in mouth_raw.items()} @@ -142,7 +143,7 @@ def normalize_character_kit(value: Any, fallback_id: str = "") -> dict[str, Any] group = {"mouth": _anchor(raw_group["mouth"], f"{pose_id} mouth anchor")} mouth_states_raw = raw_group.get("mouthStates") if mouth_states_raw is not None: - if not isinstance(mouth_states_raw, dict) or any(key not in {"closed", "small", "wide", "round"} for key in mouth_states_raw): + if not isinstance(mouth_states_raw, dict) or any(key not in _MOUTH_STATES for key in mouth_states_raw): raise ValueError(f"Anchors for {pose_id} have invalid mouth states") group["mouthStates"] = { key: _anchor(anchor, f"{pose_id} mouth {key} anchor") @@ -171,6 +172,12 @@ def normalize_character_kit(value: Any, fallback_id: str = "") -> dict[str, Any] result["voice"] = normalize_character_voice(value["voice"]) if value.get("lookNotes"): result["lookNotes"] = _text(value["lookNotes"], "Character look notes", 4000) + if value.get("restPose") is not None: + rest = value["restPose"] + if not isinstance(rest, dict): + raise ValueError("Character rest pose must be an object") + result["restPose"] = {"asset": _asset(rest.get("asset"), "Character rest pose"), + "fingerprint": _text(rest.get("fingerprint"), "Rest pose fingerprint", 8000, required=True)} for key in ("identityReference", "base"): if value.get(key) is not None: result[key] = _asset(value[key], f"Character Kit {key}") diff --git a/app/services/core_canonical_tasks.py b/app/services/core_canonical_tasks.py new file mode 100644 index 000000000..e393e1141 --- /dev/null +++ b/app/services/core_canonical_tasks.py @@ -0,0 +1,84 @@ +"""Canonical Activity adapters for the Apple Silicon core/remote profile. + +NVIDIA launch injects GPU workers into ``create_canonical_tasks_router``. +Core only has MiniMax jobs plus frontend Activity rows in TaskRegistry. +""" +from __future__ import annotations + +from typing import Any + +from fastapi import HTTPException + +from services import core_generation_commands, core_remote_image +from services.task_manager import ACTIVE_STATUSES, TERMINAL_STATUSES + + +def task_status(value: object) -> str: + raw = str(value or "queued").lower() + if raw in {"completed", "failed", "cancelled", "interrupted", "created", "queued"}: + return raw + if raw in {"paused", "waiting", "waiting_resource"}: + return "waiting_resource" + return "running" + + +def upsert_task( + workspace: str, + task_id: str, + *, + event_exclude_fields: set[str] | frozenset[str] | None = None, + **fields: Any, +) -> dict[str, Any]: + registry = core_generation_commands.registry_for(workspace) + existing = registry.get(task_id) + if existing is None: + return registry.create( + id=task_id, + workspace=workspace, + event_exclude_fields=event_exclude_fields, + **fields, + ) + 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: + return existing + mutable = { + key: value for key, value in fields.items() + if key not in {"id", "created_at"} and existing.get(key) != value + } + if not mutable: + return existing + return registry.update( + task_id, + event_exclude_fields=event_exclude_fields, + **mutable, + ) + + +def sync_tasks(workspace: str) -> None: + registry = core_generation_commands.registry_for(workspace) + tasks, _latest = registry.snapshot(limit=500) + for task in tasks: + core_generation_commands.get_task(workspace, str(task.get("id") or "")) + + +def control_task(task: dict, action: str): + workspace = str(task.get("workspace") or "default") + task_id = str(task.get("id") or "") + job_id = str(task.get("backend_job_id") or "") + if action != "cancel": + raise HTTPException(status_code=409, detail=f"Task does not support {action}") + if job_id: + cancelled = core_remote_image.cancel_job(job_id) + if cancelled is None: + raise HTTPException(status_code=404, detail="Task not found") + return cancelled + if not task_id: + raise HTTPException(status_code=404, detail="Task not found") + return core_generation_commands.registry_for(workspace).update( + task_id, + status="cancelled", + phase="cancelled", + force=True, + event_type="task.cancelled", + ) diff --git a/app/services/core_editor.py b/app/services/core_editor.py new file mode 100644 index 000000000..1f570ba3d --- /dev/null +++ b/app/services/core_editor.py @@ -0,0 +1,158 @@ +"""FFmpeg video-editor jobs for the core/remote profile. No Torch.""" +from __future__ import annotations + +import os +import re +import threading +import time +import uuid +from typing import Any + +from services import core_workspace as core +from services.media_refs import parse_media_ref +from services.media_thumbnails import ensure_media_thumbnail +from services.video_editor import extract_frame, probe_audio, probe_media, render_project + +_JOBS: dict[str, dict[str, Any]] = {} +_LOCK = threading.Lock() +THUMB_DIR = os.path.join(os.getcwd(), "outputs", "_hocuspocus", "thumbs") + + +def resolve_media(source: str, workspace: str | None = None) -> str: + path, chosen = parse_media_ref(source, workspace) + name = os.path.basename(path) + folders = [] + if chosen == "__uploads__": + folders.append(core.uploads_dir()) + else: + try: + folders.append(core.workspace_dir(chosen)) + except ValueError: + pass + folders.append(core.uploads_dir()) + for folder in folders: + for candidate in (name, path.lstrip("/")): + joined = core.safe_join(folder, candidate) + if joined and os.path.isfile(joined): + return joined + raise ValueError(f"Media could not be found: {name or path}") + + +def probe_video(source: str, workspace: str | None = None) -> dict[str, Any]: + return probe_media(resolve_media(source, workspace)) + + +def probe_soundtrack(source: str, workspace: str | None = None) -> dict[str, Any]: + return probe_audio(resolve_media(source, workspace)) + + +def thumbnail_path(source: str, workspace: str | None = None) -> str: + os.makedirs(THUMB_DIR, exist_ok=True) + return ensure_media_thumbnail(resolve_media(source, workspace), THUMB_DIR, is_video=True) + + +def unique_output_name(folder: str, filename: str) -> tuple[str, str]: + dest = os.path.join(folder, filename) + if not os.path.exists(dest): + return filename, dest + stem, ext = os.path.splitext(filename) + suffix = 2 + while True: + candidate = f"{stem}_{suffix}{ext}" + dest = os.path.join(folder, candidate) + if not os.path.exists(dest): + return candidate, dest + suffix += 1 + + +def screenshot(source: str, time_seconds: float, name: str, workspace: str | None = None) -> dict[str, Any]: + folder = core.workspace_dir(workspace) + os.makedirs(folder, exist_ok=True) + safe = re.sub(r"[^A-Za-z0-9_-]+", "_", str(name or "video_frame")).strip("_") + safe = safe[:60] or "video_frame" + stamp = time.strftime("%Y-%m-%d-%Hh%Mm%Ss") + filename, dest = unique_output_name(folder, f"{stamp}_{safe}_frame.png") + info = extract_frame(resolve_media(source, workspace), dest, time_seconds) + return {"filename": filename, "url": f"/api/v1/file/{filename}", **info} + + +def get_job(job_id: str) -> dict[str, Any] | None: + with _LOCK: + job = _JOBS.get(job_id) + return dict(job) if job else None + + +def start_export(body: dict[str, Any]) -> dict[str, Any]: + clips = body.get("clips") + if not isinstance(clips, list) or not clips: + raise ValueError("Add at least one video clip") + workspace = body.get("workspace") + prepared = [] + for clip in clips: + if not isinstance(clip, dict): + raise ValueError("Invalid clip") + item = dict(clip) + item["resolved_path"] = resolve_media(str(clip.get("source") or ""), workspace) + prepared.append(item) + soundtrack = body.get("soundtrack") + if isinstance(soundtrack, dict) and soundtrack.get("source"): + soundtrack = dict(soundtrack) + soundtrack["resolved_path"] = resolve_media(str(soundtrack.get("source") or ""), workspace) + else: + soundtrack = None + folder = core.workspace_dir(workspace) + os.makedirs(folder, exist_ok=True) + stamp = time.strftime("%Y-%m-%d-%Hh%Mm%Ss") + safe_name = re.sub(r"[^A-Za-z0-9_-]+", "_", str(body.get("name") or "edit")).strip("_") + safe_name = safe_name[:60] or "edit" + filename, _dest = unique_output_name(folder, f"{stamp}_{safe_name}.mp4") + destination = core.safe_join(folder, filename) + if not destination: + raise ValueError("Invalid export name") + job_id = uuid.uuid4().hex + job = { + "job_id": job_id, + "workspace": core.active_workspace() if workspace is None else workspace, + "status": "running", + "progress": 0, + "message": "Exporting…", + "filename": filename, + "url": None, + "error": None, + } + with _LOCK: + _JOBS[job_id] = job + + def run() -> None: + try: + def progress(percent: int, message: str) -> None: + with _LOCK: + current = _JOBS[job_id] + current["progress"] = percent + current["message"] = message + + result = render_project( + prepared, + destination, + width=int(body.get("width") or 1280), + height=int(body.get("height") or 720), + fps=int(body.get("fps") or 30), + soundtrack=soundtrack, + progress=progress, + ) + with _LOCK: + current = _JOBS[job_id] + current["status"] = "completed" + current["progress"] = 100 + current["message"] = "Export complete" + current["url"] = f"/api/v1/file/{filename}" + current["result"] = {"duration": result.get("duration"), "clip_count": len(prepared)} + except Exception as error: + with _LOCK: + current = _JOBS[job_id] + current["status"] = "failed" + current["error"] = str(error) + current["message"] = str(error) + + threading.Thread(target=run, daemon=True).start() + return dict(job) diff --git a/app/services/core_generation_commands.py b/app/services/core_generation_commands.py new file mode 100644 index 000000000..9ff43ec80 --- /dev/null +++ b/app/services/core_generation_commands.py @@ -0,0 +1,289 @@ +"""Studio generation.image commands on the core/remote profile. + +The shared UI admits MiniMax Image-01 through POST /api/v1/generation/commands. +The NVIDIA runtime owns that surface; core must honour the same receipt +contract or Generate 404s and never starts the remote job. +""" +from __future__ import annotations + +import sqlite3 +import threading +import uuid +from copy import deepcopy +from typing import Any + +from services import core_remote_image, core_workspace as core, execution_mode +from services.image_generation_commands import command_error +from services.image_generation_spec import ImageGenerationSpecError, freeze_image_generation_spec +from services.minimax_image_service import MiniMaxImageError, prepare_prompt +from services.task_command_admission import TaskCommandConflict +from services.task_manager import ACTIVE_STATUSES, TERMINAL_STATUSES, TaskRegistry +from routers.system_capabilities import require_capability_http + +_JOB_STATUS = { + "queued": "queued", + "waiting_resource": "waiting_resource", + "running": "running", + "cancelling": "running", + "completed": "completed", + "failed": "failed", + "cancelled": "cancelled", + "interrupted": "interrupted", +} + +_REGISTRIES: dict[str, TaskRegistry] = {} +_LOCK = threading.Lock() +_DISPATCH_OWNER = f"core-{uuid.uuid4().hex}" +_DISPATCH_LOCK = threading.Lock() + + +def registry_for(workspace: str) -> TaskRegistry: + """Task registry for this workspace folder. Shared with World3D export.""" + return _registry(workspace) + + +def _registry(workspace: str) -> TaskRegistry: + try: + folder = core.workspace_dir(workspace) + except ValueError as error: + raise command_error(422, "invalid_workspace", "Use an explicit valid output workspace") from error + with _LOCK: + existing = _REGISTRIES.get(folder) + if existing is not None: + return existing + registry = TaskRegistry(folder, interrupt_stale=True) + _REGISTRIES[folder] = registry + return registry + + +def _job_status(value: Any) -> str | None: + return _JOB_STATUS.get(str(value or "").strip().lower()) + + +def _sync_task_from_job(workspace: str, task: dict[str, Any]) -> dict[str, Any]: + """Project the MiniMax in-memory job onto the admitted canonical task. + + ``core_remote_image`` never writes TaskRegistry. The Wizard executor + polls ``get_task``, so a completed JPG would otherwise stay ``queued`` + with empty ``result_refs`` and never advance to upscale. + """ + job_id = str(task.get("backend_job_id") or "").strip() + if not job_id: + return task + job = core_remote_image.get_job(job_id) + if not isinstance(job, dict): + return task + mapped = _job_status(job.get("status")) + if mapped is None: + return task + current = str(task.get("status") or "") + if current in TERMINAL_STATUSES and mapped in ACTIVE_STATUSES: + return task + refs = [str(name).strip() for name in (job.get("output_files") or []) if str(name).strip()] + patch: dict[str, Any] = {} + if mapped != current: + patch["status"] = mapped + patch["phase"] = mapped + if refs and refs != list(task.get("result_refs") or []): + patch["result_refs"] = refs + message = str(job.get("message") or "").strip() + if message and message != task.get("message"): + patch["message"] = message + error = job.get("error") + if error and mapped in {"failed", "cancelled"} and error != task.get("error"): + patch["error"] = str(error) + if not patch: + return task + try: + return _registry(workspace).update( + str(task["id"]), + force=True, + event_type="adapter.synced", + **patch, + ) + except (KeyError, OSError, sqlite3.Error, ValueError): + return {**task, **patch} + + +def get_task(workspace: str, task_id: str) -> dict[str, Any] | None: + if not str(task_id or "").strip(): + return None + task = _registry(workspace).get(str(task_id)) + if task is None: + return None + return _sync_task_from_job(workspace, task) + + +def _freeze(command: dict[str, Any]) -> tuple[dict[str, Any], dict[str, Any]]: + if not isinstance(command, dict): + raise command_error(422, "invalid_command", "Command must be a JSON object") + if command.get("operation") == "tools.upscale": + require_capability_http("wangp_local") + if command.get("operation") != "generation.image": + raise command_error(422, "unsupported_operation", "This runtime admits generation.image only") + if type(command.get("version")) is int and command["version"] == 2: + from services.studio_image_spec import freeze_studio_image_spec + frozen = freeze_studio_image_spec(command) + params = { + **deepcopy(frozen["effective"]["input"]["params"]), + "workspace": frozen["effective"]["input"]["workspace"], + } + return frozen, params + frozen = freeze_image_generation_spec(command) + return frozen, deepcopy(frozen["effective"]["input"]) + + +def _require_minimax_image(params: dict[str, Any]) -> None: + model = str(params.get("model_type") or "") + if model.startswith("minimax:") or model == "image-01": + return + require_capability_http("wangp_local") + raise command_error(422, "unsupported_model", "Choose MiniMax Image-01 on this runtime") + + +def _subject_reference(params: dict[str, Any]) -> str: + refs = params.get("image_refs") + if isinstance(refs, list) and refs: + return str(refs[0] or "") + for key in ("subject_reference", "image_start"): + value = params.get(key) + if value: + return str(value) + return "" + + +def _task_fields(workspace: str, job_id: str, params: dict[str, Any]) -> dict[str, Any]: + task_id = f"task-generation-{job_id}" + return { + "id": task_id, + "root_id": task_id, + "kind": "generation", + "workflow": "generation.image", + "status": "queued", + "phase": "queued", + "message": "MiniMax image request queued", + "title": "MiniMax Image-01", + "workspace": workspace, + "backend_job_id": job_id, + "provider": "minimax", + "model": str(params.get("model_type") or core_remote_image.MODEL_ID), + "cancelable": True, + } + + +class CoreGenerationCommands: + def canonicalize_reference(self, value: str, media_kind: str = "image") -> str: + from services.studio_image_resources import StudioImageResources + from services.studio_speech_resources import StudioSpeechResources + from services.studio_sfx_resources import StudioSfxResources + from services.studio_video_resources import StudioVideoResources + + resource_type = { + "image": StudioImageResources, + "audio": StudioSpeechResources, + "video": StudioSfxResources, + "studio_video": StudioVideoResources, + }.get(media_kind) + if resource_type is None: + raise command_error(422, "invalid_reference", "Unsupported media kind") + resources = resource_type( + workspace_dir=core.workspace_dir, + uploads_dir=core.uploads_dir, + list_workspaces=core.list_workspaces, + lora_search_dirs=lambda: [], + lora_compatible=lambda *_args, **_kwargs: True, + ) + try: + return resources.canonicalize_legacy(value) + except ValueError as error: + raise command_error(422, "invalid_reference", str(error)) from error + + def receipt(self, workspace: str, intent_id: str) -> dict[str, Any]: + if not isinstance(intent_id, str) or not 1 <= len(intent_id) <= 160: + raise command_error(422, "invalid_command", "An exact intent_id is required") + try: + registry = _registry(workspace) + entry = registry.command_admission(intent_id) + if entry is None: + raise command_error( + 404, "receipt_not_found", + "No admission exists for this intention in this workspace", + ) + return {"receipt": entry["receipt"], "task": get_task(workspace, entry["task_id"])} + except (OSError, sqlite3.Error) as error: + raise command_error(503, "storage_unavailable", "Command storage is unavailable") from error + + def _ensure_job(self, workspace: str, job_id: str, params: dict[str, Any], task_id: str) -> None: + if core_remote_image.get_job(job_id) is not None: + return + core_remote_image.start_job( + { + "model_type": params.get("model_type") or core_remote_image.MODEL_ID, + "generation_mode": "image", + "prompt": params.get("prompt") or "", + "resolution": params.get("resolution") or "1024x1024", + "aspect_ratio": params.get("aspect_ratio") or "", + "subject_reference": _subject_reference(params), + "workspace": workspace, + }, + workspace=workspace, + job_id=job_id, + on_update=lambda: get_task(workspace, task_id), + ) + + async def submit(self, command, *, trusted_tool=None, submission_context=None): + del trusted_tool, submission_context + try: + frozen, params = _freeze(command) + workspace = str(params.get("workspace") or "") + _require_minimax_image(params) + execution_mode.validate_remote_provider(workspace, "minimax-image") + # Fail closed before admission so a transport retry can reuse intent_id. + prepare_prompt(str(params.get("prompt") or "")) + core_remote_image.encode_subject_reference(_subject_reference(params), workspace) + registry = _registry(workspace) + job_id = uuid.uuid4().hex + admitted = registry.admit_command_task( + intent_id=command["intent_id"], + operation=frozen["original"]["operation"], + digest=frozen["fingerprint"], + original=frozen["original"], + effective=frozen["effective"], + task_fields=_task_fields(workspace, job_id, params), + fingerprint_version=frozen["fingerprint_version"], + ) + result = admitted["receipt"]["result"] + # A concurrent replay must not mistake claim → job creation for a + # previous process losing its provider outcome. + with _DISPATCH_LOCK: + if registry.claim_command_dispatch(command["intent_id"], _DISPATCH_OWNER): + self._ensure_job(workspace, result["job_id"], params, result["task_id"]) + elif core_remote_image.get_job(result["job_id"]) is None: + task = registry.get(result["task_id"]) + if task and task["status"] in ACTIVE_STATUSES: + task = registry.update(task["id"], status="interrupted", force=True, + message="Provider outcome unknown; create a new attempt to retry") + if task: + core_remote_image.restore_job(task) + return admitted + except ImageGenerationSpecError as error: + raise command_error(422, "invalid_command", str(error)) from error + except MiniMaxImageError as error: + status = error.status_code if error.status_code in {400, 413} else 422 + raise command_error(status, "invalid_command", str(error)) from error + except TaskCommandConflict as error: + raise command_error(409, "intent_conflict", str(error)) from error + except execution_mode.ExecutionModeError as error: + raise command_error(409, "execution_policy", str(error)) from error + except (OSError, sqlite3.Error) as error: + raise command_error( + 503, "storage_unavailable", + "Command storage is unavailable; retry with the same intention", + ) from error + + +_SERVICE = CoreGenerationCommands() + + +def service() -> CoreGenerationCommands: + return _SERVICE diff --git a/app/services/core_production.py b/app/services/core_production.py new file mode 100644 index 000000000..4b143401d --- /dev/null +++ b/app/services/core_production.py @@ -0,0 +1,221 @@ +"""Credential-free production profile and remote LLM routing for core/remote.""" +from __future__ import annotations + +import copy +from typing import Any + +from services import core_workspace as core +from services.provider_profile import ( + IMAGE_PROVIDERS, + MODEL3D_PROVIDERS, + MUSIC_PROVIDERS, + TEXT_PROVIDERS, + alias_model3d_provider, + alias_text_provider, + canonicalize_remote_url, + default_url_for_provider, + resolve_minimax_key, + resolve_writing_override, +) + +PROFILE_KEY = "production_profile" +PROFILE_VERSION = 1 + +DEFAULT_VIDEO = { + "provider": "local", + "model": "minimax_h3_legacy", + "settings": { + "profile": "quality", + "steps": 20, + "flowShift": 12.0, + "audioShift": 3.0, + "turbo": False, + "cache": False, + "loras": [], + "resolution": "540p", + "aspectRatio": "16:9", + }, +} + +CORE_DEFAULT_PROFILE = { + "version": PROFILE_VERSION, + "text": {"provider": "minimax", "model": "MiniMax-M3", "base_url": "https://api.minimax.io"}, + "image": {"provider": "minimax", "model": "image-01"}, + "music": {"provider": "minimax", "model": "music-3.0"}, + "model3d": {"provider": "meshy", "model": "latest"}, + "video": copy.deepcopy(DEFAULT_VIDEO), +} + + +def _text(value: Any, label: str, *, maximum: int = 200) -> str: + if not isinstance(value, str): + raise ValueError(f"{label} must be a string.") + result = value.strip() + if not result: + raise ValueError(f"{label} cannot be empty.") + if len(result) > maximum: + raise ValueError(f"{label} is too long.") + return result + + +def _named_provider(section: dict[str, Any], label: str, allowed: frozenset[str] | set[str]) -> str: + provider = _text(section.get("provider"), f"{label} provider").lower() + if provider not in allowed: + raise ValueError(f"Unsupported production {label.lower()} provider.") + return provider + + +def _video_settings(settings: Any) -> dict[str, Any]: + if not isinstance(settings, dict): + raise ValueError("Production video settings must be an object.") + try: + steps = int(settings.get("steps", 20)) + flow_shift = float(settings.get("flowShift", 12.0)) + audio_shift = float(settings.get("audioShift", 3.0)) + except (TypeError, ValueError) as exc: + raise ValueError("Production video steps and shifts must be numeric.") from exc + loras = settings.get("loras", []) + if not isinstance(loras, list) or len(loras) > 32: + raise ValueError("Production video LoRAs must be a list of at most 32 entries.") + return { + "profile": _text(settings.get("profile", "quality"), "Video profile", maximum=40).lower(), + "steps": steps, + "flowShift": flow_shift, + "audioShift": audio_shift, + "turbo": bool(settings.get("turbo", False)), + "cache": bool(settings.get("cache", False)), + "loras": [_text(item, "Production video LoRA", maximum=500) for item in loras], + "resolution": str(settings.get("resolution") or "540p"), + "aspectRatio": str(settings.get("aspectRatio") or "16:9"), + } + + +def normalize_production_profile(value: Any) -> dict[str, Any]: + if not isinstance(value, dict): + raise ValueError("Production profile must be an object.") + text, image, music, video = value.get("text"), value.get("image"), value.get("music"), value.get("video") + model3d = value.get("model3d") if isinstance(value.get("model3d"), dict) else { + "provider": "meshy", "model": "latest", + } + if not all(isinstance(item, dict) for item in (text, image, music, video)): + raise ValueError("Production profile needs text, image, music and video sections.") + text_provider = alias_text_provider( + _named_provider(text, "Text", TEXT_PROVIDERS), str(text.get("base_url") or ""), + ) + if text_provider not in TEXT_PROVIDERS: + raise ValueError("Unsupported production text provider.") + text_base_url = default_url_for_provider( + text_provider, canonicalize_remote_url(str(text.get("base_url") or "")), + ) + return { + "version": PROFILE_VERSION, + "text": {"provider": text_provider, "model": _text(text.get("model"), "Text model"), "base_url": text_base_url}, + "image": {"provider": _named_provider(image, "Image", IMAGE_PROVIDERS), "model": _text(image.get("model"), "Image model")}, + "music": {"provider": _named_provider(music, "Music", MUSIC_PROVIDERS), "model": _text(music.get("model"), "Music model")}, + "model3d": { + "provider": alias_model3d_provider(_named_provider(model3d, "3D", MODEL3D_PROVIDERS)), + "model": _text(model3d.get("model") or "latest", "3D model"), + }, + "video": { + "provider": _named_provider(video, "Video", {"maestro", "local"}), + "model": _text(video.get("model"), "Video model"), + "settings": _video_settings(video.get("settings")), + }, + } + + +def production_profile_response() -> dict[str, Any]: + raw = core.load_config().get(PROFILE_KEY) + try: + return {"configured": raw is not None, "profile": normalize_production_profile(raw or CORE_DEFAULT_PROFILE)} + except ValueError: + return {"configured": False, "profile": dict(CORE_DEFAULT_PROFILE)} + + +def save_production_profile(profile: dict[str, Any]) -> dict[str, Any]: + normalized = normalize_production_profile(profile) + data = core.load_config() + data[PROFILE_KEY] = normalized + services = data.setdefault("services", {}) + services["llm_provider"] = normalized["text"]["provider"] + services["llm_model_id"] = normalized["text"]["model"] + if normalized["text"].get("base_url"): + services["llm_remote_url"] = normalized["text"]["base_url"] + core.save_config(data) + return {"configured": True, "profile": normalized} + + +def effective_llm_routing(services: dict | None = None) -> tuple[str, str, str]: + values = services if isinstance(services, dict) else core.services_raw() + text = production_profile_response()["profile"].get("text", {}) + remote_url = str(text.get("base_url") or values.get("llm_remote_url") or "").strip() + provider = alias_text_provider(str(text.get("provider") or "minimax").strip().lower(), remote_url) + model = str(text.get("model") or "").strip() + return provider, model, default_url_for_provider(provider, remote_url) + + +def llm_provider_credentials(provider: str, services: dict, remote_url: str = "") -> tuple[str, str]: + api_key = "" + if provider == "openai": + api_key = str(services.get("openai_api_key") or "") + elif provider == "anthropic": + api_key = str(services.get("anthropic_api_key") or "") + elif provider == "minimax": + api_key = resolve_minimax_key(services, "llm") + elif provider == "grok": + api_key = str(services.get("grok_api_key") or "") + elif provider == "deepseek": + api_key = str(services.get("deepseek_api_key") or "") + return api_key, default_url_for_provider(provider, remote_url) + + +def ensure_llm_loaded() -> None: + from routers.system_capabilities import require_capability_http + from services import llm_service + + services = core.services_raw() + provider, model, remote_url = effective_llm_routing(services) + if provider == "local": + require_capability_http("local_llm") + api_key, remote_url = llm_provider_credentials(provider, services, remote_url) + desired = model or "MiniMax-M3" + if llm_service.is_loaded(): + status = llm_service.get_status() + remote_changed = ( + provider in {"remote", "ollama", "openai", "minimax", "grok", "anthropic", "deepseek"} + and status.get("remote_url", "") != remote_url + ) + if ( + status.get("model_id") != desired + or status.get("provider") != provider + or remote_changed + ): + llm_service.unload_model() + llm_service.load_model( + model_id=desired, device="cpu", provider=provider, + remote_url=remote_url, api_key=api_key, + ) + return + llm_service.load_model( + model_id=desired, device="cpu", provider=provider, + remote_url=remote_url, api_key=api_key, + ) + + +def comic_writing_llm(body: dict) -> dict | None: + try: + return resolve_writing_override( + provider=str(body.get("writingProvider") or "maestro"), + model=str(body.get("writingModel") or ""), + requested_url=str(body.get("writingBaseUrl") or ""), + services=core.services_raw(), + mode=str(body.get("mode") or ""), + ) + except ValueError as exc: + from fastapi import HTTPException + raise HTTPException(status_code=400, detail=str(exc)) from exc + + +def resolve_visual_media(value: str, workspace: str | None) -> str: + from services import core_editor + return core_editor.resolve_media(str(value or ""), workspace) diff --git a/app/services/core_remote_3d.py b/app/services/core_remote_3d.py new file mode 100644 index 000000000..126a28095 --- /dev/null +++ b/app/services/core_remote_3d.py @@ -0,0 +1,170 @@ +"""Remote Meshy/Hi3D jobs for the core/remote profile. No Hunyuan or Torch.""" +from __future__ import annotations + +import threading +import time +import uuid +from typing import Any + +from services import core_editor, core_workspace as core +from services.provider_profile import alias_model3d_provider + +_JOBS: dict[str, dict[str, Any]] = {} +_LOCK = threading.Lock() + + +def _public(job: dict[str, Any]) -> dict[str, Any]: + return {key: value for key, value in job.items() if not str(key).startswith("_")} + + +def get_job(job_id: str) -> dict[str, Any] | None: + with _LOCK: + job = _JOBS.get(job_id) + return _public(dict(job)) if job else None + + +def cancel_job(job_id: str) -> dict[str, Any] | None: + with _LOCK: + job = _JOBS.get(job_id) + if not job: + return None + if job.get("status") in {"completed", "failed", "cancelled"}: + return _public(dict(job)) + job["_cancel_requested"] = True + if job.get("status") in {"queued", "waiting_resource"}: + job["status"] = "cancelled" + job["phase"] = "cancelled" + job["message"] = "Cancelled" + else: + job["status"] = "cancelling" + job["phase"] = "cancelling" + job["message"] = "Cancellation requested" + return _public(dict(job)) + + +def capabilities() -> dict[str, Any]: + with _LOCK: + active = sum(1 for job in _JOBS.values() if job.get("status") in {"queued", "running", "cancelling"}) + return { + "runtime": {"installed": False, "isolated_runtime": False, "releases_vram_after_job": True, "install_hint": None}, + "models": [], + "presets": [], + "texture_modes": [], + "input_views": ["front", "left", "right", "back"], + "output_formats": ["glb"], + "active_jobs": active, + "engines": [], + "remote": ["meshy", "hi3d"], + } + + +def _resolve_image(value: str, workspace: str | None) -> str: + return core_editor.resolve_media(str(value or ""), workspace) + + +def _front_image(body: dict[str, Any], workspace: str) -> str | None: + images = body.get("images") if isinstance(body.get("images"), dict) else {} + if body.get("image_path") and not images.get("front"): + images = {**images, "front": body["image_path"]} + for view in ("front", "left", "right", "back"): + value = images.get(view) + if value: + return _resolve_image(str(value), workspace) + return None + + +def _patch(job_id: str, **fields: Any) -> None: + with _LOCK: + current = _JOBS.get(job_id) + if current: + current.update(fields) + + +def _remote_provider(body: dict[str, Any], profile: dict[str, Any]) -> str: + provider = alias_model3d_provider(str( + body.get("provider") or profile.get("model3d", {}).get("provider") or "local", + ).strip().lower()) + if provider not in {"meshy", "hi3d"}: + from routers.system_capabilities import require_capability_http + require_capability_http("hunyuan3d_local") + raise RuntimeError("local Hunyuan3D is not available") + return provider + + +def start_job(body: dict[str, Any], *, workspace: str, output_dir: str) -> dict[str, Any]: + from services import execution_mode + from services.core_production import production_profile_response + + profile = production_profile_response()["profile"] + provider = _remote_provider(body, profile) + image_path = _front_image(body, workspace) + prompt = str((body.get("prompt") or "")).strip() + if provider == "hi3d" and not image_path: + raise ValueError("Hi3D needs a reference image") + if provider == "meshy" and not image_path and not prompt: + raise ValueError("A prompt or a reference image is required") + execution_mode.validate_remote_provider(workspace, provider) + job_id = uuid.uuid4().hex + model_id = str(body.get("model_id") or profile.get("model3d", {}).get("model") or provider) + job = { + "job_id": job_id, "task_id": job_id, "root_task_id": job_id, + "status": "queued", "progress": 0.0, "phase": "queued", + "message": f"Queued {provider} generation", "error": None, + "filename": None, "url": None, "operation": "generate", + "model_id": model_id, "provider": provider, "workspace": workspace, + "created_at": time.time(), "updated_at": time.time(), + "_cancel_requested": False, + "_request": {"prompt": prompt, "image_path": image_path, "model": model_id, "output_dir": output_dir}, + } + with _LOCK: + _JOBS[job_id] = job + initial = _public(dict(job)) + threading.Thread(target=_run, args=(job_id,), daemon=True, name=f"core-3d-{job_id[:8]}").start() + return initial + + +def _call_provider(provider: str, request: dict[str, Any], stem: str, cancelled) -> dict[str, Any]: + services = core.services_raw() + output_dir = str(request.get("output_dir") or core.workspace_dir()) + if provider == "meshy": + from services.meshy_3d_service import generate_model as generate_meshy + return generate_meshy( + api_key=str(services.get("meshy_api_key") or ""), + output_dir=output_dir, prompt=str(request.get("prompt") or ""), + image_path=request.get("image_path"), model=str(request.get("model") or "latest"), + cancelled=cancelled, filename_stem=stem, + ) + from services.hi3d_service import generate_model as generate_hi3d + image_path = request.get("image_path") + if not image_path: + raise RuntimeError("Hi3D needs a reference image") + return generate_hi3d( + api_key=str(services.get("hi3d_api_key") or ""), image_path=str(image_path), + output_dir=output_dir, model=str(request.get("model") or "hitem3dv2.1"), + cancelled=cancelled, filename_stem=stem, + ) + + +def _run(job_id: str) -> None: + def cancelled() -> bool: + with _LOCK: + job = _JOBS.get(job_id) or {} + return bool(job.get("_cancel_requested")) or job.get("status") in {"cancelling", "cancelled"} + + with _LOCK: + job = dict(_JOBS.get(job_id) or {}) + provider = str(job.get("provider") or "") + try: + _patch(job_id, status="running", phase="running", progress=0.1, message=f"Calling {provider}") + if cancelled(): + _patch(job_id, status="cancelled", phase="cancelled", message="Cancelled") + return + result = _call_provider(provider, dict(job.get("_request") or {}), f"{provider}-{job_id[:8]}", cancelled) + filename = result["filename"] + _patch(job_id, status="completed", phase="completed", progress=1.0, + message="3D model ready", filename=filename, url=f"/api/v1/file/{filename}") + except Exception as exc: + if cancelled(): + _patch(job_id, status="cancelled", phase="cancelled", message="Cancelled") + return + _patch(job_id, status="failed", phase="failed", message=str(exc), error=str(exc)) diff --git a/app/services/core_remote_image.py b/app/services/core_remote_image.py new file mode 100644 index 000000000..3f42ab877 --- /dev/null +++ b/app/services/core_remote_image.py @@ -0,0 +1,249 @@ +"""MiniMax Image-01 jobs for Studio generate on the core/remote profile.""" +from __future__ import annotations + +import threading +import time +import uuid +from typing import Any + +from services import core_workspace as core +from services.minimax_image_service import ( + MiniMaxImageError, + SUPPORTED_ASPECT_RATIOS, + aspect_ratio_for_resolution, + generate_image, + local_image_data_uri, + prepare_prompt, +) +from services.provider_profile import resolve_minimax_key +from services.wangp_submission import resolve_wangp_media + +MODEL_ID = "minimax:image-01" +_JOBS: dict[str, dict[str, Any]] = {} +_LOCK = threading.Lock() +_ACTIVE = {"queued", "waiting_resource", "running", "cancelling"} + + +def catalog_entry() -> dict[str, Any]: + return { + "model_type": MODEL_ID, + "name": "MiniMax Image-01", + "description": "Remote MiniMax Image-01. Requires an API key in Settings → Services.", + "family": "minimax", + "architecture": "minimax_image", + "resource_requirements": {"tier": "remote", "backend": "minimax", "note": "Remote API"}, + "is_i2v": False, + "is_t2v": False, + "guidance_max_phases": 1, + "fps": 0, + "supports_end_frame": False, + "supports_audio": False, + "supports_ref_images": True, + "is_downloaded": True, + "nsfw_only": False, + "director": { + "image": {"compatible": True, "reason": ""}, + "video": {}, + "supports_audio_input": False, + "generates_audio": False, + "supports_voice_reference": False, + "max_image_refs": 1, + }, + } + + +def model_options() -> dict[str, Any]: + return { + "model_type": MODEL_ID, + "architecture": "minimax_image", + "guidance_max_phases": 1, + "lock_guidance_phases": True, + "sliding_window": False, + "motion_amplitude": False, + "flow_shift": False, + "tea_cache": False, + "returns_audio": False, + "any_audio_prompt": False, + "audio_scale_name": "", + "lock_inference_steps": True, + "lock_guidance_scale": True, + "no_negative_prompt": True, + "i2v_class": False, + "t2v_class": False, + "image_outputs": True, + "supports_end_frame": False, + "fps": 0, + } + + +def defaults() -> dict[str, Any]: + return {"prompt": "", "resolution": "1024x1024", "image_mode": 1, "generation_mode": "image"} + + +def is_minimax_image_request(body: dict[str, Any]) -> bool: + model = str(body.get("model_type") or "") + if model.startswith("minimax:"): + return True + if str(body.get("generation_mode") or "") != "image": + return False + from services.core_production import production_profile_response + return production_profile_response()["profile"].get("image", {}).get("provider") == "minimax" + + +def _public(job: dict[str, Any]) -> dict[str, Any]: + 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), + "total_steps": job.get("total_steps", 1), + "phase": job.get("phase", ""), + "message": job["message"], + "output_files": list(job.get("output_files") or []), + "error": job.get("error"), + "created_at": job.get("created_at"), + "started_at": job.get("started_at"), + "finished_at": job.get("finished_at"), + "generation_details": {"model_type": MODEL_ID, "generation_mode": "image"}, + } + + +def get_job(job_id: str) -> dict[str, Any] | None: + with _LOCK: + job = _JOBS.get(job_id) + return _public(job) if job else None + + +def list_active() -> list[dict[str, Any]]: + with _LOCK: + return [_public(job) for job in _JOBS.values() if job.get("status") in _ACTIVE] + + +def cancel_job(job_id: str) -> dict[str, Any] | None: + with _LOCK: + job = _JOBS.get(job_id) + if not job: + return None + if job["status"] not in _ACTIVE: + return _public(job) + job["_cancel_requested"] = True + if job["status"] == "queued": + job.update(status="cancelled", phase="cancelled", message="Cancelled", finished_at=time.time()) + else: + job.update(status="cancelling", phase="cancelling", message="Cancellation requested") + result = _public(job) + notify = job.get("_on_update") + if notify: + notify() + return result + + +def _patch(job_id: str, **fields: Any) -> None: + with _LOCK: + job = _JOBS.get(job_id) + if job: + job.update(fields) + notify = job.get("_on_update") if job else None + if notify: + notify() + + +def restore_job(task: dict[str, Any]) -> None: + """Restore polling from a durable task without invoking a provider.""" + job_id = task["backend_job_id"] + with _LOCK: + _JOBS.setdefault(job_id, { + "id": job_id, "task_id": task["id"], "root_task_id": task.get("root_id"), + "status": task["status"], "phase": task["status"], + "progress": 100 if task["status"] == "completed" else 0, + "message": task.get("message") or "", "error": task.get("error"), + "output_files": list(task.get("result_refs") or []), + "created_at": task.get("created_at"), "workspace": task.get("workspace"), + }) + + +def encode_subject_reference(source: str, workspace: str) -> str: + """Encode a Studio upload/file URL the same way Comic/Director feed Image-01.""" + value = str(source or "").strip() + if not value: + return "" + if value.startswith("data:image/"): + if len(value) > 25 * 1024 * 1024: + raise MiniMaxImageError("MiniMax identity reference is too large", 413) + return value + try: + path = resolve_wangp_media( + value, + workspace, + uploads_dir=core.uploads_dir(), + workspace_dir=core.workspace_dir(workspace), + ) + except ValueError as error: + raise MiniMaxImageError("MiniMax identity reference is unavailable", 400) from error + return local_image_data_uri(path) + + +def start_job(body: dict[str, Any], *, workspace: str, job_id: str | None = None, on_update=None) -> dict[str, Any]: + from services import execution_mode + + prompt = prepare_prompt(str(body.get("prompt") or "")) + ratio = str(body.get("aspect_ratio") or "") + if ratio not in SUPPORTED_ASPECT_RATIOS: + ratio = aspect_ratio_for_resolution(str(body.get("resolution") or "1024x1024")) + execution_mode.validate_remote_provider(workspace, "minimax-image") + subject = encode_subject_reference(str(body.get("subject_reference") or ""), workspace) + job_id = str(job_id or "").strip() or uuid.uuid4().hex + now = time.time() + with _LOCK: + existing = _JOBS.get(job_id) + if existing is not None: + return _public(existing) + job = { + "id": job_id, "task_id": job_id, "root_task_id": job_id, + "status": "queued", "progress": 0, "step": 0, "total_steps": 1, + "phase": "queued", "message": "MiniMax image request queued", + "output_files": [], "error": None, "workspace": workspace, + "created_at": now, "started_at": None, "finished_at": None, + "_cancel_requested": False, + "_on_update": on_update, + "request": {"prompt": prompt, "aspect_ratio": ratio, "subject_reference": subject}, + } + _JOBS[job_id] = job + initial = _public(job) + threading.Thread(target=_run, args=(job_id,), daemon=True, name=f"minimax-image-{job_id[:8]}").start() + return initial + + +def _run(job_id: str) -> None: + with _LOCK: + job = dict(_JOBS.get(job_id) or {}) + request = job.get("request") or {} + workspace = str(job.get("workspace") or "default") + try: + _patch(job_id, status="running", phase="running", progress=10, started_at=time.time(), + message="Calling MiniMax Image-01") + with _LOCK: + cancelled = (_JOBS.get(job_id) or {}).get("_cancel_requested") + if cancelled: + _patch(job_id, status="cancelled", phase="cancelled", message="Cancelled", finished_at=time.time()) + return + result = generate_image( + api_key=resolve_minimax_key(core.services_raw(), "image"), + prompt=str(request.get("prompt") or ""), + aspect_ratio=str(request.get("aspect_ratio") or "1:1"), + output_dir=core.workspace_dir(workspace), + subject_reference=str(request.get("subject_reference") or ""), + filename_prefix="minimax-image-01", + task_id=job_id, + root_task_id=job_id, + ) + _patch( + job_id, status="completed", phase="completed", progress=100, step=1, + message="Image ready", output_files=[result["name"]], finished_at=time.time(), + ) + except MiniMaxImageError as exc: + _patch(job_id, status="failed", phase="failed", message=str(exc), error=str(exc), finished_at=time.time()) + except Exception as exc: + _patch(job_id, status="failed", phase="failed", message=str(exc), error=str(exc), finished_at=time.time()) diff --git a/app/services/core_remote_music.py b/app/services/core_remote_music.py new file mode 100644 index 000000000..344ac343f --- /dev/null +++ b/app/services/core_remote_music.py @@ -0,0 +1,145 @@ +"""MiniMax Music jobs for the core/remote profile. Local ACE-Step stays 409.""" +from __future__ import annotations + +import copy +import threading +import time +import uuid +from typing import Any + +from services import core_workspace as core +from services.minimax_music_service import ALLOWED_MODELS, COVER_MODELS, MiniMaxMusicError, generate_candidates +from services.music_submission import classify_music_route +from services.provider_profile import resolve_minimax_key + +_JOBS: dict[str, dict[str, Any]] = {} +_LOCK = threading.Lock() +_TERMINAL = {"completed", "failed", "cancelled"} + + +def _public(job: dict[str, Any]) -> dict[str, Any]: + return copy.deepcopy({key: value for key, value in job.items() if key not in {"request", "_cancel_requested"}}) + + +def get_job(job_id: str) -> dict[str, Any] | None: + with _LOCK: + job = _JOBS.get(job_id) + return _public(job) if job else None + + +def cancel_job(job_id: str) -> dict[str, Any] | None: + with _LOCK: + job = _JOBS.get(job_id) + if not job: + return None + if str(job.get("status") or "") in _TERMINAL: + return _public(job) + job["_cancel_requested"] = True + if str(job.get("status") or "") in {"queued", "waiting_resource"}: + job.update(status="cancelled", phase="cancelled", message="Cancelled before the provider call") + else: + job.update(status="cancelling", phase="cancelling", message="Cancellation requested") + return _public(job) + + +def _patch(job_id: str, **fields: Any) -> None: + with _LOCK: + current = _JOBS.get(job_id) + if current: + current.update(fields) + + +def _candidate_count(body: dict[str, Any]) -> int: + try: + return max(1, min(3, int(body.get("count") or 2))) + except (TypeError, ValueError, OverflowError) as exc: + raise ValueError("MiniMax Music candidate count must be an integer from 1 to 3") from exc + + +def _validated_request(body: dict[str, Any], workspace: str) -> dict[str, Any]: + from routers.system_capabilities import require_capability_http + from services import execution_mode + + model = str(body.get("model") or "music-3.0").strip() + try: + route = classify_music_route(model) + except Exception as exc: + raise ValueError(str(exc)) from exc + if route != "remote_minimax": + require_capability_http("local_audio_ai") + if model not in ALLOWED_MODELS: + raise ValueError(f"Unsupported MiniMax Music model: {model}") + execution_mode.validate_remote_provider(workspace, "minimax-music") + count = _candidate_count(body) + 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 ValueError("A music style prompt is required") + if model not in COVER_MODELS and not instrumental and not lyrics: + raise ValueError("Lyrics are required for a vocal song") + reference_audio_path = None + if model in COVER_MODELS: + from services import core_editor + reference_audio_path = core_editor.resolve_media(str(body.get("reference_audio_filename") or ""), workspace) + return { + "prompt": prompt, "lyrics": lyrics, "instrumental": instrumental, + "model": model, "reference_audio_path": reference_audio_path, "count": count, + } + + +def start_job(body: dict[str, Any], *, workspace: str) -> dict[str, Any]: + request = _validated_request(body, workspace) + job_id = f"minimax-music-{uuid.uuid4().hex[:12]}" + now = time.time() + job = { + "jobId": job_id, "taskId": f"task-{job_id}", "rootTaskId": f"task-{job_id}", + "workspace": workspace, "status": "queued", "phase": "queued", + "message": f"{request['count']} MiniMax Music candidate(s) queued", + "current": 0, "total": request["count"], "progress": 0, + "provider": "minimax", "model": request["model"], "candidates": [], + "result": None, "error": None, "createdAt": now, "updatedAt": now, + "_cancel_requested": False, "request": request, + } + with _LOCK: + _JOBS[job_id] = job + initial = _public(job) + threading.Thread(target=_run, args=(job_id,), daemon=True, name=job_id).start() + return initial + + +def _fail(job_id: str, message: str, **extra: Any) -> None: + _patch(job_id, status="failed", phase="failed", message=message, error=message, **extra) + + +def _run(job_id: str) -> None: + def cancelled() -> bool: + with _LOCK: + return bool((_JOBS.get(job_id) or {}).get("_cancel_requested")) + + with _LOCK: + job = copy.deepcopy(_JOBS.get(job_id) or {}) + request = job.get("request") or {} + try: + _patch(job_id, status="running", phase="running", message="Calling MiniMax Music") + results = generate_candidates( + api_key=resolve_minimax_key(core.services_raw(), "music"), + prompt=str(request.get("prompt") or ""), lyrics=str(request.get("lyrics") or ""), + count=int(request.get("count") or 1), + output_dir=core.workspace_dir(str(job.get("workspace") or "default")), + instrumental=bool(request.get("instrumental")), + model=str(request.get("model") or "music-3.0"), + reference_audio_path=request.get("reference_audio_path"), + task_id=str(job.get("taskId") or ""), root_task_id=str(job.get("rootTaskId") or ""), + cancelled=cancelled, + ) + _patch( + job_id, status="completed", phase="completed", progress=100, + current=len(results), total=len(results), + message=f"Generated {len(results)} MiniMax Music candidate(s)", + candidates=results, result={"candidates": results}, + ) + except MiniMaxMusicError as exc: + _fail(job_id, str(exc), statusCode=exc.status_code) + except Exception as exc: + _fail(job_id, str(exc)) diff --git a/app/services/core_scene_recording.py b/app/services/core_scene_recording.py new file mode 100644 index 000000000..a6d1226af --- /dev/null +++ b/app/services/core_scene_recording.py @@ -0,0 +1,237 @@ +"""Publish Scene Animator / Video3D recordings on the core/remote profile. + +The UI often sends a silent canvas/WebCodecs capture plus a separate WAV mix +when the browser cannot encode AAC. The NVIDIA runtime muxes that audio into a +unique H.264 MP4. The core profile must do the same or voiced exports are +silently published without a soundtrack and a second take overwrites the first. +""" +from __future__ import annotations + +import json +import os +import re +import time +import uuid +from typing import Any +from urllib.parse import quote + +from services import core_workspace as core +from services.asset_manifest import publish_generation_sidecar +from services.scene_recording import SceneRecordingTranscodeError, transcode_scene_recording +from services.upload_stream import UploadTooLargeError, stream_upload_file + +MAX_RECORDING_BYTES = 500 * 1024 * 1024 +MAX_AUDIO_BYTES = 32 * 1024 * 1024 +MAX_METADATA_BYTES = 8 * 1024 * 1024 + + +def parse_recording_metadata(raw: object) -> dict[str, Any]: + text = raw if isinstance(raw, str) else "{}" + if len(text.encode("utf-8")) > MAX_METADATA_BYTES: + raise ValueError("Scene recording metadata is too large") + try: + details = json.loads(text) + except json.JSONDecodeError as error: + raise ValueError("Invalid scene recording metadata") from error + if not isinstance(details, dict): + raise ValueError("Scene recording metadata must be an object") + scene = details.get("scene") + recipe = details.get("recipe") + prompt = details.get("prompt", "") + if not isinstance(scene, dict) or scene.get("version") != 1: + raise ValueError("A version 1 scene is required") + if recipe is not None and not isinstance(recipe, dict): + raise ValueError("Scene recipe must be an object") + if not isinstance(prompt, str) or len(prompt) > 200_000: + raise ValueError("Scene prompt must be text under 200,000 characters") + layers = scene.get("layers") + if not isinstance(layers, list) or len(layers) > 500: + raise ValueError("Scene layers must be a list of at most 500 items") + return details + + +def collect_scene_audio_tracks(scene: dict[str, Any], out_dir: str) -> list[dict[str, Any]]: + raw_audio_tracks = scene.get("audioTracks") or [] + if not isinstance(raw_audio_tracks, list) or len(raw_audio_tracks) > 8: + raise ValueError("Scene audio tracks must be a list of at most 8 items") + audio_tracks: list[dict[str, Any]] = [] + for index, raw_track in enumerate(raw_audio_tracks): + if not isinstance(raw_track, dict): + raise ValueError(f"Scene audio track {index + 1} is invalid") + filename = str(raw_track.get("filename") or "").strip() + if not filename or os.path.basename(filename) != filename: + raise ValueError(f"Scene audio track {index + 1} has an invalid filename") + audio_path = core.safe_join(out_dir, filename) + if not audio_path or not os.path.isfile(audio_path): + raise ValueError(f"Scene audio track {index + 1} was not found in this workspace") + try: + start_time = max(0.0, min(3600.0, float(raw_track.get("startTime", 0)))) + volume = max(0.0, min(2.0, float(raw_track.get("volume", 1)))) + except (TypeError, ValueError) as error: + raise ValueError(f"Scene audio track {index + 1} has invalid timing") from error + audio_tracks.append({"path": audio_path, "start_time": start_time, "volume": volume}) + return audio_tracks + + +def recording_output_name(scene: dict[str, Any]) -> str: + raw_name = str(scene.get("name") or "3D scene").strip() + safe_name = re.sub(r"[^A-Za-z0-9._-]+", "-", raw_name).strip("-._")[:80] or "3d-scene" + stamp = time.strftime("%Y-%m-%d-%Hh%Mm%Ss") + return f"{stamp}_{safe_name}_3d_{uuid.uuid4().hex[:6]}.mp4" + + +def finalize_scene_recording( + *, + source_path: str, + output_dir: str, + scene: dict[str, Any], + recipe: dict[str, Any] | None, + prompt: str, + embedded_audio: bool, + extra_audio_path: str | None, + workspace: str | None, + started_at: float | None = None, +) -> dict[str, Any]: + audio_tracks = collect_scene_audio_tracks(scene, output_dir) + if extra_audio_path: + audio_tracks.append({"path": extra_audio_path, "start_time": 0, "volume": 1}) + output_name = recording_output_name(scene) + output_path = os.path.join(output_dir, output_name) + fps = 60 if scene.get("fps") == 60 else 30 + begun = started_at if started_at is not None else time.time() + transcode_scene_recording( + source_path, + output_path, + fps=fps, + audio_tracks=audio_tracks, + duration=float(scene.get("duration") or 0), + embedded_audio=embedded_audio, + ) + completed_at = time.time() + width = int(scene.get("width") or 0) + height = int(scene.get("height") or 0) + duration = float(scene.get("duration") or 0) + source_assets = [] + if isinstance(recipe, dict): + source_assets = recipe.get("assets") if isinstance(recipe.get("assets"), list) else [] + if not source_assets: + layers = scene.get("layers") if isinstance(scene.get("layers"), list) else [] + source_assets = [ + { + "id": layer.get("id"), + "name": layer.get("name"), + "kind": layer.get("type"), + "source": layer.get("source"), + } + for layer in layers + if isinstance(layer, dict) and layer.get("source") + ] + sidecar = { + "params": { + "model_type": "scene-animator-3d", + "generation_mode": "3d-scene-compositor", + "prompt": prompt, + "original_prompt": prompt, + "scene_recipe": recipe, + "scene": scene, + "source_assets": source_assets, + "audio_tracks": scene.get("audioTracks") or [], + "resolution": f"{width}x{height}" if width and height else None, + "width": width, + "height": height, + "fps": fps, + "duration_seconds": duration, + "video_length": round(duration * fps) if duration > 0 else None, + }, + "generation_mode": "video", + "tool": "scene-animator-3d", + "generation_time": round(completed_at - begun, 3), + "created_at": completed_at, + "completed_at": completed_at, + "output_filename": output_name, + } + try: + publish_generation_sidecar( + output_path, + sidecar, + workspace_id=workspace, + tool="scene-animator-3d", + ) + except Exception: + try: + os.remove(output_path) + except OSError: + pass + raise + suffix = f"?workspace={workspace}" if workspace else "" + return { + "name": output_name, + "type": "video", + "mode": "video", + "size": os.path.getsize(output_path), + "created_at": completed_at, + "completed_at": completed_at, + "url": f"/api/v1/file/{output_name}{suffix}", + "thumbnail_url": f"/api/v1/outputs/thumbnail/{quote(output_name, safe='')}{suffix}", + } + + +async def publish_from_form(form: Any) -> dict[str, Any]: + upload = form.get("file") + if upload is None or not hasattr(upload, "read"): + raise ValueError("A recording file is required") + details = parse_recording_metadata(form.get("metadata") or "{}") + scene = details["scene"] + recipe = details.get("recipe") if isinstance(details.get("recipe"), dict) else None + prompt = str(details.get("prompt") or "") + workspace = str(details.get("workspace") or "").strip() or None + folder = core.workspace_dir(workspace) + os.makedirs(folder, exist_ok=True) + upload_path = os.path.join(folder, f".{uuid.uuid4().hex}.scene-recording.webm") + upload_audio_path = os.path.join(folder, f".{uuid.uuid4().hex}.scene-audio.wav") + audio = form.get("audio") + started_at = time.time() + wrote_audio = False + try: + await stream_upload_file(upload, upload_path, max_bytes=MAX_RECORDING_BYTES) + if audio is not None and hasattr(audio, "read"): + await stream_upload_file(audio, upload_audio_path, max_bytes=MAX_AUDIO_BYTES) + wrote_audio = True + return finalize_scene_recording( + source_path=upload_path, + output_dir=folder, + scene=scene, + recipe=recipe, + prompt=prompt, + embedded_audio=details.get("embeddedAudio") is True, + extra_audio_path=upload_audio_path if wrote_audio else None, + workspace=workspace, + started_at=started_at, + ) + finally: + for path in (upload_audio_path, upload_path): + try: + if os.path.isfile(path): + os.remove(path) + except OSError: + pass + + +def http_error_status(error: Exception) -> int: + if isinstance(error, UploadTooLargeError): + return 413 + if isinstance(error, SceneRecordingTranscodeError): + return 400 + if isinstance(error, ValueError): + return 400 + return 500 + + +def http_error_detail(error: Exception) -> str: + if isinstance(error, UploadTooLargeError): + return "Recording is too large (max 500 MB)" + if isinstance(error, SceneRecordingTranscodeError): + return f"Could not convert recording to MP4: {error}" + if isinstance(error, ValueError): + return str(error) + return f"Could not save MP4 recording: {error}" diff --git a/app/services/core_series_assembly.py b/app/services/core_series_assembly.py new file mode 100644 index 000000000..8e666bbc6 --- /dev/null +++ b/app/services/core_series_assembly.py @@ -0,0 +1,110 @@ +"""Series episode assembly adapters for the core/remote profile. + +NVIDIA joins approved clips with WanGP's FFmpeg helper. Core keeps the same +HTTP contract and concatenates with FFmpeg, without Torch. + +Do not use the concat demuxer (``-f concat``) with ``-c copy``: mismatched +codecs, timebases or audio layouts make ffmpeg report success while dropping +later clips. The NVIDIA helper documents that failure mode and uses the concat +filter instead. +""" +from __future__ import annotations + +import os +import shutil +from typing import Any, Callable + +from services import core_editor, core_workspace as core +from services.mix_concat import ( + _run_ffmpeg_command, + build_hard_concat_filter, + concat_with_tail_hold_and_crossfade, + probe_audio_flags, + probe_duration_seconds, + should_use_hold_crossfade, +) + + +def asset_local_path(workspace: str, asset: dict[str, Any]) -> str: + uri = str(asset.get("uri") or "") + if uri.startswith("https://"): + raise ValueError( + f"Remote Series asset {asset.get('id')} must be imported into the workspace before assembly" + ) + folder = os.path.realpath(core.workspace_dir(workspace)) + relative = uri[len("outputs/"):] if uri.startswith("outputs/") else uri + candidate = os.path.realpath(os.path.join(folder, relative)) + if candidate != folder and not candidate.startswith(folder + os.sep): + raise ValueError(f"Series asset {asset.get('id')} leaves its workspace") + if not os.path.isfile(candidate): + raise ValueError(f"Series reference file is missing: {uri}") + return candidate + + +def available_filename(directory: str, name: str) -> str: + _filename, destination = core_editor.unique_output_name(directory, name) + return destination + + +def _ffmpeg_bin() -> str | None: + return os.environ.get("FFMPEG_BINARY") or shutil.which("ffmpeg") + + +def _hard_concat_filter( + files: list[str], + output_path: str, + *, + abort_callback: Callable[[], bool] | None = None, +) -> bool: + ffmpeg = _ffmpeg_bin() + if not ffmpeg: + return False + audio_flags = probe_audio_flags(files, ffmpeg) + silent_durations = None + if audio_flags and any(audio_flags) and not all(audio_flags): + silent_durations = [probe_duration_seconds(path, ffmpeg) or 1.0 for path in files] + filter_str, maps_audio = build_hard_concat_filter( + len(files), + audio_flags=audio_flags if any(audio_flags) else None, + silent_durations=silent_durations, + ) + cmd = [ffmpeg, "-y"] + for path in files: + cmd += ["-i", path.replace("\\", "/")] + cmd += ["-filter_complex", filter_str, "-map", "[outv]"] + if maps_audio: + cmd += ["-map", "[outa]", "-c:a", "aac"] + cmd += [ + "-c:v", "libx264", "-crf", "18", "-preset", "fast", + "-pix_fmt", "yuv420p", "-movflags", "+faststart", + os.path.abspath(output_path).replace("\\", "/"), + ] + return _run_ffmpeg_command(cmd, output_path, abort_callback=abort_callback) + + +def concatenate_clips( + paths: list[str], + output_path: str, + *, + abort_callback: Callable[[], bool] | None = None, +) -> bool: + if abort_callback and abort_callback(): + return False + files = [str(path) for path in paths] + if not files or any( + not path or not os.path.isfile(path) or os.path.getsize(path) == 0 + for path in files + ): + return False + os.makedirs(os.path.dirname(os.path.abspath(output_path)) or ".", exist_ok=True) + if len(files) == 1: + shutil.copyfile(files[0], output_path) + return os.path.isfile(output_path) and os.path.getsize(output_path) > 0 + if should_use_hold_crossfade(len(files)): + if concat_with_tail_hold_and_crossfade( + files, output_path, abort_callback=abort_callback, + ): + return True + if abort_callback and abort_callback(): + return False + return _hard_concat_filter(files, output_path, abort_callback=abort_callback) diff --git a/app/services/core_series_plan.py b/app/services/core_series_plan.py new file mode 100644 index 000000000..210454f91 --- /dev/null +++ b/app/services/core_series_plan.py @@ -0,0 +1,442 @@ +"""Series Lab planning jobs on the core/remote profile. Uses the remote LLM.""" +from __future__ import annotations + +import copy +import json +import threading +import time +import uuid +from datetime import datetime, timezone +from typing import Any + +from services import core_workspace as core +from services.series_jobs import SeriesJobStore +from services.series_library import ( + read_series_library, + series_for_episode_snapshot, + write_series_library, +) +from services.series_planning import ( + apply_planning_stage, + canon_preparation_prompt, + canon_preparation_schema, + known_series_bootstrap_prompt, + known_series_bootstrap_schema, + merge_series_canon_proposal, + normalize_canon_preparation, + normalize_known_series_bootstrap, + normalize_planning_result, + planning_output_token_budget, + planning_prompt, + planning_schema, + planning_stages, +) + +_LOCK = threading.Lock() +_JOBS: dict[str, dict[str, Any]] = {} +_ACTIVE: set[str] = set() +_PUBLIC = ( + "jobId", "jobType", "kind", "workspace", "seriesId", "episodeId", + "status", "stage", "current", "total", "message", "completedStages", + "episodeResult", "seriesResult", "generateImages", "bootstrapKnownSeries", + "autoApply", "autoApplied", "appliedSeriesRevision", "applyError", + "result", "error", "createdAt", "updatedAt", + "finishedAt", "appliedAt", "taskId", "rootTaskId", +) + + +def _iso_now() -> str: + return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + + +def _store(workspace: str) -> SeriesJobStore: + return SeriesJobStore(core.workspace_dir(workspace), "planning") + + +def _public(job: dict[str, Any]) -> dict[str, Any]: + return {key: copy.deepcopy(job.get(key)) for key in _PUBLIC if key in job} + + +def _patch(job_id: str, **fields: Any) -> dict[str, Any] | None: + with _LOCK: + job = _JOBS.get(job_id) + if not job: + return None + job.update(fields) + job["updatedAt"] = time.time() + snapshot = copy.deepcopy(job) + _store(str(job["workspace"])).save(snapshot) + return snapshot + + +def load_job(job_id: str) -> dict[str, Any] | None: + with _LOCK: + cached = _JOBS.get(job_id) + if cached: + return copy.deepcopy(cached) + for workspace in [row["name"] for row in core.list_workspaces()]: + try: + job = _store(workspace).load(job_id) + except ValueError: + continue + if job: + with _LOCK: + _JOBS[job_id] = job + return copy.deepcopy(job) + return None + + +def _parse_json(raw: str) -> dict[str, Any]: + text = str(raw or "").strip() + if text.startswith("```"): + text = text.strip("`") + if text.lower().startswith("json"): + text = text[4:].lstrip() + start, end = text.find("{"), text.rfind("}") + if start < 0 or end <= start: + raise ValueError("The writing model did not return a JSON object") + payload = json.loads(text[start:end + 1]) + if not isinstance(payload, dict): + raise ValueError("The writing model did not return a JSON object") + return payload + + +def _generate_json(*, prompt: str, system_prompt: str, schema: dict, max_new_tokens: int, override: dict | None) -> dict: + from services import llm_service + from services.core_production import ensure_llm_loaded + + arguments = dict( + prompt=prompt, system_prompt=system_prompt, json_schema=schema, + max_new_tokens=max_new_tokens, temperature=0.2, + ) + if override: + raw = llm_service.generate_openai_compatible( + **arguments, model_id=override["model"], base_url=override["base_url"], api_key=override["api_key"], + ) + else: + ensure_llm_loaded() + raw = llm_service.generate(**arguments) + return _parse_json(raw) + + +def _writing_override(request: dict) -> dict | None: + from services.core_production import comic_writing_llm + return comic_writing_llm(request) + + +def _series_or_404(workspace: str, series_id: str) -> dict: + library = read_series_library(core.workspace_dir(workspace), workspace) + series = library.get("seriesById", {}).get(series_id) + if not isinstance(series, dict): + raise KeyError("Series Lab project not found") + return series, library + + +def _writing_fields(body: dict[str, Any], series: dict[str, Any]) -> dict[str, str]: + provider = series.get("provider") if isinstance(series.get("provider"), dict) else {} + return { + "writingProvider": str(body.get("writingProvider") or provider.get("writingProvider") or "maestro"), + "writingModel": str(body.get("writingModel") or provider.get("writingModel") or ""), + "writingBaseUrl": str(body.get("writingBaseUrl") or provider.get("writingBaseUrl") or ""), + } + + +def start_episode_plan(series_id: str, episode_id: str, body: dict[str, Any]) -> dict[str, Any]: + workspace = str(body.get("workspace") or core.active_workspace() or "default") + scope = str(body.get("scope") or "complete") + stages = planning_stages(scope) + with _LOCK: + series, _library = _series_or_404(workspace, series_id) + episode = series.get("episodesById", {}).get(episode_id) + if not isinstance(episode, dict): + raise KeyError("Series episode not found") + if not str(episode.get("premise") or body.get("instruction") or "").strip(): + raise ValueError("Write an episode premise or instruction first") + snapshot = series_for_episode_snapshot(series, episode) + snapshot.pop("assets", None) + snapshot["episodesById"] = {} + request = { + "scope": scope, + "instruction": str(body.get("instruction") or "")[:8000], + **_writing_fields(body, series), + "seriesSnapshot": snapshot, + "episodeSnapshot": copy.deepcopy(episode), + } + _writing_override(request) + job_id = f"series-plan-{uuid.uuid4().hex[:12]}" + now = time.time() + job = { + "jobId": job_id, "kind": "planning", "workspace": workspace, + "seriesId": series_id, "episodeId": episode_id, + "status": "queued", "stage": "queued", "current": 0, "total": len(stages), + "message": "Episode planning queued.", "request": request, + "sourceSeriesRevision": int(series.get("revision") or 1), + "sourceEpisodeUpdatedAt": episode.get("updatedAt"), + "completedStages": {}, "episodeResult": None, "result": None, "error": None, + "createdAt": now, "updatedAt": now, "taskId": f"task-{job_id}", "rootTaskId": f"task-{job_id}", + } + with _LOCK: + _JOBS[job_id] = job + _store(workspace).save(job) + threading.Thread(target=_run_episode, args=(job_id,), daemon=True).start() + return _public(job) + + +def start_canon_plan(series_id: str, body: dict[str, Any]) -> dict[str, Any]: + workspace = str(body.get("workspace") or core.active_workspace() or "default") + instruction = str(body.get("instruction") or "").strip()[:8000] + bootstrap = body.get("bootstrapKnownSeries") is True + if bootstrap and len(instruction) < 3: + raise ValueError("Describe the known series you want to continue") + with _LOCK: + series, _library = _series_or_404(workspace, series_id) + snapshot = copy.deepcopy(series) + snapshot.pop("assets", None) + snapshot["episodesById"] = {} + request = { + "instruction": instruction, + **_writing_fields(body, series), + "seriesSnapshot": snapshot, + "bootstrapKnownSeries": bootstrap, + "autoApply": bootstrap and body.get("autoApply") is not False, + } + _writing_override(request) + job_id = f"series-canon-{uuid.uuid4().hex[:12]}" + now = time.time() + job = { + "jobId": job_id, "jobType": "canon", "kind": "planning", + "workspace": workspace, "seriesId": series_id, "episodeId": "", + "status": "queued", "stage": "queued", "current": 0, "total": 1, + "message": "Canon preparation queued.", "request": request, + "sourceSeriesRevision": int(series.get("revision") or 1), + "seriesResult": None, "result": None, "error": None, + "bootstrapKnownSeries": bootstrap, "autoApply": request["autoApply"], "autoApplied": False, + "createdAt": now, "updatedAt": now, "taskId": f"task-{job_id}", "rootTaskId": f"task-{job_id}", + } + with _LOCK: + _JOBS[job_id] = job + _store(workspace).save(job) + threading.Thread(target=_run_canon, args=(job_id,), daemon=True).start() + return _public(job) + + +def cancel_job(job_id: str) -> dict[str, Any]: + job = load_job(job_id) + if not job: + raise KeyError("Series planning job not found") + if job.get("status") in {"completed", "failed", "cancelled"}: + return _public(job) + with _LOCK: + worker = job_id in _ACTIVE + updated = _patch( + job_id, + status="cancelling" if worker else "cancelled", + stage="cancelling" if worker else "cancelled", + finishedAt=None if worker else time.time(), + message="Series planning cancellation requested." if worker else "Series planning cancelled.", + ) + return _public(updated or job) + + +def resume_job(job_id: str) -> dict[str, Any]: + job = load_job(job_id) + if not job: + raise KeyError("Series planning job not found") + if job.get("status") == "completed": + return _public(job) + with _LOCK: + if job_id in _ACTIVE: + return _public(job) + _patch(job_id, status="queued", error=None, finishedAt=None, message="Resuming planning…") + target = _run_canon if job.get("jobType") == "canon" else _run_episode + threading.Thread(target=target, args=(job_id,), daemon=True).start() + return _public(load_job(job_id) or job) + + +def apply_episode(job_id: str, edited: dict | None) -> dict[str, Any]: + job = load_job(job_id) + if not job: + raise KeyError("Series planning job not found") + if job.get("jobType") == "canon": + raise ValueError("Use the canon proposal apply endpoint for this job") + if job.get("status") != "completed" or not isinstance(job.get("episodeResult"), dict): + raise ValueError("Complete the planning job before applying it") + workspace, series_id, episode_id = str(job["workspace"]), str(job["seriesId"]), str(job["episodeId"]) + with _LOCK: + series, library = _series_or_404(workspace, series_id) + current = series.get("episodesById", {}).get(episode_id) + if not isinstance(current, dict): + raise KeyError("Series episode not found") + if current.get("updatedAt") != job.get("sourceEpisodeUpdatedAt"): + raise PermissionError("The episode was edited after planning started") + proposed = copy.deepcopy(edited if isinstance(edited, dict) else job["episodeResult"]) + proposed["id"] = episode_id + proposed["updatedAt"] = _iso_now() + proposed["createdAt"] = current.get("createdAt") + series = copy.deepcopy(series) + series["episodesById"][episode_id] = proposed + series["revision"] = int(series.get("revision") or 1) + 1 + series["updatedAt"] = proposed["updatedAt"] + library["seriesById"][series_id] = series + stored = write_series_library(core.workspace_dir(workspace), library, workspace) + _patch(job_id, appliedAt=time.time(), message="Episode proposal applied for review.") + return stored["seriesById"][series_id]["episodesById"][episode_id] + + +def _commit_canon_proposal(job: dict[str, Any], proposal: dict[str, Any]) -> dict[str, Any]: + workspace, series_id = str(job["workspace"]), str(job["seriesId"]) + with _LOCK: + series, library = _series_or_404(workspace, series_id) + if int(series.get("revision") or 1) != int(job.get("sourceSeriesRevision") or 1): + raise PermissionError("The series was edited after canon preparation started") + series = merge_series_canon_proposal( + copy.deepcopy(series), proposal, bootstrap_known_series=job.get("bootstrapKnownSeries") is True, + ) + series["revision"] = int(series.get("revision") or 1) + 1 + series["updatedAt"] = _iso_now() + library["seriesById"][series_id] = series + stored = write_series_library(core.workspace_dir(workspace), library, workspace) + return stored["seriesById"][series_id] + + +def apply_canon(job_id: str) -> dict[str, Any]: + job = load_job(job_id) + if not job: + raise KeyError("Series canon planning job not found") + proposal = job.get("seriesResult") + if job.get("jobType") != "canon" or job.get("status") != "completed" or not isinstance(proposal, dict): + raise ValueError("Complete the canon preparation job before applying it") + stored = _commit_canon_proposal(job, proposal) + _patch(job_id, appliedAt=time.time(), message="Canon proposal applied as a draft for review.") + return stored + + +def _run_episode(job_id: str) -> None: + with _LOCK: + _ACTIVE.add(job_id) + try: + job = load_job(job_id) or {} + request = copy.deepcopy(job.get("request") or {}) + series = request.get("seriesSnapshot") if isinstance(request.get("seriesSnapshot"), dict) else {} + episode = request.get("episodeSnapshot") if isinstance(request.get("episodeSnapshot"), dict) else {} + completed = job.get("completedStages") if isinstance(job.get("completedStages"), dict) else {} + stages = planning_stages(str(request.get("scope") or "complete")) + override = _writing_override(request) + for index, stage in enumerate(stages): + latest = load_job(job_id) or {} + if latest.get("status") in {"cancelling", "cancelled"}: + _patch(job_id, status="cancelled", stage="cancelled", finishedAt=time.time()) + return + if stage not in completed: + _patch(job_id, status="running", stage=stage, current=index, total=len(stages), + message=f"Generating Series Lab {stage.replace('_', ' ')}…") + prompt, system_prompt = planning_prompt(stage, series, episode, str(request.get("instruction") or "")) + raw = _generate_json( + prompt=prompt, system_prompt=system_prompt, + schema=planning_schema(stage, episode), + max_new_tokens=planning_output_token_budget(stage, episode), + override=override, + ) + completed[stage] = normalize_planning_result(stage, raw, series, episode) + episode = apply_planning_stage(episode, stage, completed[stage]) + _patch(job_id, completedStages=completed, episodeResult=episode, current=index + 1, stage=stage) + _patch( + job_id, status="completed", stage="completed", current=len(stages), total=len(stages), + message="Episode proposal generated. Review and apply it when ready.", + result={"episode": episode}, error=None, finishedAt=time.time(), + ) + except Exception as exc: + _patch(job_id, status="failed", error=str(exc), finishedAt=time.time(), + message="Episode planning stopped. Completed stages remain recoverable.") + finally: + with _LOCK: + _ACTIVE.discard(job_id) + + +def _run_canon(job_id: str) -> None: + with _LOCK: + _ACTIVE.add(job_id) + try: + job = load_job(job_id) or {} + request = copy.deepcopy(job.get("request") or {}) + series = request.get("seriesSnapshot") if isinstance(request.get("seriesSnapshot"), dict) else {} + latest = load_job(job_id) or {} + if latest.get("status") in {"cancelling", "cancelled"}: + _patch(job_id, status="cancelled", stage="cancelled", finishedAt=time.time()) + return + bootstrap = job.get("bootstrapKnownSeries") is True + _patch( + job_id, status="running", + stage="known_series_research" if bootstrap else "canon", + current=0, total=1, + message=( + "Building an editable known-series bible from the writing model's general knowledge…" + if bootstrap else "Preparing a reviewable Series canon proposal…" + ), + ) + if bootstrap: + prompt, system_prompt = known_series_bootstrap_prompt( + series, str(request.get("instruction") or ""), + ) + schema = known_series_bootstrap_schema() + max_new_tokens = 14000 + else: + prompt, system_prompt = canon_preparation_prompt( + series, str(request.get("instruction") or ""), + ) + schema = canon_preparation_schema() + max_new_tokens = 6000 + raw = _generate_json( + prompt=prompt, system_prompt=system_prompt, schema=schema, + max_new_tokens=max_new_tokens, override=_writing_override(request), + ) + latest = load_job(job_id) or {} + if latest.get("status") in {"cancelling", "cancelled"}: + _patch(job_id, status="cancelled", stage="cancelled", finishedAt=time.time()) + return + proposal = ( + normalize_known_series_bootstrap(raw, series) + if bootstrap else normalize_canon_preparation(raw, series) + ) + if bootstrap and job.get("autoApply") is True: + _patch( + job_id, stage="applying_draft", seriesResult=proposal, + result={"seriesProposal": proposal}, + message="Known-series bible generated; applying it as an editable draft…", + ) + latest = load_job(job_id) or {} + if latest.get("status") in {"cancelling", "cancelled"}: + _patch(job_id, status="cancelled", stage="cancelled", finishedAt=time.time()) + return + try: + applied = _commit_canon_proposal(job, proposal) + except (PermissionError, ValueError, KeyError) as exc: + _patch( + job_id, status="completed", stage="completed", current=1, total=1, + autoApplied=False, applyError=str(exc), error=None, + seriesResult=proposal, result={"seriesProposal": proposal}, + message="Known-series proposal generated, but the project changed before it could be applied.", + finishedAt=time.time(), + ) + return + _patch( + job_id, status="completed", stage="completed", current=1, total=1, + autoApplied=True, appliedSeriesRevision=int(applied.get("revision") or 1), + appliedAt=time.time(), error=None, seriesResult=proposal, + result={"seriesProposal": proposal}, + message="Known-series bible filled as a draft. Verify facts and approve canon when ready.", + finishedAt=time.time(), + ) + return + _patch( + job_id, status="completed", stage="completed", current=1, total=1, + seriesResult=proposal, result={"seriesProposal": proposal}, error=None, + message="Canon proposal generated. Review it before applying.", + finishedAt=time.time(), + ) + except Exception as exc: + _patch(job_id, status="failed", error=str(exc), finishedAt=time.time(), + message="Canon preparation stopped.") + finally: + with _LOCK: + _ACTIVE.discard(job_id) diff --git a/app/services/core_upload.py b/app/services/core_upload.py new file mode 100644 index 000000000..db564bd4f --- /dev/null +++ b/app/services/core_upload.py @@ -0,0 +1,122 @@ +"""Core-profile uploads that match the UI FormData contract without File().""" +from __future__ import annotations + +import os +import re +import uuid +from typing import Any +from urllib.parse import unquote + +MAX_UPLOAD_BYTES = 500 * 1024 * 1024 +_SAFE_EXT = re.compile(r"^\.[A-Za-z0-9]{1,10}$") +_BOUNDARY = re.compile(r"boundary=([^;]+)", re.IGNORECASE) +_FILENAME_STAR = re.compile(r"filename\*=(?:UTF-8|utf-8)''([^;\r\n]+)") +_FILENAME_QUOTED = re.compile(r'filename="([^"]*)"') +_FILENAME_BARE = re.compile(r"filename=([^;\r\n]+)") +_FIELD_NAME = re.compile(r'name="([^"]+)"') + + +def unique_upload_name(original: str) -> str: + base = os.path.basename((original or "upload.bin").replace("\\", "/")) + ext = os.path.splitext(base)[1].lower() + if not _SAFE_EXT.fullmatch(ext): + ext = ".bin" + return f"{uuid.uuid4().hex}{ext}" + + +def _boundary(content_type: str) -> bytes | None: + match = _BOUNDARY.search(content_type or "") + if not match: + return None + value = match.group(1).strip().strip('"') + if not value: + return None + return value.encode("ascii", "ignore") + + +def _header_filename(header: bytes) -> str: + text = header.decode("utf-8", "replace") + match = _FILENAME_STAR.search(text) + if match: + return os.path.basename(unquote(match.group(1))) + match = _FILENAME_QUOTED.search(text) + if match: + return os.path.basename(match.group(1)) + match = _FILENAME_BARE.search(text) + if match: + return os.path.basename(match.group(1).strip().strip('"')) + return "" + + +def _header_field(header: bytes) -> str: + match = _FIELD_NAME.search(header.decode("utf-8", "replace")) + return match.group(1) if match else "" + + +def extract_upload(body: bytes, content_type: str, fallback_name: str = "upload.bin") -> tuple[bytes, str]: + if len(body) > MAX_UPLOAD_BYTES: + raise ValueError("File too large (max 500 MB)") + boundary = _boundary(content_type) + if boundary is None: + name = os.path.basename((fallback_name or "upload.bin").replace("\\", "/")) or "upload.bin" + if not body: + raise ValueError("A file is required") + return body, name + marker = b"--" + boundary + preferred: tuple[bytes, str] | None = None + first: tuple[bytes, str] | None = None + start = 0 + while True: + idx = body.find(marker, start) + if idx < 0: + break + idx += len(marker) + if body.startswith(b"--", idx): + break + if body.startswith(b"\r\n", idx): + idx += 2 + elif body.startswith(b"\n", idx): + idx += 1 + header_end = body.find(b"\r\n\r\n", idx) + sep = 4 + if header_end < 0: + header_end = body.find(b"\n\n", idx) + sep = 2 + if header_end < 0: + break + header = body[idx:header_end] + payload_start = header_end + sep + next_idx = body.find(b"\r\n" + marker, payload_start) + if next_idx < 0: + next_idx = body.find(b"\n" + marker, payload_start) + if next_idx < 0: + break + filename = _header_filename(header) + if filename: + payload = body[payload_start:next_idx] + item = (payload, filename) + if first is None: + first = item + if _header_field(header) == "file": + preferred = item + break + start = next_idx + chosen = preferred or first + if chosen is None: + raise ValueError("A file is required") + return chosen + + +def save_upload(folder: str, data: bytes, original_name: str) -> dict[str, Any]: + if len(data) > MAX_UPLOAD_BYTES: + raise ValueError("File too large (max 500 MB)") + os.makedirs(folder, exist_ok=True) + name = unique_upload_name(original_name) + dest = os.path.join(folder, name) + with open(dest, "wb") as handle: + handle.write(data) + return { + "filename": name, + "path": dest, + "url": f"/api/v1/uploads/{name}", + } diff --git a/app/services/core_workspace.py b/app/services/core_workspace.py new file mode 100644 index 000000000..1bb630709 --- /dev/null +++ b/app/services/core_workspace.py @@ -0,0 +1,251 @@ +"""Filesystem workspaces and core JSON config without WanGP or Torch.""" +from __future__ import annotations + +import json +import os +import re +import threading +from pathlib import Path +from typing import Any + +from app_identity import read_app_version + +_LOCK = threading.Lock() +_WORKSPACE_NAME = re.compile(r"(?:default|[A-Za-z0-9][A-Za-z0-9_-]*)") +MEDIA_EXTS = {".mp4", ".webm", ".gif", ".png", ".jpg", ".jpeg", ".webp", ".wav", ".mp3", + ".glb", ".gltf", ".obj", ".ply", ".stl", ".usdz", ".zip", ".json"} +VIDEO_EXTS = {".mp4", ".webm", ".gif"} +AUDIO_EXTS = {".wav", ".mp3"} +MODEL3D_EXTS = {".glb", ".gltf", ".obj", ".ply", ".stl", ".usdz", ".zip"} +IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".webp"} + + +def classify_output_type(name: str) -> str | None: + """Match the NVIDIA gallery kinds the UI filters on (`image`, `model3d`, …).""" + filename = os.path.basename(str(name or "")) + if filename.endswith(".preview.png"): + return None + if filename.endswith(".scene.json"): + return "scene" + if filename.endswith(".comic.json"): + return "comic" + ext = os.path.splitext(filename)[1].lower() + if ext in VIDEO_EXTS: + return "video" + if ext in AUDIO_EXTS: + return "audio" + if ext in MODEL3D_EXTS: + return "model3d" + if ext in IMAGE_EXTS: + return "image" + return None + + +def root() -> Path: + return Path(os.getcwd()) + + +def outputs_root() -> Path: + path = root() / "outputs" + path.mkdir(parents=True, exist_ok=True) + return path + + +def uploads_dir() -> str: + path = root() / "uploads" + path.mkdir(parents=True, exist_ok=True) + return str(path) + + +def settings_path() -> Path: + path = root() / "app" / "settings" + path.mkdir(parents=True, exist_ok=True) + return path / "core_config.json" + + +def load_config() -> dict[str, Any]: + path = settings_path() + if not path.is_file(): + return {"services": {"active_workspace": "default"}} + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return {"services": {"active_workspace": "default"}} + if not isinstance(data, dict): + return {"services": {"active_workspace": "default"}} + data.setdefault("services", {}) + return data + + +def save_config(data: dict[str, Any]) -> None: + path = settings_path() + temporary = path.with_suffix(".tmp") + with _LOCK: + temporary.write_text(json.dumps(data, indent=2), encoding="utf-8") + temporary.replace(path) + + +def active_workspace() -> str: + name = str(load_config().get("services", {}).get("active_workspace") or "default") + return name if _WORKSPACE_NAME.fullmatch(name) else "default" + + +def workspace_dir(workspace: str | None = None) -> str: + name = active_workspace() if workspace is None else workspace + if not isinstance(name, str) or not _WORKSPACE_NAME.fullmatch(name): + raise ValueError("Invalid workspace name") + base = outputs_root().resolve() + target = base if name == "default" else (base / name).resolve() + if os.path.commonpath((str(base), str(target))) != str(base): + raise ValueError("Invalid workspace path") + if name != "default": + target.mkdir(parents=True, exist_ok=True) + return str(target) + + +def safe_join(base: str, *parts: str) -> str | None: + try: + candidate = Path(base).joinpath(*parts).resolve() + root = Path(base).resolve() + if os.path.commonpath((str(root), str(candidate))) != str(root): + return None + return str(candidate) + except (OSError, ValueError): + return None + + +def _file_count(path: str) -> int: + try: + with os.scandir(path) as entries: + return sum(1 for item in entries if not item.name.startswith(".") and item.is_file()) + except OSError: + return 0 + + +def list_workspaces() -> list[dict[str, Any]]: + base = outputs_root() + rows = [{"name": "default", "path": str(base), "file_count": _file_count(str(base))}] + try: + names = sorted(os.listdir(base)) + except OSError: + names = [] + for name in names: + full = base / name + if full.is_dir() and not name.startswith(("_", ".")): + rows.append({"name": name, "path": str(full), "file_count": _file_count(str(full))}) + return rows + + +def list_outputs( + workspace: str = "", + media_type: str = "", + limit: int = 0, + offset: int = 0, +) -> dict[str, Any]: + folder = Path(uploads_dir()) if workspace == "__uploads__" else Path(workspace_dir(workspace or None)) + if not folder.is_dir(): + return {"outputs": [], "total": 0} + wanted = str(media_type or "").strip() + items = [] + for entry in folder.iterdir(): + if not entry.is_file() or entry.name.startswith("."): + continue + kind = classify_output_type(entry.name) + if kind is None or (wanted and kind != wanted): + continue + try: + stat = entry.stat() + except OSError: + continue + suffix = f"?workspace={workspace}" if workspace else "" + items.append({ + "name": entry.name, + "type": kind, + "mode": None, + "size": stat.st_size, + "created_at": stat.st_mtime, + "completed_at": stat.st_mtime, + "completion_time_source": "file", + "url": f"/api/v1/file/{entry.name}{suffix}", + }) + items.sort(key=lambda row: row["created_at"], reverse=True) + total = len(items) + start = max(0, int(offset or 0)) + if limit and int(limit) > 0: + items = items[start:start + int(limit)] + return {"outputs": items, "total": total} + + +def system_config() -> dict[str, Any]: + cfg = load_config() + return { + "app_version": read_app_version(), + "attention_mode": "auto", + "transformer_quantization": "int8", + "vae_config": 0, + "compile": "", + "video_profile": 4, + "image_profile": 4, + "audio_profile": 4, + "video_output_codec": cfg.get("video_output_codec", "libx264_8"), + "image_output_codec": cfg.get("image_output_codec", "jpeg_95"), + "enhancer_enabled": 0, + "prompt_enhancer_quantization": "quanto_int8", + "attention_modes_available": ["auto"], + "vram_safety_coefficient": 0.8, + "model_folders": [], + "execution_mode": "real", + "execution_workspace": active_workspace(), + "execution_allow_paid": True, + } + + +def services_raw() -> dict[str, Any]: + return dict(load_config().get("services", {})) + + +def services_config() -> dict[str, Any]: + services = load_config().get("services", {}) + return { + "llm_model_id": services.get("llm_model_id", ""), + "llm_device": "cpu", + "llm_provider": services.get("llm_provider", "minimax"), + "llm_remote_url": services.get("llm_remote_url", ""), + "enhance_llm_model_id": "", + "enhance_llm_device": "cpu", + "google_api_key_set": bool(services.get("google_api_key")), + "openai_api_key_set": bool(services.get("openai_api_key")), + "deepseek_api_key_set": bool(services.get("deepseek_api_key")), + "compatible_api_key_set": bool(services.get("compatible_api_key")), + "compatible_base_url": services.get("compatible_base_url", ""), + "anthropic_api_key_set": bool(services.get("anthropic_api_key")), + "minimax_api_key_set": bool(services.get("minimax_api_key")), + "minimax_llm_api_key_set": bool(services.get("minimax_llm_api_key")), + "minimax_image_api_key_set": bool(services.get("minimax_image_api_key")), + "minimax_music_api_key_set": bool(services.get("minimax_music_api_key")), + "grok_api_key_set": bool(services.get("grok_api_key")), + "meshy_api_key_set": bool(services.get("meshy_api_key")), + "hi3d_api_key_set": bool(services.get("hi3d_api_key")), + "use_director_v2": False, + "nsfw_mode": False, + "nsfw_accepted_at": None, + "director_prompt_polish": "off", + "workflow_parallelism_enabled": False, + "debug_trace_enabled": False, + "civitai_api_key_set": False, + "voice_reference_enabled": False, + "ltx_progressive_pipeline": False, + "show_experimental": False, + "auto_performance": False, + } + + +def merge_services(partial: dict[str, Any]) -> dict[str, Any]: + data = load_config() + services = data.setdefault("services", {}) + for key, value in partial.items(): + if key.endswith("_set"): + continue + services[key] = value + save_config(data) + return services_config() diff --git a/app/services/director/planners/music_video.py b/app/services/director/planners/music_video.py index 7fb987b14..72c47e192 100644 --- a/app/services/director/planners/music_video.py +++ b/app/services/director/planners/music_video.py @@ -983,6 +983,31 @@ def _build_clip_contexts( else: vocal_info = "instrumental" + timed_cues = [] + for cue in clip.get("lyric_cues") or []: + try: + offset = float(cue.get("offset", 0.0)) + except (TypeError, ValueError): + offset = 0.0 + timed_cues.append( + f'+{offset:.3f}s "{str(cue.get("text") or "").strip()}"' + ) + timed_events = [] + for event in clip.get("visual_events") or []: + try: + offset = float(event.get("offset", 0.0)) + except (TypeError, ValueError): + offset = 0.0 + timed_events.append( + f'+{offset:.3f}s {event.get("kind", "action")} on ' + f'"{event.get("trigger", "")}"; result must not be visible earlier' + ) + timing_hint = "" + if timed_cues: + timing_hint += " Source-audio lyric clock: " + "; ".join(timed_cues) + "." + if timed_events: + timing_hint += " Mandatory visual action anchors: " + "; ".join(timed_events) + "." + coverage = coverage_plan[i] if coverage_plan and i < len(coverage_plan) else {} coverage_hint = ( f" Planned role: {coverage.get('scene_type', 'narrative')}; " @@ -1015,7 +1040,7 @@ def _build_clip_contexts( ) if coverage.get("reuse_chorus_signature"): coverage_hint += " Return to the same chorus signature instead of inventing a new location." - ctx = f"Clip {i + 1}: {section}, {beat_count} beats, {vocal_info}.{performer_hint}{coverage_hint}" + ctx = f"Clip {i + 1}: {section}, {beat_count} beats, {vocal_info}.{performer_hint}{coverage_hint}{timing_hint}" contexts.append(ctx) return contexts @@ -1196,6 +1221,9 @@ def _plan_with_llm( - 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. +- The source-audio lyric clock is authoritative. Write action_beats in chronological order with + the supplied +seconds offsets. An entrance, reveal, transformation or impact must begin at its + mandatory action anchor; establish anticipation before it and never show the result early. EDITABLE MUSIC-VIDEO TREATMENT: {json.dumps(treatment, ensure_ascii=False, indent=2)} @@ -1594,6 +1622,8 @@ def _convert_to_shots( "bpm": clip.get("bpm", 120), "clip_start": clip.get("start", 0), "clip_end": clip.get("end", 0), + "lyric_cues": clip.get("lyric_cues", []), + "visual_events": clip.get("visual_events", []), "music_video_role": coverage.get("scene_type"), "recurring_set": coverage.get("recurring_set"), "coverage": coverage.get("coverage"), diff --git a/app/services/director_pipeline.py b/app/services/director_pipeline.py index 90f3469a0..8e89594a9 100644 --- a/app/services/director_pipeline.py +++ b/app/services/director_pipeline.py @@ -2192,8 +2192,10 @@ def _backfill_clip_video_attempts(state: dict, state_dir: str) -> dict: selected = "" clip["selected_video_filename"] = selected or None if selected: + # A Studio selection is the playback authority, but it does not + # refresh inputs. Image reruns keep video_stale so Rejoin/export + # cannot assemble a take that no longer matches the start frame. 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"]), @@ -4356,6 +4358,19 @@ def _rejoin_clips_impl(out_dir: str, pid: str) -> dict: state = _ensure_h3_segment_state(state) clips = state.get("clips", []) video_files = [] + # Image reruns keep video_stale on the clip even when a Studio selection or + # H3 segment list still points at playable files. Gate Rejoin before the + # H3 branch, which otherwise treats those files as current. + stale_clip_numbers = [ + str(index + 1) + for index, clip in enumerate(clips) + if clip.get("video_stale") + ] + if stale_clip_numbers: + raise ValueError( + "Regenerate stale video clip(s) " + f"{', '.join(stale_clip_numbers)} before rejoining." + ) legacy_h3_segments = ( _is_sequential_h3_model(state.get("video_model")) and any(clip.get("h3_segments") for clip in clips) @@ -4390,17 +4405,6 @@ def _rejoin_clips_impl(out_dir: str, pid: str) -> dict: if stale: raise ValueError("Regenerate stale H3 continuations before rejoining the final video") else: - stale_clip_numbers = [ - str(index + 1) - for index, clip in enumerate(clips) - if clip.get("video_stale") - ] - if stale_clip_numbers: - raise ValueError( - "Regenerate stale video clip(s) " - f"{', '.join(stale_clip_numbers)} before rejoining." - ) - if shot_images_required(_saved_pipeline_shot_image_policy(state)): invalid_start_numbers = _invalid_saved_media_numbers( [clip.get("start_image_filename") for clip in clips], @@ -12425,10 +12429,15 @@ def _run_comic_renderer_pipeline( "concatenate_multi_clip_videos", None, ) + # Comic assembly is hard-cut only (see comic_edit_transition forced to + # "none" below). Recast/repaint/outpaint pass audio_duration_sec so + # concatenate() skips freeze-tail + crossfade. Without that lock, two + # shots become hold+xfade and the timeline is longer than the storyboard. if not callable(concatenate) or not concatenate( clip_paths, final_path, None, + audio_duration_sec=sum(durations), ): raise RuntimeError( "All comic shots passed validation, but final hard-cut assembly " diff --git a/app/services/director_review.py b/app/services/director_review.py new file mode 100644 index 000000000..59a71318b --- /dev/null +++ b/app/services/director_review.py @@ -0,0 +1,87 @@ +"""Persist review decisions onto the existing Director pipeline, atomically.""" +from pathlib import Path +import time + +from services.director_pipeline import ( + _exclusive_pipeline_operation, _find_pipeline_file, _load_pipeline_state_locked, + _pipeline_file_lock, _write_pipeline_json_unlocked, hydrate_queue_clips, +) + + +def _select_take(clip: dict, name: str, workspace: Path, state: dict) -> None: + attempts = {item.get("filename"): item for item in clip.get("video_attempts", []) if isinstance(item, dict)} + available = set(attempts) | {clip.get("video_filename"), clip.get("selected_video_filename")} + if not isinstance(name, str) or not name or name not in available or Path(name).name != name: + raise ValueError("Select an existing take from this shot") + file = workspace / name + if not file.is_file() or file.resolve().parent != workspace.resolve() or file.stat().st_size == 0: + raise ValueError("The selected take is missing from this workspace") + changed = name != (clip.get("selected_video_filename") or clip.get("video_filename")) + clip.update(selected_video_filename=name, video_filename=name) + if changed: + clip.update(video_stale=False, tag=None) + segments = clip.get("h3_segments") or [] + if len(segments) == 1: + attempt = attempts.get(name, {}) + segment = segments[0] + segment.update(filename=name, prompt=attempt.get("prompt") or segment.get("prompt", ""), + seed=attempt.get("seed", segment.get("seed")), stale=False, updated_at=time.time()) + if attempt.get("video_length"): + segment["frames"] = attempt["video_length"] + if name not in state.get("output_files", []): + state.setdefault("output_files", []).append(name) + + +def _apply_review(state: dict, commands: list, workspace: Path, pid: str) -> None: + clips = {clip.get("index", index): clip for index, clip in enumerate(state.get("clips", []))} + for command in commands: + if not isinstance(command, dict) or command.get("pipelineId") != pid: + raise ValueError("Review command belongs to another production") + index = command.get("clipIndex") + if type(index) is not int or index not in clips: + raise ValueError("Review shot was not found") + clip = clips[index] + kind = command.get("type") + if kind == "select_take": + _select_take(clip, command.get("filename"), workspace, state) + elif kind == "tag_clip": + tag = command.get("tag") + if tag not in (None, "good", "needs_work"): + raise ValueError("Invalid review decision") + # The desk always restates the current decision with notes/select. + # After an image rerun the take stays tagged good and video_stale, + # so rejecting a no-op "good" would drop the notes in the same + # atomic batch. Only a new approval of a missing/stale take is + # blocked; Rejoin/export still read video_stale independently. + if ( + tag == "good" + and clip.get("tag") != "good" + and (not clip.get("video_filename") or clip.get("video_stale")) + ): + raise ValueError("Only a completed current take can be approved") + clip["tag"] = tag + elif kind == "note_clip": + notes = command.get("notes") + if not isinstance(notes, str) or len(notes) > 8000: + raise ValueError("Review notes must contain at most 8000 characters") + clip["review_notes"] = notes + else: + raise ValueError("Unsupported review command") + + +@_exclusive_pipeline_operation +def save_review(workspace: str, pid: str, commands: list) -> dict: + if not isinstance(commands, list) or len(commands) > 1500: + raise ValueError("Use a bounded list of review decisions") + path = _find_pipeline_file(workspace, pid) + if not path or Path(path).resolve().parent != Path(workspace).resolve(): + raise ValueError("Production not found in this workspace") + with _pipeline_file_lock: + # Same projection GET uses: sidecar/output_files histories are visible + # on the desk, so persist must accept and return those takes. + state = _load_pipeline_state_locked(workspace, pid) + if not state or str(state.get("pipeline_id") or "") != pid: + raise ValueError("Production not found in this workspace") + _apply_review(state, commands, Path(workspace), pid) + _write_pipeline_json_unlocked(path, state) + return hydrate_queue_clips(state) diff --git a/app/services/image_generation_commands.py b/app/services/image_generation_commands.py index c81669352..35b9e2a66 100644 --- a/app/services/image_generation_commands.py +++ b/app/services/image_generation_commands.py @@ -153,6 +153,7 @@ async def submit(self, command, *, trusted_tool=None, submission_context=None): request.prepared_studio_images = command["operation"] == "generation.image" and command["version"] == 2 request.prepared_studio_speech = command["operation"] == "generation.speech" request.prepared_studio_audio = command["operation"] == "generation.music" + request.prepared_studio_video = command["operation"] == "generation.video" # This callback is an in-process capability, never a JSON option. # The native facade performs its ordinary validation first and then # transfers admission to the same canonical task/worker adapter. diff --git a/app/services/image_generation_runtime.py b/app/services/image_generation_runtime.py index 7bfc27661..410e472d4 100644 --- a/app/services/image_generation_runtime.py +++ b/app/services/image_generation_runtime.py @@ -49,8 +49,9 @@ def resources(media_kind="image"): from services.studio_image_resources import StudioImageResources from services.studio_speech_resources import StudioSpeechResources from services.studio_sfx_resources import StudioSfxResources + from services.studio_video_resources import StudioVideoResources resource_type = {"image": StudioImageResources, "audio": StudioSpeechResources, - "video": StudioSfxResources}[media_kind] + "video": StudioSfxResources, "studio_video": StudioVideoResources}[media_kind] return resource_type( workspace_dir=runtime["_workspace_dir"], uploads_dir=lambda: os.path.join(os.getcwd(), "uploads"), list_workspaces=runtime["_list_workspaces"], lora_search_dirs=runtime["wgp"].get_lora_search_dirs, @@ -88,10 +89,14 @@ def prepare(params): from services.studio_music_spec import freeze_studio_music_spec from services.studio_music_preparation import prepare_studio_music from routers.studio_music_commands import music_command_catalog + from services.video_generation_commands import create_video_operation operations = { "generation.speech": audio_operation(freeze_studio_speech_spec, prepare_studio_speech, speech_command_catalog), "generation.music": audio_operation(freeze_studio_music_spec, prepare_studio_music, music_command_catalog), + "generation.video": create_video_operation( + runtime, resources=lambda: resources("studio_video"), execution_policy=execution_policy, + ), } if callable(runtime.get("_run_generation")): diff --git a/app/services/install_speech_tools.py b/app/services/install_speech_tools.py new file mode 100644 index 000000000..67be2a807 --- /dev/null +++ b/app/services/install_speech_tools.py @@ -0,0 +1,67 @@ +"""Install the pinned offline lip-sync engine during app Install/Update.""" +from __future__ import annotations + +import hashlib +import os +from pathlib import Path +import platform +import shutil +import tempfile +import urllib.request +import zipfile + +VERSION = "1.14.0" +ROOT = Path(__file__).resolve().parents[1] / ".runtime" / "speech" +RELEASES = { + "Linux": "a9a9074862cff47b2d59b8bf399a678a3b0b74f9452ad6ad94cb292913dd8667", + "Windows": "62fa416a8d5e382a3828ee4bef358ce520d0b4cabdeaea75a7ac266d098d1fe3", + "Darwin": "f991deacac6c973a14a4431a16a58b842f436531e120cfaea142c87c0d3ab4c5", +} + + +def bundled_executable() -> Path: + return ROOT / f"rhubarb-{VERSION}" / ("rhubarb.exe" if os.name == "nt" else "rhubarb") + + +def install() -> Path | None: + configured = os.environ.get("RHUBARB_EXECUTABLE", "") + if configured: + candidate = Path(configured) + if not candidate.is_absolute() or not candidate.is_file(): + raise RuntimeError("RHUBARB_EXECUTABLE must point to an existing absolute file path.") + return candidate + available = shutil.which("rhubarb") + if available: + return Path(available) + target = bundled_executable() + if target.is_file(): + return target + system = platform.system() + if system not in RELEASES or platform.machine().lower() not in {"x86_64", "amd64"}: + print("Offline lip sync is unavailable on this architecture. Install a native Rhubarb build and set RHUBARB_EXECUTABLE; the rest of the app can still be installed.") + return None + name = f"Rhubarb-Lip-Sync-{VERSION}-{'macOS' if system == 'Darwin' else system}" + ROOT.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(prefix="install-", dir=ROOT) as temporary: + folder = Path(temporary) + archive = folder / "release.zip" + request = urllib.request.Request(f"https://github.com/DanielSWolf/rhubarb-lip-sync/releases/download/v{VERSION}/{name}.zip", headers={"User-Agent": "HocusPocus-installer"}) + with urllib.request.urlopen(request, timeout=60) as response, archive.open("wb") as output: + shutil.copyfileobj(response, output) + if hashlib.sha256(archive.read_bytes()).hexdigest() != RELEASES[system]: + raise RuntimeError("Rhubarb download checksum mismatch; nothing was installed.") + with zipfile.ZipFile(archive) as bundle: + bundle.extractall(folder / "unpacked") # Exact pinned archive, verified above. + source = folder / "unpacked" / name + executable = source / target.name + if not executable.is_file(): + raise RuntimeError("The Rhubarb archive is incomplete.") + executable.chmod(executable.stat().st_mode | 0o111) + source.rename(target.parent) + return target + + +if __name__ == "__main__": + installed = install() + if installed: + print(f"Offline lip sync ready: {installed}") diff --git a/app/services/lan_auth.py b/app/services/lan_auth.py index 36bcb2da5..6981c99d0 100644 --- a/app/services/lan_auth.py +++ b/app/services/lan_auth.py @@ -165,9 +165,9 @@ def request_requires_lan_auth( environ: Mapping[str, str] | None = None, ) -> bool: path = str(getattr(getattr(request, "url", None), "path", "") or "") - # This exact endpoint always authenticates its own opt-in MCP bearer token. + # The MCP endpoint and its legacy alias authenticate their own opt-in bearer token. # Requiring a second LAN bearer here makes external MCP clients impossible. - if path == '/api/v1/wangp/mcp': + if path in {'/api/v1/mcp', '/api/v1/wangp/mcp'}: return False if path in _AUTH_PUBLIC_PATHS: return False diff --git a/app/services/llm_service.py b/app/services/llm_service.py index 64cc11274..1c7602cc5 100644 --- a/app/services/llm_service.py +++ b/app/services/llm_service.py @@ -719,6 +719,12 @@ def get_available_models(provider: str = "local", remote_url: str = "", api_key: {"id": "MiniMax-M2.7-highspeed", "label": "MiniMax M2.7 Highspeed", "size_hint": "MiniMax API", "provider": "minimax"}, ]) + if provider == "deepseek": + remote_models.extend([ + {"id": "deepseek-v4-pro", "label": "DeepSeek V4 Pro", "size_hint": "deepseek", "provider": "deepseek"}, + {"id": "deepseek-v4-flash", "label": "DeepSeek V4 Flash", "size_hint": "deepseek", "provider": "deepseek"}, + ]) + if provider == "grok" and not remote_models: remote_models.extend([ {"id": model_id, "label": f"{model_id} (Grok)", "size_hint": "grok", "provider": "grok"} diff --git a/app/services/lyric_timeline.py b/app/services/lyric_timeline.py new file mode 100644 index 000000000..45a974a3a --- /dev/null +++ b/app/services/lyric_timeline.py @@ -0,0 +1,334 @@ +"""Literal lyric alignment and source-audio visual timing.""" + +from __future__ import annotations + +import difflib +import re +import unicodedata +from dataclasses import dataclass +from typing import Optional + + +@dataclass +class LyricWord: + start: float + end: float + text: str + + +@dataclass +class LyricSegment: + start: float + end: float + text: str + speaker: Optional[str] = None + words: Optional[list[LyricWord]] = None + source: str = "transcription" + confidence: Optional[float] = None + section: Optional[str] = None + + +def normalise_token(value: str) -> str: + value = unicodedata.normalize("NFKD", value.casefold()) + value = "".join(ch for ch in value if not unicodedata.combining(ch)) + return "".join(ch for ch in value if ch.isalnum()) + + +def authoritative_lines(lyrics: str) -> list[dict]: + """Parse editable lyrics without changing their literal line text.""" + lines: list[dict] = [] + section = "" + for raw in str(lyrics or "").splitlines(): + text = raw.strip() + if not text: + continue + tag = re.fullmatch(r"\[([^\]]+)\]", text) + if tag: + section = tag.group(1).strip() + continue + tokens = [] + for token in re.findall(r"[^\W_]+(?:['’][^\W_]+)*", text, flags=re.UNICODE): + normalized = normalise_token(token) + if normalized: + tokens.append({"text": token, "normalized": normalized}) + if tokens: + lines.append({"text": text, "section": section, "tokens": tokens}) + return lines + + +def has_authoritative_lyrics(lyrics: str) -> bool: + return bool(authoritative_lines(lyrics)) + + +def _flatten_words(transcript: list[LyricSegment]) -> list[LyricWord]: + words: list[LyricWord] = [] + for segment in transcript: + if segment.words: + words.extend(segment.words) + continue + raw = segment.text.split() + if not raw: + continue + duration = max(0.001, segment.end - segment.start) + for index, token in enumerate(raw): + words.append(LyricWord( + start=segment.start + duration * index / len(raw), + end=segment.start + duration * (index + 1) / len(raw), + text=token, + )) + return words + + +def _similarity(left: str, right: str) -> float: + if left == right: + return 1.0 + if not left or not right: + return 0.0 + if left[0] != right[0] or abs(len(left) - len(right)) > 2: + return 0.0 + return difflib.SequenceMatcher(None, left, right).ratio() + + +def _global_word_matches(authored: list[str], heard: list[str]) -> dict[int, tuple[int, float]]: + """Return monotonic authored-index to heard-index matches.""" + n, m = len(authored), len(heard) + previous = [-0.65 * j for j in range(m + 1)] + trace = [bytearray(m + 1) for _ in range(n + 1)] + for i in range(1, n + 1): + current = [-1.0 * i] + [0.0] * m + trace[i][0] = 2 + for j in range(1, m + 1): + similarity = _similarity(authored[i - 1], heard[j - 1]) + match_score = 3.0 if similarity == 1.0 else (1.4 if similarity >= 0.78 else -2.2) + choices = ( + (previous[j - 1] + match_score, 1), + (previous[j] - 1.0, 2), + (current[j - 1] - 0.65, 3), + ) + current[j], trace[i][j] = max(choices, key=lambda item: item[0]) + previous = current + + matches: dict[int, tuple[int, float]] = {} + i, j = n, m + while i > 0 or j > 0: + direction = trace[i][j] + if direction == 1: + similarity = _similarity(authored[i - 1], heard[j - 1]) + if similarity >= 0.78: + matches[i - 1] = (j - 1, similarity) + i -= 1 + j -= 1 + elif direction == 2 or j == 0: + i -= 1 + else: + j -= 1 + return matches + + +def _build_cues( + lines: list[dict], + audio_words: list[LyricWord], + matches: dict[int, tuple[int, float]], +) -> list[LyricSegment]: + timeline: list[LyricSegment] = [] + token_cursor = 0 + for line in lines: + similarities = [] + aligned_words: list[LyricWord] = [] + for local_index, token in enumerate(line["tokens"]): + match = matches.get(token_cursor + local_index) + if not match: + continue + word_index, similarity = match + evidence = audio_words[word_index] + similarities.append(similarity) + aligned_words.append(LyricWord(evidence.start, evidence.end, token["text"])) + token_cursor += len(line["tokens"]) + if aligned_words: + timeline.append(LyricSegment( + start=round(aligned_words[0].start, 3), + end=round(aligned_words[-1].end, 3), + text=line["text"], words=aligned_words, + source="aligned_lyrics", + confidence=round(sum(similarities) / len(line["tokens"]), 3), + section=line["section"] or None, + )) + else: + timeline.append(LyricSegment( + 0.0, 0.0, line["text"], source="interpolated", + confidence=0.0, section=line["section"] or None, + )) + return timeline + + +def _interpolate_missing_cues(timeline: list[LyricSegment], duration: float) -> None: + index = 0 + while index < len(timeline): + if timeline[index].end > timeline[index].start: + index += 1 + continue + run_start = index + while index < len(timeline) and timeline[index].end <= timeline[index].start: + index += 1 + run_end = index + previous_end = timeline[run_start - 1].end if run_start else 0.0 + next_start = timeline[run_end].start if run_end < len(timeline) else duration + count = run_end - run_start + available = max(0.2 * count, next_start - previous_end) + for position, cue_index in enumerate(range(run_start, run_end)): + timeline[cue_index].start = round(previous_end + available * position / count, 3) + timeline[cue_index].end = round( + min(duration, previous_end + available * (position + 1) / count), 3 + ) + + +def align_authoritative_lyrics( + lyrics: str, + transcript: list[LyricSegment], + duration: float, +) -> tuple[list[LyricSegment], dict]: + """Align literal lyric lines to audio-derived word boundaries.""" + lines = authoritative_lines(lyrics) + audio_words = _flatten_words(transcript) + authored_tokens = [token for line in lines for token in line["tokens"]] + if not lines: + return [], {"method": "none", "coverage": 0.0, "matched_words": 0, "total_words": 0} + matches = _global_word_matches( + [token["normalized"] for token in authored_tokens], + [normalise_token(word.text) for word in audio_words], + ) + timeline = _build_cues(lines, audio_words, matches) + _interpolate_missing_cues(timeline, duration) + matched_count = len(matches) + return timeline, { + "method": "authoritative_lyrics_word_alignment", + "coverage": round(matched_count / max(1, len(authored_tokens)), 3), + "matched_words": matched_count, + "total_words": len(authored_tokens), + "approximate_lines": sum(cue.source == "interpolated" for cue in timeline), + } + + +def lyrics_to_srt(timeline: list[LyricSegment]) -> str: + def stamp(seconds: float) -> str: + milliseconds = max(0, round(seconds * 1000)) + hours, milliseconds = divmod(milliseconds, 3_600_000) + minutes, milliseconds = divmod(milliseconds, 60_000) + secs, milliseconds = divmod(milliseconds, 1000) + return f"{hours:02d}:{minutes:02d}:{secs:02d},{milliseconds:03d}" + + return "\n\n".join( + f"{index}\n{stamp(cue.start)} --> {stamp(max(cue.end, cue.start + 0.1))}\n{cue.text}" + for index, cue in enumerate(timeline, 1) + ) + ("\n" if timeline else "") + + +def build_visual_events(timeline: list[LyricSegment]) -> list[dict]: + patterns = { + "entrance": ("entra", "entrado", "aparece", "llega", "vuelve", "enter", "appears", "arrives", "returns", "joins"), + "transformation": ("transforma", "transform", "clona", "clone", "convierte", "becomes"), + "impact": ("explota", "explosion", "estalla", "rayo", "lightning", "breaks", "rompe"), + } + events: list[dict] = [] + for cue_index, cue in enumerate(timeline): + for word in cue.words or []: + normalized = normalise_token(word.text) + kind = next((name for name, triggers in patterns.items() if any(normalized.startswith(trigger) for trigger in triggers)), None) + if kind: + events.append({ + "time": round(word.start, 3), "end": round(word.end, 3), + "kind": kind, "cue_index": cue_index, "lyric": cue.text, + "trigger": word.text, + "rule": "The visual action starts here; do not reveal its result earlier.", + }) + return events + + +def build_timing_bundle( + lyrics: str, + transcript: list[LyricSegment], + duration: float, +) -> dict: + """Create one canonical timeline payload for the audio analyser.""" + warnings: list[str] = [] + if has_authoritative_lyrics(lyrics): + timeline, timing = align_authoritative_lyrics(lyrics, transcript, duration) + if timing["coverage"] < 0.7: + warnings.append( + "Written lyrics had low audio alignment coverage; approximate cues are marked in the timeline." + ) + else: + timeline = transcript + word_count = sum(len(segment.words or []) for segment in transcript) + timing = { + "method": "automatic_transcription", "coverage": 1.0 if transcript else 0.0, + "matched_words": word_count, "total_words": word_count, + "approximate_lines": 0, + } + return { + "timeline": timeline, + "timing": timing, + "srt": lyrics_to_srt(timeline), + "visual_events": build_visual_events(timeline), + "warnings": warnings, + } + + +def structure_from_aligned_lyrics(timeline: list[dict]) -> list[dict]: + aliases = { + "pre chorus": "pre-chorus", "prechorus": "pre-chorus", + "inst": "instrumental", "solo": "instrumental", + "intro hablado": "intro", "spoken intro": "intro", + "verso": "verse", "estrofa": "verse", + "pre estribillo": "pre-chorus", "preestribillo": "pre-chorus", + "estribillo": "chorus", "coro": "chorus", + "ultimo estribillo": "chorus", "final chorus": "chorus", + "puente": "bridge", "puente hablado": "bridge", "spoken bridge": "bridge", + "outro hablado": "outro", "spoken outro": "outro", "instrumental": "instrumental", + } + valid_labels = {"intro", "verse", "pre-chorus", "chorus", "bridge", "outro", "instrumental"} + structure: list[dict] = [] + previous = None + for cue in timeline or []: + display = str(cue.get("section") or "").strip() if isinstance(cue, dict) else "" + if not display or display.casefold() == previous: + continue + normalized = unicodedata.normalize("NFKD", display.casefold()) + normalized = "".join(ch for ch in normalized if not unicodedata.combining(ch)) + normalized = re.sub(r"\d+$", "", normalized).strip() + normalized = re.sub(r"[-_]+", " ", normalized) + label = aliases.get(normalized, normalized.replace(" ", "-")) + structure.append({ + "label": label if label in valid_labels else "verse", + "display_label": display, + "start": round(float(cue.get("start", 0.0) or 0.0), 3), + }) + previous = display.casefold() + return structure + + +def attach_timing_to_clips(clips: list[dict], analysis: dict) -> None: + """Attach absolute and clip-relative lyric timing in place.""" + timeline = analysis.get("lyric_timeline") or analysis.get("lyrics") or [] + events = analysis.get("visual_events") or [] + for clip in clips: + start, end = clip["start"], clip["end"] + clip["lyric_cues"] = [ + { + **dict(cue), + "offset": round(max(0.0, float(cue.get("start", 0.0)) - start), 3), + } + for cue in timeline + if isinstance(cue, dict) + and float(cue.get("start", 0.0)) < end + and float(cue.get("end", 0.0)) > start + ] + clip["visual_events"] = [ + { + **dict(event), + "offset": round(max(0.0, float(event.get("time", 0.0)) - start), 3), + } + for event in events + if isinstance(event, dict) + and start <= float(event.get("time", -1.0)) < end + ] diff --git a/app/services/mcp_access.py b/app/services/mcp_access.py index c40651ff9..06f3feb23 100644 --- a/app/services/mcp_access.py +++ b/app/services/mcp_access.py @@ -34,7 +34,7 @@ def status(self): with self.lock: environment = bool(self.env_token()) return {'enabled': bool(self.token()), 'managedByEnvironment': environment, - 'endpoint': '/api/v1/wangp/mcp', 'transport': 'streamable-http', + 'endpoint': '/api/v1/mcp', 'transport': 'streamable-http', 'authentication': 'Bearer', 'protocolVersion': '2025-03-26'} def update(self, enabled: bool, rotate: bool = False): diff --git a/app/services/mix_concat.py b/app/services/mix_concat.py index e5e98af80..d7df50951 100644 --- a/app/services/mix_concat.py +++ b/app/services/mix_concat.py @@ -28,9 +28,11 @@ def should_use_hold_crossfade( """Soft joins add hold+crossfade time and must not change a locked timeline. Recast / Repaint / Outpaint pass ``audio_duration_sec`` (and often - ``pad_audio``) so the assembled shot count stays exact. Those callers - then reject any frame-count drift and delete the mix. Free-form Director - and Series joins omit that lock and still get the freeze-tail dissolve. + ``pad_audio``) so the assembled shot count stays exact. Comic movies + pass the planned duration for the same reason (hard cuts only). Those + callers then reject any frame-count drift and delete the mix. Free-form + Director and Series joins omit that lock and still get the freeze-tail + dissolve. """ if int(clip_count) < 2: return False @@ -132,6 +134,23 @@ def _run_ffmpeg_command( return False +def driving_soundtrack_bound( + clip_seconds: Sequence[float], + *, + slack_sec: float = 2.0, +) -> float: + """Finite apad cap so ``-shortest`` cannot cut the concatenated pictures. + + ``audio_start_sec`` is applied earlier as ``atrim=start`` on the source + track. After ``asetpts=PTS-STARTPTS`` the remaining audio must cover the + full video span. Subtracting the source offset from the clip sum used to + bound a mid-song Director join (start=12.5, 10s of clips → 2.1s) and + discard the tail of the movie. + """ + span = sum(max(0.0, float(duration)) for duration in clip_seconds) + return max(0.1, span) + max(0.0, float(slack_sec)) + + def probe_duration_seconds(path: str, ffmpeg_bin: str = "ffmpeg") -> float | None: ffprobe_bin = ffmpeg_bin.replace("ffmpeg", "ffprobe") try: @@ -235,8 +254,11 @@ def build_hold_crossfade_filter( and not all(audio_flags) ) for index in range(count): + # xfade rejects mismatched timebases (1/30 vs 1/15360 at the same fps, + # or encoder tbn vs AV_TIME_BASE). Force a common TB before the hold. parts.append( - f"[{index}:v]tpad=stop_mode=clone:stop_duration={hold:.3f}[v{index}]" + f"[{index}:v]settb=AVTB,setpts=PTS-STARTPTS," + f"tpad=stop_mode=clone:stop_duration={hold:.3f}[v{index}]" ) if mix_audio: if use_silence_pads and audio_flags is not None: diff --git a/app/services/platform_capabilities.py b/app/services/platform_capabilities.py new file mode 100644 index 000000000..5f4cffaa6 --- /dev/null +++ b/app/services/platform_capabilities.py @@ -0,0 +1,248 @@ +"""Platform capability authority for NVIDIA-local vs core/remote profiles. + +The UI and MCP must not infer support from GPU names. This module never +imports Torch or CUDA libraries. +""" +from __future__ import annotations + +import platform +import shutil +from typing import Any, Mapping + +FEATURE_UNAVAILABLE = "feature_unavailable" + +AVAILABLE = "available" +DISABLED = "disabled" +HIDDEN = "hidden" + +PROFILE_LINUX_NVIDIA = "linux-nvidia-local" +PROFILE_WINDOWS_NVIDIA = "windows-nvidia-local" +PROFILE_MACOS_ARM64 = "macos-arm64-core-remote" +PROFILE_MACOS_INTEL = "macos-intel-unsupported" +PROFILE_CORE_REMOTE = "core-remote" + +ALWAYS_ON = ( + "projects", + "editors", + "video3d", + "remote_llm", + "remote_image", + "remote_music", + "remote_3d", +) +NVIDIA_LOCAL = ( + "local_llm", + "wangp_local", + "minimax_h3_local", + "hunyuan3d_local", + "local_audio_ai", + "sam_inpaint", + "unirig_ai", + "whisper_local", +) +NVIDIA_ALTERNATIVE = { + "local_llm": "remote_llm", + "wangp_local": "remote_image", + "minimax_h3_local": "remote_image", + "hunyuan3d_local": "remote_3d", + "local_audio_ai": "remote_music", + "sam_inpaint": "editors", + "unirig_ai": "editors", + "whisper_local": "editors", +} + + +class CapabilityDenied(Exception): + """Raised when a mutating path asks for a capability that is not available.""" + + def __init__( + self, + capability: str, + state: str, + reason_code: str, + alternative: str | None, + ) -> None: + super().__init__(capability) + self.capability = capability + self.state = state + self.reason_code = reason_code + self.alternative = alternative + + def as_detail(self) -> dict[str, Any]: + return { + "code": FEATURE_UNAVAILABLE, + "capability": self.capability, + "state": self.state, + "reason_code": self.reason_code, + "alternative": self.alternative, + } + + +def host_platform() -> str: + return platform.system().lower() + + +def host_machine() -> str: + machine = (platform.machine() or "").lower() + if machine in {"aarch64", "arm64"}: + return "arm64" + if machine in {"x86_64", "amd64"}: + return "x86_64" + return machine or "unknown" + + +def resolve_profile( + system: str, + machine: str, + *, + nvidia_local: bool | None = None, +) -> str: + if system == "darwin": + return PROFILE_MACOS_ARM64 if machine == "arm64" else PROFILE_MACOS_INTEL + if nvidia_local is False: + return PROFILE_CORE_REMOTE + if system == "windows": + return PROFILE_WINDOWS_NVIDIA + if system == "linux": + return PROFILE_LINUX_NVIDIA + return PROFILE_CORE_REMOTE + + +def _entry(state: str, reason_code: str, *, alternative: str | None = None, provider: str | None = None) -> dict[str, Any]: + return { + "state": state, + "reason_code": reason_code, + "provider": provider, + "alternative": alternative, + } + + +def _binary_state(present: bool) -> dict[str, Any]: + if present: + return _entry(AVAILABLE, "binary_present", provider="local") + return _entry(DISABLED, "missing_binary", alternative="manual") + + +def _nvidia_local_entries() -> dict[str, dict[str, Any]]: + return { + capability: _entry(AVAILABLE, "nvidia_local", provider="local-nvidia") + for capability in NVIDIA_LOCAL + } + + +def _core_remote_entries(*, hide: bool) -> dict[str, dict[str, Any]]: + state = HIDDEN if hide else DISABLED + reason = "macos_core_remote" if hide else "requires_nvidia" + return { + capability: _entry( + state, + reason, + provider="local-nvidia", + alternative=NVIDIA_ALTERNATIVE[capability], + ) + for capability in NVIDIA_LOCAL + } + + +def build_capabilities( + *, + system: str, + machine: str, + nvidia_local: bool | None = None, + ffmpeg_present: bool | None = None, + rhubarb_present: bool | None = None, +) -> dict[str, Any]: + profile = resolve_profile(system, machine, nvidia_local=nvidia_local) + capabilities = { + capability: _entry(AVAILABLE, "core", provider="core") + for capability in ALWAYS_ON + } + if profile in {PROFILE_LINUX_NVIDIA, PROFILE_WINDOWS_NVIDIA}: + capabilities.update(_nvidia_local_entries()) + show_cuda = True + mode = "nvidiaLocal" + elif profile == PROFILE_MACOS_INTEL: + capabilities.update(_core_remote_entries(hide=True)) + show_cuda = False + mode = "macosIntel" + else: + capabilities.update(_core_remote_entries(hide=True)) + show_cuda = False + mode = "macosCoreRemote" if profile == PROFILE_MACOS_ARM64 else "coreRemote" + + ffmpeg = True if ffmpeg_present is None else ffmpeg_present + rhubarb = False if rhubarb_present is None else rhubarb_present + if ffmpeg_present is None: + ffmpeg = shutil.which("ffmpeg") is not None + if rhubarb_present is None: + rhubarb = shutil.which("rhubarb") is not None + capabilities["ffmpeg"] = _binary_state(ffmpeg) + capabilities["rhubarb"] = _binary_state(rhubarb) + + return { + "platform": system, + "arch": machine, + "profile": profile, + "accelerators": { + "cuda": profile in {PROFILE_LINUX_NVIDIA, PROFILE_WINDOWS_NVIDIA}, + "mps": profile == PROFILE_MACOS_ARM64, + "metal": profile == PROFILE_MACOS_ARM64, + }, + "ui": { + "mode": mode, + "show_cuda_controls": show_cuda, + }, + "capabilities": capabilities, + } + + +def platform_capabilities(**overrides: Any) -> dict[str, Any]: + return build_capabilities( + system=overrides.get("system") or host_platform(), + machine=overrides.get("machine") or host_machine(), + nvidia_local=overrides.get("nvidia_local"), + ffmpeg_present=overrides.get("ffmpeg_present"), + rhubarb_present=overrides.get("rhubarb_present"), + ) + + +def capability_state(capability: str, snapshot: Mapping[str, Any] | None = None) -> str: + snap = snapshot or platform_capabilities() + entry = snap.get("capabilities", {}).get(capability) + if not isinstance(entry, Mapping): + return HIDDEN + state = entry.get("state") + return state if state in {AVAILABLE, DISABLED, HIDDEN} else HIDDEN + + +def visible_capabilities( + snapshot: Mapping[str, Any] | None = None, + *, + include_disabled: bool = True, +) -> list[str]: + snap = snapshot or platform_capabilities() + allowed = {AVAILABLE, DISABLED} if include_disabled else {AVAILABLE} + return [ + name + for name, entry in snap.get("capabilities", {}).items() + if isinstance(entry, Mapping) and entry.get("state") in allowed + ] + + +def require_capability(capability: str, snapshot: Mapping[str, Any] | None = None) -> None: + snap = snapshot or platform_capabilities() + entry = snap.get("capabilities", {}).get(capability) + if not isinstance(entry, Mapping): + raise CapabilityDenied(capability, HIDDEN, "unknown_capability", None) + state = str(entry.get("state") or HIDDEN) + if state == AVAILABLE: + return + raise CapabilityDenied( + capability, + state, + str(entry.get("reason_code") or "requires_nvidia"), + entry.get("alternative"), + ) + + + diff --git a/app/services/runtime_profiles.py b/app/services/runtime_profiles.py index f430a01c3..878ab64e9 100644 --- a/app/services/runtime_profiles.py +++ b/app/services/runtime_profiles.py @@ -33,14 +33,18 @@ def _version(value: str) -> tuple[int, ...]: return (parts + (0, 0, 0))[:3] -def recipe(engine: str, platform: str) -> dict: +def recipe(engine: str, platform: str, arch: str | None = None) -> dict: base = copy.deepcopy(catalog()["engines"][engine]) override = base.pop("windows", {}) if platform == "win32" else {} base.pop("windows", None) pins = {**base["constraints"], **override.get("constraints", {})} base.update(override) base["constraints"] = {k: v for k, v in pins.items() if v is not None} - base["id"] = f"{platform}-x64-nvidia-{engine}" + arch = normalize_arch(arch or ("arm64" if platform == "darwin" else "x64")) + if base.get("cuda"): + base["id"] = f"{platform}-x64-nvidia-{engine}" + else: + base["id"] = f"{platform}-{arch}-core-{engine}" base["engine"] = engine base["constraintFile"] = f"app/runtime/constraints/{platform}-{engine}.txt" return base @@ -73,7 +77,8 @@ def installation_current(engine: str, platform: str) -> bool: if not isinstance(receipt, dict): return False matches = (receipt.get("fingerprint") == dependency_fingerprint(engine, platform) - and receipt.get("profile") == spec["id"] and receipt.get("cudaCalculation") is True) + and receipt.get("profile") == spec["id"] + and receipt.get("cudaCalculation") is bool(spec.get("cuda"))) if not matches: return False from services.runtime_sources import sources_current @@ -91,35 +96,55 @@ def installation_current(engine: str, platform: str) -> bool: return False +def _common_reason(manifest: dict, platform: str, arch: str, gpu: str) -> str | None: + if platform == "darwin" and arch == "arm64": + return None + if platform not in manifest["platforms"]: + return f"No installation recipe for {platform}. Supported: Windows, Linux and Apple Silicon." + if arch not in manifest["architectures"]: + return f"No installation recipe for architecture {arch}; x64 is required." + if gpu not in manifest["accelerators"]: + return "Local AI installation currently requires NVIDIA; CPU/AMD/Intel/MPS recipes are not enabled." + return None + + +def _engine_support(definition: dict, platform: str, arch: str, driver: str | None, common: str | None, manifest: dict) -> tuple[str | None, str | None, str | None]: + macos_core = platform == "darwin" and arch == "arm64" + reason = common + warning = None + if macos_core and definition.get("cuda"): + reason = "Local NVIDIA engine; hidden on Apple Silicon core/remote." + elif not reason and platform not in definition["platforms"]: + reason = definition.get("unsupportedReason", "No compatible engine recipe.") + minimum = None + if definition.get("cuda"): + minimum = manifest["driverMinimum"][definition["cuda"]].get(platform) + if not reason and driver and minimum and _version(driver) < _version(minimum): + reason = f"{definition['label']} needs NVIDIA driver >= {minimum} for CUDA {definition['cuda']} (detected {driver})." + if not reason and not driver and definition.get("cuda"): + warning = "NVIDIA driver version could not be verified; the runtime check must confirm CUDA before use." + return reason, warning, minimum + + def select_profiles(platform: str, arch: str, gpu: str, driver: str | None = None) -> dict: """Pure selection; unknown/unsupported capabilities never silently use CUDA.""" arch = normalize_arch(arch) gpu = (gpu or "unknown").lower() manifest = catalog() - common = None - if platform not in manifest["platforms"]: - common = f"No installation recipe for {platform}. Supported: Windows and Linux." - elif arch not in manifest["architectures"]: - common = f"No installation recipe for architecture {arch}; x64 is required." - elif gpu not in manifest["accelerators"]: - common = "Local AI installation currently requires NVIDIA; CPU/AMD/Intel/MPS recipes are not enabled." + common = _common_reason(manifest, platform, arch, gpu) engines = {} for name, definition in manifest["engines"].items(): - selected = recipe(name, platform) - reason = common - warning = None - if not reason and platform not in definition["platforms"]: - reason = definition.get("unsupportedReason", "No compatible engine recipe.") - minimum = manifest["driverMinimum"][definition["cuda"]].get(platform) - if not reason and driver and minimum and _version(driver) < _version(minimum): - reason = f"{definition['label']} needs NVIDIA driver >= {minimum} for CUDA {definition['cuda']} (detected {driver})." - if not reason and not driver: - warning = "NVIDIA driver version could not be verified; the runtime check must confirm CUDA before use." - engines[name] = {**selected, "supported": reason is None, "reason": reason, + reason, warning, minimum = _engine_support(definition, platform, arch, driver, common, manifest) + engines[name] = {**recipe(name, platform, arch), "supported": reason is None, "reason": reason, "warning": warning, "driverMinimum": minimum} + required = [ + engines[name]["supported"] + for name, definition in manifest["engines"].items() + if definition.get("required") and platform in definition.get("platforms", []) + ] return {"version": manifest["version"], "revision": manifest["revision"], "platform": platform, "architecture": arch, "gpu": gpu, "driver": driver, - "supported": all(e["supported"] for e in engines.values() if e["required"]), + "supported": all(required) if required else False, "engines": engines} diff --git a/app/services/scene3d_speech.py b/app/services/scene3d_speech.py index 10e931960..1b6f82fba 100644 --- a/app/services/scene3d_speech.py +++ b/app/services/scene3d_speech.py @@ -11,6 +11,8 @@ import threading import wave +from services.speech_analysis_cache import analysis_material, remember + MAX_BYTES = 3_000_000 _LOCK = threading.BoundedSemaphore(1) @@ -28,7 +30,9 @@ def rhubarb_executable() -> str | None: if configured: candidate = Path(configured) return str(candidate) if candidate.is_absolute() and candidate.is_file() else None - return shutil.which("rhubarb") + from services.install_speech_tools import bundled_executable + bundled = bundled_executable() + return shutil.which("rhubarb") or (str(bundled) if bundled.is_file() else None) def validate_voice_wav(data: bytes) -> float: @@ -47,46 +51,75 @@ def validate_voice_wav(data: bytes) -> float: raise SpeechAnalysisError("Invalid PCM WAV.") from exc -def analyze_voice(data: bytes, isolate_vocals: bool = False) -> dict: +def analyze_voice(data: bytes, isolate_vocals: bool = False, dialogue: str = "", language: str = "") -> dict: duration = validate_voice_wav(data) executable = rhubarb_executable() if not executable: raise SpeechAnalysisUnavailable("Rhubarb is not installed. Set RHUBARB_EXECUTABLE or put rhubarb on PATH; you can also import a cues JSON or use volume analysis.") + isolation = None + if isolate_vocals: + from services.vocal_isolation import isolation_key_material + isolation = isolation_key_material() + recognizer = "pocketSphinx" if language.lower().split('-')[0] == "en" else "phonetic" + options = {"recognizer": recognizer, "extendedShapes": "GHX", "threads": 2} + if dialogue: + options["dialogue"] = dialogue + material = analysis_material( + data, duration, isolate_vocals, executable, + options, isolation) + payload = remember(material, lambda: json.dumps(_analyze_uncached(data, duration, isolate_vocals, executable, recognizer, dialogue)).encode(), ".json") + return json.loads(payload) + + +def _analyze_uncached(data: bytes, duration: float, isolate_vocals: bool, executable: str, recognizer: str = "phonetic", dialogue: str = "") -> dict: if not _LOCK.acquire(blocking=False): raise SpeechAnalysisUnavailable("Another local speech analysis is running. Try again shortly.") try: if isolate_vocals: from services.vocal_isolation import isolate_voice data = isolate_voice(data) - # Keep diagnostic files: never delete user audio or imported assets. - folder = Path(tempfile.mkdtemp(prefix="hocuspocus-speech-")) - source, output = folder / "voice.wav", folder / "cues.json" - source.write_bytes(data) - try: - completed = subprocess.run( - [executable, "--threads", "2", "--quiet", "-r", "phonetic", - "--extendedShapes", "GHX", "-f", "json", "-o", str(output), str(source)], - stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, - timeout=90, check=False, shell=False, - creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0, - ) - except (subprocess.TimeoutExpired, OSError) as exc: - raise SpeechAnalysisUnavailable("Local speech analysis failed or timed out.") from exc - if completed.returncode or not output.is_file() or output.stat().st_size > 2_000_000: - raise SpeechAnalysisUnavailable("Local speech analysis produced no valid result.") - try: - cues = json.loads(output.read_text(encoding="utf-8"))["mouthCues"] - if not isinstance(cues, list) or len(cues) > 10000: - raise ValueError("Invalid cues") - previous = 0.0 - for cue in cues: - start, end = float(cue["start"]), float(cue["end"]) - if cue["value"] not in "ABCDEFGHX" or len(cue["value"]) != 1 or not previous <= start < end <= duration + .1: - raise ValueError("Invalid cue") - previous = end - except (KeyError, ValueError, TypeError, OSError) as exc: - raise SpeechAnalysisUnavailable("Local speech analysis produced invalid cues.") from exc - return {"mouthCues": cues, "recognizer": "phonetic", "duration": duration, + cues = _rhubarb_mouth_cues(executable, data, duration, recognizer, dialogue) + return {"mouthCues": cues, "recognizer": recognizer, "duration": duration, "analysisSource": "isolated-vocals" if isolate_vocals else "original"} finally: _LOCK.release() + + +def _rhubarb_mouth_cues(executable: str, data: bytes, duration: float, recognizer: str = "phonetic", dialogue: str = "") -> list: + # Keep diagnostic files: never delete user audio or imported assets. + folder = Path(tempfile.mkdtemp(prefix="hocuspocus-speech-")) + source, output = folder / "voice.wav", folder / "cues.json" + source.write_bytes(data) + arguments = [] + if dialogue: + transcript = folder / "dialogue.txt" + transcript.write_text(dialogue, encoding="utf-8") + arguments = ["--dialogFile", str(transcript)] + try: + completed = subprocess.run( + [executable, "--threads", "2", "--quiet", "-r", recognizer, + "--extendedShapes", "GHX", "-f", "json", "-o", str(output), *arguments, str(source)], + stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + timeout=90, check=False, shell=False, + creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0, + ) + except (subprocess.TimeoutExpired, OSError) as exc: + raise SpeechAnalysisUnavailable("Local speech analysis failed or timed out.") from exc + if completed.returncode or not output.is_file() or output.stat().st_size > 2_000_000: + raise SpeechAnalysisUnavailable("Local speech analysis produced no valid result.") + try: + return _accepted_mouth_cues(json.loads(output.read_text(encoding="utf-8"))["mouthCues"], duration) + except (KeyError, ValueError, TypeError, OSError) as exc: + raise SpeechAnalysisUnavailable("Local speech analysis produced invalid cues.") from exc + + +def _accepted_mouth_cues(cues, duration: float) -> list: + if not isinstance(cues, list) or len(cues) > 10000: + raise ValueError("Invalid cues") + previous = 0.0 + for cue in cues: + start, end = float(cue["start"]), float(cue["end"]) + if cue["value"] not in "ABCDEFGHX" or len(cue["value"]) != 1 or not previous <= start < end <= duration + .1: + raise ValueError("Invalid cue") + previous = end + return cues diff --git a/app/services/scene_commands.py b/app/services/scene_commands.py index e0e7d330a..9b758ce7f 100644 --- a/app/services/scene_commands.py +++ b/app/services/scene_commands.py @@ -15,10 +15,20 @@ CATALOG = json.loads((Path(__file__).parent.parent / 'shared' / 'scene_effects.json').read_text()) PRESETS = {entry['id']: entry for entry in CATALOG} +ANIME_COUNT = sum(1 for item in CATALOG if item['collection'] == 'anime') +RETRO_COUNT = sum(1 for item in CATALOG if item['collection'] == 'retro') +ALL_SECONDS = 3 * len(CATALOG) +ANIME_SECONDS = 3 * ANIME_COUNT +RETRO_SECONDS = 3 * RETRO_COUNT +# Keep in lockstep with ui/src/features/sceneFx/world.ts WORLD_SFX_KINDS. +# Additive apply revalidates every existing worldSfx cue against this set. WORLD_KINDS = { 'portal', 'magic_circle', 'summoning_gate', 'lightning', 'energy_beam', 'laser', - 'energy_orb', 'anime_aura', 'arcane_missiles', 'shockwave', + 'energy_orb', 'anime_aura', 'arcane_missiles', 'shockwave', 'smoke', 'sparks', + 'explosion', + 'fire', 'rain', 'snow', 'fog', 'shield', 'tornado', 'splash', 'dust', 'ice_burst', 'black_hole', + 'media_portal', } @@ -102,12 +112,21 @@ class WorldFxCue(Strict): anchor: dict | None = None target: dict | None = None targetPosition: WorldVec | None = None + sourceUrl: str | None = Field(default=None, max_length=2000) @model_validator(mode='after') def valid_world_preset(self): - if self.kind not in WORLD_KINDS or self.end <= self.start: + preset = PRESETS.get(self.kind) + if self.kind not in WORLD_KINDS or preset is None or self.end <= self.start: raise ValueError('World SFX need a supported world kind and an end later than start') - self.color = self.color or PRESETS[self.kind]['color'] + self.color = self.color or preset['color'] + if self.sourceUrl is not None: + url = self.sourceUrl.strip() + lowered = url.lower() + if not url or lowered.startswith(('javascript:', 'blob:', 'file:', 'filesystem:')): + self.sourceUrl = None + else: + self.sourceUrl = url[:2000] for field_name in ('anchor', 'target'): value = getattr(self, field_name) if value is None: @@ -128,7 +147,7 @@ class EffectsShowcase(Strict): document: dict | None = None dimension: Literal['2d', '3d'] = '3d' sound: bool = True - collection: Literal['all', 'anime'] = 'all' + collection: Literal['all', 'anime', 'retro'] = 'all' @model_validator(mode='after') def resolve_document(self): @@ -156,9 +175,9 @@ class SpeechPrepare(DocumentInput): OPERATIONS = { 'scenes.speech.capabilities': (Strict, 'Read local Rhubarb and optional installed-only CPU BS-RoFormer availability. No model downloads or inference.'), - 'scenes.effects.catalog': (Strict, 'List 30 screen overlays plus world-space kinds in result.worldKinds (portal, magic_circle, summoning_gate, lightning, energy_beam, laser, energy_orb, anime_aura, arcane_missiles, shockwave). Screen uses percent; world uses meters. No AI generation.'), + 'scenes.effects.catalog': (Strict, f'List {len(CATALOG)} screen overlays plus world-space kinds in result.worldKinds (portal, magic_circle, summoning_gate, lightning, energy_beam, laser, energy_orb, anime_aura, arcane_missiles, shockwave, smoke, sparks, explosion, fire, rain, snow, fog, shield, tornado, splash, dust, ice_burst, black_hole, media_portal). Screen uses percent; world uses meters. Retro looks (psx, vhs, crt, consoles) are screen-only. No AI generation.'), 'scenes.effects.apply': (EffectsApply, 'Return an editable 2D/3D document with timed SFX. Screen cues go to sfx; worldCues go to worldSfx on Video3D only. Matching IDs replace in place. No save or export.'), - 'scenes.effects.showcase': (EffectsShowcase, 'Return a reusable SFX showcase: all effects 90 seconds, or collection anime 36 seconds. Retains actors/camera and replaces only SFX. No save or export.'), + 'scenes.effects.showcase': (EffectsShowcase, f'Return a reusable SFX showcase: all effects {ALL_SECONDS} seconds, collection anime {ANIME_SECONDS} seconds, or collection retro {RETRO_SECONDS} seconds. Retains actors/camera and replaces only SFX. No save or export.'), 'scenes.speech.prepare': (SpeechPrepare, 'Analyze an existing workspace voice with Rhubarb and attach it to an exact 3D speaker/clip. Optional isolate_vocals uses installed-only local CPU BS-RoFormer, preserving original playback. Returns an editable document; face calibration may be needed. No downloads, voice generation, save or video export.'), } diff --git a/app/services/scene_packages.py b/app/services/scene_packages.py new file mode 100644 index 000000000..2974cfcdf --- /dev/null +++ b/app/services/scene_packages.py @@ -0,0 +1,1031 @@ +"""Portable Video3D packages: hashed media, preflight repair, zip-slip guards.""" +from __future__ import annotations + +import base64 +import hashlib +import io +import json +import os +import re +import stat +import zipfile +from copy import deepcopy +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Callable, Iterable, Mapping +from urllib.parse import parse_qs, unquote, urlparse + +from services.asset_manifest import ( + build_asset_manifest, + infer_asset_kind, + read_asset_manifest, + sidecar_path, + write_asset_manifest, +) +from services.scene_commands import DocumentInput +from services.scene_library import save_world3d + +PACKAGE_KIND = "hocuspocus.scene-package" +PACKAGE_SCHEMA = "hocuspocus.scene-package" +PACKAGE_VERSION = 1 +TEMPLATE_KIND = "hocuspocus.world3d.template" +MANIFEST_NAME = "package.json" +MEDIA_DIR = "media" +DOCUMENTS_DIR = "documents" +PACKAGE_FILENAME = "package.workspace" +HASH_PREFIX = "sha256:" + +MAX_ZIP_BYTES = 256 * 1024 * 1024 +MAX_UNCOMPRESSED_BYTES = 512 * 1024 * 1024 +MAX_MEMBER_BYTES = 128 * 1024 * 1024 +MAX_DOCUMENTS = 32 +MAX_ASSETS = 256 +MAX_DOCUMENT_BYTES = 2 * 1024 * 1024 +MAX_EXPORT_BODY = 8 * 1024 * 1024 + +_WORKSPACE = re.compile(r"(?:default|[A-Za-z0-9][A-Za-z0-9_-]{0,119})$") +_SHA256 = re.compile(r"^[0-9a-f]{64}$") +_SAFE_SEGMENT = re.compile(r"^[A-Za-z0-9._-]+$") + +ALLOWED_MEDIA_EXT = frozenset({ + ".glb", ".gltf", ".png", ".jpg", ".jpeg", ".webp", ".gif", + ".wav", ".mp3", ".ogg", ".flac", ".m4a", ".aac", + ".mp4", ".webm", ".mov", +}) +CINEMA_MEMBER_HINTS = frozenset({ + "cinema.js", "cinema.ts", "cinema.mjs", "tools/cinema.js", + "tools/cinema.ts", "tools/cinema.mjs", "cinema/runtime.js", + "cinema/runtime.ts", +}) +SUPPORTED_CINEMA_EXTENSIONS = frozenset() +KNOWN_DOCUMENT_KEYS = frozenset({ + "version", "units", "up", "width", "height", "fps", "duration", + "templateId", "camera", "light", "slots", "soundtrack", "production", + "clipNumber", "sfx", "worldSfx", "texts", "playbackSpeed", + "environment", "dressing", "workshopScreen", +}) +KNOWN_WRAPPER_KEYS = frozenset({ + "kind", "version", "id", "title", "description", "includeAssets", + "createdAt", "document", +}) +KNOWN_SLOT_KEYS = frozenset({ + "id", "slot", "position", "rotationY", "scale", "sourceUrl", "sourceRef", + "speech", "media", "screen", "surface", "appearance", "textureRepeat", + "performance", "grounded", "clip", "clipPlayback", "motion", "loop", + "character", +}) +CINEMA_FIELD_NAMES = ("cinema", "cinemaExtension", "cinemaRuntime") + +STUB_PNG = b"\x89PNG\r\n\x1a\npreview" +STUB_PREVIEW = "data:image/png;base64," + base64.b64encode(STUB_PNG).decode("ascii") + +class ScenePackageError(ValueError): + status = 422 + code = "invalid_package" + + +class ScenePackageTooLarge(ScenePackageError): + status = 413 + code = "too_large" + + +class ScenePackageSecurity(ScenePackageError): + status = 422 + code = "rejected" + + +def utc_now() -> str: + return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") + + +def sha256_bytes(data: bytes) -> str: + return hashlib.sha256(data).hexdigest() + + +def sha256_file(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def require_workspace(name: str) -> str: + text = str(name or "").strip() + if not _WORKSPACE.fullmatch(text): + raise ScenePackageError("Choose an explicit workspace") + return text + + +def _issue(code: str, message: str, *, repair: bool = False, path: str = "") -> dict[str, Any]: + item: dict[str, Any] = {"code": code, "message": message, "repair": repair} + if path: + item["path"] = path + return item + + +def is_template_wrapper(value: Any) -> bool: + return isinstance(value, Mapping) and value.get("kind") == TEMPLATE_KIND + + +def _cinema_from_field(field: Any) -> str | None: + if isinstance(field, str) and field.strip(): + return field.strip() + if isinstance(field, Mapping): + ext = field.get("extension") or field.get("id") or field.get("kind") + if ext: + return str(ext) + return None + + +def cinema_extension_of(value: Any) -> str | None: + if not isinstance(value, Mapping): + return None + kind = str(value.get("kind") or "") + if kind.startswith("hocuspocus.cinema"): + return str(value.get("extension") or value.get("id") or kind) + for key in CINEMA_FIELD_NAMES: + found = _cinema_from_field(value.get(key)) + if found: + return found + extras = value.get("extensions") + if isinstance(extras, list): + for item in extras: + text = str(item or "").strip() + if "cinema" in text: + return text + return None + + +def reject_unknown_cinema(value: Any) -> None: + ext = cinema_extension_of(value) + if ext and ext not in SUPPORTED_CINEMA_EXTENSIONS: + raise ScenePackageSecurity(f"Unknown cinema extension: {ext}") + if is_template_wrapper(value): + reject_unknown_cinema(value.get("document")) + + +def unwrap_document(value: Any) -> dict[str, Any]: + if not isinstance(value, Mapping): + raise ScenePackageError("Each packed item must be a scene document") + if is_template_wrapper(value): + nested = value.get("document") + if not isinstance(nested, Mapping): + raise ScenePackageError("Scenario template is missing its scene document") + return dict(nested) + return dict(value) + + +def _prefixed(prefix: str, key: str) -> str: + return f"{prefix}{key}" if prefix else key + + +def _unknown_keys(record: Mapping[str, Any], known: frozenset[str], prefix: str) -> list[str]: + return [_prefixed(prefix, key) for key in record if key not in known] + + +def _unknown_slot_fields(slots: Any, prefix: str) -> list[str]: + found: list[str] = [] + if not isinstance(slots, list): + return found + for index, slot in enumerate(slots): + if isinstance(slot, Mapping): + found.extend( + f"{prefix}slots[{index}].{key}" + for key in slot if key not in KNOWN_SLOT_KEYS + ) + return found + + +def collect_unknown_fields(raw: Mapping[str, Any], prefix: str = "") -> list[str]: + if not is_template_wrapper(raw): + return _unknown_keys(raw, KNOWN_DOCUMENT_KEYS, prefix) + _unknown_slot_fields(raw.get("slots"), prefix) + found = _unknown_keys(raw, KNOWN_WRAPPER_KEYS, prefix) + nested = raw.get("document") + if isinstance(nested, Mapping): + nested_prefix = f"{prefix}document." if prefix else "document." + found.extend(collect_unknown_fields(nested, nested_prefix)) + return found + + +def _basename(value: str) -> str: + return Path(str(value or "").replace("\\", "/")).name + + +def safe_export_filename(name: str) -> str: + base = _basename(name) + cleaned = re.sub(r"[^A-Za-z0-9._-]+", "-", base).strip(".-")[:120] + return cleaned or "asset" + + +def _url_has_controls(text: str) -> bool: + return any(ord(char) <= 32 or char == "\\" for char in text) + + +def _classify_api_path(path: str) -> str | None: + if path.startswith("/api/v1/file/"): + return "gallery" + if path.startswith("/api/v1/uploads/"): + return "uploads" + return None + + +def _classify_local_path(path: str, text: str) -> str: + api = _classify_api_path(path) + if api: + return api + dotted = ".." in Path(path).parts + if path.startswith(f"{MEDIA_DIR}/"): + return "unsafe" if dotted else "relative" + if path.startswith("/") or "://" in text: + return "external" + return "unsafe" if dotted else "relative" + + +def classify_url(url: str) -> str: + text = str(url or "").strip() + if not text: + return "empty" + if _url_has_controls(text): + return "unsafe" + lowered = text.casefold() + if lowered.startswith(("blob:", "file:", "filesystem:", "javascript:", "data:")): + return "transient" + if lowered.startswith(("http://", "https://", "//")): + return "external" + parsed = urlparse(text) + return _classify_local_path(unquote(parsed.path or text.split("?", 1)[0]), text) + + +def parse_media_locator(url: str, fallback_workspace: str | None = None) -> tuple[str | None, str]: + text = str(url or "").strip() + parsed = urlparse(text) + path = unquote((parsed.path or text.split("?", 1)[0]).lstrip("/")) + workspace = fallback_workspace + query = parse_qs(parsed.query, keep_blank_values=False) + names = query.get("workspace") or [] + if names and str(names[0]).strip(): + workspace = str(names[0]).strip() + if path.startswith("api/v1/file/"): + return workspace, _basename(path[len("api/v1/file/"):]) + if path.startswith("api/v1/uploads/"): + return "__uploads__", _basename(path[len("api/v1/uploads/"):]) + return workspace, _basename(path) if "/" in path or path.startswith(MEDIA_DIR) else path + + +def _first_text(value: Mapping[str, Any], *keys: str) -> str: + for key in keys: + text = str(value.get(key) or "").strip() + if text: + return text + return "" + + +def _resolve_ref_location( + value: Mapping[str, Any], url: str, fallback_workspace: str | None, +) -> tuple[str, str]: + filename = _basename(_first_text(value, "filename")) + workspace = _first_text(value, "workspaceId", "workspace_id") or fallback_workspace or "" + if filename: + return workspace, filename + parsed_ws, parsed_name = parse_media_locator(url, workspace) + return parsed_ws or workspace, parsed_name + + +def _ref_from_mapping(value: Mapping[str, Any], fallback_workspace: str | None) -> dict[str, Any] | None: + url = _first_text(value, "url", "sourceUrl") + workspace, filename = _resolve_ref_location(value, url, fallback_workspace) + if not filename and not url: + return None + return { + "workspaceId": workspace, + "filename": filename or safe_export_filename(url), + "url": url, + "assetId": _first_text(value, "assetId", "asset_id"), + } + + +def _ref_from_any(raw: Any, url: str = "") -> dict[str, Any] | None: + ref = _ref_from_mapping(raw, None) if isinstance(raw, Mapping) else None + if ref is None and isinstance(raw, str) and raw: + workspace, filename = parse_media_locator(raw, None) + ref = {"workspaceId": workspace or "", "filename": filename, "url": raw, "assetId": ""} + if ref is None and url: + workspace, filename = parse_media_locator(url, None) + ref = {"workspaceId": workspace or "", "filename": filename, "url": url, "assetId": ""} + if ref is not None and url and not ref.get("url"): + ref["url"] = url + return ref + + +def _add_use(uses: list[dict[str, Any]], raw: Any, *, doc_id: str, role: str, kind: str, url: str = "") -> None: + ref = _ref_from_any(raw, url) + if ref is None: + return + uses.append({"doc_id": doc_id, "role": role, "kind": kind, **ref}) + + +def _uses_from_speech(speech: Mapping[str, Any], index: int, doc_id: str, uses: list[dict[str, Any]]) -> None: + _add_use(uses, speech.get("audio"), doc_id=doc_id, role=f"slots[{index}].speech.audio", kind="audio") + _add_use(uses, speech.get("atlas"), doc_id=doc_id, role=f"slots[{index}].speech.atlas", kind="image") + clips = speech.get("clips") + if not isinstance(clips, list): + return + for clip_index, clip in enumerate(clips): + if isinstance(clip, Mapping): + _add_use(uses, clip.get("audio"), doc_id=doc_id, role=f"slots[{index}].speech.clips[{clip_index}].audio", kind="audio") + + +def _uses_from_slot(slot: Any, index: int, doc_id: str, uses: list[dict[str, Any]]) -> None: + if not isinstance(slot, Mapping): + return + media = str(slot.get("media") or "model3d") + kind = "image" if media in {"image", "screen"} else "model3d" + _add_use(uses, slot.get("sourceRef"), doc_id=doc_id, role=f"slots[{index}]", kind=kind, url=str(slot.get("sourceUrl") or "")) + screen = slot.get("screen") + if isinstance(screen, Mapping): + screen_kind = "video" if screen.get("media") == "video" else "image" + _add_use(uses, screen.get("sourceRef"), doc_id=doc_id, role=f"slots[{index}].screen", kind=screen_kind, + url=str(screen.get("sourceUrl") or "")) + speech = slot.get("speech") + if isinstance(speech, Mapping): + _uses_from_speech(speech, index, doc_id, uses) + + +def _world_media_kind(url: str) -> str: + kind = _kind_from_name(_basename(url.split("?", 1)[0])) + return kind if kind in {"image", "video"} else "image" + + +def _packable_world_media(url: str, ref: Any) -> bool: + classified = classify_url(url) if url else "empty" + if classified in {"gallery", "uploads"}: + return True + return classified in {"empty", "relative"} and isinstance(ref, Mapping) + + +def _uses_from_world_sfx(cues: Any, doc_id: str, uses: list[dict[str, Any]]) -> None: + if not isinstance(cues, list): + return + for index, cue in enumerate(cues): + if not isinstance(cue, Mapping): + continue + url = str(cue.get("sourceUrl") or "") + ref = cue.get("sourceRef") + if not _packable_world_media(url, ref): + continue + _add_use(uses, ref, doc_id=doc_id, role=f"worldSfx[{index}]", kind=_world_media_kind(url), url=url) + + +def iter_asset_uses(document: Mapping[str, Any], *, doc_id: str = "") -> list[dict[str, Any]]: + uses: list[dict[str, Any]] = [] + body = unwrap_document(document) + slots = body.get("slots") if isinstance(body.get("slots"), list) else [] + for index, slot in enumerate(slots): + _uses_from_slot(slot, index, doc_id, uses) + tracks = body.get("soundtrack") if isinstance(body.get("soundtrack"), list) else [] + for index, track in enumerate(tracks): + if isinstance(track, Mapping): + _add_use(uses, track.get("audio"), doc_id=doc_id, role=f"soundtrack[{index}]", kind="audio") + _uses_from_world_sfx(body.get("worldSfx"), doc_id, uses) + return uses + + +def _rewrite_ref(container: dict[str, Any], key: str, locator: Callable[[dict[str, Any]], dict[str, Any] | None]) -> None: + current = container.get(key) + if not isinstance(current, Mapping): + return + updated = locator(dict(current)) + if updated is None: + return + container[key] = updated + + +def _source_payload(container: Mapping[str, Any], url_key: str = "sourceUrl", ref_key: str = "sourceRef") -> dict[str, Any]: + original = {"workspaceId": "", "filename": "", "url": str(container.get(url_key) or ""), "assetId": ""} + ref = container.get(ref_key) + if isinstance(ref, Mapping): + original.update(ref) + return original + + +def _apply_located( + target: dict[str, Any], + locator: Callable[[dict[str, Any]], dict[str, Any] | None], + url_key: str = "sourceUrl", + ref_key: str = "sourceRef", +) -> None: + updated = locator(_source_payload(target, url_key, ref_key)) + if updated is None: + return + target[ref_key] = updated + target[url_key] = updated["url"] + + +def _rewrite_speech(speech: dict[str, Any], locator: Callable[[dict[str, Any]], dict[str, Any] | None]) -> None: + _rewrite_ref(speech, "audio", locator) + _rewrite_ref(speech, "atlas", locator) + clips = speech.get("clips") + if not isinstance(clips, list): + return + for clip in clips: + if isinstance(clip, dict): + _rewrite_ref(clip, "audio", locator) + + +def _rewrite_slot(slot: Any, locator: Callable[[dict[str, Any]], dict[str, Any] | None]) -> None: + if not isinstance(slot, dict): + return + _apply_located(slot, locator) + screen = slot.get("screen") + if isinstance(screen, dict): + _apply_located(screen, locator) + speech = slot.get("speech") + if isinstance(speech, dict): + _rewrite_speech(speech, locator) + + +def _rewrite_track(track: Any, locator: Callable[[dict[str, Any]], dict[str, Any] | None]) -> None: + if isinstance(track, dict): + _rewrite_ref(track, "audio", locator) + + +def _rewrite_world_cue(cue: Any, locator: Callable[[dict[str, Any]], dict[str, Any] | None]) -> None: + if not isinstance(cue, dict): + return + updated = locator(_source_payload(cue)) + if updated is None: + return + # Keep sourceUrl only. WorldFxCue forbids extra keys such as sourceRef, and + # additive apply revalidates every existing world cue against that model. + cue["sourceUrl"] = updated["url"] + + +def _rewrite_list(entries: Any, locator: Callable[[dict[str, Any]], dict[str, Any] | None], rewrite_item: Callable) -> None: + if not isinstance(entries, list): + return + for item in entries: + rewrite_item(item, locator) + + +def _document_body(packed: dict[str, Any]) -> dict[str, Any]: + nested = packed.get("document") + if is_template_wrapper(packed) and isinstance(nested, dict): + return nested + return packed + + +def rewrite_document_refs( + document: Mapping[str, Any], + locator: Callable[[dict[str, Any]], dict[str, Any] | None], +) -> dict[str, Any]: + packed = deepcopy(dict(document)) + body = _document_body(packed) + _rewrite_list(body.get("slots"), locator, _rewrite_slot) + _rewrite_list(body.get("soundtrack"), locator, _rewrite_track) + _rewrite_list(body.get("worldSfx"), locator, _rewrite_world_cue) + return packed + + +def _is_empty_member(raw: str) -> bool: + return not raw or raw.endswith("/") + + +def _escapes_package(raw: str) -> bool: + return "\x00" in raw or raw.startswith("/") or raw.startswith("../") or raw == ".." + + +def _is_windows_abs(raw: str) -> bool: + drive = raw.split("/", 1)[0] + return ":" in drive and raw[:1].isalpha() + + +def _unsafe_member_parts(parts: list[str]) -> bool: + return not parts or ".." in parts or any(not _SAFE_SEGMENT.fullmatch(part) for part in parts) + + +def safe_zip_member(name: str) -> str: + raw = str(name or "").replace("\\", "/") + if _is_empty_member(raw) or _escapes_package(raw) or _is_windows_abs(raw): + raise ScenePackageSecurity("Rejected path traversal in the package") + parts = [part for part in raw.split("/") if part not in ("", ".")] + if _unsafe_member_parts(parts): + raise ScenePackageSecurity("Package member names must be relative and simple") + return "/".join(parts) + + +def zipinfo_is_symlink(info: zipfile.ZipInfo) -> bool: + mode = info.external_attr >> 16 + return bool(mode) and stat.S_ISLNK(mode) + + +def _read_zip_member(archive: zipfile.ZipFile, info: zipfile.ZipInfo, limit: int) -> bytes: + if info.file_size > limit: + raise ScenePackageTooLarge("A packaged file exceeds the size limit") + buffer = bytearray() + with archive.open(info, "r") as handle: + while len(buffer) < info.file_size: + chunk = handle.read(min(1024 * 1024, info.file_size - len(buffer))) + if not chunk: + break + buffer.extend(chunk) + if len(buffer) > limit: + raise ScenePackageTooLarge("Uncompressed package member exceeds the size limit") + extra = handle.read(1) + if extra: + raise ScenePackageSecurity("Zip member is larger than its declared size") + if len(buffer) != info.file_size: + raise ScenePackageError("Zip member is truncated") + return bytes(buffer) + + +def inspect_zip_members(path: Path) -> list[zipfile.ZipInfo]: + size = path.stat().st_size + if size > MAX_ZIP_BYTES: + raise ScenePackageTooLarge("Package zip exceeds the size limit") + if not zipfile.is_zipfile(path): + raise ScenePackageError("Expected a scene package zip") + with zipfile.ZipFile(path, "r") as archive: + infos = list(archive.infolist()) + total = 0 + names: set[str] = set() + for info in infos: + if info.is_dir(): + continue + member = safe_zip_member(info.filename) + if member in names: + raise ScenePackageError("Package contains duplicate members") + names.add(member) + if zipinfo_is_symlink(info): + raise ScenePackageSecurity("Rejected a symlink inside the package") + if info.file_size > MAX_MEMBER_BYTES: + raise ScenePackageTooLarge("A packaged file exceeds the size limit") + total += info.file_size + if total > MAX_UNCOMPRESSED_BYTES: + raise ScenePackageTooLarge("Uncompressed package exceeds the size limit") + lowered = member.casefold() + if lowered in CINEMA_MEMBER_HINTS or lowered.endswith("/cinema.js") or lowered.endswith("/cinema.ts"): + raise ScenePackageSecurity("Unknown cinema extension is not supported in the editor") + return infos + + +AssetReader = Callable[[str, str], bytes | None] + + +def gallery_url(workspace: str, filename: str) -> str: + from urllib.parse import quote + if workspace == "__uploads__": + return "/api/v1/uploads/" + quote(filename, safe="") + return "/api/v1/file/" + quote(filename, safe="") + "?workspace=" + quote(workspace, safe="") + + +def _kind_from_name(filename: str, hinted: str = "") -> str: + if hinted in {"image", "audio", "video", "model3d", "document", "other"}: + return hinted + return infer_asset_kind(filename) + + +def _read_local_asset(reader: AssetReader, use: Mapping[str, Any], default_workspace: str) -> tuple[bytes | None, str]: + url = str(use.get("url") or "") + kind = classify_url(url) if url else "empty" + if kind in {"transient", "unsafe", "external"}: + raise ScenePackageSecurity("Unauthorized external or transient media link") + workspace = str(use.get("workspaceId") or default_workspace or "") + filename = str(use.get("filename") or "") + if kind == "uploads": + workspace, filename = parse_media_locator(url, workspace) + elif kind == "gallery": + parsed_ws, parsed_name = parse_media_locator(url, workspace) + workspace, filename = parsed_ws or workspace, parsed_name or filename + if not filename: + return None, "" + data = reader(workspace or default_workspace, filename) + return data, filename + + +def _media_suffix(filename: str, fallback: str) -> str: + suffix = Path(filename).suffix.casefold() + if suffix in ALLOWED_MEDIA_EXT: + return suffix + other = Path(fallback or "bin").suffix.casefold() + return other if other in ALLOWED_MEDIA_EXT else ".bin" + + +def _ref_lookup_keys(ref: Mapping[str, Any], workspace: str) -> list[tuple[str, str, str]]: + url = str(ref.get("url") or "") + filename = str(ref.get("filename") or "") + scoped = str(ref.get("workspaceId") or workspace or "") + keys = [(scoped, filename, url)] + if url: + parsed_ws, parsed_name = parse_media_locator(url, scoped or None) + parsed_ws = str(parsed_ws or scoped) + parsed_name = parsed_name or filename + keys.extend(( + (parsed_ws, parsed_name, url), + (scoped, "", url), + (parsed_ws, parsed_name, ""), + ("", "", url), + )) + if filename: + keys.append((scoped, filename, "")) + seen: set[tuple[str, str, str]] = set() + unique: list[tuple[str, str, str]] = [] + for key in keys: + if key in seen: + continue + seen.add(key) + unique.append(key) + return unique + + +def _register_packed_asset( + use: Mapping[str, Any], + data: bytes, + filename: str, + workspace: str, + files: dict[str, bytes], + assets_by_hash: dict[str, dict[str, Any]], + hash_by_key: dict[tuple[str, str, str], str], +) -> None: + digest = sha256_bytes(data) + member = f"{MEDIA_DIR}/{digest}{_media_suffix(filename, str(use.get('filename') or ''))}" + current = assets_by_hash.get(digest) + if current is None: + if len(assets_by_hash) >= MAX_ASSETS: + raise ScenePackageError("Too many unique assets in one package") + files[member] = data + assets_by_hash[digest] = { + "sha256": digest, + "kind": _kind_from_name(filename, str(use.get("kind") or "")), + "path": member, + "filename": safe_export_filename(filename), + "size": len(data), + "uses": [f"{use['doc_id']}:{use['role']}"], + } + else: + current["uses"].append(f"{use['doc_id']}:{use['role']}") + for key in _ref_lookup_keys(use, workspace): + hash_by_key[key] = digest + + +def _packed_locator(workspace: str, assets_by_hash: dict[str, dict[str, Any]], hash_by_key: dict[tuple[str, str, str], str]): + def locate(ref: dict[str, Any]) -> dict[str, Any] | None: + url = str(ref.get("url") or "") + filename = str(ref.get("filename") or "") + if not url and not filename: + return None + digest = next((hash_by_key[key] for key in _ref_lookup_keys(ref, workspace) if key in hash_by_key), None) + if not digest: + return None + asset = assets_by_hash[digest] + return { + "workspaceId": PACKAGE_FILENAME, + "filename": asset["filename"], + "url": asset["path"], + "assetId": HASH_PREFIX + digest, + } + return locate + + +def _skip_empty_use(use: Mapping[str, Any]) -> bool: + url = str(use.get("url") or "") + return classify_url(url) == "empty" and not use.get("filename") + + +def _encode_packed_document(rewritten: Mapping[str, Any]) -> bytes: + encoded = json.dumps(rewritten, ensure_ascii=False, allow_nan=False).encode() + if len(encoded) > MAX_DOCUMENT_BYTES: + raise ScenePackageTooLarge("Scene document exceeds 2 MB") + return encoded + + +def _document_pack_name(rewritten: Mapping[str, Any], index: int) -> tuple[str, str, str | None]: + role = "template" if is_template_wrapper(rewritten) else "shot" + title = rewritten.get("title") if is_template_wrapper(rewritten) else None + production = rewritten.get("production") if isinstance(rewritten.get("production"), Mapping) else {} + name = str(title or production.get("title") or rewritten.get("templateId") or f"shot-{index}")[:120] + warning = None + if role == "template": + warning = "Packed a scenario wrapper; media still travels with the package, unlike a template file." + return role, name, warning + + +def _pack_document( + raw: Mapping[str, Any], + index: int, + reader: AssetReader, + workspace: str, + files: dict[str, bytes], + assets_by_hash: dict[str, dict[str, Any]], +) -> tuple[dict[str, Any], list[str], str | None]: + reject_unknown_cinema(raw) + unknown = [f"documents[{index}].{field}" for field in collect_unknown_fields(raw)] + hash_by_key: dict[tuple[str, str, str], str] = {} + for use in iter_asset_uses(raw, doc_id=f"shot-{index}"): + if _skip_empty_use(use): + continue + data, filename = _read_local_asset(reader, use, workspace) + if data is None: + raise ScenePackageError(f"Missing scene asset: {filename or use.get('url')}") + _register_packed_asset(use, data, filename, workspace, files, assets_by_hash, hash_by_key) + rewritten = rewrite_document_refs(raw, _packed_locator(workspace, assets_by_hash, hash_by_key)) + encoded = _encode_packed_document(rewritten) + path = f"{DOCUMENTS_DIR}/shot-{index}.json" + files[path] = encoded + role, name, warning = _document_pack_name(rewritten, index) + packed = {"id": f"shot-{index}", "role": role, "path": path, "sha256": sha256_bytes(encoded), "name": name} + return packed, unknown, warning + + +def build_package( + documents: Iterable[Mapping[str, Any]], + reader: AssetReader, + *, + title: str = "", + workspace: str = "", +) -> tuple[dict[str, Any], dict[str, bytes]]: + shots = list(documents) + if not shots: + raise ScenePackageError("Export at least one scene document") + if len(shots) > MAX_DOCUMENTS: + raise ScenePackageError("Too many documents in one package") + files: dict[str, bytes] = {} + assets_by_hash: dict[str, dict[str, Any]] = {} + packed_documents: list[dict[str, Any]] = [] + unknown: list[str] = [] + warnings: list[str] = [] + for index, raw in enumerate(shots, start=1): + packed, fields, warning = _pack_document(raw, index, reader, workspace, files, assets_by_hash) + packed_documents.append(packed) + unknown.extend(fields) + if warning: + warnings.append(warning) + if unknown: + warnings.append("Unknown fields are preserved and listed; the editor did not drop them.") + manifest = { + "kind": PACKAGE_KIND, + "schema": PACKAGE_SCHEMA, + "schema_version": PACKAGE_VERSION, + "title": (title or "Scene package")[:120], + "created_at": utc_now(), + "documents": packed_documents, + "assets": list(assets_by_hash.values()), + "unknown_fields": unknown, + "warnings": warnings, + } + files[MANIFEST_NAME] = json.dumps(manifest, ensure_ascii=False, indent=2).encode() + return manifest, files + + +def write_package_zip( + documents: Iterable[Mapping[str, Any]], + reader: AssetReader, + *, + title: str = "", + workspace: str = "", +) -> bytes: + manifest, files = build_package(documents, reader, title=title, workspace=workspace) + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w", compression=zipfile.ZIP_DEFLATED) as archive: + for name, data in files.items(): + info = zipfile.ZipInfo(name) + info.external_attr = 0o644 << 16 + archive.writestr(info, data) + payload = buffer.getvalue() + if len(payload) > MAX_ZIP_BYTES: + raise ScenePackageTooLarge("Package zip exceeds the size limit") + _ = manifest + return payload + + +def _load_json_member(data: bytes, name: str) -> Any: + if len(data) > MAX_DOCUMENT_BYTES: + raise ScenePackageTooLarge(f"{name} exceeds 2 MB") + try: + return json.loads(data.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise ScenePackageError(f"{name} is not valid JSON") from exc + + +def _validate_manifest(manifest_raw: Any) -> tuple[dict[str, Any], list[Any], list[Any]]: + if not isinstance(manifest_raw, Mapping): + raise ScenePackageError("package.json must be an object") + kind = manifest_raw.get("kind") or manifest_raw.get("schema") + if kind == TEMPLATE_KIND: + raise ScenePackageError("This is a scenario template, not a portable project package") + if kind != PACKAGE_KIND and manifest_raw.get("schema") != PACKAGE_SCHEMA: + raise ScenePackageError("Unsupported scene package kind") + version = manifest_raw.get("schema_version", manifest_raw.get("version")) + if version != PACKAGE_VERSION: + raise ScenePackageError("Unsupported scene package version") + reject_unknown_cinema(manifest_raw) + documents_meta = manifest_raw.get("documents") + assets_meta = manifest_raw.get("assets") + if not isinstance(documents_meta, list) or not documents_meta: + raise ScenePackageError("Package lists no documents") + if len(documents_meta) > MAX_DOCUMENTS: + raise ScenePackageError("Too many documents in one package") + if not isinstance(assets_meta, list) or len(assets_meta) > MAX_ASSETS: + raise ScenePackageError("Invalid package asset list") + return dict(manifest_raw), documents_meta, assets_meta + + +def _extract_document( + archive: zipfile.ZipFile, + members: dict[str, zipfile.ZipInfo], + entry: Mapping[str, Any], + files: dict[str, bytes], +) -> dict[str, Any]: + member_name = safe_zip_member(str(entry.get("path") or "")) + if not member_name.startswith(f"{DOCUMENTS_DIR}/"): + raise ScenePackageSecurity("Document paths must stay under documents/") + if member_name not in members: + raise ScenePackageError(f"Missing packed document {member_name}") + payload = _read_zip_member(archive, members[member_name], MAX_DOCUMENT_BYTES) + expected = str(entry.get("sha256") or "") + if expected and sha256_bytes(payload) != expected: + raise ScenePackageError(f"Document {member_name} does not match its manifest hash") + parsed = _load_json_member(payload, member_name) + if not isinstance(parsed, Mapping): + raise ScenePackageError("Packed document must be an object") + files[member_name] = payload + return dict(parsed) + + +def _extract_asset( + archive: zipfile.ZipFile, + members: dict[str, zipfile.ZipInfo], + entry: Mapping[str, Any], + files: dict[str, bytes], +) -> dict[str, Any]: + member_name = safe_zip_member(str(entry.get("path") or "")) + if not member_name.startswith(f"{MEDIA_DIR}/"): + raise ScenePackageSecurity("Media paths must stay under media/") + digest = str(entry.get("sha256") or "") + if not _SHA256.fullmatch(digest): + raise ScenePackageError("Asset hash is missing or invalid") + if not Path(member_name).name.startswith(digest): + raise ScenePackageSecurity("Media filename must start with its content hash") + status = "ok" + if member_name not in members: + status = "missing" + else: + payload = _read_zip_member(archive, members[member_name], MAX_MEMBER_BYTES) + if sha256_bytes(payload) != digest: + status = "tampered" + else: + files[member_name] = payload + return {**dict(entry), "path": member_name, "sha256": digest, "status": status, "present": status == "ok"} + + +def read_package(path: Path) -> dict[str, Any]: + infos = inspect_zip_members(path) + members = {safe_zip_member(info.filename): info for info in infos if not info.is_dir()} + if MANIFEST_NAME not in members: + raise ScenePackageError("Package is missing package.json") + with zipfile.ZipFile(path, "r") as archive: + manifest, documents_meta, assets_meta = _validate_manifest( + _load_json_member(_read_zip_member(archive, members[MANIFEST_NAME], MAX_DOCUMENT_BYTES), MANIFEST_NAME) + ) + files: dict[str, bytes] = {} + documents = [_extract_document(archive, members, entry, files) for entry in documents_meta if isinstance(entry, Mapping)] + if len(documents) != len(documents_meta): + raise ScenePackageError("Invalid document entry") + assets = [_extract_asset(archive, members, entry, files) for entry in assets_meta if isinstance(entry, Mapping)] + if len(assets) != len(assets_meta): + raise ScenePackageError("Invalid asset entry") + extras = sorted(name for name in members if name not in files and name != MANIFEST_NAME) + return {"manifest": manifest, "documents": documents, "assets": assets, "files": files, "extra_members": extras} + + +def _reject_bare_json(path: Path, first: bytes) -> None: + if not (first.lstrip().startswith(b"{") or first.lstrip().startswith(b"[")): + return + try: + raw = json.loads(path.read_text(encoding="utf-8")) + except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc: + raise ScenePackageError("Expected a scene package zip") from exc + if is_template_wrapper(raw): + raise ScenePackageError("This is a scenario template, not a portable project package") + raise ScenePackageError("Expected a scene package zip") + + +def _document_issues(document: Mapping[str, Any], index: int) -> tuple[list[dict[str, Any]], list[str]]: + prefix = f"documents[{index}]." + issues: list[dict[str, Any]] = [] + cinema = cinema_extension_of(document) + if cinema and cinema not in SUPPORTED_CINEMA_EXTENSIONS: + issues.append(_issue("cinema_extension", f"Unknown cinema extension: {cinema}", path=prefix.rstrip("."))) + try: + DocumentInput(document=unwrap_document(document)) + except Exception as exc: + issues.append(_issue("invalid_document", str(exc), path=prefix.rstrip("."))) + for use in iter_asset_uses(document, doc_id=f"shot-{index}"): + if classify_url(str(use.get("url") or "")) in {"external", "unsafe", "transient"}: + issues.append(_issue("external_link", "Unauthorized external or transient media link", path=use["role"])) + unknown = [prefix + field for field in collect_unknown_fields(document)] + return issues, unknown + + +def _asset_issues(assets: list[Mapping[str, Any]]) -> list[dict[str, Any]]: + issues: list[dict[str, Any]] = [] + for asset in assets: + label = asset.get("filename") or asset["sha256"] + if asset["status"] == "missing": + issues.append(_issue("missing_asset", f"Missing packed asset {label}", repair=True, path=str(asset["path"]))) + elif asset["status"] == "tampered": + issues.append(_issue("tampered_asset", f"Asset {label} does not match its hash", repair=True, path=str(asset["path"]))) + return issues + + +def preflight_package(path: Path) -> dict[str, Any]: + _reject_bare_json(path, path.read_bytes()[:32]) + packed = read_package(path) + issues: list[dict[str, Any]] = [] + unknown: list[str] = [] + for index, document in enumerate(packed["documents"], start=1): + doc_issues, fields = _document_issues(document, index) + issues.extend(doc_issues) + unknown.extend(fields) + issues.extend(_asset_issues(packed["assets"])) + issues.extend(_issue("extra_member", f"Unexpected package member {extra}", path=extra) for extra in packed["extra_members"]) + if unknown: + issues.append(_issue("unknown_fields", "Unknown fields were kept and listed rather than dropped")) + blocking = [item for item in issues if item["code"] in {"cinema_extension", "external_link", "invalid_document"}] + ok = not blocking and not any(item.get("repair") for item in issues) + summaries = packed["manifest"].get("documents") or [] + return { + "ok": ok, + "kind": PACKAGE_KIND, + "schema_version": PACKAGE_VERSION, + "title": packed["manifest"].get("title") or "", + "documents": [{"id": entry.get("id"), "role": entry.get("role"), "name": entry.get("name")} for entry in summaries], + "assets": packed["assets"], + "unknown_fields": unknown or packed["manifest"].get("unknown_fields") or [], + "warnings": packed["manifest"].get("warnings") or [], + "issues": issues, + "extra_members": packed["extra_members"], + "size_bytes": path.stat().st_size, + "can_import": ok, + } + + + +from services.scene_packages_import import ( + apply_reassign, + find_existing_by_hash, + import_package, +) + + +def make_workspace_reader( + workspace_dir: Callable[[str], str], + uploads_dir: Callable[[], str] | None = None, +) -> AssetReader: + def reader(workspace_id: str, filename: str) -> bytes | None: + name = _basename(filename) + if not name or name in {".", ".."}: + return None + roots: list[Path] = [] + if workspace_id == "__uploads__" and uploads_dir is not None: + roots.append(Path(uploads_dir())) + else: + try: + roots.append(Path(workspace_dir(workspace_id or "default"))) + except Exception: + return None + if uploads_dir is not None: + roots.append(Path(uploads_dir())) + for root in roots: + candidate = (root / name).resolve() + try: + if os.path.commonpath((str(candidate), str(root.resolve()))) != str(root.resolve()): + continue + except (OSError, ValueError): + continue + if candidate.is_file() and not candidate.is_symlink(): + return candidate.read_bytes() + return None + return reader + + +def format_contract() -> dict[str, Any]: + return { + "kind": PACKAGE_KIND, + "schema": PACKAGE_SCHEMA, + "schema_version": PACKAGE_VERSION, + "template_kind": TEMPLATE_KIND, + "layout": [MANIFEST_NAME, f"{DOCUMENTS_DIR}/shot-N.json", f"{MEDIA_DIR}/."], + "limits": { + "zip_bytes": MAX_ZIP_BYTES, + "uncompressed_bytes": MAX_UNCOMPRESSED_BYTES, + "member_bytes": MAX_MEMBER_BYTES, + "documents": MAX_DOCUMENTS, + "assets": MAX_ASSETS, + }, + "notes": [ + "Export packs only referenced media and dedupes by SHA-256.", + "A .world3d.template.json is a scenario layout, not a portable project.", + "Unknown cinema extensions are rejected; unknown document fields are listed and kept.", + ], + } diff --git a/app/services/scene_packages_import.py b/app/services/scene_packages_import.py new file mode 100644 index 000000000..8fac77f54 --- /dev/null +++ b/app/services/scene_packages_import.py @@ -0,0 +1,313 @@ +"""Import, reassignment and unique-name helpers for scene packages.""" +from __future__ import annotations + +import os +from pathlib import Path +from typing import Any, Callable, Iterable, Mapping + +from services.asset_manifest import ( + build_asset_manifest, + read_asset_manifest, + sidecar_path, + write_asset_manifest, +) +from services.scene_library import save_world3d + +from services.scene_packages import ( + HASH_PREFIX, + PACKAGE_KIND, + STUB_PREVIEW, + AssetReader, + ScenePackageError, + _SHA256, + _basename, + _kind_from_name, + classify_url, + gallery_url, + is_template_wrapper, + parse_media_locator, + preflight_package, + read_package, + require_workspace, + rewrite_document_refs, + safe_export_filename, + sha256_bytes, + sha256_file, + unwrap_document, +) + +def _sidecar_hash(path: Path) -> str | None: + manifest = read_asset_manifest(path) + if not isinstance(manifest, Mapping): + return None + asset = manifest.get("asset") + if isinstance(asset, Mapping) and str(asset.get("filename") or "") not in {"", path.name}: + return None + technical = manifest.get("technical") + if isinstance(technical, Mapping): + digest = str(technical.get("sha256") or "") + if _SHA256.fullmatch(digest): + return digest + return None + + +def find_existing_by_hash(root: Path, digest: str, size: int | None = None) -> Path | None: + if not root.is_dir(): + return None + try: + entries = list(root.iterdir()) + except OSError: + return None + sized: list[Path] = [] + for entry in entries: + if not entry.is_file() or entry.name.endswith(".meta.json") or entry.name.endswith(".preview.png"): + continue + marked = _sidecar_hash(entry) + if marked == digest: + try: + if sha256_file(entry) == digest: + return entry + except OSError: + continue + continue + try: + if size is not None and entry.stat().st_size == size: + sized.append(entry) + except OSError: + continue + for entry in sized: + try: + if sha256_file(entry) == digest: + return entry + except OSError: + continue + return None + + +def _name_is_free(root: Path, name: str, digest: str) -> bool: + dest = root / name + if dest.exists(): + try: + return sha256_file(dest) == digest + except OSError: + return False + return not sidecar_path(dest).exists() + + +def _unique_name(root: Path, filename: str, digest: str) -> str: + safe = safe_export_filename(filename) + stem, suffix = Path(safe).stem, Path(safe).suffix + candidates = [safe, f"{stem}-{digest[:8]}{suffix}"] + candidates.extend(f"{stem}-{digest[:8]}-{index}{suffix}" for index in range(2, 16)) + for name in candidates: + if _name_is_free(root, name, digest): + return name + raise ScenePackageError(f"Could not allocate a unique name for {safe}") + + +def _write_bytes(path: Path, data: bytes) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + temporary = path.with_name(path.name + ".pkg.tmp") + with temporary.open("xb") as handle: + handle.write(data) + handle.flush() + os.fsync(handle.fileno()) + temporary.replace(path) + + +def _reassign_locator(item: Mapping[str, Any], workspace: str) -> tuple[str, str]: + filename = str(item.get("filename") or "") + source_workspace = str(item.get("workspace") or item.get("workspaceId") or workspace) + url = str(item.get("url") or "") + if not url: + return filename, source_workspace + parsed_ws, parsed_name = parse_media_locator(url, source_workspace) + return parsed_name or filename, parsed_ws or source_workspace + + +def _reassign_one(item: Mapping[str, Any], reader: AssetReader, workspace: str) -> tuple[str, dict[str, Any]]: + digest = str(item.get("sha256") or "").casefold() + if not _SHA256.fullmatch(digest): + raise ScenePackageError("Reassignment is missing a content hash") + filename, source_workspace = _reassign_locator(item, workspace) + data = reader(source_workspace, filename) + if data is None: + raise ScenePackageError(f"Replacement asset not found: {filename}") + return digest, { + "data": data, + "filename": filename, + "workspaceId": source_workspace, + "url": gallery_url(source_workspace, filename), + "sha256": sha256_bytes(data), + "assetId": str(item.get("assetId") or ""), + } + + +def apply_reassign( + packed: Mapping[str, Any], + reassign: Iterable[Mapping[str, Any]], + reader: AssetReader, + workspace: str, +) -> dict[str, dict[str, Any]]: + replacements: dict[str, dict[str, Any]] = {} + for item in reassign: + if isinstance(item, Mapping): + digest, payload = _reassign_one(item, reader, workspace) + replacements[digest] = payload + return replacements + + +def _assert_importable(report: Mapping[str, Any], packed: Mapping[str, Any], replacements: Mapping[str, Any]) -> None: + blocking = {"cinema_extension", "external_link", "invalid_document"} + for issue in report["issues"]: + if issue["code"] in blocking: + raise ScenePackageError(issue["message"]) + for asset in packed["assets"]: + if asset["status"] != "ok" and asset["sha256"] not in replacements: + raise ScenePackageError(f"Repair required for {asset.get('filename') or asset['sha256']}") + + +def _published_ref(workspace: str, filename: str, url: str, asset_id: str = "") -> dict[str, Any]: + return {"workspaceId": workspace, "filename": filename, "url": url, "assetId": asset_id} + + +def _publish_one_asset( + asset: Mapping[str, Any], + packed: Mapping[str, Any], + replacements: Mapping[str, dict[str, Any]], + workspace: str, + root: Path, + written: list[Path], +) -> tuple[str, dict[str, Any], bool]: + digest = str(asset["sha256"]) + replacement = replacements.get(digest) + if replacement: + return digest, _published_ref( + replacement["workspaceId"] or workspace, replacement["filename"], + replacement["url"], str(replacement.get("assetId") or ""), + ), True + data = packed["files"].get(asset["path"]) + filename = str(asset.get("filename") or f"{digest}.bin") + if not data: + raise ScenePackageError(f"Missing packed asset {filename}") + existing = find_existing_by_hash(root, digest, len(data)) + if existing is not None: + manifest = read_asset_manifest(existing) or {} + asset_block = manifest.get("asset") if isinstance(manifest, Mapping) else None + asset_id = str(asset_block.get("id") or "") if isinstance(asset_block, Mapping) else "" + return digest, _published_ref(workspace, existing.name, gallery_url(workspace, existing.name), asset_id), True + dest_name = _unique_name(root, filename, digest) + dest = root / dest_name + if dest.exists(): + manifest = read_asset_manifest(dest) or {} + asset_block = manifest.get("asset") if isinstance(manifest, Mapping) else None + asset_id = str(asset_block.get("id") or "") if isinstance(asset_block, Mapping) else "" + return digest, _published_ref(workspace, dest.name, gallery_url(workspace, dest.name), asset_id), True + _write_bytes(dest, data) + written.append(dest) + manifest = build_asset_manifest( + dest, kind=_kind_from_name(dest_name, str(asset.get("kind") or "")), + workspace_id=workspace, tool="scene-package-import", actor="user", + execution_mode="import", technical={"sha256": digest, "package_kind": PACKAGE_KIND}, + ) + written.append(write_asset_manifest(dest, manifest)) + return digest, _published_ref(workspace, dest_name, gallery_url(workspace, dest_name), manifest["asset"]["id"]), False + + +def _imported_name(rewritten: Mapping[str, Any], body: Mapping[str, Any]) -> str: + title = rewritten.get("title") if is_template_wrapper(rewritten) else None + production = body.get("production") if isinstance(body.get("production"), Mapping) else {} + return str(title or production.get("title") or body.get("templateId") or "Imported scene") + + +def _locate_published( + published_by_hash: Mapping[str, Mapping[str, Any]], + published_by_filename: Mapping[str, Mapping[str, Any]] | None = None, +): + by_filename = published_by_filename or {} + + def locate(ref: dict[str, Any]) -> dict[str, Any] | None: + url = str(ref.get("url") or "") + asset_id = str(ref.get("assetId") or "") + digest = asset_id[len(HASH_PREFIX):] if asset_id.startswith(HASH_PREFIX) else "" + if not digest and classify_url(url) == "relative": + digest = Path(url).stem[:64] + published = published_by_hash.get(digest) + if not published: + filename = str(ref.get("filename") or "") + if not filename and url: + _, filename = parse_media_locator(url) + published = by_filename.get(filename) + if not published: + return None + return { + "workspaceId": published["workspaceId"], + "filename": published["filename"], + "url": published["url"], + "assetId": published["assetId"] or asset_id, + } + return locate + + +def _rollback(written: list[Path]) -> None: + for item in reversed(written): + try: + item.unlink(missing_ok=True) + except OSError: + pass + + +def import_package( + path: Path, + *, + workspace: str, + workspace_dir: Callable[[str], str], + reader: AssetReader, + reassign: Iterable[Mapping[str, Any]] = (), + preview: str | None = None, +) -> dict[str, Any]: + workspace = require_workspace(workspace) + report = preflight_package(path) + packed = read_package(path) + replacements = apply_reassign(packed, reassign, reader, workspace) + _assert_importable(report, packed, replacements) + root = Path(workspace_dir(workspace)) + root.mkdir(parents=True, exist_ok=True) + published_by_hash: dict[str, dict[str, Any]] = {} + published_by_filename: dict[str, dict[str, Any]] = {} + written: list[Path] = [] + reused = created = 0 + try: + for asset in packed["assets"]: + digest, published, was_reused = _publish_one_asset(asset, packed, replacements, workspace, root, written) + published_by_hash[digest] = published + original_name = str(asset.get("filename") or published["filename"] or "") + if original_name: + published_by_filename[original_name] = published + reused += int(was_reused) + created += int(not was_reused) + published_scenes = [] + locate = _locate_published(published_by_hash, published_by_filename) + for document in packed["documents"]: + rewritten = rewrite_document_refs(document, locate) + body = unwrap_document(rewritten) + saved = save_world3d( + {"workspace": workspace, "document": body, "name": _imported_name(rewritten, body), + "preview": preview or STUB_PREVIEW}, + workspace_dir, + ) + written.append(root / saved["name"]) + thumb = root / saved["name"].replace(".json", ".preview.png") + if thumb.exists(): + written.append(thumb) + published_scenes.append(saved) + return { + "ok": True, "workspace": workspace, "scenes": published_scenes, + "assets_created": created, "assets_reused": reused, + "unknown_fields": report.get("unknown_fields") or [], + "warnings": report.get("warnings") or [], + } + except Exception: + _rollback(written) + raise + diff --git a/app/services/series_library.py b/app/services/series_library.py index 9feffa827..b67dfc7ec 100644 --- a/app/services/series_library.py +++ b/app/services/series_library.py @@ -35,7 +35,7 @@ "dialogueBeats", "visibleCharacterIds", "speakingCharacterIds", "primarySpeakerId", "locationId", "locationVariantId", "wardrobeByCharacterId", "propIds", "emotionalStateByCharacterId", - "continuityFromShotId", "renderStrategy", "referencePolicy", "prompt", + "continuityFromShotId", "renderStrategy", "productionMethod", "referencePolicy", "prompt", "negativePrompt", "audioDirection", "sourceDialogueIds", "dialogueOrigin", }) SHOT_SERVER_FIELDS = frozenset({"attempts", "approvedAttemptId", "referenceManifest"}) @@ -323,8 +323,12 @@ def _normalize_attempt(value: dict, shot_id: str, index: int) -> dict: def _normalize_shot(value: dict, index: int) -> dict: from .series_render import normalize_series_shot_duration + from .series_production import PRODUCTION_METHODS shot = copy.deepcopy(value) + method = shot.get("productionMethod") or "generated_video" + if method not in PRODUCTION_METHODS: + raise ValueError("Unsupported Series shot production method") shot_id = _id(shot.get("id"), f"shot_{index + 1}") dialogue = [ _normalize_dialogue_beat(item, f"{shot_id}_dialogue_{dialogue_index + 1}") @@ -338,7 +342,9 @@ def _normalize_shot(value: dict, index: int) -> dict: "id": shot_id, "sceneId": _id(shot.get("sceneId"), "scene_1"), "order": _integer(shot.get("order"), index + 1, 1), - "durationSeconds": float(normalize_series_shot_duration(shot.get("durationSeconds"))), + "productionMethod": method, + "durationSeconds": float(normalize_series_shot_duration(shot.get("durationSeconds"))) if method == "generated_video" + else min(600, _number(shot.get("durationSeconds"), 5, .1)), "framing": _text(shot.get("framing")), "camera": _text(shot.get("camera")), "action": _text(shot.get("action")), @@ -727,6 +733,7 @@ def require_known(value: Any, known: set[str], path: str, kind: str, *, optional def normalize_series_project(value: Any, key: str, workspace_id: str) -> dict: + from services.series_production import normalize_production_methods if not isinstance(value, dict): raise ValueError("Every Series Lab project must be a JSON object") project = copy.deepcopy(value) @@ -849,6 +856,7 @@ def normalize_series_project(value: Any, key: str, workspace_id: str) -> dict: "version": 1, "id": series_id, "revision": _integer(project.get("revision"), 1, 1), + "allowedProductionMethods": normalize_production_methods(project.get("allowedProductionMethods")), "title": _text(project.get("title"), "Untitled series"), "logline": _text(project.get("logline")), "premise": _text(project.get("premise")), diff --git a/app/services/series_planning.py b/app/services/series_planning.py index 0f5c4e078..d05c4a04f 100644 --- a/app/services/series_planning.py +++ b/app/services/series_planning.py @@ -147,6 +147,7 @@ def planning_schema(stage: str, episode: dict | None = None) -> dict: "emotionalStateByCharacterId": {"type": "object"}, "continuityFromShotId": string, "renderStrategy": {"enum": ["auto", "direct", "first_frame", "references", "first_last"]}, + "productionMethod": {"enum": ["generated_video", "animation_2d", "animation_3d", "imported_video"]}, "prompt": string, "negativePrompt": string, }, "required": [ @@ -154,7 +155,7 @@ def planning_schema(stage: str, episode: dict | None = None) -> dict: "dialogueBeats", "visibleCharacterIds", "speakingCharacterIds", "primarySpeakerId", "locationId", "locationVariantId", "wardrobeByCharacterId", "propIds", "emotionalStateByCharacterId", "continuityFromShotId", "renderStrategy", - "prompt", "negativePrompt", + "prompt", "negativePrompt", "productionMethod", ], "additionalProperties": False, } @@ -791,6 +792,7 @@ def _bounded(value: Any, depth: int = 0) -> Any: def planning_prompt(stage: str, series: dict, episode: dict, instruction: str = "") -> tuple[str, str]: + from services.series_production import normalize_production_methods shot_profile = series_shot_count_profile(episode) script_scene_ids = [ str(item.get("id")) for item in episode.get("script", []) @@ -802,7 +804,7 @@ def planning_prompt(stage: str, series: dict, episode: dict, instruction: str = key: series.get(key) for key in ( "title", "logline", "premise", "format", "language", "spokenLanguage", "languageIntent", "genre", "tone", "audience", "visualStyle", "characterVisualStyle", "cameraLanguage", - "allowClipText", "sourceMode", "masterUniversePrompt", + "allowClipText", "sourceMode", "masterUniversePrompt", "allowedProductionMethods", ) }, "canonSnapshot": canon_snapshot, @@ -819,6 +821,13 @@ def planning_prompt(stage: str, series: dict, episode: dict, instruction: str = system = ( "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. " + f"For each shot choose productionMethod only from {json.dumps(normalize_production_methods(series.get('allowedProductionMethods')))}. " + "animation_2d means an editable layered animation; animation_3d means an editable spatial scene; " + "generated_video means a video generation model; imported_video means a supplied clip. " + "When several are allowed, choose per shot according to the requested mix and the scene. " + "Every animation_2d or animation_3d shot needs a canonical locationId for its background or environment, " + "including establishing shots with no visible characters. Use its script scene's location unless a different " + "canonical setting is explicitly needed. Plan both the environment and the visible cast. " "Every speaking character must also be visible. Never invent a reference asset or entity ID. " "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. " @@ -995,6 +1004,21 @@ def _assign_series_shot_durations(shots: list[dict], target: float) -> None: shot["durationSeconds"] = duration +def _normalize_shot_production(series: dict, shot: dict, script_scenes: list[dict], + location_ids: set[str], location_lookup: dict) -> None: + """Resolve an allowed production method and the required animation environment.""" + from services.series_production import series_shot_method + shot["productionMethod"] = series_shot_method(series, shot) + if shot["productionMethod"] not in {"animation_2d", "animation_3d"} or shot["locationId"]: + return + scene = next(item for item in script_scenes if item["id"] == shot["sceneId"]) + shot["locationId"] = _resolve(scene.get("locationId"), location_ids, location_lookup) + if shot["locationId"] not in location_ids: + raise ValueError(f"Animation shot {shot['id']} needs a canonical location for its environment") + if not shot.get("locationVariantId"): + shot["locationVariantId"] = scene.get("locationVariantId") or "" + + 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") @@ -1207,6 +1231,7 @@ 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 + _normalize_shot_production(series, shot, script_scenes, location_ids, location_lookup) shot["renderStrategy"] = shot.get("renderStrategy") if shot.get("renderStrategy") in { "auto", "direct", "first_frame", "references", "first_last" } else "auto" diff --git a/app/services/series_production.py b/app/services/series_production.py new file mode 100644 index 000000000..03a79548d --- /dev/null +++ b/app/services/series_production.py @@ -0,0 +1,136 @@ +"""Series production permissions and deliberate reference updates for existing episodes.""" +from __future__ import annotations + +import copy + +PRODUCTION_METHODS = ("generated_video", "animation_2d", "animation_3d", "imported_video") + + +def existing_generated_reference(series: dict, owner_type: str, owner_id: str, metadata: dict) -> dict | None: + job_id = metadata.get("jobId") + if not isinstance(job_id, str) or not job_id: + return None + return next((asset for asset in series.get("assets", {}).values() + if asset.get("ownerType") == owner_type and asset.get("ownerId") == owner_id + and asset.get("metadata", {}).get("jobId") == job_id), None) + + +def attach_series_import(series: dict, asset: dict, *, as_take: bool = False, source_path: str = "") -> None: + """Attach an imported reference or a verified completed take to its exact owner.""" + owner_type, owner_id = asset["ownerType"], asset["ownerId"] + if owner_type not in {"series", "character", "location", "prop", "episode", "shot"}: + raise ValueError("Unsupported Series asset owner") + if as_take and (owner_type != "shot" or asset["kind"] != "video"): + raise ValueError("A completed take must be a video owned by a shot") + collection = {"character": "characters", "location": "locations", "prop": "props"}.get(owner_type) + if collection: + _attach_entity_reference(series, asset, collection) + elif owner_type == "episode" and owner_id not in series.get("episodesById", {}): + raise ValueError("Series episode not found") + elif owner_type == "shot": + _attach_shot_asset(series, asset, as_take, source_path) + elif owner_type == "series" and owner_id != series["id"]: + raise ValueError("Series asset belongs to another project") + series.setdefault("assets", {})[asset["id"]] = asset + + +def _attach_entity_reference(series: dict, asset: dict, collection: str) -> None: + entity = next((item for item in series.get(collection, []) if item.get("id") == asset["ownerId"]), None) + if entity is None: + raise ValueError("Series reference subject no longer exists") + entity["referenceAssetIds"] = list(dict.fromkeys([*entity.get("referenceAssetIds", []), asset["id"]])) + if asset["ownerType"] == "character" and not entity.get("primaryReferenceAssetId"): + entity["primaryReferenceAssetId"] = asset["id"] + entity["approval"] = "draft" + series["canon"].update(approval="draft", approvedAt="") + + +def _verified_take_media(series: dict, shot: dict, source_path: str) -> tuple[str, dict]: + from services.video_editor import probe_media + method = series_shot_method(series, shot) + if method == "generated_video": + raise ValueError("Choose an animation or imported-video method before attaching a completed take") + if any(item.get("status") in {"queued", "running", "cancelling"} for item in shot.get("attempts", [])): + raise ValueError("Wait for this shot's render to finish before importing a take") + media = probe_media(source_path) + if float(media["duration"]) + .05 < float(shot.get("durationSeconds") or 0): + raise ValueError("This clip is shorter than the shot; adjust its duration before importing") + return method, media + + +def _attach_shot_asset(series: dict, asset: dict, as_take: bool, source_path: str) -> None: + from services.series_library import append_shot_render_attempt + found = next(((episode, index, shot) for episode in series.get("episodesById", {}).values() + for index, shot in enumerate(episode.get("shots", [])) if shot.get("id") == asset["ownerId"]), None) + if found is None: + raise ValueError("Series shot not found") + if not as_take: + return + episode, index, shot = found + method, media = _verified_take_media(series, shot, source_path) + updated, attempt = append_shot_render_attempt(shot, manifest=shot.get("referenceManifest") or {}, + model=method, settings={"productionMethod": method, "sourceDurationSeconds": media["duration"]}, seed=None) + updated["attempts"][-1].update(status="completed", outputAssetIds=[asset["id"]]) + asset.update(ownerType="attempt", ownerId=attempt["id"]) + asset["metadata"].update(productionMethod=method, **media) + episode["shots"][index] = updated + + +def normalize_production_methods(value=None) -> list[str]: + if value is None: + return ["generated_video"] + if not isinstance(value, list) or not value or any(item not in PRODUCTION_METHODS for item in value): + raise ValueError("Choose at least one supported Series production method") + return list(dict.fromkeys(value)) + + +def series_shot_method(series: dict, shot: dict) -> str: + allowed = normalize_production_methods(series.get("allowedProductionMethods")) + selected = shot.get("productionMethod") or allowed[0] + if selected not in allowed: + raise ValueError(f"Shot {shot.get('order', shot.get('id'))}: production method {selected} is not permitted for this series") + return selected + + +def refresh_episode_references(series: dict, episode_id: str, base_revision: int) -> dict: + from services.series_library import SeriesConflictError, create_episode_canon_snapshot + if int(series.get("revision") or 1) != base_revision: + raise SeriesConflictError("Series changed; reload before updating episode references") + if series.get("canon", {}).get("approval") != "approved": + raise ValueError("Approve the current canon before updating episode references") + result = copy.deepcopy(series) + episode = result.get("episodesById", {}).get(episode_id) + if not isinstance(episode, dict): + raise ValueError("Series episode not found") + if any(attempt.get("status") in {"queued", "running", "cancelling"} + for shot in episode.get("shots", []) for attempt in shot.get("attempts", [])): + raise SeriesConflictError("Wait for the episode render to finish before updating references") + latest = create_episode_canon_snapshot(series) + snapshot = episode.setdefault("canonSnapshot", {}) + for collection in ("characters", "locations", "props"): + by_id = {item["id"]: item for item in latest.get(collection, [])} + for entity in snapshot.get(collection, []): + current = by_id.get(entity.get("id")) + if current is None: + continue + entity["referenceAssetIds"] = copy.deepcopy(current.get("referenceAssetIds", [])) + if collection == "characters": + entity["primaryReferenceAssetId"] = current.get("primaryReferenceAssetId", "") + _refresh_variant_references(entity, current) + snapshot.setdefault("assets", {}).update(copy.deepcopy(latest["assets"])) + snapshot["approvedReferenceAssetIds"] = list(dict.fromkeys([ + *snapshot.get("approvedReferenceAssetIds", []), *latest["approvedReferenceAssetIds"], + ])) + snapshot["referenceRevision"] = latest["revision"] + for shot in episode.get("shots", []): + shot.pop("referenceManifest", None) + result["revision"] = base_revision + 1 + return result + + +def _refresh_variant_references(entity: dict, current: dict) -> None: + for key in ("variants", "wardrobeVariants"): + variants = {item["id"]: item for item in current.get(key, [])} + for variant in entity.get(key, []): + if variant.get("id") in variants: + variant["referenceAssetIds"] = copy.deepcopy(variants[variant["id"]].get("referenceAssetIds", [])) diff --git a/app/services/series_render.py b/app/services/series_render.py index f8b253ca0..859350a15 100644 --- a/app/services/series_render.py +++ b/app/services/series_render.py @@ -182,6 +182,10 @@ def plan_series_shot_duration(series: dict, shot: dict) -> dict: def apply_series_shot_duration(series: dict, shot: dict) -> dict: """Mutate a persisted/rendered shot to the authoritative duration plan.""" + if shot.get("productionMethod") in {"animation_2d", "animation_3d", "imported_video"}: + shot["durationSeconds"] = max(.1, min(600, float(shot.get("durationSeconds") or 5))) + shot.pop("dialogueDuration", None) + return shot planned = plan_series_shot_duration(series, shot) shot.clear() shot.update(planned) diff --git a/app/services/speech_analysis_cache.py b/app/services/speech_analysis_cache.py new file mode 100644 index 000000000..89e8aa956 --- /dev/null +++ b/app/services/speech_analysis_cache.py @@ -0,0 +1,241 @@ +"""Atomic on-disk cache for vocal isolation and Rhubarb analysis.""" +from __future__ import annotations + +import hashlib +import json +import os +import tempfile +import threading +from concurrent.futures import Future +from pathlib import Path + +SCHEMA = 1 +DEFAULT_MAX_BYTES = 128 * 1024 * 1024 +DEFAULT_MAX_ENTRIES = 64 + +_inflight_guard = threading.Lock() +_inflight: dict[str, Future] = {} +_pin_guard = threading.Lock() +_pins: dict[str, int] = {} +_store_lock = threading.Lock() + + +def cache_dir() -> Path: + override = os.environ.get("SPEECH_ANALYSIS_CACHE_DIR", "").strip() + if override: + return Path(override) + return Path(__file__).resolve().parents[2] / "cache" / "speech-analysis" + + +def reset_runtime_state() -> None: + """Drop in-flight waiters. Cached files are left in place.""" + with _inflight_guard: + _inflight.clear() + with _pin_guard: + _pins.clear() + + +def audio_digest(data: bytes) -> str: + return hashlib.sha256(data).hexdigest() + + +def file_identity(path: str | Path | None) -> list: + if not path: + return [] + candidate = Path(path) + try: + stat = candidate.stat() + except OSError: + return [str(candidate), "missing"] + try: + resolved = str(candidate.resolve()) + except OSError: + resolved = str(candidate) + return [resolved, stat.st_size, stat.st_mtime_ns] + + +def material_key(material: dict) -> str: + payload = json.dumps(material, sort_keys=True, separators=(",", ":"), ensure_ascii=True) + return hashlib.sha256(payload.encode("utf-8")).hexdigest() + + +def segment_window(duration: float) -> dict: + """Window of the payload itself. Never a stitch of other analyses.""" + return {"start": 0.0, "duration": duration, "rate": 16000, "channels": 1, "width": 2} + + +def isolation_material(data: bytes, duration: float, model: dict) -> dict: + return { + "schema": SCHEMA, + "kind": "isolation", + "audio": audio_digest(data), + "bytes": len(data), + "segment": segment_window(duration), + **model, + } + + +def analysis_material( + data: bytes, + duration: float, + isolate: bool, + rhubarb: str | None, + params: dict, + isolation: dict | None = None, +) -> dict: + material = { + "schema": SCHEMA, + "kind": "analysis", + "audio": audio_digest(data), + "bytes": len(data), + "segment": segment_window(duration), + "isolate": isolate, + "rhubarb": file_identity(rhubarb), + "params": params, + } + if isolation: + material["isolation"] = isolation + return material + + +def remember(material: dict, compute, suffix: str, *, root: Path | None = None) -> bytes: + key = material_key(material) + folder = root or cache_dir() + _pin(key) + try: + hit = _load(folder, key, suffix) + if hit is not None: + return hit + return _coalesce(folder, key, suffix, compute) + finally: + _unpin(key) + + +def _pin(key: str) -> None: + with _pin_guard: + _pins[key] = _pins.get(key, 0) + 1 + + +def _unpin(key: str) -> None: + with _pin_guard: + remaining = _pins.get(key, 0) - 1 + if remaining > 0: + _pins[key] = remaining + else: + _pins.pop(key, None) + + +def _protected() -> set[str]: + with _pin_guard: + pinned = {key for key, count in _pins.items() if count > 0} + with _inflight_guard: + return pinned | set(_inflight) + + +def _entry_path(folder: Path, key: str, suffix: str) -> Path: + return folder / key[:2] / f"{key}{suffix}" + + +def _load(folder: Path, key: str, suffix: str) -> bytes | None: + path = _entry_path(folder, key, suffix) + try: + data = path.read_bytes() + except OSError: + return None + return data or None + + +def _store(folder: Path, key: str, suffix: str, data: bytes) -> None: + path = _entry_path(folder, key, suffix) + path.parent.mkdir(parents=True, exist_ok=True) + handle, tmp_name = tempfile.mkstemp(prefix=f".{key}.", suffix=".tmp", dir=path.parent) + tmp = Path(tmp_name) + try: + with os.fdopen(handle, "wb") as output: + output.write(data) + output.flush() + os.fsync(output.fileno()) + os.replace(tmp, path) + except Exception: + try: + tmp.unlink() + except OSError: + pass + raise + + +def _coalesce(folder: Path, key: str, suffix: str, compute) -> bytes: + owner = False + with _inflight_guard: + pending = _inflight.get(key) + if pending is None: + pending = Future() + _inflight[key] = pending + owner = True + if not owner: + return pending.result() + try: + hit = _load(folder, key, suffix) + payload = hit if hit is not None else _compute_and_store(folder, key, suffix, compute) + pending.set_result(payload) + return payload + except BaseException as error: + pending.set_exception(error) + raise + finally: + with _inflight_guard: + if _inflight.get(key) is pending: + del _inflight[key] + + +def _compute_and_store(folder: Path, key: str, suffix: str, compute) -> bytes: + payload = bytes(compute()) + if not payload: + raise RuntimeError("Speech analysis produced an empty cache payload.") + try: + with _store_lock: + _store(folder, key, suffix, payload) + _evict(folder, keep={key}) + except OSError: + pass + return payload + + +def _list_entries(folder: Path) -> list[tuple[float, int, str, Path]]: + found: list[tuple[float, int, str, Path]] = [] + if not folder.is_dir(): + return found + for path in folder.glob("*/*"): + name = path.name + if not path.is_file() or name.startswith(".") or len(name) < 64: + continue + try: + stat = path.stat() + except OSError: + continue + found.append((stat.st_mtime, stat.st_size, name[:64], path)) + return found + + +def _evict(folder: Path, keep: set[str]) -> None: + max_bytes = int(os.environ.get("SPEECH_ANALYSIS_CACHE_MAX_BYTES", DEFAULT_MAX_BYTES)) + max_entries = int(os.environ.get("SPEECH_ANALYSIS_CACHE_MAX_ENTRIES", DEFAULT_MAX_ENTRIES)) + entries = sorted(_list_entries(folder)) + protected = _protected() | keep + total = sum(item[1] for item in entries) + count = len(entries) + for _mtime, size, key, path in entries: + if count <= max_entries and total <= max_bytes: + return + if key in protected: + continue + try: + path.unlink() + except OSError: + continue + total -= size + count -= 1 + try: + path.parent.rmdir() + except OSError: + pass diff --git a/app/services/speech_analysis_request.py b/app/services/speech_analysis_request.py new file mode 100644 index 000000000..8d5b1277c --- /dev/null +++ b/app/services/speech_analysis_request.py @@ -0,0 +1,24 @@ +"""The existing WAV endpoint also accepts a bounded audio + script envelope.""" +import base64 +import binascii +import json + +from services.scene3d_speech import SpeechAnalysisError + +MAX_REQUEST_BYTES = 4_100_000 + + +def speech_request(data: bytes, content_type: str) -> tuple[bytes, dict]: + if content_type == "audio/wav": + return data, {} + try: + body = json.loads(data) + dialogue, language = body.get("dialogue", ""), body.get("language", "") + if not isinstance(dialogue, str) or len(dialogue) > 4000 or "\x00" in dialogue: + raise ValueError("Invalid dialogue") + if not isinstance(language, str) or len(language) > 16: + raise ValueError("Invalid language") + audio = base64.b64decode(body["wavBase64"], validate=True) + return audio, {"dialogue": dialogue, "language": language} + except (ValueError, TypeError, AttributeError, KeyError, binascii.Error) as exc: + raise SpeechAnalysisError("Expected a WAV with up to 4000 script characters and a language code.") from exc diff --git a/app/services/studio_video_preparation.py b/app/services/studio_video_preparation.py new file mode 100644 index 000000000..dc06a2247 --- /dev/null +++ b/app/services/studio_video_preparation.py @@ -0,0 +1,195 @@ +"""Provider-free preparation for the closed generation.video command. + +Model catalog and installed-file checks run through injected callbacks. +``StudioVideoResources`` inspects canonical references. This boundary does +not download weights, schedule a worker or create a second queue. +""" + +from __future__ import annotations + +from copy import deepcopy +import math +from collections.abc import Callable, Mapping +from typing import Any + +from fastapi import HTTPException + +from services.image_generation_commands import command_error +from services.studio_image_resources import validate_lora_multipliers +from services.video_generation_spec import VIDEO_MODEL_TYPES, WAN_T2V_ARCHITECTURES + + +_REFERENCE_FIELDS = ( + "image_start", + "image_end", + "image_refs", + "video_guide", + "video_mask", + "video_source", +) + + +def _definition_for(model_definition, model_type: str) -> dict[str, Any]: + if callable(model_definition): + definition = model_definition(model_type) + elif isinstance(model_definition, Mapping): + definition = model_definition.get(model_type) + else: + definition = None + if not isinstance(definition, Mapping): + raise ValueError("Choose an installed Wan 2.1 Text2Video model from the catalog") + return deepcopy(dict(definition)) + + +def _finite(value: Any, field: str, *, minimum: float | None = None, maximum: float | None = None) -> float: + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise ValueError(f"input.params.{field} must be a finite number") + parsed = float(value) + if not math.isfinite(parsed): + raise ValueError(f"input.params.{field} must be a finite number") + if minimum is not None and parsed < minimum: + raise ValueError(f"input.params.{field} is below the model's declared minimum") + if maximum is not None and parsed > maximum: + raise ValueError(f"input.params.{field} exceeds the model's declared maximum") + return parsed + + +def _has_reference(value) -> bool: + if value in (None, "", []): + return False + if isinstance(value, list): + return any(item not in (None, "") for item in value) + return True + + +def _validate_model( + model_type: str, + definition: Mapping[str, Any], + model_downloaded: Callable[[str], bool], +) -> None: + if model_type not in VIDEO_MODEL_TYPES: + raise ValueError("Choose a registered Wan 2.1 Text2Video model") + if definition.get("image_outputs") or definition.get("audio_only") or definition.get("returns_audio"): + raise ValueError("The selected model is not a Wan 2.1 Text2Video handler") + architecture = definition.get("architecture") + if architecture is not None and str(architecture) not in WAN_T2V_ARCHITECTURES: + raise ValueError("The selected model definition is not a Wan 2.1 Text2Video architecture") + if architecture is None and definition.get("t2v_class") is not True: + raise ValueError("The selected model definition is not a Wan 2.1 Text2Video handler") + if not callable(model_downloaded) or not model_downloaded(model_type): + raise command_error( + 409, + "model_unavailable", + "Required Wan 2.1 Text2Video model files are not installed; install them before submitting", + ) + + +def _validate_frames(working: dict[str, Any], definition: Mapping[str, Any]) -> None: + length = working.get("video_length") + if type(length) is not int: + raise ValueError("input.params.video_length must be an integer frame count") + minimum = int(definition.get("frames_minimum", 5) or 5) + step = int(definition.get("frames_steps", 4) or 4) + maximum = definition.get("frames_maximum") + if length < minimum: + raise ValueError("input.params.video_length is below the model's declared minimum") + if maximum is not None and length > int(maximum): + raise ValueError("input.params.video_length exceeds the model's declared maximum") + if step > 0 and (length - minimum) % step: + raise ValueError("input.params.video_length must follow the model's frame step") + + +def _validate_sampling(working: dict[str, Any], definition: Mapping[str, Any]) -> int: + steps = working.get("num_inference_steps") + if type(steps) is not int or steps < 1: + raise ValueError("input.params.num_inference_steps must be a positive integer") + lower = definition.get("inference_steps_min") + upper = definition.get("inference_steps_max") + if lower is not None and steps < int(lower): + raise ValueError("input.params.num_inference_steps is below the model's declared minimum") + if upper is not None and steps > int(upper): + raise ValueError("input.params.num_inference_steps exceeds the model's declared maximum") + _finite(working.get("guidance_scale"), "guidance_scale", minimum=0) + phases = working.get("guidance_phases", 1) + if type(phases) is not int or not 1 <= phases <= 3: + raise ValueError("input.params.guidance_phases must be an integer from 1 to 3") + maximum = definition.get("guidance_max_phases") + if maximum is not None and phases > int(maximum): + raise ValueError("input.params.guidance_phases exceeds the model's declared maximum") + solver = working.get("sample_solver") or "" + choices = definition.get("sample_solvers") or [] + allowed = [item[1] if isinstance(item, (list, tuple)) else str(item) for item in choices] + if solver and allowed and solver not in allowed: + raise ValueError("input.params.sample_solver is not supported by this model") + if working.get("negative_prompt") and definition.get("no_negative_prompt"): + raise ValueError("input.params.negative_prompt is not supported by this model") + return max(1, int(maximum if maximum is not None else 1)) + + +def _reject_unsupported_references(working: Mapping[str, Any]) -> None: + active = [field for field in _REFERENCE_FIELDS if _has_reference(working.get(field))] + if active: + raise ValueError( + "This Wan 2.1 Text2Video command does not accept image or video references" + ) + + +def prepare_studio_video( + params, + *, + model_definition, + model_downloaded, + resources, + execution_policy, +): + """Return detached native video parameters and portable resource identities.""" + if not isinstance(params, dict): + raise command_error(422, "invalid_studio_video_input", "Video parameters must be an object") + working = deepcopy(params) + try: + workspace = working.get("workspace") + if not isinstance(workspace, str) or not workspace.strip(): + raise ValueError("input.workspace must be an explicit output workspace") + if not callable(execution_policy): + raise TypeError("execution_policy must be a workspace policy callback") + execution_policy(workspace) + + model_type = working.get("model_type") + if not isinstance(model_type, str): + raise ValueError("input.params.model_type must be a string") + definition = _definition_for(model_definition, model_type) + _validate_model(model_type, definition, model_downloaded) + _validate_frames(working, definition) + # T2V forbids sliding windows. primary_settings defaults + # sliding_window_size to 129; validate_settings then returns None for + # any longer admitted job, and _run_generation treats a skipped-only + # queue as success with no file. Pin one window to the requested length. + working["sliding_window_size"] = working["video_length"] + working.setdefault("multi_prompts_gen_type", 2) + phases = _validate_sampling(working, definition) + if working.get("activated_loras") or working.get("loras_multipliers"): + validate_lora_multipliers(working, phases) + + prepared, media = resources.prepare_media(working) + if not isinstance(prepared, dict) or not isinstance(media, list): + raise ValueError("video resource preparation returned invalid native parameters") + prepared = deepcopy(prepared) + media = deepcopy(media) + _reject_unsupported_references(prepared) + + prepared["generation_mode"] = "video" + prepared["image_mode"] = 0 + prepared["sliding_window_size"] = prepared["video_length"] + prepared.setdefault("repeat_generation", 1) + prepared.setdefault("batch_size", 1) + prepared.setdefault("prompt_enhancer", "") + prepared.setdefault("multi_prompts_gen_type", 2) + loras = resources.prepare_loras(prepared, definition) + return prepared, [*media, *deepcopy(loras)] + except HTTPException: + raise + except (OSError, TypeError, ValueError) as error: + raise command_error(422, "invalid_studio_video_input", str(error)) from error + + +__all__ = ["prepare_studio_video"] diff --git a/app/services/studio_video_resources.py b/app/services/studio_video_resources.py new file mode 100644 index 000000000..675b8fa7c --- /dev/null +++ b/app/services/studio_video_resources.py @@ -0,0 +1,101 @@ +"""Inspect canonical image/video references for typed generation.video. + +SFX keeps ``resources("video")``. This adapter is a distinct ``studio_video`` +path so image/video references of Wan Text2Video never reuse the MMAudio +guide resolver. Portable identities stay on the receipt; resolved paths stay +in the detached worker map. +""" + +from __future__ import annotations + +from copy import deepcopy +import math +import subprocess + +from PIL import Image + +from services.studio_image_resources import IMAGE_FIELDS, StudioImageResources, file_identity +from services.video_editor import probe_media + + +VIDEO_FIELDS = ("video_guide", "video_mask", "video_source") + + +class StudioVideoResources(StudioImageResources): + """Resolve image and optional video references for generation.video.""" + + media_kind = "image" + + def _resolve(self, value, kind): + previous = self.media_kind + self.media_kind = kind + try: + return self._media(value) + finally: + self.media_kind = previous + + def _image_identity(self, path, url, workspace, role, index): + identity = file_identity(path) + with Image.open(path) as picture: + picture.verify() + return { + "role": role, + "index": index, + "url": url, + "workspace": workspace, + **identity, + } + + def _video_identity(self, path, url, workspace, role, index): + identity = file_identity(path) + try: + information = probe_media(path) + except subprocess.TimeoutExpired as error: + raise ValueError("A selected video reference could not be inspected in time") from error + except (OSError, TypeError, ValueError) as error: + raise ValueError("A selected video reference is not a readable video") from error + duration = information.get("duration") + width = information.get("width") + height = information.get("height") + if (isinstance(duration, bool) or not isinstance(duration, (int, float)) + or not math.isfinite(float(duration)) or float(duration) <= 0): + raise ValueError("A selected video reference has no finite positive duration") + if type(width) is not int or width <= 0 or type(height) is not int or height <= 0: + raise ValueError("A selected video reference has invalid dimensions") + return { + "role": role, + "index": index, + "url": url, + "workspace": workspace, + "duration_seconds": float(duration), + "width": width, + "height": height, + "fps": information.get("fps"), + **identity, + } + + def _prepare_field(self, working, resources, field, kind, identity): + raw = working.get(field) + if raw in (None, "", []): + return + values = raw if isinstance(raw, list) else [raw] + paths = [] + for index, value in enumerate(values): + if value in (None, ""): + paths.append("" if isinstance(raw, list) else None) + continue + if not isinstance(value, str): + raise ValueError(f"input.params.{field} must be a canonical {kind} reference") + path, workspace = self._resolve(value, kind) + resources.append(identity(path, value, workspace, field, index)) + paths.append(path) + working[field] = paths if isinstance(raw, list) else paths[0] + + def prepare_media(self, params): + working = deepcopy(params) + resources = [] + for field in IMAGE_FIELDS: + self._prepare_field(working, resources, field, "image", self._image_identity) + for field in VIDEO_FIELDS: + self._prepare_field(working, resources, field, "video", self._video_identity) + return working, resources diff --git a/app/services/studio_video_spec.py b/app/services/studio_video_spec.py new file mode 100644 index 000000000..fff2ab28e --- /dev/null +++ b/app/services/studio_video_spec.py @@ -0,0 +1,61 @@ +"""Studio aliases for the closed ``generation.video`` contract. + +The freeze/schema authority lives in :mod:`video_generation_spec`. This module +keeps the Studio naming used by speech/music/SFX adapters. +""" + +from services.video_generation_spec import ( + EXCLUDED_VIDEO_FIELDS, + FINGERPRINT_VERSION, + INACTIVE_VIDEO_FIELDS, + OPERATION, + SCHEMA_VERSION, + STUDIO_VIDEO_DEFAULTS, + SUPPORTED_INPUT_FIELDS, + VIDEO_MODEL_FAMILY, + VIDEO_MODEL_TYPES, + WAN_T2V_ARCHITECTURES, + StudioVideoInput, + StudioVideoParams, + VideoGenerationInput, + VideoGenerationParams, + VideoGenerationSpecError, + freeze_studio_video_spec, + freeze_video_generation_spec, + studio_video_schema, + video_generation_schema, +) + +STUDIO_VIDEO_OPERATION = OPERATION +STUDIO_VIDEO_SCHEMA_VERSION = SCHEMA_VERSION +StudioVideoSpecError = VideoGenerationSpecError +StudioVideoGenerationInput = StudioVideoInput +StudioVideoGenerationParams = StudioVideoParams + + +__all__ = [ + "EXCLUDED_VIDEO_FIELDS", + "FINGERPRINT_VERSION", + "INACTIVE_VIDEO_FIELDS", + "OPERATION", + "SCHEMA_VERSION", + "STUDIO_VIDEO_DEFAULTS", + "STUDIO_VIDEO_OPERATION", + "STUDIO_VIDEO_SCHEMA_VERSION", + "SUPPORTED_INPUT_FIELDS", + "VIDEO_MODEL_FAMILY", + "VIDEO_MODEL_TYPES", + "WAN_T2V_ARCHITECTURES", + "StudioVideoGenerationInput", + "StudioVideoGenerationParams", + "StudioVideoInput", + "StudioVideoParams", + "StudioVideoSpecError", + "VideoGenerationInput", + "VideoGenerationParams", + "VideoGenerationSpecError", + "freeze_studio_video_spec", + "freeze_video_generation_spec", + "studio_video_schema", + "video_generation_schema", +] diff --git a/app/services/user_diagnostics.py b/app/services/user_diagnostics.py new file mode 100644 index 000000000..7f1e64545 --- /dev/null +++ b/app/services/user_diagnostics.py @@ -0,0 +1,550 @@ +"""User-facing install and generation diagnostics. + +Explains why an operation or model is available using observed facts: +component, driver/backend, RAM/VRAM, version, and an existing repair path. +Does not import CUDA, Torch, WanGP or other heavy engines. +""" +from __future__ import annotations + +import hashlib +import json +import platform as host_platform +import re +import subprocess +import sys +from datetime import datetime, timezone +from typing import Any, Mapping + +from app_identity import read_app_version +from services import runtime_profiles as profiles + +SCHEMA = "hocuspocus.user-diagnostics-report" +SCHEMA_VERSION = 1 + +REPAIR = { + "install_update": { + "id": "install_update", + "summary": "Run Install or Update to repair the selected runtime before Start.", + }, + "repair_web_ui": { + "id": "repair_web_ui", + "summary": "Retry Repair Web UI, then restart Start. This does not reinstall engines.", + }, + "nvidia_driver": { + "id": "nvidia_driver", + "summary": "Install or upgrade the NVIDIA driver to the recipe minimum, then re-run Install or Update.", + }, + "cpu_amd_recipe": { + "id": "cpu_amd_recipe", + "summary": "Local AI installation currently requires NVIDIA; CPU/AMD/Intel/MPS recipes are not enabled.", + }, + "platform_unsupported": { + "id": "platform_unsupported", + "summary": "No installation recipe for this OS/architecture; Windows and Linux x64 are supported.", + }, + "download_model": { + "id": "download_model", + "summary": "Install required model files from the model catalog before submitting.", + }, + "lower_vram": { + "id": "lower_vram", + "summary": "Lower VRAM headroom (vram_safety_coefficient) or choose a smaller catalog variant.", + }, + "unpublished": { + "id": "unpublished", + "summary": "This operation is not published in the native command catalog.", + }, +} + +OPERATIONS = ( + {"id": "generation.image", "kind": "operation", "component": "wangp", + "published": True, "needs_gpu": True, "requires_install": True, "vram_gb": 6, "ram_gb": 16}, + {"id": "generation.speech", "kind": "operation", "component": "wangp", + "published": True, "needs_gpu": True, "requires_install": True, "vram_gb": 4, "ram_gb": 16}, + {"id": "generation.music", "kind": "operation", "component": "wangp", + "published": True, "needs_gpu": True, "requires_install": True, "vram_gb": 6, "ram_gb": 16}, + {"id": "generation.sfx", "kind": "operation", "component": "wangp", + "published": True, "needs_gpu": True, "requires_install": True, "vram_gb": 6, "ram_gb": 16}, + {"id": "generation.video", "kind": "operation", "component": "wangp", + "published": True, "needs_gpu": True, "requires_install": True, "vram_gb": 8, "ram_gb": 16}, + {"id": "generation.receipt", "kind": "operation", "component": "wangp", + "published": True, "needs_gpu": False, "requires_install": False, "vram_gb": None, "ram_gb": None}, + {"id": "tools.upscale", "kind": "operation", "component": "wangp", + "published": True, "needs_gpu": True, "requires_install": True, "vram_gb": 8, "ram_gb": 16}, + {"id": "generation.model3d", "kind": "operation", "component": "hunyuan3d", + "published": False, "needs_gpu": True, "requires_install": True, "vram_gb": 8, "ram_gb": 16}, + {"id": "engine.minimax_h3", "kind": "operation", "component": "minimax_h3", + "published": True, "needs_gpu": True, "requires_install": True, "vram_gb": 24, "ram_gb": 32}, + {"id": "engine.sam", "kind": "operation", "component": "sam", + "published": True, "needs_gpu": True, "requires_install": True, "vram_gb": 6, "ram_gb": 16}, + {"id": "engine.rigging", "kind": "operation", "component": "rigging", + "published": True, "needs_gpu": True, "requires_install": True, "vram_gb": 8, "ram_gb": 16}, +) + +MODELS = ( + {"id": "flux2_klein_4b", "kind": "model", "component": "wangp", + "published": True, "needs_gpu": True, "requires_install": True, "vram_gb": 6, "ram_gb": 16}, + {"id": "ltx2_22B", "kind": "model", "component": "wangp", + "published": True, "needs_gpu": True, "requires_install": True, "vram_gb": 16, "ram_gb": 32}, + {"id": "minimax_h3", "kind": "model", "component": "minimax_h3", + "published": True, "needs_gpu": True, "requires_install": True, "vram_gb": 24, "ram_gb": 32}, + {"id": "hunyuan3d-2.1", "kind": "model", "component": "hunyuan3d", + "published": True, "needs_gpu": True, "requires_install": True, "vram_gb": 10, "ram_gb": 16}, + {"id": "ace_step_v1_5_xl", "kind": "model", "component": "wangp", + "published": True, "needs_gpu": True, "requires_install": True, "vram_gb": 6, "ram_gb": 16}, + {"id": "kugelaudio_0_open", "kind": "model", "component": "wangp", + "published": True, "needs_gpu": True, "requires_install": True, "vram_gb": 6, "ram_gb": 16}, + {"id": "trellis2", "kind": "model", "component": "hunyuan3d", + "published": True, "needs_gpu": True, "requires_install": True, "vram_gb": 24, "ram_gb": 32}, +) + +_OMIT_KEYS = frozenset({ + "prompt", "negative_prompt", "lyrics", "prompt_full", "prompt_original", + "prompt_effective", "prompt_display", "enhanced_prompt", "video_prompt", + "cookie", "cookies", "set_cookie", +}) +_SENSITIVE_PARTS = ( + "api_key", "apikey", "access_token", "refresh_token", "authorization", + "password", "passwd", "client_secret", "private_key", "cookie", "session", + "secret", "credential", +) +_BEARER_RE = re.compile(r"(?i)\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]+") +_QUERY_RE = re.compile(r"(?i)([?&](?:token|api[_-]?key|access[_-]?token|session)=)[^&#\s]+") +_COOKIE_RE = re.compile(r"(?i)((?:cookie|set-cookie)\s*[:=]\s*)[^\r\n]+") +_ASSIGN_RE = re.compile( + r"(?i)\b(api[_-]?key|access[_-]?token|password|secret|session|token)\s*[:=]\s*[^\s,;]+" +) +_SK_RE = re.compile(r"(?i)\bsk-[A-Za-z0-9_-]+") + + +def _now() -> str: + return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") + + +def _sensitive_action(key: str) -> str: + token = str(key or "").strip().casefold().replace("-", "_") + if token in _OMIT_KEYS: + return "omit" + if token == "token" or token.endswith("_token"): + return "redact" + for part in _SENSITIVE_PARTS: + if part in token: + return "redact" + return "keep" + + +def _redact_string(value: str) -> str: + text = _BEARER_RE.sub(r"\1 [REDACTED]", value) + text = _QUERY_RE.sub(r"\1[REDACTED]", text) + text = _COOKIE_RE.sub(r"\1[REDACTED]", text) + text = _ASSIGN_RE.sub(r"\1=[REDACTED]", text) + return _SK_RE.sub("[REDACTED]", text)[:2000] + + +def _redact_mapping(value: Mapping[str, Any], depth: int) -> dict[str, Any]: + result: dict[str, Any] = {} + for key, item in list(value.items())[:80]: + name = str(key)[:160] + action = _sensitive_action(name) + if action == "omit": + continue + if action == "redact": + result[name] = "[REDACTED]" + continue + result[name] = redact_for_pack(item, depth + 1) + return result + + +def redact_for_pack(value: Any, depth: int = 0) -> Any: + """Drop prompts/cookies and mask credentials before a support pack is written.""" + if depth > 8: + return None + if isinstance(value, str): + return _redact_string(value) + if isinstance(value, list): + return [redact_for_pack(item, depth + 1) for item in value[:80]] + if isinstance(value, dict): + return _redact_mapping(value, depth) + if value is None or isinstance(value, (bool, int, float)): + return value + return str(value)[:500] + + +def _gpu_csv() -> str | None: + try: + result = subprocess.run( + ["nvidia-smi", "--query-gpu=name,driver_version,memory.total", + "--format=csv,noheader,nounits"], + capture_output=True, text=True, timeout=5, check=True, + ) + except (OSError, subprocess.SubprocessError): + return None + text = (result.stdout or "").strip() + return text or None + + +def _mib_to_gb(raw: str) -> float | None: + try: + mib = float(raw) + except (TypeError, ValueError): + return None + if mib < 0: + return None + return round(mib / 1024.0, 1) + + +def parse_gpu(csv_text: str | None) -> dict[str, Any]: + empty = {"name": None, "driver": None, "vram_gb": None, "backend": "none", "kind": "unknown"} + if not csv_text or not str(csv_text).strip(): + return dict(empty) + line = str(csv_text).strip().splitlines()[0] + parts = [part.strip() for part in line.split(",")] + name = parts[0] if parts else "" + driver = parts[1] if len(parts) > 1 else "" + memory = parts[2] if len(parts) > 2 else "" + driver_ok = bool(re.fullmatch(r"\d+(?:\.\d+)+", driver)) + kind = "nvidia" if driver_ok or name else "unknown" + return { + "name": name or None, + "driver": driver if driver_ok else None, + "vram_gb": _mib_to_gb(memory), + "backend": "nvidia" if kind == "nvidia" else "none", + "kind": kind, + } + + +def _ram_gb() -> float | None: + try: + import psutil + return round(psutil.virtual_memory().total / (1024 ** 3), 1) + except Exception: + return None + + +def _cpu_count() -> int | None: + try: + import psutil + return int(psutil.cpu_count(logical=True) or 0) or None + except Exception: + return None + + +def _git_revision() -> str | None: + try: + result = subprocess.run( + ["git", "-C", str(profiles.APP_DIR.parent), "rev-parse", "--short", "HEAD"], + capture_output=True, text=True, timeout=3, check=True, + ) + except (OSError, subprocess.SubprocessError): + return None + value = (result.stdout or "").strip() + return value or None + + +def _ui_build_id() -> str: + index = profiles.APP_DIR.parent / "ui" / "dist" / "index.html" + try: + return hashlib.sha256(index.read_bytes()).hexdigest()[:16] + except OSError: + return "missing" + + +def _pick(observe: Mapping[str, Any], key: str, fallback): + if key in observe: + return observe[key] + return fallback() if callable(fallback) else fallback + + +def receipt_status(engine: str, platform: str) -> dict[str, Any]: + """Read the managed receipt only. Never spawn engine Python or import Torch.""" + spec = profiles.recipe(engine, platform) + # Core and WanGP share app/env, but have different platform recipes. + # A receipt in that folder is not evidence for an unsupported engine. + if platform not in spec["platforms"]: + return {"present": False, "installed": False, "fingerprint_match": False} + path = profiles.APP_DIR.parent / spec["env"] / ".hocus-runtime-profile.json" + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError): + return {"present": False, "installed": False, "fingerprint_match": False} + if not isinstance(payload, dict): + return {"present": True, "installed": False, "fingerprint_match": False} + try: + expected = profiles.dependency_fingerprint(engine, platform) + except OSError: + # An incomplete installation is a diagnostic result, not a server error. + return {"present": True, "installed": False, "fingerprint_match": False} + fingerprint_match = payload.get("fingerprint") == expected + installed = ( + fingerprint_match + and payload.get("profile") == spec["id"] + and payload.get("cudaCalculation") is bool(spec.get("cuda")) + ) + return { + "present": True, + "installed": bool(installed), + "fingerprint_match": bool(fingerprint_match), + "profile": payload.get("profile"), + "cuda_calculation": payload.get("cudaCalculation"), + } + + +def repair_for_reason(reason: str | None) -> dict[str, str]: + text = (reason or "").lower() + if "driver" in text and "nvidia" in text: + return REPAIR["nvidia_driver"] + if "cpu/amd" in text or "intel/mps" in text or "requires nvidia" in text: + return REPAIR["cpu_amd_recipe"] + if "architecture" in text or "no installation recipe" in text: + return REPAIR["platform_unsupported"] + return REPAIR["install_update"] + + +def memory_blockers( + spec: Mapping[str, Any], observed: Mapping[str, Any], +) -> list[tuple[str, dict[str, str]]]: + blockers: list[tuple[str, dict[str, str]]] = [] + vram_need = spec.get("vram_gb") + vram = observed.get("vram_gb") + if isinstance(vram_need, (int, float)) and isinstance(vram, (int, float)) and vram < vram_need: + repair = REPAIR["lower_vram"] if vram >= 8 else REPAIR["download_model"] + blockers.append(( + f"Observed VRAM {vram} GB is below the {vram_need} GB catalog minimum.", + repair, + )) + ram_need = spec.get("ram_gb") + ram = observed.get("ram_gb") + if isinstance(ram_need, (int, float)) and isinstance(ram, (int, float)) and ram < ram_need: + blockers.append(( + f"Observed RAM {ram} GB is below the {ram_need} GB catalog minimum.", + REPAIR["install_update"], + )) + return blockers + + +def engine_blockers( + spec: Mapping[str, Any], engine: Mapping[str, Any], observed: Mapping[str, Any], +) -> list[tuple[str, dict[str, str]]]: + blockers: list[tuple[str, dict[str, str]]] = [] + if not spec.get("published", True): + blockers.append(( + "This operation is not published in the native command catalog.", + REPAIR["unpublished"], + )) + needs_gpu = spec.get("needs_gpu", True) + if needs_gpu and not engine.get("supported"): + reason = str(engine.get("reason") or "No compatible engine recipe.") + blockers.append((reason, repair_for_reason(reason))) + if spec.get("requires_install", True) and engine.get("supported") and not engine.get("installed"): + label = engine.get("label") or spec["component"] + blockers.append(( + f"{label} is not installed with a matching runtime receipt.", + REPAIR["install_update"], + )) + if needs_gpu and observed.get("backend") != "nvidia": + blockers.append(( + "No NVIDIA driver/backend was observed; local generation recipes require NVIDIA.", + REPAIR["cpu_amd_recipe"], + )) + blockers.extend(memory_blockers(spec, observed)) + return blockers + + +def explain_target( + spec: Mapping[str, Any], + engines: Mapping[str, Mapping[str, Any]], + observed: Mapping[str, Any], + app_version: str, +) -> dict[str, Any]: + engine = engines.get(str(spec["component"])) or {} + blockers = engine_blockers(spec, engine, observed) + reasons = [text for text, _repair in blockers] + if not blockers: + reasons.append("Recipe, driver, and observed memory meet the published facts.") + reasons.append("Weight files are not probed by this report.") + return { + "kind": spec["kind"], + "id": spec["id"], + "available": not blockers, + "component": spec["component"], + "driver": observed.get("driver"), + "backend": observed.get("backend"), + "ram_gb_observed": observed.get("ram_gb"), + "vram_gb_observed": observed.get("vram_gb"), + "version": { + "app": app_version, + "recipe": engine.get("recipe_id"), + "python": engine.get("python"), + "torch": engine.get("torch"), + "cuda": engine.get("cuda"), + }, + "repair_path": blockers[0][1] if blockers else None, + "reasons": reasons, + "weights": "not_probed", + } + + +def describe_engine(name: str, selected: Mapping[str, Any], receipt: Mapping[str, Any]) -> dict[str, Any]: + supported = bool(selected.get("supported")) + repair = None + if not supported: + repair = repair_for_reason(selected.get("reason") if isinstance(selected.get("reason"), str) else None) + elif not receipt.get("installed"): + repair = REPAIR["install_update"] + return { + "id": name, + "label": selected.get("label"), + "required": bool(selected.get("required")), + "supported": supported, + "installed": bool(receipt.get("installed")) and supported, + "reason": selected.get("reason"), + "warning": selected.get("warning"), + "recipe_id": selected.get("id"), + "python": selected.get("python"), + "torch": selected.get("torch"), + "cuda": selected.get("cuda"), + "driver_minimum": selected.get("driverMinimum"), + "repair_path": repair, + "receipt": { + "present": bool(receipt.get("present")), + "fingerprint_match": bool(receipt.get("fingerprint_match")), + }, + } + + +def _observe_host(observe: Mapping[str, Any]) -> dict[str, Any]: + gpu = parse_gpu(_pick(observe, "gpu_csv", _gpu_csv)) + platform = str(observe.get("platform") or sys.platform) + architecture = profiles.normalize_arch(str(observe.get("architecture") or host_platform.machine())) + return { + "platform": platform, + "architecture": architecture, + "python": host_platform.python_version(), + "gpu_name": gpu["name"], + "driver": gpu["driver"], + "backend": gpu["backend"], + "gpu_kind": gpu["kind"], + "ram_gb": _pick(observe, "ram_gb", _ram_gb), + "vram_gb": gpu["vram_gb"], + "cpu_count": _pick(observe, "cpu_count", _cpu_count), + "app_version": _pick(observe, "app_version", read_app_version), + "git_revision": _pick(observe, "git_revision", _git_revision), + "ui_build_id": _pick(observe, "ui_build_id", _ui_build_id), + "receipts": observe.get("receipts"), + } + + +def _receipt_for(name: str, platform: str, overrides: Mapping[str, Any] | None) -> dict[str, Any]: + if overrides is not None and name in overrides: + item = overrides[name] + return dict(item) if isinstance(item, Mapping) else {"present": False, "installed": False} + return receipt_status(name, platform) + + +def _engine_table(host: Mapping[str, Any]) -> dict[str, dict[str, Any]]: + selected = profiles.select_profiles( + host["platform"], host["architecture"], host["gpu_kind"], host["driver"], + ) + overrides = host.get("receipts") if isinstance(host.get("receipts"), dict) else None + engines: dict[str, dict[str, Any]] = {} + for name, item in selected["engines"].items(): + engines[name] = describe_engine(name, item, _receipt_for(name, selected["platform"], overrides)) + return engines + + +def snapshot(*, observe: Mapping[str, Any] | None = None) -> dict[str, Any]: + host = _observe_host(observe or {}) + engines = _engine_table(host) + availability = [ + explain_target(spec, engines, host, str(host["app_version"] or "")) + for spec in (*OPERATIONS, *MODELS) + ] + return { + "schema": SCHEMA, + "schema_version": SCHEMA_VERSION, + "build": { + "app_version": host["app_version"], + "git_revision": host["git_revision"], + "ui_build_id": host["ui_build_id"], + }, + "platform": { + "os": host["platform"], + "architecture": host["architecture"], + "python": host["python"], + "gpu": host["gpu_kind"], + "gpu_name": host["gpu_name"], + "driver": host["driver"], + "backend": host["backend"], + }, + "observed": { + "ram_gb": host["ram_gb"], + "vram_gb": host["vram_gb"], + "cpu_count": host["cpu_count"], + }, + "capabilities": {"engines": list(engines.values())}, + "availability": availability, + } + + +def _mapping(value: Any) -> dict[str, Any]: + return dict(value) if isinstance(value, Mapping) else {} + + +def _bounded_message(value: Any) -> str | None: + if value is None: + return None + text = _redact_string(" ".join(str(value).split())) + return text[:300] or None + + +def _oom_fields(value: Any) -> dict[str, Any] | None: + info = _mapping(value) + if not info: + return None + keys = ("is_oom", "current_coefficient", "suggested_coefficient") + picked = {key: info[key] for key in keys if key in info} + return picked or None + + +def _first(*values: Any) -> Any: + for value in values: + if value not in (None, ""): + return value + return None + + +def _error_payload(task_map: Mapping[str, Any], receipt_map: Mapping[str, Any], error_map: Mapping[str, Any]) -> dict[str, Any]: + result = _mapping(receipt_map.get("result")) + oom = _oom_fields(_mapping(task_map.get("metadata")).get("oom_info")) + return { + "task_id": _first(task_map.get("id"), result.get("task_id")), + "intent_id": _first(receipt_map.get("commandId"), receipt_map.get("command_id")), + "operation": _first(receipt_map.get("operation"), task_map.get("workflow")), + "status": _first(task_map.get("status"), receipt_map.get("status"), result.get("status")), + "job_id": _first(task_map.get("backend_job_id"), result.get("job_id")), + "workspace": _first(task_map.get("workspace"), result.get("workspace")), + "code": _first(error_map.get("code"), "oom" if oom else None), + "message": _bounded_message(_first(error_map.get("message"), task_map.get("message"))), + "oom": oom, + } + + +def correlate_error(*, task: Any = None, receipt: Any = None, error: Any = None) -> dict[str, Any] | None: + if task is None and receipt is None and error is None: + return None + payload = _error_payload(_mapping(task), _mapping(receipt), _mapping(error)) + cleaned = {key: value for key, value in payload.items() if value not in (None, "", {})} + return redact_for_pack(cleaned) + + +def collect_report( + *, + observe: Mapping[str, Any] | None = None, + task: Any = None, + receipt: Any = None, + error: Any = None, +) -> dict[str, Any]: + pack = snapshot(observe=observe) + pack["generated_at"] = _now() + pack["error"] = correlate_error(task=task, receipt=receipt, error=error) + return redact_for_pack(pack) diff --git a/app/services/video_editor.py b/app/services/video_editor.py index 399f682a9..af64b9285 100644 --- a/app/services/video_editor.py +++ b/app/services/video_editor.py @@ -3,6 +3,26 @@ The editor deliberately stores only references to uploads/workspace outputs. Path validation remains the responsibility of the API layer; every path passed to this module must already be resolved to a permitted local file. + +Frame / PTS contract +-------------------- +Assembly is counted in integer frames at the integer output fps +``{24, 25, 30, 50, 60}``. Seconds are the rational ``frames / fps``; they are +never rounded to 4 decimals and then multiplied back by fps. + +Each source frame ``i`` covers the half-open interval ``[i/fps, (i+1)/fps)``. +Trim endpoints snap to the nearest source frame. A full-span clip whose +source fps matches the output fps keeps every decoded source frame: a +193-frame 30fps file stays 193 frames, not 192. The historical 589-from-590 +export came from ``round(duration, 4)`` (6.4333s for 193/30) feeding ``-t``, +which stopped short of the last frame before concat. + +Crossfades subtract ``round(overlap_seconds * fps)`` frames. Interstitial +time cards add ``round(card_seconds * fps)`` frames. Audio is padded to the +video span and compared by decoded samples / stream duration, not by +container duration alone. Export writes a staging file and replaces the +destination only after that validation; failures and cancels leave any +previous output untouched. """ from __future__ import annotations @@ -11,19 +31,37 @@ import math import os import random -import shutil import subprocess import tempfile -from collections.abc import Callable from typing import Any - -ProgressCallback = Callable[[int, str], None] +from services.video_editor_frames import ( + AbortCallback, + MIN_TRIM_SECONDS, + ProgressCallback, + SUPPORTED_FPS, + VideoEditorCancelled, + VideoEditorError, + _check_abort, + _concat_with_transitions, + _concat_without_transition, + _expected_concat_frames, + _layout_filter, + _normalise_clip, + _promote_output, + _run, + _seconds_for_ffmpeg, + _validate_export_artifact, + count_decoded_video_frames, + plan_clip_frames, + plan_transition_frames, + probe_assembly_source, + probe_audio_timing, +) INTERSTITIAL_TRANSITIONS = frozenset( {"later-clock", "later-tropical", "later-cinematic"} ) - SOURCE_SIDECAR_LIMIT_BYTES = 4 * 1024 * 1024 @@ -101,21 +139,6 @@ def is_interstitial_transition(transition: str) -> bool: """Return whether a transition inserts a full time-card between clips.""" return transition in INTERSTITIAL_TRANSITIONS - -def _run(command: list[str], *, timeout: int, label: str) -> None: - result = subprocess.run( - command, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - timeout=timeout, - check=False, - ) - if result.returncode != 0: - detail = (result.stderr or result.stdout or "Unknown FFmpeg error").strip() - raise RuntimeError(f"{label} failed: {detail[-1200:]}") - - def probe_media(path: str) -> dict[str, Any]: """Return the timing and primary stream information needed by the editor.""" result = subprocess.run( @@ -229,13 +252,20 @@ def _mix_soundtrack( ( f"[1:a:0]atrim=duration={mix_duration:.6f}," f"asetpts=PTS-STARTPTS,volume={volume:.4f}[music];" - "[0:a:0][music]amix=inputs=2:duration=first:dropout_transition=0[mixed]" + "[0:a:0][music]amix=inputs=2:duration=first:dropout_transition=0," + f"apad,atrim=duration={duration:.6f}[mixed]" ), "-map", "0:v:0", "-map", "[mixed]", "-c:v", "copy", "-c:a", "aac", "-b:a", "192k", "-movflags", "+faststart", output_path, ]) - _run(command, timeout=1800, label="Mixing editor soundtrack") + _run( + command, + timeout=1800, + label="Mixing editor soundtrack", + phase="soundtrack", + output={"path": os.path.basename(output_path)}, + ) def extract_frame( @@ -291,185 +321,7 @@ def capture(at: float) -> None: def _video_filter(width: int, height: int, fps: int, fit: str) -> str: - if fit == "fill": - sizing = ( - f"scale={width}:{height}:force_original_aspect_ratio=increase," - f"crop={width}:{height}" - ) - else: - sizing = ( - f"scale={width}:{height}:force_original_aspect_ratio=decrease," - f"pad={width}:{height}:(ow-iw)/2:(oh-ih)/2:color=black" - ) - return f"{sizing},fps={fps},setsar=1,format=yuv420p" - - -def _normalise_clip( - source: str, - destination: str, - clip: dict[str, Any], - width: int, - height: int, - fps: int, -) -> float: - media = probe_media(source) - source_duration = float(media["duration"]) - trim_start = max(0.0, min(float(clip.get("trim_start") or 0), source_duration - 0.05)) - requested_end = float(clip.get("trim_end") or source_duration) - trim_end = max(trim_start + 0.05, min(requested_end, source_duration)) - duration = trim_end - trim_start - volume = 0.0 if clip.get("muted") else max(0.0, min(float(clip.get("volume", 1)), 2.0)) - fit = "fill" if clip.get("fit") == "fill" else "fit" - - command = ["ffmpeg", "-y", "-ss", f"{trim_start:.6f}", "-i", source] - if not media["has_audio"]: - command.extend( - ["-f", "lavfi", "-t", f"{duration:.6f}", "-i", "anullsrc=r=48000:cl=stereo"] - ) - - command.extend(["-t", f"{duration:.6f}", "-map", "0:v:0"]) - command.extend(["-map", "0:a:0" if media["has_audio"] else "1:a:0"]) - command.extend( - [ - "-vf", - _video_filter(width, height, fps, fit), - "-af", - f"aresample=48000:async=1:first_pts=0,volume={volume:.4f},apad", - "-c:v", - "libx264", - "-preset", - "veryfast", - "-crf", - "18", - "-c:a", - "aac", - "-b:a", - "192k", - "-ar", - "48000", - "-ac", - "2", - "-shortest", - destination, - ] - ) - _run(command, timeout=max(300, int(duration * 20)), label=f"Preparing {os.path.basename(source)}") - return duration - - -def _concat_without_transition(segments: list[str], output_path: str) -> None: - if len(segments) == 1: - shutil.copy2(segments[0], output_path) - return - - list_path = os.path.join(os.path.dirname(segments[0]), "concat.txt") - with open(list_path, "w", encoding="utf-8") as handle: - for segment in segments: - escaped = os.path.abspath(segment).replace("\\", "/").replace("'", "'\\''") - handle.write(f"file '{escaped}'\n") - _run( - [ - "ffmpeg", - "-y", - "-f", - "concat", - "-safe", - "0", - "-i", - list_path, - "-c", - "copy", - "-movflags", - "+faststart", - output_path, - ], - timeout=1200, - label="Joining clips", - ) - - -def _concat_with_transitions( - segments: list[str], - durations: list[float], - output_path: str, - transitions: list[dict[str, Any]], -) -> None: - command = ["ffmpeg", "-y"] - for segment in segments: - command.extend(["-i", segment]) - - filters: list[str] = [] - video_label = "0:v" - audio_label = "0:a" - running_duration = durations[0] - for index in range(1, len(segments)): - out_video = f"v{index}" - out_audio = f"a{index}" - transition = transitions[index - 1] - transition_type = str(transition.get("type") or "none") - fade_duration = float(transition.get("duration") or 0) - if transition_type == "none" or fade_duration <= 0: - filters.append( - f"[{video_label}][{index}:v]concat=n=2:v=1:a=0[{out_video}]" - ) - filters.append( - f"[{audio_label}][{index}:a]concat=n=2:v=0:a=1[{out_audio}]" - ) - running_duration += durations[index] - else: - transition_name = { - "crossfade": "fade", - "fade-black": "fadeblack", - "wipe-left": "wipeleft", - "slide-left": "slideleft", - "slide-right": "slideright", - "circle-open": "circleopen", - "dissolve": "dissolve", - "pixelize": "pixelize", - "blur": "hblur", - "zoom-in": "zoomin", - }.get(transition_type, "fade") - offset = max(0.0, running_duration - fade_duration) - filters.append( - f"[{video_label}][{index}:v]xfade=transition={transition_name}:" - f"duration={fade_duration:.6f}:offset={offset:.6f}[{out_video}]" - ) - filters.append( - f"[{audio_label}][{index}:a]acrossfade=d={fade_duration:.6f}:" - f"c1=tri:c2=tri[{out_audio}]" - ) - running_duration += durations[index] - fade_duration - video_label = out_video - audio_label = out_audio - - command.extend( - [ - "-filter_complex", - ";".join(filters), - "-map", - f"[{video_label}]", - "-map", - f"[{audio_label}]", - "-c:v", - "libx264", - "-preset", - "medium", - "-crf", - "18", - "-c:a", - "aac", - "-b:a", - "192k", - "-movflags", - "+faststart", - output_path, - ] - ) - _run( - command, - timeout=max(1200, int(sum(durations) * 30)), - label="Rendering transitions", - ) + return f"{_layout_filter(width, height, fit)},fps={fps}" def _load_time_card_font(size: int, *, bold: bool = True): @@ -795,19 +647,28 @@ def _render_time_card_segment( width=width, height=height, ) + frames = max(1, int(round(float(duration) * fps))) + span = _seconds_for_ffmpeg(frames, fps) _run( [ "ffmpeg", "-y", "-loop", "1", "-i", card_path, - "-f", "lavfi", "-t", f"{duration:.6f}", + "-f", "lavfi", "-t", span, "-i", "anullsrc=r=48000:cl=stereo", - "-t", f"{duration:.6f}", "-map", "0:v:0", "-map", "1:a:0", - "-vf", f"fps={fps},setsar=1,format=yuv420p", + "-map", "0:v:0", "-map", "1:a:0", + "-vf", ( + f"fps={fps},tpad=stop_mode=clone:stop=2," + f"trim=end_frame={frames},setpts=N/{fps}/TB,setsar=1,format=yuv420p" + ), + "-frames:v", str(frames), + "-fps_mode", "cfr", "-r", str(fps), "-c:v", "libx264", "-preset", "veryfast", "-crf", "18", "-c:a", "aac", "-b:a", "192k", "-ar", "48000", "-ac", "2", - "-shortest", destination, + destination, ], - timeout=max(180, int(duration * 30)), + timeout=max(180, int(frames / fps * 30) + 30), label="Rendering time-card transition", + phase="time_card", + output={"frames": frames, "fps": fps}, ) @@ -862,6 +723,7 @@ def render_project( fps: int, soundtrack: dict[str, Any] | None = None, progress: ProgressCallback | None = None, + abort_callback: AbortCallback | None = None, ) -> dict[str, Any]: """Normalise, trim and assemble clips into a shareable H.264 MP4.""" if not clips: @@ -870,33 +732,40 @@ def render_project( raise ValueError("Output resolution must be between 240 and 3840 pixels") if width % 2 or height % 2: raise ValueError("Output width and height must be even numbers") - if fps not in (24, 25, 30, 50, 60): + if fps not in SUPPORTED_FPS: raise ValueError("Unsupported frame rate") - os.makedirs(os.path.dirname(output_path), exist_ok=True) + destination = os.path.abspath(output_path) + os.makedirs(os.path.dirname(destination), exist_ok=True) total_stages = len(clips) + 1 - with tempfile.TemporaryDirectory(prefix=".video_editor_", dir=os.path.dirname(output_path)) as temp_dir: + with tempfile.TemporaryDirectory( + prefix=".video_editor_", + dir=os.path.dirname(destination), + ) as temp_dir: segments: list[str] = [] + clip_frames: list[int] = [] durations: list[float] = [] for index, clip in enumerate(clips): + _check_abort(abort_callback, phase="normalise") if progress: progress( round((index / total_stages) * 100), f"Preparing clip {index + 1} of {len(clips)}…", ) segment = os.path.join(temp_dir, f"segment_{index:04d}.mp4") - durations.append( - _normalise_clip( - str(clip["resolved_path"]), - segment, - clip, - width, - height, - fps, - ) + frames = _normalise_clip( + str(clip["resolved_path"]), + segment, + clip, + width, + height, + fps, ) + clip_frames.append(frames) + durations.append(frames / fps) segments.append(segment) + _check_abort(abort_callback, phase="concat") if progress: progress( round((len(clips) / total_stages) * 100), @@ -908,12 +777,17 @@ def render_project( requested_duration = float(clips[index].get("transition_duration") or 0.4) actual_duration = max(0.5, min(requested_duration, 5.0)) if is_interstitial_transition(transition_type) else ( max( - 0.05, + MIN_TRIM_SECONDS, min(requested_duration, durations[index] * 0.45, durations[index + 1] * 0.45), ) if transition_type != "none" else 0.0 ) + if transition_type != "none" and not is_interstitial_transition(transition_type): + fade_frames = plan_transition_frames( + actual_duration, fps, clip_frames[index], clip_frames[index + 1], + ) + actual_duration = fade_frames / fps if fade_frames else 0.0 transitions.append({ "type": transition_type, "duration": actual_duration, @@ -936,34 +810,60 @@ def render_project( else: render_segments, render_durations, render_transitions = segments, durations, transitions - assembled_path = os.path.join(temp_dir, "assembled.mp4") if soundtrack else output_path + render_frame_counts = [ + max(1, int(round(float(duration) * fps))) for duration in render_durations + ] + expected_frames = _expected_concat_frames(render_frame_counts, render_transitions, fps) + assembled_path = os.path.join(temp_dir, "assembled.mp4") if not any(item["type"] != "none" for item in render_transitions) or len(render_segments) == 1: - _concat_without_transition(render_segments, assembled_path) + _concat_without_transition( + render_segments, + assembled_path, + fps=fps, + expected_frames=expected_frames, + frame_counts=render_frame_counts, + ) else: _concat_with_transitions( render_segments, render_durations, assembled_path, render_transitions, + fps=fps, + frame_counts=render_frame_counts, ) - duration = sum(durations) + sum( - float(item["duration"]) - if is_interstitial_transition(item["type"]) - else -float(item["duration"]) - for item in transitions - ) + staging_path = assembled_path + duration_seconds = expected_frames / fps if soundtrack: + _check_abort(abort_callback, phase="soundtrack") if progress: progress(96, "Mixing external soundtrack…") - _mix_soundtrack(assembled_path, output_path, soundtrack, duration) + mixed_path = os.path.join(temp_dir, "final.mp4") + _mix_soundtrack(assembled_path, mixed_path, soundtrack, duration_seconds) + staging_path = mixed_path + + if progress: + progress(98, "Validating exported frames and audio…") + accounting = _validate_export_artifact( + staging_path, + expected_frames=expected_frames, + fps=fps, + expect_audio=True, + phase="validate", + ) + _check_abort(abort_callback, phase="validate") + _promote_output(staging_path, destination) if progress: progress(100, "Video export complete") return { - "duration": round(duration, 3), + "duration": round(duration_seconds, 3), + "frames": expected_frames, + "fps": fps, "clip_count": len(clips), "transitions": transitions, + "audio_seconds": accounting.get("audio_seconds"), } @@ -1024,18 +924,24 @@ def _render_still_segment( fps=fps, motion=motion, ) + frames = max(2, round(duration * fps)) + span = _seconds_for_ffmpeg(frames, fps) _run( [ "ffmpeg", "-y", "-loop", "1", "-i", source, - "-f", "lavfi", "-t", f"{duration:.6f}", + "-f", "lavfi", "-t", span, "-i", "anullsrc=r=48000:cl=stereo", - "-t", f"{duration:.6f}", "-map", "0:v:0", "-map", "1:a:0", + "-map", "0:v:0", "-map", "1:a:0", "-vf", video_filter, + "-frames:v", str(frames), + "-fps_mode", "cfr", "-r", str(fps), "-c:v", "libx264", "-preset", "veryfast", "-crf", "18", - "-c:a", "aac", "-b:a", "128k", "-shortest", destination, + "-c:a", "aac", "-b:a", "128k", destination, ], - timeout=max(180, int(duration * 30)), + timeout=max(180, int(frames / fps * 30) + 30), label=f"Animating {os.path.basename(source)}", + phase="animatic", + output={"path": os.path.basename(source), "frames": frames, "fps": fps}, ) @@ -1049,42 +955,84 @@ def render_comic_animatic( transition: str = "none", transition_duration: float = 0.35, progress: ProgressCallback | None = None, + abort_callback: AbortCallback | None = None, ) -> dict[str, Any]: """Render ordered, already-lettered comic panels as a cinematic animatic.""" if not panels: raise ValueError("The comic has no panels to animate") if width < 240 or height < 240 or width > 3840 or height > 3840 or width % 2 or height % 2: raise ValueError("Invalid animatic resolution") - if fps not in (24, 25, 30, 50, 60): + if fps not in SUPPORTED_FPS: raise ValueError("Unsupported animatic frame rate") - os.makedirs(os.path.dirname(output_path), exist_ok=True) - with tempfile.TemporaryDirectory(prefix=".comic_animatic_", dir=os.path.dirname(output_path)) as temp_dir: + destination = os.path.abspath(output_path) + os.makedirs(os.path.dirname(destination), exist_ok=True) + with tempfile.TemporaryDirectory( + prefix=".comic_animatic_", + dir=os.path.dirname(destination), + ) as temp_dir: segments: list[str] = [] durations: list[float] = [] + frame_counts: list[int] = [] for index, panel in enumerate(panels): + _check_abort(abort_callback, phase="animatic") if progress: progress(round(index / (len(panels) + 1) * 100), f"Animating panel {index + 1} of {len(panels)}…") duration = max(0.8, min(float(panel.get("duration") or 3.0), 20.0)) - destination = os.path.join(temp_dir, f"panel_{index:04d}.mp4") + frames = max(2, round(duration * fps)) + panel_path = os.path.join(temp_dir, f"panel_{index:04d}.mp4") _render_still_segment( - str(panel["resolved_path"]), destination, duration=duration, + str(panel["resolved_path"]), panel_path, duration=duration, width=width, height=height, fps=fps, motion=str(panel.get("motion") or "none"), ) - segments.append(destination) - durations.append(duration) + segments.append(panel_path) + durations.append(frames / fps) + frame_counts.append(frames) transitions = [] for index in range(max(0, len(segments) - 1)): - duration = max(0.05, min(transition_duration, durations[index] * .45, durations[index + 1] * .45)) if transition != "none" else 0 - transitions.append({"type": transition, "duration": duration}) + if transition == "none": + fade = 0.0 + else: + fade_frames = plan_transition_frames( + transition_duration, fps, frame_counts[index], frame_counts[index + 1], + ) + fade = fade_frames / fps if fade_frames else 0.0 + transitions.append({"type": transition, "duration": fade}) + assembled_path = os.path.join(temp_dir, "assembled.mp4") + expected_frames = _expected_concat_frames(frame_counts, transitions, fps) if len(segments) == 1 or transition == "none": - _concat_without_transition(segments, output_path) + _concat_without_transition( + segments, + assembled_path, + fps=fps, + expected_frames=expected_frames, + frame_counts=frame_counts, + ) else: - _concat_with_transitions(segments, durations, output_path, transitions) + _concat_with_transitions( + segments, + durations, + assembled_path, + transitions, + fps=fps, + frame_counts=frame_counts, + ) + accounting = _validate_export_artifact( + assembled_path, + expected_frames=expected_frames, + fps=fps, + expect_audio=True, + phase="validate", + ) + _check_abort(abort_callback, phase="validate") + _promote_output(assembled_path, destination) if progress: progress(100, "Comic animatic complete") return { - "duration": round(sum(durations) - sum(item["duration"] for item in transitions), 3), + "duration": round(expected_frames / fps, 3), + "frames": expected_frames, + "fps": fps, "clip_count": len(segments), "transitions": transitions, + "audio_seconds": accounting.get("audio_seconds"), } diff --git a/app/services/video_editor_frames.py b/app/services/video_editor_frames.py new file mode 100644 index 000000000..9328e2316 --- /dev/null +++ b/app/services/video_editor_frames.py @@ -0,0 +1,695 @@ +"""Frame-accurate FFmpeg assembly helpers for the Video Editor. + +Keep integer frame counts as the source of truth. Container duration rounded +to four decimals must not drive ``-t`` or concat; that dropped the last frame +of a 193-frame 30fps clip (historical 589-from-590 export). +""" + +from __future__ import annotations + +import json +import os +import shutil +import subprocess +from collections.abc import Callable +from fractions import Fraction +from typing import Any + +ProgressCallback = Callable[[int, str], None] +AbortCallback = Callable[[], bool] + +MIN_TRIM_SECONDS = 0.05 +SUPPORTED_FPS = (24, 25, 30, 50, 60) +_AUDIO_PAD_TOLERANCE_SECONDS = 0.25 + + +class VideoEditorError(RuntimeError): + """Assembly failure that names the phase and the output facts we have.""" + + def __init__( + self, + message: str, + *, + phase: str, + output: dict[str, Any] | None = None, + ) -> None: + self.phase = str(phase) + self.output = dict(output or {}) + detail = f"[{self.phase}] {message}" + if self.output: + facts = ", ".join(f"{key}={value}" for key, value in self.output.items()) + detail = f"{detail} ({facts})" + super().__init__(detail) + + +class VideoEditorCancelled(VideoEditorError): + """Raised when an export stops before promoting a new artifact.""" + + +def _seconds_for_ffmpeg(frames: int, fps: int) -> str: + """Format a rational frame span for FFmpeg time options.""" + return f"{int(frames) / int(fps):.10f}" + + +def _rate_fraction(value: Any) -> Fraction: + text = str(value or "0/1").strip() + try: + if "/" in text: + numerator, denominator = text.split("/", 1) + return Fraction(int(numerator), max(int(denominator), 1)) + return Fraction(text) + except (TypeError, ValueError, ZeroDivisionError): + return Fraction(0) + + +def plan_clip_frames( + *, + source_frames: int, + source_fps: Fraction, + output_fps: int, + trim_start: float = 0.0, + trim_end: float | None = None, +) -> tuple[int, int, int]: + """Return ``(start_frame, end_frame, output_frames)`` in half-open source frames. + + ``end_frame`` is exclusive. When the trim covers the whole source and the + rates match, ``output_frames == source_frames``. + """ + frames = max(0, int(source_frames)) + rate = source_fps if isinstance(source_fps, Fraction) else _rate_fraction(source_fps) + if frames < 1 or rate <= 0: + raise VideoEditorError( + "Source video has no countable frames", + phase="probe", + output={"source_frames": frames, "source_fps": str(rate)}, + ) + min_source = max(1, int(round(MIN_TRIM_SECONDS * float(rate)))) + start = int(round(max(0.0, float(trim_start)) * float(rate))) + start = max(0, min(start, max(0, frames - min_source))) + if trim_end is None: + end = frames + else: + end = int(round(max(0.0, float(trim_end)) * float(rate))) + end = max(start + min_source, min(end, frames)) + end = min(end, frames) + if end <= start: + end = min(frames, start + 1) + span = max(1, end - start) + if rate == Fraction(int(output_fps), 1): + output_frames = span + else: + output_frames = max(1, int(round(span * int(output_fps) / rate))) + return start, end, output_frames + + +def plan_transition_frames( + duration: float, + fps: int, + left_frames: int, + right_frames: int, +) -> int: + """Snap an overlapping transition to whole frames, capped at 45% of each side.""" + max_overlap = min(max(0, int(left_frames) - 1), max(0, int(right_frames) - 1)) + if max_overlap < 1: + return 0 + requested = max(MIN_TRIM_SECONDS, float(duration)) + capped_seconds = min(requested, (min(left_frames, right_frames) * 0.45) / fps) + overlap = int(round(capped_seconds * fps)) + return max(1, min(overlap, max_overlap)) + + +def count_decoded_video_frames(path: str, *, timeout: int = 120) -> int: + """Count decoded video frames; container duration is not used.""" + result = subprocess.run( + [ + "ffprobe", + "-v", + "error", + "-select_streams", + "v:0", + "-count_frames", + "-show_entries", + "stream=nb_read_frames,nb_frames", + "-of", + "json", + path, + ], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + timeout=timeout, + check=False, + ) + if result.returncode != 0: + raise VideoEditorError( + (result.stderr or "ffprobe could not count frames").strip()[-600:], + phase="probe", + output={"path": os.path.basename(path)}, + ) + try: + payload = json.loads(result.stdout) + except json.JSONDecodeError as exc: + raise VideoEditorError( + "ffprobe returned invalid frame information", + phase="probe", + output={"path": os.path.basename(path)}, + ) from exc + streams = payload.get("streams") if isinstance(payload.get("streams"), list) else [] + stream = streams[0] if streams else {} + for key in ("nb_read_frames", "nb_frames"): + raw = stream.get(key) + if raw in (None, "", "N/A"): + continue + try: + value = int(raw) + except (TypeError, ValueError): + continue + if value > 0: + return value + raise VideoEditorError( + "Could not count decoded video frames", + phase="probe", + output={"path": os.path.basename(path)}, + ) + + +def probe_audio_timing(path: str, *, timeout: int = 60) -> dict[str, Any] | None: + """Audio stream duration and sample rate from the stream, not the container.""" + result = subprocess.run( + [ + "ffprobe", + "-v", + "error", + "-select_streams", + "a:0", + "-show_entries", + "format=duration:stream=codec_type,duration,sample_rate,nb_frames", + "-of", + "json", + path, + ], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + timeout=timeout, + check=False, + ) + if result.returncode != 0: + return None + try: + payload = json.loads(result.stdout) + except json.JSONDecodeError: + return None + streams = payload.get("streams") if isinstance(payload.get("streams"), list) else [] + audio = next((stream for stream in streams if stream.get("codec_type") == "audio"), None) + if not audio: + return None + try: + duration = float(audio.get("duration") or 0) + except (TypeError, ValueError): + duration = 0.0 + if duration <= 0: + try: + duration = float((payload.get("format") or {}).get("duration") or 0) + except (TypeError, ValueError): + duration = 0.0 + try: + sample_rate = int(audio.get("sample_rate") or 0) + except (TypeError, ValueError): + sample_rate = 0 + if duration <= 0 and sample_rate <= 0: + return None + return { + "duration": duration, + "sample_rate": sample_rate, + "has_audio": True, + } + + +def probe_assembly_source(path: str) -> dict[str, Any]: + """Frame-accurate source facts for export. UI probe stays on ``probe_media``.""" + result = subprocess.run( + [ + "ffprobe", + "-v", + "error", + "-show_entries", + "format=duration:stream=index,codec_type,width,height,r_frame_rate,avg_frame_rate,nb_frames", + "-of", + "json", + path, + ], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + timeout=60, + check=False, + ) + if result.returncode != 0: + raise VideoEditorError( + (result.stderr or "ffprobe could not read this media file").strip()[-600:], + phase="probe", + output={"path": os.path.basename(path)}, + ) + try: + payload = json.loads(result.stdout) + except json.JSONDecodeError as exc: + raise VideoEditorError( + "ffprobe returned invalid media information", + phase="probe", + output={"path": os.path.basename(path)}, + ) from exc + streams = payload.get("streams") if isinstance(payload.get("streams"), list) else [] + video = next((stream for stream in streams if stream.get("codec_type") == "video"), None) + if not video: + raise VideoEditorError( + "The selected file does not contain a video stream", + phase="probe", + output={"path": os.path.basename(path)}, + ) + rate = _rate_fraction(video.get("avg_frame_rate")) + if rate <= 0: + rate = _rate_fraction(video.get("r_frame_rate")) + if rate <= 0: + raise VideoEditorError( + "The selected video has no readable frame rate", + phase="probe", + output={"path": os.path.basename(path)}, + ) + nb_frames = 0 + raw_frames = video.get("nb_frames") + if raw_frames not in (None, "", "N/A"): + try: + nb_frames = int(raw_frames) + except (TypeError, ValueError): + nb_frames = 0 + if nb_frames < 1: + nb_frames = count_decoded_video_frames(path) + return { + "nb_frames": nb_frames, + "fps": rate, + "width": int(video.get("width") or 0), + "height": int(video.get("height") or 0), + "has_audio": any(stream.get("codec_type") == "audio" for stream in streams), + "duration": float(nb_frames / rate) if rate else 0.0, + } + + +def _check_abort(abort_callback: AbortCallback | None, *, phase: str) -> None: + if abort_callback is not None and abort_callback(): + raise VideoEditorCancelled( + "Export cancelled before the artifact was finalised", + phase=phase, + ) + + +def _promote_output(staging_path: str, output_path: str) -> None: + os.makedirs(os.path.dirname(os.path.abspath(output_path)), exist_ok=True) + os.replace(staging_path, output_path) + + +def _validate_export_artifact( + path: str, + *, + expected_frames: int, + fps: int, + expect_audio: bool = True, + phase: str = "validate", +) -> dict[str, Any]: + facts = { + "path": os.path.basename(path), + "expected_frames": int(expected_frames), + "fps": int(fps), + } + if not os.path.isfile(path) or os.path.getsize(path) <= 0: + raise VideoEditorError( + "FFmpeg produced no output file", + phase=phase, + output=facts, + ) + frames = count_decoded_video_frames(path) + facts["frames"] = frames + if frames != int(expected_frames): + raise VideoEditorError( + f"Assembled video has {frames} decoded frames, expected {expected_frames}", + phase=phase, + output=facts, + ) + audio = probe_audio_timing(path) + facts["has_audio"] = bool(audio) + video_seconds = int(expected_frames) / int(fps) + facts["video_seconds"] = video_seconds + if expect_audio: + if not audio: + raise VideoEditorError( + "Assembled video has no audio stream to compare", + phase=phase, + output=facts, + ) + audio_seconds = float(audio["duration"] or 0) + facts["audio_seconds"] = audio_seconds + if audio_seconds + max(1.0 / fps, 0.05) < video_seconds: + raise VideoEditorError( + "Assembled audio is shorter than the video span", + phase=phase, + output=facts, + ) + if audio_seconds > video_seconds + _AUDIO_PAD_TOLERANCE_SECONDS: + raise VideoEditorError( + "Assembled audio is much longer than the video span", + phase=phase, + output=facts, + ) + return facts + + +def _run( + command: list[str], + *, + timeout: int, + label: str, + phase: str = "ffmpeg", + output: dict[str, Any] | None = None, +) -> None: + try: + result = subprocess.run( + command, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + timeout=timeout, + check=False, + ) + except subprocess.TimeoutExpired as exc: + raise VideoEditorError( + f"{label} timed out after {timeout}s", + phase=phase, + output=output, + ) from exc + if result.returncode != 0: + detail = (result.stderr or result.stdout or "Unknown FFmpeg error").strip() + raise VideoEditorError( + f"{label} failed: {detail[-1200:]}", + phase=phase, + output=output, + ) + + +def _layout_filter(width: int, height: int, fit: str) -> str: + if fit == "fill": + sizing = ( + f"scale={width}:{height}:force_original_aspect_ratio=increase," + f"crop={width}:{height}" + ) + else: + sizing = ( + f"scale={width}:{height}:force_original_aspect_ratio=decrease," + f"pad={width}:{height}:(ow-iw)/2:(oh-ih)/2:color=black" + ) + return f"{sizing},setsar=1,format=yuv420p" + + +def _normalise_clip( + source: str, + destination: str, + clip: dict[str, Any], + width: int, + height: int, + fps: int, +) -> int: + media = probe_assembly_source(source) + trim_end_raw = clip.get("trim_end") + try: + trim_end = float(trim_end_raw) if trim_end_raw not in (None, "", 0, 0.0) else None + except (TypeError, ValueError): + trim_end = None + start_frame, end_frame, output_frames = plan_clip_frames( + source_frames=int(media["nb_frames"]), + source_fps=media["fps"], + output_fps=fps, + trim_start=float(clip.get("trim_start") or 0), + trim_end=trim_end, + ) + volume = 0.0 if clip.get("muted") else max(0.0, min(float(clip.get("volume", 1)), 2.0)) + fit = "fill" if clip.get("fit") == "fill" else "fit" + video_span = _seconds_for_ffmpeg(output_frames, fps) + source_rate = float(media["fps"]) + audio_start = start_frame / source_rate + audio_end = end_frame / source_rate + video_graph = ( + f"{_layout_filter(width, height, fit)}," + f"trim=start_frame={start_frame}:end_frame={end_frame},setpts=PTS-STARTPTS," + f"fps={fps},tpad=stop_mode=clone:stop=2," + f"trim=end_frame={output_frames},setpts=N/{fps}/TB" + ) + audio_graph = ( + f"atrim=start={audio_start:.10f}:end={audio_end:.10f},asetpts=PTS-STARTPTS," + f"aresample=48000:async=1:first_pts=0,volume={volume:.4f}," + f"apad,atrim=duration={video_span}" + ) + command = ["ffmpeg", "-y", "-i", source] + facts = { + "path": os.path.basename(source), + "start_frame": start_frame, + "end_frame": end_frame, + "output_frames": output_frames, + "fps": fps, + } + if media["has_audio"]: + command.extend( + [ + "-filter_complex", + f"[0:v:0]{video_graph}[vout];[0:a:0]{audio_graph}[aout]", + "-map", + "[vout]", + "-map", + "[aout]", + ] + ) + else: + command.extend( + [ + "-f", + "lavfi", + "-t", + video_span, + "-i", + "anullsrc=r=48000:cl=stereo", + "-filter_complex", + ( + f"[0:v:0]{video_graph}[vout];" + f"[1:a:0]volume={volume:.4f},atrim=duration={video_span}," + "asetpts=PTS-STARTPTS[aout]" + ), + "-map", + "[vout]", + "-map", + "[aout]", + ] + ) + command.extend( + [ + "-frames:v", + str(output_frames), + "-fps_mode", + "cfr", + "-r", + str(fps), + "-c:v", + "libx264", + "-preset", + "veryfast", + "-crf", + "18", + "-c:a", + "aac", + "-b:a", + "192k", + "-ar", + "48000", + "-ac", + "2", + "-muxpreload", + "0", + "-muxdelay", + "0", + destination, + ] + ) + _run( + command, + timeout=max(300, int(output_frames / fps * 20) + 30), + label=f"Preparing {os.path.basename(source)}", + phase="normalise", + output=facts, + ) + return output_frames + + +def _concat_without_transition( + segments: list[str], + output_path: str, + *, + fps: int | None = None, + expected_frames: int | None = None, + frame_counts: list[int] | None = None, +) -> None: + if len(segments) == 1: + shutil.copy2(segments[0], output_path) + return + + # Packet-copy concat keeps AAC priming/padding at each cut and shifts speech + # against the frame-counted picture. Decode each segment onto the same clock. + counts = list(frame_counts) if frame_counts is not None else [ + count_decoded_video_frames(segment) for segment in segments + ] + output_fps = int(fps or probe_assembly_source(segments[0])["fps"]) + _concat_with_transitions( + segments, [count / output_fps for count in counts], output_path, + [{"type": "none", "duration": 0} for _ in segments[1:]], + fps=output_fps, frame_counts=counts, + ) + + +def _concat_with_transitions( + segments: list[str], + durations: list[float], + output_path: str, + transitions: list[dict[str, Any]], + *, + fps: int | None = None, + frame_counts: list[int] | None = None, +) -> None: + command = ["ffmpeg", "-y", "-filter_complex_threads", "1"] + for segment in segments: + # Many short shots must not allocate one full decoder thread pool each. + command.extend(["-threads", "1", "-i", segment]) + + output_fps = int(fps or 30) + counts = list(frame_counts) if frame_counts is not None else [ + max(1, int(round(float(duration) * output_fps))) for duration in durations + ] + filters: list[str] = [] + for index, count in enumerate(counts): + filters.append( + f"[{index}:v]fps={output_fps},setpts=N/{output_fps}/TB," + f"tpad=stop_mode=clone:stop=2,trim=end_frame={count}," + f"setpts=N/{output_fps}/TB[v{index}s]" + ) + filters.append( + f"[{index}:a]aresample=48000,apad," + f"atrim=end_sample={round(count * 48000 / output_fps)}," + f"asetpts=N/SR/TB[a{index}s]" + ) + video_label = "v0s" + audio_label = "a0s" + running_frames = counts[0] + for index in range(1, len(segments)): + out_video = f"v{index}" + out_audio = f"a{index}" + transition = transitions[index - 1] + transition_type = str(transition.get("type") or "none") + fade_duration = float(transition.get("duration") or 0) + if transition_type == "none" or fade_duration <= 0: + # concat resets the timebase to AV_TIME_BASE (1/1000000). Restore + # 1/fps so a later xfade does not reject the graph. + filters.append( + f"[{video_label}][v{index}s]concat=n=2:v=1:a=0," + f"settb=1/{output_fps},setpts=N/{output_fps}/TB[{out_video}]" + ) + filters.append( + f"[{audio_label}][a{index}s]concat=n=2:v=0:a=1," + f"aresample=48000,asetpts=PTS-STARTPTS[{out_audio}]" + ) + running_frames += counts[index] + else: + transition_name = { + "crossfade": "fade", + "fade-black": "fadeblack", + "wipe-left": "wipeleft", + "slide-left": "slideleft", + "slide-right": "slideright", + "circle-open": "circleopen", + "dissolve": "dissolve", + "pixelize": "pixelize", + "blur": "hblur", + "zoom-in": "zoomin", + }.get(transition_type, "fade") + fade_frames = max(1, int(round(fade_duration * output_fps))) + fade_frames = min(fade_frames, max(1, running_frames - 1), max(1, counts[index] - 1)) + offset = max(0, running_frames - fade_frames) + fade_seconds = _seconds_for_ffmpeg(fade_frames, output_fps) + offset_seconds = _seconds_for_ffmpeg(offset, output_fps) + filters.append( + f"[{video_label}]settb=1/{output_fps},setpts=N/{output_fps}/TB[v{index}l];" + f"[v{index}s]settb=1/{output_fps},setpts=N/{output_fps}/TB[v{index}r];" + f"[v{index}l][v{index}r]xfade=transition={transition_name}:" + f"duration={fade_seconds}:offset={offset_seconds}[{out_video}]" + ) + filters.append( + f"[{audio_label}]aresample=48000,asetpts=PTS-STARTPTS[a{index}l];" + f"[a{index}s]aresample=48000,asetpts=PTS-STARTPTS[a{index}r];" + f"[a{index}l][a{index}r]acrossfade=d={fade_seconds}:" + f"c1=tri:c2=tri[{out_audio}]" + ) + running_frames += counts[index] - fade_frames + video_label = out_video + audio_label = out_audio + + command.extend( + [ + "-filter_complex", + ";".join(filters), + "-map", + f"[{video_label}]", + "-map", + f"[{audio_label}]", + "-frames:v", + str(running_frames), + "-fps_mode", + "cfr", + "-r", + str(output_fps), + "-c:v", + "libx264", + "-preset", + "medium", + "-crf", + "18", + "-c:a", + "aac", + "-b:a", + "192k", + "-movflags", + "+faststart", + output_path, + ] + ) + _run( + command, + timeout=max(1200, int(sum(durations) * 30)), + label="Rendering transitions", + phase="concat", + output={"segments": len(segments), "expected_frames": running_frames, "fps": output_fps}, + ) + + +def _expected_concat_frames( + frame_counts: list[int], + transitions: list[dict[str, Any]], + fps: int, +) -> int: + if not frame_counts: + return 0 + total = int(frame_counts[0]) + for index, transition in enumerate(transitions): + incoming = int(frame_counts[index + 1]) + kind = str(transition.get("type") or "none") + fade = float(transition.get("duration") or 0) + if kind == "none" or fade <= 0: + total += incoming + elif kind in {"later-clock", "later-tropical", "later-cinematic"}: + total += max(1, int(round(fade * fps))) + incoming + else: + total += incoming - max(1, int(round(fade * fps))) + return total diff --git a/app/services/video_generation_commands.py b/app/services/video_generation_commands.py new file mode 100644 index 000000000..df368f4ae --- /dev/null +++ b/app/services/video_generation_commands.py @@ -0,0 +1,34 @@ +"""Connect generation.video to native admission and the existing generation FIFO.""" + +from copy import deepcopy + +from routers.studio_video_commands import video_command_catalog +from services.native_generation_operation import NativeGenerationOperation +from services.studio_video_preparation import prepare_studio_video +from services.video_generation_spec import freeze_video_generation_spec + + +def create_video_operation(runtime, *, resources, execution_policy): + def freeze(command): + frozen = freeze_video_generation_spec(command) + effective = frozen["effective"]["input"] + return frozen, {**deepcopy(effective["params"]), "workspace": effective["workspace"]} + + def prepare(params): + return prepare_studio_video( + params, + model_definition=runtime["wgp"].get_model_def, + model_downloaded=runtime["_check_model_downloaded"], + resources=resources(), + execution_policy=execution_policy, + ) + + return NativeGenerationOperation( + freeze=freeze, + prepare=prepare, + catalog=video_command_catalog(), + use_generation_defaults=False, + ) + + +__all__ = ["create_video_operation"] diff --git a/app/services/video_generation_spec.py b/app/services/video_generation_spec.py new file mode 100644 index 000000000..9596262f0 --- /dev/null +++ b/app/services/video_generation_spec.py @@ -0,0 +1,413 @@ +"""Closed, provider-free contract for the typed ``generation.video`` command. + +This first vertical freezes one Wan 2.1 Text2Video family (``t2v`` and +``t2v_1.3B``) before model lookup, resource inspection or task admission. +``original`` keeps the submitted envelope byte-for-byte. ``effective`` adds +only adapter-owned video sentinels. The fingerprint covers operation, +workspace and native parameters and excludes ``intent_id``. +""" + +from __future__ import annotations + +from copy import deepcopy +import hashlib +import json +import re +from typing import Annotated, Any, Literal + +from pydantic import ( + BaseModel, + ConfigDict, + Field, + StringConstraints, + StrictFloat, + StrictInt, + StrictStr, + ValidationError, + field_validator, + model_validator, +) + +from services.image_generation_spec import ImageGenerationSpecError +from services.studio_image_spec import _validate_reference + + +SCHEMA_VERSION = 2 +FINGERPRINT_VERSION = 2 +OPERATION = "generation.video" +VIDEO_MODEL_FAMILY = "wan_t2v_2_1" +VIDEO_MODEL_TYPES = frozenset({"t2v", "t2v_1.3B"}) +WAN_T2V_ARCHITECTURES = frozenset({"t2v", "t2v_1.3B"}) + +_MAX_ID_LENGTH = 240 +_MAX_INTENT_LENGTH = 160 +_MAX_PROMPT_LENGTH = 200_000 +_MAX_REFERENCE_LENGTH = 8_192 +_MAX_LORA_COUNT = 64 +_RESOLUTION = re.compile(r"^([1-9][0-9]{1,4})x([1-9][0-9]{1,4})$") + +STUDIO_VIDEO_DEFAULTS: dict[str, Any] = { + "generation_mode": "video", + "image_mode": 0, + "repeat_generation": 1, + "batch_size": 1, + "prompt_enhancer": "", + "activated_loras": [], + "loras_multipliers": "", + "negative_prompt": "", + "seed": -1, + "video_prompt_type": "", + "image_prompt_type": "", + # Keep a literal multi-line prompt as one video. Image/speech/music already + # pin this; omitting it lets wgp.primary_settings (default 0) split each + # newline into a separate generation at execute time. + "multi_prompts_gen_type": 2, +} + +SUPPORTED_INPUT_FIELDS = ( + "prompt", + "negative_prompt", + "model_type", + "resolution", + "video_length", + "num_inference_steps", + "guidance_scale", + "seed", + "image_mode", + "generation_mode", + "repeat_generation", + "batch_size", + "activated_loras", + "loras_multipliers", + "image_start", + "image_end", + "image_refs", + "video_guide", + "video_source", + "video_mask", + "image_prompt_type", + "video_prompt_type", + "prompt_enhancer", + "flow_shift", + "sample_solver", + "guidance_phases", + "multi_prompts_gen_type", +) + +INACTIVE_VIDEO_FIELDS = ( + "generation_mode=video", + "image_mode=0", + "repeat_generation=1", + "batch_size=1", + "prompt_enhancer=empty", + "video_prompt_type=empty", + "image_prompt_type=empty", + "multi_prompts_gen_type=2", + "image_end=empty_or_null", + "video_source=empty_or_null", + "video_mask=empty_or_null", +) + +EXCLUDED_VIDEO_FIELDS = ( + "actor", + "client", + "permission", + "provenance", + "workspace inside input.params", + "filesystem paths", + "remote URLs", + "free-form provider payloads", + "speech, music and SFX controls", + "avatar, recast and model3d controls", + "Hunyuan, LTX, MiniMax H3 and Wan 2.2 families", + "download or queue controls", +) + + +class VideoGenerationSpecError(ImageGenerationSpecError): + """Validation error for the closed generation.video envelope.""" + + +class _ClosedModel(BaseModel): + model_config = ConfigDict(extra="forbid", strict=True, populate_by_name=False) + + +_Identity = Annotated[ + StrictStr, + StringConstraints(min_length=1, max_length=_MAX_ID_LENGTH), +] +_Workspace = Annotated[ + StrictStr, + StringConstraints( + min_length=1, + max_length=_MAX_ID_LENGTH, + pattern=r"^(?:default|[A-Za-z0-9][A-Za-z0-9_-]*)$", + ), +] +_WorkspaceCollectionId = Annotated[ + StrictStr, + StringConstraints(min_length=1, max_length=200), +] +_IntentId = Annotated[ + StrictStr, + StringConstraints(min_length=1, max_length=_MAX_INTENT_LENGTH), +] +_Prompt = Annotated[ + StrictStr, + StringConstraints(min_length=1, max_length=_MAX_PROMPT_LENGTH), +] +_Text = Annotated[StrictStr, StringConstraints(max_length=_MAX_PROMPT_LENGTH)] +_ShortText = Annotated[StrictStr, StringConstraints(max_length=8192)] +_Reference = Annotated[ + StrictStr, + StringConstraints(min_length=1, max_length=_MAX_REFERENCE_LENGTH), +] +_Steps = Annotated[StrictInt, Field(ge=1, le=1000)] +_Seed = Annotated[StrictInt, Field(ge=-(2**63), le=2**63 - 1)] +_Count = Annotated[StrictInt, Field(ge=1, le=1)] +_Two = Annotated[StrictInt, Field(ge=2, le=2)] +_Zero = Annotated[StrictInt, Field(ge=0, le=0)] +_Frames = Annotated[StrictInt, Field(ge=5, le=10_000)] +_PhaseCount = Annotated[StrictInt, Field(ge=1, le=3)] +_Guidance = Annotated[StrictFloat, Field(ge=0, le=1000, allow_inf_nan=False)] +_Finite = Annotated[StrictFloat, Field(allow_inf_nan=False)] +_Resolution = Annotated[StrictStr, StringConstraints(min_length=1, max_length=128)] + + +def _non_blank(value: str, field: str) -> None: + if not value.strip(): + raise ValueError(f"{field} must contain a non-blank value") + + +def _check_resolution(value: str) -> str: + match = _RESOLUTION.fullmatch(value) + if match is None: + raise ValueError("resolution must be WIDTHxHEIGHT") + width, height = (int(part) for part in match.groups()) + if any(not 64 <= size <= 4096 or size % 8 for size in (width, height)): + raise ValueError("resolution sides must be 64..4096 and a multiple of 8") + return value + + +def _check_optional_reference(value): + if value in (None, ""): + return value + if isinstance(value, list): + if any(item not in (None, "") and not isinstance(item, str) for item in value): + raise ValueError("reference lists must contain strings") + return [_validate_reference(item) if item not in (None, "") else item for item in value] + return _validate_reference(value) + + +class VideoGenerationParams(_ClosedModel): + """Closed native parameters for one Wan 2.1 Text2Video job.""" + + prompt: _Prompt + negative_prompt: _Text = "" + model_type: Literal["t2v", "t2v_1.3B"] + resolution: _Resolution + video_length: _Frames + num_inference_steps: _Steps + guidance_scale: _Guidance + seed: _Seed = -1 + image_mode: _Zero = 0 + generation_mode: Literal["video"] = "video" + repeat_generation: _Count = 1 + batch_size: _Count = 1 + multi_prompts_gen_type: _Two = 2 + activated_loras: list[_Identity] = Field(default_factory=list, max_length=_MAX_LORA_COUNT) + loras_multipliers: _ShortText = "" + prompt_enhancer: Literal["", None] = "" + image_prompt_type: Literal["", None] = "" + video_prompt_type: Literal["", None] = "" + flow_shift: _Finite | None = None + sample_solver: _ShortText = "" + guidance_phases: _PhaseCount = 1 + + image_start: _Reference | Literal["", None] | list[StrictStr] = None + image_end: Literal["", None] | list[StrictStr] = None + image_refs: list[StrictStr] | None = None + video_guide: _Reference | Literal["", None] = None + video_source: Literal["", None] = None + video_mask: Literal["", None] = None + + @field_validator("resolution") + @classmethod + def _resolution(cls, value): + return _check_resolution(value) + + @field_validator("activated_loras") + @classmethod + def _lora_names(cls, values): + for value in values: + if not value.strip() or value in {".", ".."} or "/" in value or "\\" in value: + raise ValueError("activated_loras must contain exact catalog names") + return values + + @field_validator("image_start", "video_guide") + @classmethod + def _active_reference(cls, value): + return _check_optional_reference(value) + + @field_validator("image_end", "image_refs") + @classmethod + def _optional_reference_list(cls, value): + if value in (None, "", []): + return value + if isinstance(value, list) and all(item == "" for item in value): + return value + if isinstance(value, list): + return _check_optional_reference(value) + raise ValueError("inactive image lists must be empty") + + @model_validator(mode="after") + def _check_semantics(self): + _non_blank(self.prompt, "input.params.prompt") + _non_blank(self.model_type, "input.params.model_type") + if self.model_type not in VIDEO_MODEL_TYPES: + raise ValueError("input.params.model_type is not a registered Wan 2.1 Text2Video model") + return self + + +class VideoGenerationInput(_ClosedModel): + workspace: _Workspace + workspace_collection_id: _WorkspaceCollectionId | None = None + params: VideoGenerationParams + + @model_validator(mode="after") + def _check_collection_id(self): + if self.workspace_collection_id is not None: + _non_blank(self.workspace_collection_id, "input.workspace_collection_id") + return self + + +class _VideoGenerationEnvelope(_ClosedModel): + version: Literal[SCHEMA_VERSION] + operation: Literal[OPERATION] + intent_id: _IntentId + input: VideoGenerationInput + + @model_validator(mode="after") + def _check_intent(self): + _non_blank(self.intent_id, "intent_id") + return self + + +def _validation_error(exc: ValidationError) -> VideoGenerationSpecError: + details: list[dict[str, Any]] = [] + messages: list[str] = [] + for error in exc.errors(include_input=False): + location = ".".join(str(part) for part in error.get("loc", ())) or "command" + message = str(error.get("msg") or "Invalid value") + details.append({"loc": location, "message": message, "type": error.get("type")}) + messages.append(f"{location}: {message}") + return VideoGenerationSpecError( + "; ".join(messages) or "Invalid generation.video command", + details=details, + ) + + +def _canonical_content(effective: dict[str, Any]) -> dict[str, Any]: + return { + "version": SCHEMA_VERSION, + "operation": OPERATION, + "input": deepcopy(effective["input"]), + } + + +def _fingerprint(content: dict[str, Any]) -> str: + encoded = json.dumps( + content, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + allow_nan=False, + ).encode("utf-8") + return hashlib.sha256(encoded).hexdigest() + + +def freeze_video_generation_spec(command: Any) -> dict[str, Any]: + """Validate and detach one generation.video command without I/O.""" + if type(command) is not dict: + raise VideoGenerationSpecError("generation.video command must be an object") + try: + envelope = _VideoGenerationEnvelope.model_validate(command) + except ValidationError as exc: + raise _validation_error(exc) from exc + + original = deepcopy(command) + explicit_params = envelope.input.params.model_dump(mode="json", exclude_unset=True) + effective_params = deepcopy(explicit_params) + for key, value in STUDIO_VIDEO_DEFAULTS.items(): + effective_params.setdefault(key, deepcopy(value)) + + effective = { + "version": SCHEMA_VERSION, + "operation": OPERATION, + "intent_id": envelope.intent_id, + "input": { + "workspace": envelope.input.workspace, + "params": effective_params, + }, + } + explicit_input = envelope.input.model_dump(mode="json", exclude_unset=True) + if "workspace_collection_id" in explicit_input: + effective["input"]["workspace_collection_id"] = explicit_input["workspace_collection_id"] + return { + "original": original, + "effective": effective, + "fingerprint_version": FINGERPRINT_VERSION, + "fingerprint": _fingerprint(_canonical_content(effective)), + } + + +def video_generation_schema() -> dict[str, Any]: + """Return the executable discovery schema for ``generation.video``.""" + input_schema = VideoGenerationInput.model_json_schema() + envelope_schema = _VideoGenerationEnvelope.model_json_schema() + return { + "version": SCHEMA_VERSION, + "operation": OPERATION, + "intent_id": envelope_schema["properties"]["intent_id"], + "input": input_schema, + "supported_input_fields": list(SUPPORTED_INPUT_FIELDS), + "video_model_family": VIDEO_MODEL_FAMILY, + "video_model_types": sorted(VIDEO_MODEL_TYPES), + "architectures": sorted(WAN_T2V_ARCHITECTURES), + "effects": deepcopy(STUDIO_VIDEO_DEFAULTS), + "inactive": list(INACTIVE_VIDEO_FIELDS), + "excluded": list(EXCLUDED_VIDEO_FIELDS), + "limits": { + "video_length_frames": {"minimum": 5, "maximum": 10000}, + "resolution": "WIDTHxHEIGHT, each 64..4096 and a multiple of 8", + }, + } + + +StudioVideoParams = VideoGenerationParams +StudioVideoInput = VideoGenerationInput +freeze_studio_video_spec = freeze_video_generation_spec +studio_video_schema = video_generation_schema + + +__all__ = [ + "EXCLUDED_VIDEO_FIELDS", + "FINGERPRINT_VERSION", + "INACTIVE_VIDEO_FIELDS", + "OPERATION", + "SCHEMA_VERSION", + "STUDIO_VIDEO_DEFAULTS", + "SUPPORTED_INPUT_FIELDS", + "VIDEO_MODEL_FAMILY", + "VIDEO_MODEL_TYPES", + "WAN_T2V_ARCHITECTURES", + "StudioVideoInput", + "StudioVideoParams", + "VideoGenerationInput", + "VideoGenerationParams", + "VideoGenerationSpecError", + "freeze_studio_video_spec", + "freeze_video_generation_spec", + "studio_video_schema", + "video_generation_schema", +] diff --git a/app/services/vocal_isolation.py b/app/services/vocal_isolation.py index ecf728498..82ce6c334 100644 --- a/app/services/vocal_isolation.py +++ b/app/services/vocal_isolation.py @@ -11,50 +11,93 @@ import threading from services.scene3d_speech import SpeechAnalysisUnavailable, validate_voice_wav +from services.speech_analysis_cache import file_identity, isolation_material, remember MODEL_NAME = 'model_bs_roformer_ep_317_sdr_12.9755' MODEL_DIR = Path(__file__).resolve().parents[1] / 'ckpts' / 'roformer' +ISOLATION_PARAMS = { + 'device': 'cpu', 'segmentSize': 256, 'overlap': 2, 'pitchShift': 0, + 'stem': 'Vocals', 'batchSize': 1, +} _LOCK = threading.BoundedSemaphore(1) def isolation_capability(): installed = all((MODEL_DIR / (MODEL_NAME + ext)).is_file() for ext in ('.ckpt', '.yaml')) - available = installed and importlib.util.find_spec('audio_separator') is not None + separator = importlib.util.find_spec('audio_separator') is not None + available = installed and separator + if available: + reason = 'ready' + elif not installed: + reason = 'optional_model_missing' + else: + reason = 'audio_separator_missing' return {'available': available, 'model': 'BS-RoFormer', 'device': 'cpu', - 'maxSeconds': 90, 'downloads': False, - 'reason': 'ready' if available else 'optional_model_missing'} + 'maxSeconds': 90, 'downloads': False, 'reason': reason} + + +def isolation_key_material() -> dict: + return { + 'model': MODEL_NAME, + 'modelFiles': [file_identity(MODEL_DIR / (MODEL_NAME + ext)) for ext in ('.ckpt', '.yaml')], + 'params': {**ISOLATION_PARAMS, 'separator': _separator_version()}, + } def isolate_voice(data: bytes) -> bytes: duration = validate_voice_wav(data) - if not isolation_capability()['available']: - raise SpeechAnalysisUnavailable('Optional BS-RoFormer model and audio-separator must already be installed. No files were downloaded.') + material = isolation_material(data, duration, isolation_key_material()) + return remember(material, lambda: _isolate_uncached(data, duration), '.wav') + + +def _separator_version() -> str: + try: + from importlib.metadata import version + return version('audio-separator') + except Exception: + return 'missing' + + +def _isolate_uncached(data: bytes, duration: float) -> bytes: + capability = isolation_capability() + if not capability['available']: + raise SpeechAnalysisUnavailable( + 'Optional BS-RoFormer model and audio-separator must already be installed. ' + f"No files were downloaded ({capability['reason']}).") if not _LOCK.acquire(blocking=False): raise SpeechAnalysisUnavailable('Another vocal isolation is running. Try again shortly.') try: - with tempfile.TemporaryDirectory(prefix='hocuspocus-vocals-') as folder: - source, target = Path(folder) / 'source.wav', Path(folder) / 'voice.wav' - source.write_bytes(data) - environment = {**os.environ, 'CUDA_VISIBLE_DEVICES': '-1', 'HF_HUB_OFFLINE': '1', - 'TRANSFORMERS_OFFLINE': '1', 'OMP_NUM_THREADS': '2', 'MKL_NUM_THREADS': '2'} - try: - diagnostic = Path(folder) / 'worker.log' - with diagnostic.open('wb') as log: - done = subprocess.run([sys.executable, str(Path(__file__).with_name('vocal_isolation_worker.py')), - str(source), str(target), str(MODEL_DIR), MODEL_NAME], - env=environment, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, - stderr=log, timeout=900, check=False, - creationflags=subprocess.CREATE_NO_WINDOW if os.name == 'nt' else 0) - except (OSError, subprocess.TimeoutExpired) as error: - raise SpeechAnalysisUnavailable('Local vocal isolation failed or exceeded 15 minutes. Existing lip cues are unchanged.') from error - if done.returncode or not target.is_file() or target.stat().st_size > 3_000_000: - with diagnostic.open('rb') as log: - log.seek(max(0, diagnostic.stat().st_size - 8000)) - logging.getLogger(__name__).warning('Local vocal isolation worker failed: %s', log.read().decode(errors='replace')) - raise SpeechAnalysisUnavailable('Local vocal isolation failed. Check the installed model and audio-separator; no downloads were attempted.') - result = target.read_bytes() - if abs(validate_voice_wav(result) - duration) > 1 / 16000: - raise SpeechAnalysisUnavailable('Isolated voice changed the source timing.') - return result + return _run_isolation_worker(data, duration) finally: _LOCK.release() + + +def _run_isolation_worker(data: bytes, duration: float) -> bytes: + with tempfile.TemporaryDirectory(prefix='hocuspocus-vocals-') as folder: + source, target = Path(folder) / 'source.wav', Path(folder) / 'voice.wav' + source.write_bytes(data) + environment = {**os.environ, 'CUDA_VISIBLE_DEVICES': '-1', 'HF_HUB_OFFLINE': '1', + 'TRANSFORMERS_OFFLINE': '1', 'OMP_NUM_THREADS': '2', 'MKL_NUM_THREADS': '2'} + try: + diagnostic = Path(folder) / 'worker.log' + with diagnostic.open('wb') as log: + done = subprocess.run([sys.executable, str(Path(__file__).with_name('vocal_isolation_worker.py')), + str(source), str(target), str(MODEL_DIR), MODEL_NAME], + env=environment, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, + stderr=log, timeout=900, check=False, + creationflags=subprocess.CREATE_NO_WINDOW if os.name == 'nt' else 0) + except (OSError, subprocess.TimeoutExpired) as error: + raise SpeechAnalysisUnavailable('Local vocal isolation failed or exceeded 15 minutes. Existing lip cues are unchanged.') from error + if done.returncode or not target.is_file() or target.stat().st_size > 3_000_000: + _log_isolation_failure(diagnostic) + raise SpeechAnalysisUnavailable('Local vocal isolation failed. Check the installed model and audio-separator; no downloads were attempted.') + result = target.read_bytes() + if abs(validate_voice_wav(result) - duration) > 1 / 16000: + raise SpeechAnalysisUnavailable('Isolated voice changed the source timing.') + return result + + +def _log_isolation_failure(diagnostic: Path) -> None: + with diagnostic.open('rb') as log: + log.seek(max(0, diagnostic.stat().st_size - 8000)) + logging.getLogger(__name__).warning('Local vocal isolation worker failed: %s', log.read().decode(errors='replace')) diff --git a/app/services/wizard_workflow_executor.py b/app/services/wizard_workflow_executor.py new file mode 100644 index 000000000..3f643ed05 --- /dev/null +++ b/app/services/wizard_workflow_executor.py @@ -0,0 +1,771 @@ +"""Server executor for one image → upscale Wizard circuit. + +Admission still goes through ``generation.image`` and ``tools.upscale``. This +module never starts a second generation queue. Old checkpoints of other types +are left untouched. +""" +from __future__ import annotations + +import asyncio +import hashlib +import inspect +import os +import threading +import uuid +from copy import deepcopy +from typing import Any +from urllib.parse import quote, urlencode + +from fastapi import HTTPException +from services.image_generation_commands import command_error +from services.tools_upscale import TOOL_UPSCALE_METHODS +from services.wizard_workflows import ( + WizardWorkflowRevisionConflict, + read_workflows, + write_workflows, +) + + +WORKFLOW_TYPE = "image_then_upscale" +STEP_IMAGE = "generate_image" +STEP_UPSCALE = "upscale_image" +SERVER_OWNER = "server" +IMAGE_OPERATION = "generation.image" +UPSCALE_OPERATION = "tools.upscale" +TERMINAL_STATES = frozenset({"completed", "failed", "cancelled", "partial"}) +ACTIVE_TASK_STATES = frozenset({"created", "queued", "waiting_resource", "running"}) +FAILED_TASK_STATES = frozenset({"failed", "cancelled", "interrupted"}) + + +def _now() -> int: + import time + return int(time.time() * 1000) + + +def _step_intent(workflow_id: str, step_id: str) -> str: + raw = f"{workflow_id}:{step_id}" + if len(raw) <= 160: + return raw + return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:160] + + +def _execution_intent(workflow: dict, step_id: str) -> str: + step = next(item for item in workflow["steps"] if item["stepId"] == step_id) + return step.get("executionKey") or _step_intent(workflow["workflowId"], step_id) + + +def _file_url(name: str, workspace: str) -> str: + text = str(name or "").strip() + if text.startswith("/api/v1/"): + return text + base = os.path.basename(text.replace("\\", "/")) + return f"/api/v1/file/{quote(base)}?{urlencode({'workspace': workspace})}" + + +def _output_names(refs: Any) -> list[str]: + names: list[str] = [] + if not isinstance(refs, list): + return names + for item in refs: + if isinstance(item, str) and item.strip(): + names.append(item.strip()) + elif isinstance(item, dict): + label = str(item.get("name") or item.get("path") or "").strip() + if label: + names.append(label) + return names + + +def _image_methods() -> list[str]: + return sorted( + method + for method in TOOL_UPSCALE_METHODS + if method != "h3facerefine" and not method.startswith(("rife", "dlssg")) + ) + + +def _task_id(receipt: dict) -> str: + ids = receipt.get("taskIds") + if isinstance(ids, list) and ids: + return str(ids[0]) + result = receipt.get("result") + if isinstance(result, dict) and result.get("task_id"): + return str(result["task_id"]) + return "" + + +def _unique(values: list[str]) -> list[str]: + seen: list[str] = [] + for item in values: + if item and item not in seen: + seen.append(item) + return seen + + +def _revision_conflict(expected: int, current: int) -> HTTPException: + return HTTPException(409, { + "code": "wizard_workflow_revision_conflict", + "message": f"Wizard workflow revision conflict: expected {expected}, current {current}", + "expectedRevision": expected, + "currentRevision": current, + "retryable": False, + "recoverable": True, + }) + + +def _lease_conflict(owner: str) -> HTTPException: + return HTTPException(409, { + "code": "wizard_workflow_lease_conflict", + "message": f"Workflow is leased by {owner}", + "retryable": False, + "recoverable": True, + }) + + +def _new_steps(workflow_id: str, snapshot: dict) -> list[dict[str, Any]]: + steps = [] + for step_id, kind in ((STEP_IMAGE, IMAGE_OPERATION), (STEP_UPSCALE, UPSCALE_OPERATION)): + steps.append({ + "stepId": step_id, + "kind": kind, + "state": "pending", + "input": deepcopy(snapshot) if step_id == STEP_IMAGE else {}, + "output": {}, + "taskId": "", + "pipelineId": "", + "outputRefs": [], + "executionKey": _step_intent(workflow_id, step_id), + "startedAt": 0, + "completedAt": 0, + "attempts": 0, + "error": "", + }) + return steps + + +def _new_workflow(body: dict[str, Any], owner: str) -> dict[str, Any]: + workspace = str(body.get("workspace") or "").strip() + snapshot = body.get("inputSnapshot") or body.get("input") or {} + if not isinstance(snapshot, dict): + raise command_error(422, "invalid_command", "inputSnapshot must be an object") + if not workspace: + raise command_error(422, "invalid_workspace", "Use an explicit valid output workspace") + for field in ("model_type", "prompt", "resolution"): + if not str(snapshot.get(field) or "").strip(): + raise command_error(422, "invalid_command", f"{field} is required") + workflow_id = str(body.get("workflowId") or uuid.uuid4()) + now = _now() + return { + "workflowId": workflow_id, + "type": WORKFLOW_TYPE, + "workspace": workspace, + "userRequest": str(body.get("userRequest") or ""), + "state": "prepared", + "currentStep": 0, + "steps": _new_steps(workflow_id, snapshot), + "resolvedEntityIds": {}, + "inputSnapshot": deepcopy(snapshot), + "taskIds": [], + "pipelineIds": [], + "outputRefs": [], + "confirmationScope": ["generate", "upscale"], + "processedEventIds": [], + "attempts": 0, + "createdAt": now, + "updatedAt": now, + "recoverableError": "", + "cancelRequested": False, + "resumeRequested": False, + "pendingInput": None, + "executorOwner": owner, + "leaseToken": uuid.uuid4().hex, + "leaseExpiresAt": 0, + } + + +def _replace(collection: dict[str, Any], workflow: dict[str, Any]) -> None: + workflows = collection["workflows"] + for index, item in enumerate(workflows): + if item.get("workflowId") == workflow["workflowId"]: + workflows[index] = workflow + return + workflows.append(workflow) + + +def _image_command(workflow: dict[str, Any]) -> dict[str, Any]: + snapshot = workflow["inputSnapshot"] + payload = { + "workspace": workflow["workspace"], + "model_type": snapshot["model_type"], + "prompt": snapshot["prompt"], + "resolution": snapshot["resolution"], + "num_inference_steps": int(snapshot.get("num_inference_steps") or 1), + "seed": int(snapshot["seed"]) if "seed" in snapshot else -1, + "guidance_scale": float(snapshot["guidance_scale"]) if "guidance_scale" in snapshot else 1.0, + } + if "negative_prompt" in snapshot: + payload["negative_prompt"] = snapshot["negative_prompt"] + return { + "version": 1, + "operation": IMAGE_OPERATION, + "intent_id": _execution_intent(workflow, STEP_IMAGE), + "input": payload, + } + + +def _upscale_source(workflow: dict[str, Any]) -> str: + snapshot = workflow["inputSnapshot"] + source = str(snapshot.get("source") or "").strip() + if source: + return source if source.startswith("/api/v1/") else _file_url(source, workflow["workspace"]) + refs = _output_names(workflow["steps"][0].get("outputRefs") if workflow["steps"] else []) + if len(refs) == 1: + return _file_url(refs[0], workflow["workspace"]) + return "" + + +def _upscale_method(workflow: dict[str, Any]) -> str: + snapshot = workflow["inputSnapshot"] + return str(snapshot.get("upscaleMethod") or snapshot.get("method") or "").strip() + + +def _pause_request(workflow: dict[str, Any]) -> dict[str, Any] | None: + fields: list[str] = [] + options: list[dict[str, Any]] = [] + if not _upscale_method(workflow): + fields.append("upscaleMethod") + for name in _image_methods(): + options.append({"value": name, "label": name, "field": "upscaleMethod"}) + refs = _output_names(workflow["steps"][0].get("outputRefs") if workflow["steps"] else []) + if not _upscale_source(workflow) and len(refs) != 1: + fields.append("source") + for name in refs: + options.append({ + "value": _file_url(name, workflow["workspace"]), + "label": name, + "field": "source", + }) + if not fields: + return None + reason = "Choose the exact upscale method and source before continuing." + if fields == ["upscaleMethod"]: + reason = "Choose the exact upscale method before continuing." + elif fields == ["source"]: + reason = "The image step published multiple outputs. Choose one exact source." + return {"reason": reason, "fields": fields, "options": options} + + +def _upscale_command(workflow: dict[str, Any]) -> dict[str, Any]: + source = _upscale_source(workflow) + method = _upscale_method(workflow) + if not source or not method: + raise command_error(409, "awaiting_input", "Upscale inputs are not resolved") + return { + "version": 2, + "operation": UPSCALE_OPERATION, + "intent_id": _execution_intent(workflow, STEP_UPSCALE), + "input": { + "workspace": workflow["workspace"], + "params": { + "source": source, + "source_workspace": workflow["workspace"], + "source_kind": "image", + "method": method, + "seed": int(workflow["inputSnapshot"].get("upscaleSeed") or -1), + }, + }, + } + + +def _field_options(pending: dict[str, Any], field: str) -> list[dict[str, Any]]: + matched = [] + for item in pending.get("options") or []: + if item.get("field") in {None, "", field}: + matched.append(item) + return matched + + +def _answer_value_allowed(pending: dict[str, Any], field: str, value: Any) -> None: + if value is None: + raise command_error(422, "invalid_answer", f"Input answer for {field} must not be empty") + if isinstance(value, str) and not value.strip(): + raise command_error(422, "invalid_answer", f"Input answer for {field} must not be empty") + options = _field_options(pending, field) + if not options: + return + for item in options: + if item.get("value") == value: + return + raise command_error(422, "invalid_answer", f"Input answer for {field} is not one of the available options") + + +def _validate_answer(pending: dict[str, Any], answer: dict[str, Any]) -> None: + declared = list(pending.get("fields") or []) + extra = [key for key in answer if key not in declared] + if extra: + raise command_error(422, "invalid_answer", f"Input answer contains undeclared field(s): {', '.join(extra)}") + missing = [field for field in declared if field not in answer] + if missing: + raise command_error(422, "invalid_answer", f"Input answer is missing field(s): {', '.join(missing)}") + for field in declared: + _answer_value_allowed(pending, field, answer[field]) + + +def _apply_answer(workflow: dict[str, Any], answer: dict[str, Any]) -> None: + snapshot = dict(workflow.get("inputSnapshot") or {}) + snapshot.update(answer) + workflow["inputSnapshot"] = snapshot + step = workflow["steps"][workflow["currentStep"]] + incoming = dict(step.get("input") or {}) + incoming.update(answer) + step["input"] = incoming + + +def _failure_state(workflow: dict[str, Any]) -> str: + if any(step.get("state") == "completed" for step in workflow.get("steps") or []): + return "partial" + return "failed" + + +def catalog() -> list[dict[str, Any]]: + return [ + { + "name": "wizard.image_upscale", + "version": 1, + "supportedVersions": [1], + "domain": "wizard", + "mutation": True, + "description": ( + "Start the server-owned image then upscale workflow. Admission uses " + "generation.image and tools.upscale; the receipt proves each step." + ), + "inputSchema": { + "type": "object", + "additionalProperties": False, + "properties": { + "version": {"type": "integer", "const": 1}, + "workflowId": {"type": "string", "minLength": 1, "maxLength": 200}, + "workspace": {"type": "string", "minLength": 1}, + "userRequest": {"type": "string"}, + "input": {"type": "object"}, + "inputSnapshot": {"type": "object"}, + }, + "required": ["version", "workspace"], + }, + }, + { + "name": "wizard.workflow_answer", + "version": 1, + "supportedVersions": [1], + "domain": "wizard", + "mutation": True, + "description": ( + "Answer a paused server-owned workflow. expectedRevision is compare-and-swap; " + "a losing client receives a recoverable conflict and must not invent a new decision." + ), + "inputSchema": { + "type": "object", + "additionalProperties": False, + "properties": { + "version": {"type": "integer", "const": 1}, + "workspace": {"type": "string", "minLength": 1}, + "workflowId": {"type": "string", "minLength": 1}, + "expectedRevision": {"type": "integer", "minimum": 0}, + "stepId": {"type": "string"}, + "answerVersion": {"type": "integer", "minimum": 1}, + "answer": {"type": "object"}, + }, + "required": ["version", "workspace", "workflowId", "expectedRevision", "answer"], + }, + }, + ] + + +class WizardWorkflowExecutor: + """Advance one durable image → upscale workflow using existing operations.""" + + def __init__(self, *, workspace_dir, submit_command, command_receipt, get_task, owner: str = SERVER_OWNER): + self._workspace_dir = workspace_dir + self._submit_command = submit_command + self._command_receipt = command_receipt + self._get_task = get_task + self._owner = owner + self._lock = threading.RLock() + self._advance_lock = asyncio.Lock() + + def _dir(self, workspace: str) -> str: + return self._workspace_dir(workspace) + + def _read(self, workspace: str) -> dict[str, Any]: + return read_workflows(self._dir(workspace)) + + def _commit(self, workspace: str, collection: dict[str, Any], workflow: dict[str, Any]) -> dict[str, Any]: + """Persist one workflow without dropping siblings written during await. + + Admission can yield inside ``_submit``. A Wizard UI persist of another + row increments the shared revision in that window; retrying against the + latest collection keeps the receipt instead of leaving a running + checkpoint with no task id. + """ + workflow["updatedAt"] = _now() + candidate = collection + last_error: Exception | None = None + for _ in range(5): + try: + _replace(candidate, workflow) + saved = write_workflows( + self._dir(workspace), + candidate, + base_revision=int(candidate["revision"]), + ) + collection["revision"] = saved["revision"] + if candidate is not collection: + collection["workflows"] = candidate["workflows"] + return {"revision": saved["revision"], "workflow": workflow} + except WizardWorkflowRevisionConflict as error: + last_error = error + candidate = self._read(workspace) + assert last_error is not None + raise last_error + + def _load(self, workspace: str, workflow_id: str) -> tuple[dict[str, Any], dict[str, Any]]: + collection = self._read(workspace) + for item in collection["workflows"]: + if item.get("workflowId") == workflow_id: + return collection, item + raise command_error(404, "workflow_not_found", "No workflow exists for this identifier") + + def _require_lease(self, workflow: dict[str, Any]) -> None: + owner = str(workflow.get("executorOwner") or "") + if owner and owner != self._owner: + raise _lease_conflict(owner) + workflow["executorOwner"] = self._owner + if not workflow.get("leaseToken"): + workflow["leaseToken"] = uuid.uuid4().hex + + def _check_revision(self, collection: dict[str, Any], expected: int | None) -> None: + if expected is None: + return + current = int(collection["revision"]) + if expected != current: + raise _revision_conflict(expected, current) + + def _lookup_receipt(self, workspace: str, intent_id: str) -> dict[str, Any] | None: + try: + payload = self._command_receipt(workspace, intent_id) + except HTTPException as error: + if error.status_code == 404: + return None + raise + if not isinstance(payload, dict) or not isinstance(payload.get("receipt"), dict): + return None + return payload + + async def _submit(self, command: dict[str, Any], workflow: dict[str, Any]) -> dict[str, Any]: + result = self._submit_command( + command, + trusted_tool="wizard", + submission_context={"workflowId": workflow["workflowId"]}, + ) + if inspect.isawaitable(result): + result = await result + if not isinstance(result, dict) or not isinstance(result.get("receipt"), dict): + raise command_error(503, "admission_unavailable", "Command admission did not return a receipt") + return result + + def _attach(self, workflow: dict[str, Any], step: dict[str, Any], admitted: dict[str, Any]) -> None: + receipt = deepcopy(admitted["receipt"]) + step["output"] = {**(step.get("output") or {}), "receipt": receipt, "replayed": bool(admitted.get("replayed"))} + step["taskId"] = _task_id(receipt) + workflow["taskIds"] = _unique([*(workflow.get("taskIds") or []), step["taskId"]]) + + def _fail(self, workspace: str, collection: dict[str, Any], workflow: dict[str, Any], step: dict[str, Any], message: str) -> dict[str, Any]: + step["state"] = "failed" + step["error"] = message + workflow["state"] = _failure_state(workflow) + workflow["recoverableError"] = message + return self._commit(workspace, collection, workflow) + + def _ask(self, workspace: str, collection: dict[str, Any], workflow: dict[str, Any], step: dict[str, Any], request: dict[str, Any]) -> dict[str, Any]: + now = _now() + previous = workflow.get("pendingInput") if isinstance(workflow.get("pendingInput"), dict) else None + version = 1 + if previous and previous.get("answer") is not None: + version = max(1, int(previous.get("version") or 1) + 1) + elif previous: + version = max(1, int(previous.get("version") or 1)) + workflow["pendingInput"] = { + "workflowId": workflow["workflowId"], + "stepId": step["stepId"], + "reason": request["reason"], + "fields": request["fields"], + "options": request.get("options") or [], + "recommended": (request.get("options") or [{}])[0].get("value") if request.get("options") else None, + "resolvedEntityIds": dict(workflow.get("resolvedEntityIds") or {}), + "answer": None, + "version": version, + "requestedAt": now, + "createdAt": now, + "updatedAt": now, + "answeredAt": 0, + } + step["state"] = "awaiting_input" + workflow["state"] = "awaiting_input" + return self._commit(workspace, collection, workflow) + + async def _ensure_admitted(self, workspace: str, collection: dict[str, Any], workflow: dict[str, Any], step: dict[str, Any], command: dict[str, Any]) -> dict[str, Any]: + intent = command["intent_id"] + step["executionKey"] = intent + step["state"] = "running" + step["startedAt"] = step.get("startedAt") or _now() + step["attempts"] = int(step.get("attempts") or 0) + 1 + step["error"] = "" + workflow["state"] = "retrying" if workflow.get("resumeRequested") else "running" + self._commit(workspace, collection, workflow) + collection, workflow = self._load(workspace, workflow["workflowId"]) + step = workflow["steps"][workflow["currentStep"]] + found = self._lookup_receipt(workspace, intent) + try: + if found is None: + found = await self._submit(command, workflow) + except HTTPException as error: + detail = error.detail if isinstance(error.detail, dict) else {} + message = str(detail.get("message") or error.detail) + return self._fail(workspace, collection, workflow, step, message) + self._attach(workflow, step, found) + step["state"] = "waiting" + workflow["state"] = "queued" + workflow["resumeRequested"] = False + return self._commit(workspace, collection, workflow) + + def _complete_current(self, workspace: str, collection: dict[str, Any], workflow: dict[str, Any], step: dict[str, Any], task: dict[str, Any]) -> str: + refs = _output_names(task.get("result_refs") or (step.get("output") or {}).get("receipt", {}).get("artifacts")) + step["state"] = "completed" + step["completedAt"] = _now() + step["outputRefs"] = _unique([*(step.get("outputRefs") or []), *refs]) + step["output"] = {**(step.get("output") or {}), "taskStatus": "completed"} + workflow["outputRefs"] = _unique([*(workflow.get("outputRefs") or []), *step["outputRefs"]]) + workflow["currentStep"] = int(workflow["currentStep"]) + 1 + workflow["state"] = "completed" if workflow["currentStep"] >= len(workflow["steps"]) else "running" + self._commit(workspace, collection, workflow) + return "continue" + + async def _finish_waiting(self, workspace: str, collection: dict[str, Any], workflow: dict[str, Any], step: dict[str, Any]): + task = self._get_task(workspace, step.get("taskId") or "") + if not isinstance(task, dict): + found = self._lookup_receipt(workspace, step.get("executionKey") or "") + if found and found.get("task"): + task = found["task"] + self._attach(workflow, step, found) + else: + return await self._run_step(workspace, collection, workflow, step) + status = str((task or {}).get("status") or "") + if status == "completed": + return self._complete_current(workspace, collection, workflow, step, task) + if status in FAILED_TASK_STATES: + return self._fail(workspace, collection, workflow, step, f"Task {step.get('taskId')} {status}") + if status in ACTIVE_TASK_STATES: + desired = "running" if status == "running" else "queued" + if workflow["state"] == desired: + return {"revision": collection["revision"], "workflow": workflow} + workflow["state"] = desired + return self._commit(workspace, collection, workflow) + return {"revision": collection["revision"], "workflow": workflow} + + async def _run_step(self, workspace: str, collection: dict[str, Any], workflow: dict[str, Any], step: dict[str, Any]): + if step["stepId"] == STEP_IMAGE: + return await self._ensure_admitted(workspace, collection, workflow, step, _image_command(workflow)) + if step["stepId"] != STEP_UPSCALE: + return self._fail(workspace, collection, workflow, step, f"Unknown step {step['stepId']}") + refs = _output_names(workflow["steps"][0].get("outputRefs") if workflow["steps"] else []) + if not refs and not str(workflow["inputSnapshot"].get("source") or "").strip(): + return self._fail(workspace, collection, workflow, step, "Image finished without a usable output") + pause = _pause_request(workflow) + if pause: + return self._ask(workspace, collection, workflow, step, pause) + return await self._ensure_admitted(workspace, collection, workflow, step, _upscale_command(workflow)) + + async def _advance_once(self, workspace: str, workflow_id: str): + collection, workflow = self._load(workspace, workflow_id) + if workflow["type"] != WORKFLOW_TYPE: + return {"revision": collection["revision"], "workflow": workflow} + self._require_lease(workflow) + if workflow.get("cancelRequested") or workflow["state"] == "cancelled": + workflow["state"] = "cancelled" + return self._commit(workspace, collection, workflow) + if workflow["state"] in TERMINAL_STATES or workflow["state"] == "awaiting_input": + return {"revision": collection["revision"], "workflow": workflow} + index = int(workflow.get("currentStep") or 0) + steps = workflow.get("steps") or [] + if index >= len(steps): + workflow["state"] = "completed" + return self._commit(workspace, collection, workflow) + step = steps[index] + if step["state"] == "completed": + workflow["currentStep"] = index + 1 + self._commit(workspace, collection, workflow) + return "continue" + if step["state"] == "awaiting_input": + return {"revision": collection["revision"], "workflow": workflow} + if step["state"] == "waiting": + return await self._finish_waiting(workspace, collection, workflow, step) + return await self._run_step(workspace, collection, workflow, step) + + async def _advance(self, workspace: str, workflow_id: str) -> dict[str, Any]: + async with self._advance_lock: + return await self._advance_serial(workspace, workflow_id) + + async def _advance_serial(self, workspace: str, workflow_id: str) -> dict[str, Any]: + result: dict[str, Any] | str = {"revision": 0, "workflow": {}} + for _ in range(12): + result = await self._advance_once(workspace, workflow_id) + if result != "continue": + return result + raise command_error(500, "workflow_stuck", "Workflow advance did not settle") + + async def start(self, body: dict[str, Any]) -> dict[str, Any]: + if not isinstance(body, dict): + raise command_error(422, "invalid_command", "Start body must be a JSON object") + workflow = _new_workflow(body, self._owner) + workspace = workflow["workspace"] + workflow_id = workflow["workflowId"] + with self._lock: + collection = self._read(workspace) + existing = next((item for item in collection["workflows"] if item.get("workflowId") == workflow_id), None) + if existing is not None: + if self._return_existing(existing): + return {"revision": collection["revision"], "workflow": existing} + else: + self._commit(workspace, collection, workflow) + return await self._advance(workspace, workflow_id) + + def _return_existing(self, existing: dict[str, Any]) -> bool: + owner = str(existing.get("executorOwner") or "") + return ( + existing.get("type") != WORKFLOW_TYPE + or existing.get("state") in TERMINAL_STATES + or bool(owner and owner != self._owner) + ) + + def _prepare_answer(self, workspace: str, workflow_id: str, body: dict[str, Any], answer: dict[str, Any], expected: int) -> None: + collection, workflow = self._load(workspace, workflow_id) + self._check_revision(collection, expected) + self._require_lease(workflow) + step = workflow["steps"][workflow["currentStep"]] if workflow["currentStep"] < len(workflow["steps"]) else None + pending = workflow.get("pendingInput") if isinstance(workflow.get("pendingInput"), dict) else None + answered = pending.get("answer") if pending else None + if workflow["state"] != "awaiting_input" or step is None or step["state"] != "awaiting_input" or pending is None: + if answered == answer: + return + raise _revision_conflict(expected, int(collection["revision"])) + if body.get("stepId") and body["stepId"] != step["stepId"]: + raise command_error(422, "invalid_answer", "Input answer targets a different workflow step") + if body.get("answerVersion") not in {None, pending.get("version")}: + raise command_error(409, "stale_answer", f"Input answer is stale (expected version {pending.get('version')})") + _validate_answer(pending, answer) + now = _now() + _apply_answer(workflow, answer) + pending["answer"] = deepcopy(answer) + pending["answeredAt"] = now + pending["updatedAt"] = now + step["state"] = "pending" + step["error"] = "" + workflow["state"] = "retrying" + workflow["resumeRequested"] = True + workflow["recoverableError"] = "" + workflow["attempts"] = int(workflow.get("attempts") or 0) + 1 + try: + self._commit(workspace, collection, workflow) + except WizardWorkflowRevisionConflict as error: + raise _revision_conflict(error.expected, error.current) from error + + async def answer(self, body: dict[str, Any]) -> dict[str, Any]: + if not isinstance(body, dict): + raise command_error(422, "invalid_command", "Answer body must be a JSON object") + workspace = str(body.get("workspace") or "").strip() + workflow_id = str(body.get("workflowId") or "").strip() + answer = body.get("answer") + expected = body.get("expectedRevision") + if not workspace or not workflow_id or not isinstance(answer, dict) or type(expected) is not int: + raise command_error(422, "invalid_command", "workspace, workflowId, expectedRevision and answer are required") + with self._lock: + self._prepare_answer(workspace, workflow_id, body, answer, expected) + return await self._advance(workspace, workflow_id) + + async def resume(self, body: dict[str, Any]) -> dict[str, Any]: + if not isinstance(body, dict): + raise command_error(422, "invalid_command", "Resume body must be a JSON object") + workspace = str(body.get("workspace") or "").strip() + workflow_id = str(body.get("workflowId") or "").strip() + with self._lock: + collection, workflow = self._load(workspace, workflow_id) + self._check_revision(collection, body.get("expectedRevision") if type(body.get("expectedRevision")) is int else None) + self._require_lease(workflow) + if workflow["state"] not in {"failed", "partial", "cancelled"}: + return {"revision": collection["revision"], "workflow": workflow} + step = workflow["steps"][workflow["currentStep"]] if workflow["currentStep"] < len(workflow["steps"]) else None + if step and step["state"] != "completed": + task = self._get_task(workspace, step.get("taskId") or "") + if task and task.get("status") in FAILED_TASK_STATES: + step["executionKey"] = _step_intent(workflow_id, f"{step['stepId']}:retry:{uuid.uuid4().hex}") + step["taskId"] = "" + step["output"] = {} + step["startedAt"] = 0 + step["state"] = "pending" + step["error"] = "" + workflow["state"] = "retrying" + workflow["resumeRequested"] = True + workflow["cancelRequested"] = False + workflow["recoverableError"] = "" + workflow["attempts"] = int(workflow.get("attempts") or 0) + 1 + self._commit(workspace, collection, workflow) + return await self._advance(workspace, workflow_id) + + async def reconcile(self, workspace: str) -> list[dict[str, Any]]: + collection = self._read(workspace) + results = [] + for item in list(collection["workflows"]): + if item.get("type") != WORKFLOW_TYPE or item.get("state") in TERMINAL_STATES: + continue + owner = str(item.get("executorOwner") or "") + if owner and owner != self._owner: + continue + results.append(await self._advance(workspace, item["workflowId"])) + return results + + async def recover(self, workspaces: list[str]) -> list[dict[str, Any]]: + results = [] + for workspace in workspaces: + results.extend(await self.reconcile(workspace)) + return results + + def reconcile_blocking(self, workspace: str) -> list[dict[str, Any]]: + return asyncio.run(self.reconcile(workspace)) + + def recover_blocking(self, workspaces: list[str]) -> list[dict[str, Any]]: + return asyncio.run(self.recover(workspaces)) + + def get(self, workspace: str, workflow_id: str) -> dict[str, Any]: + collection, workflow = self._load(workspace, workflow_id) + return {"revision": collection["revision"], "workflow": workflow} + + def list(self, workspace: str) -> dict[str, Any]: + collection = self._read(workspace) + workflows = [item for item in collection["workflows"] if item.get("type") == WORKFLOW_TYPE] + return {"revision": collection["revision"], "workflows": workflows} + + +def command_handlers(executor: WizardWorkflowExecutor) -> dict[str, Any]: + async def start(arguments): + if not isinstance(arguments, dict): + raise command_error(422, "invalid_command", "Use a JSON object") + payload = dict(arguments) + payload.setdefault("inputSnapshot", payload.pop("input", payload.get("inputSnapshot") or {})) + return await executor.start(payload) + + async def answer(arguments): + if not isinstance(arguments, dict): + raise command_error(422, "invalid_command", "Use a JSON object") + return await executor.answer(arguments) + + return {"wizard.image_upscale": start, "wizard.workflow_answer": answer} diff --git a/app/services/wizard_workflow_supervisor.py b/app/services/wizard_workflow_supervisor.py new file mode 100644 index 000000000..852eed4cc --- /dev/null +++ b/app/services/wizard_workflow_supervisor.py @@ -0,0 +1,36 @@ +"""Application-owned polling for durable Wizard workflows; no second job queue.""" +from __future__ import annotations + +import asyncio +import logging +from contextlib import asynccontextmanager, suppress + +log = logging.getLogger(__name__) + + +def workflow_lifespan(executor, list_workspaces, interval: float = 1.0): + async def supervise(): + while True: + try: + workspaces = list_workspaces() + for item in workspaces: + workspace = item["name"] if isinstance(item, dict) else item + try: + await executor.reconcile(workspace) + except Exception: + log.exception("Wizard recovery failed for workspace %s", workspace) + except Exception: + log.exception("Wizard workspace enumeration failed") + await asyncio.sleep(interval) + + @asynccontextmanager + async def lifespan(_app): + task = asyncio.create_task(supervise(), name="wizard-workflow-supervisor") + try: + yield + finally: + task.cancel() + with suppress(asyncio.CancelledError): + await task + + return lifespan diff --git a/app/services/wizard_workflows.py b/app/services/wizard_workflows.py index f7c0b78fb..0ee4ee980 100644 --- a/app/services/wizard_workflows.py +++ b/app/services/wizard_workflows.py @@ -211,6 +211,9 @@ def _clean_workflow(value: Any) -> dict[str, Any] | None: "cancelRequested": value.get("cancelRequested") is True, "resumeRequested": value.get("resumeRequested") is True, "pendingInput": pending_input, + "executorOwner": _text(value.get("executorOwner"), 160), + "leaseToken": _text(value.get("leaseToken"), 200), + "leaseExpiresAt": _integer(value.get("leaseExpiresAt")), } diff --git a/app/services/world3d_export.py b/app/services/world3d_export.py new file mode 100644 index 000000000..cd7d9ec3a --- /dev/null +++ b/app/services/world3d_export.py @@ -0,0 +1,689 @@ +"""Admit a frozen World3D snapshot and render it without the caller's browser tab. + +The existing Video 3D document and export plan are the source of truth. Painting +reuses that renderer (headless Chromium is allowed). This module does not invent +a second compositor. Receipts prove admission; the canonical task reports +progress, cancel, retry and validated publication. +""" +from __future__ import annotations + +from copy import deepcopy +from pathlib import Path +import hashlib +import json +import os +import re +import shutil +import sqlite3 +import struct +import subprocess +import threading +import time +import uuid +import zlib + +from fastapi import HTTPException +from pydantic import ValidationError + +from services.asset_manifest import publish_generation_sidecar +from services.media_refs import parse_media_ref +from services.scene_commands import DocumentInput, command_error as scene_error +from services.scene_recording import SceneRecordingTranscodeError, validate_scene_recording_output +from services.task_command_admission import TaskCommandConflict +from services.task_manager import get_cancellation_token, new_task_id + + +OPERATION = "scenes.world3d.export" +RECEIPT_OPERATION = "scenes.world3d.export.receipt" +CANCEL_OPERATION = "scenes.world3d.export.cancel" +WORKSPACE_RE = re.compile(r"(?:default|[A-Za-z0-9][A-Za-z0-9_-]{0,119})") +BLOCKED_URLS = ("blob:", "file:", "javascript:", "filesystem:") +MEDIA_KINDS = frozenset({"model3d", "image", "screen"}) +COMMAND_KEYS = frozenset({"version", "operation", "intent_id", "input"}) +INPUT_KEYS = frozenset({"workspace", "document", "refs"}) +INTENT_RE = re.compile(r"[A-Za-z0-9._-]{1,160}") + + +class World3DExportCancelled(Exception): + """The canonical task was cancelled while the worker still held partials.""" + + +class World3DExportPending(RuntimeError): + """Admission is durable, but a real headless render cannot run here.""" + + +def http_error(status: int, code: str, message: str) -> HTTPException: + return HTTPException(status, { + "code": code, "message": message, "retryable": status >= 500, + "recoverable": status >= 500 or code in {"intent_conflict", "real_render_pending"}, + }) + + +def even_dim(value) -> int: + number = round(float(value if value == value else 0)) + return max(2, number - (number % 2)) + + +def export_size(width, height) -> tuple[int, int]: + width = float(width or 0) + height = float(height or 0) + max_w, max_h = (1920, 1080) if width >= height else (1080, 1920) + scale = min(1, max_w / max(1, width), max_h / max(1, height)) + return even_dim(width * scale), even_dim(height * scale) + + +def playback_speed(value) -> float: + if isinstance(value, (int, float)) and value == value: + return max(0.25, min(4.0, float(value))) + return 1.0 + + +def output_duration(document: dict) -> float: + return float(document["duration"]) / playback_speed(document.get("playbackSpeed")) + + +def frame_count(duration: float, fps: int) -> int: + return max(1, round(float(duration) * int(fps))) + + +def export_plan(document: dict) -> dict: + duration = output_duration(document) + fps = document.get("fps", 30) + if fps not in (24, 30, 60): + raise ValueError("Export fps must be 24, 30 or 60") + width, height = export_size(document.get("width"), document.get("height")) + return {"width": width, "height": height, "fps": fps, "duration": duration, + "count": frame_count(duration, fps)} + + +def playwright_module() -> Path | None: + path = Path(__file__).resolve().parents[2] / "ui" / "node_modules" / "playwright" + entry = path / "index.mjs" + return entry if entry.is_file() else None + + +# Drives the existing Video 3D stage.paint path in a process-owned Chromium. +# It is not a second compositor: overlay helpers are the same UI modules. +_OWNED_BROWSER_JS = """ +import fs from 'node:fs'; +import { pathToFileURL } from 'node:url'; +const snapshot = JSON.parse(fs.readFileSync(process.argv[2], 'utf8')); +const staging = process.argv[3]; +const appUrl = process.env.HOCUS_APP_URL; +if (!appUrl) process.exit(2); +const { chromium } = await import(process.env.PLAYWRIGHT_MODULE + ? pathToFileURL(process.env.PLAYWRIGHT_MODULE).href : 'playwright'); +const browser = await chromium.launch({ headless: true }); +const page = await browser.newPage(); +const frames = `${staging}/frames`; +fs.mkdirSync(frames, { recursive: true }); +try { + await page.goto(new URL('/world3d-render.html', appUrl).href, { waitUntil: 'domcontentloaded' }); + await page.waitForFunction(() => !!window.__world3dExport, null, { timeout: 60000 }); + const plan = snapshot.plan; + const doc = snapshot.document; + await page.evaluate(({ document: scene, plan: size }) => window.__world3dExport.load(scene, size), { document: doc, plan }); + for (let index = 0; index < plan.count; index += 1) { + const png = await page.evaluate(seconds => window.__world3dExport.frame(seconds), Math.min(plan.duration, index / plan.fps)); + const name = String(index + 1).padStart(6, '0'); + fs.writeFileSync(`${frames}/frame_${name}.png`, Buffer.from(png.split(',')[1], 'base64')); + fs.writeFileSync(`${staging}/progress.json`, JSON.stringify({ current: index + 1, total: plan.count })); + } +} finally { + await page.evaluate(() => { window.__world3dExport?.dispose(); }).catch(() => {}); + await browser.close(); +} +""" + + +def _wait_owned_browser(proc, cancelled) -> str: + while proc.poll() is None: + if cancelled(): + proc.terminate() + raise World3DExportCancelled() + time.sleep(0.1) + _stdout, stderr = proc.communicate() + return stderr or "" + + +def run_owned_browser(snapshot: dict, staging: Path, cancelled, *, app_url: str, module: Path) -> list[Path]: + script = staging / "owned_browser.mjs" + script.write_text(_OWNED_BROWSER_JS, encoding="utf-8") + proc = subprocess.Popen( + ["node", str(script), str(staging / "snapshot.json"), str(staging)], + env={**os.environ, "HOCUS_APP_URL": app_url, "PLAYWRIGHT_MODULE": str(module)}, + stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, + ) + stderr = _wait_owned_browser(proc, cancelled) + if proc.returncode == 2: + raise World3DExportPending("real-render pending: no application URL for the world3d stage") + if proc.returncode != 0: + raise RuntimeError(stderr.strip()[-1000:] or "Headless world3d export failed") + frames = sorted((staging / "frames").glob("frame_*.png")) + if not frames: + raise RuntimeError("Headless world3d export produced no frames") + return frames + + +def export_capabilities(app_url: str | None = None) -> dict: + ffmpeg = bool(shutil.which("ffmpeg") and shutil.which("ffprobe")) + playwright = playwright_module() is not None + return { + "ffmpeg": ffmpeg, "playwright": playwright, + "realRender": "ready" if ffmpeg and playwright and renderer_available(app_url) else "pending", + "renderer": "world3d-export-flow", + "fps": [24, 30, 60], "maxDuration": 600, "maxVoicedDuration": 0, + } + + +def renderer_available(app_url: str | None) -> bool: + from services.world3d_renderer_support import renderer_available as available + return available(app_url or os.environ.get("HOCUS_APP_URL", ""), playwright_module()) + + +def write_png(path: Path, width: int, height: int, rgb: tuple[int, int, int]) -> None: + row = b"\x00" + bytes(rgb) * width + raw = row * height + + def chunk(tag: bytes, data: bytes) -> bytes: + return struct.pack(">I", len(data)) + tag + data + struct.pack(">I", zlib.crc32(tag + data) & 0xFFFFFFFF) + + path.parent.mkdir(parents=True, exist_ok=True) + header = struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0) + path.write_bytes(b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", header) + chunk(b"IDAT", zlib.compress(raw, 9)) + chunk(b"IEND", b"")) + + +def mux_frame_sequence(frames: list[Path], destination: Path, *, fps: int, duration: float) -> Path: + if not shutil.which("ffmpeg"): + raise World3DExportPending("real-render pending: ffmpeg is not available") + if not frames: + raise RuntimeError("Export produced no frames") + temporary = destination.with_name(f".{destination.stem}.{os.getpid()}.partial.mp4") + destination.parent.mkdir(parents=True, exist_ok=True) + command = [ + "ffmpeg", "-v", "error", "-y", "-framerate", str(int(fps)), + "-i", str(frames[0].parent / "frame_%06d.png"), + "-c:v", "libx264", "-preset", "fast", "-crf", "18", "-pix_fmt", "yuv420p", + "-threads", "1", "-t", f"{float(duration):.3f}", "-movflags", "+faststart", str(temporary), + ] + try: + result = subprocess.run( + command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, + text=True, timeout=1800, check=False, + ) + if result.returncode != 0 or not temporary.is_file() or temporary.stat().st_size <= 0: + detail = (result.stderr or "FFmpeg did not produce an MP4").strip() + raise RuntimeError(detail[-1000:]) + expected = duration if float(duration) >= 0.5 else None + validate_scene_recording_output(temporary, expected_duration=expected, expected_fps=fps) + os.replace(temporary, destination) + return destination + except SceneRecordingTranscodeError as error: + raise RuntimeError(str(error)) from error + finally: + temporary.unlink(missing_ok=True) + + +def _digest(value) -> str: + payload = json.dumps(value, ensure_ascii=False, sort_keys=True, allow_nan=False, separators=(",", ":")) + return hashlib.sha256(payload.encode("utf-8")).hexdigest() + + +def _envelope(command) -> dict: + if not isinstance(command, dict) or set(command) != COMMAND_KEYS: + raise http_error(422, "invalid_command", "Use version, operation, intent_id and input") + intent = command.get("intent_id") + if command.get("version") != 1 or command.get("operation") != OPERATION: + raise http_error(422, "invalid_command", "Use version 1 scenes.world3d.export") + if not isinstance(intent, str) or not 1 <= len(intent) <= 160 or intent != intent.strip(): + raise http_error(422, "invalid_command", "An exact intent_id is required") + return command + + +def _input(value) -> dict: + if not isinstance(value, dict) or set(value) - INPUT_KEYS: + raise http_error(422, "invalid_command", "input may only include workspace, document and refs") + workspace = value.get("workspace") + if not isinstance(workspace, str) or not WORKSPACE_RE.fullmatch(workspace): + raise http_error(422, "invalid_workspace", "Use an explicit valid output workspace") + if "document" not in value: + raise http_error(422, "invalid_document", "A frozen world3d document is required") + return value + + +def _validated_document(raw) -> dict: + if not isinstance(raw, dict): + raise http_error(422, "invalid_document", "Use a version 1 world3d document") + try: + document = deepcopy(DocumentInput(document=raw).document) + except (ValidationError, ValueError, TypeError) as error: + raise http_error(422, "invalid_document", scene_error(error)) from error + if "slots" not in document: + raise http_error(422, "invalid_document", "Choose a Video3D scene") + if document.get("fps") not in (24, 30, 60): + raise http_error(422, "unsupported_capability", "Export fps must be 24, 30 or 60") + return document + + +def _cue_sounds(cue) -> bool: + if not isinstance(cue, dict) or not cue.get("sound"): + return False + try: + return float(cue.get("volume") or 0) > 0 + except (TypeError, ValueError): + return False + + +def _has_sound(document: dict) -> bool: + if any(_cue_sounds(cue) for cue in document.get("sfx") or []): + return True + if any(_cue_sounds(cue) for cue in document.get("worldSfx") or []): + return True + if document.get("soundtrack"): + return True + return any(isinstance(slot, dict) and slot.get("speech") for slot in document.get("slots") or []) + + +def _blocked_url(value) -> bool: + return isinstance(value, str) and value.strip().lower().startswith(BLOCKED_URLS) + + +def _walk_strings(value): + if isinstance(value, str): + yield value + elif isinstance(value, dict): + for item in value.values(): + yield from _walk_strings(item) + elif isinstance(value, list): + for item in value: + yield from _walk_strings(item) + + +def unsupported_capabilities(document: dict) -> list[str]: + reasons = [] + if any(_blocked_url(item) for item in _walk_strings(document)): + reasons.append("ephemeral_url") + if _has_sound(document): + reasons.append("voiced_duration" if output_duration(document) > 180 else "voiced_audio") + for slot in document.get("slots") or []: + media = slot.get("media") if isinstance(slot, dict) else None + if media not in MEDIA_KINDS: + reasons.append("unsupported_media") + break + return reasons + + +def _ref_from_url(slot: dict, url: str, workspace: str) -> dict: + path, ref_workspace = parse_media_ref(url, workspace) + filename = os.path.basename((path or "").replace("\\", "/")) + if not filename: + raise http_error(422, "missing_ref", "Each used slot needs a durable media ref") + return { + "slotId": slot["id"], "url": url, "kind": slot.get("media") or "model3d", + "filename": filename, "workspace": ref_workspace or workspace, + } + + +def _index_refs(refs) -> dict: + if refs is None: + refs = [] + if not isinstance(refs, list) or len(refs) > 64: + raise http_error(422, "invalid_command", "refs must be a list of at most 64 durable media refs") + by_slot = {} + for item in refs: + if not isinstance(item, dict) or not isinstance(item.get("slotId"), str): + raise http_error(422, "invalid_command", "Each ref needs a slotId and url") + if _blocked_url(item.get("url")): + raise http_error(422, "unsupported_capability", "Ephemeral blob or file URLs cannot be exported") + by_slot[item["slotId"]] = item + return by_slot + + +def _slot_ref(slot: dict, by_slot: dict, workspace: str) -> dict | None: + url = str(slot.get("sourceUrl") or "").strip() + if not url: + return None + if _blocked_url(url): + raise http_error(422, "unsupported_capability", "Ephemeral blob or file URLs cannot be exported") + return by_slot.get(slot["id"]) or _ref_from_url(slot, url, workspace) + + +def _validated_refs(document: dict, refs, workspace: str) -> list[dict]: + by_slot = _index_refs(refs) + resolved = [] + for slot in document["slots"]: + item = _slot_ref(slot, by_slot, workspace) + if item is not None: + resolved.append(item) + return resolved + + +def build_snapshot(document: dict, refs: list[dict], workspace: str) -> dict: + return { + "workspace": workspace, "document": deepcopy(document), "refs": deepcopy(refs), + "plan": export_plan(document), + } + + +def freeze_export_command(command) -> dict: + envelope = _envelope(command) + payload = _input(envelope["input"]) + document = _validated_document(payload["document"]) + refs = _validated_refs(document, payload.get("refs"), payload["workspace"]) + reasons = unsupported_capabilities(document) + if reasons: + raise http_error(422, "unsupported_capability", "Unsupported export capability: " + ", ".join(reasons)) + snapshot = build_snapshot(document, refs, payload["workspace"]) + original = deepcopy(envelope) + effective = {"version": 1, "operation": OPERATION, + "input": {"workspace": payload["workspace"], "snapshot": snapshot}} + return { + "original": original, "effective": effective, + "fingerprint": _digest({"operation": OPERATION, "input": effective["input"]}), + "fingerprint_version": 1, + } + + +def command_catalog() -> list[dict]: + intent = {"type": "string", "minLength": 1, "maxLength": 160} + workspace = {"type": "string", "minLength": 1, "maxLength": 120} + receipt_input = {"type": "object", "additionalProperties": False, + "properties": {"workspace": workspace, "intent_id": intent}, + "required": ["workspace", "intent_id"]} + export_input = {"type": "object", "additionalProperties": False, + "properties": {"workspace": workspace, "document": {"type": "object"}, + "refs": {"type": "array", "maxItems": 64}}, + "required": ["workspace", "document"]} + return [ + {"name": OPERATION, "version": 1, "supportedVersions": [1], "domain": "scenes", "mutation": True, + "description": "Admit an immutable Video 3D snapshot and durable media refs as one canonical task. A server-owned worker renders with the existing world3d exporter (headless browser is allowed). Closing the UI does not cancel. Reuse intent_id only to recover the receipt; inspect its task for progress, cancel, retry and the validated MP4.", + "inputSchema": {"type": "object", "additionalProperties": False, + "properties": {"version": {"type": "integer", "const": 1}, + "operation": {"const": OPERATION}, "intent_id": intent, + "input": export_input}, + "required": ["version", "operation", "intent_id", "input"]}}, + {"name": RECEIPT_OPERATION, "version": 1, "domain": "scenes", "mutation": False, + "description": "Read the immutable World3D export admission and its current canonical task in the original workspace.", + "inputSchema": {"type": "object", "additionalProperties": False, + "properties": {"version": {"type": "integer", "const": 1}, + "operation": {"const": RECEIPT_OPERATION}, "input": receipt_input}, + "required": ["version", "operation", "input"]}}, + {"name": CANCEL_OPERATION, "version": 1, "domain": "scenes", "mutation": True, + "description": "Cancel a World3D export by exact workspace and intent_id. Partials stay recoverable; the frozen document is kept.", + "inputSchema": {"type": "object", "additionalProperties": False, + "properties": {"version": {"type": "integer", "const": 1}, + "operation": {"const": CANCEL_OPERATION}, "input": receipt_input}, + "required": ["version", "operation", "input"]}}, + ] + + +def _tool_ids(arguments, required) -> dict: + if (not isinstance(arguments, dict) or set(arguments) != {"version", "input"} + or arguments.get("version") != 1 or not isinstance(arguments.get("input"), dict) + or set(arguments["input"]) != required): + raise http_error(422, "invalid_command", "Use version 1 with workspace and intent_id") + return arguments["input"] + + +def command_handlers(service): + def submit(arguments): + if not isinstance(arguments, dict) or set(arguments) != {"version", "intent_id", "input"}: + raise http_error(422, "invalid_command", "Use version, intent_id and input for the export tool") + return service.submit({**arguments, "operation": OPERATION}) + + def receipt(arguments): + payload = _tool_ids(arguments, {"workspace", "intent_id"}) + return service.receipt(payload["workspace"], payload["intent_id"]) + + def cancel(arguments): + payload = _tool_ids(arguments, {"workspace", "intent_id"}) + return service.cancel(payload["workspace"], payload["intent_id"]) + + return {OPERATION: submit, RECEIPT_OPERATION: receipt, CANCEL_OPERATION: cancel} + + +def staging_dir(workspace_path: str, intent_id: str) -> Path: + safe = bool(INTENT_RE.fullmatch(intent_id)) and intent_id not in {".", ".."} and ".." not in intent_id + token = intent_id if safe else hashlib.sha256(intent_id.encode("utf-8")).hexdigest()[:32] + path = Path(workspace_path) / ".world3d-export" / token + path.mkdir(parents=True, exist_ok=True) + return path + + +class World3DExportService: + """Canonical admission plus a process-owned worker independent of the UI tab.""" + + def __init__(self, *, workspace_dir, registry_for, renderer=None, app_url=None): + self.workspace_dir = workspace_dir + self.registry_for = registry_for + self.renderer = renderer + self.app_url = app_url if app_url is not None else os.environ.get("HOCUS_APP_URL", "") + self.owner = uuid.uuid4().hex + self._lock = threading.RLock() + self._workers: dict[str, threading.Thread] = {} + + def capabilities(self) -> dict: + return export_capabilities(self.app_url) + + def _registry(self, workspace: str): + if not isinstance(workspace, str) or not WORKSPACE_RE.fullmatch(workspace): + raise http_error(422, "invalid_workspace", "Use an explicit valid output workspace") + return self.registry_for(workspace) + + def _assert_refs(self, refs: list[dict], workspace: str) -> None: + root = Path(self.workspace_dir(workspace)) + for ref in refs: + name = ref.get("filename") + if not name: + continue + if not (root / str(name)).is_file(): + raise http_error(409, "missing_ref", "Upload local scene resources before exporting") + + def submit(self, command) -> dict: + try: + frozen = freeze_export_command(command) + workspace = frozen["effective"]["input"]["workspace"] + self._assert_refs(frozen["effective"]["input"]["snapshot"]["refs"], workspace) + registry = self._registry(workspace) + previous = registry.command_admission(command["intent_id"]) + if previous is not None: + return self._replay(registry, frozen, previous) + return self._admit(registry, frozen, workspace) + except TaskCommandConflict as error: + raise http_error(409, "intent_conflict", str(error)) from error + except (OSError, sqlite3.Error) as error: + raise http_error(503, "storage_unavailable", "Command storage is unavailable; retry with the same intention") from error + + def _validate_replay(self, previous, frozen) -> None: + if (previous["operation"] != frozen["original"]["operation"] or previous["digest"] != frozen["fingerprint"] + or previous["fingerprint_version"] != frozen["fingerprint_version"]): + raise TaskCommandConflict("intent_id was already used with different parameters or preconditions") + + def _replay(self, registry, frozen, previous) -> dict: + self._validate_replay(previous, frozen) + task = registry.get(previous["task_id"]) + if task and task["status"] in {"failed", "interrupted", "cancelled"}: + registry.update(previous["task_id"], status="queued", phase="queued", + message="Retrying Video 3D export", error=None) + self._dispatch(registry, previous["intent_id"]) + current = registry.command_admission(previous["intent_id"]) + return {"receipt": deepcopy(current["receipt"]), "replayed": True, "capabilities": self.capabilities()} + + def _task_fields(self, *, task_id, job_id, workspace, plan) -> dict: + return { + "id": task_id, "root_id": task_id, "kind": "video", "workflow": OPERATION, + "title": "Video 3D export", "status": "queued", "phase": "queued", + "message": "Queued for Video 3D export", "workspace": workspace, + "backend_job_id": job_id, "current": 0, "total": plan["count"], + "resource_requirements": ["local_cpu:ffmpeg"], "cancelable": True, + "resumable": True, "recoverable": True, + "metadata": {"operation": OPERATION}, + } + + def _admit(self, registry, frozen, workspace) -> dict: + snapshot = frozen["effective"]["input"]["snapshot"] + job_id = f"world3d-export-{uuid.uuid4().hex}" + task_id = new_task_id("world3d-export") + admitted = registry.admit_command_task( + intent_id=frozen["original"]["intent_id"], operation=OPERATION, + digest=frozen["fingerprint"], original=frozen["original"], + effective=frozen["effective"], fingerprint_version=1, + task_fields=self._task_fields(task_id=task_id, job_id=job_id, workspace=workspace, plan=snapshot["plan"]), + ) + self._dispatch(registry, frozen["original"]["intent_id"]) + return {**admitted, "capabilities": self.capabilities()} + + def _dispatch(self, registry, intent_id: str) -> None: + entry = registry.command_admission(intent_id) + task = registry.get(entry["task_id"]) if entry else None + if not entry or not task or task["status"] != "queued": + return + if entry["dispatch_owner"] is None: + registry.claim_command_dispatch(intent_id, self.owner) + with self._lock: + existing = self._workers.get(intent_id) + if existing is not None and existing.is_alive(): + return + thread = threading.Thread( + target=self._run_worker, args=(intent_id, entry["task_id"], task["workspace"]), + name=f"world3d-export-{intent_id[:12]}", daemon=True, + ) + self._workers[intent_id] = thread + thread.start() + + def receipt(self, workspace: str, intent_id: str) -> dict: + if not isinstance(intent_id, str) or not 1 <= len(intent_id) <= 160: + raise http_error(422, "invalid_command", "An exact intent_id is required") + try: + registry = self._registry(workspace) + entry = registry.command_admission(intent_id) + if entry is None: + raise http_error(404, "receipt_not_found", "No admission exists for this intention in this workspace") + task = registry.get(entry["task_id"]) + return {"receipt": entry["receipt"], "task": task, "capabilities": self.capabilities()} + except (OSError, sqlite3.Error) as error: + raise http_error(503, "storage_unavailable", "Command storage is unavailable") from error + + def cancel(self, workspace: str, intent_id: str) -> dict: + viewed = self.receipt(workspace, intent_id) + task = viewed["task"] + if not task: + raise http_error(404, "receipt_not_found", "No admission exists for this intention in this workspace") + if task["status"] == "completed": + raise http_error(409, "already_completed", "A completed export cannot be cancelled") + if task["status"] != "cancelled": + try: + self._registry(workspace).update( + task["id"], status="cancelled", phase="cancelling", + message="Export cancelled", + ) + except ValueError as error: + raise http_error(409, "cannot_cancel", str(error)) from error + return self.receipt(workspace, intent_id) + + def _run_worker(self, intent_id: str, task_id: str, workspace: str) -> None: + registry = self.registry_for(workspace) + try: + self._export(registry, intent_id, task_id, workspace) + except World3DExportCancelled: + self._finish(registry, task_id, "cancelled", phase="cancelled", message="Export cancelled") + except World3DExportPending as error: + self._finish(registry, task_id, "failed", phase="pending", message=str(error), + error={"code": "real_render_pending", "message": str(error)}) + except Exception as error: + self._finish(registry, task_id, "failed", phase="failed", + message=str(error)[:500], error={"code": "export_failed", "message": str(error)[:500]}) + + def _ensure_active(self, token, registry, task_id) -> None: + task = registry.get(task_id) or {} + if token.is_cancelled() or task.get("status") == "cancelled": + raise World3DExportCancelled() + + def _progress(self, registry, task_id, current: int, total: int) -> None: + registry.update(task_id, current=current, total=total, + message=f"Rendering frame {current}/{total}", + event_exclude_fields={"current", "message", "progress"}) + + def _export(self, registry, intent_id, task_id, workspace) -> None: + token = get_cancellation_token(registry.workspace_dir, task_id) + self._ensure_active(token, registry, task_id) + try: + registry.update(task_id, status="running", phase="exporting", message="Exporting Video 3D") + except ValueError as error: + raise World3DExportCancelled() from error + entry = registry.command_admission(intent_id) + snapshot = deepcopy(entry["effective"]["input"]["snapshot"]) + staging = staging_dir(registry.workspace_dir, intent_id) + (staging / "snapshot.json").write_text( + json.dumps(snapshot, ensure_ascii=False, indent=2), encoding="utf-8") + frames = self._render_frames(snapshot, staging, token, registry, task_id) + published = self._publish(snapshot, staging, frames, workspace, registry, task_id, token) + self._finish(registry, task_id, "completed", phase="completed", + message="Published Video 3D MP4", result_refs=[published["name"]], + metadata={"operation": OPERATION, "output": published}) + + def _owned_browser(self, snapshot, staging, progress, cancelled) -> list[Path]: + module = playwright_module() + if not self.app_url or module is None or not shutil.which("node"): + raise World3DExportPending("real-render pending: playwright/ffmpeg headless export is not configured") + frames = run_owned_browser(snapshot, staging, cancelled, app_url=self.app_url, module=module) + progress(len(frames), snapshot["plan"]["count"]) + return frames + + def _render_frames(self, snapshot, staging, token, registry, task_id) -> list[Path]: + renderer = self.renderer or self._owned_browser + folder = staging / "frames" + folder.mkdir(parents=True, exist_ok=True) + + def progress(current, total): + self._ensure_active(token, registry, task_id) + self._progress(registry, task_id, current, total) + + return list(renderer(snapshot, staging, progress, lambda: token.is_cancelled() or False)) + + def _publish(self, snapshot, staging, frames, workspace, registry, task_id, token) -> dict: + self._ensure_active(token, registry, task_id) + if _has_sound(snapshot["document"]): + raise RuntimeError("Voiced World3D export cannot publish a silent MP4") + plan = snapshot["plan"] + encoded = staging / "encoded.mp4" + mux_frame_sequence(frames, encoded, fps=plan["fps"], duration=plan["duration"]) + self._ensure_active(token, registry, task_id) + template = re.sub(r"[^A-Za-z0-9._-]+", "-", str(snapshot["document"].get("templateId") or "scene")).strip("-._")[:40] or "scene" + name = f"{time.strftime('%Y-%m-%d-%Hh%Mm%Ss')}_world3d-{template}_{uuid.uuid4().hex[:6]}.mp4" + output = Path(self.workspace_dir(workspace)) / name + os.replace(encoded, output) + sidecar = { + "params": { + "model_type": "scene-animator-3d", "generation_mode": "3d-scene-compositor", + "scene": {"version": 1, "name": name, "width": plan["width"], "height": plan["height"], + "fps": plan["fps"], "duration": plan["duration"], "layers": []}, + "scene_recipe": {"engine": "world3d", "document": snapshot["document"], "refs": snapshot["refs"]}, + "width": plan["width"], "height": plan["height"], "fps": plan["fps"], + "duration_seconds": plan["duration"], + }, + "generation_mode": "video", "tool": "world3d-export", "output_filename": name, + } + publish_generation_sidecar(output, sidecar, workspace_id=workspace, tool="world3d-export", + capability=OPERATION, actor="user") + return {"name": name, "url": f"/api/v1/file/{name}", "workspace": workspace} + + def _finish(self, registry, task_id, status, **fields) -> None: + task = registry.get(task_id) + if task is None: + return + if task["status"] in {"completed", "cancelled"} and status == "failed": + return + try: + registry.update(task_id, status=status, **fields) + except ValueError: + return + + +__all__ = [ + "CANCEL_OPERATION", "OPERATION", "RECEIPT_OPERATION", "World3DExportCancelled", + "World3DExportPending", "World3DExportService", "build_snapshot", "command_catalog", + "command_handlers", "even_dim", "export_capabilities", "export_plan", "export_size", + "freeze_export_command", "http_error", "mux_frame_sequence", "playwright_module", + "staging_dir", "unsupported_capabilities", "write_png", +] diff --git a/app/services/world3d_renderer_support.py b/app/services/world3d_renderer_support.py new file mode 100644 index 000000000..2136d3364 --- /dev/null +++ b/app/services/world3d_renderer_support.py @@ -0,0 +1,25 @@ +"""Check the built renderer and installed browser without launching a render.""" +from functools import lru_cache +from pathlib import Path +import shutil +import subprocess + + +@lru_cache(maxsize=4) +def _browser_path(module: str) -> str: + try: + result = subprocess.run( + ["node", "--input-type=module", "-e", "const { chromium } = await import(process.argv[1]); process.stdout.write(chromium.executablePath())", Path(module).as_uri()], + capture_output=True, text=True, timeout=10, check=True, + ) + return result.stdout.strip() + except (OSError, subprocess.SubprocessError): + return "" + + +def renderer_available(app_url: str, module: Path | None) -> bool: + entry = Path(__file__).resolve().parents[2] / "ui" / "dist" / "world3d-render.html" + if not app_url or not entry.is_file() or not module or not shutil.which("node"): + return False + browser = _browser_path(str(module)) + return bool(browser and Path(browser).is_file()) diff --git a/app/shared/scene_effects.json b/app/shared/scene_effects.json index 2c11e328c..d9d3577f2 100644 --- a/app/shared/scene_effects.json +++ b/app/shared/scene_effects.json @@ -178,5 +178,101 @@ "color": "#ffec77", "sound": "impact", "collection": "anime" + }, + { + "id": "fire", + "color": "#ff6a22", + "sound": "crackle", + "collection": "classic" + }, + { + "id": "shield", + "color": "#66ddff", + "sound": "power", + "collection": "classic" + }, + { + "id": "tornado", + "color": "#c5d4e0", + "sound": "wind", + "collection": "classic" + }, + { + "id": "splash", + "color": "#7ad4ff", + "sound": "impact", + "collection": "classic" + }, + { + "id": "dust", + "color": "#c4a574", + "sound": "wind", + "collection": "classic" + }, + { + "id": "media_portal", + "color": "#3da5ff", + "sound": "rise", + "collection": "classic" + }, + { + "id": "psx", + "color": "#c4c4a8", + "sound": "scan", + "collection": "retro" + }, + { + "id": "n64", + "color": "#7aa3c4", + "sound": "wind", + "collection": "retro" + }, + { + "id": "nes", + "color": "#e84c3d", + "sound": "pop", + "collection": "retro" + }, + { + "id": "snes", + "color": "#7b68c4", + "sound": "chime", + "collection": "retro" + }, + { + "id": "gameboy", + "color": "#8bac0f", + "sound": "scan", + "collection": "retro" + }, + { + "id": "gameboy_color", + "color": "#ff9ec8", + "sound": "chime", + "collection": "retro" + }, + { + "id": "genesis", + "color": "#3d5cff", + "sound": "crackle", + "collection": "retro" + }, + { + "id": "vhs", + "color": "#c9a227", + "sound": "crackle", + "collection": "retro" + }, + { + "id": "crt", + "color": "#88ff88", + "sound": "scan", + "collection": "retro" + }, + { + "id": "c64", + "color": "#887ecb", + "sound": "pop", + "collection": "retro" } ] diff --git a/app/wgp.py b/app/wgp.py index bdcc49cd3..82ded104d 100644 --- a/app/wgp.py +++ b/app/wgp.py @@ -6642,6 +6642,7 @@ def concatenate_multi_clip_videos( from services.mix_concat import ( build_hard_concat_filter, concat_with_tail_hold_and_crossfade, + driving_soundtrack_bound, probe_audio_flags, probe_duration_seconds, should_use_hold_crossfade, @@ -6650,6 +6651,7 @@ def concatenate_multi_clip_videos( from app.services.mix_concat import ( build_hard_concat_filter, concat_with_tail_hold_and_crossfade, + driving_soundtrack_bound, probe_audio_flags, probe_duration_seconds, should_use_hold_crossfade, @@ -6734,18 +6736,30 @@ def concatenate_multi_clip_videos( else: filter_inputs = "".join(f"[{i}:v]" for i in range(n)) filter_str = f"{filter_inputs}concat=n={n}:v=1:a=0[outv]" - if audio_path and (audio_start_sec > 0 or pad_audio): + if audio_path: + # Always pad the driving soundtrack before -shortest. Mapping the + # raw song used to stop encoding when the mp3 ended, discarding + # the tail of the concatenated video (4s of clips + 1s song → 1s + # movie). Bound apad so its infinite stream cannot stall concat. audio_filters = [] if audio_start_sec > 0: audio_filters.append( f"atrim=start={audio_start_sec:.6f}" ) audio_filters.append("asetpts=PTS-STARTPTS") + audio_filters.append("apad") if pad_audio: - audio_filters.append("apad") audio_filters.append( f"atrim=duration={audio_duration_sec:.6f}" ) + else: + clip_secs = [ + probe_duration_seconds(path, ffmpeg_bin) or 1.0 + for path in valid_paths + ] + # audio_start_sec is atrim=start on the song, not video to drop. + bound = driving_soundtrack_bound(clip_secs) + audio_filters.append(f"atrim=duration={bound:.6f}") filter_str += ( f";[{n}:a]" + ",".join(audio_filters) @@ -6758,12 +6772,7 @@ def concatenate_multi_clip_videos( # Keep one pristine continuous soundtrack, but trim any leading time # omitted by the Director plan. This avoids both lip-sync offset and # the audible boundary blips caused by concatenating native clip audio. - audio_map = ( - "[outa]" - if audio_start_sec > 0 or pad_audio - else f"{n}:a:0" - ) - cmd += ["-map", audio_map] + cmd += ["-map", "[outa]"] cmd += ["-c:a", "aac", "-shortest"] # Force constant frame rate to prevent cumulative timing drift. diff --git a/docs/3d-video-compositor/HOWUSEIT.md b/docs/3d-video-compositor/HOWUSEIT.md index ad7bb0ebf..bf7405242 100644 --- a/docs/3d-video-compositor/HOWUSEIT.md +++ b/docs/3d-video-compositor/HOWUSEIT.md @@ -22,6 +22,8 @@ A **layered compositor**, not MiniMax H3. | Character Kits | **3D Video** sidebar | Reusable 2D cutout puppets + Face Rig mouth overlays. Operator guide: [Character Kits](../character-kits/HOWUSEIT.md) | | TV / sprite head | **3D Video** → subject GLB | Animated GLB with a **plane parented to `headfront`**. Bundled example: `/examples/tv-head-humanoid.glb` (~7 KB CRT-head walker, template **CRT-head walk**). Meshy TV-heads work the same if they expose `headfront`. | +**Shareable Video 3D scenarios** live under **Shot library → My scenarios**. **Export scenario template** writes `name.world3d.template.json` (`kind: hocuspocus.world3d.template`): camera, dressing, slot layout, motion, screens, texts and world SFX. Blob URLs are stripped; optional durable gallery URLs can be included. **Import** stores up to 24 templates in this browser (`localStorage`, 1.5 MB each). Apply remounts the scenario; **Keep my objects** copies current GLBs onto matching slots. This is not **Save shot JSON** (`clip-NN-id.world3d.json`, one clip with clip number) and it is not a zip of meshes—recipients still assign their own GLBs unless durable URLs were included. + Use the compositor when you need **controllable motion of a known object** over plates: a ship crossing stars, a UFO rising behind mountains, a logo flying in, rain over a still. Use H3 when you need **performance, speech, or a living location**. Mix them: H3 for people/places, compositor for the vehicle insert, Video Editor to cut them together. Use a **Character Kit** when the known object is a graphic puppet that must speak with mouth overlays—not a Hunyuan mesh and not H3 lip-sync. Use **Put a face / screen on this character** when the GLB already has a TV/monitor head: the compositor anchors a plane to `headfront` (or another bone) and the clip (Walk/Run) carries the picture with it. For a TV head, choose **Anchor a plane**, then adjust its width, height, local diff --git a/docs/APP_USER_GUIDE.md b/docs/APP_USER_GUIDE.md index 5c9b69fbd..9a788d36d 100644 --- a/docs/APP_USER_GUIDE.md +++ b/docs/APP_USER_GUIDE.md @@ -61,7 +61,7 @@ identidad y revisa el consumo antes de reanudarla. | Comics | Crea un cómic, define páginas y viñetas, genera y revisa cada panel antes de exportar. | `create_comic`, `generate_comic`, `generate_comic_panel`: «Crea un cómic nuevo de dos páginas sobre un mago programador». | | Character Creator | Crea un kit de personaje, adjunta referencias consistentes y construye el kit. Usa después sus vistas o rig en otros estudios. | `create_character_kit`, `attach_character_kit_references`, `build_character_kit`. | | Video 2.5D | Crea una escena de capas, añade imágenes, anima cámara y capas, ajusta el ritmo, guarda y exporta. | `create_3d_scene`, `add_3d_scene_layer`, `apply_3d_rhythm`, `save_3d_scene`, `export_3d_scene`. Estas acciones se refieren a **2.5D**, aunque sus identificadores incluyan `3d`. | -| Video 3D | Monta objetos GLB en un mundo, ajusta escala y cámara, elige las animaciones disponibles y sus recorridos, previsualiza, guarda la escena y exporta. | En el registro inspeccionado no hay capacidades dedicadas para montar GLB y ajustar las cámaras de este editor. Hazlo manualmente. | +| Video 3D | Monta objetos GLB en un mundo, ajusta escala y cámara, elige las animaciones disponibles y sus recorridos, previsualiza, guarda la escena y exporta. Exporta o importa un tipo de escenario desde **Biblioteca de planos → Mis escenarios** (`.world3d.template.json`). Eso no sustituye el JSON de un plano ni empaqueta los GLB. | En el registro inspeccionado no hay capacidades dedicadas para montar GLB y ajustar las cámaras de este editor. Hazlo manualmente. | | Replace character | Selecciona el vídeo y un fotograma editado que muestre la sustitución. Revisa los ajustes y genera. | Manual. | | Animate | Abre un personaje compatible, revisa su rig y selecciona la animación con los controles del estudio. | `open_character_kit_rig` abre el rig del kit; la edición completa de animación requiere controles manuales. | diff --git a/docs/HOWUSEIT.md b/docs/HOWUSEIT.md index 9ad12ef3e..95f8c6308 100644 --- a/docs/HOWUSEIT.md +++ b/docs/HOWUSEIT.md @@ -2,6 +2,8 @@ Operator guides for HocusPocus subsystems. Prefer these over inventing a second workflow. +- **Studio Tools** (upscale, SeedVC revoice, rembg background removal — new file, never overwrite): [`docs/tools/HOWUSEIT.md`](tools/HOWUSEIT.md) - **Video Editor** (import Lab clips, timeline, export) and **assembled mixes** (`result_kind` gallery tabs): [`docs/video-editor/HOWUSEIT.md`](video-editor/HOWUSEIT.md) - **Workspaces tab** (Director generation threads — not the output-folder selector): [`docs/workspaces/HOWUSEIT.md`](workspaces/HOWUSEIT.md) - **3D Video compositor** (Hunyuan meshes + plates + camera + rain/fog, mixed with MiniMax H3): [`docs/3d-video-compositor/HOWUSEIT.md`](3d-video-compositor/HOWUSEIT.md) +- **Character Kits / Face Rig** (2D cutout puppets, mouth overlays, dialogue cadence): [`docs/character-kits/HOWUSEIT.md`](character-kits/HOWUSEIT.md) diff --git a/docs/character-kits/HOWUSEIT.md b/docs/character-kits/HOWUSEIT.md index 9c635e9d6..bc652ecbc 100644 --- a/docs/character-kits/HOWUSEIT.md +++ b/docs/character-kits/HOWUSEIT.md @@ -12,7 +12,9 @@ UI: **3D Video** sidebar (`SceneAnimatorPanel` → Character Kits). Code: `app/_launch_runtime.py`. Related: [3D Video compositor](../3d-video-compositor/HOWUSEIT.md), -[Character Creator orbit](../3d-video-compositor/HOWUSEIT.md#54-hunyuan3d-mesh). +[Character Creator orbit](../3d-video-compositor/HOWUSEIT.md#54-hunyuan3d-mesh), +[Studio Tools rembg](../tools/HOWUSEIT.md) (general image background removal; +Face Rig cleanup is a different endpoint). --- @@ -66,8 +68,9 @@ without creating or moving files. See the frame. Defaults are mouth `{ offsetX: 0, offsetY: -18, scale: 0.05, rotation: 0 }` and eyes `{ offsetX: 0, offsetY: -28, scale: 0.12, rotation: 0 }`. Bounds are offset ±200, scale 0.001–20, rotation ±360. -7. **`lookNotes` is UI-only:** style and trait notes help the current editor - build prompts, but `normalize_character_kit` strips the field on save. +7. **`lookNotes` persist:** style and trait notes are stored on the kit + (max 4000 characters) and shown when Story Lab or Series links the + character. Face Rig still uses them to build overlay prompts. 8. **Delete is record-only:** deleting a kit removes its library entry, not its pose PNGs, cleaned overlays, or scene layers. @@ -83,6 +86,9 @@ CharacterKit identityReference?, base?, poses{} mouth { closed?, small?, wide?, round? } eyes { open?, blink? } + voice? { provider: local, model: qwen3_tts_customvoice, voiceId, instructions? } + lookNotes? + speech3d? { model, digest, settings? } anchors { [poseId]: { mouth, mouthStates?, eyes? } } provenance[] ``` @@ -343,7 +349,8 @@ The response includes `filename`, public `source`, `original`, `width`, guidance prevents this, but a manually edited JSON can still be inconsistent. - Naming overlays without mouth/viseme tokens or `faceBinding`. Discovery has a legacy label fallback; mounted kits set semantic bindings explicitly. -- Expecting `lookNotes` to survive Save kit. +- Expecting Story Lab’s acting-notes field to be the TTS engine. TTS lives + on the kit (`voice`); the story row is casting notes for this plot. - Running cleanup on a full-body pose when you intended to clean only one overlay; the endpoint crops the opaque bounding box. - Trying Face Rig from Character Creator object mode; it is rejected on purpose. diff --git a/docs/character-kits/SPEECH_QUALITY.md b/docs/character-kits/SPEECH_QUALITY.md new file mode 100644 index 000000000..c73765c40 --- /dev/null +++ b/docs/character-kits/SPEECH_QUALITY.md @@ -0,0 +1,97 @@ +# 2D speech quality and reusable mouths + +Save the character in **Character Creator**, then return to **Series Lab → Shots → Regenerate all** (or **Regenerate this shot**). Regeneration creates new editable scenes and unapproved MP4 takes. Existing motion, recordings and approved takes are preserved. + +**Series Lab → Results** keeps **Watch full episode** and **Download joined episode** +links for the last saved assembly, including after reopening the app. Regenerating +shots preserves that exported file until a new assembly is saved. + +## Resting face + +The animation rig retains the wiped, mouthless base plus separate mouth layers. Saving also composes a reusable still from that base and the selected **closed** mouth, using its saved placement. Library thumbnails and still-reference consumers prefer this resting image. A changed base, closed drawing or placement invalidates the old composite; saving rebuilds it. No original image is overwritten and pending assets stay pending. + +Series mounts saved mouths on configured visible characters, including listeners and characters in silent shots. An incomplete listener retains its original reference rather than appearing mouthless. Offscreen dialogue keeps its audio and never drives another actor's face. Existing scenes acquire the new appearance when regenerated; old approved takes remain unchanged. + +## Timing and drawings + +The previous Series planner distributed letters across the recorded phrase. The automatic 2D batch now analyzes each isolated voice recording through Rhubarb, retaining phonetic boundaries, actual pauses and consonant closures. English (`en`/`en-US`) uses PocketSphinx with the known script; other languages use Rhubarb's language-independent phonetic recognizer. Script, recognizer, audio bytes, fragment and executable identity participate in the existing bounded cache. + +Rhubarb already inserts suitable intermediate mouth shapes. The editor stores those cues alongside their audio/text provenance and compiles ordinary editable opacity keyframes. Moving a complete line moves its relative cues; changing the text, track binding or duration invalidates the old analysis. No video-generation model runs. If the offline engine is unavailable, the batch reports an error instead of silently claiming phonetic quality from estimated letter timings. + +New speaking shots require all nine mouth positions. Character Creator shows all +nine slots, including missing ones, and its checklist reports approval and pose +compatibility. Missing-mouth generation covers all nine; the default shared pack +is a complete one. Drafts can still be saved before they are complete. Previously +rendered clips and imported legacy four-drawing scenes remain usable; preparing or +regenerating speaking shots requires completing their kits first. Explicit draft +regeneration retains its existing policy of accepting saved pending images, while +missing/rejected/incompatible images remain blockers. + +**Try with their voice** generates a line with the character's saved voice and +analyzes its entire isolated recording using the same phonetic endpoint as native +shots. It no longer limits the preview to three/four seconds or substitutes word +timing when phonetic analysis fails. Playback follows the audio clock, closes the +mouth in gaps and at the end, and invalidates the preview when the character or +text changes. The separate quick text preview is explicitly approximate. + +Nine drawings retain these distinctions: + +| Drawing | Rhubarb | Use | +| --- | --- | --- | +| closed | X | Relaxed silence/listening | +| pressed | A | M, B, P | +| small | B | Narrow consonants / EE | +| medium | C | EH and intermediate opening | +| wide | D | AH | +| round | E | O | +| pucker | F | OO/W | +| bite | G | F/V | +| tongue | H | L | + +Audio amplitude helps locate activity and pauses; it does not identify vowels by itself. Rhubarb analyzes speech sounds, with a script-assisted recognizer for English. Recognition remains approximate, especially with noisy recordings, accents and very fast speech. The scene's cue track and layers remain editable. For mixed recordings, the existing speech-analysis endpoint can isolate vocals; generated Series voice lines are already isolated and do not need that extra operation. + +Video Editor joins decoded picture and audio on the same frame-counted timeline. +Each segment's audio is padded or trimmed to its exact sample span before joining; +AAC encoder padding is not carried across cuts. This also applies when exported +scenes are joined into a complete episode. Existing assembled videos need to be +exported again to benefit from this correction. Joining now includes an encoding +pass, so long exports can take longer than the previous packet-copy join. + +## Twenty styles to share + +Character Creator's mouth selector includes **20 new Studio styles**, each containing nine aligned 512px transparent PNGs. **Download this style** and **Download the 20 new styles** export ZIP files with images, a manifest, slot mapping and reuse instructions. Original generated artwork provenance is recorded in the manifest. All nine sprites share a frame and scale: keep their square canvases to avoid size/placement jumps. The six earlier four-state packs remain available. + +The PNGs and manifest ship in both the UI and app preset directories. `scripts/prepare_mouth_presets.py` mechanically slices generated 3×3 atlases; it does not draw replacement artwork. + +## Installation and API + +Pinokio Install/Update invokes `speech_install.js`. **Advanced → Repair offline lip sync** runs just that step. It installs Rhubarb 1.14.0 in the app's `.runtime/speech` directory from a pinned official archive with a checked SHA-256; no elevated/global install or runtime model download is needed. Bundled releases support x86-64 Linux, Windows and macOS; other architectures can supply a compatible executable through `RHUBARB_EXECUTABLE`. The installer reuses a configured/PATH executable and does not block the rest of the app on unsupported architectures; automatic phonetic production reports unavailable until that engine is supplied. + +`GET /api/v1/character-kits/speech/capabilities` reports availability. `POST /api/v1/character-kits/speech/analyze` still accepts raw `audio/wav` (mono, PCM16, 16 kHz, at most 90 seconds). It additionally accepts JSON `{wavBase64, dialogue, language}`; the script is bounded to 4,000 characters and stays out of request URLs. Add `?isolate_vocals=true` for an existing mixed recording when vocal isolation is installed. + +```javascript +const wavBase64 = Buffer.from(wavBytes).toString('base64'); +const response = await fetch(`${base}/api/v1/character-kits/speech/analyze`, { + method: 'POST', headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({wavBase64, dialogue: 'Move now.', language: 'en'}) +}); +if (!response.ok) throw new Error(await response.text()); +const {mouthCues, recognizer, duration} = await response.json(); +``` + +```python +import base64, requests +with open('voice.wav', 'rb') as audio: + payload = dict(wavBase64=base64.b64encode(audio.read()).decode(), + dialogue='Move now.', language='en') +response = requests.post(f'{base}/api/v1/character-kits/speech/analyze', json=payload) +response.raise_for_status() +cues = response.json()['mouthCues'] +``` + +```sh +curl --fail-with-body "$BASE/api/v1/character-kits/speech/analyze" \ + -H 'Content-Type: application/json' --data-binary @voice-with-script.json +``` + +Reference: [Rhubarb mouth shapes, recognizers and script hints](https://github.com/DanielSWolf/rhubarb-lip-sync#mouth-shapes). diff --git a/docs/development/AGENT_QA_POLICY.md b/docs/development/AGENT_QA_POLICY.md index 0e078412f..eb424490a 100644 --- a/docs/development/AGENT_QA_POLICY.md +++ b/docs/development/AGENT_QA_POLICY.md @@ -31,6 +31,11 @@ The workflow already emits these names. They were not removed. 3. `UI E2E boot (Chromium + simulated API)` 4. `CI required` +Python tests are duration-sharded as `Python tests A` and `Python tests B`. +Those jobs, Windows speech E2E, and the compile/docs guard are all inputs to +`CI required`. Caches, shard membership and the fail-closed local selector +are in [CI_CACHE_AND_SHARDS.md](CI_CACHE_AND_SHARDS.md). + `CI required` is the aggregator from P2: cancelled, skipped or failed dependencies are not success. The **GitHub required context** to enforce is the job name `CI required`, not the workflow title `CI`. A job that exists diff --git a/docs/development/CI_CACHE_AND_SHARDS.md b/docs/development/CI_CACHE_AND_SHARDS.md new file mode 100644 index 000000000..551034a4d --- /dev/null +++ b/docs/development/CI_CACHE_AND_SHARDS.md @@ -0,0 +1,110 @@ +# CI caches and Python test shards + +Faster CI without dropping jobs. `CI required` stays a fail-closed +aggregator: cancelled, skipped, missing or failed dependencies are not +success. Windows speech E2E and UI E2E stay in that set. + +## Launch and keep working + +Push one cohesive commit, then continue a *different* reserved package. Do +not sit on `gh pr checks --watch`. Extra pushes to the same PR cancel the +in-flight run (`cancel-in-progress` is PR-only). Do not use skip-ci. + +```bash +git push -u origin HEAD +# next reserved hotspot, not another amend of this PR +python3 .grok-coordinacion/coordinar.py check +``` + +Local fast path, from the repo root: + +```bash +python scripts/select_local_tests.py path/you/changed.py +python -m pytest -q $(python scripts/select_local_tests.py path/you/changed.py) +# or +python scripts/select_local_tests.py --run path/you/changed.py +``` + +Unknown or empty paths print a stderr warning and run the **full** automated +suite. They never print an empty list. A mapped test file runs that file. +`scripts/ci_required.py` maps to `tests/test_ci_required.py`. + +`--full` local validation (`bash scripts/validate_local.sh --full`) is still +the CI-equivalent wrapper: it runs every safe test, not a shard. + +## Caches + +| Surface | Mechanism | Key material | +|---|---|---| +| Python pip (Linux shards) | `actions/setup-python` `cache: pip` | OS + Python 3.10 + hash of `scripts/ci-python-requirements.txt`, `scripts/ci-python-torch-cpu.txt`, `app/requirements.txt`, `app/runtime/locks/*.txt` | +| Python pip (Windows speech) | same | OS + Python 3.10 + hash of `scripts/ci-python-windows-requirements.txt`, `app/requirements.txt`, `app/runtime/locks/*.txt` | +| apt ffmpeg | `actions/cache` pinned SHA, `~/.cache/hocus-apt-archives` | OS + arch + `ubuntu-24.04-ffmpeg` | +| Node | existing `setup-node` `cache: npm` | `ui/package-lock.json` | + +A lockfile or CI requirements change invalidates pip. A cache miss still +`pip install -r` / `apt-get install` from the network; jobs do not assume a +warm cache. Caches hold public wheels and debs only. No tokens, no workspace +outputs, no pytest result cache. + +JUnit XML per shard is uploaded as `pytest-shard-a` / `pytest-shard-b`. + +## Shards + +`scripts/ci_test_groups.json` partitions every `tests/test_*.py` file into +`python-a` and `python-b` (job names `Python tests A` / `Python tests B`). +Weights are pytest `--collect-only` nodeid counts (parametrized tests +included), packed greedily. That is a duration **proxy**. CI #320 did not +publish per-test timings. + +The compile/docs job (`Clean-repo guard + Python checks`) no longer runs +pytest. `CI required` needs: + +- `Clean-repo guard + Python checks` +- `Python tests A` +- `Python tests B` +- `UI tests + lint + type-check + build` +- `UI E2E boot (Chromium + simulated API)` +- `Speech E2E Windows (real H.264 + AAC)` + +Failure or cancellation of **any** of those keeps `CI required` red. +`scripts/ci_required.py` treats a missing pair as `missing`, not success. + +Adding a `tests/test_*.py` file without listing it in the manifest makes +`--group` (CI shards) fail closed. Put the file in the lighter group and +keep the two path lists disjoint. + +```bash +python scripts/select_local_tests.py --check-partition +python -m pytest tests/test_ci_shards.py tests/test_select_local_tests.py tests/test_ci_required.py -q +``` + +`tests/manual_grammar_live_test.py` is a manual script (`*_test.py` with no +pytest cases). It is not in the automated partition. + +## Timing (honest) + +Measured here, worktree at `origin/development` `780d3915`, existing local +interpreter, **not** a GitHub-hosted runner: + +- pytest `--collect-only -q`: **3107 tests in 14.06s** +- Selector/shard unit tests: see the PR test log (seconds, not minutes) + +From evidence **E16** (CI #320, previous monolithic guard job): + +- Guard wall ~**7m18s** +- Light pip install **101s**, Torch CPU **15s**, collect **22s**, pytest **266s** +- 50-minute queues were **not** measured there or here + +Not measured in this change (needs GitHub Actions after push): + +- Cold vs hot pip/apt cache on `ubuntu-24.04` / `windows-2025` +- Wall clock of `CI required` with two parallel pytest jobs + +Expected shape, not a promise: on a **hot** pip cache the ~116s install +should shrink; pytest wall clock for `CI required` should approach the +slower shard (about half of 266s if the count proxy matches runtime) plus +remaining install/ffmpeg. A **cold** run still pays network installs. This +is not a 2-minute CI. UI E2E, Windows speech export, and npm remain on the +critical path. + +Do not treat a green aggregator on an old SHA as coverage of a new HEAD. diff --git a/docs/development/CODE_HEALTH.md b/docs/development/CODE_HEALTH.md index 73b85bb52..1cbbb523d 100644 --- a/docs/development/CODE_HEALTH.md +++ b/docs/development/CODE_HEALTH.md @@ -12,6 +12,11 @@ pull request shows the current hotspots, the 0–100 quality score and the delta versus the PR base. The job that runs the ratchet does not get comment permissions. +After UI dependencies install successfully, CI still runs UI tests, lint and +the build when the ratchet fails. Their individual results remain visible; +any failed validation, including the ratchet, still fails `CI required`. +Cancelling the run or failing dependency installation skips those later checks. + The PR table starts with a transparent quality score from 0 to 100 and its change against the PR's exact base commit. Higher is better. The score combines cyclomatic health (45%), concentration in the largest files (25%), oversized @@ -48,7 +53,8 @@ If `ui/node_modules` is absent, the normal report still works but warns that UI complexity is unavailable. CI compares a pull request with the exact code-health report generated from -its base commit. The committed baseline remains the repository trend +its base commit. The release integration rule below only changes the unit for +the two cumulative growth budgets. The committed baseline remains the repository trend dashboard and is used when running the check outside a pull request: ```bash @@ -107,3 +113,63 @@ were complete. Exceptions live in `scripts/code_health_exceptions.json` and must include path, rule, reason, owner, issue and expiry; there is no global hotspot waiver. New-function complexity caps are not enabled until current symbols are measured separately. + +## Release integration: development → main + +Ordinary feature PRs and pushes keep the existing single-change ratchet. +For a release PR whose actual source is `development` and base is `main`, +`check_code_health_pr_base.sh` invokes `code_health_integration.py`. +Both PR repositories must also match GitHub's current repository identity; +a fork branch named `development` keeps the ordinary ratchet. + +- Production LOC and the number of functions at complexity 15+ must stay within + the unchanged budget at **every first-parent integration**. Splitting a release + into many commits does not permit any individual step to exceed its budget; + a later reduction cannot erase an earlier aggregate-budget failure. +- Maximum complexity, per-file complexity, large-file growth, new-file limits, + policy, scope and complete measurements remain mandatory between the **exact + main base and final candidate**. An intermediate local hotspot can be repaired + before release; its historical finding stays in the report and the final + tree must satisfy the original base limits. There is no local-rule waiver. +- The report retains the full cumulative LOC/function deltas and quality score + against main. The baseline and exception file are never rewritten. + +The verifier requires full Git history, exact source/base SHAs, contiguous +first-parent ancestry and a checked-out candidate tree equal to the source +tree. The main base must have the same tree as the integration merge-base +(publication-only merge history is allowed). It rejects dirty product files, +missing blobs, omitted measurements, and changes to the analyzer, policy, +ESLint configuration or UI dependency manifests anywhere in the chain. +Changes to `scripts.test` are allowed because measurement invokes ESLint +directly; all other manifest fields, install hooks and the lockfile must remain +identical. Historical blob metrics must reproduce the gate's base and candidate +measurements exactly. Unsupported history or measurement changes fail closed and require +separate review; branch labels alone are insufficient to pass. + +Unique source blobs are analyzed once with the existing Python AST counter and +ESLint complexity rule, then reconstructed into each exact commit's product +paths. This avoids rerunning full UI analysis for unchanged code at every merge. +The `code-health-report` artifact includes `code-health-integration.json` with +every checkpoint SHA, tree, aggregate metrics, historical findings and verdict. +CI fetches complete history when necessary. Local reproduction uses exact SHAs: + +```bash +BASE_BRANCH=main SOURCE_BRANCH=development \ +GITHUB_REPOSITORY=IAnMove/hocuspocus \ +BASE_REPOSITORY=IAnMove/hocuspocus SOURCE_REPOSITORY=IAnMove/hocuspocus \ +BASE_SHA= SOURCE_HEAD_SHA= \ +bash scripts/check_code_health_pr_base.sh +``` + +The integration that introduced the explosion effects had two historical +hotspot regressions. Separating material/particle animation and replacing +effect-default branches with a defaults table repairs those current functions; +the release rule still checks their final complexity against main. + +The same rule applies to the ensuing canonical `push` to `main` only when its +`before` SHA equals the first parent of a two-parent merge, the published tree +equals its second parent's tree, and that second parent belongs to a freshly +fetched `origin/development` history. Thus publishing the verified development +tree keeps the same gate. Fast-forward/squash commits, changed merge trees, +unrelated second parents and ambiguous metadata keep the ordinary ratchet; +missing history or a failed fetch cannot produce a pass. diff --git a/docs/development/CURRENT_WORK.md b/docs/development/CURRENT_WORK.md index 847551a75..eed9d7cd9 100644 --- a/docs/development/CURRENT_WORK.md +++ b/docs/development/CURRENT_WORK.md @@ -4,6 +4,14 @@ Verificado el 7 de septiembre de 2026 contra `origin/development` **`ef5b0871`** Es una fotografía con evidencia, no un sustituto de Git. Antes de reservar trabajo: `git fetch origin development`, consultar PR abiertos y comprobar sus archivos. +## Correcciones de integración — 12 septiembre 2026 + +Rama `fix/integration-audit-20260912`, base `5f68eb12`, preparada para PR hacia +development. Ocho correcciones en generación, borradores, ejecución Wizard, +exportación World3D, inspector y revisión de producción. Evidencia y límites en +[INTEGRATION_AUDIT_2026-09-12](INTEGRATION_AUDIT_2026-09-12.md). No implica merge +ni publicación de la aplicación local. + ## SFX, habla y MCP — 10 septiembre 2026 PR **#299** (draft hacia development), base integrada `729f784c`. Contrato: @@ -16,6 +24,14 @@ y ejemplo inglés. Corregida conversión de coordenadas de piel animada. Contrat [VIDEO3D_SPEECH](VIDEO3D_SPEECH.md). Consultar el HEAD y sus checks en el PR antes de integrar. No es una publicación. +## Timeline de letra desde el audio — 11 septiembre 2026 + +La rama de trabajo de fidelidad musical conserva la letra escrita, la alinea con +palabras detectadas en el audio, genera SRT dentro de la aplicación y entrega al +Director offsets exactos para apariciones y acciones. Contrato y límites: +[SOURCE_AUDIO_LYRIC_TIMELINE](SOURCE_AUDIO_LYRIC_TIMELINE.md). Consultar PR y HEAD +vigentes antes de integrar; la evidencia local no equivale a publicación. + ## Lectura mínima Lee este documento y el contrato del dominio que vas a modificar. Para contribuir, diff --git a/docs/development/INTEGRATION_AUDIT_2026-09-12.md b/docs/development/INTEGRATION_AUDIT_2026-09-12.md new file mode 100644 index 000000000..11a248be9 --- /dev/null +++ b/docs/development/INTEGRATION_AUDIT_2026-09-12.md @@ -0,0 +1,91 @@ +# Correcciones de integración — 12/09/2026 + +Base: `5f68eb124146c73cef8e5aeac33204aa07f2704e` (`development`). +Rama: `fix/integration-audit-20260912`, PR #404 hacia development. +Sin merge ni publicación de la aplicación. + +| Hallazgo | Comportamiento corregido | Evidencia principal | +| --- | --- | --- | +| F1: repetir una intención de imagen remota podía ejecutar otra vez al proveedor tras perder la memoria del proceso | Reclamo de despacho persistente; estados y archivos sincronizados con la tarea. Un resultado desconocido queda interrumpido y requiere una intención nueva | `test_integration_audit_regressions.py`, `test_core_runtime.py` | +| F2: abrir más de ocho documentos eliminaba borradores sin guardar | La limpieza conserva borradores distintos de su checkpoint, aunque superen el límite de caché | `integrationAuditRegressions.test.tsx`, `scene3dDocumentHistory.test.ts` | +| F3: imagen → upscale dependía de reconciliar desde el cliente | Supervisor ligado a la vida de la aplicación, recuperación al arrancar y avance serializado | `test_integration_audit_regressions.py`, `test_wizard_workflow_executor.py` | +| F4: el renderer del servidor dependía de una vista sin montar y de imports `/src/` ausentes en producción | Entrada compilada propia; monta el stage real, espera recursos, usa el bloqueo y reloj de exportación compartidos y dispone el renderer | `test_world3d_owned_render_smoke.py`, `test_world3d_export.py` | +| F5: reanudar enlazaba la tarea fallida anterior | Nueva clave de ejecución solo para reintentar una tarea fallida, cancelada o interrumpida; conserva el historial | `test_integration_audit_regressions.py` | +| F6: Generar en el inspector no enviaba nada | Adaptadores de generación, recibo visible, errores visibles y bloqueo durante el envío. Recuperar un recibo conserva la intención y no reconstruye parámetros desde metadata | `integrationAuditRegressions.test.tsx` | +| F7: exportar a 24 fps terminaba a 30 fps | Plan, número de fotogramas, renderer y MP4 mantienen 24 fps | Smoke real con 12 fotogramas, 256×144, 24 fps, 0,5 s | +| F8: revisión de producción sin integrar y acciones que simulaban éxito | Panel en Director; guardado atómico de selección, aprobación y notas; regeneración mediante el endpoint existente; exportación real de las tomas aprobadas | `test_director_review.py`, `productionReviewRuntime.test.tsx` | + +Al cambiar de toma se retira su aprobación anterior. La selección actualiza +también el segmento H3 único y los outputs, sin reordenar intentos ni cambiar +sus identidades. Si guardar falla, el panel conserva la selección anterior y +muestra el error. Las notas se guardan al salir del campo. + +El guardado devuelve el pipeline persistido al dashboard y refresca su selección, +tags y contadores. Una respuesta tardía no cambia la producción/workspace que se +esté viendo después de salir de la revisión. Las duraciones de comparación usan +metadata de cada archivo y, cuando están disponibles, segundos o frames/fps de +la toma; no asignan la duración planificada a todas las versiones de un plano. + +## Corrección de CI y revisión de #404 + +El job de UI del primer HEAD (`93c99821`) terminó con el runner apagado y +134 tests cancelados. Se reprodujo un agotamiento de heap en el test de revisión +con 30 ms de latencia: `assert.equal(HTMLElement, null)` intentaba representar +el grafo DOM/React mientras esperaba el guardado. La aserción compara ahora un +booleano y el test mantiene esa latencia para ejercitar el estado pendiente. +La reproducción anterior falla con heap de 256 MiB; las 20 pruebas enfocadas +corregidas pasan con ese mismo límite. + +`npm test` fija la concurrencia en dos procesos, de forma que local y CI ejecuten +la misma suite completa. No se omiten tests ni se modifican umbrales de checks. +También se corrigen los dos avisos de Bugbot: actualización del dashboard y +duración individual de las tomas, incluyendo metadata del vídeo y protección +frente a respuestas de guardado tardías. +El inventario de arquitectura registra el nuevo test del dashboard como lector +de comportamiento de la fachada pública Zustand; las entradas previas se conservan. + +El codec de comandos de vídeo pasa a `ui/src/lib/videoGenerationCommand.ts`. +Wizard conserva sus exports y el inspector utiliza `ui/src/api`; ninguna +superficie nueva importa directamente `features/agent`. + +El inventario de rutas añade únicamente `PUT /api/v1/director/pipelines/{pid}/review`; +se comprobó que las rutas previas y su orden relativo permanecen iguales. +Las tres suites Python nuevas están incluidas en el reparto de CI. No se +modifican baselines de calidad ni se rebajan los checks. + +## Validación + +- Regresiones del servidor y de UI, persistencia real en archivos/SQLite con + proveedores simulados y pruebas de componentes montados. +- Suite completa de UI: 1720 pruebas, cero fallos, concurrencia limitada a dos + procesos mediante `npm test` (94,99 s). TypeScript, ESLint, compilación e inventario ES/EN correctos. +- Presupuesto del bundle: entrada principal 190565 bytes gzip, límite 327680. +- Smoke explícito con Chromium y FFmpeg reales sobre `ui/dist`: carga un GLB, + avanza su movimiento, exporta y verifica el MP4. No usa modelos ni proveedores. +- Navegador: 36 pruebas generales y cuatro de habla correctas. Estas últimas + usan la copia de Chrome ya instalada mediante configuración local temporal, + porque la ruta predeterminada `/opt/google/chrome/chrome` no existe aquí. +- Python completo: 3368 passed, 2 skipped (139,54 s), con + `OMP_NUM_THREADS=1 MKL_NUM_THREADS=1 python -m pytest -q tests --durations=5`. +- Guards de repositorio, dependencias, documentación, marca y compilación Python + correctos. Ratchet contra la base de development correcto; sin rebajar policy. + +Reproducción del smoke después de compilar la UI: + +```sh +RUN_WORLD3D_RENDER_SMOKE=1 python -m pytest -q tests/test_world3d_owned_render_smoke.py +``` + +## Límites + +- El exportador World3D del servidor mantiene el rechazo explícito de escenas + con voz/audio; este PR no implementa su mezcla. El smoke verifica movimiento + y carga de recursos, no calidad artística, retargeting ni lipsync. +- El inspector usa los contratos de modelos admitidos por cada endpoint. El + contrato tipado de vídeo sigue limitado a Wan 2.1 `t2v`/`t2v_1.3B`. +- La revisión exporta la selección aprobada con el audio de esos clips; no + reconstruye la mezcla musical completa de una producción. +- No se ha ejecutado generación de pago, inferencia con modelos ni validación + física en macOS/Windows. Los tests simulados no equivalen a esas validaciones. +- Revisión independiente y CI remoto pertenecen al HEAD del PR; no se deducen + de estas pruebas del implementador. diff --git a/docs/development/MACOS_COMPATIBILITY.md b/docs/development/MACOS_COMPATIBILITY.md new file mode 100644 index 000000000..9487e324d --- /dev/null +++ b/docs/development/MACOS_COMPATIBILITY.md @@ -0,0 +1,66 @@ +# macOS compatibility contract + +Status: Apple Silicon core/remote implemented on `development-mac-integration`. +Physical Mac install/start/export QA is still required before merging to +`development`. +Apple Silicon core/remote is the first supported Mac profile. Intel Mac is +explicitly out of the first launch. This document is the live contract, not +the full engineering estimate. + +## Authority + +`GET /api/v1/system/capabilities` is the platform authority. The UI and MCP +must not infer support from GPU names. Mutating endpoints that need a local +NVIDIA engine call `require_capability_http` and return `409` with +`detail.code = feature_unavailable`. + +Each capability has `state` (`available`, `disabled`, `hidden`), +`reason_code`, optional `provider` and optional `alternative`. + +- **hidden**: never offered when creating work on this machine. +- **disabled**: kept when opening a project/recipe that already used it; + configuration is preserved; the user can switch to the `alternative`. +- Opening a Linux/NVIDIA project on Mac must not delete options or data. + +## Profiles + +| Profile | Machine | Local NVIDIA engines | +|---|---|---| +| `linux-nvidia-local` / `windows-nvidia-local` | Current default | available | +| `macos-arm64-core-remote` | Darwin arm64 | hidden | +| `macos-intel-unsupported` | Darwin x86_64 | hidden | +| `core-remote` | Forced non-NVIDIA | hidden | + +Core surfaces that stay available on Apple Silicon: projects, editors, +Video3D, remote LLM/image/music/3D (including Meshy). FFmpeg and Rhubarb +are `available` only when the binary is present; otherwise `disabled`. + +## Integration line + +Work lands on `development-mac-integration`, not on `development`, until the +Apple Silicon profile can install and start. PRs into that line should be +large working slices, not a contract-only drip. + +## Core/remote profile + +`select_profiles("darwin", "arm64", …)` is supported via the `core` engine +(`app/env`, FastAPI/UI, no Torch). WanGP, MiniMax H3, Hunyuan3D, SAM and +UniRig stay unsupported and are skipped by `installEngines`. `launch.py` +starts `core_runtime` instead of `_launch_runtime` so the server does not +import CUDA. `POST /api/v1/generate`, recast, upscale, Hunyuan3D, UniRig, Director pipeline +start and local audio analysis return `409 feature_unavailable`, including MCP +`generate`. Local llama.cpp load is blocked; remote MiniMax/OpenAI/Grok/Anthropic +loads, generate and song-writer stay available. Meshy/Hi3D `POST /api/v1/model3d/generate` +and MiniMax Music `POST /api/v1/stories/music-candidates/jobs` run without CUDA. +Wizard conversations/workflows, Story library, Character Kits and Series CRUD +persist as workspace JSON. The production profile defaults to MiniMax text/image/music +and Meshy 3D. MCP `tools/list` omits local generate/recast/upscale. +The Pinokio Advanced menu hides SAM and UniRig installers on Darwin. +Settings hides CUDA/VRAM/Triton controls when `show_cuda_controls` is false. +Studio Generate stays available for MiniMax Image-01; video/audio local engines +stay hidden with an NVIDIA hint. Video Editor probe/export +and Video3D scene/recording save use FFmpeg/WebCodecs, not CUDA. Comics CRUD +and remote MiniMax image keys are available. + +Linux/Windows NVIDIA recipes and receipt IDs (`linux-x64-nvidia-wangp`) +are unchanged. diff --git a/docs/development/SCENE_EFFECTS_AND_MCP.md b/docs/development/SCENE_EFFECTS_AND_MCP.md index e58d4850c..5dd607b40 100644 --- a/docs/development/SCENE_EFFECTS_AND_MCP.md +++ b/docs/development/SCENE_EFFECTS_AND_MCP.md @@ -94,7 +94,12 @@ revokes the previous key. Keys are stored in `app/settings/mcp-access.json` with mode 0600 where supported. A configured `HOCUS_MCP_TOKEN` takes precedence; Settings can disable access, but environment-managed keys rotate outside the UI. -The endpoint is the app's existing address plus `/api/v1/wangp/mcp`. There is no +This is the **Hocuspocus MCP server**: it exposes the installation's published +generation, asset, collection, scene and workflow tools. The historical +`/api/v1/wangp/mcp` URL remains a compatibility alias to the same server, with +the same token, tool catalog and request journal. + +The endpoint is the app's existing address plus `/api/v1/mcp`. There is no second listener or daemon. The HocusPocus process must be running. A client on another machine uses the reachable LAN address shown when accessing the app from that machine, rather than `localhost` on the client. @@ -103,7 +108,7 @@ Use an HTTP-capable MCP client with an Authorization header. For clients that support this configuration shape: ```json -{"mcpServers":{"hocuspocus":{"url":"http://APP_HOST:PORT/api/v1/wangp/mcp","headers":{"Authorization":"Bearer "}}}} +{"mcpServers":{"hocuspocus":{"url":"http://APP_HOST:PORT/api/v1/mcp","headers":{"Authorization":"Bearer "}}}} ``` Client configuration keys vary. Transport: Streamable HTTP JSON-RPC POST, @@ -174,3 +179,32 @@ the media gallery as well. Saving references uploads/workspace resources; it is not a portable asset package. The server rejects transient blob/file references. The native save endpoint is `POST /api/v1/scenes/world3d` with `document`, `name`, `workspace` and PNG data-URL `preview`. Existing 2D scene persistence is unchanged. + +## Cinematic spatial effects + +Video3D's existing ten spatial kinds now use noisy energy surfaces, soft particles, +branching lightning and a shared bloom pipeline. `smoke` and `sparks` also accept +world coordinates. Existing IDs, anchors, timing and sound settings are preserved; +screen overlays remain a separate track. Beam endpoints still use slot-local +anchors, including normalized/scaled GLBs. All motion derives from scene time and +seed, including backward seeks. Three pooled lights illuminate nearby geometry. + +The Cinema template browser includes **Reflective stage** and **Character +materialization**. Both use the bundled animated TV robot; replace its GLB and +choose an animation from that model. The second template adds two image screens, +a lightning strike and a centered platform. Hold a living pose samples the chosen +clip at its start offset with subtle yaw motion; it never retargets another rig. +Set the character's Y position to `.235` meters for the supplied platform. + +**Cinematic environment** controls mirror floor, platform and bloom. A background +image slot with `surface: environment` covers the frame with centered aspect-fill; +it is an illustrated backdrop, not modeled architecture. The reflective brushed +metal floor blends into the background at a distance. Reflections are bounded to +1280×720. Other slots retain ordinary image/wall/floor behavior. + +Per-slot `appearance: {start, duration, color}` reveals the posed mesh upward with +an energized edge. It composes with native speech shaders and supports lit and +unlit GLTF materials. It does not create a voice or calculate phonetic cues. +Preview, gizmo/media redraws and exported frames share the postprocessing path. +Save/reopen preserves these native document fields; no external cinema extension +is required for these templates or effects. diff --git a/docs/development/SCENE_TEMPLATE_LIBRARY.md b/docs/development/SCENE_TEMPLATE_LIBRARY.md index 6c5bfc68f..dc64c300b 100644 --- a/docs/development/SCENE_TEMPLATE_LIBRARY.md +++ b/docs/development/SCENE_TEMPLATE_LIBRARY.md @@ -71,3 +71,12 @@ regenerar una demo ni afirmar que las variantes nuevas han sido aprobadas. Pendiente: preparación y generación explícita de assets, encuadre por anclas, promoción versionada de variantes aprobadas, P01 identidad de escena/shot/run, recetas AN y posterior motor 3D. No declarar P00D completo sólo por este corte. + +### Reflective floor and electrical arrival + +The Cinema category includes `reflective-stage` and `character-materialization`. +They are native templates with editable background image, GLB, portrait screens, +spatial effects and camera. Both have an animated bundled model so a fresh example +never relies on a T-pose. Replacement models use their own selected clip; choose +its start offset for a relaxed held pose. See [cinematic spatial effects](SCENE_EFFECTS_AND_MCP.md#cinematic-spatial-effects) +for coordinates, appearance timing, lighting, reflection limits and save/export. diff --git a/docs/development/SHARED_NATIVE_COMMANDS.md b/docs/development/SHARED_NATIVE_COMMANDS.md index 622cb51c3..1ec05a808 100644 --- a/docs/development/SHARED_NATIVE_COMMANDS.md +++ b/docs/development/SHARED_NATIVE_COMMANDS.md @@ -54,7 +54,7 @@ Direct command envelopes still reject incompatible active Speech metadata. Set `HOCUS_MCP_TOKEN` in the server's environment before starting the application. Configure the external client's HTTP endpoint as -`http://SERVER:PORT/api/v1/wangp/mcp` and its Authorization header as +`http://SERVER:PORT/api/v1/mcp` and its Authorization header as `Bearer YOUR_TOKEN`. Keep the actual token out of saved requests and reports. The endpoint is disabled when no token is configured. diff --git a/docs/development/SOURCE_AUDIO_LYRIC_TIMELINE.md b/docs/development/SOURCE_AUDIO_LYRIC_TIMELINE.md new file mode 100644 index 000000000..59d05c0aa --- /dev/null +++ b/docs/development/SOURCE_AUDIO_LYRIC_TIMELINE.md @@ -0,0 +1,47 @@ +# Source-audio lyric timeline + +Status: implemented on the feature branch; real-song validation is local evidence +and does not replace CI or review. + +## Contract + +Music-video analysis produces one source-audio clock before visual planning. When +editable written lyrics are supplied, their literal text is authoritative and +Whisper supplies word boundaries only. The analysis response keeps the raw ASR in +`transcript`, exposes the aligned lines in both `lyrics` and `lyric_timeline`, and +serializes the same cues in `lyrics_srt`. + +`lyric_timing` records method, word coverage and approximate-line count. A line +that cannot be supported by recognized words is retained and interpolated between +neighbouring evidence with `source: interpolated` and zero confidence. The UI must +show low coverage rather than present interpolation as exact transcription. + +For an unknown uploaded song, the automatic transcript remains the timeline and +the app still emits SRT. Supplying the written lyrics is the path that guarantees +literal text and permits coverage measurement. + +## Visual planning + +Every planned clip receives the lyric cues that overlap it with absolute times and +clip-relative `offset` values. Semantic entrance, transformation and impact words +also become `visual_events`. The Music Video planner must place the chronological +action at the supplied offset and may build anticipation before it, but it must not +show the result earlier. + +The event vocabulary is intentionally small and deterministic. Other story meaning +still comes from the visual planner, which receives every timed lyric line. The +event list does not identify a person from capitalization or invent an asset. + +Tagged verse/chorus/bridge boundaries are derived from the first aligned cue in +each section. The section classifier must prefer those audio times over the older +word-count estimate, including Spanish and English section names. + +## Validation + +- Unit coverage checks literal preservation, quiet-intro transcription options, + SRT formatting, event anchoring and propagation into clip planning. +- Local acceptance against the three supplied Gandalf tracks aligned 94.5–97.6% + of written words. For `Gandalf ha entrado al chat`, the line begins at 18.300s + and the entrance action is anchored to `entrado` at 19.160s. +- This validates timeline construction from existing ASR evidence. It is not a new + GPU transcription run and does not certify every singer, language or mix. diff --git a/docs/development/STUDIO_PANEL_LAYOUT_REVIEW.md b/docs/development/STUDIO_PANEL_LAYOUT_REVIEW.md index 3992bbe70..ad6489076 100644 --- a/docs/development/STUDIO_PANEL_LAYOUT_REVIEW.md +++ b/docs/development/STUDIO_PANEL_LAYOUT_REVIEW.md @@ -1,9 +1,26 @@ # Revisión pendiente de la distribución de paneles de Studio Fecha: 7 de septiembre de 2026. Origen: prueba manual del flujo Viggle. -Estado: **distribución general aplazada; sin rediseño global aprobado**. +Estado: **dirección concretada el 11/09/2026** — Wizard lateral plegable; Generación directa y Director en el área principal. Implementación en `feat/unified-main-workspace-20260911`. + +## Destinos (antes → después) + +| Destino | Antes | Después | +|---|---|---| +| Ask to the Wizard | Columna izquierda plegable | Igual; al plegarlo el área principal ocupa el ancho | +| Generación directa | Columna fija 420px + galería | Formulario en el área principal; resultados al lado en XL | +| Biblioteca (imágenes/vídeos/…) | `mediaFilter` compartido con el generador | Destino `section`: solo galería; el prompt se conserva | +| Director | Columna 420px; oculto en Estudios hasta #323/#326 | Área principal; abrir Director siempre lo muestra | +| Comic Director | Cómics + sidebar Director | Director en el área principal (incluye panel de cómic) | +| Story Lab, Series, 2.5D, 3D, Animate, personajes, Replace | Área principal | Igual, sin columna de generación | +| Ajustes / Productions | Overlay | Overlay | +| Móvil | Overlay de generación; Wizard aparte | Una columna; Wizard bajo demanda | + +`visibleWorkspaceSurface()` separa generate / director / section. `sidebarOpen` + `sidebarMode` siguen siendo el contrato de apertura; ya no montan una columna permanente. El usuario pidió documentar el problema para revisar la distribución después de -las pruebas de Viggle. +las pruebas de Viggle. El encargo de unificación del 11/09/2026 concreta esa +revisión: no ampliar la columna de 420px, sino trasladar el trabajo al área +principal. Actualización de la misma sesión: al probar la guía, el usuario concretó un cambio acotado para Viggle: una sección destacada **Reemplazar personaje**, junto a Vídeo diff --git a/docs/development/VIDEO3D_SPEECH.md b/docs/development/VIDEO3D_SPEECH.md index 508136f49..61d2b0d82 100644 --- a/docs/development/VIDEO3D_SPEECH.md +++ b/docs/development/VIDEO3D_SPEECH.md @@ -80,6 +80,16 @@ hasta 90 s / 3 MB. Un proceso, dos hilos y timeout de 90 s. No acepta rutas ni URLs externas. El editor convierte el audio antes de enviarlo. Una voz existente puede durar hasta 600 s / 32 MB; se analizan fragmentos de hasta 90 s. +La separación vocal y Rhubarb reutilizan una caché por contenido del audio, +versión de herramienta, parámetros y ventana analizada. Las solicitudes +simultáneas comparten el trabajo; ventanas distintas mantienen resultados +independientes. La mezcla final conserva la banda sonora original. +La caché usa `cache/speech-analysis/` o `SPEECH_ANALYSIS_CACHE_DIR`, con límites +configurables `SPEECH_ANALYSIS_CACHE_MAX_BYTES` (128 MiB) y +`SPEECH_ANALYSIS_CACHE_MAX_ENTRIES` (64). Los fallos no publican entradas +parciales. La separación opcional requiere sus modelos ya instalados: estas +operaciones no descargan modelos para completar el análisis. + Exportación con voces: hasta 180 segundos de salida, 1280×720, mezcla mono a 48 kHz. La velocidad se aplica una sola vez, también al tono. El documento se congela durante la exportación. Si el navegador no codifica AAC, envía PCM junto diff --git a/docs/development/WIZARD_MCP_USAGE.md b/docs/development/WIZARD_MCP_USAGE.md new file mode 100644 index 000000000..31b06df3d --- /dev/null +++ b/docs/development/WIZARD_MCP_USAGE.md @@ -0,0 +1,261 @@ +# Wizard and MCP usage (corpus guide) + +Status: H17 usage guide for published native commands. This is **not** whole-app +QA. Evidence states follow [AGENT_QA_POLICY](AGENT_QA_POLICY.md): a simulation +is not a real generation; a queued receipt is not a finished file. + +Machine fixture: [`tests/fixtures/wizard_mcp_corpus.json`](../../tests/fixtures/wizard_mcp_corpus.json). +Expect **actions and receipts**, never the exact wording of the language model. + +Related contracts: [SHARED_NATIVE_COMMANDS](SHARED_NATIVE_COMMANDS.md), +[IMAGE_COMMANDS](IMAGE_COMMANDS.md), [SPEECH_COMMANDS](SPEECH_COMMANDS.md), +[MUSIC_COMMANDS](MUSIC_COMMANDS.md), [SFX_COMMANDS](SFX_COMMANDS.md), +[TOOLS_COMMANDS](TOOLS_COMMANDS.md), [WORKSPACE_COMMANDS](WORKSPACE_COMMANDS.md). + +--- + +## English + +### What Wizard is, and what MCP is + +- **Wizard** is the chat inside the web app. You speak; the app turns that into + typed actions (`prepare_image`, `start_generation`, `retry_task`, …). The + visible answer is built from execution receipts and rejection codes, not from + “I created the file” model prose. +- **MCP** is an external client talking to `POST /api/v1/mcp` with a + Bearer token. You call a **named tool**. The server does not ask Wizard’s LLM + to interpret that call. + +Both share the same published operations, the same `intent_id`, and the same +canonical task. Closing the browser does not cancel an already admitted job. + +Wizard's model interprets the intended outcome using the conversation and current +project. Its structured `intent` distinguishes conversation, clarification and +action, with an execution scope of none, preparation or running work. The panel +validates that plan without reconstructing actions from keywords. Clarification +questions stay visible alongside navigation receipts. A series request without +creative direction can start with one question. Once the user supplies a subject, +setting, tone or references, Wizard develops a first episode draft, inventing +provisional missing titles and plot details. It does not require another explicit +creation command or a phrase delegating invention. Requests to discuss before +saving remain conversational. Opening Series Lab alone does not create an episode +or render a video. Creation receipts include the premises actually saved. + +### Before you start + +1. Choose the **output workspace**. Generations land there. +2. In **Settings**, enable only models that are already installed. This guide + never downloads weights. +3. For MCP, enable it in Settings (or set `HOCUS_MCP_TOKEN` before start). The + endpoint is `http://SERVER:PORT/api/v1/mcp`. Keep the token out of + screenshots and reports. If no token is configured, MCP answers `503`. +4. Open **Ask to the Wizard** or connect your MCP client. Discovery is + authoritative: `GET /api/v1/generation/commands` and MCP `tools/list`. + +### Published operations (this tree) + +| Operation | Who uses it | What success means | +| --- | --- | --- | +| `generation.image` | Studio Image, Wizard `prepare_image` + `start_generation`, MCP | Admission queued. Inspect the task. | +| `generation.speech` | Studio Audio → Speech, MCP | Same. Literal text is preserved. | +| `generation.music` | Studio Audio → Music, MCP | Lyrics and Music Caption stay distinct. | +| `generation.sfx` | Studio Audio → SFX, MCP | Text or a canonical video guide. | +| `tools.upscale` | Tools → Upscale, MCP | Exact source + method. | +| `generation.receipt` | HTTP GET or MCP | Recovers the same admission after a lost response. | + +Collections (`collections.create` / `update` / `get` / `list` / `commands.receipt`) +are a separate catalog at `GET /api/v1/commands`. + +**Not published here:** `generation.video`. Asking MCP for that tool must fail +without creating a task. Wizard can still fill Studio → Video through +`prepare_video` (a UI action, not this native command). Native video is a +different lane. + +Legacy MCP names `generate` / `upscale` still exist. Do not mix their envelopes +with the typed operations above. + +### Wizard tour (visible) + +1. Open **Ask to the Wizard**. +2. **Refusal.** “Prepare a Flux image of a red boat, but do not generate it.” + The form may fill. **No new Activity task.** The reply must not claim a file. +3. **How-to.** “How do I generate an image in Studio?” Navigation or + explanation only. No `start_generation`. +4. **Ambiguous.** “Generate that again.” Without an exact target this cannot + start. The Wizard should ask which item you mean. An inconsistent action + proposal produces a local rejection, never an invented filename. +5. **Workspace change.** “Switch to workspace corpus-b, then prepare a lantern. + Do not generate yet.” The destination must change **before** a later + generate. Recover receipts in the **original** output workspace. +6. **Retry.** After a timeout, repeat the **same** intention. Do not invent a + second job. In Wizard, `retry_task` with the exact task id retries that job. +7. **Compound.** Preparation must precede start. The model interprets requests + to prepare and generate together, regardless of phrasing, and orders the + actions. If its proposal puts start before preparation, validation rejects + that start. A request to prepare for later must not launch work. Queued ≠ + completed. +8. **Unpublished.** If the model proposes `generation_video` or another unknown + action, the panel lists **Actions not executed**. That is not success. + +Spanish equivalents are in the fixture (`es-negation-no-generes`, +`es-how-to-image`, `es-workspace-change`, …). + +### MCP client tour + +1. `initialize` then `tools/list`. Confirm the published names. `generation.video` + must be absent. +2. Call `generation.image` with `version`, `intent_id`, and `input` (no + `operation` field; the tool name carries it). +3. Save `receipt.result.job_id` and `receipt.result.task_id`. Status is + **queued**. +4. Repeat the **identical** call (lost HTTP response). `replayed: true` and the + **same IDs**. Two clients must show that same id. +5. `generation.receipt` with the original workspace + `intent_id`. +6. Call `generation.video`. `isError: true`, **zero** tasks. +7. Wrong Bearer token → `401`. Disabled MCP → `503`. + +Example image envelope (replace the model with an **installed** id): + +```json +{ + "version": 1, + "intent_id": "one-client-intention", + "input": { + "workspace": "my-outputs", + "model_type": "flux2_klein_4b", + "prompt": "A red boat on calm water", + "resolution": "512x512", + "num_inference_steps": 1, + "seed": 42, + "guidance_scale": 1.0 + } +} +``` + +A deliberate second generation needs a **new** `intent_id`, even if the prompt +is identical. Changing the prompt under the old id is a conflict (`409`), not a +retry. + +### Errors and recovery + +| Situation | What you should see | What not to do | +| --- | --- | --- | +| “Don’t generate” / “cómo genero” | No task | Do not treat model prose as a receipt | +| Extra/unknown fields | HTTP `422`, no admission | Do not reuse that payload | +| Unpublished tool | MCP `isError`, Wizard rejection | Do not promise an MP4 | +| Timeout after admit | Same job id on replay | Do not mint a new `intent_id` | +| Receipt in another workspace | `404` | The physical output folder is part of identity | +| Queued / running | Distinct labels | Do not say “finished” | + +### Evidence matrix (this cut) + +| Item | Designed | Implemented | Simulated | Real-executed | Pending | +| --- | --- | --- | --- | --- | --- | +| HTTP catalog | yes | yes | yes | read-only probe | not every model | +| MCP `tools/list` | yes | yes | yes | no (token not read from the shared runtime) | live list | +| image/speech/music/sfx/upscale admit+replay | yes | yes | yes | **no GPU job** | one small real generate | +| `generation.video` | yes | **no** (H01) | unpublished error | n/a | native video command | +| Wizard EN/ES corpus | yes | yes | parser + e2e harness | no live LLM | paid/live Wizard | + +Local evidence (not in git): `outputs/wizard-mcp-corpus-20260911/`. + +Conversation-only plans keep the model's explanation; it is not an execution +receipt. Plans with actions or validation rejections replace free-form claims +with actual receipts. Clarification plans display their dedicated question. + +Follow-up verification (2026-09-13): four live MiniMax-M3 planning probes covered +a broad series ambition, an indirect episodic idea, creative delegation using +earlier context with video deferred, and an explanation-only request. All four +returned the expected intent and execution scope. Proposed actions were not +executed. Local evidence: `outputs/wizard-intent-20260913/live-plans.json`. + +The creative-continuation regression can be checked against the configured LLM +from `ui/` with `HOCUSPOCUS_BASE_URL=http://127.0.0.1:PORT node_modules/.bin/tsx +--tsconfig tsconfig.app.json scripts/check-wizard-intent-live.ts` (use the actual +running port). It covers creative references, indirect follow-up without a title, +discussion before saving and an idea without a subject. It validates plans only +and does not execute actions. `seriesWizardDraft.test.ts` separately exercises +the Series adapter against simulated persistence, including the saved premises +in its receipt and excluding media generation. + +--- + +## Español + +### Qué es el Wizard y qué es MCP + +- **Wizard** es el chat de la aplicación. Convierte tu frase en acciones + tipadas. La respuesta visible sale de recibos y rechazos, no de un “ya está + el archivo” inventado por el modelo. +- **MCP** es un cliente externo contra `POST /api/v1/mcp` con token + Bearer. Llamas a una **herramienta con nombre**. El servidor no pasa esa + llamada por el LLM del Wizard. + +Comparten operaciones publicadas, `intent_id` y la tarea canónica. Cerrar el +navegador no cancela un trabajo ya admitido. + +El modelo interpreta la intención usando la conversación y el proyecto actual. +Distingue entre conversar, pedir un dato necesario y actuar, y entre preparar +algo o ejecutarlo. La aplicación valida ese plan sin reconstruir acciones a +partir de palabras clave. Las preguntas se muestran incluso cuando también se +abre una sección. Si todavía no hay dirección creativa puede preguntar, pero al +recibir un tema, ambiente, tono o referencias desarrolla un primer borrador y +propone los títulos y detalles que falten. No necesita otra orden de creación +ni una frase que delegue la invención. Si pides conversar antes de guardar, se +mantiene en esa fase. El recibo muestra las premisas guardadas. Abrir Series Lab +por sí solo no crea el episodio ni genera vídeos. + +### Antes de empezar + +1. Elige la **carpeta de salida**. +2. En **Ajustes**, deja visibles solo modelos **ya instalados**. Esta guía no + descarga pesos. +3. Para MCP, actívalo en Ajustes (o `HOCUS_MCP_TOKEN` al arrancar). El + endpoint es `http://SERVIDOR:PUERTO/api/v1/mcp`. No guardes el token + en capturas. Sin token, MCP responde `503`. +4. Abre **Ask to the Wizard** o conecta el cliente. La autoridad de + descubrimiento es `GET /api/v1/generation/commands` y `tools/list`. + +### Operaciones publicadas + +Las mismas de la tabla inglesa. **No está publicado** `generation.video`: una +llamada MCP debe fallar sin crear tarea. El Wizard puede rellenar Studio → +Vídeo con `prepare_video` (acción de UI, no este comando nativo). + +### Recorrido Wizard + +1. Abre el asistente. +2. **Negación.** «Prepara una imagen de un barco rojo, no la generes.» Puede + rellenar el formulario. **No hay tarea nueva.** +3. **Cómo.** «¿Cómo genero una imagen en Studio?» Sin `start_generation`. +4. **Ambiguo.** «Genera eso otra vez.» Sin destino exacto pregunta a qué recurso + te refieres; no arranca una generación inventada. +5. **Cambio de workspace.** Cambia a `corpus-b` y prepara; no generes aún. + Los recibos se recuperan en el workspace **original**. +6. **Reintento.** Tras un timeout, la **misma** intención. No un segundo + trabajo. `retry_task` usa el id exacto. +7. **Compuesto.** Preparar y luego generar. En cola no es terminado. +8. **No publicado.** Si propone `generation_video`, verás **Acciones no + ejecutadas**, no un MP4. + +### Recorrido MCP + +1. `tools/list` sin `generation.video`. +2. `generation.image` con `intent_id` estable. +3. Guarda `job_id` / `task_id`. Estado **en cola**. +4. Repite la llamada idéntica: mismos ids, `replayed: true`. Dos clientes + muestran el mismo id. +5. `generation.receipt` en el workspace original. +6. `generation.video` → error, cero tareas. +7. Token incorrecto → `401`. + +Una segunda generación deliberada lleva **otro** `intent_id`. Cambiar el +contenido con el mismo id es un conflicto `409`. + +### Matriz y límites + +La matriz está arriba. Este corte **no** declara QA de toda la aplicación ni +lanza inferencia en el runtime compartido aunque haya modelos instalados +(`flux2_klein_4b`, `kugelaudio_0_open`, ACE-Step). La generación real queda +**PENDING**. Fallos ajenos (vídeo nativo H01, frames del Video Editor H09) se +registran con reproducción; no se “arreglan” aquí. diff --git a/docs/series-lab/IMPLEMENTATION.md b/docs/series-lab/IMPLEMENTATION.md index ef10f3145..2a8a5e705 100644 --- a/docs/series-lab/IMPLEMENTATION.md +++ b/docs/series-lab/IMPLEMENTATION.md @@ -64,3 +64,78 @@ app/env/bin/python -m pytest -q tests ``` Broader Story Lab, Director, job lifecycle and Video Editor regression suites are required before release. + +## Reference images and mixed production + +In **Canon → Characters / Locations**, **Generate reference image** uses the subject description and the series visual style. The prompt can be edited before submission. Generation uses the configured image provider and the existing image job queue. A pending job can reconnect from the same browser without submitting another generation. Images are copied into the series asset library with their prompt, provider and job provenance. Choose the primary character image or remove a reference from its card; removing a reference preserves its file and earlier episode evidence. + +Location references use a dedicated empty-environment prompt. Before a new location image job, the series writing model extracts the physical setting and environment rendering style, removing character-design instructions and occupants even when mentioned in the location description. **Prepare environment prompt** previews that editable result without generating an image. The same preparation is used by the Setup and Shots reference batches. Failed preparation does not submit an image; reconnecting an existing image job preserves its original prompt. Locations use 16:9 framing, an explicit zero-occupant instruction and clean local model defaults so an old character reference cannot leak in from Studio. The scoped writer uses `/api/v1/llm/generate` with optional `writingProvider`, `writingModel` and `writingBaseUrl`, preserving the configured global model. + +Review the result and approve the canon. New episodes capture those references automatically. For an existing pilot, click **Use approved references in this episode** directly in Shots (the Episode room also retains its refresh action). This updates the matching characters' and locations' reference images, preserving the episode's frozen story, dialogue and existing takes. Active rendering and stale revisions block this update. + +**Setup → Allowed production methods** stores a nonempty `allowedProductionMethods` list: + +| Value | Shot workflow | +| --- | --- | +| `generated_video` | Automatic video-model rendering with the configured H3 variant. | +| `animation_2d` | Open the episode references as editable character/background layers in Video 2D. | +| `animation_3d` | Open an editable spatial composition using reference image planes; models can be assigned in Video 3D. | +| `imported_video` | Import a completed clip from another workflow or generator. | + +Select several methods to permit a mixed episode. The planner chooses `productionMethod` per shot from that list; each shot can be reassigned manually. Existing legacy shots retain `generated_video`. H3 batch rendering processes only permitted model-video shots, and checks the permission again before running queued shots. Native/imported shot durations are editable independently of H3 duration quantization. + +The same production-method checkboxes are available at the top of **Shots**, including in existing series. Enabling a method adds it to every shot's selector. To reassign an existing episode, choose the enabled method in **Method for shots without a take**, then click **Apply to shots without a take**. This updates eligible shots in the current episode, preserving completed/approved takes and queued/running/cancelling attempts. Each shot's **Configure series methods** link returns to the checkboxes. Changing the allowed list alone preserves existing shot assignments. + +**Generate all** runs the existing Scene Animator automatically: prepare source-keyed transparent cutouts, generate each dialogue line with the exact linked Character Kit voice, measure its audio, extend the shot when needed, save editable keyframes and audio, export, then import a completed but unapproved take. It skips existing drafts/finals and active attempts. A visible batch banner offers stop-after-current-shot. Keep the browser tab open; completed takes persist independently and retry processes only missing takes. This is basic limited-animation blocking. Dialogue preflight requires the exact linked kit's approved base and four compatible, approved mouth states with saved placement. Missing setup links to Character Creator. The compositor mounts mouth overlays parented to the exact Series character layer and compiles held keyframes from offline phonetic analysis of each isolated recording. Four-state rigs use compatible fallbacks; nine-state rigs retain additional articulation. A wiped base uses its own source-keyed transparent derivative; the original image and its cutout cannot replace the mouthless pose. The batch does not create mouth rigs or complex acting. + +**Generate all / Regenerate all** is the main 2D action. Generate processes missing shots; regenerate explicitly creates new versions of all permitted, inactive 2D shots, including current, approved and silent shots. Each shot has a matching **Regenerate this shot** action that scopes the batch to its exact ID. Existing saved scenes retain recorded audio and authored motion while refreshing the saved mouth setup; a shot without an editable scene uses the normal draft preparation path. Draft regeneration accepts structurally complete saved pending base/mouth assets; missing, rejected or incompatible assets block only their affected shots and link to Character Creator. Initial generation retains its approval checks. The batch appends unapproved takes and never changes character approvals or approved originals. A persisted receipt links to History, where new versions are labeled; assembly uses approved originals until the user chooses a replacement. Opening a generated shot previews its latest completed, non-rejected version. Production settings are grouped once, each card has a single **Edit shot** disclosure, ready reference preparation and irrelevant AI controls are hidden, and 2D voice/mouth settings link directly to Character Creator. The narrower changed-mouth planner remains available internally for selective updates. Offscreen speakers keep their audio without animating another character. + +Character cards expose the same background-removal operation. A derived Series asset records the original source ID and cleanup job ID; the approved identity image and canon are preserved. Changing the source requires a new cutout. The saved scene filename accompanies each automatic take, so opening its production editor reloads its actual animation/audio. + +The per-shot 2D/3D buttons also prepare scenes; further animation, model assignment, facial rigs and export can be completed in those editors. Import the exported video back into its shot using **Import video as a take**. The server verifies the video stream and duration, appends a completed take, and preserves earlier takes and approval. Approve the imported take in Review to include it in the normal episode assembly. + +Both animation methods require an approved environment image and approved images for every visible character in the episode snapshot. Setup shows environment/cast preparation counts and links to their Bible cards. Each animation shot displays the assigned environment, its optional variant and the exact reference previews; preparation stays disabled until all required images are available. Video assets, derived thumbnails and unapproved references do not satisfy this requirement. Establishing shots can have an empty cast, but still need an environment. When a planning response omits an animation shot's location, it inherits its script scene's canonical location; a plan without either is rejected. Shots also shows **Generate all missing references**, which generates only the characters and locations used by this episode that lack an image. It deduplicates subjects across shots and skips completed imports on retry. Generation does not approve canon or replace the episode snapshot. **Review and approve canon**, then **Use approved references in this episode**, complete the workflow. References that already exist in the series are labelled as available for incorporation rather than missing images. + +The normal project PUT API persists `allowedProductionMethods`; episode/shot updates persist `productionMethod`. To refresh approved references in an existing episode, POST `/api/v1/series/{seriesId}/episodes/{episodeId}/references/refresh` with `workspace` and the current series `baseRevision`: + +```javascript +const result = await fetch(`${base}/api/v1/series/${seriesId}/episodes/${episodeId}/references/refresh`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ workspace: 'default', baseRevision: series.revision }), +}); +if (!result.ok) throw new Error(await result.text()); +const updated = await result.json(); +``` + +```python +response = requests.post(f'{base}/api/v1/series/{series_id}/episodes/{episode_id}/references/refresh', + json={'workspace': 'default', 'baseRevision': series['revision']}) +response.raise_for_status() +updated = response.json() +``` + +```bash +curl -X POST "$BASE/api/v1/series/$SERIES_ID/episodes/$EPISODE_ID/references/refresh" \ + -H 'Content-Type: application/json' \ + -d '{"workspace":"default","baseRevision":12}' +``` + +Import a completed take using the existing `/api/v1/series/{seriesId}/assets/import` endpoint with `ownerType: "shot"`, `ownerId: shotId`, `kind: "video"`, `asTake: true`, and an `uploadPath` returned by `/api/v1/upload`. Both imports and reference refresh are supported by the full and core runtimes. The automatic H3 renderer remains part of the full runtime. + +## Reusable character voice and lip-sync configuration + +**Canon → Characters** owns the setup entry for each series character. It reports voice, 2D mouth-pack and 3D face-calibration readiness separately. An empty library no longer leaves the user with only an unlinked selector: **Configure in Character Creator** navigates to a dedicated, spacious editor in the Character Creator tab with the exact character name and reference image as a draft. Identity selection is locked to that subject. Voice and model drafts survive studio-tab navigation; the 2D workshop retains its existing scoped recovery. The sticky **Save everything and return to Series Lab** button merges voice/model fields with the mounted mouth workshop (or its scoped recovery draft) in one revision-checked Character Kit write, awaits the Series link, then returns to the source character and episode. Failed saves retain drafts and do not navigate. A fully saved session can yield to another character when navigating by studio tabs; unsaved sessions remain protected. Saving persists a Character Kit and its exact workspace/id link in the series. Editing that link opens the same identity; it never matches or merges characters by name. The compact Series card keeps a visible selector for linking an existing library character and separate readiness indicators for voice and lip sync. Saving links only the original series/workspace/character; changed selections are never silently overwritten. + +Voice-only characters do not need a GLB. The 3D model is optional; configuring the 3D face still requires a verified GLB. The 2D workshop opens the saved character's exact ID and keeps its recovery draft separate from other characters and the general workshop. A missing image or unprepared mouth remains visibly pending. Opening or saving settings does not generate audio, images or video. The 2D workshop includes a prerecorded English sample and its bundled Rhubarb timing; playback uses the current draft mouth shapes without upload, TTS jobs or approval changes. The generic sample voice is distinct from the configured character voice. Eyes/blinking live in an optional collapsed section; missing eye overlays use the drawn eyes. **Apply placement to all mouths** copies the selected mouth transform and does not freeze animation. + +Dialogue-shot speech controls open the same Character Creator destination directly. The advanced voice table links to the corresponding character card. The 3D speech workflow consumes the linked kit's model, face settings and local TTS preset. 2D mouth preparation continues through the existing workshop and compositor. Native-audio AI video does not consume the local TTS voice ID; its provider generates the voice. Scene audio and existing takes remain independent of character settings. + +### Render actions and approval guidance + +**Shots** and **Results** report reference readiness separately from video takes: not created, awaiting review, or approved. Missing reference names open the exact character/location card, and pending-take links select their shot or review slot. Approving a character image does not create a video take. + +**Generate AI draft takes** creates unapproved takes only for shots assigned to permitted AI video generation. Results shows method-specific shortcuts for 2D/3D preparation and importing clips. Its history and playback actions send native/imported shots to their production controls instead of the AI regeneration form. Empty AI requests are blocked before submission in both the UI and Wizard. Failed retries use the latest attempt; explicit per-shot AI regeneration can still append an alternative while retaining an existing approved take. The automatic 2D batch runs those editor steps and imports its outputs; manual 2D/3D exports can still be imported and reviewed individually. + +## Phonetic 2D speech and resting faces + +Automatic 2D generation and regeneration analyze isolated recordings with the shared offline Rhubarb service. Saved scene beats retain nine-position phonetic cues and their audio/text provenance; four-state kits use compatible fallbacks. All configured visible actors use the saved mouthless base and resting mouth, including listeners and silent shots. Saving Character Creator also composes a reusable resting still. Twenty new reusable styles and ZIP downloads are included. See [speech quality, mouth packs and API](../character-kits/SPEECH_QUALITY.md). diff --git a/docs/series-lab/series-library-v1.schema.json b/docs/series-lab/series-library-v1.schema.json index 18a759264..d56ba4eb2 100644 --- a/docs/series-lab/series-library-v1.schema.json +++ b/docs/series-lab/series-library-v1.schema.json @@ -388,6 +388,7 @@ "approvedReferenceAssetIds" ], "properties": { + "referenceRevision": { "type": "integer", "minimum": 0 }, "revision": { "type": "integer", "minimum": 0 }, "worldSummary": { "type": "string" }, "immutableRules": { @@ -597,6 +598,7 @@ "attempts" ], "properties": { + "productionMethod": { "enum": ["generated_video", "animation_2d", "animation_3d", "imported_video"], "default": "generated_video" }, "id": { "$ref": "#/$defs/id" }, "sceneId": { "$ref": "#/$defs/id" }, "order": { "type": "integer", "minimum": 0 }, @@ -782,6 +784,11 @@ "updatedAt" ], "properties": { + "allowedProductionMethods": { + "type": "array", "minItems": 1, "uniqueItems": true, + "items": { "enum": ["generated_video", "animation_2d", "animation_3d", "imported_video"] }, + "default": ["generated_video"] + }, "version": { "const": 1 }, "id": { "$ref": "#/$defs/id" }, "revision": { "type": "integer", "minimum": 0 }, diff --git a/docs/tools/HOWUSEIT.md b/docs/tools/HOWUSEIT.md new file mode 100644 index 000000000..e14f985cd --- /dev/null +++ b/docs/tools/HOWUSEIT.md @@ -0,0 +1,218 @@ +# HOWUSEIT — Studio Tools (upscale, revoice, remove background) + +Operator guide for **post-processing existing media**. Tools do not invent a +new shot: they take one exact image or clip, write a **new** file, and leave +the source untouched. + +UI: Studio sidebar → **Tools** (`generationMode: tools`, +`ui/src/components/Sidebar/ToolsPanel.tsx`). Upscale uses the version 2 +`tools.upscale` operation at `POST /api/v1/generation/commands` from Studio, +Wizard and MCP. Revoice and remove-background still use +`POST /api/v1/tools/revoice` and `POST /api/v1/tools/remove-background`. +The native `/api/v1/tools/upscale` route remains for legacy clients. Workers: +`app/services/tools_upscale.py`, `app/shared/tools/`, +`app/_launch_runtime.py`. Poll and cancel with the shared job endpoints. + +Related: [Video Editor](../video-editor/HOWUSEIT.md) (cut, do not regenerate), +[Character Kits Face Rig cleanup](../character-kits/HOWUSEIT.md) +(`POST /api/v1/character-kits/face-rig/cleanup` is a different rembg path), +[HTTP API](../../app/docs/API.md). + +--- + +## 1. What this system is + +| Tool | Accepts | Backend | Output | +|---|---|---|---| +| **Upscale / processors** | Image or video, depending on processor | FlashVSR, Lanczos and available native processors | New `_upscaled` PNG or video | +| **Revoice** | Video + 1–2 voice refs | SeedVC | New `_revoiced` clip (same container) | +| **Remove background** | Image | rembg U2Net | New `{stem}.no-background-{id}.png` | + +Use Tools when the pixels (or voices) are already good and you need a +derivative. Use Studio generate when you need a new image/clip. Use Video +Editor when you only need trim, order, and export. + +The three actions share the generation GPU slot, Activity footer, and +`GET /api/v1/status/{job_id}` / `POST /api/v1/cancel/{job_id}`. They are not +a second scheduler. + +### Output folder versus Workspace collection + +`workspace` on these routes is the **physical output folder** +(`default` or `[A-Za-z0-9][A-Za-z0-9_-]*`). It is not a logical Workspace +collection ID. Uploads use the virtual scope `__uploads__`. See +[domain model](../development/DOMAIN_MODEL_AND_ASSET_PROVENANCE.md). + +--- + +## 2. Hard limits + +1. **Never overwrite.** Every tool writes a new filename. A cancelled job + deletes its partial output when the worker can settle the cancel. +2. **Exact source.** For version 2 upscale, put an exact asset ID or canonical + local API media URL in `input.params.source`. Host paths, remote URLs and + bare filenames are rejected. Native Tools routes also accept confined + filenames/paths and a separate `asset_id`; do not mix those schemas. +3. **Source folder.** Keep the source folder distinct from the destination + `workspace`. A file URL carries it in `?workspace=`; an explicit + `source_workspace` must agree. Select a scope when an asset has multiple + locations. Upload URLs use `__uploads__`. Preserve the full canonical URL. +4. **Conflicting aliases.** The native upscale/revoice endpoints accept + `source`, `source_path`, and legacy `video_path`. Different values return + `409`. The version 2 upscale contract accepts only `params.source`. +5. **Kind gates.** Revoice is video-only. Remove-background is image-only + (`.png`, `.jpg`, `.jpeg`, `.webp`). Upscale images also allow + `.bmp`, `.gif`, `.tif`, `.tiff`. Videos: + `.avi`, `.m4v`, `.mkv`, `.mov`, `.mp4`, `.mpeg`, `.mpg`, `.webm`, `.wmv`. +6. **Instruction is metadata.** Remove-background accepts `instruction` + (max 2 000 chars) and stores it on the job/sidecar. U2Net does **not** + read it; the matte is the same with or without the note. + +--- + +## 3. Operator workflow + +1. Open Studio and choose **Tools** (Direct generation → Tools). +2. Pick **Upscale**, **Revoice**, or **Remove background**. +3. Set the source: + - gallery card → **Use selected gallery image/clip** + - resource selector: browse the library and confirm with **Choose**; + **Cancel** keeps the current selection + - upload through the same source field (image/video as allowed by the tool) + - from a selected video: **Send to Tools** / quick upscale in the info bar +4. Set tool-specific parameters. Run. Upscale presents the prepared request + before admission and returns a shared receipt. Watch the footer; the gallery + refreshes on `completed`. Failed/cancelled tiles remain available to inspect. + +Wizard has adapters for upscale and remove-background. It does not currently +expose a dedicated Revoice action; use the Tools panel for Revoice. MCP has the +shared `tools.upscale` operation; that entry does not imply shared operations +for all three tools. See [shared commands](../development/SHARED_NATIVE_COMMANDS.md). + +--- + +## 4. Upscale + +Built-in spatial choices include: + +``` +flashvsr2, flashvsr3, flashvsr4, flashvsr2pass2, flashvsr2pass4, +lanczos1.5, lanczos2 +``` + +Additional processor choices are discovered from the server and filtered by +source kind and availability. Consult `GET /api/v1/generation/commands` for +the `tools.upscale` schema and the panel's processor options; the list above +is not the whole catalog. The shared command requires an explicit `method`. +Only the native legacy route defaults an omitted method to `flashvsr2`. + +FlashVSR is model-based super-resolution (weights download on first use). +Lanczos is a fast classic resize. If Settings → Services has FlashVSR mode +`0`, the panel warns but still lets you pick a FlashVSR method. + +- Images go through the spatial upsampler in still mode and always become a + new PNG (`_upscaled`). Optional `seed` is an integer (`-1` default). +- Videos keep the existing audio-preserving pipeline and write a new clip + (`_upscaled` + configured container). + +```bash +curl -X POST "$HOCUSPOCUS_URL/api/v1/generation/commands" \ + -H "Content-Type: application/json" \ + -d '{ + "version": 2, + "operation": "tools.upscale", + "intent_id": "upscale-still-001", + "input": { + "workspace": "default", + "params": { + "source": "/api/v1/file/still.png?workspace=default", + "source_kind": "image", + "method": "lanczos2" + } + } + }' +``` + +Replace the example source with an existing resource. Keep the same +`intent_id` when retrying an uncertain response to this request; choose a new +one for another intentional operation. Admission returns `receipt.result.job_id` +and `receipt.result.task_id`; it does not mean the file is complete. Follow the +job status or recover through `generation.receipt` as described in +[shared commands](../development/SHARED_NATIVE_COMMANDS.md). + +Native legacy clients may keep using `/api/v1/tools/upscale` with the flat +body and `video_path` alias. That endpoint does not provide the shared +command receipt/replay contract; see [Tools command contract](../development/TOOLS_COMMANDS.md). + +--- + +## 5. Revoice (SeedVC) + +Body: `{ video_path, voice_ref_paths: [1–2 paths], mode?: "single"|"two", +diffusion_steps?: 25, cfg_rate?: 0.5, workspace? }`. + +| Mode | Effect | +|---|---| +| `single` (default) | Replace every voice with the first reference | +| `two` | Detect two speakers; first → Voice A, second → Voice B; keep music and silence | + +Supply two references for `two`; with only one, the worker falls back to +single-voice conversion. Any other `mode` string is coerced to `single`. +Voice refs may be audio or +video files resolved inside the destination folder or uploads. The worker +copies the source first, then converts the copy. + +Failure cases you will actually see: clip has no audio, SeedVC is +unavailable, or no reference file could be resolved. + +```bash +curl -X POST "$HOCUSPOCUS_URL/api/v1/tools/revoice" \ + -H "Content-Type: application/json" \ + -d '{ + "video_path": "take.mp4", + "voice_ref_paths": ["ref-a.wav"], + "mode": "single", + "workspace": "default" + }' +``` + +--- + +## 6. Remove background + +Prefer `asset_id`. `source` alone is accepted. Destination `workspace` +defaults to the server active output folder. + +```bash +curl -X POST "$HOCUSPOCUS_URL/api/v1/tools/remove-background" \ + -H "Content-Type: application/json" \ + -d '{ + "asset_id": "asset_image_123", + "workspace": "default", + "provenance": {"actor": "user"} + }' +``` + +Accepted immediately with `job_id`, `task_id`, `root_task_id`, and frozen +`generation_details.model_type: rembg-u2net`. The sidecar records source +lineage, timings, and transparent-PNG metrics (`width`, `height`, `alpha`). + +Face Rig overlay cleanup is a **different** endpoint +(`POST /api/v1/character-kits/face-rig/cleanup`) that also uses rembg U2Net +plus crop-to-alpha. Do not substitute one for the other. + +--- + +## 7. Pitfalls + +- Sending a `.bmp` / `.tif` to remove-background fails; those formats are + upscale-only. +- Revoice on an image is rejected by both the panel and the HTTP + `expected_kinds=("video",)` gate. +- `instruction` will not “preserve hair.” It is stored, not consumed. +- Two different `source` / `video_path` values fail with `409`, not a silent + pick-one. +- Gallery URLs look like `/api/v1/file/clip.mp4?workspace=default`. Keep the + query: it identifies the source folder. It is not part of the disk filename. +- Tools share the GPU lock with Studio generate. A long FlashVSR job blocks + the next generation until it finishes or is cancelled. diff --git a/docs/video-editor/HOWUSEIT.md b/docs/video-editor/HOWUSEIT.md index c119a2b62..b5ab581a7 100644 --- a/docs/video-editor/HOWUSEIT.md +++ b/docs/video-editor/HOWUSEIT.md @@ -6,7 +6,7 @@ This is not MiniMax H3 and not the 3D compositor. The editor **never regenerates UI tab: **Video Editor** (`mediaFilter: videoeditor`). Code: `ui/src/features/video-editor/`. Render: `app/services/video_editor.py`. HTTP: `app/_launch_runtime.py`. Mix kinds: `app/services/output_result_kind.py`. -Related: [3D Video compositor](../3d-video-compositor/HOWUSEIT.md) §5.8, [Workspaces / Director threads](../workspaces/HOWUSEIT.md). +Related: [Studio Tools](../tools/HOWUSEIT.md) (upscale / revoice / rembg), [3D Video compositor](../3d-video-compositor/HOWUSEIT.md) §5.8, [Workspaces / Director threads](../workspaces/HOWUSEIT.md). --- diff --git a/pinokio.js b/pinokio.js index 29794b2fd..8049dc72e 100644 --- a/pinokio.js +++ b/pinokio.js @@ -13,7 +13,6 @@ module.exports = { let running = { install: info.running("install.js"), start: info.running("start.js"), - start_classic: info.running("start_classic.js"), update: info.running("update.js"), ui_build: info.running("ui_build.js"), reset: info.running("reset.js") @@ -36,10 +35,6 @@ module.exports = { icon: "fa-solid fa-rocket", text: "Open Web UI", href: local.url, - }, { - icon: "fa-solid fa-rocket", - text: "Open Classic UI", - href: local.url + "/classic", }, { icon: 'fa-solid fa-terminal', text: "Terminal", @@ -52,26 +47,6 @@ module.exports = { href: "start.js", }] } - } else if (running.start_classic) { - let local = info.local("start_classic.js") - if (local && local.url) { - return [{ - default: true, - icon: "fa-solid fa-rocket", - text: "Open Classic UI", - href: local.url, - }, { - icon: 'fa-solid fa-terminal', - text: "Terminal", - href: "start_classic.js", - }] - } else { - return [{ - icon: 'fa-solid fa-terminal', - text: "Terminal", - href: "start_classic.js", - }] - } } else if (running.update) { return [{ default: true, @@ -91,38 +66,6 @@ module.exports = { icon: "fa-solid fa-power-off", text: "Start", href: "start.js", - }, { - icon: "fa-solid fa-display", - text: "Start (Classic UI)", - href: "start_classic.js", - }, { - icon: "fa-solid fa-power-off", - text: "Advanced", - menu: [{ - icon: "fa-solid fa-power-off", - text: "Compiled (Faster but may not work)", - href: "start.js", - params: { - compile: true - } - }, { - icon: "fa-solid fa-power-off", - text: "Classic Compiled", - href: "start_classic.js", - params: { - compile: true - } - }] - }, { - icon: "fa-regular fa-folder-open", - text: "T2V Loras (save lora files here)", - href: "app/loras", - fs: true - }, { - icon: "fa-regular fa-folder-open", - text: "I2V Loras (save lora files here)", - href: "app/loras_i2v", - fs: true }, { icon: "fa-solid fa-plug", text: "Update", @@ -133,32 +76,50 @@ module.exports = { href: "ui_build.js", params: {force: true}, }, { - icon: "fa-solid fa-plug", - text: "Install", - href: "install.js", - }, { - // Install / re-install the SAM 3.1 segmentation service - // (separate Python 3.12 conda env, takes ~5 min). Only - // needed for the experimental Inpaint feature in Edit - // mode — most users never need it, which is why install.js - // no longer runs sam_install.js automatically. Label flips - // to "Update Inpaint Support" once installed so users can - // refresh SAM independently of the main app update. - icon: "fa-solid fa-vector-square", - text: info.exists("app/services/sam/env") - ? "Update Inpaint Support (SAM 3.1)" - : "Install Inpaint Support (SAM 3.1)", - href: "sam_install.js", + icon: "fa-regular fa-folder-open", + text: "LoRAs", + menu: [{ + icon: "fa-regular fa-folder-open", + text: "T2V LoRAs", + href: "app/loras", + fs: true + }, { + icon: "fa-regular fa-folder-open", + text: "I2V LoRAs", + href: "app/loras_i2v", + fs: true + }] }, { - // Install / re-install the UniRig AI auto-rigging engine - // (separate Python 3.11 conda env; weights ~2GB download on - // first use; needs an NVIDIA GPU with 8GB+ VRAM). Optional: - // the Animate tab's procedural engine works without it. - icon: "fa-solid fa-person-running", - text: info.exists("app/services/rigging/env") - ? "Update AI Rigging (UniRig)" - : "Install AI Rigging (UniRig)", - href: "rigging_install.js", + icon: "fa-solid fa-ellipsis", + text: "Advanced", + menu: [{ + icon: "fa-solid fa-comment-dots", + text: "Repair offline lip sync", + href: "speech_install.js", + }, { + icon: "fa-solid fa-bolt", + text: "Start compiled (experimental)", + href: "start.js", + params: { + compile: true + } + }, ...((kernel.platform || require("os").platform()) === "darwin" ? [] : [{ + icon: "fa-solid fa-vector-square", + text: info.exists("app/services/sam/env") + ? "Update Inpaint Support (SAM 3.1)" + : "Install Inpaint Support (SAM 3.1)", + href: "sam_install.js", + }, { + icon: "fa-solid fa-person-running", + text: info.exists("app/services/rigging/env") + ? "Update AI Rigging (UniRig)" + : "Install AI Rigging (UniRig)", + href: "rigging_install.js", + }]), { + icon: "fa-solid fa-plug", + text: "Reinstall", + href: "install.js", + }] }, { icon: "fa-regular fa-circle-xmark", text: "
Reset
Revert to pre-install state
", diff --git a/runtime_install.js b/runtime_install.js index ed825f8af..1ce44bb9e 100644 --- a/runtime_install.js +++ b/runtime_install.js @@ -72,6 +72,16 @@ function startGuard() { }} } +function startGuards() { + return [ + startGuard(), + {when: "{{exists('app/.runtime/core.managed') && !exists('app/.runtime/wangp.managed')}}", method: 'shell.run', params: { + path: 'app', venv: 'env', env: {PYTHONNOUSERSITE: '1', PYTHONPATH: '', PYTHONHOME: ''}, + message: guarded('python ../scripts/runtime_probe.py --require-installed core'), + }}, + ] +} + function vendorSteps(id) { const vendor = vendors[id] if (!vendor) throw new Error(`Unknown vendor ${id}`) @@ -102,15 +112,19 @@ function engineSteps(engine, platform) { .map(k => `${k}==${spec[k]}+cu${spec.cuda.replace('.', '')}`).join(' ') const removals = engine === 'wangp' && platform === 'win32' ? [pip(engine, platform, 'uninstall torchcodec')] : [] - run.push({method: 'shell.run', params: {...shell(engine, platform), message: [ + const packages = [ ...removals, - pip(engine, platform, `install ${torch}`), + ...(torch ? [pip(engine, platform, `install ${torch}`)] : []), pip(engine, platform, `install -r app/runtime/locks/${platform}-${engine}.txt`), - ]}}) + ] + run.push({method: 'shell.run', params: {...shell(engine, platform), message: packages}}) const triton = spec.constraints['triton-windows'] if (platform === 'win32' && triton) run.push({method: 'shell.run', params: { ...shell(engine, platform), message: pip(engine, platform, `install triton-windows==${triton}`), }}) + if (engine === 'core') run.push({method: 'shell.run', params: { + message: guarded('conda install -y -c conda-forge ffmpeg'), + }}) if (engine === 'wangp') run.push(...call('torch.js', {managed: true})) if (engine === 'hunyuan3d') { run.push({method: 'shell.run', params: {...shell(engine, platform), @@ -156,4 +170,4 @@ function installEngines(names) { })))) } -module.exports = {catalog, selected, shell, python, pip, guarded, call, preflight, startGuard, vendorSteps, installEngines} +module.exports = {catalog, selected, shell, python, pip, guarded, call, preflight, startGuard, startGuards, vendorSteps, installEngines} diff --git a/runtime_setup.js b/runtime_setup.js index 31d5028dc..7469e6a3d 100644 --- a/runtime_setup.js +++ b/runtime_setup.js @@ -9,7 +9,8 @@ module.exports = { {when: "{{!exists('app/postprocessing/seedvc/__init__.py')}}", method: 'shell.run', params: { message: runtime.guarded('git clone --depth 1 --branch v1.0.0 https://github.com/Blizaine/maestro-seedvc app/postprocessing/seedvc'), }}, - ...runtime.installEngines(['wangp', 'hunyuan3d', 'minimax_h3']), + ...runtime.installEngines(['core', 'wangp', 'hunyuan3d', 'minimax_h3']), + ...runtime.call('speech_install.js'), ...runtime.call('sam_install.js').map(step => ({...step, when: `{{args.update && exists('app/services/sam/env') && local.runtime.engines.sam.supported${step.when ? ' && (' + step.when.slice(2,-2) + ')' : ''}}}`, })), diff --git a/scripts/check_code_health_pr_base.sh b/scripts/check_code_health_pr_base.sh index 69e352bca..ac448f005 100755 --- a/scripts/check_code_health_pr_base.sh +++ b/scripts/check_code_health_pr_base.sh @@ -55,6 +55,35 @@ fi echo "[code-health] HEAD=${HEAD_SHA:-unknown}" >&2 echo "[code-health] base=$BASE_SHA" >&2 +RELEASE_INTEGRATION=false +if [[ "${BASE_BRANCH:-}" == "main" && "${SOURCE_BRANCH:-}" == "development" \ + && -n "${GITHUB_REPOSITORY:-}" \ + && "${BASE_REPOSITORY:-}" == "$GITHUB_REPOSITORY" \ + && "${SOURCE_REPOSITORY:-}" == "$GITHUB_REPOSITORY" ]]; then + if [[ ! "${SOURCE_HEAD_SHA:-}" =~ ^[0-9a-f]{40}$ ]]; then + echo '[code-health] release requires the exact source HEAD SHA' >&2 + exit 2 + fi + if [[ "$(git -C "$ROOT" rev-parse --is-shallow-repository)" == "true" ]]; then + git -C "$ROOT" fetch --no-tags --unshallow origin "$SOURCE_HEAD_SHA" "$BASE_SHA" + fi + RELEASE_INTEGRATION=true +elif [[ "${GITHUB_EVENT_NAME:-}" == "push" && "${GITHUB_REF:-}" == "refs/heads/main" \ + && -n "${GITHUB_REPOSITORY:-}" && "${EVENT_REPOSITORY:-}" == "$GITHUB_REPOSITORY" \ + && "${GITHUB_SHA:-}" == "$HEAD_SHA" \ + && "$HEAD_SHA" == "$(git -C "$ROOT" rev-parse HEAD)" ]]; then + if [[ "$(git -C "$ROOT" rev-parse --is-shallow-repository)" == "true" ]]; then + git -C "$ROOT" fetch --no-tags --unshallow origin "$HEAD_SHA" "$BASE_SHA" + fi + git -C "$ROOT" fetch --no-tags origin refs/heads/development:refs/remotes/origin/development + SOURCE_HEAD_SHA="$(cd "$ROOT/scripts" && "$PYTHON" -c \ + 'import sys; from code_health_integration import main_push_source; print(main_push_source(*sys.argv[1:]) or "")' \ + "$BASE_SHA" "$HEAD_SHA" refs/remotes/origin/development)" + if [[ -n "$SOURCE_HEAD_SHA" ]]; then + RELEASE_INTEGRATION=true + fi +fi + BASE_PARENT="$(mktemp -d "${TMPDIR:-/tmp}/hocus-health-base.XXXXXX")" BASE_DIR="$BASE_PARENT/repo" cleanup() { @@ -75,7 +104,14 @@ if ! (cd "$BASE_DIR" && "$PYTHON" scripts/code_health.py --json) > "$BASE_DIR/co echo "[code-health] analyzer failed on base $BASE_SHA" >&2 exit 1 fi -"$PYTHON" "$ROOT/scripts/code_health.py" --check --markdown \ - --baseline "$BASE_DIR/code-health-base.json" \ - --score-baseline "$BASE_DIR/code-health-base.json" \ - --score-baseline-label "PR base" +if [[ "$RELEASE_INTEGRATION" == "true" ]]; then + "$PYTHON" "$ROOT/scripts/code_health_integration.py" \ + --base "$BASE_SHA" --head "$SOURCE_HEAD_SHA" \ + --baseline "$BASE_DIR/code-health-base.json" \ + --evidence "$ROOT/code-health-integration.json" +else + "$PYTHON" "$ROOT/scripts/code_health.py" --check --markdown \ + --baseline "$BASE_DIR/code-health-base.json" \ + --score-baseline "$BASE_DIR/code-health-base.json" \ + --score-baseline-label "PR base" +fi diff --git a/scripts/ci-python-requirements.txt b/scripts/ci-python-requirements.txt new file mode 100644 index 000000000..d94c1b3e0 --- /dev/null +++ b/scripts/ci-python-requirements.txt @@ -0,0 +1,27 @@ +# CI CPU test dependencies. Hashed into the pip cache key together with +# app/requirements.txt and app/runtime/locks/*.txt. Not a product lockfile. +starlette==0.46.1 +soundfile==0.13.1 +numpy==2.2.6 +opencv-python-headless==4.12.0.88 +Pillow==11.3.0 +requests==2.32.4 +accelerate==1.12.0 +av==16.1.0 +diffusers==0.36.0 +decord==0.6.0 +einops==0.8.2 +fastapi==0.115.12 +ffmpeg-python==0.2.0 +imageio==2.37.2 +imageio-ffmpeg==0.6.0 +json_repair==0.59.5 +mmgp==3.7.6 +onnxruntime==1.23.2 +pydantic==2.10.6 +psutil==7.2.2 +pytest==8.3.5 +rembg==2.0.65 +tqdm==4.67.3 +transformers==4.57.1 +websocket-client==1.9.0 diff --git a/scripts/ci-python-torch-cpu.txt b/scripts/ci-python-torch-cpu.txt new file mode 100644 index 000000000..7caa66ec3 --- /dev/null +++ b/scripts/ci-python-torch-cpu.txt @@ -0,0 +1,5 @@ +# CPU-only wheels for GitHub-hosted runners. Extra index is public. +--extra-index-url https://download.pytorch.org/whl/cpu +torch==2.7.0+cpu +torchaudio==2.7.0+cpu +torchvision==0.22.0+cpu diff --git a/scripts/ci-python-windows-requirements.txt b/scripts/ci-python-windows-requirements.txt new file mode 100644 index 000000000..44ec30127 --- /dev/null +++ b/scripts/ci-python-windows-requirements.txt @@ -0,0 +1,3 @@ +# Windows CI job: runtime/launcher tests before the UI E2E export checks. +pytest==8.3.5 +setuptools==80.9.0 diff --git a/scripts/ci_required.py b/scripts/ci_required.py index 01296e8af..dcf88e8c8 100644 --- a/scripts/ci_required.py +++ b/scripts/ci_required.py @@ -10,6 +10,16 @@ SUCCESS = "success" +# Display names must match job ``name:`` strings in .github/workflows/ci.yml. +REQUIRED_JOB_NAMES = ( + "Clean-repo guard + Python checks", + "Python tests A", + "Python tests B", + "UI tests + lint + type-check + build", + "UI E2E boot (Chromium + simulated API)", + "Speech E2E Windows (real H.264 + AAC)", +) + def evaluate(results: dict[str, str]) -> tuple[bool, list[str]]: if not results: @@ -22,6 +32,12 @@ def evaluate(results: dict[str, str]) -> tuple[bool, list[str]]: return (not failed), failed +def evaluate_required(results: dict[str, str]) -> tuple[bool, list[str]]: + """Fail closed if any required job is missing, skipped, cancelled or failed.""" + combined = {name: results.get(name, "") for name in REQUIRED_JOB_NAMES} + return evaluate(combined) + + def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument( @@ -41,7 +57,7 @@ def main(argv: list[str] | None = None) -> int: print(f"invalid result pair: {item}", file=sys.stderr) return 2 results[name] = value.strip() - ok, failed = evaluate(results) + ok, failed = evaluate_required(results) for name, value in results.items(): print(f"{name}: {value}") if not ok: diff --git a/scripts/ci_test_groups.json b/scripts/ci_test_groups.json new file mode 100644 index 000000000..a55df399b --- /dev/null +++ b/scripts/ci_test_groups.json @@ -0,0 +1,366 @@ +{ + "version": 1, + "suite": "tests", + "python_files": "test_*.py", + "weight_unit": "collected_nodeids", + "weight_note": "Greedy two-bin pack by pytest --collect-only nodeids on 780d3915 (3107 tests in 14.06s locally). CI #320 had no per-test durations; this is a count proxy, not wall-clock.", + "groups": [ + { + "id": "python-a", + "job": "python-tests-a", + "name": "Python tests A", + "weight": 1555, + "paths": [ + "tests/test_access_log_filter.py", + "tests/test_activity_generation_details.py", + "tests/test_analyze_pr.py", + "tests/test_app_identity.py", + "tests/test_architecture_contracts.py", + "tests/test_architecture_factory.py", + "tests/test_asset_catalog.py", + "tests/test_audio_analysis_cleanup.py", + "tests/test_audio_analysis_jobs.py", + "tests/test_audio_word_timestamps.py", + "tests/test_call_llm_json_grammar.py", + "tests/test_canonical_tasks_router.py", + "tests/test_character_face_patch.py", + "tests/test_character_kit_face_cleanup.py", + "tests/test_ci_required.py", + "tests/test_client_task_identity.py", + "tests/test_code_quality_score.py", + "tests/test_comic_director_json_recovery.py", + "tests/test_comic_movie_cinematic_adapter.py", + "tests/test_comic_plan_resume_contract.py", + "tests/test_comic_story_reference.py", + "tests/test_comic_video_preflight_pipeline.py", + "tests/test_comics_router.py", + "tests/test_director_cancellation.py", + "tests/test_director_minimax_llm_routing.py", + "tests/test_director_parallel_resources.py", + "tests/test_director_pipeline_observer.py", + "tests/test_director_pipeline_recovery.py", + "tests/test_director_pipeline_timing.py", + "tests/test_director_plan_jobs.py", + "tests/test_director_review.py", + "tests/test_director_stop_endpoint.py", + "tests/test_director_v2_story_refs.py", + "tests/test_execution_mode.py", + "tests/test_generation_provenance_submission.py", + "tests/test_generation_record.py", + "tests/test_generation_runtime.py", + "tests/test_h3_benchmark_client.py", + "tests/test_h3_director_dialogue.py", + "tests/test_h3_policy_language_audio_creative.py", + "tests/test_h3_preplan_job_contract.py", + "tests/test_h3_semantic_bridge.py", + "tests/test_image_generation_commands.py", + "tests/test_integration_audit_regressions.py", + "tests/test_job_lifecycle.py", + "tests/test_job_lifecycle_wiring.py", + "tests/test_labs_wizard_action_matrix.py", + "tests/test_lan_auth.py", + "tests/test_language_intent.py", + "tests/test_launcher_compatibility.py", + "tests/test_llm_request_cancellation.py", + "tests/test_ltx_gemma_prompt_batching.py", + "tests/test_ltx_prompt_queue.py", + "tests/test_media_path_security.py", + "tests/test_media_thumbnails.py", + "tests/test_minimax_h3_duration.py", + "tests/test_minimax_h3_fused_turbo.py", + "tests/test_minimax_h3_pdd.py", + "tests/test_minimax_h3_prompting.py", + "tests/test_minimax_song_writer_prompt.py", + "tests/test_model3d_external.py", + "tests/test_model_cache_job_guards.py", + "tests/test_model_selection_persistence.py", + "tests/test_music_model_contract.py", + "tests/test_operation_logging.py", + "tests/test_output_completion_time.py", + "tests/test_output_move_security.py", + "tests/test_pass2_descriptor_canonicalize.py", + "tests/test_pass3_storyboard_skip.py", + "tests/test_phase1_issue_fixes.py", + "tests/test_prompt_polish_fixes.py", + "tests/test_provider_profile.py", + "tests/test_provider_ternary_freeze.py", + "tests/test_publish_pr_markdown.py", + "tests/test_qa_evidence.py", + "tests/test_qa_provenance.py", + "tests/test_quick_video_batches.py", + "tests/test_rembg_adapter.py", + "tests/test_runtime_profiles.py", + "tests/test_scail2_dedicated_model.py", + "tests/test_scene3d_profiles.py", + "tests/test_scene3d_speech_profile_digest.py", + "tests/test_scene_library.py", + "tests/test_scene_packages.py", + "tests/test_scene_recording.py", + "tests/test_select_local_tests.py", + "tests/test_series_assembly.py", + "tests/test_series_assembly_contract.py", + "tests/test_series_episode_update_router.py", + "tests/test_series_lab_ui.py", + "tests/test_series_library.py", + "tests/test_series_lifecycle.py", + "tests/test_series_reference_router.py", + "tests/test_series_render.py", + "tests/test_sex_act_leet_strip.py", + "tests/test_spoken_language_contract.py", + "tests/test_story_lab_id_repair.py", + "tests/test_story_lab_music_plan.py", + "tests/test_studio_image_asset_ids.py", + "tests/test_studio_image_commands.py", + "tests/test_studio_image_native_boundary.py", + "tests/test_studio_image_resources.py", + "tests/test_studio_image_spec.py", + "tests/test_studio_music_preparation.py", + "tests/test_studio_sfx_execution.py", + "tests/test_studio_sfx_native_worker.py", + "tests/test_studio_speech_preparation.py", + "tests/test_studio_speech_resources.py", + "tests/test_studio_speech_runtime_review.py", + "tests/test_studio_video_commands.py", + "tests/test_system_memory_profiles.py", + "tests/test_task_command_admission.py", + "tests/test_task_snapshot_cursor.py", + "tests/test_temporal_depth_assets.py", + "tests/test_tools_command_runtime.py", + "tests/test_ui_distribution.py", + "tests/test_validate_local_wrapper.py", + "tests/test_video_decode_metadata.py", + "tests/test_video_editor_replacement_ui.py", + "tests/test_video_editor_scheduler_jobs.py", + "tests/test_video_editor_soundtrack.py", + "tests/test_video_editor_time_cards.py", + "tests/test_video_generation_commands.py", + "tests/test_video_generation_spec.py", + "tests/test_voice_reference_settings.py", + "tests/test_wangp1272_runtime.py", + "tests/test_wizard_conversations.py", + "tests/test_wizard_mcp_corpus.py", + "tests/test_wizard_workflows.py", + "tests/test_workspace_collections_router.py", + "tests/test_workspace_commands.py", + "tests/test_workspace_registry.py", + "tests/test_world3d_owned_render_smoke.py" + ] + }, + { + "id": "python-b", + "job": "python-tests-b", + "name": "Python tests B", + "weight": 1566, + "paths": [ + "tests/test_acceptance_runner.py", + "tests/test_alternative_songs.py", + "tests/test_api_workspace_security.py", + "tests/test_architecture_graph.py", + "tests/test_asset_manifest.py", + "tests/test_assets_router.py", + "tests/test_cancellable_model_downloads.py", + "tests/test_character_kit_library.py", + "tests/test_character_sheet.py", + "tests/test_character_speech_definition.py", + "tests/test_ci_shards.py", + "tests/test_code_health.py", + "tests/test_core_runtime.py", + "tests/test_core_series_assembly.py", + "tests/test_debug_trace.py", + "tests/test_development_branch_policy.py", + "tests/test_director_h3_identity_continuity.py", + "tests/test_director_h3_workflow_edits.py", + "tests/test_director_i2v_preparation.py", + "tests/test_director_minimax_h3.py", + "tests/test_director_model_compat.py", + "tests/test_director_pipeline_state.py", + "tests/test_director_pipeline_status.py", + "tests/test_director_resume_endpoint.py", + "tests/test_director_task_adapter.py", + "tests/test_director_workspace_queue.py", + "tests/test_download_state.py", + "tests/test_durable_generation_queue.py", + "tests/test_generation_task_timing.py", + "tests/test_h3_adoption.py", + "tests/test_h3_advanced_convrot.py", + "tests/test_h3_guide_resolution.py", + "tests/test_h3_prompt_finalization.py", + "tests/test_h3_window_planner.py", + "tests/test_hunyuan_dit_cache.py", + "tests/test_image_command_restart_boundaries.py", + "tests/test_image_generation_spec.py", + "tests/test_krea2_features.py", + "tests/test_live_stats.py", + "tests/test_llm_activity_tracking.py", + "tests/test_llm_resource_scheduling.py", + "tests/test_llm_router.py", + "tests/test_lyrics_language.py", + "tests/test_mcp_access.py", + "tests/test_media_refs.py", + "tests/test_merge_eligibility.py", + "tests/test_minimax_h3.py", + "tests/test_minimax_h3_service.py", + "tests/test_minimax_h3_sol_engine.py", + "tests/test_minimax_image_jobs.py", + "tests/test_minimax_image_service.py", + "tests/test_minimax_music3_local.py", + "tests/test_minimax_music_jobs.py", + "tests/test_minimax_music_service.py", + "tests/test_mix_concat.py", + "tests/test_model3d_hang_guards.py", + "tests/test_model3d_rig_spawn_cancellation.py", + "tests/test_model3d_rig_task_identity.py", + "tests/test_music_finalization.py", + "tests/test_music_p5_http.py", + "tests/test_music_submission.py", + "tests/test_music_video_pacing.py", + "tests/test_nightly_wizard_report.py", + "tests/test_openai_compatible_comic_llm.py", + "tests/test_output_result_kind.py", + "tests/test_pass2_duration_buckets.py", + "tests/test_pass2_duration_enforcement.py", + "tests/test_pass2_fragmentation_merge.py", + "tests/test_perf_recommend_pass_scale.py", + "tests/test_platform_capabilities.py", + "tests/test_polish_dialogue_and_hallucination.py", + "tests/test_procedural_3d_assets_router.py", + "tests/test_procedural_glb_inspector.py", + "tests/test_production_run.py", + "tests/test_productions_router.py", + "tests/test_project_catalog.py", + "tests/test_projects_router.py", + "tests/test_provenance_3d_director.py", + "tests/test_recipes_router.py", + "tests/test_remove_background_tool.py", + "tests/test_resource_scheduler.py", + "tests/test_safety_scan.py", + "tests/test_scail2_workflows.py", + "tests/test_scene3d_speech.py", + "tests/test_scene_effect_commands.py", + "tests/test_series_assembly_router.py", + "tests/test_series_jobs.py", + "tests/test_series_planning.py", + "tests/test_series_production.py", + "tests/test_series_shot_dialogue.py", + "tests/test_shared_api_bootstrap.py", + "tests/test_speech_analysis_cache.py", + "tests/test_speech_quality.py", + "tests/test_story_asset_import.py", + "tests/test_story_lab_audio_ui.py", + "tests/test_story_lab_resume_provider.py", + "tests/test_story_lab_trailer_ui.py", + "tests/test_story_library.py", + "tests/test_story_montage_clip_history_ui.py", + "tests/test_storyboard_camera_name_strip.py", + "tests/test_studio_image_preparation.py", + "tests/test_studio_music_commands.py", + "tests/test_studio_music_spec.py", + "tests/test_studio_sfx_commands.py", + "tests/test_studio_sfx_preparation.py", + "tests/test_studio_sfx_resources.py", + "tests/test_studio_sfx_spec.py", + "tests/test_studio_speech_commands.py", + "tests/test_studio_speech_spec.py", + "tests/test_style_library.py", + "tests/test_task_adapter_helpers.py", + "tests/test_task_maintenance.py", + "tests/test_task_manager.py", + "tests/test_task_manager_active_listing.py", + "tests/test_text_encoder_cache.py", + "tests/test_tools_upscale_contract.py", + "tests/test_tools_upscale_preparation.py", + "tests/test_tools_upscale_spec.py", + "tests/test_upload_streaming.py", + "tests/test_user_diagnostics.py", + "tests/test_video_editor_animatic.py", + "tests/test_video_editor_concat_regression.py", + "tests/test_video_editor_frame_accounting.py", + "tests/test_video_editor_lipsync_timing.py", + "tests/test_video_editor_preview_canvas.py", + "tests/test_video_editor_provenance.py", + "tests/test_video_editor_router.py", + "tests/test_video_extra_info.py", + "tests/test_vocal_isolation.py", + "tests/test_wangp_analysis.py", + "tests/test_wangp_frame_extraction.py", + "tests/test_wangp_mcp.py", + "tests/test_wangp_submission.py", + "tests/test_wizard_workflow_executor.py", + "tests/test_workspace_command_failures.py", + "tests/test_world3d_export.py" + ] + } + ], + "path_rules": [ + { + "prefix": "app/services/user_diagnostics.py", + "paths": [ + "tests/test_user_diagnostics.py" + ] + }, + { + "prefix": "app/routers/user_diagnostics.py", + "paths": [ + "tests/test_user_diagnostics.py" + ] + }, + { + "prefix": "scripts/ci_required.py", + "paths": [ + "tests/test_ci_required.py" + ] + }, + { + "prefix": "scripts/select_local_tests.py", + "paths": [ + "tests/test_select_local_tests.py" + ] + }, + { + "prefix": "scripts/ci_test_groups.json", + "paths": [ + "tests/test_ci_shards.py", + "tests/test_select_local_tests.py" + ] + }, + { + "prefix": "scripts/ci-python-requirements.txt", + "paths": [ + "tests/test_ci_shards.py" + ] + }, + { + "prefix": "scripts/ci-python-torch-cpu.txt", + "paths": [ + "tests/test_ci_shards.py" + ] + }, + { + "prefix": "scripts/ci-python-windows-requirements.txt", + "paths": [ + "tests/test_ci_shards.py" + ] + }, + { + "prefix": ".github/workflows/ci.yml", + "paths": [ + "tests/test_ci_required.py", + "tests/test_ci_shards.py", + "tests/test_development_branch_policy.py" + ] + }, + { + "prefix": "docs/development/CI_CACHE_AND_SHARDS.md", + "paths": [ + "tests/test_ci_shards.py", + "tests/test_select_local_tests.py" + ] + }, + { + "prefix": "docs/development/AGENT_QA_POLICY.md", + "paths": [ + "tests/test_development_branch_policy.py" + ] + } + ] +} diff --git a/scripts/code_health_integration.py b/scripts/code_health_integration.py new file mode 100644 index 000000000..25d0d4c09 --- /dev/null +++ b/scripts/code_health_integration.py @@ -0,0 +1,263 @@ +#!/usr/bin/env python3 +"""Verify cumulative release budgets without relaxing current code-health limits.""" +from __future__ import annotations + +import argparse +import json +import re +import shutil +import subprocess +import tempfile +from pathlib import Path + +import code_health as health + +ROOT = Path(__file__).resolve().parents[1] +MEASUREMENT_INPUTS = ( + "scripts/code_health.py", "scripts/code_quality_score.py", + "ui/eslint.config.js", "ui/package.json", "ui/package-lock.json", +) +AGGREGATE_PREFIXES = ("production LOC grew ", "high-complexity function count grew ") + + +def git(*args: str) -> str: + return subprocess.check_output(["git", "-C", str(ROOT), *args], text=True).strip() + + +def tree_entries(sha: str) -> list[tuple[str, str, str, str]]: + output = subprocess.check_output(["git", "-C", str(ROOT), "ls-tree", "-r", "-z", sha]) + entries = [] + for entry in output.decode("utf-8").split("\0"): + if entry: + metadata, path = entry.split("\t", 1) + mode, kind, blob = metadata.split() + entries.append((mode, kind, blob, path)) + return entries + + +def main_push_source(base: str, head: str, development: str) -> str | None: + """Recognize only an unchanged development tree published by a merge commit.""" + if not all(re.fullmatch(r"[0-9a-f]{40}", sha) for sha in (base, head)): + return None + if git("rev-parse", "HEAD") != head: + return None + parents = git("rev-list", "--parents", "-n", "1", head).split() + if len(parents) != 3 or parents[0] != head or parents[1] != base: + return None + source = parents[2] + if git("rev-parse", f"{head}^{{tree}}") != git("rev-parse", f"{source}^{{tree}}"): + return None + if subprocess.run( + ["git", "-C", str(ROOT), "merge-base", "--is-ancestor", source, development], + capture_output=True, + ).returncode != 0: + return None + return source + + +def measurement_manifest(source: str) -> str: + manifest = json.loads(source) + # The directly invoked ESLint scanner does not run the test command. + # Lifecycle/install hooks remain inputs because they can modify dependencies. + manifest.get("scripts", {}).pop("test", None) + return json.dumps(manifest, sort_keys=True) + + +def release_chain(base: str, head: str) -> list[str]: + """Require complete history and a release tree identical to its fork point.""" + if not all(re.fullmatch(r"[0-9a-f]{40}", sha) for sha in (base, head)): + raise ValueError("Release base/head must be exact commit SHAs") + if git("rev-parse", "--is-shallow-repository") != "false": + raise ValueError("Release verification requires complete history") + common = git("merge-base", base, head) + if git("rev-parse", f"{base}^{{tree}}") != git("rev-parse", f"{common}^{{tree}}"): + raise ValueError("Release base tree differs from the integration merge-base") + if git("rev-parse", "HEAD^{tree}") != git("rev-parse", f"{head}^{{tree}}"): + raise ValueError("Checked-out candidate tree differs from the source HEAD") + dirty = git("diff", "HEAD", "--name-only", "--", "app", "ui/src", *MEASUREMENT_INPUTS) + if dirty: + raise ValueError("Commit candidate changes before verifying release history") + commits = git("rev-list", "--first-parent", "--reverse", f"{common}..{head}").splitlines() + previous = common + for commit in commits: + if git("rev-parse", f"{commit}^1") != previous: + raise ValueError("Integration history has a gap in its first-parent chain") + previous = commit + if previous != head: + raise ValueError("Integration history does not reach the requested HEAD") + return [base, *commits] + + +def read_trees(chain: list[str]) -> tuple[list[dict[str, str]], dict[str, str]]: + trees, blobs, inputs = [], set(), None + for sha in chain: + tree = {} + measurement = {} + for mode, kind, blob, path in tree_entries(sha): + if path in MEASUREMENT_INPUTS: + measurement[path] = blob + if health._is_product(path): + if kind != "blob" or mode not in {"100644", "100755"}: + raise ValueError(f"Unsupported product entry at {sha}: {path}") + tree[path] = blob + blobs.add(blob) + if set(measurement) != set(MEASUREMENT_INPUTS): + raise ValueError(f"Missing measurement inputs at {sha}") + measurement["ui/package.json"] = measurement_manifest(git("show", f"{sha}:ui/package.json")) + if inputs is not None and measurement != inputs: + raise ValueError(f"Policy, analyzer or UI measurement inputs changed at {sha}") + inputs = measurement + trees.append(tree) + batch = subprocess.run( + ["git", "-C", str(ROOT), "cat-file", "--batch"], + input="\n".join(sorted(blobs)).encode() + b"\n", capture_output=True, check=True, + ).stdout + sources, offset = {}, 0 + for expected in sorted(blobs): + end = batch.index(b"\n", offset) + actual, kind, size = batch[offset:end].decode().split() + if actual != expected or kind != "blob": + raise ValueError(f"Missing source blob: {expected}") + offset = end + 1 + sources[expected] = batch[offset:offset + int(size)].decode("utf-8") + offset += int(size) + 1 + return trees, sources + + +def source_complexity(trees: list[dict[str, str]], sources: dict[str, str]) -> dict: + """Measure each unique source once with the same AST/ESLint rules as the gate.""" + metrics = {} + with tempfile.TemporaryDirectory(prefix="hocus-release-health-") as temporary: + folder = Path(temporary) + (folder / "src").mkdir() + # Resolve the installed packages without reinstalling or modifying them. + (folder / "node_modules").symlink_to(ROOT / "ui/node_modules", target_is_directory=True) + shutil.copy(ROOT / "ui/eslint.config.js", folder / "eslint.config.js") + (folder / "package.json").write_text('{"type":"module"}', encoding="utf-8") + for tree in trees: + for path, blob in tree.items(): + suffix = Path(path).suffix + key = (blob, suffix) + if key in metrics: + continue + if suffix == ".py": + collector = health._PythonFunctionCollector(path) + collector.visit(health.ast.parse(sources[blob], filename=path)) + metrics[key] = [item.complexity for item in collector.metrics] + else: + (folder / "src" / f"{blob}{suffix}").write_text(sources[blob], encoding="utf-8") + metrics[key] = [] + result = subprocess.run( + ["node", str(ROOT / "ui/node_modules/eslint/bin/eslint.js"), "src", "--format", "json", + "--rule", 'complexity: ["error", 0]'], cwd=folder, text=True, capture_output=True, + ) + if result.returncode not in {0, 1}: + raise ValueError(f"Historical UI measurement failed: {result.stderr.strip()}") + seen = set() + for report in json.loads(result.stdout): + path = Path(report["filePath"]) + key = (path.stem, path.suffix) + if key not in metrics: + raise ValueError(f"Unexpected UI measurement: {path.name}") + seen.add(key) + for message in report["messages"]: + if message.get("fatal"): + raise ValueError(f"Historical source cannot be parsed: {path.name}") + if message.get("ruleId") == "complexity": + match = re.search(r"complexity of (\d+)", message["message"]) + if not match: + raise ValueError("Unrecognized ESLint complexity result") + metrics[key].append(int(match.group(1))) + if seen != {key for key in metrics if key[1] != ".py"}: + raise ValueError("Historical UI measurement omitted source files") + return metrics + + +def checkpoint(tree: dict[str, str], sources: dict[str, str], metrics: dict) -> dict: + lines = {path: len(sources[blob].splitlines()) for path, blob in tree.items()} + functions = {path: metrics[(blob, Path(path).suffix)] for path, blob in tree.items()} + values = [value for items in functions.values() for value in items] + return { + "policy": health.POLICY, "policy_version": health.POLICY_VERSION, + "measurement": {"ui": "complete"}, "product_paths": sorted(tree), + "summary": { + "production_lines": sum(lines.values()), + "complex_functions": sum(value >= health.COMPLEXITY_WARNING for value in values), + "max_complexity": max(values, default=0), "functions_measured": len(values), + }, + "hotspots": {path: count for path, count in lines.items() if count >= health.HOTSPOT_LINES}, + "complexity_hotspots": { + path: max(items) for path, items in functions.items() + if items and max(items) >= health.COMPLEXITY_WARNING + }, + } + + +def compare_release(current: dict, baseline: dict, chain: list[str], reports: list[dict]) -> tuple[list[str], list[str], list]: + """Check aggregate budgets at every step and every local rule on the final tree.""" + if len(chain) != len(reports) or not reports: + raise ValueError("Missing integration checkpoint reports") + for expected, measured in ((baseline, reports[0]), (current, reports[-1])): + for key in ("production_lines", "complex_functions", "max_complexity", "functions_measured"): + if expected.get("summary", {}).get(key) != measured["summary"][key]: + raise ValueError(f"Historical and full-tree measurements disagree: {key}") + for key in ("product_paths", "hotspots", "complexity_hotspots"): + if expected.get(key) != measured[key]: + raise ValueError(f"Historical and full-tree measurements disagree: {key}") + warnings, total_failures = health.compare(current, baseline) + failures = [item for item in total_failures if not item.startswith(AGGREGATE_PREFIXES)] + historical = [] + for before, after, sha in zip(reports, reports[1:], chain[1:]): + _, step_failures = health.compare(after, before) + aggregate = [item for item in step_failures if item.startswith(AGGREGATE_PREFIXES)] + failures.extend(f"Integration {sha}: {item}" for item in aggregate) + if step_failures: + historical.append({"sha": sha, "aggregate_failures": aggregate, + "local_findings": [item for item in step_failures if item not in aggregate]}) + return warnings, failures, historical + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--base", required=True) + parser.add_argument("--head", required=True) + parser.add_argument("--baseline", required=True, type=Path) + parser.add_argument("--evidence", type=Path) + args = parser.parse_args() + try: + chain = release_chain(args.base, args.head) + baseline = json.loads(args.baseline.read_text(encoding="utf-8")) + current = health.collect(require_ui=True) + trees, sources = read_trees(chain) + metrics = source_complexity(trees, sources) + reports = [checkpoint(tree, sources, metrics) for tree in trees] + warnings, failures, historical = compare_release(current, baseline, chain, reports) + if args.evidence: + args.evidence.write_text(json.dumps({ + "base": args.base, "source_head": args.head, + "policy": health.POLICY, "failures": failures, "historical_findings": historical, + "checkpoints": [ + {"sha": sha, "tree": git("rev-parse", f"{sha}^{{tree}}"), **report["summary"]} + for sha, report in zip(chain, reports) + ], + }, indent=2) + "\n", encoding="utf-8") + except (OSError, ValueError, RuntimeError, subprocess.CalledProcessError) as error: + print(f"FAIL: release code-health verification: {error}") + return 2 + print(health._markdown_report(current, baseline, warnings, failures, score_baseline_label="PR base"), end="") + print("\n### Release integration budgets\n") + print(f"Base `{args.base}` → source HEAD `{args.head}`; **{len(chain) - 1} verified first-parent transitions**.") + print("LOC and complex-function growth use the unchanged budget at every transition. " + "All other limits compare the complete current tree with the release base. " + "The cumulative deltas above remain visible; no baseline or exception is changed.") + for item in historical: + for finding in item["local_findings"]: + print(f"- Historical local finding at `{item['sha']}`: {finding}. " + "Current local limits are checked against the release base above.") + if failures: + print("\n**Release verification failed.**") + return int(bool(failures)) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/prepare_mouth_presets.py b/scripts/prepare_mouth_presets.py new file mode 100644 index 000000000..31023b7fb --- /dev/null +++ b/scripts/prepare_mouth_presets.py @@ -0,0 +1,68 @@ +"""Slice generated 3×3 atlases into aligned PNG assets; never redraw the artwork. + +Input JSON: {specs: [[id, label, art brief], ...], paths: {id: atlas.png}}. +All nine cells share one scale and use centered pivots on 512px alpha canvases. +""" +import hashlib +import json +from pathlib import Path +import shutil +import sys + +from PIL import Image + +STATES = ("closed", "small", "wide", "round", "pressed", "medium", "pucker", "bite", "tongue") +ROOT = Path(__file__).resolve().parents[1] + + +def slices(path): + with Image.open(path) as image: + image = image.convert("RGBA") + cells = [] + for i in range(9): + x, y = i % 3, i // 3 + cell = image.crop((round(x * image.width / 3), round(y * image.height / 3), + round((x + 1) * image.width / 3), round((y + 1) * image.height / 3))) + # Ignore imperceptible alpha specks when finding the sprite frame. + bounds = cell.getchannel("A").point(lambda alpha: 255 if alpha >= 8 else 0).getbbox() + if bounds is None: + raise ValueError(f"Empty mouth cell {i} in {path}") + cells.append(cell.crop(bounds)) + return cells + + +def pack_images(path, destination): + cells = slices(path) + ratio = 448 / max(max(cell.size) for cell in cells) + destination.mkdir(parents=True, exist_ok=True) + for state, cell in zip(STATES, cells): + size = tuple(max(1, round(value * ratio)) for value in cell.size) + scaled = cell.resize(size, Image.Resampling.LANCZOS) + sprite = Image.new("RGBA", (512, 512)) + sprite.alpha_composite(scaled, ((512 - size[0]) // 2, (512 - size[1]) // 2)) + sprite.save(destination / f"{state}.png", optimize=True) + + +def build(specification): + root = ROOT / "ui/public/character-kit-presets/mouths" + manifest = json.loads((root / "manifest.json").read_text()) + new_ids = {row[0] for row in specification["specs"]} + packs = [item for item in manifest["packs"] if item["id"] not in new_ids] + for identifier, label, brief in specification["specs"]: + source = Path(specification["paths"][identifier]) + pack_images(source, root / identifier) + packs.append({"id": identifier, "label": label, "style": "cutout", "collection": "studio-20", + "notes": brief + ". Nine phonetic positions, transparent PNG, shared 512px frame and pivot.", + "provenance": {"tool": "OpenAI imagegen", "atlasSha256": hashlib.sha256(source.read_bytes()).hexdigest()}, + "states": {state: {"file": f"{identifier}/{state}.png", "width": 512, "height": 512} for state in STATES}}) + manifest.update(states=list(STATES), packs=packs) + (root / "manifest.json").write_text(json.dumps(manifest, indent=2) + "\n") + mirror = ROOT / "app/character_kit_presets/mouths" + for identifier in new_ids: + shutil.copytree(root / identifier, mirror / identifier, dirs_exist_ok=True) + shutil.copy2(root / "manifest.json", mirror / "manifest.json") + print(f"Prepared {len(new_ids)} packs, {len(new_ids) * len(STATES)} aligned sprites.") + + +if __name__ == "__main__": + build(json.loads(Path(sys.argv[1]).read_text())) diff --git a/scripts/runtime_verify.py b/scripts/runtime_verify.py index 60ab03eef..04a953a7e 100644 --- a/scripts/runtime_verify.py +++ b/scripts/runtime_verify.py @@ -36,6 +36,11 @@ def verify(engine: str, *, cuda: bool = True) -> dict: if not sources_current(spec.get("vendors", [])): raise RuntimeError(f"{engine}: pinned source checkout is incomplete or has a different revision") installed = inspect_environment(engine) + if not spec.get("cuda"): + return {"engine": engine, "profile": spec["id"], "python": spec["python"], + "prefix": str((ROOT / spec["env"]).resolve()), "packages": installed, "cuda": None, + "cudaCalculation": False, "modelsExecuted": False, + "fingerprint": dependency_fingerprint(engine, sys.platform)} torch = importlib.import_module("torch") if torch.version.cuda != spec["cuda"]: raise RuntimeError(f"{engine}: expected CUDA {spec['cuda']} wheel; got {torch.version.cuda}") diff --git a/scripts/select_local_tests.py b/scripts/select_local_tests.py new file mode 100644 index 000000000..cf5bcff97 --- /dev/null +++ b/scripts/select_local_tests.py @@ -0,0 +1,352 @@ +#!/usr/bin/env python3 +"""Select pytest targets for local runs and CI shards. + +Unknown or empty inputs fail closed: they never produce an empty suite. +CI ``--group`` also refuses a broken partition so a shard cannot silently +drop tests. +""" +from __future__ import annotations + +import argparse +import json +import subprocess +import sys +from pathlib import Path +from typing import Any + +ROOT = Path(__file__).resolve().parents[1] +DEFAULT_MANIFEST = ROOT / "scripts" / "ci_test_groups.json" + + +class ManifestError(ValueError): + """Invalid or unusable shard manifest.""" + + +def posix_path(path: str | Path) -> str: + text = str(path).replace("\\", "/") + while text.startswith("./"): + text = text[2:] + return text + + +def discover_suite_files(root: Path) -> list[str]: + """Automated pytest modules under tests/ (pytest python_files test_*.py).""" + tests = root / "tests" + if not tests.is_dir(): + return [] + return sorted( + path.relative_to(root).as_posix() + for path in tests.rglob("test_*.py") + if path.is_file() + ) + + +def load_manifest(path: Path) -> dict[str, Any]: + if not path.is_file(): + raise ManifestError(f"missing manifest: {path}") + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + raise ManifestError(f"invalid manifest JSON: {path}: {exc}") from exc + if not isinstance(payload, dict): + raise ManifestError("manifest must be an object") + groups = payload.get("groups") + if not isinstance(groups, list) or not groups: + raise ManifestError("manifest groups must be a non-empty list") + seen_ids: set[str] = set() + for group in groups: + if not isinstance(group, dict): + raise ManifestError("each group must be an object") + group_id = group.get("id") + paths = group.get("paths") + if not isinstance(group_id, str) or not group_id: + raise ManifestError("group id is required") + if group_id in seen_ids: + raise ManifestError(f"duplicate group id: {group_id}") + seen_ids.add(group_id) + if not isinstance(paths, list) or not paths: + raise ManifestError(f"group {group_id} has no paths") + if any(not isinstance(item, str) or not item for item in paths): + raise ManifestError(f"group {group_id} paths must be non-empty strings") + rules = payload.get("path_rules") or [] + if not isinstance(rules, list): + raise ManifestError("path_rules must be a list") + for rule in rules: + if not isinstance(rule, dict) or not isinstance(rule.get("prefix"), str): + raise ManifestError("path rule prefix is required") + paths = rule.get("paths") + if not isinstance(paths, list) or not paths: + raise ManifestError(f"path rule {rule.get('prefix')!r} has no paths") + return payload + + +def grouped_paths(manifest: dict[str, Any]) -> list[str]: + paths: list[str] = [] + seen: set[str] = set() + for group in manifest["groups"]: + for item in group["paths"]: + path = posix_path(item) + if path not in seen: + seen.add(path) + paths.append(path) + return paths + + +def group_by_id(manifest: dict[str, Any]) -> dict[str, dict[str, Any]]: + return {str(group["id"]): group for group in manifest["groups"]} + + +def partition_errors(root: Path, manifest: dict[str, Any]) -> list[str]: + discovered = discover_suite_files(root) + discovered_set = set(discovered) + owner: dict[str, str] = {} + overlap: list[str] = [] + extra: list[str] = [] + for group in manifest["groups"]: + group_id = str(group["id"]) + for raw in group["paths"]: + path = posix_path(raw) + if path in owner: + overlap.append(path) + else: + owner[path] = group_id + if path not in discovered_set: + extra.append(path) + missing = [path for path in discovered if path not in owner] + errors: list[str] = [] + if missing: + errors.append("missing from groups: " + ", ".join(missing)) + if extra: + errors.append("not in automated suite: " + ", ".join(extra)) + if overlap: + unique = [] + seen: set[str] = set() + for path in overlap: + if path not in seen: + seen.add(path) + unique.append(path) + errors.append("in multiple groups: " + ", ".join(unique)) + return errors + + +def check_partition(root: Path, manifest: dict[str, Any]) -> None: + errors = partition_errors(root, manifest) + if errors: + raise ManifestError("; ".join(errors)) + + +def full_suite_paths(root: Path, manifest: dict[str, Any]) -> list[str]: + """Union of groups when the partition is exact; otherwise ``tests``.""" + if partition_errors(root, manifest): + return ["tests"] + paths = sorted(grouped_paths(manifest)) + return paths if paths else ["tests"] + + +def _prefix_matches(path: str, prefix: str) -> bool: + prefix = posix_path(prefix) + path = posix_path(path) + if path == prefix: + return True + if prefix.endswith("/"): + return path.startswith(prefix) + return path.startswith(prefix + "/") + + +def match_rule_paths(path: str, manifest: dict[str, Any]) -> list[str] | None: + best: list[str] | None = None + best_len = -1 + for rule in manifest.get("path_rules") or []: + prefix = posix_path(rule["prefix"]) + if _prefix_matches(path, prefix) and len(prefix) > best_len: + best = [posix_path(item) for item in rule["paths"]] + best_len = len(prefix) + return best + + +def unique_paths(paths: list[str]) -> list[str]: + seen: set[str] = set() + ordered: list[str] = [] + for path in paths: + item = posix_path(path) + if item not in seen: + seen.add(item) + ordered.append(item) + return ordered + + +def select_paths( + changed: list[str], + root: Path, + manifest: dict[str, Any], +) -> tuple[list[str], str, list[str]]: + """Return ``(pytest_args, reason, unknown_paths)``. + + Reasons: + - ``mapped``: every input resolved to test paths + - ``unknown-path``: at least one input had no mapping (full suite) + - ``empty-input``: no paths given (full suite) + - ``empty-match``: mapping produced nothing (full suite) + """ + grouped = set(grouped_paths(manifest)) + if not changed: + return full_suite_paths(root, manifest), "empty-input", [] + targets: list[str] = [] + unknown: list[str] = [] + for raw in changed: + path = posix_path(raw) + if path in grouped: + targets.append(path) + continue + mapped = match_rule_paths(path, manifest) + if mapped is None: + unknown.append(path) + else: + targets.extend(mapped) + if unknown: + return full_suite_paths(root, manifest), "unknown-path", unknown + targets = unique_paths(targets) + if not targets: + return full_suite_paths(root, manifest), "empty-match", [] + return targets, "mapped", [] + + +def ungrouped_suite_files(root: Path, manifest: dict[str, Any]) -> list[str]: + """Automated tests that the committed manifest does not list yet.""" + owned: set[str] = set() + for group in manifest["groups"]: + owned.update(posix_path(item) for item in group["paths"]) + return [path for path in discover_suite_files(root) if path not in owned] + + +def group_paths(group_id: str, root: Path, manifest: dict[str, Any]) -> list[str]: + """Return one shard. Ungrouped suite files go to the first group. + + ``--check-partition`` still fails on a stale manifest. ``--group`` must + not drop those files: a new test module would otherwise turn every PR red + and skip the tests. + """ + groups = group_by_id(manifest) + if group_id not in groups: + known = ", ".join(sorted(groups)) + raise ManifestError(f"unknown group {group_id!r}; known: {known}") + paths = unique_paths([posix_path(item) for item in groups[group_id]["paths"]]) + first_id = str(manifest["groups"][0]["id"]) + extra = ungrouped_suite_files(root, manifest) if group_id == first_id else [] + if extra: + print( + "select_local_tests: assigning ungrouped files to " + + group_id + + ": " + + ", ".join(extra), + file=sys.stderr, + ) + paths = unique_paths(paths + extra) + if not paths: + raise ManifestError(f"empty group {group_id!r}") + return paths + + +def _print_paths(paths: list[str]) -> None: + sys.stdout.write("\n".join(paths) + ("\n" if paths else "")) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "paths", + nargs="*", + help="Changed files. Unknown paths select the full suite.", + ) + parser.add_argument("--manifest", type=Path, default=DEFAULT_MANIFEST) + parser.add_argument("--root", type=Path, default=ROOT) + parser.add_argument("--group", help="Emit one CI shard after a partition check") + parser.add_argument( + "--check-partition", + action="store_true", + help="Exit 2 if groups do not partition the automated suite", + ) + parser.add_argument( + "--json", + action="store_true", + help="Print a JSON object instead of pytest path lines", + ) + parser.add_argument( + "--run", + action="store_true", + help="Run pytest -q on the selected paths (never with an empty list)", + ) + args = parser.parse_args(argv) + root = args.root.resolve() + try: + manifest = load_manifest(args.manifest) + if args.check_partition and args.group is None and not args.paths and not args.run: + check_partition(root, manifest) + print("partition ok", file=sys.stderr) + return 0 + unknown: list[str] = [] + if args.group: + if args.paths: + print("do not combine --group with path arguments", file=sys.stderr) + return 2 + selected = group_paths(args.group, root, manifest) + reason = "group" + else: + selected, reason, unknown = select_paths(args.paths, root, manifest) + if not selected: + print("select_local_tests: refused empty suite", file=sys.stderr) + return 2 + if reason == "unknown-path": + print( + "select_local_tests: unknown path " + + ", ".join(unknown) + + "; running full suite", + file=sys.stderr, + ) + elif reason == "empty-input": + print( + "select_local_tests: no paths given; running full suite", + file=sys.stderr, + ) + elif reason == "empty-match": + print( + "select_local_tests: mapping produced no tests; running full suite", + file=sys.stderr, + ) + elif reason == "group": + print( + f"select_local_tests: group {args.group} ({len(selected)} files)", + file=sys.stderr, + ) + else: + print( + f"select_local_tests: mapped {len(selected)} path(s)", + file=sys.stderr, + ) + if args.json: + json.dump( + { + "reason": reason, + "paths": selected, + "unknown": unknown, + "group": args.group, + }, + sys.stdout, + indent=2, + ) + sys.stdout.write("\n") + else: + _print_paths(selected) + if args.run: + return subprocess.call( + [sys.executable, "-m", "pytest", "-q", *selected], + cwd=root, + ) + return 0 + except ManifestError as exc: + print(f"select_local_tests: {exc}", file=sys.stderr) + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/speech_install.js b/speech_install.js new file mode 100644 index 000000000..1c54e54f6 --- /dev/null +++ b/speech_install.js @@ -0,0 +1,10 @@ +// Same app-local venv pattern as system/examples/comfy/install.js:50–59. +const runtime = require('./runtime_install') +module.exports = { + run: [ + {method: 'shell.run', params: {path: 'app', venv: 'env', + message: runtime.guarded('python -m services.install_speech_tools'), + }}, + {method: 'script.return', params: {success: true}}, + ], +} diff --git a/start.js b/start.js index d47cee8f1..460e1aa38 100644 --- a/start.js +++ b/start.js @@ -16,7 +16,7 @@ module.exports = async (kernel) => { run: [ // A ready backend is not proof of a usable React UI. Repair before loading AI. ...runtime.call('ui_build.js'), - runtime.startGuard(), + ...runtime.startGuards(), // SAM service starts on demand (launched by the backend when inpaint is used) // — not started here to avoid holding a CUDA context that wastes VRAM { diff --git a/tests/fixtures/architecture_wire_inventory.json b/tests/fixtures/architecture_wire_inventory.json index 278de5f2b..faf332140 100644 --- a/tests/fixtures/architecture_wire_inventory.json +++ b/tests/fixtures/architecture_wire_inventory.json @@ -295,6 +295,12 @@ "classification": "fragile_source", "reason": "Python inspects TypeScript source; splitting useStore requires converting or relocating this contract." }, + { + "file": "tests/test_scene_packages.py", + "target": "app/_launch_runtime.py", + "classification": "fragile_source", + "reason": "Reads launch text directly and may need conversion when the referenced domain is extracted." + }, { "file": "tests/test_series_jobs.py", "target": "app/_launch_runtime.py", @@ -415,12 +421,30 @@ "classification": "symbol_importable", "reason": "Extracts selected launch symbols with AST/exec; migrate to direct imports when that domain moves." }, + { + "file": "tests/test_wizard_workflow_executor.py", + "target": "app/_launch_runtime.py", + "classification": "fragile_source", + "reason": "Reads launch text directly and may need conversion when the referenced domain is extracted." + }, { "file": "tests/test_wizard_workflows.py", "target": "app/_launch_runtime.py", "classification": "fragile_source", "reason": "Reads launch text directly and may need conversion when the referenced domain is extracted." }, + { + "file": "tests/test_world3d_export.py", + "target": "app/_launch_runtime.py", + "classification": "fragile_source", + "reason": "Reads launch text directly and may need conversion when the referenced domain is extracted." + }, + { + "file": "ui/tests/activityFooter.test.tsx", + "target": "ui/src/stores/useStore.ts", + "classification": "behavior", + "reason": "Imports the public Zustand facade; it should survive slice extraction unchanged." + }, { "file": "ui/tests/agentActions.test.mjs", "target": "ui/src/stores/useStore.ts", @@ -511,6 +535,12 @@ "classification": "behavior", "reason": "Imports the public Zustand facade; it should survive slice extraction unchanged." }, + { + "file": "ui/tests/helpOverlay.test.tsx", + "target": "ui/src/stores/useStore.ts", + "classification": "behavior", + "reason": "Imports the public Zustand facade; it should survive slice extraction unchanged." + }, { "file": "ui/tests/hunyuan3DGalleryPicker.test.tsx", "target": "ui/src/stores/useStore.ts", @@ -559,6 +589,12 @@ "classification": "behavior", "reason": "Imports the public Zustand facade; it should survive slice extraction unchanged." }, + { + "file": "ui/tests/productionReviewRuntime.test.tsx", + "target": "ui/src/stores/useStore.ts", + "classification": "behavior", + "reason": "Imports the public Zustand facade; it should survive slice extraction unchanged." + }, { "file": "ui/tests/recastRestylePicker.test.tsx", "target": "ui/src/stores/useStore.ts", @@ -571,12 +607,42 @@ "classification": "behavior", "reason": "Imports the public Zustand facade; it should survive slice extraction unchanged." }, + { + "file": "ui/tests/seriesCharacterSpeech.test.tsx", + "target": "ui/src/stores/useStore.ts", + "classification": "behavior", + "reason": "Imports the public Zustand facade; it should survive slice extraction unchanged." + }, + { + "file": "ui/tests/seriesEpisodeReferences.test.tsx", + "target": "ui/src/stores/useStore.ts", + "classification": "behavior", + "reason": "Imports the public Zustand facade; it should survive slice extraction unchanged." + }, + { + "file": "ui/tests/seriesProduction.test.tsx", + "target": "ui/src/stores/useStore.ts", + "classification": "behavior", + "reason": "Imports the public Zustand facade; it should survive slice extraction unchanged." + }, + { + "file": "ui/tests/seriesRenderMethods.test.tsx", + "target": "ui/src/stores/useStore.ts", + "classification": "behavior", + "reason": "Imports the public Zustand facade; it should survive slice extraction unchanged." + }, { "file": "ui/tests/seriesResponsive.test.tsx", "target": "ui/src/stores/useStore.ts", "classification": "behavior", "reason": "Imports the public Zustand facade; it should survive slice extraction unchanged." }, + { + "file": "ui/tests/seriesWizardDraft.test.ts", + "target": "ui/src/stores/useStore.ts", + "classification": "behavior", + "reason": "Imports the public Zustand facade; it should survive slice extraction unchanged." + }, { "file": "ui/tests/storyComicProgress.test.mjs", "target": "ui/src/stores/useStore.ts", diff --git a/tests/fixtures/route_table.json b/tests/fixtures/route_table.json index 5d8e26087..800cb401b 100644 --- a/tests/fixtures/route_table.json +++ b/tests/fixtures/route_table.json @@ -1211,6 +1211,16 @@ "source": "app/_launch_runtime.py", "ordinal": 120 }, + { + "method": "PUT", + "path": "/api/v1/director/pipelines/{pid}/review", + "endpoint": "save", + "status_code": null, + "response_model": null, + "include_in_schema": null, + "source": "app/routers/director_review.py", + "ordinal": 121 + }, { "method": "GET", "path": "/api/v1/director/pipelines", @@ -1219,7 +1229,7 @@ "response_model": null, "include_in_schema": null, "source": "app/_launch_runtime.py", - "ordinal": 121 + "ordinal": 122 }, { "method": "GET", @@ -1229,7 +1239,7 @@ "response_model": null, "include_in_schema": null, "source": "app/_launch_runtime.py", - "ordinal": 122 + "ordinal": 123 }, { "method": "GET", @@ -1239,7 +1249,7 @@ "response_model": null, "include_in_schema": null, "source": "app/_launch_runtime.py", - "ordinal": 123 + "ordinal": 124 }, { "method": "PUT", @@ -1249,7 +1259,7 @@ "response_model": null, "include_in_schema": null, "source": "app/_launch_runtime.py", - "ordinal": 124 + "ordinal": 125 }, { "method": "PUT", @@ -1259,7 +1269,7 @@ "response_model": null, "include_in_schema": null, "source": "app/_launch_runtime.py", - "ordinal": 125 + "ordinal": 126 }, { "method": "PUT", @@ -1269,7 +1279,7 @@ "response_model": null, "include_in_schema": null, "source": "app/_launch_runtime.py", - "ordinal": 126 + "ordinal": 127 }, { "method": "POST", @@ -1279,7 +1289,7 @@ "response_model": null, "include_in_schema": null, "source": "app/_launch_runtime.py", - "ordinal": 127 + "ordinal": 128 }, { "method": "POST", @@ -1289,7 +1299,7 @@ "response_model": null, "include_in_schema": null, "source": "app/_launch_runtime.py", - "ordinal": 128 + "ordinal": 129 }, { "method": "POST", @@ -1299,7 +1309,7 @@ "response_model": null, "include_in_schema": null, "source": "app/_launch_runtime.py", - "ordinal": 129 + "ordinal": 130 }, { "method": "POST", @@ -1309,7 +1319,7 @@ "response_model": null, "include_in_schema": null, "source": "app/_launch_runtime.py", - "ordinal": 130 + "ordinal": 131 }, { "method": "POST", @@ -1319,7 +1329,7 @@ "response_model": null, "include_in_schema": null, "source": "app/_launch_runtime.py", - "ordinal": 131 + "ordinal": 132 }, { "method": "POST", @@ -1329,7 +1339,7 @@ "response_model": null, "include_in_schema": null, "source": "app/_launch_runtime.py", - "ordinal": 132 + "ordinal": 133 }, { "method": "DELETE", @@ -1339,7 +1349,7 @@ "response_model": null, "include_in_schema": null, "source": "app/_launch_runtime.py", - "ordinal": 133 + "ordinal": 134 }, { "method": "POST", @@ -1349,7 +1359,7 @@ "response_model": null, "include_in_schema": null, "source": "app/_launch_runtime.py", - "ordinal": 134 + "ordinal": 135 }, { "method": "GET", @@ -1359,7 +1369,7 @@ "response_model": null, "include_in_schema": null, "source": "app/_launch_runtime.py", - "ordinal": 135 + "ordinal": 136 }, { "method": "GET", @@ -1369,7 +1379,7 @@ "response_model": null, "include_in_schema": null, "source": "app/_launch_runtime.py", - "ordinal": 136 + "ordinal": 137 }, { "method": "POST", @@ -1379,7 +1389,7 @@ "response_model": null, "include_in_schema": null, "source": "app/_launch_runtime.py", - "ordinal": 137 + "ordinal": 138 }, { "method": "GET", @@ -1389,7 +1399,7 @@ "response_model": null, "include_in_schema": null, "source": "app/_launch_runtime.py", - "ordinal": 138 + "ordinal": 139 }, { "method": "POST", @@ -1399,7 +1409,7 @@ "response_model": null, "include_in_schema": null, "source": "app/_launch_runtime.py", - "ordinal": 139 + "ordinal": 140 }, { "method": "POST", @@ -1409,7 +1419,7 @@ "response_model": null, "include_in_schema": null, "source": "app/_launch_runtime.py", - "ordinal": 140 + "ordinal": 141 }, { "method": "POST", @@ -1419,7 +1429,7 @@ "response_model": null, "include_in_schema": null, "source": "app/_launch_runtime.py", - "ordinal": 141 + "ordinal": 142 }, { "method": "POST", @@ -1429,7 +1439,7 @@ "response_model": null, "include_in_schema": null, "source": "app/_launch_runtime.py", - "ordinal": 142 + "ordinal": 143 }, { "method": "POST", @@ -1439,7 +1449,7 @@ "response_model": null, "include_in_schema": null, "source": "app/_launch_runtime.py", - "ordinal": 143 + "ordinal": 144 }, { "method": "POST", @@ -1449,7 +1459,7 @@ "response_model": null, "include_in_schema": null, "source": "app/_launch_runtime.py", - "ordinal": 144 + "ordinal": 145 }, { "method": "POST", @@ -1459,7 +1469,7 @@ "response_model": null, "include_in_schema": null, "source": "app/_launch_runtime.py", - "ordinal": 145 + "ordinal": 146 }, { "method": "POST", @@ -1469,7 +1479,7 @@ "response_model": null, "include_in_schema": null, "source": "app/_launch_runtime.py", - "ordinal": 146 + "ordinal": 147 }, { "method": "POST", @@ -1479,7 +1489,7 @@ "response_model": null, "include_in_schema": null, "source": "app/_launch_runtime.py", - "ordinal": 147 + "ordinal": 148 }, { "method": "POST", @@ -1489,7 +1499,7 @@ "response_model": null, "include_in_schema": null, "source": "app/_launch_runtime.py", - "ordinal": 148 + "ordinal": 149 }, { "method": "GET", @@ -1499,7 +1509,7 @@ "response_model": null, "include_in_schema": null, "source": "app/_launch_runtime.py", - "ordinal": 149 + "ordinal": 150 }, { "method": "POST", @@ -1509,7 +1519,7 @@ "response_model": null, "include_in_schema": null, "source": "app/_launch_runtime.py", - "ordinal": 150 + "ordinal": 151 }, { "method": "POST", @@ -1519,7 +1529,7 @@ "response_model": null, "include_in_schema": null, "source": "app/_launch_runtime.py", - "ordinal": 151 + "ordinal": 152 }, { "method": "POST", @@ -1529,7 +1539,7 @@ "response_model": null, "include_in_schema": null, "source": "app/_launch_runtime.py", - "ordinal": 152 + "ordinal": 153 }, { "method": "POST", @@ -1539,7 +1549,7 @@ "response_model": null, "include_in_schema": null, "source": "app/_launch_runtime.py", - "ordinal": 153 + "ordinal": 154 }, { "method": "GET", @@ -1549,7 +1559,7 @@ "response_model": null, "include_in_schema": null, "source": "app/_launch_runtime.py", - "ordinal": 154 + "ordinal": 155 }, { "method": "POST", @@ -1559,7 +1569,7 @@ "response_model": null, "include_in_schema": null, "source": "app/_launch_runtime.py", - "ordinal": 155 + "ordinal": 156 }, { "method": "GET", @@ -1569,7 +1579,7 @@ "response_model": null, "include_in_schema": null, "source": "app/_launch_runtime.py", - "ordinal": 156 + "ordinal": 157 }, { "method": "GET", @@ -1579,7 +1589,7 @@ "response_model": null, "include_in_schema": null, "source": "app/_launch_runtime.py", - "ordinal": 157 + "ordinal": 158 }, { "method": "POST", @@ -1589,7 +1599,7 @@ "response_model": null, "include_in_schema": null, "source": "app/_launch_runtime.py", - "ordinal": 158 + "ordinal": 159 }, { "method": "POST", @@ -1599,7 +1609,7 @@ "response_model": null, "include_in_schema": null, "source": "app/_launch_runtime.py", - "ordinal": 159 + "ordinal": 160 }, { "method": "GET", @@ -1609,7 +1619,7 @@ "response_model": null, "include_in_schema": null, "source": "app/_launch_runtime.py", - "ordinal": 160 + "ordinal": 161 }, { "method": "POST", @@ -1619,7 +1629,7 @@ "response_model": null, "include_in_schema": null, "source": "app/_launch_runtime.py", - "ordinal": 161 + "ordinal": 162 }, { "method": "POST", @@ -1629,7 +1639,7 @@ "response_model": null, "include_in_schema": null, "source": "app/_launch_runtime.py", - "ordinal": 162 + "ordinal": 163 }, { "method": "GET", @@ -1639,7 +1649,7 @@ "response_model": null, "include_in_schema": null, "source": "app/_launch_runtime.py", - "ordinal": 163 + "ordinal": 164 }, { "method": "PATCH", @@ -1649,7 +1659,7 @@ "response_model": null, "include_in_schema": null, "source": "app/_launch_runtime.py", - "ordinal": 164 + "ordinal": 165 }, { "method": "DELETE", @@ -1659,7 +1669,7 @@ "response_model": null, "include_in_schema": null, "source": "app/_launch_runtime.py", - "ordinal": 165 + "ordinal": 166 }, { "method": "POST", @@ -1669,7 +1679,7 @@ "response_model": null, "include_in_schema": null, "source": "app/routers/character_kit_face.py", - "ordinal": 166 + "ordinal": 167 }, { "method": "POST", @@ -1679,7 +1689,7 @@ "response_model": null, "include_in_schema": null, "source": "app/_launch_runtime.py", - "ordinal": 167 + "ordinal": 168 }, { "method": "POST", @@ -1689,7 +1699,7 @@ "response_model": null, "include_in_schema": null, "source": "app/routers/comics.py", - "ordinal": 168 + "ordinal": 169 }, { "method": "POST", @@ -1699,7 +1709,7 @@ "response_model": null, "include_in_schema": null, "source": "app/routers/comics.py", - "ordinal": 169 + "ordinal": 170 }, { "method": "GET", @@ -1709,7 +1719,7 @@ "response_model": null, "include_in_schema": null, "source": "app/routers/comics.py", - "ordinal": 170 + "ordinal": 171 }, { "method": "GET", @@ -1719,7 +1729,7 @@ "response_model": null, "include_in_schema": null, "source": "app/routers/comics.py", - "ordinal": 171 + "ordinal": 172 }, { "method": "PUT", @@ -1729,7 +1739,7 @@ "response_model": null, "include_in_schema": null, "source": "app/routers/comics.py", - "ordinal": 172 + "ordinal": 173 }, { "method": "GET", @@ -1739,7 +1749,7 @@ "response_model": null, "include_in_schema": null, "source": "app/routers/comics.py", - "ordinal": 173 + "ordinal": 174 }, { "method": "POST", @@ -1749,7 +1759,7 @@ "response_model": null, "include_in_schema": null, "source": "app/routers/comics.py", - "ordinal": 174 + "ordinal": 175 }, { "method": "GET", @@ -1759,7 +1769,7 @@ "response_model": null, "include_in_schema": null, "source": "app/routers/comics.py", - "ordinal": 175 + "ordinal": 176 }, { "method": "POST", @@ -1769,7 +1779,7 @@ "response_model": null, "include_in_schema": null, "source": "app/routers/comics.py", - "ordinal": 176 + "ordinal": 177 }, { "method": "POST", @@ -1779,7 +1789,7 @@ "response_model": null, "include_in_schema": null, "source": "app/routers/comics.py", - "ordinal": 177 + "ordinal": 178 }, { "method": "GET", @@ -1789,7 +1799,7 @@ "response_model": null, "include_in_schema": null, "source": "app/_launch_runtime.py", - "ordinal": 178 + "ordinal": 179 }, { "method": "PUT", @@ -1799,7 +1809,7 @@ "response_model": null, "include_in_schema": null, "source": "app/_launch_runtime.py", - "ordinal": 179 + "ordinal": 180 }, { "method": "GET", @@ -1809,7 +1819,7 @@ "response_model": null, "include_in_schema": null, "source": "app/_launch_runtime.py", - "ordinal": 180 + "ordinal": 181 }, { "method": "POST", @@ -1819,7 +1829,7 @@ "response_model": null, "include_in_schema": null, "source": "app/_launch_runtime.py", - "ordinal": 181 + "ordinal": 182 }, { "method": "GET", @@ -1829,7 +1839,7 @@ "response_model": null, "include_in_schema": null, "source": "app/_launch_runtime.py", - "ordinal": 182 + "ordinal": 183 }, { "method": "PUT", @@ -1839,7 +1849,7 @@ "response_model": null, "include_in_schema": null, "source": "app/_launch_runtime.py", - "ordinal": 183 + "ordinal": 184 }, { "method": "DELETE", @@ -1849,7 +1859,7 @@ "response_model": null, "include_in_schema": null, "source": "app/_launch_runtime.py", - "ordinal": 184 + "ordinal": 185 }, { "method": "POST", @@ -1859,7 +1869,7 @@ "response_model": null, "include_in_schema": null, "source": "app/_launch_runtime.py", - "ordinal": 185 + "ordinal": 186 }, { "method": "POST", @@ -1869,7 +1879,7 @@ "response_model": null, "include_in_schema": null, "source": "app/_launch_runtime.py", - "ordinal": 186 + "ordinal": 187 }, { "method": "POST", @@ -1879,7 +1889,7 @@ "response_model": null, "include_in_schema": null, "source": "app/_launch_runtime.py", - "ordinal": 187 + "ordinal": 188 }, { "method": "GET", @@ -1889,7 +1899,7 @@ "response_model": null, "include_in_schema": null, "source": "app/_launch_runtime.py", - "ordinal": 188 + "ordinal": 189 }, { "method": "GET", @@ -1899,7 +1909,7 @@ "response_model": null, "include_in_schema": null, "source": "app/_launch_runtime.py", - "ordinal": 189 + "ordinal": 190 }, { "method": "DELETE", @@ -1909,7 +1919,7 @@ "response_model": null, "include_in_schema": null, "source": "app/_launch_runtime.py", - "ordinal": 190 + "ordinal": 191 }, { "method": "POST", @@ -1919,7 +1929,7 @@ "response_model": null, "include_in_schema": null, "source": "app/_launch_runtime.py", - "ordinal": 191 + "ordinal": 192 }, { "method": "PUT", @@ -1929,7 +1939,7 @@ "response_model": null, "include_in_schema": null, "source": "app/_launch_runtime.py", - "ordinal": 192 + "ordinal": 193 }, { "method": "POST", @@ -1939,7 +1949,17 @@ "response_model": null, "include_in_schema": null, "source": "app/_launch_runtime.py", - "ordinal": 193 + "ordinal": 194 + }, + { + "method": "POST", + "path": "/api/v1/series/{series_id}/episodes/{episode_id}/references/refresh", + "endpoint": "refresh_series_episode_references", + "status_code": null, + "response_model": null, + "include_in_schema": null, + "source": "app/_launch_runtime.py", + "ordinal": 195 }, { "method": "POST", @@ -1949,7 +1969,7 @@ "response_model": null, "include_in_schema": null, "source": "app/_launch_runtime.py", - "ordinal": 194 + "ordinal": 196 }, { "method": "POST", @@ -1959,7 +1979,7 @@ "response_model": null, "include_in_schema": null, "source": "app/_launch_runtime.py", - "ordinal": 195 + "ordinal": 197 }, { "method": "POST", @@ -1969,7 +1989,7 @@ "response_model": null, "include_in_schema": null, "source": "app/_launch_runtime.py", - "ordinal": 196 + "ordinal": 198 }, { "method": "POST", @@ -1979,7 +1999,7 @@ "response_model": null, "include_in_schema": null, "source": "app/_launch_runtime.py", - "ordinal": 197 + "ordinal": 199 }, { "method": "GET", @@ -1989,7 +2009,7 @@ "response_model": null, "include_in_schema": null, "source": "app/_launch_runtime.py", - "ordinal": 198 + "ordinal": 200 }, { "method": "POST", @@ -1999,7 +2019,7 @@ "response_model": null, "include_in_schema": null, "source": "app/_launch_runtime.py", - "ordinal": 199 + "ordinal": 201 }, { "method": "POST", @@ -2009,7 +2029,7 @@ "response_model": null, "include_in_schema": null, "source": "app/_launch_runtime.py", - "ordinal": 200 + "ordinal": 202 }, { "method": "POST", @@ -2019,7 +2039,7 @@ "response_model": null, "include_in_schema": null, "source": "app/_launch_runtime.py", - "ordinal": 201 + "ordinal": 203 }, { "method": "POST", @@ -2029,7 +2049,7 @@ "response_model": null, "include_in_schema": null, "source": "app/_launch_runtime.py", - "ordinal": 202 + "ordinal": 204 }, { "method": "POST", @@ -2039,7 +2059,7 @@ "response_model": null, "include_in_schema": null, "source": "app/_launch_runtime.py", - "ordinal": 203 + "ordinal": 205 }, { "method": "GET", @@ -2049,7 +2069,7 @@ "response_model": null, "include_in_schema": null, "source": "app/_launch_runtime.py", - "ordinal": 204 + "ordinal": 206 }, { "method": "DELETE", @@ -2059,7 +2079,7 @@ "response_model": null, "include_in_schema": null, "source": "app/_launch_runtime.py", - "ordinal": 205 + "ordinal": 207 }, { "method": "POST", @@ -2069,7 +2089,7 @@ "response_model": null, "include_in_schema": null, "source": "app/_launch_runtime.py", - "ordinal": 206 + "ordinal": 208 }, { "method": "GET", @@ -2079,7 +2099,7 @@ "response_model": null, "include_in_schema": null, "source": "app/_launch_runtime.py", - "ordinal": 207 + "ordinal": 209 }, { "method": "POST", @@ -2089,7 +2109,7 @@ "response_model": null, "include_in_schema": null, "source": "app/_launch_runtime.py", - "ordinal": 208 + "ordinal": 210 }, { "method": "GET", @@ -2099,7 +2119,7 @@ "response_model": null, "include_in_schema": null, "source": "app/_launch_runtime.py", - "ordinal": 209 + "ordinal": 211 }, { "method": "POST", @@ -2109,7 +2129,7 @@ "response_model": null, "include_in_schema": null, "source": "app/_launch_runtime.py", - "ordinal": 210 + "ordinal": 212 }, { "method": "DELETE", @@ -2119,7 +2139,7 @@ "response_model": null, "include_in_schema": null, "source": "app/_launch_runtime.py", - "ordinal": 211 + "ordinal": 213 }, { "method": "POST", @@ -2129,7 +2149,7 @@ "response_model": null, "include_in_schema": null, "source": "app/_launch_runtime.py", - "ordinal": 212 + "ordinal": 214 }, { "method": "POST", @@ -2139,7 +2159,7 @@ "response_model": null, "include_in_schema": null, "source": "app/_launch_runtime.py", - "ordinal": 213 + "ordinal": 215 }, { "method": "POST", @@ -2149,7 +2169,7 @@ "response_model": "SeriesAssemblyJobResponse", "include_in_schema": null, "source": "app/routers/series_assembly.py", - "ordinal": 214 + "ordinal": 216 }, { "method": "POST", @@ -2159,7 +2179,7 @@ "response_model": "SeriesAssemblyJobResponse", "include_in_schema": null, "source": "app/routers/series_assembly.py", - "ordinal": 215 + "ordinal": 217 }, { "method": "POST", @@ -2169,7 +2189,7 @@ "response_model": "SeriesAssemblyJobResponse", "include_in_schema": null, "source": "app/routers/series_assembly.py", - "ordinal": 216 + "ordinal": 218 }, { "method": "GET", @@ -2179,7 +2199,7 @@ "response_model": "SeriesAssemblyRecoveryResponse", "include_in_schema": null, "source": "app/routers/series_assembly.py", - "ordinal": 217 + "ordinal": 219 }, { "method": "DELETE", @@ -2189,7 +2209,7 @@ "response_model": "SeriesAssemblyDiscardResponse", "include_in_schema": null, "source": "app/routers/series_assembly.py", - "ordinal": 218 + "ordinal": 220 }, { "method": "GET", @@ -2199,7 +2219,7 @@ "response_model": "SeriesAssemblyJobResponse", "include_in_schema": null, "source": "app/routers/series_assembly.py", - "ordinal": 219 + "ordinal": 221 }, { "method": "POST", @@ -2209,7 +2229,7 @@ "response_model": null, "include_in_schema": null, "source": "app/routers/quick_video_batches.py", - "ordinal": 220 + "ordinal": 222 }, { "method": "GET", @@ -2219,7 +2239,7 @@ "response_model": null, "include_in_schema": null, "source": "app/routers/quick_video_batches.py", - "ordinal": 221 + "ordinal": 223 }, { "method": "GET", @@ -2229,7 +2249,7 @@ "response_model": null, "include_in_schema": null, "source": "app/routers/quick_video_batches.py", - "ordinal": 222 + "ordinal": 224 }, { "method": "POST", @@ -2239,7 +2259,7 @@ "response_model": null, "include_in_schema": null, "source": "app/routers/quick_video_batches.py", - "ordinal": 223 + "ordinal": 225 }, { "method": "GET", @@ -2249,7 +2269,7 @@ "response_model": null, "include_in_schema": null, "source": "app/routers/style_library.py", - "ordinal": 224 + "ordinal": 226 }, { "method": "GET", @@ -2259,7 +2279,7 @@ "response_model": null, "include_in_schema": null, "source": "app/routers/style_library.py", - "ordinal": 225 + "ordinal": 227 }, { "method": "POST", @@ -2269,7 +2289,7 @@ "response_model": null, "include_in_schema": null, "source": "app/routers/style_library.py", - "ordinal": 226 + "ordinal": 228 }, { "method": "GET", @@ -2279,7 +2299,7 @@ "response_model": null, "include_in_schema": null, "source": "app/routers/style_library.py", - "ordinal": 227 + "ordinal": 229 }, { "method": "POST", @@ -2289,7 +2309,7 @@ "response_model": null, "include_in_schema": null, "source": "app/routers/style_library.py", - "ordinal": 228 + "ordinal": 230 }, { "method": "GET", @@ -2299,7 +2319,7 @@ "response_model": null, "include_in_schema": null, "source": "app/routers/style_library.py", - "ordinal": 229 + "ordinal": 231 }, { "method": "POST", @@ -2309,7 +2329,7 @@ "response_model": null, "include_in_schema": null, "source": "app/routers/style_library.py", - "ordinal": 230 + "ordinal": 232 }, { "method": "GET", @@ -2319,7 +2339,7 @@ "response_model": null, "include_in_schema": null, "source": "app/routers/style_library.py", - "ordinal": 231 + "ordinal": 233 }, { "method": "GET", @@ -2329,7 +2349,7 @@ "response_model": null, "include_in_schema": null, "source": "app/routers/style_library.py", - "ordinal": 232 + "ordinal": 234 }, { "method": "DELETE", @@ -2339,7 +2359,7 @@ "response_model": null, "include_in_schema": null, "source": "app/routers/style_library.py", - "ordinal": 233 + "ordinal": 235 }, { "method": "POST", @@ -2349,7 +2369,7 @@ "response_model": null, "include_in_schema": null, "source": "app/_launch_runtime.py", - "ordinal": 234 + "ordinal": 236 }, { "method": "GET", @@ -2359,7 +2379,7 @@ "response_model": null, "include_in_schema": null, "source": "app/_launch_runtime.py", - "ordinal": 235 + "ordinal": 237 }, { "method": "PUT", @@ -2369,7 +2389,7 @@ "response_model": null, "include_in_schema": null, "source": "app/_launch_runtime.py", - "ordinal": 236 + "ordinal": 238 }, { "method": "GET", @@ -2379,7 +2399,7 @@ "response_model": null, "include_in_schema": null, "source": "app/_launch_runtime.py", - "ordinal": 237 + "ordinal": 239 }, { "method": "PUT", @@ -2389,7 +2409,7 @@ "response_model": null, "include_in_schema": null, "source": "app/_launch_runtime.py", - "ordinal": 238 + "ordinal": 240 }, { "method": "GET", @@ -2399,7 +2419,7 @@ "response_model": null, "include_in_schema": null, "source": "app/_launch_runtime.py", - "ordinal": 239 + "ordinal": 241 }, { "method": "PUT", @@ -2409,7 +2429,7 @@ "response_model": null, "include_in_schema": null, "source": "app/_launch_runtime.py", - "ordinal": 240 + "ordinal": 242 }, { "method": "PATCH", @@ -2419,7 +2439,7 @@ "response_model": null, "include_in_schema": null, "source": "app/_launch_runtime.py", - "ordinal": 241 + "ordinal": 243 }, { "method": "DELETE", @@ -2429,7 +2449,7 @@ "response_model": null, "include_in_schema": null, "source": "app/_launch_runtime.py", - "ordinal": 242 + "ordinal": 244 }, { "method": "POST", @@ -2439,7 +2459,7 @@ "response_model": null, "include_in_schema": null, "source": "app/_launch_runtime.py", - "ordinal": 243 + "ordinal": 245 }, { "method": "POST", @@ -2449,7 +2469,7 @@ "response_model": null, "include_in_schema": null, "source": "app/_launch_runtime.py", - "ordinal": 244 + "ordinal": 246 }, { "method": "POST", @@ -2459,7 +2479,7 @@ "response_model": null, "include_in_schema": null, "source": "app/_launch_runtime.py", - "ordinal": 245 + "ordinal": 247 }, { "method": "GET", @@ -2469,7 +2489,7 @@ "response_model": null, "include_in_schema": null, "source": "app/_launch_runtime.py", - "ordinal": 246 + "ordinal": 248 }, { "method": "POST", @@ -2479,7 +2499,7 @@ "response_model": null, "include_in_schema": null, "source": "app/_launch_runtime.py", - "ordinal": 247 + "ordinal": 249 }, { "method": "POST", @@ -2489,7 +2509,7 @@ "response_model": null, "include_in_schema": null, "source": "app/_launch_runtime.py", - "ordinal": 248 + "ordinal": 250 }, { "method": "POST", @@ -2499,7 +2519,7 @@ "response_model": null, "include_in_schema": null, "source": "app/_launch_runtime.py", - "ordinal": 249 + "ordinal": 251 }, { "method": "POST", @@ -2509,7 +2529,7 @@ "response_model": null, "include_in_schema": null, "source": "app/_launch_runtime.py", - "ordinal": 250 + "ordinal": 252 }, { "method": "GET", @@ -2519,7 +2539,7 @@ "response_model": null, "include_in_schema": null, "source": "app/_launch_runtime.py", - "ordinal": 251 + "ordinal": 253 }, { "method": "POST", @@ -2529,7 +2549,7 @@ "response_model": null, "include_in_schema": null, "source": "app/_launch_runtime.py", - "ordinal": 252 + "ordinal": 254 }, { "method": "POST", @@ -2539,7 +2559,7 @@ "response_model": null, "include_in_schema": null, "source": "app/_launch_runtime.py", - "ordinal": 253 + "ordinal": 255 }, { "method": "POST", @@ -2549,7 +2569,7 @@ "response_model": null, "include_in_schema": null, "source": "app/_launch_runtime.py", - "ordinal": 254 + "ordinal": 256 }, { "method": "POST", @@ -2559,7 +2579,7 @@ "response_model": null, "include_in_schema": null, "source": "app/_launch_runtime.py", - "ordinal": 255 + "ordinal": 257 }, { "method": "POST", @@ -2569,7 +2589,7 @@ "response_model": null, "include_in_schema": null, "source": "app/_launch_runtime.py", - "ordinal": 256 + "ordinal": 258 }, { "method": "POST", @@ -2579,7 +2599,7 @@ "response_model": null, "include_in_schema": null, "source": "app/_launch_runtime.py", - "ordinal": 257 + "ordinal": 259 }, { "method": "GET", @@ -2589,7 +2609,7 @@ "response_model": null, "include_in_schema": null, "source": "app/_launch_runtime.py", - "ordinal": 258 + "ordinal": 260 }, { "method": "POST", @@ -2599,7 +2619,7 @@ "response_model": null, "include_in_schema": null, "source": "app/_launch_runtime.py", - "ordinal": 259 + "ordinal": 261 }, { "method": "GET", @@ -2609,7 +2629,7 @@ "response_model": null, "include_in_schema": null, "source": "app/_launch_runtime.py", - "ordinal": 260 + "ordinal": 262 }, { "method": "GET", @@ -2619,7 +2639,7 @@ "response_model": null, "include_in_schema": null, "source": "app/_launch_runtime.py", - "ordinal": 261 + "ordinal": 263 }, { "method": "GET", @@ -2629,7 +2649,7 @@ "response_model": null, "include_in_schema": null, "source": "app/_launch_runtime.py", - "ordinal": 262 + "ordinal": 264 }, { "method": "GET", @@ -2639,7 +2659,7 @@ "response_model": null, "include_in_schema": null, "source": "app/_launch_runtime.py", - "ordinal": 263 + "ordinal": 265 }, { "method": "GET", @@ -2649,7 +2669,7 @@ "response_model": null, "include_in_schema": null, "source": "app/_launch_runtime.py", - "ordinal": 264 + "ordinal": 266 }, { "method": "GET", @@ -2659,7 +2679,7 @@ "response_model": null, "include_in_schema": null, "source": "app/_launch_runtime.py", - "ordinal": 265 + "ordinal": 267 }, { "method": "POST", @@ -2669,7 +2689,7 @@ "response_model": null, "include_in_schema": null, "source": "app/_launch_runtime.py", - "ordinal": 266 + "ordinal": 268 }, { "method": "DELETE", @@ -2679,7 +2699,7 @@ "response_model": null, "include_in_schema": null, "source": "app/_launch_runtime.py", - "ordinal": 267 + "ordinal": 269 }, { "method": "POST", @@ -2689,7 +2709,7 @@ "response_model": null, "include_in_schema": null, "source": "app/_launch_runtime.py", - "ordinal": 268 + "ordinal": 270 }, { "method": "GET", @@ -2699,7 +2719,7 @@ "response_model": null, "include_in_schema": null, "source": "app/_launch_runtime.py", - "ordinal": 269 + "ordinal": 271 }, { "method": "POST", @@ -2709,7 +2729,7 @@ "response_model": null, "include_in_schema": null, "source": "app/_launch_runtime.py", - "ordinal": 270 + "ordinal": 272 }, { "method": "POST", @@ -2719,7 +2739,7 @@ "response_model": null, "include_in_schema": null, "source": "app/_launch_runtime.py", - "ordinal": 271 + "ordinal": 273 }, { "method": "GET", @@ -2729,7 +2749,7 @@ "response_model": null, "include_in_schema": null, "source": "app/_launch_runtime.py", - "ordinal": 272 + "ordinal": 274 }, { "method": "POST", @@ -2739,7 +2759,7 @@ "response_model": null, "include_in_schema": null, "source": "app/_launch_runtime.py", - "ordinal": 273 + "ordinal": 275 }, { "method": "POST", @@ -2749,7 +2769,7 @@ "response_model": null, "include_in_schema": null, "source": "app/_launch_runtime.py", - "ordinal": 274 + "ordinal": 276 }, { "method": "GET", @@ -2759,7 +2779,7 @@ "response_model": null, "include_in_schema": null, "source": "app/_launch_runtime.py", - "ordinal": 275 + "ordinal": 277 }, { "method": "POST", @@ -2769,7 +2789,7 @@ "response_model": null, "include_in_schema": null, "source": "app/_launch_runtime.py", - "ordinal": 276 + "ordinal": 278 }, { "method": "POST", @@ -2779,7 +2799,7 @@ "response_model": null, "include_in_schema": null, "source": "app/_launch_runtime.py", - "ordinal": 277 + "ordinal": 279 }, { "method": "POST", @@ -2789,7 +2809,7 @@ "response_model": null, "include_in_schema": null, "source": "app/routers/comics.py", - "ordinal": 278 + "ordinal": 280 }, { "method": "GET", @@ -2799,7 +2819,7 @@ "response_model": null, "include_in_schema": null, "source": "app/_launch_runtime.py", - "ordinal": 279 + "ordinal": 281 }, { "method": "POST", @@ -2809,7 +2829,7 @@ "response_model": null, "include_in_schema": null, "source": "app/_launch_runtime.py", - "ordinal": 280 + "ordinal": 282 }, { "method": "POST", @@ -2819,7 +2839,7 @@ "response_model": null, "include_in_schema": null, "source": "app/_launch_runtime.py", - "ordinal": 281 + "ordinal": 283 }, { "method": "DELETE", @@ -2829,7 +2849,7 @@ "response_model": null, "include_in_schema": null, "source": "app/_launch_runtime.py", - "ordinal": 282 + "ordinal": 284 }, { "method": "POST", @@ -2839,7 +2859,7 @@ "response_model": null, "include_in_schema": null, "source": "app/_launch_runtime.py", - "ordinal": 283 + "ordinal": 285 }, { "method": "GET", @@ -2849,7 +2869,7 @@ "response_model": null, "include_in_schema": null, "source": "app/_launch_runtime.py", - "ordinal": 284 + "ordinal": 286 }, { "method": "GET", @@ -2859,7 +2879,7 @@ "response_model": null, "include_in_schema": null, "source": "app/_launch_runtime.py", - "ordinal": 285 + "ordinal": 287 }, { "method": "GET", @@ -2869,7 +2889,7 @@ "response_model": null, "include_in_schema": null, "source": "app/routers/canonical_tasks.py", - "ordinal": 286 + "ordinal": 288 }, { "method": "POST", @@ -2879,7 +2899,7 @@ "response_model": null, "include_in_schema": null, "source": "app/routers/canonical_tasks.py", - "ordinal": 287 + "ordinal": 289 }, { "method": "GET", @@ -2889,7 +2909,7 @@ "response_model": null, "include_in_schema": null, "source": "app/routers/canonical_tasks.py", - "ordinal": 288 + "ordinal": 290 }, { "method": "GET", @@ -2899,7 +2919,7 @@ "response_model": null, "include_in_schema": null, "source": "app/routers/canonical_tasks.py", - "ordinal": 289 + "ordinal": 291 }, { "method": "GET", @@ -2909,7 +2929,7 @@ "response_model": null, "include_in_schema": null, "source": "app/routers/canonical_tasks.py", - "ordinal": 290 + "ordinal": 292 }, { "method": "POST", @@ -2919,7 +2939,7 @@ "response_model": null, "include_in_schema": null, "source": "app/routers/canonical_tasks.py", - "ordinal": 291 + "ordinal": 293 }, { "method": "POST", @@ -2929,7 +2949,7 @@ "response_model": null, "include_in_schema": null, "source": "app/routers/canonical_tasks.py", - "ordinal": 292 + "ordinal": 294 }, { "method": "POST", @@ -2939,7 +2959,7 @@ "response_model": null, "include_in_schema": null, "source": "app/routers/canonical_tasks.py", - "ordinal": 293 + "ordinal": 295 }, { "method": "DELETE", @@ -2949,7 +2969,7 @@ "response_model": null, "include_in_schema": null, "source": "app/routers/canonical_tasks.py", - "ordinal": 294 + "ordinal": 296 }, { "method": "GET", @@ -2959,7 +2979,7 @@ "response_model": null, "include_in_schema": null, "source": "app/routers/assets.py", - "ordinal": 295 + "ordinal": 297 }, { "method": "GET", @@ -2969,7 +2989,7 @@ "response_model": null, "include_in_schema": null, "source": "app/routers/assets.py", - "ordinal": 296 + "ordinal": 298 }, { "method": "POST", @@ -2979,7 +2999,7 @@ "response_model": null, "include_in_schema": null, "source": "app/routers/tools.py", - "ordinal": 297 + "ordinal": 299 }, { "method": "GET", @@ -2989,7 +3009,7 @@ "response_model": null, "include_in_schema": null, "source": "app/routers/projects.py", - "ordinal": 298 + "ordinal": 300 }, { "method": "GET", @@ -2999,7 +3019,7 @@ "response_model": null, "include_in_schema": null, "source": "app/routers/projects.py", - "ordinal": 299 + "ordinal": 301 }, { "method": "GET", @@ -3009,7 +3029,7 @@ "response_model": null, "include_in_schema": null, "source": "app/routers/productions.py", - "ordinal": 300 + "ordinal": 302 }, { "method": "GET", @@ -3019,7 +3039,7 @@ "response_model": null, "include_in_schema": null, "source": "app/routers/productions.py", - "ordinal": 301 + "ordinal": 303 }, { "method": "GET", @@ -3029,7 +3049,7 @@ "response_model": null, "include_in_schema": null, "source": "app/routers/productions.py", - "ordinal": 302 + "ordinal": 304 }, { "method": "GET", @@ -3039,7 +3059,7 @@ "response_model": null, "include_in_schema": null, "source": "app/routers/productions.py", - "ordinal": 303 + "ordinal": 305 }, { "method": "GET", @@ -3049,7 +3069,7 @@ "response_model": null, "include_in_schema": null, "source": "app/routers/workspace_collections.py", - "ordinal": 304 + "ordinal": 306 }, { "method": "POST", @@ -3059,7 +3079,7 @@ "response_model": null, "include_in_schema": null, "source": "app/routers/workspace_collections.py", - "ordinal": 305 + "ordinal": 307 }, { "method": "GET", @@ -3069,7 +3089,7 @@ "response_model": null, "include_in_schema": null, "source": "app/routers/workspace_collections.py", - "ordinal": 306 + "ordinal": 308 }, { "method": "GET", @@ -3079,7 +3099,7 @@ "response_model": null, "include_in_schema": null, "source": "app/routers/workspace_collections.py", - "ordinal": 307 + "ordinal": 309 }, { "method": "POST", @@ -3089,7 +3109,7 @@ "response_model": null, "include_in_schema": null, "source": "app/routers/workspace_collections.py", - "ordinal": 308 + "ordinal": 310 }, { "method": "PUT", @@ -3099,7 +3119,7 @@ "response_model": null, "include_in_schema": null, "source": "app/routers/workspace_collections.py", - "ordinal": 309 + "ordinal": 311 }, { "method": "DELETE", @@ -3109,7 +3129,7 @@ "response_model": null, "include_in_schema": null, "source": "app/routers/workspace_collections.py", - "ordinal": 310 + "ordinal": 312 }, { "method": "GET", @@ -3119,7 +3139,7 @@ "response_model": null, "include_in_schema": false, "source": "app/_launch_runtime.py", - "ordinal": 311 + "ordinal": 313 }, { "method": "POST", @@ -3129,7 +3149,7 @@ "response_model": null, "include_in_schema": null, "source": "app/routers/scene_commands.py", - "ordinal": 312 + "ordinal": 314 }, { "method": "GET", @@ -3139,7 +3159,7 @@ "response_model": null, "include_in_schema": null, "source": "app/routers/scene_commands.py", - "ordinal": 313 + "ordinal": 315 }, { "method": "POST", @@ -3149,7 +3169,57 @@ "response_model": null, "include_in_schema": null, "source": "app/routers/scene_commands.py", - "ordinal": 314 + "ordinal": 316 + }, + { + "method": "GET", + "path": "/api/v1/scenes/world3d/export/commands", + "endpoint": "commands", + "status_code": null, + "response_model": null, + "include_in_schema": null, + "source": "app/routers/world3d_export.py", + "ordinal": 317 + }, + { + "method": "GET", + "path": "/api/v1/scenes/world3d/export/capabilities", + "endpoint": "capabilities", + "status_code": null, + "response_model": null, + "include_in_schema": null, + "source": "app/routers/world3d_export.py", + "ordinal": 318 + }, + { + "method": "POST", + "path": "/api/v1/scenes/world3d/export", + "endpoint": "submit", + "status_code": null, + "response_model": null, + "include_in_schema": null, + "source": "app/routers/world3d_export.py", + "ordinal": 319 + }, + { + "method": "GET", + "path": "/api/v1/scenes/world3d/export/receipt", + "endpoint": "receipt", + "status_code": null, + "response_model": null, + "include_in_schema": null, + "source": "app/routers/world3d_export.py", + "ordinal": 320 + }, + { + "method": "POST", + "path": "/api/v1/scenes/world3d/export/cancel", + "endpoint": "cancel", + "status_code": null, + "response_model": null, + "include_in_schema": null, + "source": "app/routers/world3d_export.py", + "ordinal": 321 }, { "method": "GET", @@ -3159,7 +3229,7 @@ "response_model": null, "include_in_schema": null, "source": "app/routers/mcp_access.py", - "ordinal": 315 + "ordinal": 322 }, { "method": "PUT", @@ -3169,7 +3239,7 @@ "response_model": null, "include_in_schema": null, "source": "app/routers/mcp_access.py", - "ordinal": 316 + "ordinal": 323 }, { "method": "GET", @@ -3179,7 +3249,7 @@ "response_model": null, "include_in_schema": null, "source": "app/routers/image_generation_commands.py", - "ordinal": 317 + "ordinal": 324 }, { "method": "POST", @@ -3189,7 +3259,7 @@ "response_model": null, "include_in_schema": null, "source": "app/routers/image_generation_commands.py", - "ordinal": 318 + "ordinal": 325 }, { "method": "GET", @@ -3199,7 +3269,7 @@ "response_model": null, "include_in_schema": null, "source": "app/routers/image_generation_commands.py", - "ordinal": 319 + "ordinal": 326 }, { "method": "POST", @@ -3209,7 +3279,77 @@ "response_model": null, "include_in_schema": null, "source": "app/routers/image_generation_commands.py", - "ordinal": 320 + "ordinal": 327 + }, + { + "method": "GET", + "path": "/api/v1/wizard/workflows/executor/commands", + "endpoint": "commands", + "status_code": null, + "response_model": null, + "include_in_schema": null, + "source": "app/routers/wizard_workflow_executor.py", + "ordinal": 328 + }, + { + "method": "GET", + "path": "/api/v1/wizard/workflows/executor", + "endpoint": "list_workflows", + "status_code": null, + "response_model": null, + "include_in_schema": null, + "source": "app/routers/wizard_workflow_executor.py", + "ordinal": 329 + }, + { + "method": "GET", + "path": "/api/v1/wizard/workflows/executor/{workflow_id}", + "endpoint": "get_workflow", + "status_code": null, + "response_model": null, + "include_in_schema": null, + "source": "app/routers/wizard_workflow_executor.py", + "ordinal": 330 + }, + { + "method": "POST", + "path": "/api/v1/wizard/workflows/executor", + "endpoint": "start", + "status_code": null, + "response_model": null, + "include_in_schema": null, + "source": "app/routers/wizard_workflow_executor.py", + "ordinal": 331 + }, + { + "method": "POST", + "path": "/api/v1/wizard/workflows/executor/answer", + "endpoint": "answer", + "status_code": null, + "response_model": null, + "include_in_schema": null, + "source": "app/routers/wizard_workflow_executor.py", + "ordinal": 332 + }, + { + "method": "POST", + "path": "/api/v1/wizard/workflows/executor/resume", + "endpoint": "resume", + "status_code": null, + "response_model": null, + "include_in_schema": null, + "source": "app/routers/wizard_workflow_executor.py", + "ordinal": 333 + }, + { + "method": "POST", + "path": "/api/v1/wizard/workflows/executor/reconcile", + "endpoint": "reconcile", + "status_code": null, + "response_model": null, + "include_in_schema": null, + "source": "app/routers/wizard_workflow_executor.py", + "ordinal": 334 }, { "method": "POST", @@ -3217,9 +3357,19 @@ "endpoint": "mcp", "status_code": null, "response_model": null, + "include_in_schema": false, + "source": "app/routers/wangp_mcp.py", + "ordinal": 335 + }, + { + "method": "POST", + "path": "/api/v1/mcp", + "endpoint": "mcp", + "status_code": null, + "response_model": null, "include_in_schema": null, "source": "app/routers/wangp_mcp.py", - "ordinal": 321 + "ordinal": 336 }, { "method": "GET", @@ -3227,9 +3377,29 @@ "endpoint": "no_stream", "status_code": null, "response_model": null, + "include_in_schema": false, + "source": "app/routers/wangp_mcp.py", + "ordinal": 337 + }, + { + "method": "GET", + "path": "/api/v1/mcp", + "endpoint": "no_stream", + "status_code": null, + "response_model": null, "include_in_schema": null, "source": "app/routers/wangp_mcp.py", - "ordinal": 322 + "ordinal": 338 + }, + { + "method": "GET", + "path": "/api/v1/system/capabilities", + "endpoint": "get_capabilities", + "status_code": null, + "response_model": null, + "include_in_schema": null, + "source": "app/routers/system_capabilities.py", + "ordinal": 339 }, { "method": "GET", @@ -3239,7 +3409,7 @@ "response_model": null, "include_in_schema": null, "source": "app/_launch_runtime.py", - "ordinal": 323 + "ordinal": 340 } ] } diff --git a/tests/fixtures/wizard_mcp_corpus.json b/tests/fixtures/wizard_mcp_corpus.json new file mode 100644 index 000000000..9dc9119d4 --- /dev/null +++ b/tests/fixtures/wizard_mcp_corpus.json @@ -0,0 +1,671 @@ +{ + "version": 1, + "id": "wizard-mcp-corpus-20260911", + "base": "origin/development", + "expect_actions_not_prose": true, + "notes": { + "en": "Expect action types, receipts, rejection codes and task identity. Do not assert exact LLM prose.", + "es": "Espera tipos de acción, recibos, códigos de rechazo e identidad de tarea. No exijas la prosa exacta del LLM." + }, + "published_operations": [ + "generation.image", + "generation.speech", + "generation.music", + "generation.sfx", + "tools.upscale", + "generation.receipt" + ], + "published_collection_operations": [ + "collections.create", + "collections.update", + "collections.get", + "collections.list", + "commands.receipt" + ], + "unpublished_operations": [ + "generation.model3d" + ], + "legacy_mcp_tools": [ + "models", + "processors", + "status", + "assets", + "collections", + "organize", + "analyze", + "generate", + "recast", + "upscale" + ], + "cases": [ + { + "id": "en-image-generate", + "lang": "en", + "kind": "intent", + "surface": "both", + "request": "Prepare a Flux image of a red boat on calm water and generate it now.", + "proposal": { + "reply": "I created invented-boat.png successfully.", + "actions": [ + {"type": "prepare_image", "prompt": "a red boat on calm water", "model_type": "flux2_klein_4b"}, + {"type": "start_generation", "confirm": true} + ], + "intent": { + "kind": "action", + "goal": "Prepare a Flux image of a red boat on calm water and generate it now.", + "question": "", + "execution": "run" + } + }, + "expect": { + "operation": "generation.image", + "action_types": ["prepare_image", "start_generation"], + "creates_task": true, + "promise_success": false, + "receipt_status": "queued", + "reply_must_not_match": ["invented-boat.png", "successfully"] + } + }, + { + "id": "es-image-generate", + "lang": "es", + "kind": "intent", + "surface": "both", + "request": "Prepara una imagen Flux de un barco rojo en agua en calma y genérala ahora.", + "proposal": { + "reply": "He creado invented-barco.png con éxito.", + "actions": [ + {"type": "prepare_image", "prompt": "un barco rojo en agua en calma", "model_type": "flux2_klein_4b"}, + {"type": "start_generation", "confirm": true} + ], + "intent": { + "kind": "action", + "goal": "Prepara una imagen Flux de un barco rojo en agua en calma y genérala ahora.", + "question": "", + "execution": "run" + } + }, + "expect": { + "operation": "generation.image", + "action_types": ["prepare_image", "start_generation"], + "creates_task": true, + "promise_success": false, + "receipt_status": "queued", + "reply_must_not_match": ["invented-barco.png"] + } + }, + { + "id": "en-negation-do-not-generate", + "lang": "en", + "kind": "negation", + "surface": "wizard", + "request": "Prepare a Flux image of a red boat, but do not generate it.", + "proposal": { + "reply": "Queued invented-boat.png.", + "actions": [ + {"type": "prepare_image", "prompt": "a red boat"}, + {"type": "start_generation", "confirm": true} + ], + "intent": { + "kind": "action", + "goal": "Prepare a Flux image of a red boat, but do not generate it.", + "question": "", + "execution": "prepare" + } + }, + "expect": { + "action_types": ["prepare_image"], + "forbidden_action_types": ["start_generation"], + "creates_task": false, + "promise_success": false, + "reply_must_not_match": ["invented-boat.png"] + } + }, + { + "id": "es-negation-no-generes", + "lang": "es", + "kind": "negation", + "surface": "wizard", + "request": "Prepara una imagen de un barco rojo, no la generes.", + "proposal": { + "reply": "Generación en cola.", + "actions": [ + {"type": "prepare_image", "prompt": "un barco rojo"}, + {"type": "start_generation", "confirm": true} + ], + "intent": { + "kind": "action", + "goal": "Prepara una imagen de un barco rojo, no la generes.", + "question": "", + "execution": "prepare" + } + }, + "expect": { + "action_types": ["prepare_image"], + "forbidden_action_types": ["start_generation"], + "creates_task": false, + "promise_success": false + } + }, + { + "id": "en-how-to-image", + "lang": "en", + "kind": "negation", + "surface": "wizard", + "request": "How do I generate an image in Studio?", + "proposal": { + "reply": "I generated invented.png.", + "actions": [ + {"type": "prepare_image", "prompt": "an invented image"}, + {"type": "start_generation", "confirm": true} + ], + "intent": { + "kind": "conversation", + "goal": "How do I generate an image in Studio?", + "question": "", + "execution": "none" + } + }, + "expect": { + "action_types": [], + "forbidden_action_types": ["start_generation", "prepare_image"], + "creates_task": false, + "promise_success": false, + "reply_must_not_match": ["invented.png"] + } + }, + { + "id": "es-how-to-image", + "lang": "es", + "kind": "negation", + "surface": "wizard", + "request": "¿Cómo genero una imagen en Studio?", + "proposal": { + "reply": "He generado invented.png.", + "actions": [ + {"type": "prepare_image", "prompt": "una imagen inventada"}, + {"type": "start_generation", "confirm": true} + ], + "intent": { + "kind": "conversation", + "goal": "¿Cómo genero una imagen en Studio?", + "question": "", + "execution": "none" + } + }, + "expect": { + "action_types": [], + "forbidden_action_types": ["start_generation", "prepare_image"], + "creates_task": false, + "promise_success": false + } + }, + { + "id": "en-ambiguous-that", + "lang": "en", + "kind": "ambiguous", + "surface": "wizard", + "request": "Generate that again.", + "proposal": { + "reply": "I launched invented-999.", + "actions": [{"type": "start_generation", "confirm": true}], + "intent": { + "kind": "clarification", + "goal": "Generate that again.", + "question": "Which exact item should I regenerate?", + "execution": "none" + } + }, + "expect": { + "action_types": [], + "forbidden_action_types": ["start_generation"], + "creates_task": false, + "promise_success": false, + "rejection_codes": ["preparation_required"], + "reply_must_not_match": ["invented-999"] + } + }, + { + "id": "es-ambiguous-eso", + "lang": "es", + "kind": "ambiguous", + "surface": "wizard", + "request": "Genera eso otra vez.", + "proposal": { + "reply": "He lanzado invented-999.", + "actions": [{"type": "start_generation", "confirm": true}], + "intent": { + "kind": "clarification", + "goal": "Genera eso otra vez.", + "question": "¿Qué recurso exacto quieres que regenere?", + "execution": "none" + } + }, + "expect": { + "action_types": [], + "forbidden_action_types": ["start_generation"], + "creates_task": false, + "promise_success": false, + "rejection_codes": ["preparation_required"] + } + }, + { + "id": "en-workspace-change", + "lang": "en", + "kind": "workspace_change", + "surface": "both", + "request": "Switch to workspace corpus-b, then prepare a Flux image of a lantern. Do not generate yet.", + "proposal": { + "reply": "I generated in the previous folder.", + "actions": [ + {"type": "select_workspace", "workspace_name": "corpus-b"}, + {"type": "prepare_image", "prompt": "a lantern"}, + {"type": "start_generation", "confirm": true} + ], + "intent": { + "kind": "action", + "goal": "Switch to workspace corpus-b, then prepare a Flux image of a lantern. Do not generate yet.", + "question": "", + "execution": "prepare" + } + }, + "expect": { + "action_types": ["select_workspace", "prepare_image"], + "forbidden_action_types": ["start_generation"], + "creates_task": false, + "promise_success": false, + "receipt_workspace": "workspace-a", + "other_workspace": "corpus-b" + } + }, + { + "id": "es-workspace-change", + "lang": "es", + "kind": "workspace_change", + "surface": "wizard", + "request": "Cambia al workspace corpus-b y prepara una imagen de un farol. Todavía no la generes.", + "proposal": { + "reply": "Generado en la carpeta anterior.", + "actions": [ + {"type": "select_workspace", "workspace_name": "corpus-b"}, + {"type": "prepare_image", "prompt": "un farol"}, + {"type": "start_generation", "confirm": true} + ], + "intent": { + "kind": "action", + "goal": "Cambia al workspace corpus-b y prepara una imagen de un farol. Todavía no la generes.", + "question": "", + "execution": "prepare" + } + }, + "expect": { + "action_types": ["select_workspace", "prepare_image"], + "forbidden_action_types": ["start_generation"], + "creates_task": false, + "promise_success": false + } + }, + { + "id": "en-retry-same-intent", + "lang": "en", + "kind": "retry", + "surface": "mcp", + "request": "The HTTP response timed out. Repeat the exact generation.image command with the same intent_id; do not start a second job.", + "expect": { + "operation": "generation.image", + "creates_task": true, + "replay_same_id": true, + "two_clients_same_id": true, + "promise_success": false, + "receipt_status": "queued" + } + }, + { + "id": "es-retry-task", + "lang": "es", + "kind": "retry", + "surface": "wizard", + "request": "Reintenta exactamente una vez la tarea task-generation-corpus-1. No lances otra generación distinta.", + "proposal": { + "reply": "Creé un trabajo nuevo invented-2.", + "actions": [{"type": "retry_task", "task_id": "task-generation-corpus-1", "confirm": true}], + "intent": { + "kind": "action", + "goal": "Reintenta exactamente una vez la tarea task-generation-corpus-1. No lances otra generación distinta.", + "question": "", + "execution": "run" + } + }, + "expect": { + "action_types": ["retry_task"], + "forbidden_action_types": ["start_generation"], + "creates_task": false, + "promise_success": false, + "reply_must_not_match": ["invented-2"] + } + }, + { + "id": "en-compound-image", + "lang": "en", + "kind": "compound", + "surface": "wizard", + "request": "Open Studio, prepare a Flux image of a lantern, and generate it.", + "proposal": { + "reply": "Done, invented.png is finished.", + "actions": [ + {"type": "start_generation", "confirm": true}, + {"type": "open_tab", "tab": "studio"}, + {"type": "prepare_image", "prompt": "a lantern"} + ], + "intent": { + "kind": "action", + "goal": "Open Studio, prepare a Flux image of a lantern, and generate it.", + "question": "", + "execution": "run" + } + }, + "expect": { + "action_types": ["open_tab", "prepare_image"], + "forbidden_action_types": ["start_generation"], + "creates_task": false, + "promise_success": false, + "parser_drops_unprepared_start": true, + "reply_must_not_match": ["invented.png", "finished"] + } + }, + { + "id": "es-compound-story-song", + "lang": "es", + "kind": "compound", + "surface": "wizard", + "request": "Crea una historia titulada Faro y configura su canción. No inicies el videoclip.", + "proposal": { + "reply": "Canción y videoclip terminados.", + "actions": [ + {"type": "generate_story_song", "confirm": true, "target_story_title": "Faro", "cue_title": "Faro"}, + {"type": "create_story", "title": "Faro", "premise": "Un faro encierra un aviso.", "project_type": "music_video"}, + {"type": "configure_story_song", "target_story_title": "Faro", "song_title": "Faro", "song_brief": "aviso del faro", "music_style": "faro nocturno, cuerdas graves", "write_lyrics": true, "instrumental": false} + ], + "intent": { + "kind": "action", + "goal": "Crea una historia titulada Faro y configura su canción. No inicies el videoclip.", + "question": "", + "execution": "prepare" + } + }, + "expect": { + "ordered_predecessors": ["create_story", "configure_story_song", "generate_story_song"], + "creates_task": false, + "promise_success": false + } + }, + { + "id": "en-unpublished-model3d-mcp", + "lang": "en", + "kind": "unpublished", + "surface": "mcp", + "request": "Call MCP tool generation.model3d for a native mesh.", + "expect": { + "operation": "generation.model3d", + "unpublished": true, + "creates_task": false, + "promise_success": false, + "mcp_is_error": true + } + }, + { + "id": "es-unpublished-unknown-action", + "lang": "es", + "kind": "unpublished", + "surface": "wizard", + "request": "Usa la herramienta generation.video y dime que ya está el MP4.", + "proposal": { + "reply": "El MP4 invented.mp4 ya está listo.", + "actions": [{"type": "generation_video", "confirm": true, "prompt": "un clip"}], + "intent": { + "kind": "action", + "goal": "Usa la herramienta generation.video y dime que ya está el MP4.", + "question": "", + "execution": "run" + } + }, + "expect": { + "action_types": [], + "creates_task": false, + "promise_success": false, + "rejection_codes": ["invalid_action"], + "reply_must_not_match": ["invented.mp4", "listo"] + } + }, + { + "id": "en-speech-generate", + "lang": "en", + "kind": "intent", + "surface": "mcp", + "request": "Admit one Studio speech job with the literal line The system is watching.", + "expect": { + "operation": "generation.speech", + "creates_task": true, + "promise_success": false, + "receipt_status": "queued", + "replay_same_id": true + } + }, + { + "id": "es-music-generate", + "lang": "es", + "kind": "intent", + "surface": "mcp", + "request": "Admite una canción local con letra literal. No afirmes que el audio ya existe.", + "expect": { + "operation": "generation.music", + "creates_task": true, + "promise_success": false, + "receipt_status": "queued", + "replay_same_id": true + } + }, + { + "id": "en-sfx-generate", + "lang": "en", + "kind": "intent", + "surface": "mcp", + "request": "Admit MMAudio rain against glass in the output workspace.", + "expect": { + "operation": "generation.sfx", + "creates_task": true, + "promise_success": false, + "receipt_status": "queued" + } + }, + { + "id": "en-upscale", + "lang": "en", + "kind": "intent", + "surface": "mcp", + "request": "Admit tools.upscale lanczos2 for the exact source image.", + "expect": { + "operation": "tools.upscale", + "creates_task": true, + "promise_success": false, + "receipt_status": "queued", + "two_clients_same_id": true + } + }, + { + "id": "en-invalid-extra-field", + "lang": "en", + "kind": "error_recovery", + "surface": "mcp", + "request": "Submit generation.image with an authority actor field.", + "expect": { + "operation": "generation.image", + "invalid_extra_field": "actor", + "creates_task": false, + "http_status": 422, + "promise_success": false + } + }, + { + "id": "en-receipt-wrong-workspace", + "lang": "en", + "kind": "error_recovery", + "surface": "mcp", + "request": "Ask generation.receipt in a different workspace after a successful admission.", + "expect": { + "operation": "generation.receipt", + "creates_task": false, + "http_status": 404, + "promise_success": false + } + } + ], + "tours": [ + { + "id": "wizard-visible", + "surface": "wizard", + "steps": [ + "open-wizard", + "refusal-no-task", + "unpublished-rejection", + "queued-receipt-is-not-finished", + "workspace-change-prepare-only" + ] + }, + { + "id": "mcp-client", + "surface": "mcp", + "steps": [ + "discover-catalog", + "tools-list-published-only", + "admit-image", + "timeout-replay-same-id", + "second-client-same-id", + "unpublished-video-error", + "receipt-recovery" + ] + } + ], + "matrix": [ + { + "id": "catalog-http", + "item": "GET /api/v1/generation/commands", + "designed": true, + "implemented": true, + "simulated": true, + "real_executed": "probe", + "pending": "Does not enumerate every installed model; only published operations." + }, + { + "id": "catalog-mcp", + "item": "MCP tools/list vs HTTP catalog", + "designed": true, + "implemented": true, + "simulated": true, + "real_executed": false, + "pending": "Live MCP tools/list needs the Bearer token; this cut does not read it from the shared runtime." + }, + { + "id": "image-admission", + "item": "generation.image HTTP+MCP replay", + "designed": true, + "implemented": true, + "simulated": true, + "real_executed": false, + "pending": "flux2_klein_4b is installed on the shared runtime; no GPU job was enqueued there." + }, + { + "id": "speech-admission", + "item": "generation.speech", + "designed": true, + "implemented": true, + "simulated": true, + "real_executed": false, + "pending": "kugelaudio_0_open is installed; real speech inference not run in H17." + }, + { + "id": "music-admission", + "item": "generation.music", + "designed": true, + "implemented": true, + "simulated": true, + "real_executed": false, + "pending": "ace_step_v1_5_xl_sft_lm_4b is installed; real music inference not run in H17." + }, + { + "id": "sfx-admission", + "item": "generation.sfx", + "designed": true, + "implemented": true, + "simulated": true, + "real_executed": false, + "pending": "Real MMAudio inference not run in H17." + }, + { + "id": "upscale-admission", + "item": "tools.upscale", + "designed": true, + "implemented": true, + "simulated": true, + "real_executed": false, + "pending": "Lanczos does not need neural weights; still not dispatched on the shared GPU runtime." + }, + { + "id": "video-native", + "item": "generation.video", + "designed": true, + "implemented": false, + "simulated": true, + "real_executed": false, + "pending": "Unpublished on this base. Owned by H01. Corpus proves it cannot promise success." + }, + { + "id": "wizard-negation", + "item": "Refusal / how-to / do-not-generate", + "designed": true, + "implemented": true, + "simulated": true, + "real_executed": false, + "pending": "No live LLM tokens spent. Parser+reconcile contract only." + }, + { + "id": "idempotent-retry", + "item": "Timeout replay and two clients, same ID", + "designed": true, + "implemented": true, + "simulated": true, + "real_executed": false, + "pending": "Proven with FakeNative TestClient, not against Pinokio." + } + ], + "failures": [ + { + "id": "generation-video-unpublished", + "owner": "H01", + "severity": "expected-gap", + "repro": "GET /api/v1/generation/commands on development 780d3915 lists image/speech/music/sfx/upscale/receipt only. MCP tools/call name=generation.video returns isError and creates zero tasks.", + "status": "recorded_not_fixed" + }, + { + "id": "video-editor-frame-off-by-one", + "owner": "H09", + "severity": "prior-finding", + "repro": "Earlier V6 evidence: Video Editor job video-edit-3fb2aa82bd8e returned 589/590 frames. Not retested in H17.", + "status": "recorded_not_fixed" + }, + { + "id": "compound-prepare-and-generate-it", + "owner": "agentActions.reconcile", + "severity": "limitation", + "repro": "Ask 'Open Studio, prepare a Flux image of a lantern, and generate it.' Parser drops the leading start_generation (preparation_required). isExplicitImageGenerationRequest is false because 'generate' is not followed by 'image' in that clause, so start_generation is not restored. Result: form fill only, no task. Contrast 'Prepare a Flux image … and generate it now.' which does launch when the proposal already contains prepare+start in order.", + "status": "recorded_not_fixed" + }, + { + "id": "howto-empty-turn-keeps-model-prose", + "owner": "wizardTurnReport", + "severity": "limitation", + "repro": "Ask 'How do I generate an image in Studio?' while the model replies 'I generated invented.png.' Reconcile strips start_generation (no task). formatWizardTurnReply still keeps the invented sentence because the turn has no actions and the question is classified as explanation.", + "status": "recorded_not_fixed" + } + ] +} diff --git a/tests/test_activity_generation_details.py b/tests/test_activity_generation_details.py index eee1d78a6..0dabd7b16 100644 --- a/tests/test_activity_generation_details.py +++ b/tests/test_activity_generation_details.py @@ -9,6 +9,16 @@ LAUNCH = ROOT / "app" / "_launch_runtime.py" STORE = ROOT / "ui" / "src" / "stores" / "useStore.ts" ACTIVITY = ROOT / "ui" / "src" / "components" / "ActivityFooter.tsx" +ACTIVITY_FEATURE = ROOT / "ui" / "src" / "features" / "activity" +ACTIVITY_EN = ROOT / "ui" / "src" / "i18n" / "locales" / "en" / "activity.json" + + +def activity_ui() -> str: + parts = [ACTIVITY.read_text(encoding="utf-8"), ACTIVITY_EN.read_text(encoding="utf-8")] + for path in sorted(ACTIVITY_FEATURE.glob("*")): + if path.suffix in {".ts", ".tsx"}: + parts.append(path.read_text(encoding="utf-8")) + return "\n".join(parts) def test_backend_status_and_reconnect_publish_frozen_generation_details(): @@ -27,43 +37,45 @@ def test_backend_status_and_reconnect_publish_frozen_generation_details(): def test_activity_footer_places_exact_model_and_recipe_next_to_cancel(): - source = ACTIVITY.read_text(encoding="utf-8") + source = activity_ui() - assert "function generationRecipe" in source + assert "export function generationRecipe" in source assert "const parts = [task.provider, task.model]" in source - assert "details.video_model_name || details.video_model_type" in source - assert "details.image_model_name || details.image_model_type" in source - assert "flow shift ${details.flow_shift ?? details.flowShift}" in source - assert "audio shift ${details.audio_shift ?? details.audioShift}" in source + assert "details.video_model_name" in source + assert "details.video_model_type" in source + assert "details.image_model_name" in source + assert "details.image_model_type" in source + assert "flow shift" in source + assert "audio shift" in source assert "profile ${details.profile}" in source assert "Turbo ${details.turbo ? 'on' : 'off'}" in source assert "Cache off" in source assert "LoRAs off" in source - assert "primary?.model" in source + assert "primary.model" in source assert "title={generationRecipe(primary)}" in source assert "api.cancelCanonicalTask(taskId, workspace)" in source assert "primary.cancelable" in source - assert "function generationPrompt" in source - assert "function generationInitiator" in source + assert "export function generationPrompt" in source + assert "export function generationInitiator" in source assert "Click to copy the complete prompt" in source - assert "Started by {initiator}" in source + assert "Started by {{name}}" in source def test_activity_footer_treats_cancellation_as_terminal_history(): - source = ACTIVITY.read_text(encoding="utf-8") + source = activity_ui() - assert "planning: 'Planning'" in source - assert "cancelling: 'Cancelling at a safe boundary'" in source - assert "cancelled: 'Cancelled'" in source - assert "const ACTIVE = new Set(['created', 'queued', 'waiting_resource', 'running'])" in source - assert "primaryVisualState === 'cancelled'" in source + assert '"planning": "Planning"' in source + assert '"cancelling": "Cancelling at a safe boundary"' in source + assert '"cancelled": "Cancelled"' in source + assert "LIVE_TASK_STATUSES = new Set(['created', 'queued', 'waiting_resource', 'running'])" in source + assert "visual === 'cancelled'" in source def test_activity_footer_recovers_and_cancels_series_lab_jobs(): - source = ACTIVITY.read_text(encoding="utf-8") + source = activity_ui() assert "api.fetchCanonicalTasks(activeWorkspace, 'all')" in source assert "api.subscribeCanonicalTaskEvents" in source assert "api.cancelCanonicalTask(taskId, workspace)" in source assert "api.dismissCanonicalTask(taskId, workspace)" in source - assert "known_series_research: 'Building series bible'" in source + assert "Building series bible" in source diff --git a/tests/test_audio_word_timestamps.py b/tests/test_audio_word_timestamps.py index 3330903ba..ec688fffa 100644 --- a/tests/test_audio_word_timestamps.py +++ b/tests/test_audio_word_timestamps.py @@ -38,3 +38,68 @@ def test_transcription_exposes_clean_word_alignment(monkeypatch): (1.0, 1.3, "Hello"), (1.31, 1.7, "there"), ] + + +def test_known_lyrics_disable_vad_so_a_quiet_intro_is_not_dropped(monkeypatch): + model = _Model() + monkeypatch.setattr(audio_analysis, "_get_whisper_model", lambda: model) + + audio_analysis._transcribe("song.wav", "[Intro]\nThe wizard enters the chat") + + assert model.kwargs["vad_filter"] is False + assert model.kwargs["condition_on_previous_text"] is False + assert model.kwargs["max_initial_timestamp"] == 30.0 + + +def test_literal_lyrics_align_to_audio_and_create_action_anchor(): + transcript = [audio_analysis.LyricSegment( + start=18.3, + end=20.42, + text="Gandalf ha entrado al chat", + words=[ + audio_analysis.LyricWord(18.3, 18.9, "Gandalf"), + audio_analysis.LyricWord(18.91, 19.1, "ha"), + audio_analysis.LyricWord(19.16, 19.7, "entrado"), + audio_analysis.LyricWord(19.71, 19.9, "al"), + audio_analysis.LyricWord(19.91, 20.42, "chat"), + ], + )] + + timeline, timing = audio_analysis.align_authoritative_lyrics( + "[Intro hablado]\nGandalf ha entrado al chat.", transcript, 30.0, + ) + events = audio_analysis.build_visual_events(timeline) + + assert timeline[0].text == "Gandalf ha entrado al chat." + assert (timeline[0].start, timeline[0].end) == (18.3, 20.42) + assert timing["coverage"] == 1.0 + assert events[0]["kind"] == "entrance" + assert events[0]["time"] == 19.16 + assert "00:00:18,300 --> 00:00:20,420" in audio_analysis.lyrics_to_srt(timeline) + + +def test_aligned_section_structure_uses_audio_times(): + timeline = [ + {"start": 3.2, "section": "Intro hablado", "text": "Welcome"}, + {"start": 18.3, "section": "Verso 1", "text": "First verse"}, + {"start": 42.75, "section": "Estribillo", "text": "Chorus"}, + ] + + structure = audio_analysis.structure_from_aligned_lyrics(timeline) + + assert [item["start"] for item in structure] == [3.2, 18.3, 42.75] + assert [item["display_label"] for item in structure] == [ + "Intro hablado", "Verso 1", "Estribillo", + ] + assert [item["label"] for item in structure] == ["intro", "verse", "chorus"] + + +def test_missing_asr_keeps_all_written_lines_as_approximate_editable_cues(): + timeline, timing = audio_analysis.align_authoritative_lyrics( + "[Verse]\nFirst line\nSecond line", [], 20.0, + ) + + assert [cue.text for cue in timeline] == ["First line", "Second line"] + assert [(cue.start, cue.end) for cue in timeline] == [(0.0, 10.0), (10.0, 20.0)] + assert all(cue.source == "interpolated" for cue in timeline) + assert timing["coverage"] == 0.0 diff --git a/tests/test_ci_required.py b/tests/test_ci_required.py index 8ac6e9eb8..ce7b5f19a 100644 --- a/tests/test_ci_required.py +++ b/tests/test_ci_required.py @@ -7,17 +7,16 @@ import pytest -from scripts.ci_required import evaluate, main +from scripts.ci_required import REQUIRED_JOB_NAMES, evaluate, evaluate_required, main ROOT = Path(__file__).resolve().parents[1] SCRIPT = ROOT / "scripts" / "ci_required.py" -REQUIRED = [ - "Clean-repo guard + Python checks=success", - "UI tests + lint + type-check + build=success", - "UI E2E boot (Chromium + simulated API)=success", - "Speech E2E Windows (real H.264 + AAC)=success", -] +REQUIRED = [f"{name}=success" for name in REQUIRED_JOB_NAMES] + + +def _pairs_with(name: str, result: str) -> list[str]: + return [f"{item}={result if item == name else 'success'}" for item in REQUIRED_JOB_NAMES] def test_all_success_is_ok(): @@ -49,30 +48,45 @@ def test_cli_success_exit(): def test_cli_failed_dependency_exit(): - pairs = list(REQUIRED) - pairs[1] = "UI tests + lint + type-check + build=failure" - assert main(pairs) == 1 + assert main(_pairs_with("UI tests + lint + type-check + build", "failure")) == 1 @pytest.mark.parametrize("result", ["failure", "cancelled", "skipped", ""]) def test_windows_speech_export_is_a_required_dependency(result): - pairs = list(REQUIRED) - pairs[3] = f"Speech E2E Windows (real H.264 + AAC)={result}" - assert main(pairs) == 1 + assert main(_pairs_with("Speech E2E Windows (real H.264 + AAC)", result)) == 1 + + +@pytest.mark.parametrize("name", ["Python tests A", "Python tests B"]) +@pytest.mark.parametrize("result", ["failure", "cancelled", "skipped", ""]) +def test_python_shards_are_required_dependencies(name, result): + assert main(_pairs_with(name, result)) == 1 def test_cli_cancelled_dependency_exit(): - pairs = list(REQUIRED) - pairs[2] = "UI E2E boot (Chromium + simulated API)=cancelled" - assert main(pairs) == 1 + assert main(_pairs_with("UI E2E boot (Chromium + simulated API)", "cancelled")) == 1 def test_cli_skipped_dependency_exit(): - pairs = list(REQUIRED) - pairs[0] = "Clean-repo guard + Python checks=skipped" + assert main(_pairs_with("Clean-repo guard + Python checks", "skipped")) == 1 + + +def test_cli_missing_shard_fails_closed(): + pairs = [item for item in REQUIRED if not item.startswith("Python tests A=")] assert main(pairs) == 1 +def test_evaluate_required_fills_in_missing_jobs(): + ok, failed = evaluate_required({ + "Clean-repo guard + Python checks": "success", + "UI tests + lint + type-check + build": "success", + "UI E2E boot (Chromium + simulated API)": "success", + "Speech E2E Windows (real H.264 + AAC)": "success", + }) + assert ok is False + assert "Python tests A=missing" in failed + assert "Python tests B=missing" in failed + + def test_cli_invalid_pair_fails_closed(): assert main(["not-a-pair"]) == 2 @@ -92,4 +106,5 @@ def test_script_subprocess_matches_cli(): text=True, ) assert failed.returncode == 1 - assert "docs=skipped" in failed.stderr + assert "Python tests A=missing" in failed.stderr + assert "Python tests B=missing" in failed.stderr diff --git a/tests/test_ci_shards.py b/tests/test_ci_shards.py new file mode 100644 index 000000000..8cce1c173 --- /dev/null +++ b/tests/test_ci_shards.py @@ -0,0 +1,121 @@ +"""Shard membership and CI wiring. No GitHub API required.""" +from __future__ import annotations + +from pathlib import Path + +from scripts.ci_required import REQUIRED_JOB_NAMES +from scripts.select_local_tests import ( + discover_suite_files, + grouped_paths, + load_manifest, + partition_errors, +) + + +ROOT = Path(__file__).resolve().parents[1] +MANIFEST = ROOT / "scripts" / "ci_test_groups.json" +WORKFLOW = ROOT / ".github" / "workflows" / "ci.yml" +AGGREGATOR = ROOT / "scripts" / "ci_required.py" + + +def _manifest(): + return load_manifest(MANIFEST) + + +def test_every_automated_test_file_is_in_exactly_one_group(): + manifest = _manifest() + errors = partition_errors(ROOT, manifest) + assert errors == [] + discovered = discover_suite_files(ROOT) + grouped = grouped_paths(manifest) + assert discovered + assert sorted(grouped) == discovered + assert len(grouped) == len(discovered) + by_file = {} + for group in manifest["groups"]: + for path in group["paths"]: + by_file.setdefault(path, []).append(group["id"]) + overlapped = {path: ids for path, ids in by_file.items() if len(ids) != 1} + assert overlapped == {} + + +def test_shard_weights_are_duration_balanced(): + groups = _manifest()["groups"] + assert [group["id"] for group in groups] == ["python-a", "python-b"] + weights = [int(group["weight"]) for group in groups] + assert min(weights) > 0 + assert max(weights) / min(weights) <= 1.25 + file_counts = [len(group["paths"]) for group in groups] + assert abs(file_counts[0] - file_counts[1]) <= 5 + + +def test_workflow_lists_every_shard_in_needs_and_aggregator_pairs(): + workflow = WORKFLOW.read_text(encoding="utf-8") + aggregator = AGGREGATOR.read_text(encoding="utf-8") + assert "name: Python tests A" in workflow + assert "name: Python tests B" in workflow + assert "needs: [guard, python-tests-a, python-tests-b, ui-check, ui-e2e, ui-speech-windows]" in workflow + assert 'Python tests A=${{ needs.python-tests-a.result }}' in workflow + assert 'Python tests B=${{ needs.python-tests-b.result }}' in workflow + assert 'Speech E2E Windows (real H.264 + AAC)=${{ needs.ui-speech-windows.result }}' in workflow + for name in REQUIRED_JOB_NAMES: + assert f'"{name}"' in aggregator + assert f"{name}=" in workflow + required_block = workflow[workflow.index(" ci-required:") :] + assert "if: always()" in required_block + assert "code-health-comment" not in required_block.split("needs:", 1)[1].split("\n", 1)[0] + + +def test_python_shards_do_not_weaken_required_jobs(): + workflow = WORKFLOW.read_text(encoding="utf-8") + assert "skip-ci" not in workflow + assert "skip ci" not in workflow.lower() + a = workflow[workflow.index(" python-tests-a:") : workflow.index(" python-tests-b:")] + b = workflow[workflow.index(" python-tests-b:") : workflow.index(" ui-check:")] + required = workflow[workflow.index(" ci-required:") :] + for block in (a, b, required): + assert "continue-on-error:" not in block + guard = workflow[workflow.index(" guard:") : workflow.index(" python-tests-a:")] + assert "python -m pytest" not in guard + assert "python -m compileall" in guard + assert "--group python-a" in a + assert "--group python-b" in b + assert "test -s" in a + assert "test -s" in b + + +def test_pip_cache_keys_include_locks_and_python_requirements(): + workflow = WORKFLOW.read_text(encoding="utf-8") + a = workflow[workflow.index(" python-tests-a:") : workflow.index(" python-tests-b:")] + assert "cache: pip" in a + assert "scripts/ci-python-requirements.txt" in a + assert "scripts/ci-python-torch-cpu.txt" in a + assert "app/requirements.txt" in a + assert "app/runtime/locks/*.txt" in a + windows = workflow[workflow.index(" ui-speech-windows:") : workflow.index(" code-health-comment:")] + assert "cache: pip" in windows + assert "scripts/ci-python-windows-requirements.txt" in windows + assert "app/runtime/locks/*.txt" in windows + assert "actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9" in workflow + assert "actions/cache@v" not in workflow + assert "setup-python@v" not in workflow + + +def test_windows_speech_and_ui_e2e_stay_required(): + workflow = WORKFLOW.read_text(encoding="utf-8") + assert "name: Speech E2E Windows (real H.264 + AAC)" in workflow + assert "name: UI E2E boot (Chromium + simulated API)" in workflow + assert "HOCUSPOCUS_REQUIRE_SPEECH_AAC: \"1\"" in workflow + assert "scene3d-speech.spec.ts scene3d-media-screen.spec.ts" in workflow + + +def test_job_display_names_match_manifest(): + manifest = _manifest() + names = {group["id"]: group["name"] for group in manifest["groups"]} + jobs = {group["id"]: group["job"] for group in manifest["groups"]} + assert names["python-a"] == "Python tests A" + assert names["python-b"] == "Python tests B" + assert jobs["python-a"] == "python-tests-a" + assert jobs["python-b"] == "python-tests-b" + assert names["python-a"] in REQUIRED_JOB_NAMES + assert names["python-b"] in REQUIRED_JOB_NAMES diff --git a/tests/test_code_health.py b/tests/test_code_health.py index 4f31186ac..1030ddffe 100644 --- a/tests/test_code_health.py +++ b/tests/test_code_health.py @@ -253,5 +253,205 @@ def test_line_count_reports_physical_and_non_blank_lines(self): self.assertEqual(code_health._line_count(path), (3, 2)) + + +class ReleaseIntegrationHealthTests(unittest.TestCase): + def setUp(self): + from unittest.mock import patch + import code_health_integration + self.release = code_health_integration + self.patch = patch + + def report(self, lines=100_000, complex_count=100, file_complexity=20): + return { + "policy": dict(code_health.POLICY), + "measurement": {"ui": "complete"}, + "product_paths": ["app/services/example.py"], + "summary": { + "production_lines": lines, "complex_functions": complex_count, + "max_complexity": 50, "functions_measured": 1000, + }, + "hotspots": {}, + "complexity_hotspots": {"app/services/example.py": file_complexity}, + } + + def compare(self, *reports): + return self.release.compare_release( + reports[-1], reports[0], [str(i) for i in range(len(reports))], list(reports), + ) + + def test_cumulative_growth_passes_only_when_each_integration_fits(self): + first = self.report() + middle = self.report(103_000, 105) + last = self.report(106_000, 110) + _, ordinary_failures = code_health.compare(last, first) + self.assertEqual(len(ordinary_failures), 2) + _, failures, _ = self.compare(first, middle, last) + self.assertEqual(failures, []) + + def test_later_reduction_cannot_hide_an_aggregate_budget_failure(self): + for middle in (self.report(104_000), self.report(complex_count=106)): + with self.subTest(summary=middle["summary"]): + _, failures, _ = self.compare(self.report(), middle, self.report()) + self.assertTrue(any(item.startswith("Integration 1:") for item in failures)) + + def test_current_hotspot_still_compares_with_release_base(self): + _, failures, _ = self.compare( + self.report(), self.report(file_complexity=25), self.report(file_complexity=30), + ) + self.assertTrue(any("complexity hotspot" in item for item in failures)) + + def test_repaired_historical_hotspot_is_reported_and_final_limit_remains(self): + _, failures, historical = self.compare( + self.report(), self.report(file_complexity=40), self.report(), + ) + self.assertEqual(failures, []) + self.assertEqual(len(historical), 1) + self.assertIn("complexity hotspot", historical[0]["local_findings"][0]) + + def test_policy_and_missing_measurement_failures_are_not_relaxed(self): + last = self.report() + last["policy"]["line_growth_pct"] = 1 + self.assertTrue(any("policy changed" in item for item in self.compare(self.report(), last)[1])) + last = self.report() + last["measurement"]["ui"] = "missing" + self.assertTrue(any("not measured" in item for item in self.compare(self.report(), last)[1])) + + def test_missing_or_disagreeing_checkpoints_fail_closed(self): + baseline = self.report() + for measured in ({**baseline, "product_paths": []}, self.report(complex_count=99)): + with self.subTest(measured=measured): + with self.assertRaisesRegex(ValueError, "disagree"): + self.release.compare_release(baseline, baseline, ["base", "head"], [baseline, measured]) + with self.assertRaisesRegex(ValueError, "Missing integration"): + self.release.compare_release(baseline, baseline, ["base", "head"], [baseline]) + + def chain_git(self, overrides=None): + base, head, common = "a" * 40, "b" * 40, "c" * 40 + answers = { + ("rev-parse", "--is-shallow-repository"): "false", + ("merge-base", base, head): common, + ("rev-parse", f"{base}^{{tree}}"): "base-tree", + ("rev-parse", f"{common}^{{tree}}"): "base-tree", + ("rev-parse", "HEAD^{tree}"): "head-tree", + ("rev-parse", f"{head}^{{tree}}"): "head-tree", + ("diff", "HEAD", "--name-only", "--", "app", "ui/src", *self.release.MEASUREMENT_INPUTS): "", + ("rev-list", "--first-parent", "--reverse", f"{common}..{head}"): head, + ("rev-parse", f"{head}^1"): common, + } + answers.update(overrides or {}) + return base, head, lambda *args: answers[args] + + def test_chain_requires_full_history_matching_trees_and_contiguous_parents(self): + base, head, fake_git = self.chain_git() + with self.patch.object(self.release, "git", fake_git): + self.assertEqual(self.release.release_chain(base, head), [base, head]) + cases = [ + ({("rev-parse", "--is-shallow-repository"): "true"}, "complete history"), + ({("rev-parse", f"{base}^{{tree}}"): "other"}, "merge-base"), + ({("rev-parse", "HEAD^{tree}"): "other"}, "candidate tree"), + ({("rev-parse", f"{head}^1"): "other"}, "gap"), + ] + for overrides, expected in cases: + with self.subTest(expected=expected): + _, _, fake_git = self.chain_git(overrides) + with self.patch.object(self.release, "git", fake_git): + with self.assertRaisesRegex(ValueError, expected): + self.release.release_chain(base, head) + with self.assertRaisesRegex(ValueError, "exact commit"): + self.release.release_chain("origin/main", head) + + def test_changed_or_missing_historical_measurement_inputs_fail_closed(self): + rows = [('100644', 'blob', 'a' * 40, path) for path in self.release.MEASUREMENT_INPUTS] + for second in (rows[:-1], [(mode, kind, 'b' * 40, path) for mode, kind, _, path in rows]): + with self.patch.object(self.release, 'tree_entries', side_effect=[rows, second]), \ + self.patch.object(self.release, "git", return_value='{}'): + with self.assertRaisesRegex(ValueError, "measurement inputs|measurement inputs changed"): + self.release.read_trees(["base", "head"]) + + def test_only_test_script_changes_are_irrelevant_to_measurement(self): + base = {"scripts": {"test": "old", "postinstall": "safe"}, "dependencies": {"eslint": "1"}} + changed = {**base, "scripts": {"test": "new", "postinstall": "safe"}} + original = self.release.measurement_manifest(json.dumps(base)) + self.assertEqual(original, self.release.measurement_manifest(json.dumps(changed))) + changed["scripts"]["postinstall"] = "mutate-eslint" + self.assertNotEqual(original, self.release.measurement_manifest(json.dumps(changed))) + + def test_main_push_requires_exact_unchanged_two_parent_development_merge(self): + import subprocess + + with tempfile.TemporaryDirectory() as folder: + root = Path(folder) + def git(*args): + return subprocess.check_output( + ['git', '-C', folder, '-c', 'user.name=Test', '-c', 'user.email=test@example.test', *args], + text=True, + ).strip() + git('init', '-q') + (root / 'file').write_text('base') + git('add', '.') + git('commit', '-qm', 'base') + base = git('rev-parse', 'HEAD') + git('checkout', '-qb', 'development') + (root / 'file').write_text('development') + git('commit', '-qam', 'feature') + source = git('rev-parse', 'HEAD') + git('checkout', '-qb', 'published', base) + git('merge', '--no-ff', '-qm', 'release', source) + head = git('rev-parse', 'HEAD') + with self.patch.object(self.release, 'ROOT', root): + self.assertEqual(self.release.main_push_source(base, head, source), source) + self.assertIsNone(self.release.main_push_source(source, head, source)) + self.assertIsNone(self.release.main_push_source(base, source, source)) + git('checkout', '-q', '--detach', source) + self.assertIsNone(self.release.main_push_source(base, source, source)) + git('checkout', '-q', '--detach', head) + self.assertIsNone(self.release.main_push_source(base, head, base)) + self.assertIsNone(self.release.main_push_source(base, 'HEAD', source)) + # A conflict resolution that changes the published tree is not a release passthrough. + changed = git('commit-tree', f'{base}^{{tree}}', '-p', base, '-p', source, '-m', 'changed merge') + git('checkout', '-q', '--detach', changed) + self.assertIsNone(self.release.main_push_source(base, changed, source)) + third = git('commit-tree', f'{source}^{{tree}}', '-p', base, '-m', 'third') + octopus = git('commit-tree', f'{source}^{{tree}}', '-p', base, '-p', source, '-p', third, '-m', 'octopus') + git('checkout', '-q', '--detach', octopus) + self.assertIsNone(self.release.main_push_source(base, octopus, source)) + + def test_intermediate_unicode_product_cannot_disappear_from_history(self): + import subprocess + + with tempfile.TemporaryDirectory() as folder: + root = Path(folder) + def git(*args): + return subprocess.check_output( + ['git', '-C', folder, '-c', 'user.name=Test', '-c', 'user.email=test@example.test', *args], + text=True, + ).strip() + git('init', '-q') + for name in self.release.MEASUREMENT_INPUTS: + path = root / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text('{}' if name.endswith('.json') else '', encoding='utf-8') + git('add', '.') + git('commit', '-qm', 'base') + chain = [git('rev-parse', 'HEAD')] + name = 'app/services/épisode.py' + path = root / name + path.parent.mkdir(parents=True) + path.write_text('pass\n' * 4001, encoding='utf-8') + git('add', '.') + git('commit', '-qm', 'temporary growth') + chain.append(git('rev-parse', 'HEAD')) + path.unlink() + git('add', '-u') + git('commit', '-qm', 'remove temporary growth') + chain.append(git('rev-parse', 'HEAD')) + with self.patch.object(self.release, 'ROOT', root): + trees, sources = self.release.read_trees(chain) + self.assertNotIn(name, trees[0]) + self.assertNotIn(name, trees[-1]) + self.assertEqual(len(sources[trees[1][name]].splitlines()), 4001) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_comic_video_preflight_pipeline.py b/tests/test_comic_video_preflight_pipeline.py index dbed6aadb..261ae67ed 100644 --- a/tests/test_comic_video_preflight_pipeline.py +++ b/tests/test_comic_video_preflight_pipeline.py @@ -1447,7 +1447,10 @@ def test_failed_validation_clears_only_failed_checkpoint_for_resume( ): wgp = _wgp_stub(tmp_path, fps=10) - def concatenate(_inputs, destination, _audio): + concat_calls: list[dict] = [] + + def concatenate(_inputs, destination, _audio, **kwargs): + concat_calls.append(kwargs) with open(destination, "wb") as handle: handle.write(b"joined") return True @@ -1583,6 +1586,8 @@ def regenerate( assert director_pipeline._pipelines[pid]["_clip_video_files"] == ( exact_names ) + assert concat_calls, "comic assembly must call concatenate" + assert concat_calls[-1].get("audio_duration_sec") == pytest.approx(2.0) finally: director_pipeline._pipelines.pop(pid, None) diff --git a/tests/test_core_runtime.py b/tests/test_core_runtime.py new file mode 100644 index 000000000..5e6ceba3f --- /dev/null +++ b/tests/test_core_runtime.py @@ -0,0 +1,1445 @@ +from __future__ import annotations + +import base64 +import json +import os +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +from fastapi.testclient import TestClient + +import core_runtime +from core_runtime import api +from services import core_upload +from services.platform_capabilities import FEATURE_UNAVAILABLE +from services.scene_commands import SceneCommands +from services import core_remote_image +from services.core_remote_image import catalog_entry + + +class ImmediateThread: + def __init__(self, target=None, args=(), kwargs=None, daemon=None, name=None): + self._target = target + self._args = args + + def start(self): + self._target(*self._args) + + +class CoreRuntimeTests(unittest.TestCase): + def setUp(self): + self.client = TestClient(api) + self.patches = [ + patch("services.platform_capabilities.host_platform", return_value="darwin"), + patch("services.platform_capabilities.host_machine", return_value="arm64"), + ] + for item in self.patches: + item.start() + + def tearDown(self): + for item in self.patches: + item.stop() + + def _in_temp_workspace(self): + folder = tempfile.TemporaryDirectory() + previous = os.getcwd() + os.chdir(folder.name) + return folder, previous + + def _leave_temp_workspace(self, folder, previous): + os.chdir(previous) + folder.cleanup() + + def test_saved_scene_url_keeps_its_workspace_after_active_folder_changes(self): + from services import core_workspace as core + + for requested in ("scene-lab", "default", None): + with self.subTest(workspace=requested): + folder, previous = self._in_temp_workspace() + try: + core.save_config({"services": {"active_workspace": "active-lab"}}) + scene = {"version": 1, "name": "Workspace round trip"} + payload = {"scene": scene} + if requested is not None: + payload["workspace"] = requested + saved = self.client.post("/api/v1/scenes", json=payload) + self.assertEqual(saved.status_code, 200, saved.text) + result = saved.json() + workspace = requested if requested is not None else "active-lab" + self.assertEqual( + result["url"], f"/api/v1/file/{result['name']}?workspace={workspace}", + ) + core.save_config({"services": {"active_workspace": "other-lab"}}) + fetched = self.client.get(result["url"]) + self.assertEqual(fetched.status_code, 200, fetched.text) + self.assertEqual(fetched.json(), scene) + self.assertEqual( + self.client.get(f"/api/v1/file/{result['name']}").status_code, 404, + ) + finally: + self._leave_temp_workspace(folder, previous) + + def test_boot_surface_and_local_engines_are_blocked(self): + listed = self.client.get("/api/v1/system/capabilities") + self.assertEqual(listed.status_code, 200) + self.assertEqual(listed.json()["profile"], "macos-arm64-core-remote") + self.assertFalse(listed.json()["ui"]["show_cuda_controls"]) + model = self.client.get("/api/v1/models").json()["models"][0] + self.assertEqual(model["model_type"], "minimax:image-01") + self.assertFalse(model["is_t2v"]) + self.assertEqual(model["family"], "minimax") + self.assertFalse(catalog_entry()["is_t2v"]) + self.assertIn("workspaces", self.client.get("/api/v1/workspaces").json()) + self.assertEqual(self.client.get("/api/v1/system-config").status_code, 200) + self.assertEqual(self.client.get("/api/v1/services-config").status_code, 200) + self.assertEqual(self.client.get("/api/v1/jobs").status_code, 200) + self.assertEqual(self.client.get("/api/v1/outputs").status_code, 200) + self.assertEqual(self.client.get("/api/v1/recipes").status_code, 200) + denied = self.client.post("/api/v1/generate") + self.assertEqual(denied.status_code, 409) + self.assertEqual(denied.json()["detail"]["code"], FEATURE_UNAVAILABLE) + self.assertEqual(self.client.post("/api/v1/model3d/generate", json={"provider": "local"}).status_code, 409) + self.assertEqual(self.client.post("/api/v1/director/pipeline/start").status_code, 409) + self.assertEqual(self.client.post("/api/v1/rig/generate").status_code, 409) + mcp = self.client.post("/api/v1/wangp/mcp", json={"params": {"name": "generate"}}) + self.assertEqual(mcp.status_code, 503) + self.assertEqual(self.client.post("/api/v1/tools/remove-background").status_code, 409) + missing = self.client.post("/api/v1/video-editor/probe", json={"source": "missing.mp4"}) + self.assertEqual(missing.status_code, 400) + + def test_diagnostics_snapshot_and_report_on_the_core_profile(self): + snapshot = self.client.get("/api/v1/diagnostics") + self.assertEqual(snapshot.status_code, 200, snapshot.text) + body = snapshot.json() + self.assertEqual(body["schema"], "hocuspocus.user-diagnostics-report") + self.assertIn("platform", body) + self.assertIn("capabilities", body) + report = self.client.post("/api/v1/diagnostics/report", json={ + "error": {"message": "MiniMax key missing", "code": "provider_unconfigured"}, + }) + self.assertEqual(report.status_code, 200, report.text) + packed = report.json() + self.assertEqual(packed["schema"], "hocuspocus.user-diagnostics-report") + self.assertEqual(packed["error"]["code"], "provider_unconfigured") + blob = json.dumps(packed) + self.assertNotIn("sk-", blob) + + def test_series_reference_import_and_refresh_are_persisted_in_core(self): + folder, previous = self._in_temp_workspace() + try: + series = self.client.post('/api/v1/series', json={'workspace':'default', 'title':'Reference test'}).json() + series['canon']['worldSummary'] = 'A persistent world' + series['characters'] = [{'id':'char_a', 'name':'Ada', 'referenceAssetIds':[]}] + series['locations'] = [{'id':'loc_a', 'name':'Lab', 'referenceAssetIds':[]}] + series['allowedProductionMethods'] = ['animation_2d', 'imported_video'] + path = f"/api/v1/series/{series['id']}" + saved = self.client.put(path, json={'workspace':'default', 'series':series, 'baseRevision':series['revision']}) + self.assertEqual(saved.status_code, 200, saved.text) + series = saved.json() + series = self.client.post(path + '/canon/approve', json={'workspace':'default', 'baseRevision':series['canon']['revision']}).json() + episode_response = self.client.post(path + '/episodes', json={'workspace':'default'}) + self.assertEqual(episode_response.status_code, 200, episode_response.text) + episode = episode_response.json() + Path('uploads').mkdir(exist_ok=True) + Path('uploads/portrait.png').write_bytes(b'reference fixture') + imported = self.client.post(path + '/assets/import', json={'workspace':'default', 'uploadPath':'portrait.png', + 'ownerType':'character', 'ownerId':'char_a', 'kind':'character', 'referenceRole':'primary_portrait', + 'metadata':{'prompt':'Ada in a paper cutout style', 'jobId':'image-job'}}) + self.assertEqual(imported.status_code, 200, imported.text) + result = imported.json() + self.assertEqual(result['series']['characters'][0]['referenceAssetIds'], [result['asset']['id']]) + self.assertEqual(result['asset']['metadata']['jobId'], 'image-job') + self.assertEqual(result['series']['canon']['approval'], 'draft') + retry = self.client.post(path + '/assets/import', json={'workspace':'default', 'uploadPath':'portrait.png', + 'ownerType':'character', 'ownerId':'char_a', 'kind':'character', 'metadata':{'jobId':'image-job'}}) + self.assertEqual(retry.status_code, 200, retry.text) + self.assertEqual(retry.json()['asset']['id'], result['asset']['id']) + self.assertEqual(retry.json()['series']['revision'], result['series']['revision']) + approved = self.client.post(path + '/canon/approve', json={'workspace':'default', 'baseRevision':result['series']['canon']['revision']}).json() + refreshed = self.client.post(path + f"/episodes/{episode['id']}/references/refresh", json={'workspace':'default', 'baseRevision':approved['revision']}) + self.assertEqual(refreshed.status_code, 200, refreshed.text) + snapshot = refreshed.json()['episodesById'][episode['id']]['canonSnapshot'] + self.assertEqual(snapshot['characters'][0]['primaryReferenceAssetId'], result['asset']['id']) + self.assertEqual(refreshed.json()['allowedProductionMethods'], ['animation_2d', 'imported_video']) + finally: + self._leave_temp_workspace(folder, previous) + + def test_mcp_settings_status_on_the_core_profile(self): + listed = self.client.get("/api/v1/settings/mcp") + self.assertEqual(listed.status_code, 200, listed.text) + body = listed.json() + self.assertEqual(body["endpoint"], "/api/v1/mcp") + self.assertEqual(body["authentication"], "Bearer") + self.assertNotIn("token", body) + blocked = self.client.put("/api/v1/settings/mcp", json={"enabled": True}) + self.assertEqual(blocked.status_code, 403) + closed = self.client.post("/api/v1/wangp/mcp", json={"jsonrpc": "2.0", "id": 1, "method": "ping"}) + self.assertEqual(closed.status_code, 503) + + def test_settings_token_gates_core_mcp_and_closes_when_disabled(self): + folder = tempfile.TemporaryDirectory() + original_path = core_runtime._mcp_access.path + client = TestClient(api, base_url="http://127.0.0.1:8080") + origin = {"Origin": "http://127.0.0.1:8080"} + ping = {"jsonrpc": "2.0", "id": 1, "method": "ping"} + try: + core_runtime._mcp_access.path = Path(folder.name) / "mcp-access.json" + self.assertEqual(client.post("/api/v1/wangp/mcp", json=ping).status_code, 503) + created = client.put("/api/v1/settings/mcp", json={"enabled": True}, headers=origin) + self.assertEqual(created.status_code, 200, created.text) + token = created.json()["token"] + self.assertGreaterEqual(len(token), 40) + authorized = {"Authorization": f"Bearer {token}"} + opened = client.post("/api/v1/wangp/mcp", headers=authorized, json=ping) + self.assertEqual(opened.status_code, 200, opened.text) + leaked = client.post("/api/v1/wangp/mcp", json=ping) + self.assertEqual(leaked.status_code, 401) + client.put("/api/v1/settings/mcp", json={"enabled": False}, headers=origin) + self.assertEqual(client.post("/api/v1/wangp/mcp", headers=authorized, json=ping).status_code, 503) + finally: + core_runtime._mcp_access.path = original_path + folder.cleanup() + + def test_remote_llm_load_is_not_blocked_as_local_engine(self): + with patch("services.llm_service.load_model"), patch( + "services.llm_service.get_status", return_value={"loaded": False, "provider": "minimax"}, + ): + response = self.client.post("/api/v1/llm/load", json={"provider": "minimax", "model_id": "MiniMax-M3"}) + self.assertEqual(response.status_code, 200) + + def test_llm_prompt_tools_use_remote_llm_and_hide_h3_planner(self): + missing = self.client.post("/api/v1/llm/enhance-prompt", json={}) + self.assertEqual(missing.status_code, 400, missing.text) + self.assertEqual(missing.json()["detail"], "prompt is required") + with patch("services.llm_service.is_loaded", return_value=False), patch( + "services.llm_service.load_model", + ), patch("services.llm_service.enhance_prompt", return_value="a talking cat") as enhance: + enhanced = self.client.post("/api/v1/llm/enhance-prompt", json={"prompt": "a cat", "mode": "image"}) + self.assertEqual(enhanced.status_code, 200, enhanced.text) + self.assertEqual(enhanced.json(), {"original": "a cat", "enhanced": "a talking cat"}) + enhance.assert_called_once() + with patch("services.llm_service.is_loaded", return_value=False), patch( + "services.llm_service.load_model", + ), patch("services.llm_service.describe_image", return_value="a red cube") as describe: + described = self.client.post("/api/v1/llm/describe-image", json={"image_path": "/tmp/cube.png"}) + self.assertEqual(described.status_code, 200, described.text) + self.assertEqual(described.json(), {"description": "a red cube"}) + describe.assert_called_once() + planned = self.client.post( + "/api/v1/llm/plan-h3-windows", + json={"prompt": "Clark turns toward the truck", "model_type": "minimax_h3"}, + ) + self.assertEqual(planned.status_code, 409, planned.text) + self.assertEqual(planned.json()["detail"]["code"], FEATURE_UNAVAILABLE) + + def test_video3d_world3d_save_persists_the_document(self): + document = SceneCommands(None).execute({ + "version": 1, + "operation": "scenes.effects.showcase", + "input": {"dimension": "3d", "collection": "anime"}, + })["result"]["document"] + preview = "data:image/png;base64," + base64.b64encode( + b"\x89PNG\r\n\x1a\npreview" + ).decode() + folder, previous = self._in_temp_workspace() + try: + missing = self.client.post("/api/v1/scenes/world3d", json=[]) + saved = self.client.post("/api/v1/scenes/world3d", json={ + "workspace": "default", + "document": document, + "name": "../My shot / v2", + "preview": preview, + }) + finally: + self._leave_temp_workspace(folder, previous) + self.assertEqual(missing.status_code, 422) + self.assertEqual(saved.status_code, 200, saved.text) + body = saved.json() + self.assertTrue(body["name"].endswith(".world3d.scene.json")) + self.assertIn("workspace=default", body["url"]) + self.assertNotIn("..", body["name"]) + + def test_world3d_export_admits_on_the_core_profile(self): + document = { + "version": 1, "units": "meters", "up": "y", "width": 64, "height": 64, + "fps": 30, "duration": 2 / 30, "templateId": "two-shot", + "camera": {"family": "establishment", "eye": [0, 1.6, 4.2], "look": [0, 1, 0], "fov": 50}, + "light": {"kind": "directional", "direction": [-0.35, -1, -0.25], "intensity": 1.15, "color": "#fff4e5"}, + "slots": [{ + "id": "subject_1", "slot": "subject_1", "position": [0, 0, 0], "rotationY": 0, + "scale": 1, "sourceUrl": "", "media": "model3d", "clip": None, + }], + } + folder, previous = self._in_temp_workspace() + try: + Path("outputs").mkdir() + catalog = self.client.get("/api/v1/scenes/world3d/export/commands") + with patch("services.world3d_export.threading.Thread", ImmediateThread): + admitted = self.client.post("/api/v1/scenes/world3d/export", json={ + "version": 1, + "operation": "scenes.world3d.export", + "intent_id": "mac-world3d-export", + "input": {"workspace": "default", "document": document, "refs": []}, + }) + finally: + self._leave_temp_workspace(folder, previous) + self.assertEqual(catalog.status_code, 200, catalog.text) + names = {item.get("name") for item in catalog.json().get("operations") or []} + self.assertIn("scenes.world3d.export", names) + self.assertEqual(admitted.status_code, 200, admitted.text) + body = admitted.json() + self.assertFalse(body["replayed"]) + self.assertEqual(body["receipt"]["operation"], "scenes.world3d.export") + self.assertTrue(body["receipt"]["taskIds"]) + + def test_scene_packages_export_and_import_on_the_core_profile(self): + from services.scene_packages import PACKAGE_KIND + + document = { + "version": 1, "units": "meters", "up": "y", "width": 64, "height": 64, + "fps": 30, "duration": 1, "templateId": "two-shot", + "camera": {"family": "establishment", "eye": [0, 1.6, 4.2], "look": [0, 1, 0], "fov": 50}, + "light": {"kind": "directional", "direction": [-0.35, -1, -0.25], "intensity": 1.15, "color": "#fff4e5"}, + "slots": [{ + "id": "subject_1", "slot": "subject_1", "position": [0, 0, 0], "rotationY": 0, + "scale": 1, + "sourceUrl": "/api/v1/file/hero.glb?workspace=default", + "sourceRef": { + "workspaceId": "default", + "filename": "hero.glb", + "url": "/api/v1/file/hero.glb?workspace=default", + "assetId": "asset_hero", + }, + "media": "model3d", + "clip": None, + }], + } + folder, previous = self._in_temp_workspace() + try: + Path("outputs").mkdir() + Path("outputs", "lab").mkdir() + Path("outputs", "hero.glb").write_bytes(b"glb-core-package") + catalog = self.client.get("/api/v1/scene-packages/format") + missing = self.client.post("/api/v1/scene-packages/export", json={}) + exported = self.client.post("/api/v1/scene-packages/export", json={ + "workspace": "default", + "title": "Harbour", + "documents": [document], + }) + imported = self.client.post( + "/api/v1/scene-packages/import", + params={"workspace": "lab"}, + content=exported.content, + headers={"Content-Type": "application/zip"}, + ) + finally: + self._leave_temp_workspace(folder, previous) + self.assertEqual(catalog.status_code, 200, catalog.text) + self.assertEqual(catalog.json()["kind"], PACKAGE_KIND) + self.assertEqual(missing.status_code, 422, missing.text) + self.assertEqual(exported.status_code, 200, exported.text) + self.assertTrue(exported.headers.get("content-type", "").startswith("application/zip")) + self.assertIn("harbour.scene-package.zip", exported.headers.get("content-disposition", "").lower()) + self.assertEqual(imported.status_code, 200, imported.text) + body = imported.json() + self.assertTrue(body["ok"]) + self.assertEqual(len(body["scenes"]), 1) + self.assertGreaterEqual(body["assets_created"], 1) + + def test_wizard_conversation_put_round_trips_instead_of_emptying(self): + conversation = { + "version": 1, + "revision": 0, + "messages": [{ + "id": "msg-1", + "role": "user", + "text": "Exporta el plano", + "createdAt": 1, + }], + "executions": [], + } + folder, previous = self._in_temp_workspace() + try: + empty = self.client.get("/api/v1/wizard/conversations") + saved = self.client.put("/api/v1/wizard/conversations", json={ + "workspace": "default", + "baseRevision": 0, + "conversation": conversation, + }) + loaded = self.client.get("/api/v1/wizard/conversations") + conflict = self.client.put("/api/v1/wizard/conversations", json={ + "workspace": "default", + "baseRevision": 0, + "conversation": conversation, + }) + finally: + self._leave_temp_workspace(folder, previous) + self.assertEqual(empty.status_code, 200) + self.assertEqual(empty.json()["messages"], []) + self.assertEqual(empty.json()["revision"], 0) + self.assertNotIn("conversations", empty.json()) + self.assertEqual(saved.status_code, 200, saved.text) + self.assertEqual(saved.json()["revision"], 1) + self.assertEqual(saved.json()["messages"][0]["text"], "Exporta el plano") + self.assertEqual(loaded.json()["messages"][0]["id"], "msg-1") + self.assertEqual(conflict.status_code, 409) + self.assertEqual(conflict.json()["detail"]["code"], "wizard_conversation_revision_conflict") + + def test_video_editor_export_name_cannot_escape_the_workspace(self): + from services import core_editor, core_workspace as core + + folder, previous = self._in_temp_workspace() + try: + workspace = core.workspace_dir("default") + with patch.object(core_editor, "resolve_media", return_value=os.path.join(workspace, "clip.mp4")), \ + patch.object(core_editor, "render_project", return_value={"duration": 1}): + Path(workspace, "clip.mp4").write_bytes(b"x") + job = core_editor.start_export({ + "clips": [{"source": "clip.mp4"}], + "name": "../etc/passwd / cut", + "workspace": "default", + }) + finally: + self._leave_temp_workspace(folder, previous) + self.assertNotIn("..", job["filename"]) + self.assertNotIn("/", job["filename"]) + self.assertTrue(job["filename"].endswith("_etc_passwd_cut.mp4")) + + def test_video_editor_screenshot_keeps_each_character_sheet_frame(self): + from services import core_editor, core_workspace as core + + def fake_extract(source, dest, time_seconds): + Path(dest).write_bytes(f"frame-{time_seconds}".encode()) + return {"time": time_seconds, "width": 8, "height": 8} + + folder, previous = self._in_temp_workspace() + try: + workspace = core.workspace_dir("default") + Path(workspace, "orbit.mp4").write_bytes(b"clip") + with patch.object(core_editor, "time") as frozen, patch.object( + core_editor, "extract_frame", side_effect=fake_extract, + ): + frozen.strftime.return_value = "2026-09-11-13h55m00s" + first = self.client.post("/api/v1/video-editor/screenshot", json={ + "source": "orbit.mp4", + "time": 0.1, + "name": "character_front", + "workspace": "default", + }) + second = self.client.post("/api/v1/video-editor/screenshot", json={ + "source": "orbit.mp4", + "time": 0.8, + "name": "character_front", + "workspace": "default", + }) + first_name = first.json()["filename"] + second_name = second.json()["filename"] + first_bytes = Path(workspace, first_name).read_bytes() + second_bytes = Path(workspace, second_name).read_bytes() + finally: + self._leave_temp_workspace(folder, previous) + self.assertEqual(first.status_code, 200, first.text) + self.assertEqual(second.status_code, 200, second.text) + self.assertEqual(first_name, "2026-09-11-13h55m00s_character_front_frame.png") + self.assertEqual(second_name, "2026-09-11-13h55m00s_character_front_frame_2.png") + self.assertEqual(first_bytes, b"frame-0.1") + self.assertEqual(second_bytes, b"frame-0.8") + + def test_video_editor_export_keeps_a_second_same_second_cut(self): + from services import core_editor, core_workspace as core + + def fake_render(clips, destination, **_kwargs): + Path(destination).write_bytes(f"export-{len(clips)}".encode()) + return {"duration": 1} + + folder, previous = self._in_temp_workspace() + try: + workspace = core.workspace_dir("default") + Path(workspace, "clip.mp4").write_bytes(b"clip") + with patch.object(core_editor, "time") as frozen, patch( + "services.core_editor.threading.Thread", ImmediateThread, + ), patch.object(core_editor, "render_project", side_effect=fake_render): + frozen.strftime.return_value = "2026-09-11-13h55m00s" + first = core_editor.start_export({ + "clips": [{"source": "clip.mp4"}], + "name": "Harbour cut", + "workspace": "default", + }) + second = core_editor.start_export({ + "clips": [{"source": "clip.mp4"}, {"source": "clip.mp4"}], + "name": "Harbour cut", + "workspace": "default", + }) + first_bytes = Path(workspace, first["filename"]).read_bytes() + second_bytes = Path(workspace, second["filename"]).read_bytes() + finally: + self._leave_temp_workspace(folder, previous) + self.assertEqual(first["filename"], "2026-09-11-13h55m00s_Harbour_cut.mp4") + self.assertEqual(second["filename"], "2026-09-11-13h55m00s_Harbour_cut_2.mp4") + self.assertEqual(first_bytes, b"export-1") + self.assertEqual(second_bytes, b"export-2") + + def test_production_profile_defaults_to_remote_providers(self): + listed = self.client.get("/api/v1/production-profile") + self.assertEqual(listed.status_code, 200) + profile = listed.json()["profile"] + self.assertEqual(profile["text"]["provider"], "minimax") + self.assertEqual(profile["image"]["provider"], "minimax") + self.assertEqual(profile["music"]["provider"], "minimax") + self.assertEqual(profile["model3d"]["provider"], "meshy") + + def test_mcp_lists_read_tools_and_omits_local_generate(self): + headers = {"Authorization": "Bearer core-mcp-token"} + with patch.object(core_runtime._mcp_access, "token", return_value="core-mcp-token"): + denied = self.client.post("/api/v1/wangp/mcp", json={ + "jsonrpc": "2.0", "id": 1, "method": "tools/list", + }) + self.assertEqual(denied.status_code, 401) + response = self.client.post("/api/v1/wangp/mcp", headers=headers, json={ + "jsonrpc": "2.0", "id": 1, "method": "tools/list", + }) + generate = self.client.post( + "/api/v1/wangp/mcp", headers=headers, json={"params": {"name": "generate"}}, + ) + self.assertEqual(response.status_code, 200) + names = {tool["name"] for tool in response.json()["result"]["tools"]} + self.assertIn("assets", names) + self.assertNotIn("generate", names) + self.assertEqual(generate.status_code, 409) + self.assertEqual(generate.json()["detail"]["code"], FEATURE_UNAVAILABLE) + + def test_story_and_series_libraries_persist(self): + folder, previous = self._in_temp_workspace() + try: + Path("outputs").mkdir() + library = self.client.put("/api/v1/stories/library", json={ + "workspace": "default", + "baseRevision": 0, + "library": {"version": 2, "revision": 0, "activeId": "", "projects": {}}, + }) + created = self.client.post("/api/v1/series", json={"workspace": "default", "title": "Mac series"}) + finally: + self._leave_temp_workspace(folder, previous) + self.assertEqual(library.status_code, 200) + self.assertEqual(created.status_code, 200) + self.assertTrue(str(created.json()["id"]).startswith("series_")) + + def test_series_assembly_joins_approved_clips_on_the_core_profile(self): + from services.series_library import ( + create_series_project, + empty_series_library, + write_series_library, + ) + + def concatenate(paths, output_path, **_kwargs): + Path(output_path).write_bytes(b"".join(Path(path).read_bytes() for path in paths)) + return True + + folder, previous = self._in_temp_workspace() + try: + Path("outputs").mkdir() + Path("outputs", "one.mp4").write_bytes(b"one") + Path("outputs", "two.mp4").write_bytes(b"two") + series = create_series_project("default", title="Harbour") + season_id = series["seasons"][0]["id"] + series["assets"] = { + "asset-1": {"id": "asset-1", "kind": "video", "uri": "outputs/one.mp4", + "ownerType": "episode", "ownerId": "episode-1"}, + "asset-2": {"id": "asset-2", "kind": "video", "uri": "outputs/two.mp4", + "ownerType": "episode", "ownerId": "episode-1"}, + } + series["episodesById"] = { + "episode-1": { + "id": "episode-1", + "seasonId": season_id, + "script": [{"id": "scene_1", "beats": [], "dialogue": []}], + "shots": [{ + "id": "shot-2", "order": 2, "sceneId": "scene_1", + "approvedAttemptId": "attempt-2", + "attempts": [{"id": "attempt-2", "status": "completed", + "outputAssetIds": ["asset-2"]}], + }, { + "id": "shot-1", "order": 1, "sceneId": "scene_1", + "approvedAttemptId": "attempt-1", + "attempts": [{"id": "attempt-1", "status": "completed", + "outputAssetIds": ["asset-1"]}], + }], + }, + } + series["seasons"][0]["episodeOrder"] = ["episode-1"] + library = empty_series_library("default") + library["seriesById"][series["id"]] = series + library["seriesOrder"] = [series["id"]] + write_series_library(str(Path("outputs")), library, "default") + with patch("services.core_series_assembly.concatenate_clips", side_effect=concatenate), patch( + "routers.series_assembly.threading.Thread", ImmediateThread, + ): + missing = self.client.post( + "/api/v1/series/missing/episodes/episode-1/assembly/start", + json={"workspace": "default"}, + ) + started = self.client.post( + f"/api/v1/series/{series['id']}/episodes/episode-1/assembly/start", + json={"workspace": "default"}, + ) + job = started.json() if started.status_code == 200 else {} + status = self.client.get( + f"/api/v1/series/assembly/jobs/{job.get('jobId')}", + params={"workspace": "default"}, + ) + listed = self.client.get("/api/v1/series/assembly/recovery", params={"workspace": "default"}) + finally: + self._leave_temp_workspace(folder, previous) + self.assertEqual(missing.status_code, 404, missing.text) + self.assertEqual(started.status_code, 200, started.text) + self.assertEqual(status.status_code, 200, status.text) + body = status.json() + self.assertEqual(body["status"], "completed", status.text) + self.assertTrue(body["assetId"]) + self.assertTrue(str(body["filename"]).endswith(".mp4")) + self.assertEqual(listed.status_code, 200, listed.text) + + def test_series_import_copies_story_uploads_into_the_workspace(self): + folder, previous = self._in_temp_workspace() + try: + Path("outputs").mkdir() + Path("uploads").mkdir() + Path("uploads", "hero.png").write_bytes(b"png-bytes") + saved = self.client.put("/api/v1/stories/library", json={ + "workspace": "default", + "baseRevision": 0, + "library": { + "version": 2, + "revision": 0, + "activeId": "story_mac", + "projects": { + "story_mac": { + "id": "story_mac", + "title": "Harbour", + "premise": "A lantern wakes the harbour.", + "assets": { + "asset_hero": { + "id": "asset_hero", + "name": "Hero", + "source": "/api/v1/uploads/hero.png", + } + }, + "characters": [{ + "id": "char_keeper", + "name": "Keeper", + "referenceAssetIds": ["asset_hero"], + "primaryReferenceAssetId": "asset_hero", + }], + } + }, + }, + }) + imported = self.client.post("/api/v1/series/import-story", json={ + "workspace": "default", + "storyId": "story_mac", + }) + series = imported.json() if imported.status_code == 200 else {} + copied_bytes = b"" + copied_count = 0 + if series.get("id"): + assets_dir = Path("outputs") / "assets" / series["id"] + copied = list(assets_dir.glob("*")) if assets_dir.is_dir() else [] + copied_count = len(copied) + copied_bytes = copied[0].read_bytes() if copied else b"" + finally: + self._leave_temp_workspace(folder, previous) + self.assertEqual(saved.status_code, 200, saved.text) + self.assertEqual(imported.status_code, 200, imported.text) + assets = series.get("assets") or {} + self.assertIn("asset_hero", assets) + self.assertTrue(str(assets["asset_hero"]["uri"]).startswith(f"assets/{series['id']}/")) + self.assertTrue(str(assets["asset_hero"]["uri"]).endswith(".png")) + self.assertEqual(series["characters"][0]["referenceAssetIds"], ["asset_hero"]) + self.assertEqual(series["characters"][0]["primaryReferenceAssetId"], "asset_hero") + self.assertEqual(copied_count, 1) + self.assertEqual(copied_bytes, b"png-bytes") + + def test_series_import_rejects_a_missing_story_upload(self): + folder, previous = self._in_temp_workspace() + try: + Path("outputs").mkdir() + Path("uploads").mkdir() + denied = self.client.post("/api/v1/series/import-story", json={ + "workspace": "default", + "story": { + "id": "story_missing", + "title": "Missing hero", + "assets": { + "asset_hero": { + "id": "asset_hero", + "name": "Hero", + "source": "/api/v1/uploads/missing.png", + } + }, + }, + }) + finally: + self._leave_temp_workspace(folder, previous) + self.assertEqual(denied.status_code, 400, denied.text) + self.assertIn("no longer available", denied.json()["detail"]) + + def test_meshy_generate_starts_a_remote_job(self): + folder, previous = self._in_temp_workspace() + try: + Path("outputs").mkdir() + fake = {"filename": "meshy-test.glb", "path": "meshy-test.glb", "provider": "meshy"} + with patch("services.meshy_3d_service.generate_model", return_value=fake), patch( + "services.execution_mode.validate_remote_provider", + ): + response = self.client.post("/api/v1/model3d/generate", json={ + "provider": "meshy", "prompt": "a clay robot", "workspace": "default", + }) + finally: + self._leave_temp_workspace(folder, previous) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["provider"], "meshy") + + def test_minimax_music_rejects_local_models(self): + denied = self.client.post("/api/v1/stories/music-candidates/jobs", json={ + "model": "ace_step_v1_5_xl_sft_lm_4b", + "prompt": "synthwave", + "lyrics": "hello", + "workspace": "default", + }) + self.assertEqual(denied.status_code, 409) + self.assertEqual(denied.json()["detail"]["code"], FEATURE_UNAVAILABLE) + + def test_minimax_image_generate_starts_a_studio_job(self): + folder, previous = self._in_temp_workspace() + try: + Path("outputs").mkdir() + fake = {"name": "minimax.jpg", "path": "minimax.jpg", "prompt": "a lantern", "aspect_ratio": "1:1"} + with patch("services.core_remote_image.generate_image", return_value=fake), patch( + "services.execution_mode.validate_remote_provider", + ): + response = self.client.post("/api/v1/generate", json={ + "model_type": "minimax:image-01", + "generation_mode": "image", + "prompt": "a lantern in the rain", + "workspace": "default", + }) + finally: + self._leave_temp_workspace(folder, previous) + self.assertEqual(response.status_code, 200, response.text) + self.assertIn("job_id", response.json()) + status = self.client.get(f"/api/v1/status/{response.json()['job_id']}") + self.assertEqual(status.status_code, 200) + + def test_minimax_image_generate_encodes_upload_subject_reference(self): + captured = {} + + def fake_generate(**kwargs): + captured.update(kwargs) + return {"name": "minimax.jpg", "path": "minimax.jpg", "prompt": "a lantern", "aspect_ratio": "1:1"} + + folder, previous = self._in_temp_workspace() + try: + Path("uploads").mkdir() + Path("uploads", "hero.png").write_bytes(b"\x89PNG\r\n\x1a\n") + Path("outputs").mkdir() + with patch("services.core_remote_image.generate_image", side_effect=fake_generate), patch( + "services.execution_mode.validate_remote_provider", + ), patch("services.core_remote_image.threading.Thread", ImmediateThread): + response = self.client.post("/api/v1/generate", json={ + "model_type": "minimax:image-01", + "generation_mode": "image", + "prompt": "a lantern in the rain", + "workspace": "default", + "subject_reference": "/api/v1/uploads/hero.png", + }) + finally: + self._leave_temp_workspace(folder, previous) + self.assertEqual(response.status_code, 200, response.text) + self.assertTrue(captured["subject_reference"].startswith("data:image/png;base64,")) + + def _studio_image_command(self, intent="mac-image-intent"): + return { + "version": 2, + "operation": "generation.image", + "intent_id": intent, + "input": { + "workspace": "default", + "params": { + "model_type": "minimax:image-01", + "prompt": "a lantern in the rain", + "resolution": "1024x1024", + "num_inference_steps": 1, + "guidance_scale": 1.0, + "seed": 1, + "generation_mode": "image", + "image_mode": 1, + "video_length": 1, + }, + }, + } + + def test_studio_image_command_admits_minimax_and_replays(self): + from services.studio_image_spec import freeze_studio_image_spec + + command = self._studio_image_command() + folder, previous = self._in_temp_workspace() + try: + Path("outputs").mkdir() + fake = {"name": "minimax.jpg", "path": "minimax.jpg", "prompt": "a lantern", "aspect_ratio": "1:1"} + with patch("services.core_remote_image.generate_image", return_value=fake), patch( + "services.execution_mode.validate_remote_provider", + ), patch("services.core_remote_image.threading.Thread", ImmediateThread): + missing = self.client.post("/api/v1/generation/commands", json=[]) + first = self.client.post("/api/v1/generation/commands", json=command) + replay = self.client.post("/api/v1/generation/commands", json=command) + changed = dict(command) + changed["input"] = {**command["input"], "params": {**command["input"]["params"], "prompt": "another"}} + conflict = self.client.post("/api/v1/generation/commands", json=changed) + receipt = self.client.get( + "/api/v1/generation/commands/receipt", + params={"workspace": "default", "intent_id": command["intent_id"]}, + ) + local = self.client.post("/api/v1/generation/commands", json={ + **self._studio_image_command("local-flux"), + "input": { + "workspace": "default", + "params": { + **self._studio_image_command()["input"]["params"], + "model_type": "pi_flux2", + }, + }, + }) + finally: + self._leave_temp_workspace(folder, previous) + self.assertEqual(missing.status_code, 422, missing.text) + self.assertEqual(first.status_code, 200, first.text) + body = first.json() + receipt_body = body["receipt"] + self.assertFalse(body["replayed"]) + self.assertEqual(receipt_body["commandId"], command["intent_id"]) + self.assertEqual(receipt_body["operation"], "generation.image") + self.assertEqual(receipt_body["status"], "queued") + self.assertEqual(receipt_body["commandVersion"], 2) + self.assertEqual(receipt_body["contentFingerprint"], freeze_studio_image_spec(command)["fingerprint"]) + self.assertTrue(receipt_body["result"]["job_id"]) + self.assertEqual(receipt_body["result"]["workspace"], "default") + self.assertEqual(replay.status_code, 200, replay.text) + self.assertTrue(replay.json()["replayed"]) + self.assertEqual(replay.json()["receipt"]["result"]["job_id"], receipt_body["result"]["job_id"]) + self.assertEqual(conflict.status_code, 409, conflict.text) + self.assertEqual(conflict.json()["detail"]["code"], "intent_conflict") + self.assertEqual(receipt.status_code, 200, receipt.text) + self.assertEqual(receipt.json()["receipt"]["commandId"], command["intent_id"]) + status = self.client.get(f"/api/v1/status/{receipt_body['result']['job_id']}") + self.assertEqual(status.status_code, 200, status.text) + self.assertEqual(local.status_code, 409, local.text) + self.assertEqual(local.json()["detail"]["code"], FEATURE_UNAVAILABLE) + + def test_canonical_tasks_list_minimax_jobs_instead_of_an_empty_stub(self): + command = self._studio_image_command("mac-task-list") + folder, previous = self._in_temp_workspace() + try: + Path("outputs").mkdir() + fake = {"name": "minimax.jpg", "path": "minimax.jpg", "prompt": "a lantern", "aspect_ratio": "1:1"} + with patch("services.core_remote_image.generate_image", return_value=fake), patch( + "services.execution_mode.validate_remote_provider", + ), patch("services.core_remote_image.threading.Thread", ImmediateThread): + admitted = self.client.post("/api/v1/generation/commands", json=command) + listed = self.client.get("/api/v1/tasks", params={"workspace": "default", "status": "all"}) + task_ids = admitted.json()["receipt"].get("taskIds") or [] + task_id = str(task_ids[0]) if task_ids else "" + fetched = self.client.get(f"/api/v1/tasks/{task_id}", params={"workspace": "default"}) + cancelled = self.client.post( + f"/api/v1/tasks/{task_id}/cancel", + params={"workspace": "default"}, + ) + events = self.client.get( + f"/api/v1/tasks/{task_id}/events", + params={"workspace": "default"}, + ) + finally: + self._leave_temp_workspace(folder, previous) + self.assertEqual(admitted.status_code, 200, admitted.text) + self.assertTrue(task_id) + self.assertEqual(listed.status_code, 200, listed.text) + self.assertIn(task_id, {item.get("id") for item in listed.json().get("tasks") or []}) + self.assertNotEqual(listed.json().get("tasks"), []) + self.assertEqual(fetched.status_code, 200, fetched.text) + self.assertEqual(fetched.json()["task"]["id"], task_id) + self.assertEqual(cancelled.status_code, 200, cancelled.text) + self.assertEqual(events.status_code, 200, events.text) + + def test_studio_image_command_canonicalizes_upload_references(self): + folder, previous = self._in_temp_workspace() + try: + Path("uploads").mkdir() + Path("uploads", "hero.png").write_bytes(b"\x89PNG\r\n\x1a\n") + Path("outputs").mkdir() + response = self.client.post("/api/v1/generation/commands/references", json={ + "references": [str(Path("uploads", "hero.png").resolve())], + }) + finally: + self._leave_temp_workspace(folder, previous) + self.assertEqual(response.status_code, 200, response.text) + self.assertEqual(response.json()["references"], ["/api/v1/uploads/hero.png"]) + + def test_wizard_image_upscale_executor_starts_minimax_and_blocks_local_upscale(self): + from services import core_generation_commands + + folder, previous = self._in_temp_workspace() + try: + Path("outputs").mkdir() + fake = {"name": "minimax.jpg", "path": "minimax.jpg", "prompt": "a lantern", "aspect_ratio": "1:1"} + catalog = self.client.get("/api/v1/wizard/workflows/executor/commands") + with patch("services.core_remote_image.generate_image", return_value=fake), patch( + "services.execution_mode.validate_remote_provider", + ), patch("services.core_remote_image.threading.Thread", ImmediateThread): + started = self.client.post("/api/v1/wizard/workflows/executor", json={ + "workspace": "default", + "workflowId": "wf-mac-image", + "userRequest": "Generate a lantern", + "inputSnapshot": { + "model_type": "minimax:image-01", + "prompt": "a lantern in the rain", + "resolution": "1024x1024", + "num_inference_steps": 1, + "seed": 1, + "guidance_scale": 1.0, + }, + }) + self.assertEqual(started.status_code, 200, started.text) + workflow = started.json()["workflow"] + image_task_id = workflow["steps"][0]["taskId"] + synced = core_generation_commands.get_task("default", image_task_id) + ticked = self.client.post( + "/api/v1/wizard/workflows/executor/reconcile", + json={"workspace": "default"}, + ) + upscale = self.client.post("/api/v1/generation/commands", json={ + "version": 1, + "operation": "tools.upscale", + "intent_id": "mac-upscale", + "input": {"workspace": "default", "params": {"source": "minimax.jpg"}}, + }) + finally: + self._leave_temp_workspace(folder, previous) + self.assertEqual(catalog.status_code, 200, catalog.text) + operations = {item.get("name") for item in catalog.json().get("operations") or []} + self.assertIn("wizard.image_upscale", operations) + self.assertEqual(workflow["workflowId"], "wf-mac-image") + self.assertEqual(workflow["steps"][0]["kind"], "generation.image") + self.assertEqual(workflow["steps"][0]["state"], "waiting") + self.assertTrue(image_task_id) + self.assertIsNotNone(synced) + self.assertEqual(synced["status"], "completed") + self.assertEqual(synced["result_refs"], ["minimax.jpg"]) + self.assertEqual(ticked.status_code, 200, ticked.text) + advanced = ticked.json()["results"][0]["workflow"] + self.assertEqual(advanced["steps"][0]["state"], "completed") + self.assertEqual(advanced["steps"][0]["outputRefs"], ["minimax.jpg"]) + self.assertEqual(advanced["state"], "awaiting_input") + self.assertEqual(advanced["steps"][1]["state"], "awaiting_input") + self.assertEqual(upscale.status_code, 409, upscale.text) + self.assertEqual(upscale.json()["detail"]["code"], FEATURE_UNAVAILABLE) + + def test_outputs_classify_studio_kinds_and_honor_media_type(self): + from services.core_workspace import classify_output_type + + self.assertEqual(classify_output_type("2026-09-11_minimax-image-01_abcd1234.jpg"), "image") + self.assertEqual(classify_output_type("meshy-harbour.glb"), "model3d") + self.assertEqual(classify_output_type("minimax-music.wav"), "audio") + self.assertEqual(classify_output_type("harbour-shot.mp4"), "video") + self.assertEqual(classify_output_type("harbour.world3d.scene.json"), "scene") + self.assertEqual(classify_output_type("page-01.comic.json"), "comic") + self.assertIsNone(classify_output_type("harbour.preview.png")) + self.assertIsNone(classify_output_type("harbour.meta.json")) + + folder, previous = self._in_temp_workspace() + try: + Path("outputs").mkdir() + Path("outputs", "2026-09-11_minimax-image-01_abcd1234.jpg").write_bytes(b"jpg") + Path("outputs", "meshy-harbour.glb").write_bytes(b"glb") + Path("outputs", "minimax-music.wav").write_bytes(b"wav") + Path("outputs", "harbour-shot.mp4").write_bytes(b"mp4") + Path("outputs", "harbour.world3d.scene.json").write_text("{}", encoding="utf-8") + Path("outputs", "harbour.preview.png").write_bytes(b"preview") + Path("outputs", "harbour.meta.json").write_text("{}", encoding="utf-8") + listed = self.client.get("/api/v1/outputs") + images = self.client.get("/api/v1/outputs", params={"media_type": "image"}) + models = self.client.get("/api/v1/outputs", params={"media_type": "model3d", "limit": 1}) + finally: + self._leave_temp_workspace(folder, previous) + self.assertEqual(listed.status_code, 200, listed.text) + by_name = {item["name"]: item for item in listed.json()["outputs"]} + self.assertEqual(set(by_name), { + "2026-09-11_minimax-image-01_abcd1234.jpg", + "meshy-harbour.glb", + "minimax-music.wav", + "harbour-shot.mp4", + "harbour.world3d.scene.json", + }) + self.assertEqual(by_name["2026-09-11_minimax-image-01_abcd1234.jpg"]["type"], "image") + self.assertEqual(by_name["meshy-harbour.glb"]["type"], "model3d") + self.assertEqual(by_name["minimax-music.wav"]["type"], "audio") + self.assertEqual(by_name["harbour-shot.mp4"]["type"], "video") + self.assertEqual(by_name["harbour.world3d.scene.json"]["type"], "scene") + self.assertNotIn("file", {item["type"] for item in listed.json()["outputs"]}) + self.assertEqual([item["name"] for item in images.json()["outputs"]], [ + "2026-09-11_minimax-image-01_abcd1234.jpg", + ]) + self.assertEqual(images.json()["outputs"][0]["type"], "image") + self.assertEqual(models.json()["total"], 1) + self.assertEqual(models.json()["outputs"][0]["type"], "model3d") + + def test_wizard_image_executor_fails_when_minimax_job_fails(self): + from services import core_generation_commands + + folder, previous = self._in_temp_workspace() + try: + Path("outputs").mkdir() + with patch("services.core_remote_image.generate_image", side_effect=RuntimeError("minimax down")), patch( + "services.execution_mode.validate_remote_provider", + ), patch("services.core_remote_image.threading.Thread", ImmediateThread): + started = self.client.post("/api/v1/wizard/workflows/executor", json={ + "workspace": "default", + "workflowId": "wf-mac-fail", + "userRequest": "Generate a lantern", + "inputSnapshot": { + "model_type": "minimax:image-01", + "prompt": "a lantern in the rain", + "resolution": "1024x1024", + "num_inference_steps": 1, + "seed": 1, + "guidance_scale": 1.0, + }, + }) + self.assertEqual(started.status_code, 200, started.text) + image_task_id = started.json()["workflow"]["steps"][0]["taskId"] + synced = core_generation_commands.get_task("default", image_task_id) + ticked = self.client.post( + "/api/v1/wizard/workflows/executor/reconcile", + json={"workspace": "default"}, + ) + finally: + self._leave_temp_workspace(folder, previous) + self.assertIsNotNone(synced) + self.assertEqual(synced["status"], "failed") + self.assertEqual(ticked.status_code, 200, ticked.text) + failed = ticked.json()["results"][0]["workflow"] + self.assertEqual(failed["state"], "failed") + self.assertEqual(failed["steps"][0]["state"], "failed") + self.assertEqual(failed["workflowId"], "wf-mac-fail") + + def test_studio_image_command_encodes_upload_refs_for_minimax(self): + command = self._studio_image_command("mac-image-ref") + command["input"]["params"]["image_refs"] = ["/api/v1/uploads/hero.png"] + captured = {} + + def fake_generate(**kwargs): + captured.update(kwargs) + return {"name": "minimax.jpg", "path": "minimax.jpg", "prompt": "a lantern", "aspect_ratio": "1:1"} + + folder, previous = self._in_temp_workspace() + try: + Path("uploads").mkdir() + Path("uploads", "hero.png").write_bytes(b"\x89PNG\r\n\x1a\n") + Path("outputs").mkdir() + with patch("services.core_remote_image.generate_image", side_effect=fake_generate), patch( + "services.execution_mode.validate_remote_provider", + ), patch("services.core_remote_image.threading.Thread", ImmediateThread): + response = self.client.post("/api/v1/generation/commands", json=command) + finally: + self._leave_temp_workspace(folder, previous) + self.assertEqual(response.status_code, 200, response.text) + self.assertTrue(captured["subject_reference"].startswith("data:image/png;base64,")) + + def test_studio_image_command_rejects_oversized_prompt_before_admit(self): + command = self._studio_image_command("mac-image-long") + command["input"]["params"]["prompt"] = "x" * 10001 + folder, previous = self._in_temp_workspace() + try: + Path("outputs").mkdir() + with patch("services.execution_mode.validate_remote_provider"): + rejected = self.client.post("/api/v1/generation/commands", json=command) + command["input"]["params"]["prompt"] = "a lantern in the rain" + fake = {"name": "minimax.jpg", "path": "minimax.jpg", "prompt": "a lantern", "aspect_ratio": "1:1"} + with patch("services.core_remote_image.generate_image", return_value=fake), patch( + "services.core_remote_image.threading.Thread", ImmediateThread, + ): + accepted = self.client.post("/api/v1/generation/commands", json=command) + finally: + self._leave_temp_workspace(folder, previous) + self.assertEqual(rejected.status_code, 400, rejected.text) + self.assertEqual(rejected.json()["detail"]["code"], "invalid_command") + self.assertEqual(accepted.status_code, 200, accepted.text) + self.assertFalse(accepted.json()["replayed"]) + + def test_studio_image_command_replay_restarts_a_missing_job(self): + from services import core_remote_image + command = self._studio_image_command("mac-image-replay") + folder, previous = self._in_temp_workspace() + try: + Path("outputs").mkdir() + fake = {"name": "minimax.jpg", "path": "minimax.jpg", "prompt": "a lantern", "aspect_ratio": "1:1"} + with patch("services.core_remote_image.generate_image", return_value=fake), patch( + "services.execution_mode.validate_remote_provider", + ), patch("services.core_remote_image.threading.Thread", ImmediateThread): + first = self.client.post("/api/v1/generation/commands", json=command) + job_id = first.json()["receipt"]["result"]["job_id"] + core_remote_image._JOBS.pop(job_id, None) + replay = self.client.post("/api/v1/generation/commands", json=command) + status = self.client.get(f"/api/v1/status/{job_id}") + finally: + self._leave_temp_workspace(folder, previous) + self.assertEqual(first.status_code, 200, first.text) + self.assertEqual(replay.status_code, 200, replay.text) + self.assertTrue(replay.json()["replayed"]) + self.assertEqual(replay.json()["receipt"]["result"]["job_id"], job_id) + self.assertEqual(status.status_code, 200, status.text) + self.assertEqual(status.json()["job_id"], job_id) + + def test_series_plan_start_uses_the_remote_llm(self): + series = { + "id": "series_mac", "revision": 1, "provider": {}, + "episodesById": {"ep1": {"id": "ep1", "premise": "A lantern wakes the harbour.", "updatedAt": "t"}}, + } + library = {"seriesById": {"series_mac": series}} + + folder, previous = self._in_temp_workspace() + try: + Path("outputs").mkdir() + with patch("services.core_series_plan._series_or_404", return_value=(series, library)), patch( + "services.core_series_plan._generate_json", return_value={"outline": {"beats": ["The lantern wakes."]}}, + ), patch("services.core_series_plan._writing_override", return_value=None), patch( + "services.core_series_plan.threading.Thread", ImmediateThread, + ): + started = self.client.post( + "/api/v1/series/series_mac/episodes/ep1/plan/start", + json={"workspace": "default", "scope": "outline"}, + ) + finally: + os.chdir(previous) + folder.cleanup() + self.assertEqual(started.status_code, 200, started.text) + self.assertTrue(str(started.json()["jobId"]).startswith("series-plan-")) + + def test_series_known_series_bootstrap_auto_applies(self): + folder, previous = self._in_temp_workspace() + try: + Path("outputs").mkdir() + created = self.client.post("/api/v1/series", json={"workspace": "default", "title": "Untitled series"}) + self.assertEqual(created.status_code, 200, created.text) + series_id = created.json()["id"] + raw = { + "setup": { + "title": "Harbour Lights", + "premise": "A lantern wakes the harbour every dusk.", + "visualStyle": "Wet cobbles, amber lamps", + "format": "episodic", + "defaultEpisodeDurationSeconds": 75, + }, + "canon": { + "worldSummary": "A coastal town that bargains with the tide.", + "immutableRules": [{"id": "rule_tide", "description": "The lantern must be lit at dusk."}], + "currentFacts": [], + "forbiddenChanges": [], + "themes": ["duty"], + "longArcs": [], + "timeline": [], + }, + "characters": [{"id": "mara", "name": "Mara", "role": "keeper"}], + "locations": [{"id": "harbour", "name": "Harbour"}], + "relationships": [], + "props": [], + } + with patch("services.core_series_plan._generate_json", return_value=raw), patch( + "services.core_series_plan._writing_override", return_value=None, + ), patch("services.core_series_plan.threading.Thread", ImmediateThread): + started = self.client.post( + f"/api/v1/series/{series_id}/canon/prepare/start", + json={ + "workspace": "default", + "instruction": "Continue Harbour Lights", + "bootstrapKnownSeries": True, + "autoApply": True, + }, + ) + self.assertEqual(started.status_code, 200, started.text) + job_id = started.json()["jobId"] + job = self.client.get(f"/api/v1/series/plan/jobs/{job_id}") + self.assertEqual(job.status_code, 200, job.text) + body = job.json() + self.assertEqual(body["status"], "completed") + self.assertTrue(body["autoApplied"]) + self.assertEqual(body["seriesResult"]["title"], "Harbour Lights") + self.assertEqual(body["seriesResult"]["canon"]["immutableRules"][0]["description"], "The lantern must be lit at dusk.") + stored = self.client.get(f"/api/v1/series/{series_id}", params={"workspace": "default"}) + self.assertEqual(stored.status_code, 200, stored.text) + project = stored.json() + self.assertEqual(project["title"], "Harbour Lights") + self.assertEqual(project["characters"][0]["name"], "Mara") + self.assertEqual(project["characters"][0]["approval"], "draft") + finally: + self._leave_temp_workspace(folder, previous) + + def test_series_canon_prepare_normalizes_before_review(self): + folder, previous = self._in_temp_workspace() + try: + Path("outputs").mkdir() + created = self.client.post("/api/v1/series", json={ + "workspace": "default", + "title": "Harbour Lights", + }) + self.assertEqual(created.status_code, 200, created.text) + series_id = created.json()["id"] + raw = { + "canon": { + "worldSummary": "A coastal town that bargains with the tide.", + "immutableRules": [{"id": "rule_tide", "description": "The lantern must be lit at dusk."}], + "forbiddenChanges": ["No daylight lantern"], + "themes": ["duty"], + "longArcs": [], + }, + "characters": [{"id": "mara", "name": "Mara"}], + "locations": [{"id": "harbour", "name": "Harbour"}], + "relationships": [], + } + with patch("services.core_series_plan._generate_json", return_value=raw), patch( + "services.core_series_plan._writing_override", return_value=None, + ), patch("services.core_series_plan.threading.Thread", ImmediateThread): + started = self.client.post( + f"/api/v1/series/{series_id}/canon/prepare/start", + json={"workspace": "default", "instruction": "Tighten the bible"}, + ) + self.assertEqual(started.status_code, 200, started.text) + job = self.client.get(f"/api/v1/series/plan/jobs/{started.json()['jobId']}") + self.assertEqual(job.status_code, 200, job.text) + proposal = job.json()["seriesResult"] + self.assertEqual(job.json()["status"], "completed") + self.assertFalse(job.json().get("autoApplied")) + self.assertEqual(proposal["characters"][0]["approval"], "draft") + self.assertEqual(proposal["canon"]["immutableRules"][0]["status"], "draft") + applied = self.client.post(f"/api/v1/series/plan/jobs/{started.json()['jobId']}/apply-canon") + self.assertEqual(applied.status_code, 200, applied.text) + self.assertEqual(applied.json()["characters"][0]["name"], "Mara") + finally: + self._leave_temp_workspace(folder, previous) + + def test_local_llm_load_is_blocked(self): + blocked = self.client.post("/api/v1/llm/load", json={"provider": "local"}) + self.assertEqual(blocked.status_code, 409) + self.assertEqual(blocked.json()["detail"]["code"], FEATURE_UNAVAILABLE) + + def test_scene_recording_muxes_sidecar_audio_and_keeps_unique_names(self): + from services import core_scene_recording + + folder, previous = self._in_temp_workspace() + try: + Path("outputs").mkdir() + Path("outputs", "client-a").mkdir(parents=True) + source = Path("outputs", "client-a", "capture.webm") + voice = Path("outputs", "client-a", "mix.wav") + source.write_bytes(b"webm") + voice.write_bytes(b"wav") + seen = [] + + def fake_transcode(src, dest, *, fps, audio_tracks, duration, embedded_audio): + Path(dest).write_bytes(b"mp4-bytes") + seen.append({ + "src": src, + "dest": dest, + "fps": fps, + "audio_tracks": list(audio_tracks), + "duration": duration, + "embedded_audio": embedded_audio, + }) + + scene = { + "version": 1, + "name": "Harbour Shot", + "width": 64, + "height": 64, + "fps": 30, + "duration": 2, + "layers": [], + } + with patch("services.core_scene_recording.transcode_scene_recording", side_effect=fake_transcode), patch( + "services.core_scene_recording.publish_generation_sidecar", + ): + first = core_scene_recording.finalize_scene_recording( + source_path=str(source), + output_dir=str(Path("outputs", "client-a")), + scene=scene, + recipe={"engine": "world3d"}, + prompt="", + embedded_audio=False, + extra_audio_path=str(voice), + workspace="client-a", + ) + second = core_scene_recording.finalize_scene_recording( + source_path=str(source), + output_dir=str(Path("outputs", "client-a")), + scene=scene, + recipe={"engine": "world3d"}, + prompt="", + embedded_audio=False, + extra_audio_path=str(voice), + workspace="client-a", + ) + finally: + self._leave_temp_workspace(folder, previous) + self.assertEqual(len(seen), 2) + self.assertEqual(seen[0]["audio_tracks"][0]["path"], str(voice)) + self.assertFalse(seen[0]["embedded_audio"]) + self.assertNotEqual(first["name"], second["name"]) + self.assertTrue(first["name"].endswith(".mp4")) + self.assertIn("Harbour-Shot", first["name"]) + self.assertIn("workspace=client-a", first["url"]) + self.assertNotEqual(first["name"], "Harbour Shot.webm") + + def test_scene_recording_reads_workspace_from_metadata_not_a_form_field(self): + from services import core_scene_recording + + details = core_scene_recording.parse_recording_metadata(json.dumps({ + "workspace": "client-a", + "embeddedAudio": False, + "prompt": "", + "recipe": {"engine": "world3d"}, + "scene": { + "version": 1, + "name": "clip-01-world3d-scene", + "width": 64, + "height": 64, + "fps": 30, + "duration": 1, + "layers": [], + }, + })) + self.assertEqual(details["workspace"], "client-a") + self.assertEqual(details["scene"]["name"], "clip-01-world3d-scene") + with self.assertRaises(ValueError): + core_scene_recording.parse_recording_metadata("{}") + + def test_scene_recording_form_keeps_the_separate_voice_mix(self): + import asyncio + + from services import core_scene_recording + + class ChunkUpload: + def __init__(self, payload): + self._payload = payload + + async def read(self, size=-1): + data, self._payload = self._payload, b"" + return data + + async def close(self): + return None + + folder, previous = self._in_temp_workspace() + try: + Path("outputs").mkdir() + seen = [] + + def fake_transcode(src, dest, *, fps, audio_tracks, duration, embedded_audio): + Path(dest).write_bytes(b"mp4-bytes") + seen.append({"audio_tracks": [dict(item) for item in audio_tracks]}) + + form = { + "file": ChunkUpload(b"silent-webm"), + "audio": ChunkUpload(b"voice-wav"), + "metadata": json.dumps({ + "workspace": "default", + "embeddedAudio": False, + "prompt": "", + "recipe": {"engine": "world3d"}, + "scene": { + "version": 1, + "name": "Harbour Shot", + "width": 64, + "height": 64, + "fps": 30, + "duration": 2, + "layers": [], + }, + }), + } + with patch("services.core_scene_recording.transcode_scene_recording", side_effect=fake_transcode), patch( + "services.core_scene_recording.publish_generation_sidecar", + ): + saved = asyncio.run(core_scene_recording.publish_from_form(form)) + mix_path = seen[0]["audio_tracks"][0]["path"] + finally: + self._leave_temp_workspace(folder, previous) + self.assertTrue(saved["name"].endswith(".mp4")) + self.assertEqual(len(seen[0]["audio_tracks"]), 1) + self.assertIn("scene-audio", mix_path) + self.assertFalse(os.path.isfile(mix_path)) + + def _multipart_upload(self, filename: str, payload: bytes, content_type: str = "image/png"): + boundary = "----CoreUploadBoundary" + body = ( + f"--{boundary}\r\n" + f'Content-Disposition: form-data; name="file"; filename="{filename}"\r\n' + f"Content-Type: {content_type}\r\n" + f"\r\n" + ).encode("utf-8") + payload + f"\r\n--{boundary}--\r\n".encode("utf-8") + return self.client.post( + "/api/v1/upload", + content=body, + headers={"Content-Type": f"multipart/form-data; boundary={boundary}"}, + ) + + def test_multipart_upload_keeps_file_bytes_and_unique_names(self): + png = b"\x89PNG\r\n\x1a\nfirst-clip" + jpeg = b"\xff\xd8\xffsecond-clip" + folder, previous = self._in_temp_workspace() + try: + first = self._multipart_upload("../shot / take.png", png) + second = self._multipart_upload("take.jpg", jpeg) + self.assertEqual(first.status_code, 200, first.text) + self.assertEqual(second.status_code, 200, second.text) + first_body = first.json() + second_body = second.json() + served = self.client.get(first_body["url"]) + first_bytes = Path(first_body["path"]).read_bytes() + second_bytes = Path(second_body["path"]).read_bytes() + finally: + self._leave_temp_workspace(folder, previous) + self.assertTrue(first_body["filename"].endswith(".png")) + self.assertTrue(second_body["filename"].endswith(".jpg")) + self.assertNotEqual(first_body["filename"], second_body["filename"]) + self.assertNotIn("..", first_body["filename"]) + self.assertNotIn("/", first_body["filename"]) + self.assertTrue(first_body["url"].startswith("/api/v1/uploads/")) + self.assertEqual(first_bytes, png) + self.assertEqual(second_bytes, jpeg) + self.assertNotIn(b"Content-Disposition", first_bytes) + self.assertEqual(served.status_code, 200) + self.assertEqual(served.content, png) + + def test_extract_upload_prefers_file_part_and_rejects_empty_multipart(self): + extra = ( + b"------Part\r\n" + b'Content-Disposition: form-data; name="note"\r\n\r\n' + b"ignore\r\n" + b"------Part\r\n" + b'Content-Disposition: form-data; name="file"; filename="hero.png"\r\n' + b"Content-Type: image/png\r\n\r\n" + b"PIXELS\r\n" + b"------Part--\r\n" + ) + data, name = core_upload.extract_upload(extra, "multipart/form-data; boundary=----Part") + self.assertEqual(name, "hero.png") + self.assertEqual(data, b"PIXELS") + with self.assertRaises(ValueError): + core_upload.extract_upload( + b"------Part\r\nContent-Disposition: form-data; name=\"note\"\r\n\r\nx\r\n------Part--\r\n", + "multipart/form-data; boundary=----Part", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_core_series_assembly.py b/tests/test_core_series_assembly.py new file mode 100644 index 000000000..10be2cac7 --- /dev/null +++ b/tests/test_core_series_assembly.py @@ -0,0 +1,81 @@ +import shutil +import subprocess +from pathlib import Path + +import pytest + +from services.core_series_assembly import concatenate_clips +from services.mix_concat import probe_duration_seconds, probe_has_audio + + +def test_core_assembly_does_not_use_concat_demuxer(): + source = Path(__file__).resolve().parents[1] / "app" / "services" / "core_series_assembly.py" + text = source.read_text(encoding="utf-8") + assert '"-f", "concat"' not in text + assert '"-c", "copy"' not in text + assert "build_hard_concat_filter" in text + assert "concat_with_tail_hold_and_crossfade" in text + + +def test_concatenate_clips_fails_closed_when_a_planned_file_is_missing(tmp_path): + first = tmp_path / "one.mp4" + first.write_bytes(b"clip") + output = tmp_path / "joined.mp4" + assert concatenate_clips([str(first), str(tmp_path / "missing.mp4")], str(output)) is False + assert not output.exists() + + +def test_concatenate_clips_fails_closed_when_a_planned_file_is_empty(tmp_path): + first = tmp_path / "one.mp4" + empty = tmp_path / "empty.mp4" + first.write_bytes(b"clip") + empty.write_bytes(b"") + output = tmp_path / "joined.mp4" + assert concatenate_clips([str(first), str(empty)], str(output)) is False + assert not output.exists() + + +def _write_clip(path: Path, *, with_audio: bool, duration: float, fps: int, timescale: int) -> None: + cmd = [ + "ffmpeg", "-y", "-hide_banner", "-loglevel", "error", + "-f", "lavfi", "-i", f"testsrc=size=320x240:rate={fps}", + ] + if with_audio: + cmd += ["-f", "lavfi", "-i", f"sine=frequency=440:duration={duration}"] + cmd += ["-c:a", "aac"] + cmd += [ + "-t", f"{duration:.3f}", + "-c:v", "libx264", "-preset", "ultrafast", "-pix_fmt", "yuv420p", + "-video_track_timescale", str(timescale), + str(path), + ] + completed = subprocess.run(cmd, capture_output=True, text=True, timeout=30) + assert completed.returncode == 0, completed.stderr[-400:] + + +def _probe_duration(path: Path) -> float: + value = probe_duration_seconds(str(path)) + assert value is not None + return value + + +@pytest.mark.skipif( + shutil.which("ffmpeg") is None or shutil.which("ffprobe") is None, + reason="ffmpeg/ffprobe required", +) +def test_concatenate_clips_keeps_both_shots_when_timebase_and_audio_differ(tmp_path): + """Dialogue + video-only bumper used to be remuxed by concat+copy. + + ffmpeg returned 0, published a ~2.0s file, and dropped the bumper's 1.5s + (non-monotonic DTS, audio only from clip 0). The concat filter keeps both. + """ + talk = tmp_path / "talk.mp4" + bumper = tmp_path / "bumper.mp4" + output = tmp_path / "episode.mp4" + _write_clip(talk, with_audio=True, duration=1.0, fps=30, timescale=15360) + _write_clip(bumper, with_audio=False, duration=1.5, fps=24, timescale=12288) + assert concatenate_clips([str(talk), str(bumper)], str(output)) is True + duration = _probe_duration(output) + # Soft join adds a short freeze-tail; both source clips must still be there. + assert duration >= 2.4, duration + assert probe_has_audio(str(output)) is True diff --git a/tests/test_development_branch_policy.py b/tests/test_development_branch_policy.py index 39a8c79e7..2d55e6163 100644 --- a/tests/test_development_branch_policy.py +++ b/tests/test_development_branch_policy.py @@ -37,6 +37,29 @@ def test_ci_ratchet_uses_pr_base_or_push_before_and_fails_closed(self): text, ) + def test_release_budget_selection_requires_same_repository_metadata(self): + import os + import subprocess + + workflow = (ROOT / '.github/workflows/ci.yml').read_text(encoding='utf-8') + for field in ('base', 'head'): + self.assertIn(f'github.event.pull_request.{field}.repo.full_name', workflow) + wrapper = (ROOT / 'scripts/check_code_health_pr_base.sh').read_text(encoding='utf-8') + condition = wrapper.split('RELEASE_INTEGRATION=false\n', 1)[1].split('; then', 1)[0] + command = condition + '; then exit 0; else exit 1; fi' + metadata = { + 'BASE_BRANCH': 'main', 'SOURCE_BRANCH': 'development', + 'GITHUB_REPOSITORY': 'owner/project', + 'BASE_REPOSITORY': 'owner/project', 'SOURCE_REPOSITORY': 'owner/project', + } + cases = [({}, 0), ({'SOURCE_REPOSITORY': 'fork/project'}, 1), + ({'BASE_REPOSITORY': ''}, 1), ({'GITHUB_REPOSITORY': ''}, 1), + ({'SOURCE_BRANCH': 'feature'}, 1), ({'BASE_BRANCH': 'development'}, 1)] + for changed, expected in cases: + with self.subTest(changed=changed): + result = subprocess.run(['bash', '-c', command], env={**os.environ, **metadata, **changed}) + self.assertEqual(result.returncode, expected) + def test_ci_measures_without_pr_write_and_reuses_helper(self): text = (ROOT / '.github/workflows/ci.yml').read_text(encoding='utf-8') self.assertRegex(text, r'(?m)^permissions:\n contents: read\n') @@ -62,6 +85,19 @@ def test_ci_measures_without_pr_write_and_reuses_helper(self): self.assertLess(status_at, summary_at) self.assertLess(summary_at, exit_at) + def test_main_push_verification_fetches_complete_development_history_first(self): + wrapper = (ROOT / 'scripts/check_code_health_pr_base.sh').read_text(encoding='utf-8') + push = wrapper.split('elif [[ "${GITHUB_EVENT_NAME:-}" == "push"', 1)[1] + self.assertIn('"${GITHUB_REF:-}" == "refs/heads/main"', push) + self.assertIn('"${EVENT_REPOSITORY:-}" == "$GITHUB_REPOSITORY"', push) + self.assertIn('"${GITHUB_SHA:-}" == "$HEAD_SHA"', push) + self.assertIn('"$HEAD_SHA" == "$(git -C "$ROOT" rev-parse HEAD)"', push) + self.assertLess(push.index('--unshallow'), push.index('main_push_source')) + self.assertLess( + push.index('refs/heads/development:refs/remotes/origin/development'), + push.index('main_push_source'), + ) + def test_ci_cancels_only_superseded_pull_requests(self): text = (ROOT / '.github/workflows/ci.yml').read_text(encoding='utf-8') self.assertIn( @@ -73,18 +109,34 @@ def test_ci_cancels_only_superseded_pull_requests(self): text, ) + def test_ui_validation_runs_after_ratchet_failure_without_masking_failures(self): + text = (ROOT / '.github/workflows/ci.yml').read_text(encoding='utf-8') + ui = text[text.index(' ui-check:'):text.index(' ui-e2e:')] + self.assertIn('name: Install UI deps\n id: ui-deps', ui) + for name in ('UI tests', 'Lint with zero warnings', 'Type-check, build and bundle budget'): + self.assertIn( + f"name: {name}\n if: ${{{{ !cancelled() && steps.ui-deps.outcome == 'success' }}}}", + ui, + ) + self.assertNotIn('continue-on-error:', ui) + self.assertIn('exit "$STATUS"', ui) + def test_ci_required_aggregates_existing_job_names(self): text = (ROOT / '.github/workflows/ci.yml').read_text(encoding='utf-8') self.assertIn('name: Clean-repo guard + Python checks', text) + self.assertIn('name: Python tests A', text) + self.assertIn('name: Python tests B', text) self.assertIn('name: UI tests + lint + type-check + build', text) self.assertIn('name: UI E2E boot (Chromium + simulated API)', text) self.assertIn('name: CI required', text) self.assertIn('if: always()', text) - dependencies = 'needs: [guard, ui-check, ui-e2e, ui-speech-windows]' + dependencies = 'needs: [guard, python-tests-a, python-tests-b, ui-check, ui-e2e, ui-speech-windows]' self.assertIn(dependencies, text) self.assertNotIn('code-health-comment', text.split(dependencies, 1)[1][:200]) self.assertNotIn('independent-qa', text.split(dependencies, 1)[1][:200]) self.assertNotIn('Independent QA', text.split(dependencies, 1)[1][:400]) + self.assertIn('Python tests A=${{ needs.python-tests-a.result }}', text) + self.assertIn('Python tests B=${{ needs.python-tests-b.result }}', text) self.assertIn('Speech E2E Windows (real H.264 + AAC)=${{ needs.ui-speech-windows.result }}', text) windows = text.split(' ui-speech-windows:', 1)[1].split(' code-health-comment:', 1)[0] self.assertIn('HOCUSPOCUS_REQUIRE_SPEECH_AAC: "1"', windows) diff --git a/tests/test_director_cancellation.py b/tests/test_director_cancellation.py index 65fef171c..088cda306 100644 --- a/tests/test_director_cancellation.py +++ b/tests/test_director_cancellation.py @@ -1153,6 +1153,46 @@ def test_rejoin_rejects_stale_video_instead_of_omitting_clip(self): concatenate.assert_not_called() + def test_rejoin_rejects_stale_video_even_when_a_take_is_selected(self): + pid = "pipe-stale-selected-rejoin" + record = self._add_pipeline(pid, "completed") + record["clip_plans"] = [ + {"image_prompt": "one", "video_prompt": "one"}, + {"image_prompt": "two", "video_prompt": "two"}, + ] + record["_clip_video_files"] = ["one.mp4", "two.mp4"] + for filename in record["_clip_video_files"]: + self._write_media(filename, b"video") + self.assertTrue(pipeline._save_pipeline_state(pid)) + + def mark_selected_stale(state): + clip = state["clips"][0] + clip["selected_video_filename"] = clip["video_filename"] + clip["video_stale"] = True + + pipeline._update_saved_pipeline(self.temp_dir.name, pid, mark_selected_stale) + loaded = pipeline.load_pipeline_state(self.temp_dir.name, pid) + self.assertTrue(loaded["clips"][0]["video_stale"]) + self.assertEqual(loaded["clips"][0]["selected_video_filename"], "one.mp4") + + def keep_notes(state): + state["clips"][0]["review_notes"] = "keep stale" + + pipeline._update_saved_pipeline(self.temp_dir.name, pid, keep_notes) + raw_path = pipeline._find_pipeline_file(self.temp_dir.name, pid) + with open(raw_path, encoding="utf-8") as handle: + saved = json.load(handle) + self.assertTrue(saved["clips"][0]["video_stale"]) + self.assertEqual(saved["clips"][0]["review_notes"], "keep stale") + + concatenate = Mock(return_value=True) + pipeline._wgp.concatenate_multi_clip_videos = concatenate + with self.assertRaisesRegex( + ValueError, "stale video clip.*1.*before rejoining", + ): + pipeline.rejoin_clips(self.temp_dir.name, pid) + concatenate.assert_not_called() + def test_rejoin_rejects_clip_whose_start_image_is_missing(self): pid = "pipe-missing-rejoin-start" record = self._add_pipeline(pid, "completed") diff --git a/tests/test_director_h3_workflow_edits.py b/tests/test_director_h3_workflow_edits.py index 525e5834b..430eaebf2 100644 --- a/tests/test_director_h3_workflow_edits.py +++ b/tests/test_director_h3_workflow_edits.py @@ -2,6 +2,8 @@ from pathlib import Path from unittest.mock import patch +import pytest + from app.services import director_pipeline @@ -346,3 +348,59 @@ def concatenate_multi_clip_videos(paths, destination, _audio, **_kwargs): director_pipeline.rejoin_clips(str(tmp_path), "h3-selection") assert joined == ["shot0_studio.mp4", "shot1.mp4"] + + +@pytest.mark.parametrize("video_model", ["minimax_h3", "minimax_h3_legacy"]) +@pytest.mark.parametrize("selected", [None, "shot0_studio.mp4"]) +def test_h3_rejoin_rejects_stale_clip_even_when_segments_are_playable(tmp_path: Path, video_model, selected): + filenames = ("shot0_a.mp4", "shot0_b.mp4", "shot1.mp4") + for filename in filenames: + (tmp_path / filename).write_bytes(b"video") + if selected: + (tmp_path / selected).write_bytes(b"selected video") + _write_pipeline(tmp_path, { + "pipeline_id": "h3-stale-rejoin", + "created_at": 10.0, + "status": "completed", + "pipeline_type": "short_film_story", + "video_model": video_model, + "clips": [ + { + "index": 0, + "video_filename": "shot0_b.mp4", + "selected_video_filename": selected, + "video_stale": True, + "video_prompt": "Whole shot zero", + "h3_segments": [ + {"index": 0, "filename": "shot0_a.mp4", "stale": False}, + {"index": 1, "filename": "shot0_b.mp4", "stale": False}, + ], + }, + { + "index": 1, + "video_filename": "shot1.mp4", + "video_prompt": "Whole shot one", + "h3_segments": [ + {"index": 0, "filename": "shot1.mp4", "stale": False}, + ], + }, + ], + "output_files": list(filenames), + "workspace": "default", + }) + checkpoint = Path(director_pipeline._find_pipeline_file(str(tmp_path), "h3-stale-rejoin")) + before = checkpoint.read_bytes() + joined = [] + + class FakeWgp: + @staticmethod + def concatenate_multi_clip_videos(paths, destination, _audio, **_kwargs): + joined.extend(Path(path).name for path in paths) + Path(destination).write_bytes(b"joined") + return True + + with patch.object(director_pipeline, "_wgp", FakeWgp()): + with pytest.raises(ValueError, match="stale video clip.*1.*before rejoining"): + director_pipeline.rejoin_clips(str(tmp_path), "h3-stale-rejoin") + assert joined == [] + assert checkpoint.read_bytes() == before diff --git a/tests/test_director_review.py b/tests/test_director_review.py new file mode 100644 index 000000000..8f228965b --- /dev/null +++ b/tests/test_director_review.py @@ -0,0 +1,211 @@ +import json +from pathlib import Path + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient +from routers.director_review import create_director_review_router +from services import director_pipeline as pipeline +from services.director_review import save_review + + +def fixture(tmp_path): + state = {"pipeline_id": "review-test", "status": "completed", "clips": [ + {"index": 0, "video_filename": "new.mp4", "video_attempts": [ + {"id": "old-id", "filename": "old.mp4"}, {"id": "new-id", "filename": "new.mp4"}], + "video_prompt": "literal prompt", "tag": None}, + {"index": 1, "video_filename": "approved.mp4", "tag": "good"}, + ]} + path = tmp_path / f"{pipeline._PIPELINE_FILE_PREFIX}review-test.json" + path.write_text(json.dumps(state)) + for name in ("old.mp4", "new.mp4", "approved.mp4"): + (tmp_path / name).write_bytes(b"media placeholder for persistence-only test") + return path, state + + +def test_review_persists_exact_take_tag_and_notes_and_preserves_other_shots(tmp_path): + path, state = fixture(tmp_path) + app = FastAPI() + app.include_router(create_director_review_router(lambda name: str(tmp_path))) + response = TestClient(app).put('/api/v1/director/pipelines/review-test/review', json={ + 'workspace': 'test', 'commands': [ + {'type': 'select_take', 'pipelineId': 'review-test', 'clipIndex': 0, 'filename': 'old.mp4', 'takeId': 'old-id'}, + {'type': 'tag_clip', 'pipelineId': 'review-test', 'clipIndex': 0, 'tag': 'good'}, + {'type': 'note_clip', 'pipelineId': 'review-test', 'clipIndex': 0, 'notes': ' literal\nnotes '}, + ], + }) + assert response.status_code == 200, response.text + saved = json.loads(path.read_text()) + assert saved['clips'][0]['selected_video_filename'] == 'old.mp4' + assert saved['clips'][0]['review_notes'] == ' literal\nnotes ' + assert {item['filename'] for item in saved['clips'][0]['video_attempts']} == {'old.mp4', 'new.mp4'} + for original in state['clips'][0]['video_attempts']: + actual = next(item for item in saved['clips'][0]['video_attempts'] if item['filename'] == original['filename']) + assert {key: actual[key] for key in original} == original + assert {key: saved['clips'][1][key] for key in state['clips'][1]} == state['clips'][1] + + +def test_review_rejects_invalid_batch_without_partial_save(tmp_path): + path, _ = fixture(tmp_path) + before = path.read_bytes() + with pytest.raises(ValueError): + save_review(str(tmp_path), 'review-test', [ + {'type': 'note_clip', 'pipelineId': 'review-test', 'clipIndex': 0, 'notes': 'should roll back'}, + {'type': 'select_take', 'pipelineId': 'review-test', 'clipIndex': 0, 'filename': '../elsewhere.mp4'}, + ]) + assert path.read_bytes() == before + + +def test_review_obeys_pipeline_busy_guard(tmp_path): + fixture(tmp_path) + pipeline._pipeline_operations.add('review-test') + try: + with pytest.raises(pipeline.PipelineBusyError): + save_review(str(tmp_path), 'review-test', []) + finally: + pipeline._pipeline_operations.discard('review-test') + + +def test_switching_an_approved_take_updates_h3_selection_without_approving_it(tmp_path): + path, state = fixture(tmp_path) + state['clips'][0].update(tag='good', h3_segments=[{'filename': 'new.mp4', 'stale': True}]) + path.write_text(json.dumps(state)) + save_review(str(tmp_path), 'review-test', [ + {'type': 'select_take', 'pipelineId': 'review-test', 'clipIndex': 0, 'filename': 'old.mp4'}, + ]) + saved = json.loads(path.read_text()) + assert saved['clips'][0]['tag'] is None + assert saved['clips'][0]['h3_segments'][0]['filename'] == 'old.mp4' + assert saved['clips'][0]['h3_segments'][0]['stale'] is False + assert 'old.mp4' in saved['output_files'] + + +def _sidecar_history(tmp_path): + state = {"pipeline_id": "review-test", "status": "completed", "clips": [ + {"index": 0, "video_filename": "new.mp4", "video_prompt": "literal prompt", "tag": None}, + ]} + path = tmp_path / f"{pipeline._PIPELINE_FILE_PREFIX}review-test.json" + path.write_text(json.dumps(state)) + (tmp_path / "new.mp4").write_bytes(b"current take") + (tmp_path / "old.mp4").write_bytes(b"recovered take") + (tmp_path / "old.mp4.meta.json").write_text(json.dumps({ + "output_filename": "old.mp4", + "director_pipeline_id": "review-test", + "director_clip_index": 0, + "created_at": 1, + "params": {"_director_clip_index": 0, "prompt": "older take"}, + })) + return path + + +def test_review_can_select_a_sidecar_take_missing_from_the_checkpoint(tmp_path): + path = _sidecar_history(tmp_path) + saved = save_review(str(tmp_path), "review-test", [ + {"type": "select_take", "pipelineId": "review-test", "clipIndex": 0, "filename": "old.mp4"}, + {"type": "tag_clip", "pipelineId": "review-test", "clipIndex": 0, "tag": "good"}, + ]) + names = {item["filename"] for item in saved["clips"][0]["video_attempts"]} + assert names == {"old.mp4", "new.mp4"} + assert saved["clips"][0]["selected_video_filename"] == "old.mp4" + assert saved["clips"][0]["tag"] == "good" + disk = json.loads(path.read_text()) + assert disk["clips"][0]["selected_video_filename"] == "old.mp4" + assert {item["filename"] for item in disk["clips"][0]["video_attempts"]} == names + + +def test_review_notes_keep_recovered_takes_in_the_saved_pipeline(tmp_path): + _sidecar_history(tmp_path) + saved = save_review(str(tmp_path), "review-test", [ + {"type": "note_clip", "pipelineId": "review-test", "clipIndex": 0, "notes": "keep history"}, + ]) + names = {item["filename"] for item in saved["clips"][0]["video_attempts"]} + assert names == {"old.mp4", "new.mp4"} + assert saved["clips"][0]["review_notes"] == "keep history" + assert saved["clips"][0]["video_filename"] == "new.mp4" + + +def test_review_cannot_select_a_sidecar_from_another_production(tmp_path): + path = _sidecar_history(tmp_path) + sidecar = tmp_path / 'old.mp4.meta.json' + metadata = json.loads(sidecar.read_text()) + metadata['director_pipeline_id'] = 'another-production' + sidecar.write_text(json.dumps(metadata)) + before = path.read_bytes() + with pytest.raises(ValueError, match='existing take'): + save_review(str(tmp_path), 'review-test', [ + {'type': 'select_take', 'pipelineId': 'review-test', 'clipIndex': 0, 'filename': 'old.mp4'}, + ]) + assert path.read_bytes() == before + + +def test_invalid_review_does_not_persist_hydrated_history_or_partial_notes(tmp_path): + path = _sidecar_history(tmp_path) + before = path.read_bytes() + with pytest.raises(ValueError, match='Invalid review decision'): + save_review(str(tmp_path), 'review-test', [ + {'type': 'note_clip', 'pipelineId': 'review-test', 'clipIndex': 0, 'notes': 'not committed'}, + {'type': 'tag_clip', 'pipelineId': 'review-test', 'clipIndex': 0, 'tag': 'invalid'}, + ]) + assert path.read_bytes() == before + + +def test_review_notes_keep_a_stale_selected_take_stale(tmp_path): + path, state = fixture(tmp_path) + state['clips'][0].update( + selected_video_filename='old.mp4', + video_filename='old.mp4', + video_stale=True, + tag='good', + ) + path.write_text(json.dumps(state)) + loaded = pipeline.load_pipeline_state(str(tmp_path), 'review-test') + assert loaded['clips'][0]['video_stale'] is True + assert loaded['clips'][0]['selected_video_filename'] == 'old.mp4' + save_review(str(tmp_path), 'review-test', [ + {'type': 'note_clip', 'pipelineId': 'review-test', 'clipIndex': 0, 'notes': 'keep stale'}, + ]) + saved = json.loads(path.read_text()) + assert saved['clips'][0]['video_stale'] is True + assert saved['clips'][0]['selected_video_filename'] == 'old.mp4' + assert saved['clips'][0]['review_notes'] == 'keep stale' + assert saved['clips'][0]['tag'] == 'good' + + +def test_desk_notes_persist_can_restate_an_existing_stale_approval(tmp_path): + path, state = fixture(tmp_path) + state['clips'][0].update( + selected_video_filename='old.mp4', + video_filename='old.mp4', + video_stale=True, + tag='good', + ) + path.write_text(json.dumps(state)) + save_review(str(tmp_path), 'review-test', [ + {'type': 'select_take', 'pipelineId': 'review-test', 'clipIndex': 0, 'filename': 'old.mp4', 'takeId': 'old-id'}, + {'type': 'tag_clip', 'pipelineId': 'review-test', 'clipIndex': 0, 'tag': 'good'}, + {'type': 'note_clip', 'pipelineId': 'review-test', 'clipIndex': 0, 'notes': 'rerun the start frame'}, + ]) + saved = json.loads(path.read_text()) + assert saved['clips'][0]['review_notes'] == 'rerun the start frame' + assert saved['clips'][0]['tag'] == 'good' + assert saved['clips'][0]['video_stale'] is True + assert saved['clips'][0]['selected_video_filename'] == 'old.mp4' + + +def test_review_cannot_newly_approve_a_stale_take(tmp_path): + path, state = fixture(tmp_path) + state['clips'][0].update( + selected_video_filename='old.mp4', + video_filename='old.mp4', + video_stale=True, + tag=None, + ) + path.write_text(json.dumps(state)) + before = path.read_bytes() + with pytest.raises(ValueError, match='completed current take'): + save_review(str(tmp_path), 'review-test', [ + {'type': 'select_take', 'pipelineId': 'review-test', 'clipIndex': 0, 'filename': 'old.mp4'}, + {'type': 'tag_clip', 'pipelineId': 'review-test', 'clipIndex': 0, 'tag': 'good'}, + {'type': 'note_clip', 'pipelineId': 'review-test', 'clipIndex': 0, 'notes': 'should roll back'}, + ]) + assert path.read_bytes() == before diff --git a/tests/test_integration_audit_regressions.py b/tests/test_integration_audit_regressions.py new file mode 100644 index 000000000..eeed46b6c --- /dev/null +++ b/tests/test_integration_audit_regressions.py @@ -0,0 +1,111 @@ +"""Integration regressions with fake providers and real canonical persistence.""" +import json +import time +from pathlib import Path +from unittest.mock import patch + +from tests import test_wizard_workflow_executor as wf +from tests import test_core_runtime as core_tests +from services import core_generation_commands, core_remote_image +from services.world3d_export import export_plan +from routers.wizard_workflow_executor import create_wizard_workflow_executor_router +from fastapi import FastAPI +from fastapi.testclient import TestClient +import pytest + + +def test_completed_image_advances_without_a_client_reconcile(tmp_path): + executor, native, _, _ = wf._executor(tmp_path) + app = FastAPI() + app.include_router(create_wizard_workflow_executor_router(executor, list_workspaces=lambda: [wf.WORKSPACE], interval=0.01)) + with TestClient(app) as client: + workflow = client.post('/api/v1/wizard/workflows/executor', json=wf._start_body()).json()['workflow'] + wf._complete(native, wf.WORKSPACE, workflow['steps'][0]['taskId'], ['poster.png']) + deadline = time.monotonic() + 2 + current = executor.get(wf.WORKSPACE, workflow['workflowId'])['workflow'] + # Dispatch is admitted before its receipt is checkpointed. Wait for + # the public workflow to attach that receipt, not a fake's call list. + while not current['steps'][1]['taskId'] and time.monotonic() < deadline: + time.sleep(0.01) + current = executor.get(wf.WORKSPACE, workflow['workflowId'])['workflow'] + assert current['steps'][1]['taskId'] + assert len(native.dispatch_calls) == 2 + wf._complete(native, wf.WORKSPACE, current['steps'][1]['taskId'], ['upscaled.png']) + deadline = time.monotonic() + 2 + while executor.get(wf.WORKSPACE, workflow['workflowId'])['workflow']['state'] != 'completed' and time.monotonic() < deadline: + time.sleep(0.01) + assert executor.get(wf.WORKSPACE, workflow['workflowId'])['workflow']['state'] == 'completed' + + +def test_resuming_failed_step_dispatches_a_new_attempt(tmp_path): + executor, native, _, _ = wf._executor(tmp_path) + with wf._app(executor, tmp_path) as client: + current = client.post('/api/v1/wizard/workflows/executor', json=wf._start_body()).json()['workflow'] + task_id = current['steps'][0]['taskId'] + native.registry(wf.WORKSPACE).update(task_id, status='failed', force=True) + client.post('/api/v1/wizard/workflows/executor/reconcile', json={'workspace': wf.WORKSPACE}) + resumed = client.post('/api/v1/wizard/workflows/executor/resume', json={'workspace': wf.WORKSPACE, 'workflowId': current['workflowId']}) + assert resumed.status_code == 200, resumed.text + client.post('/api/v1/wizard/workflows/executor/reconcile', json={'workspace': wf.WORKSPACE}) + current = client.get('/api/v1/wizard/workflows/executor/' + current['workflowId'], params={'workspace': wf.WORKSPACE}).json()['workflow'] + print(json.dumps({'case': 'resume_failed', 'dispatches': len(native.dispatch_calls), + 'state_after_resume': current['state'], 'same_failed_task': current['steps'][0]['taskId'] == task_id})) + assert len(native.dispatch_calls) == 2, 'Resume reuses the failed admission and never dispatches another attempt' + + +@pytest.mark.parametrize('terminal', ['completed', 'cancelled', 'failed', 'interrupted', 'running']) +def test_replay_of_terminal_core_job_does_not_call_provider_again(tmp_path, monkeypatch, terminal): + monkeypatch.chdir(tmp_path) + Path('outputs').mkdir() + case = core_tests.CoreRuntimeTests() + case.setUp() + job_id = None + try: + fake = {'name': 'minimax.jpg', 'path': 'minimax.jpg', 'prompt': 'a lantern', 'aspect_ratio': '1:1'} + with patch('services.core_remote_image.generate_image', return_value=fake) as provider, patch('services.execution_mode.validate_remote_provider'), patch('services.core_remote_image.threading.Thread', core_tests.ImmediateThread): + command = case._studio_image_command('audit-completed-replay') + first = case.client.post('/api/v1/generation/commands', json=command) + assert first.status_code == 200, first.text + job_id = first.json()['receipt']['result']['job_id'] + receipt = core_generation_commands.service().receipt('default', command['intent_id']) + assert receipt['task']['status'] == 'completed' + registry = core_generation_commands.registry_for('default') + registry.update(receipt['task']['id'], status=terminal, force=True) + # Same persisted completed task; only process-owned job memory is gone. + core_remote_image._JOBS.pop(job_id) + replay = case.client.post('/api/v1/generation/commands', json=command) + assert replay.status_code == 200, replay.text + assert replay.json()['replayed'] is True + expected = 'interrupted' if terminal == 'running' else terminal + assert case.client.get(f'/api/v1/status/{job_id}').json()['status'] == expected + print(json.dumps({'case': 'paid_replay', 'terminal_before_replay': receipt['task']['status'], + 'replayed': replay.json()['replayed'], 'provider_call_count': provider.call_count})) + assert provider.call_count == 1, 'Replaying an already completed intent executes the remote provider again' + finally: + case.tearDown() + if job_id: + core_remote_image._JOBS.pop(job_id, None) + + +def test_server_export_preserves_accepted_24fps(): + plan = export_plan({'duration': 2, 'fps': 24, 'width': 640, 'height': 360}) + print(json.dumps({'case': '24fps', 'requested_fps': 24, 'plan': plan})) + assert plan['fps'] == 24 + assert plan['count'] == 48 + + +def test_supervisor_recovers_existing_workflow_on_startup(tmp_path): + import asyncio + executor, native, _, _ = wf._executor(tmp_path) + workflow = asyncio.run(executor.start(wf._start_body()))['workflow'] + wf._complete(native, wf.WORKSPACE, workflow['steps'][0]['taskId'], ['poster.png']) + restarted, _, _, _ = wf._executor(tmp_path, native) + app = FastAPI() + app.include_router(create_wizard_workflow_executor_router(restarted, list_workspaces=lambda: [wf.WORKSPACE], interval=0.01)) + with TestClient(app): + deadline = time.monotonic() + 2 + while len(native.dispatch_calls) < 2 and time.monotonic() < deadline: + time.sleep(0.01) + assert len(native.dispatch_calls) == 2 + time.sleep(0.04) + assert len(native.dispatch_calls) == 2 diff --git a/tests/test_job_lifecycle_wiring.py b/tests/test_job_lifecycle_wiring.py index 97abf4ba6..fc0398668 100644 --- a/tests/test_job_lifecycle_wiring.py +++ b/tests/test_job_lifecycle_wiring.py @@ -394,10 +394,13 @@ def fake_run(command, **kwargs): command = next(c for c in commands if "-filter_complex" in c) filter_value = command[command.index("-filter_complex") + 1] self.assertIn( - "[1:a]atrim=start=2.000000,asetpts=PTS-STARTPTS[outa]", + "[1:a]atrim=start=2.000000,asetpts=PTS-STARTPTS,apad," + "atrim=duration=3.000000[outa]", filter_value, ) self.assertIn("[outa]", command) + self.assertIn("-shortest", command) + self.assertNotIn("atrim=duration=2.100000", filter_value) def test_multiclip_concat_can_be_cancelled_during_ffmpeg(self): concatenate = _load_isolated_function( diff --git a/tests/test_launcher_compatibility.py b/tests/test_launcher_compatibility.py index 9ea8b618e..4d1459575 100644 --- a/tests/test_launcher_compatibility.py +++ b/tests/test_launcher_compatibility.py @@ -16,6 +16,12 @@ def test_installed_app_menu_is_not_hidden_by_early_gpu_detection(self): self.assertNotIn("if (kernel.gpu", launcher) self.assertIn('text: "Start"', launcher) self.assertIn('href: "start.js"', launcher) + self.assertNotIn("Start (Classic UI)", launcher) + self.assertNotIn("Open Classic UI", launcher) + self.assertNotIn("Classic Compiled", launcher) + self.assertNotIn("start_classic.js", launcher) + self.assertIn('=== "darwin"', launcher) + self.assertIn("sam_install.js", launcher) def test_fresh_install_still_uses_pinokios_documented_gpu_variable(self): installer = (_ROOT / "install.js").read_text(encoding="utf-8") @@ -25,6 +31,10 @@ def test_fresh_install_still_uses_pinokios_documented_gpu_variable(self): rejected = select_profiles("win32", "x64", "amd") self.assertFalse(rejected["supported"]) self.assertIn("NVIDIA", rejected["engines"]["wangp"]["reason"]) + apple = select_profiles("darwin", "arm64", "apple") + self.assertTrue(apple["supported"]) + self.assertTrue(apple["engines"]["core"]["supported"]) + self.assertFalse(apple["engines"]["wangp"]["supported"]) def test_start_url_uses_the_required_capture_object(self): start = (_ROOT / "start.js").read_text(encoding="utf-8") diff --git a/tests/test_llm_router.py b/tests/test_llm_router.py index 5a5bdb20f..1b629c1ae 100644 --- a/tests/test_llm_router.py +++ b/tests/test_llm_router.py @@ -96,6 +96,48 @@ def test_generate_returns_llm_text_without_loading_wgp(): generate.assert_called_once() +def test_generate_can_use_scoped_series_writer_without_changing_the_global_model(): + app = FastAPI() + loaded = [] + requests = [] + def writer(body): + requests.append(body) + return {"model": "series-writer", "base_url": "http://writer.example/v1", "api_key": "test"} + app.include_router(_core_router(ensure_llm_loaded=lambda: loaded.append(True), comic_writing_llm=writer)) + client = TestClient(app) + schema = {"type": "object", "properties": {"environment": {"type": "string"}}} + with patch("services.llm_service.generate_openai_compatible", return_value='{"environment":"Empty diner"}') as generate: + response = client.post("/api/v1/llm/generate", json={ + "prompt": "Prepare the location", "writingProvider": "minimax", "writingModel": "series-writer", + "system_prompt": "Remove occupants", "json_schema": schema, + }) + assert response.status_code == 200 + assert response.json() == {"text": '{"environment":"Empty diner"}'} + assert requests[0]["writingProvider"] == "minimax" + assert not loaded + assert generate.call_args.kwargs["model_id"] == "series-writer" + assert generate.call_args.kwargs["json_schema"] == schema + assert generate.call_args.kwargs["system_prompt"] == "Remove occupants" + + +def test_list_llm_models_forwards_url_query_to_the_catalog(): + app = FastAPI() + app.include_router(_core_router()) + client = TestClient(app) + with patch("services.llm_service.get_available_models", return_value=[ + {"id": "qwen3:32b", "label": "qwen3:32b (Ollama)", "size_hint": "ollama", "provider": "ollama"}, + ]) as catalog: + response = client.get("/api/v1/llm/models", params={ + "provider": "ollama", + "url": "http://192.168.1.10:11434", + }) + assert response.status_code == 200 + assert response.json()["models"][0]["id"] == "qwen3:32b" + catalog.assert_called_once() + assert catalog.call_args.kwargs["provider"] == "ollama" + assert catalog.call_args.kwargs["remote_url"] == "http://192.168.1.10:11434" + + def test_plan_h3_windows_rejects_non_h3_models(): app = FastAPI() app.include_router(_prompt_router(get_model_def=lambda _model_type: {"architecture": "ltx2"})) diff --git a/tests/test_mcp_access.py b/tests/test_mcp_access.py index 7134ad0df..6e3f5ef56 100644 --- a/tests/test_mcp_access.py +++ b/tests/test_mcp_access.py @@ -1,5 +1,6 @@ import os +import pytest from fastapi import FastAPI from fastapi.testclient import TestClient @@ -8,18 +9,29 @@ from services.mcp_access import McpAccess -def client_for(tmp_path, environment=lambda: ''): +MCP_ENDPOINTS = ('/api/v1/mcp', '/api/v1/wangp/mcp') + + +def client_for(tmp_path, environment=lambda: '', profile='full'): access = McpAccess(tmp_path / 'private.json', env_token=environment) app = FastAPI() app.include_router(create_mcp_access_router(access)) - app.include_router(create_wangp_mcp_router(handlers={}, token_getter=access.token, journal_path=str(tmp_path / 'journal.db'))) + if profile == 'core': + from routers.core_mcp import create_core_mcp_router + app.include_router(create_core_mcp_router(access)) + else: + app.include_router(create_wangp_mcp_router(handlers={}, token_getter=access.token, journal_path=str(tmp_path / 'journal.db'))) return access, TestClient(app, base_url='http://127.0.0.1:8080') -def test_toggle_token_rotation_revocation_and_private_storage(tmp_path): - access, client = client_for(tmp_path) +@pytest.mark.parametrize('endpoint', MCP_ENDPOINTS) +@pytest.mark.parametrize('profile', ('full', 'core')) +def test_toggle_token_rotation_revocation_and_private_storage(tmp_path, endpoint, profile): + access, client = client_for(tmp_path, profile=profile) headers = {'Origin': 'http://127.0.0.1:8080'} - assert not client.get('/api/v1/settings/mcp').json()['enabled'] + status = client.get('/api/v1/settings/mcp').json() + assert not status['enabled'] + assert status['endpoint'] == '/api/v1/mcp' created = client.put('/api/v1/settings/mcp', json={'enabled': True}, headers=headers) assert created.status_code == 200 token = created.json()['token'] @@ -29,7 +41,7 @@ def test_toggle_token_rotation_revocation_and_private_storage(tmp_path): if os.name != 'nt': assert access.path.stat().st_mode & 0o777 == 0o600 def ping(key): - return client.post('/api/v1/wangp/mcp', headers={'Authorization': 'Bearer ' + key}, json={'jsonrpc': '2.0', 'id': 1, 'method': 'ping'}) + return client.post(endpoint, headers={'Authorization': 'Bearer ' + key}, json={'jsonrpc': '2.0', 'id': 1, 'method': 'ping'}) assert ping(token).status_code == 200 assert McpAccess(access.path, env_token=lambda: '').token() == token replacement = client.put('/api/v1/settings/mcp', json={'enabled': True, 'rotate': True}, headers=headers).json()['token'] @@ -56,7 +68,8 @@ def test_env_precedence_toggle_and_no_environment_secret_disclosure(tmp_path): assert not access.token() -def test_shared_lan_mcp_uses_its_own_token_without_unlocking_other_apis(tmp_path, monkeypatch): +@pytest.mark.parametrize('endpoint', MCP_ENDPOINTS) +def test_shared_lan_mcp_uses_its_own_token_without_unlocking_other_apis(tmp_path, monkeypatch, endpoint): from services.lan_auth import LanAuthMiddleware monkeypatch.setenv('PINOKIO_SHARE_LOCAL', 'true') monkeypatch.setenv('LOREFRAME_LAN_AUTH', 'true') @@ -69,8 +82,8 @@ def test_shared_lan_mcp_uses_its_own_token_without_unlocking_other_apis(tmp_path app.include_router(create_wangp_mcp_router(handlers={}, token_getter=access.token, journal_path=str(tmp_path / 'journal.db'))) client = TestClient(app, base_url='http://192.168.1.87:8080') message = {'jsonrpc': '2.0', 'id': 1, 'method': 'ping'} - assert client.post('/api/v1/wangp/mcp', json=message).status_code == 401 - assert client.post('/api/v1/wangp/mcp', json=message, headers={'Authorization': 'Bearer ' + token}).status_code == 200 + assert client.post(endpoint, json=message).status_code == 401 + assert client.post(endpoint, json=message, headers={'Authorization': 'Bearer ' + token}).status_code == 200 assert client.get('/api/v1/settings/mcp', headers={'Authorization': 'Bearer ' + token}).status_code == 401 access.update(False) - assert client.post('/api/v1/wangp/mcp', json=message, headers={'Authorization': 'Bearer ' + token}).status_code == 503 + assert client.post(endpoint, json=message, headers={'Authorization': 'Bearer ' + token}).status_code == 503 diff --git a/tests/test_mix_concat.py b/tests/test_mix_concat.py index e8bc60db8..03c81840c 100644 --- a/tests/test_mix_concat.py +++ b/tests/test_mix_concat.py @@ -11,7 +11,9 @@ from app.services.mix_concat import ( build_hard_concat_filter, build_hold_crossfade_filter, + concat_with_tail_hold_and_crossfade, concatenate_multi_clip_videos, + driving_soundtrack_bound, hold_crossfade_output_seconds, probe_audio_flags, probe_has_audio, @@ -53,7 +55,10 @@ def fake_concat(*args, **kwargs): def test_hold_crossfade_filter_covers_every_clip_and_xfade(): filter_str, video, audio = build_hold_crossfade_filter([5.0, 5.0, 5.0]) - assert "[0:v]tpad=stop_mode=clone:stop_duration=0.500[v0]" in filter_str + assert ( + "[0:v]settb=AVTB,setpts=PTS-STARTPTS," + "tpad=stop_mode=clone:stop_duration=0.500[v0]" + ) in filter_str assert "[1:a]apad=pad_dur=0.500[a1]" in filter_str assert "xfade=transition=fade:duration=0.400" in filter_str assert "acrossfade=d=0.400" in filter_str @@ -61,6 +66,13 @@ def test_hold_crossfade_filter_covers_every_clip_and_xfade(): assert audio == "ax2" +def test_hold_crossfade_forces_a_common_timebase_before_xfade(): + # Same fps with encoder tbn 1/30 vs 1/15360 used to fail with + # "First input link main timebase do not match". + filter_str, _video, _audio = build_hold_crossfade_filter([1.0, 1.0], with_audio=False) + assert filter_str.count("settb=AVTB,setpts=PTS-STARTPTS") == 2 + + def test_video_only_filter_omits_audio_pads(): filter_str, video, audio = build_hold_crossfade_filter( [6.0, 6.0], @@ -95,6 +107,18 @@ def test_concatenate_gates_soft_join_on_the_duration_lock(): assert "abort_callback=abort_callback" in text assert "probe_audio_flags" in text assert "build_hard_concat_filter" in text + # External soundtrack used to be mapped as `{n}:a:0` with -shortest, so a + # song shorter than the concat truncated the video. Always apad first. + concat_fn = text[ + text.index("def concatenate_multi_clip_videos(") + : text.index("def _remove_partial_output():") + ] + assert 'f"{n}:a:0"' not in concat_fn + assert "audio_filters.append(\"apad\")" in concat_fn + assert "atrim=duration={bound:.6f}" in concat_fn + assert "driving_soundtrack_bound(clip_secs)" in concat_fn + assert "sum(clip_secs) - audio_start_sec" not in concat_fn + assert '"-map", "[outa]"' in concat_fn # Hard concat used to probe only valid_paths[0] for audio. The fps # probe may still read clip 0; the audio decision must not. audio_probe_window = text[ @@ -267,3 +291,135 @@ def test_hard_concat_mixed_audio_ffmpeg_survives_later_silent_clip(tmp_path): ) assert completed.returncode == 0, completed.stderr[-600:] assert probe_has_audio(str(out)) is True + + +def _write_timescale_clip(path: Path, *, frames: int, fps: int, timescale: int) -> None: + completed = subprocess.run( + [ + "ffmpeg", "-y", "-hide_banner", "-loglevel", "error", + "-f", "lavfi", "-i", f"color=c=blue:s=160x120:r={fps}", + "-frames:v", str(frames), + "-c:v", "libx264", "-preset", "ultrafast", "-pix_fmt", "yuv420p", + "-video_track_timescale", str(timescale), + str(path), + ], + capture_output=True, text=True, timeout=30, + ) + assert completed.returncode == 0, completed.stderr[-400:] + + +@pytest.mark.skipif(shutil.which("ffmpeg") is None, reason="ffmpeg is required") +def test_hold_crossfade_ffmpeg_accepts_mismatched_encoder_timebases(tmp_path): + # Two 30fps clips with tbn 1/30 vs 1/15360. Without settb, xfade rejects + # the graph and Director fell back to a slap-cut. + first = tmp_path / "tbn30.mp4" + second = tmp_path / "tbn15360.mp4" + out = tmp_path / "xfade.mp4" + _write_timescale_clip(first, frames=30, fps=30, timescale=30) + _write_timescale_clip(second, frames=30, fps=30, timescale=15360) + assert concat_with_tail_hold_and_crossfade( + [str(first), str(second)], str(out), + ) is True + assert out.is_file() and out.stat().st_size > 0 + + +def test_driving_soundtrack_bound_covers_the_pictures_not_the_song_offset(): + # Director music_video / rejoin pass audio_start_sec=12.5 (clip 0 start) + # and pad_audio=False. That offset is atrim=start on the track. The old + # bound subtracted it from the clip sum, so 10s of film + start=12.5 + # became atrim=duration=2.1 and -shortest discarded the tail. + assert driving_soundtrack_bound([5.0, 5.0]) == 12.0 + assert driving_soundtrack_bound([2.0, 2.0]) == 6.0 + assert driving_soundtrack_bound([0.0]) == 2.1 + + +@pytest.mark.skipif(shutil.which("ffmpeg") is None, reason="ffmpeg is required") +def test_padded_soundtrack_shortest_keeps_the_concat_video(tmp_path): + # Director used to map a raw song with -shortest, so 4s of video + 1s of + # music encoded 1s of pictures. apad + -shortest keeps the video span. + first = tmp_path / "a.mp4" + second = tmp_path / "b.mp4" + song = tmp_path / "song.m4a" + out = tmp_path / "joined.mp4" + _write_test_clip(first, with_audio=False, duration=2.0) + _write_test_clip(second, with_audio=False, duration=2.0) + completed = subprocess.run( + [ + "ffmpeg", "-y", "-hide_banner", "-loglevel", "error", + "-f", "lavfi", "-i", "sine=f=440:d=1", "-c:a", "aac", str(song), + ], + capture_output=True, text=True, timeout=30, + ) + assert completed.returncode == 0, completed.stderr[-400:] + completed = subprocess.run( + [ + "ffmpeg", "-y", "-hide_banner", "-loglevel", "error", + "-i", str(first), "-i", str(second), "-i", str(song), + "-filter_complex", + "[0:v][1:v]concat=n=2:v=1:a=0[outv];" + "[2:a]asetpts=PTS-STARTPTS,apad,atrim=duration=6[outa]", + "-map", "[outv]", "-map", "[outa]", + "-c:v", "libx264", "-c:a", "aac", "-shortest", + "-pix_fmt", "yuv420p", str(out), + ], + capture_output=True, text=True, timeout=30, + ) + assert completed.returncode == 0, completed.stderr[-600:] + probe = subprocess.run( + [ + "ffprobe", "-v", "error", "-select_streams", "v:0", "-count_frames", + "-show_entries", "stream=nb_read_frames", "-of", "csv=p=0", str(out), + ], + capture_output=True, text=True, timeout=30, + ) + frames = int((probe.stdout or "0").strip() or 0) + assert frames >= 100, frames + + +@pytest.mark.skipif(shutil.which("ffmpeg") is None, reason="ffmpeg is required") +def test_mid_song_offset_does_not_truncate_concat_video(tmp_path): + # Director music_video / rejoin: two 2s clips, song starts at 12.5s, + # pad_audio=False. Subtracting that offset from the clip sum used to + # atrim=2.1s; -shortest then kept ~2s of a 4s movie. + first = tmp_path / "verse.mp4" + second = tmp_path / "chorus.mp4" + song = tmp_path / "song.m4a" + out = tmp_path / "joined.mp4" + _write_test_clip(first, with_audio=False, duration=2.0) + _write_test_clip(second, with_audio=False, duration=2.0) + completed = subprocess.run( + [ + "ffmpeg", "-y", "-hide_banner", "-loglevel", "error", + "-f", "lavfi", "-i", "sine=f=440:d=20", "-c:a", "aac", str(song), + ], + capture_output=True, text=True, timeout=30, + ) + assert completed.returncode == 0, completed.stderr[-400:] + stale_bound = max(0.1, 4.0 - 12.5) + 2.0 + bound = driving_soundtrack_bound([2.0, 2.0]) + assert bound == 6.0 + assert stale_bound == 2.1 + completed = subprocess.run( + [ + "ffmpeg", "-y", "-hide_banner", "-loglevel", "error", + "-i", str(first), "-i", str(second), "-i", str(song), + "-filter_complex", + "[0:v][1:v]concat=n=2:v=1:a=0[outv];" + "[2:a]atrim=start=12.5,asetpts=PTS-STARTPTS,apad," + f"atrim=duration={bound:.6f}[outa]", + "-map", "[outv]", "-map", "[outa]", + "-c:v", "libx264", "-c:a", "aac", "-shortest", + "-pix_fmt", "yuv420p", str(out), + ], + capture_output=True, text=True, timeout=30, + ) + assert completed.returncode == 0, completed.stderr[-600:] + probe = subprocess.run( + [ + "ffprobe", "-v", "error", "-select_streams", "v:0", "-count_frames", + "-show_entries", "stream=nb_read_frames", "-of", "csv=p=0", str(out), + ], + capture_output=True, text=True, timeout=30, + ) + frames = int((probe.stdout or "0").strip() or 0) + assert frames >= 100, frames diff --git a/tests/test_music_video_pacing.py b/tests/test_music_video_pacing.py index 538295f85..32d4de1ce 100644 --- a/tests/test_music_video_pacing.py +++ b/tests/test_music_video_pacing.py @@ -1,6 +1,7 @@ """Regression tests for music-video pacing and structured Story lyrics.""" from app.services.audio_analysis import plan_clip_structure +from app.services.director.planners.music_video import MusicVideoPlanner from app.services.llm_service import structure_from_tagged_lyrics @@ -70,6 +71,44 @@ def test_zero_duration_uses_a_safe_non_empty_fallback_timeline(): assert clips[-1]["end"] == 180.0 +def test_source_audio_events_are_assigned_to_clip_with_exact_offset(): + analysis = _analysis(duration=30.0, section_count=3) + analysis["lyric_timeline"] = [{ + "start": 18.3, "end": 20.42, "text": "Gandalf ha entrado al chat.", + "source": "aligned_lyrics", "confidence": 1.0, + }] + analysis["visual_events"] = [{ + "time": 19.16, "end": 19.7, "kind": "entrance", "cue_index": 0, + "lyric": "Gandalf ha entrado al chat.", "trigger": "entrado", + "rule": "The visual action starts here; do not reveal its result earlier.", + }] + + clips = plan_clip_structure(analysis, pacing_profile="balanced") + event_clip = next(clip for clip in clips if clip["visual_events"]) + event = event_clip["visual_events"][0] + + assert event["time"] == 19.16 + assert event["offset"] == round(19.16 - event_clip["start"], 3) + assert event_clip["start"] <= 19.16 < event_clip["end"] + + +def test_music_planner_receives_mandatory_in_clip_action_time(): + clip = { + "start": 16.0, "end": 22.0, "label": "verse", "beat_count": 12, + "lyric_cues": [{"offset": 2.3, "text": "Gandalf ha entrado al chat."}], + "visual_events": [{"offset": 3.16, "kind": "entrance", "trigger": "entrado"}], + } + + contexts = MusicVideoPlanner._build_clip_contexts( + MusicVideoPlanner.__new__(MusicVideoPlanner), + [clip], [], {}, {}, {}, [{}], + ) + + assert '+2.300s "Gandalf ha entrado al chat."' in contexts[0] + assert '+3.160s entrance on "entrado"' in contexts[0] + assert "must not be visible earlier" in contexts[0] + + def test_structured_story_lyrics_are_authoritative(): structure = structure_from_tagged_lyrics( "[Intro]\n(instrumental)\n[Verse 1]\nA seed crosses the empty sky\n" diff --git a/tests/test_phase1_issue_fixes.py b/tests/test_phase1_issue_fixes.py index 27f1199af..0b1fa844b 100644 --- a/tests/test_phase1_issue_fixes.py +++ b/tests/test_phase1_issue_fixes.py @@ -1766,10 +1766,20 @@ def test_outpaint_generate_button_explains_zero_generation_area(self): "GenerateButton.tsx", ) ) + generate_gate = _read( + os.path.join( + _ROOT, + "ui", + "src", + "lib", + "generateButtonGate.ts", + ) + ) studio = json.loads(_read(_STUDIO_EN_PATH)) self.assertIn("needsOutpaintArea", generate_button) - self.assertIn("t('generate.chooseCanvas')", generate_button) - self.assertIn("t('generate.outpaintAreaHint')", generate_button) + self.assertIn("generateBlockedCopy", generate_button) + self.assertIn("t('generate.chooseCanvas')", generate_gate) + self.assertIn("t('generate.outpaintAreaHint')", generate_gate) self.assertEqual(studio["generate"]["chooseCanvas"], "Choose canvas") self.assertIn("area for Outpaint to generate", studio["generate"]["outpaintAreaHint"]) diff --git a/tests/test_platform_capabilities.py b/tests/test_platform_capabilities.py new file mode 100644 index 000000000..7e9352f20 --- /dev/null +++ b/tests/test_platform_capabilities.py @@ -0,0 +1,95 @@ +from __future__ import annotations + +import unittest + +from fastapi import FastAPI, HTTPException +from fastapi.testclient import TestClient + +from routers.system_capabilities import create_system_capabilities_router, require_capability_http +from services.platform_capabilities import ( + AVAILABLE, + FEATURE_UNAVAILABLE, + HIDDEN, + PROFILE_LINUX_NVIDIA, + PROFILE_MACOS_ARM64, + PROFILE_MACOS_INTEL, + CapabilityDenied, + build_capabilities, + require_capability, +) + + +def _snapshot(system: str, machine: str, **kwargs): + return build_capabilities( + system=system, + machine=machine, + ffmpeg_present=True, + rhubarb_present=False, + **kwargs, + ) + + +class PlatformCapabilitiesTests(unittest.TestCase): + def test_linux_keeps_local_nvidia_engines(self): + snap = _snapshot("linux", "x86_64") + self.assertEqual(snap["profile"], PROFILE_LINUX_NVIDIA) + self.assertTrue(snap["accelerators"]["cuda"]) + self.assertTrue(snap["ui"]["show_cuda_controls"]) + self.assertEqual(snap["capabilities"]["wangp_local"]["state"], AVAILABLE) + self.assertEqual(snap["capabilities"]["minimax_h3_local"]["state"], AVAILABLE) + self.assertEqual(snap["capabilities"]["hunyuan3d_local"]["state"], AVAILABLE) + self.assertEqual(snap["capabilities"]["projects"]["state"], AVAILABLE) + require_capability("wangp_local", snap) + + def test_apple_silicon_hides_cuda_engines_and_keeps_editors(self): + snap = _snapshot("darwin", "arm64") + self.assertEqual(snap["profile"], PROFILE_MACOS_ARM64) + self.assertFalse(snap["accelerators"]["cuda"]) + self.assertTrue(snap["accelerators"]["mps"]) + self.assertFalse(snap["ui"]["show_cuda_controls"]) + self.assertEqual(snap["ui"]["mode"], "macosCoreRemote") + self.assertEqual(snap["capabilities"]["editors"]["state"], AVAILABLE) + self.assertEqual(snap["capabilities"]["video3d"]["state"], AVAILABLE) + self.assertEqual(snap["capabilities"]["remote_llm"]["state"], AVAILABLE) + self.assertEqual(snap["capabilities"]["wangp_local"]["state"], HIDDEN) + self.assertEqual(snap["capabilities"]["wangp_local"]["alternative"], "remote_image") + self.assertEqual(snap["capabilities"]["rhubarb"]["state"], "disabled") + with self.assertRaises(CapabilityDenied) as raised: + require_capability("wangp_local", snap) + detail = raised.exception.as_detail() + self.assertEqual(detail["code"], FEATURE_UNAVAILABLE) + self.assertEqual(detail["capability"], "wangp_local") + self.assertEqual(detail["state"], HIDDEN) + + def test_intel_mac_is_an_explicit_unsupported_profile(self): + snap = _snapshot("darwin", "x86_64") + self.assertEqual(snap["profile"], PROFILE_MACOS_INTEL) + self.assertEqual(snap["ui"]["mode"], "macosIntel") + self.assertEqual(snap["capabilities"]["minimax_h3_local"]["state"], HIDDEN) + + def test_http_surface_and_409_guard(self): + app = FastAPI() + app.include_router(create_system_capabilities_router()) + + @app.post("/probe") + def probe(): + require_capability_http("wangp_local") + return {"ok": True} + + client = TestClient(app) + listed = client.get("/api/v1/system/capabilities") + self.assertEqual(listed.status_code, 200) + self.assertIn("capabilities", listed.json()) + self.assertIn("profile", listed.json()) + + denied = HTTPException(status_code=409, detail={"code": FEATURE_UNAVAILABLE}) + try: + require_capability_http("missing-engine") + except HTTPException as error: + denied = error + self.assertEqual(denied.status_code, 409) + self.assertEqual(denied.detail["code"], FEATURE_UNAVAILABLE) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_provider_profile.py b/tests/test_provider_profile.py index 9549d9b3b..ff7834ea1 100644 --- a/tests/test_provider_profile.py +++ b/tests/test_provider_profile.py @@ -72,6 +72,11 @@ def json(self): ids = [item["id"] for item in models if item.get("provider") == "ollama"] self.assertEqual(ids, ["gemma3:4b", "llama3.2:3b"]) + def test_deepseek_catalog_lists_v4_models(self): + models = llm_service.get_available_models(provider="deepseek") + ids = [item["id"] for item in models if item.get("provider") == "deepseek"] + self.assertEqual(ids, ["deepseek-v4-pro", "deepseek-v4-flash"]) + class TestRemote3D(unittest.TestCase): def test_meshy_image_to_3d_polls_and_downloads(self): diff --git a/tests/test_runtime_profiles.py b/tests/test_runtime_profiles.py index 02c64fd17..7a37e8099 100644 --- a/tests/test_runtime_profiles.py +++ b/tests/test_runtime_profiles.py @@ -35,12 +35,26 @@ def test_older_driver_keeps_core_available_without_claiming_h3_support(): def test_unavailable_platforms_and_accelerators_never_fall_through_to_cuda(): - for platform, arch, gpu in [("darwin", "arm64", "apple"), ("linux", "arm64", "nvidia"), + for platform, arch, gpu in [("linux", "arm64", "nvidia"), ("win32", "x64", "amd"), ("linux", "x64", "intel"), ("linux", "x64", "cpu"), ("linux", "x64", "unknown")]: result = profiles.select_profiles(platform, arch, gpu) assert not result["supported"] - assert all(not engine["supported"] and engine["reason"] for engine in result["engines"].values()) + assert all(not engine["supported"] and engine["reason"] for engine in result["engines"].values() + if engine.get("cuda")) + + +def test_apple_silicon_installs_core_without_cuda_engines(): + result = profiles.select_profiles("darwin", "arm64", "apple") + assert result["supported"] + assert result["engines"]["core"]["supported"] + assert result["engines"]["core"]["id"] == "darwin-arm64-core-core" + assert not result["engines"]["wangp"]["supported"] + assert not result["engines"]["minimax_h3"]["supported"] + assert not result["engines"]["hunyuan3d"]["supported"] + assert "NVIDIA" in result["engines"]["wangp"]["reason"] + intel = profiles.select_profiles("darwin", "x64", "apple") + assert not intel["supported"] def test_missing_driver_is_explicitly_unverified(): diff --git a/tests/test_scene3d_speech.py b/tests/test_scene3d_speech.py index e82ad6395..2fe883c39 100644 --- a/tests/test_scene3d_speech.py +++ b/tests/test_scene3d_speech.py @@ -9,6 +9,15 @@ from fastapi.testclient import TestClient from routers.character_kit_face import create_character_kit_face_router from services import scene3d_speech as speech +from services.speech_analysis_cache import reset_runtime_state + + +@pytest.fixture(autouse=True) +def _speech_analysis_cache(tmp_path, monkeypatch): + monkeypatch.setenv("SPEECH_ANALYSIS_CACHE_DIR", str(tmp_path / "speech-cache")) + reset_runtime_state() + yield + reset_runtime_state() def wav(seconds=1, rate=16000): diff --git a/tests/test_scene3d_speech_profile_digest.py b/tests/test_scene3d_speech_profile_digest.py new file mode 100644 index 000000000..e075c44cb --- /dev/null +++ b/tests/test_scene3d_speech_profile_digest.py @@ -0,0 +1,46 @@ +import hashlib +from fastapi import FastAPI +from fastapi.testclient import TestClient +from routers.character_kit_face import create_character_kit_face_router +from routers import scene3d_profiles as profiles + +DIGEST = "/api/v1/character-kits/speech/digest" +BYTES = b"same GLB bytes" + + +def client_for(tmp_path): + app = FastAPI() + app.include_router(create_character_kit_face_router( + workspace_dir=lambda name: str(tmp_path / name), uploads_root=lambda: str(tmp_path / "uploads"))) + return TestClient(app) + + +def test_digest_is_content_addressed_and_ignores_filename(tmp_path): + client = client_for(tmp_path) + one = tmp_path / "episode-one" + one.mkdir() + (one / "mira.glb").write_bytes(BYTES) + (one / "twin.glb").write_bytes(BYTES) + (one / "other.glb").write_bytes(b"other GLB bytes") + expected = hashlib.sha256(BYTES).hexdigest() + first = client.get(DIGEST, params={"workspace": "episode-one", "filename": "mira.glb"}) + twin = client.get(DIGEST, params={"workspace": "episode-one", "filename": "twin.glb"}) + other = client.get(DIGEST, params={"workspace": "episode-one", "filename": "other.glb"}) + assert first.status_code == 200 and first.json()["digest"] == expected + assert twin.json()["digest"] == expected + assert other.json()["digest"] != expected + assert first.json()["digest"] != "mira.glb" + assert first.json()["bytes"] == len(BYTES) + + +def test_digest_rejects_escape_missing_and_oversized_models(tmp_path, monkeypatch): + client = client_for(tmp_path) + workspace = tmp_path / "valid" + workspace.mkdir() + (workspace / "mira.glb").write_bytes(BYTES) + assert client.get(DIGEST, params={"workspace": "../escape", "filename": "mira.glb"}).status_code in (400, 422) + assert client.get(DIGEST, params={"workspace": "valid", "filename": "../mira.glb"}).status_code == 400 + assert client.get(DIGEST, params={"workspace": "valid", "filename": "missing.glb"}).status_code == 404 + assert client.get(DIGEST, params={"workspace": "valid", "filename": "notes.txt"}).status_code == 400 + monkeypatch.setattr(profiles, "MAX_MODEL_BYTES", 4) + assert client.get(DIGEST, params={"workspace": "valid", "filename": "mira.glb"}).status_code == 413 diff --git a/tests/test_scene_effect_commands.py b/tests/test_scene_effect_commands.py index 52046cab3..92d97cded 100644 --- a/tests/test_scene_effect_commands.py +++ b/tests/test_scene_effect_commands.py @@ -23,8 +23,8 @@ def showcase(service, dimension='3d'): def test_both_templates_use_all_catalog_effects_and_are_replayable(service): for dimension in ('2d', '3d'): doc = showcase(service, dimension) - assert doc['duration'] == 90 - assert len(doc['sfx']) == 30 + assert doc['duration'] == 138 + assert len(doc['sfx']) == 46 assert all(cue['sound'] and cue['label'] for cue in doc['sfx']) assert doc == showcase(service, dimension) assert ('slots' in doc) == (dimension == '3d') @@ -39,7 +39,7 @@ def test_apply_replaces_exact_cue_preserving_scene_and_caller(service): assert original == before assert first == service.execute(command) actual = first['result']['document'] - assert len(actual['sfx']) == 30 + assert len(actual['sfx']) == 46 assert actual['sfx'][3] == cue assert actual['slots'] == original['slots'] assert not first['result']['saved'] and not first['result']['exported'] @@ -114,9 +114,14 @@ def test_catalog_lists_all_world_kinds(service): ops = {item['name']: item for item in command_catalog()} assert 'worldKinds' in ops['scenes.effects.catalog']['description'] assert 'energy_beam' in ops['scenes.effects.catalog']['description'] + assert 'explosion' in ops['scenes.effects.catalog']['description'] + assert 'media_portal' in ops['scenes.effects.catalog']['description'] kinds = service.execute({'version': 1, 'operation': 'scenes.effects.catalog', 'input': {}})['result']['worldKinds'] assert 'energy_beam' in kinds assert 'anime_aura' in kinds + assert 'explosion' in kinds + assert 'media_portal' in kinds + assert 'fire' in kinds def test_speech_rejects_paths_and_unknown_character_before_analysis(service): @@ -133,7 +138,7 @@ def test_speech_rejects_paths_and_unknown_character_before_analysis(service): def test_shared_catalog_remains_a_packaged_resource(): path = Path(__file__).parents[1] / 'app/shared/scene_effects.json' - assert len(json.loads(path.read_text())) == 30 + assert len(json.loads(path.read_text())) == 46 def test_speech_append_preserves_previous_voice_and_rejects_overlap(): @@ -148,6 +153,17 @@ def test_speech_append_preserves_previous_voice_and_rejects_overlap(): assert 'clips' not in original +def test_retro_showcase_is_screen_only_and_thirty_seconds(service): + scene = service.execute({'version': 1, 'operation': 'scenes.effects.showcase', + 'input': {'collection': 'retro', 'dimension': '2d'}})['result']['document'] + assert scene['duration'] == 30 and len(scene['sfx']) == 10 + assert [cue['kind'] for cue in scene['sfx']] == [ + 'psx', 'n64', 'nes', 'snes', 'gameboy', 'gameboy_color', 'genesis', 'vhs', 'crt', 'c64'] + catalog = service.execute({'version': 1, 'operation': 'scenes.effects.catalog', 'input': {}})['result'] + assert 'psx' not in catalog['worldKinds'] + assert any(item['id'] == 'psx' for item in catalog['effects']) + + def test_anime_showcase_uses_36_seconds_and_preserves_longer_authored_scenes(service): command = {'version': 1, 'operation': 'scenes.effects.showcase', 'input': {'collection': 'anime'}} scene = service.execute(command)['result']['document'] @@ -157,7 +173,7 @@ def test_anime_showcase_uses_36_seconds_and_preserves_longer_authored_scenes(ser assert service.execute(command)['result']['document']['duration'] == 72 -@pytest.mark.parametrize('collection,seconds', [('anime', 36), ('all', 90)]) +@pytest.mark.parametrize('collection,seconds', [('anime', 36), ('retro', 30), ('all', 138)]) def test_default_2d_showcase_has_no_longer_background_tail(service, collection, seconds): scene = service.execute({'version': 1, 'operation': 'scenes.effects.showcase', 'input': {'dimension': '2d', 'collection': collection}})['result']['document'] @@ -167,3 +183,60 @@ def test_default_2d_showcase_has_no_longer_background_tail(service, collection, preserved = service.execute({'version': 1, 'operation': 'scenes.effects.showcase', 'input': {'document': scene, 'collection': collection}})['result']['document'] assert preserved['layers'] == scene['layers'] + + +@pytest.mark.parametrize('kind', ['smoke', 'sparks']) +def test_soft_spatial_effects_roundtrip_through_commands(service, kind): + doc = showcase(service) + doc['environment'] = {'reflectiveFloor': True, 'platform': True, 'bloom': .48} + doc['slots'][0]['appearance'] = {'start': 1, 'duration': .8, 'color': '#83e8ff'} + cue = {'id': kind, 'kind': kind, 'start': 0, 'end': 3} + result = service.execute({'version': 1, 'operation': 'scenes.effects.apply', + 'input': {'document': doc, 'worldCues': [cue]}})['result']['document'] + assert result['environment'] == doc['environment'] + assert result['slots'][0]['appearance'] == doc['slots'][0]['appearance'] + assert result['worldSfx'][0]['kind'] == kind + + +def test_additive_world_apply_keeps_explosion_and_portal_media(service): + doc = showcase(service) + blast = {'id': 'blast', 'kind': 'explosion', 'start': 1.05, 'end': 3.4, + 'position': {'x': 0, 'y': .42, 'z': -.15}, 'scale': 1.8, 'color': '#ff6a32', + 'intensity': 1.35, 'sound': True, 'volume': .4} + with_blast = service.execute({'version': 1, 'operation': 'scenes.effects.apply', + 'input': {'document': doc, 'worldCues': [blast]}})['result']['document'] + assert with_blast['worldSfx'][0]['kind'] == 'explosion' + portal = {'id': 'tv', 'kind': 'media_portal', 'start': 0, 'end': 3, + 'sourceUrl': '/examples/tv-head-face.png'} + both = service.execute({'version': 1, 'operation': 'scenes.effects.apply', + 'input': {'document': with_blast, 'worldCues': [portal]}})['result']['document'] + kinds = {cue['kind'] for cue in both['worldSfx']} + assert kinds == {'explosion', 'media_portal'} + media = next(cue for cue in both['worldSfx'] if cue['kind'] == 'media_portal') + assert media['sourceUrl'] == '/examples/tv-head-face.png' + again = service.execute({'version': 1, 'operation': 'scenes.effects.apply', + 'input': {'document': both, 'worldCues': [ + {'id': 'ring', 'kind': 'shockwave', 'start': 1, 'end': 2}]}}) + assert again == service.execute({'version': 1, 'operation': 'scenes.effects.apply', + 'input': {'document': both, 'worldCues': [ + {'id': 'ring', 'kind': 'shockwave', 'start': 1, 'end': 2}]}}) + assert {cue['kind'] for cue in again['result']['document']['worldSfx']} == { + 'explosion', 'media_portal', 'shockwave'} + assert next(cue for cue in again['result']['document']['worldSfx'] + if cue['kind'] == 'media_portal')['sourceUrl'] == '/examples/tv-head-face.png' + + +@pytest.mark.parametrize('url', [ + 'JavaScript:alert(1)', + 'blob:http://localhost/abc', + 'file:///tmp/portal.png', + 'filesystem:http://localhost/tmp', +]) +def test_world_portal_media_strips_transient_urls(service, url): + doc = showcase(service) + cue = {'id': 'tv', 'kind': 'media_portal', 'start': 0, 'end': 2, + 'sourceUrl': url} + result = service.execute({'version': 1, 'operation': 'scenes.effects.apply', + 'input': {'document': doc, 'worldCues': [cue]}})['result']['document'] + assert result['worldSfx'][0]['kind'] == 'media_portal' + assert not result['worldSfx'][0].get('sourceUrl') diff --git a/tests/test_scene_library.py b/tests/test_scene_library.py index 75e5f3a99..4abf5ee9f 100644 --- a/tests/test_scene_library.py +++ b/tests/test_scene_library.py @@ -41,6 +41,13 @@ def test_invalid_saves_leave_no_outputs(tmp_path, patch): def test_reject_transient_assets_and_wrong_document_kind(tmp_path): body = payload() body['document']['slots'][0]['sourceUrl'] = 'blob:temporary-browser-model' + with pytest.raises(ValueError, match='Upload'): + save_world3d(body, lambda workspace: tmp_path / workspace) + body['document']['slots'][0]['sourceUrl'] = '' + body['document']['worldSfx'] = [{ + 'id': 'tv', 'kind': 'media_portal', 'start': 0, 'end': 2, + 'sourceUrl': 'blob:http://localhost/portal', + }] with pytest.raises(ValueError, match='Upload'): save_world3d(body, lambda workspace: tmp_path / workspace) body['document'].pop('slots') @@ -49,6 +56,36 @@ def test_reject_transient_assets_and_wrong_document_kind(tmp_path): save_world3d(body, lambda workspace: tmp_path / workspace) +def test_portal_media_with_uploaded_url_can_be_saved(tmp_path): + body = payload() + body['document']['worldSfx'] = [{ + 'id': 'tv', 'kind': 'media_portal', 'start': 0, 'end': 2, + 'position': {'x': 0, 'y': 1.15, 'z': 0}, 'rotation': {'x': 0, 'y': 0, 'z': 0}, + 'scale': 1.7, 'intensity': 1, 'color': '#3da5ff', 'seed': 1, 'sound': False, 'volume': 0.25, + 'sourceUrl': '/api/v1/uploads/portal.png', + }] + saved = save_world3d(body, lambda workspace: tmp_path / workspace) + stored = json.loads((tmp_path / 'my-film' / saved['name']).read_text()) + assert stored['worldSfx'][0]['sourceUrl'] == '/api/v1/uploads/portal.png' + + +def test_apply_strips_blob_portal_so_the_scene_can_be_saved(tmp_path): + service = SceneCommands(lambda workspace: tmp_path / workspace) + doc = document() + prepared = service.execute({ + 'version': 1, 'operation': 'scenes.effects.apply', + 'input': {'document': doc, 'worldCues': [{ + 'id': 'tv', 'kind': 'media_portal', 'start': 0, 'end': 2, + 'sourceUrl': 'blob:http://localhost/portal', + }]}, + })['result']['document'] + assert not prepared['worldSfx'][0].get('sourceUrl') + saved = save_world3d({**payload(), 'document': prepared}, lambda workspace: tmp_path / workspace) + stored = json.loads((tmp_path / 'my-film' / saved['name']).read_text()) + assert stored['worldSfx'][0]['kind'] == 'media_portal' + assert 'blob:' not in json.dumps(stored) + + def test_native_route_does_not_require_or_overwrite_legacy_layers(tmp_path): app = FastAPI() app.include_router(create_scene_commands_router(SceneCommands(lambda workspace: tmp_path / workspace))) diff --git a/tests/test_scene_packages.py b/tests/test_scene_packages.py new file mode 100644 index 000000000..8c01b0e86 --- /dev/null +++ b/tests/test_scene_packages.py @@ -0,0 +1,638 @@ +from __future__ import annotations + +import io +import json +import stat +import sys +import zipfile +from pathlib import Path + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from routers.scene_packages import create_scene_packages_router +from services.scene_library import save_world3d +from services.scene_packages import ( + MAX_ZIP_BYTES, + PACKAGE_KIND, + TEMPLATE_KIND, + ScenePackageError, + ScenePackageSecurity, + ScenePackageTooLarge, + find_existing_by_hash, + import_package, + make_workspace_reader, + preflight_package, + sha256_bytes, + write_package_zip, +) + + +def _client(tmp_path: Path) -> tuple[TestClient, dict[str, Path]]: + roots = { + "film": tmp_path / "film", + "lab": tmp_path / "lab", + "__uploads__": tmp_path / "uploads", + } + for path in roots.values(): + path.mkdir(parents=True, exist_ok=True) + app = FastAPI() + app.include_router(create_scene_packages_router( + list_workspaces=lambda: [{"name": "film"}, {"name": "lab"}], + workspace_dir=lambda name: str(roots[name]), + uploads_dir=lambda: str(roots["__uploads__"]), + )) + return TestClient(app), roots + + +def _post_zip(client: TestClient, path: str, content: bytes, *, workspace: str | None = None, reassign: str | None = None): + params: dict[str, str] = {} + if workspace: + params["workspace"] = workspace + if reassign is not None: + params["reassign"] = reassign + return client.post(path, params=params, content=content, headers={"Content-Type": "application/zip"}) + + +def _png() -> bytes: + return b"\x89PNG\r\n\x1a\npreview" + + +def _preview() -> str: + import base64 + return "data:image/png;base64," + base64.b64encode(_png()).decode() + + +def _source(workspace: str, filename: str, asset_id: str) -> dict: + url = f"/api/v1/file/{filename}?workspace={workspace}" + return {"workspaceId": workspace, "filename": filename, "url": url, "assetId": asset_id} + + +def _shot(*, title: str, glb: dict, voice: dict, screen: dict, environment: dict, extra=None) -> dict: + document = { + "version": 1, + "units": "meters", + "up": "y", + "width": 1280, + "height": 720, + "fps": 30, + "duration": 4, + "templateId": "two-shot", + "production": {"kind": "dialogue", "title": title, "workspace": glb["workspaceId"]}, + "camera": {"family": "establishment", "eye": [0, 1.6, 4.2], "look": [0, 1, 0], "fov": 50}, + "light": {"kind": "directional", "direction": [-0.35, -1, -0.25], "intensity": 1.15, "color": "#fff4e5"}, + "environment": {"reflectiveFloor": True, "platform": True, "bloom": 0.48}, + "texts": [{ + "id": "title", "text": title, "start": 0, "end": 3, "preset": "impact", + "x": 50, "y": 80, "size": 9, "color": "#ffe3a0", "rotation": 0, + }], + "sfx": [{ + "id": "flash-1", "kind": "sparks", "start": 0.2, "end": 1.2, + "x": 50, "y": 50, "size": 65, "intensity": 1, "color": "#ffbb55", + "seed": 3, "sound": True, "volume": 0.25, + }], + "worldSfx": [{ + "id": "portal-1", "kind": "portal", "start": 0, "end": 2, + "position": {"x": 0, "y": 1.1, "z": -1.2}, + "rotation": {"x": 0, "y": 0, "z": 0}, + "scale": 1.4, "intensity": 1, "color": "#88ccff", "seed": 1, + "sound": True, "volume": 0.25, + }], + "slots": [ + { + "id": "subject_1", + "slot": "subject_1", + "position": [-0.85, 0, 0], + "rotationY": 0.35, + "scale": 1, + "sourceUrl": glb["url"], + "sourceRef": glb, + "media": "model3d", + "clip": {"index": 0, "name": "Idle"}, + "clipPlayback": {"speed": 1, "start": 0, "loop": True}, + "motion": {"to": [0.85, 0, 0], "easing": "smooth"}, + "speech": { + "version": 1, "enabled": True, + "cues": [{"start": 0, "end": 0.4, "viseme": "A"}], + "driver": "imported", "start": 0, "offset": 0, "gain": 1, "strength": 0.85, + "clean": True, "style": "soft", "lip": "#874d47", "expression": "neutral", + "blink": True, "eyes": True, "audio": voice, + }, + "screen": { + "sourceUrl": screen["url"], "sourceRef": screen, "media": "image", + "mode": "mesh", "targetMesh": "SCREEN_CONTENT", "anchor": "", + "offset": [0, 0, 0], "pitch": 0, "yaw": 0, "roll": 0, + "width": 4, "height": 3, "style": "monitor", "fit": "contain", + "start": 0, "speed": 1, "loop": True, "flipY": False, + }, + }, + { + "id": "background", + "slot": "background", + "position": [0, 0, -6], + "rotationY": 0, + "scale": 1, + "sourceUrl": environment["url"], + "sourceRef": environment, + "media": "image", + "surface": "environment", + "clip": None, + }, + ], + "soundtrack": [{"id": "bed", "audio": voice, "start": 0, "offset": 0, "gain": 0.8}], + } + if extra: + document.update(extra) + return document + + +def _seed_film(root: Path) -> dict[str, dict]: + (root / "hero.glb").write_bytes(b"glb-shared") + (root / "voice.wav").write_bytes(b"RIFF-voice") + (root / "screen.png").write_bytes(_png() + b"-screen") + (root / "env.png").write_bytes(_png() + b"-env") + return { + "glb": _source("film", "hero.glb", "asset_hero"), + "voice": _source("film", "voice.wav", "asset_voice"), + "screen": _source("film", "screen.png", "asset_screen"), + "env": _source("film", "env.png", "asset_env"), + } + + +def test_format_and_router_isolation(tmp_path: Path): + sys.modules.pop("_launch_runtime", None) + sys.modules.pop("app._launch_runtime", None) + client, _roots = _client(tmp_path) + body = client.get("/api/v1/scene-packages/format").json() + assert body["kind"] == PACKAGE_KIND + assert body["template_kind"] == TEMPLATE_KIND + assert body["schema_version"] == 1 + assert "_launch_runtime" not in sys.modules + assert "app._launch_runtime" not in sys.modules + + +def test_export_two_shots_dedupes_shared_glb_and_audio(tmp_path: Path): + client, roots = _client(tmp_path) + refs = _seed_film(roots["film"]) + shots = [ + _shot(title="One", glb=refs["glb"], voice=refs["voice"], screen=refs["screen"], environment=refs["env"]), + _shot(title="Two", glb=refs["glb"], voice=refs["voice"], screen=refs["screen"], environment=refs["env"]), + ] + response = client.post("/api/v1/scene-packages/export", json={ + "workspace": "film", "title": "Pair", "documents": shots, + }) + assert response.status_code == 200, response.text + assert response.headers["content-type"].startswith("application/zip") + with zipfile.ZipFile(io.BytesIO(response.content)) as archive: + names = [info.filename for info in archive.infolist() if not info.is_dir()] + manifest = json.loads(archive.read("package.json")) + media = [name for name in names if name.startswith("media/")] + assert len(media) == 4 + assert manifest["kind"] == PACKAGE_KIND + assert len(manifest["documents"]) == 2 + glb_asset = next(item for item in manifest["assets"] if item["filename"] == "hero.glb") + assert len(glb_asset["uses"]) == 2 + voice_asset = next(item for item in manifest["assets"] if item["filename"] == "voice.wav") + assert len(voice_asset["uses"]) >= 2 + + +def test_import_preserves_lips_screen_animation_text_environment_sfx(tmp_path: Path): + client, roots = _client(tmp_path) + refs = _seed_film(roots["film"]) + shots = [ + _shot(title="One", glb=refs["glb"], voice=refs["voice"], screen=refs["screen"], environment=refs["env"]), + _shot(title="Two", glb=refs["glb"], voice=refs["voice"], screen=refs["screen"], environment=refs["env"]), + ] + exported = client.post("/api/v1/scene-packages/export", json={ + "workspace": "film", "title": "Pair", "documents": shots, + }) + imported = client.post( + "/api/v1/scene-packages/import", + params={"workspace": "lab"}, + content=exported.content, + headers={"Content-Type": "application/zip"}, + ) + assert imported.status_code == 200, imported.text + body = imported.json() + assert body["ok"] is True + assert len(body["scenes"]) == 2 + assert body["assets_created"] == 4 + lab_files = {path.name: path for path in roots["lab"].iterdir() if path.is_file()} + scenes = [json.loads(path.read_text()) for name, path in lab_files.items() if name.endswith(".world3d.scene.json")] + assert len(scenes) == 2 + for document in scenes: + slot = document["slots"][0] + assert slot["speech"]["cues"][0]["viseme"] == "A" + assert slot["speech"]["audio"]["workspaceId"] == "lab" + assert slot["speech"]["audio"]["url"].startswith("/api/v1/file/") + assert slot["screen"]["sourceUrl"].startswith("/api/v1/file/") + assert slot["clip"] == {"index": 0, "name": "Idle"} + assert slot["clipPlayback"]["loop"] is True + assert slot["motion"]["to"] == [0.85, 0, 0] + assert document["texts"][0]["text"] in {"One", "Two"} + assert document["environment"]["reflectiveFloor"] is True + assert document["slots"][1]["surface"] == "environment" + assert document["worldSfx"][0]["kind"] == "portal" + assert document["sfx"][0]["kind"] == "sparks" + assert document["soundtrack"][0]["audio"]["filename"].endswith(".wav") + media = [name for name in lab_files if not name.endswith(".json") and not name.endswith(".preview.png") and ".meta.json" not in name] + assert len(media) == 4 + + +def test_repeat_import_reuses_hashed_media(tmp_path: Path): + client, roots = _client(tmp_path) + refs = _seed_film(roots["film"]) + shots = [_shot(title="One", glb=refs["glb"], voice=refs["voice"], screen=refs["screen"], environment=refs["env"])] + exported = client.post("/api/v1/scene-packages/export", json={ + "workspace": "film", "documents": shots, + }).content + first = _post_zip(client, "/api/v1/scene-packages/import", exported, workspace="lab") + second = _post_zip(client, "/api/v1/scene-packages/import", exported, workspace="lab") + assert first.status_code == 200 and second.status_code == 200, second.text + assert second.json()["assets_reused"] == 4 + assert second.json()["assets_created"] == 0 + media = [path for path in roots["lab"].iterdir() + if path.is_file() and path.suffix in {".glb", ".wav", ".png"} and not path.name.endswith(".preview.png")] + assert len(media) == 4 + scenes = list(roots["lab"].glob("*.world3d.scene.json")) + assert len(scenes) == 2 + + +def test_tampered_asset_is_detected_and_replaced(tmp_path: Path): + client, roots = _client(tmp_path) + refs = _seed_film(roots["film"]) + shots = [_shot(title="One", glb=refs["glb"], voice=refs["voice"], screen=refs["screen"], environment=refs["env"])] + exported = client.post("/api/v1/scene-packages/export", json={"workspace": "film", "documents": shots}).content + buffer = io.BytesIO(exported) + with zipfile.ZipFile(buffer, "r") as original: + manifest = json.loads(original.read("package.json")) + glb = next(item for item in manifest["assets"] if item["filename"] == "hero.glb") + rewritten = io.BytesIO() + with zipfile.ZipFile(rewritten, "w") as dirty: + for info in original.infolist(): + data = original.read(info.filename) + if info.filename == glb["path"]: + data = b"not-the-glb" + dirty.writestr(info, data) + tampered = rewritten.getvalue() + report = _post_zip(client, "/api/v1/scene-packages/preflight", tampered).json() + assert report["ok"] is False + assert any(issue["code"] == "tampered_asset" and issue["repair"] for issue in report["issues"]) + blocked = _post_zip(client, "/api/v1/scene-packages/import", tampered, workspace="lab") + assert blocked.status_code == 422 + (roots["lab"] / "hero.glb").write_bytes(b"glb-shared") + repaired = _post_zip( + client, + "/api/v1/scene-packages/import", + tampered, + workspace="lab", + reassign=json.dumps([{"sha256": glb["sha256"], "filename": "hero.glb", "workspace": "lab"}]), + ) + assert repaired.status_code == 200, repaired.text + scene = json.loads(next(roots["lab"].glob("*.world3d.scene.json")).read_text()) + assert scene["slots"][0]["sourceRef"]["filename"] == "hero.glb" + + +def test_failed_import_leaves_previous_project_intact(tmp_path: Path): + client, roots = _client(tmp_path) + refs = _seed_film(roots["film"]) + existing = save_world3d( + {"workspace": "lab", "name": "Keep me", "preview": _preview(), + "document": _shot(title="Keep", glb=refs["glb"], voice=refs["voice"], screen=refs["screen"], environment=refs["env"])}, + lambda name: roots[name], + ) + before = {path.name: path.read_bytes() for path in roots["lab"].iterdir() if path.is_file()} + broken = _shot(title="Bad", glb=refs["glb"], voice=refs["voice"], screen=refs["screen"], environment=refs["env"]) + broken["cinema"] = {"extension": "gandalf-portal-v1"} + archive = client.post("/api/v1/scene-packages/export", json={"workspace": "film", "documents": [broken]}) + # Export itself should refuse unknown cinema. + assert archive.status_code == 422 + shots = [_shot(title="Ok", glb=refs["glb"], voice=refs["voice"], screen=refs["screen"], environment=refs["env"])] + zip_bytes = client.post("/api/v1/scene-packages/export", json={"workspace": "film", "documents": shots}).content + with zipfile.ZipFile(io.BytesIO(zip_bytes), "r") as original: + rewritten = io.BytesIO() + with zipfile.ZipFile(rewritten, "w") as dirty: + for info in original.infolist(): + data = original.read(info.filename) + if info.filename.startswith("documents/"): + document = json.loads(data) + document["duration"] = 0 + data = json.dumps(document).encode() + dirty.writestr(info, data) + failed = _post_zip(client, "/api/v1/scene-packages/import", rewritten.getvalue(), workspace="lab") + assert failed.status_code == 422 + after = {path.name: path.read_bytes() for path in roots["lab"].iterdir() if path.is_file()} + assert after == before + assert (roots["lab"] / existing["name"]).is_file() + + +def test_reject_path_traversal_symlink_and_absolute_members(tmp_path: Path): + client, roots = _client(tmp_path) + for name, payload in { + "slip.zip": ("../../etc/passwd", b"root"), + "abs.zip": ("/tmp/evil.glb", b"x"), + "nested.zip": ("media/../../evil.glb", b"x"), + }.items(): + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w") as archive: + archive.writestr("package.json", json.dumps({ + "kind": PACKAGE_KIND, "schema_version": 1, "documents": [], "assets": [], + })) + archive.writestr(payload[0], payload[1]) + response = _post_zip(client, "/api/v1/scene-packages/preflight", buffer.getvalue()) + assert response.status_code == 422, response.text + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w") as archive: + archive.writestr("package.json", json.dumps({ + "kind": PACKAGE_KIND, "schema_version": 1, "documents": [], "assets": [], + })) + info = zipfile.ZipInfo("media/" + "a" * 64 + ".glb") + info.create_system = 3 + info.external_attr = (stat.S_IFLNK | 0o777) << 16 + archive.writestr(info, b"target") + response = _post_zip(client, "/api/v1/scene-packages/preflight", buffer.getvalue()) + assert response.status_code == 422 + + +def test_reject_external_links_oversized_zip_unknown_cinema_and_template(tmp_path: Path): + client, roots = _client(tmp_path) + refs = _seed_film(roots["film"]) + external = _shot(title="Net", glb={**refs["glb"], "url": "https://evil.example/hero.glb"}, + voice=refs["voice"], screen=refs["screen"], environment=refs["env"]) + response = client.post("/api/v1/scene-packages/export", json={"workspace": "film", "documents": [external]}) + assert response.status_code == 422 + cinema = _shot(title="Cinema", glb=refs["glb"], voice=refs["voice"], screen=refs["screen"], environment=refs["env"], + extra={"cinemaExtension": "tools/cinema"}) + response = client.post("/api/v1/scene-packages/export", json={"workspace": "film", "documents": [cinema]}) + assert response.status_code == 422 + template = { + "kind": TEMPLATE_KIND, "version": 1, "id": "user-1", "title": "Cafe", + "description": "", "includeAssets": False, "createdAt": "2026-09-11T00:00:00Z", + "document": _shot(title="Cafe", glb=refs["glb"], voice=refs["voice"], screen=refs["screen"], environment=refs["env"]), + } + response = client.post( + "/api/v1/scene-packages/preflight", + content=json.dumps(template).encode(), + headers={"Content-Type": "application/json"}, + ) + assert response.status_code == 422 + assert "template" in response.json()["detail"].lower() + monkey_zip = tmp_path / "tiny.zip" + monkey_zip.write_bytes(b"PK\x03\x04" + b"0" * 80) + original = MAX_ZIP_BYTES + try: + import services.scene_packages as pkg + pkg.MAX_ZIP_BYTES = 10 + from services.scene_packages import inspect_zip_members + with pytest.raises(ScenePackageTooLarge): + inspect_zip_members(monkey_zip) + finally: + import services.scene_packages as pkg + pkg.MAX_ZIP_BYTES = original + + +def test_unknown_fields_are_listed_and_kept(tmp_path: Path): + client, roots = _client(tmp_path) + refs = _seed_film(roots["film"]) + shot = _shot(title="Flags", glb=refs["glb"], voice=refs["voice"], screen=refs["screen"], environment=refs["env"], + extra={"customRendererFlag": True}) + shot["slots"][0]["mysteryRig"] = {"bones": 2} + exported = client.post("/api/v1/scene-packages/export", json={"workspace": "film", "documents": [shot]}) + assert exported.status_code == 200, exported.text + report = _post_zip(client, "/api/v1/scene-packages/preflight", exported.content).json() + joined = " ".join(report["unknown_fields"]) + assert "customRendererFlag" in joined + assert "mysteryRig" in joined + imported = _post_zip(client, "/api/v1/scene-packages/import", exported.content, workspace="lab") + assert imported.status_code == 200, imported.text + document = json.loads(next(roots["lab"].glob("*.world3d.scene.json")).read_text()) + assert document["customRendererFlag"] is True + assert document["slots"][0]["mysteryRig"] == {"bones": 2} + + +def test_wrapper_is_accepted_inside_a_package_but_not_as_the_package(tmp_path: Path): + client, roots = _client(tmp_path) + refs = _seed_film(roots["film"]) + inner = _shot(title="Wrap", glb=refs["glb"], voice=refs["voice"], screen=refs["screen"], environment=refs["env"]) + wrapper = { + "kind": TEMPLATE_KIND, "version": 1, "id": "user-cafe", "title": "Cafe", + "description": "", "includeAssets": True, "createdAt": "2026-09-11T00:00:00Z", + "document": inner, + } + exported = client.post("/api/v1/scene-packages/export", json={"workspace": "film", "documents": [wrapper]}) + assert exported.status_code == 200, exported.text + with zipfile.ZipFile(io.BytesIO(exported.content)) as archive: + manifest = json.loads(archive.read("package.json")) + packed = json.loads(archive.read(manifest["documents"][0]["path"])) + assert manifest["kind"] == PACKAGE_KIND + assert packed["kind"] == TEMPLATE_KIND + imported = _post_zip(client, "/api/v1/scene-packages/import", exported.content, workspace="lab") + assert imported.status_code == 200, imported.text + document = json.loads(next(roots["lab"].glob("*.world3d.scene.json")).read_text()) + assert document["texts"][0]["text"] == "Wrap" + assert document.get("kind") != TEMPLATE_KIND + + +def test_import_keeps_empty_companion_slots(tmp_path: Path): + client, roots = _client(tmp_path) + refs = _seed_film(roots["film"]) + shot = _shot(title="Partial", glb=refs["glb"], voice=refs["voice"], screen=refs["screen"], environment=refs["env"]) + shot["slots"].append({ + "id": "subject_2", + "slot": "subject_2", + "position": [0.85, 0, 0], + "rotationY": -0.35, + "scale": 1, + "sourceUrl": "", + "media": "model3d", + "clip": None, + "screen": { + "sourceUrl": "", "media": "image", "mode": "mesh", + "targetMesh": "SCREEN_CONTENT", "anchor": "", + "offset": [0, 0, 0], "pitch": 0, "yaw": 0, "roll": 0, + "width": 4, "height": 3, "style": "monitor", "fit": "contain", + "start": 0, "speed": 1, "loop": True, "flipY": False, + }, + }) + exported = client.post("/api/v1/scene-packages/export", json={"workspace": "film", "documents": [shot]}) + assert exported.status_code == 200, exported.text + imported = _post_zip(client, "/api/v1/scene-packages/import", exported.content, workspace="lab") + assert imported.status_code == 200, imported.text + document = json.loads(next(roots["lab"].glob("*.world3d.scene.json")).read_text()) + empty = next(slot for slot in document["slots"] if slot["id"] == "subject_2") + assert empty["sourceUrl"] == "" + assert empty["screen"]["sourceUrl"] == "" + assert document["slots"][0]["sourceUrl"].startswith("/api/v1/file/") + assert "workspace=lab" in document["slots"][0]["sourceUrl"] + + +def test_media_portal_source_url_is_packed_and_rebound(tmp_path: Path): + client, roots = _client(tmp_path) + refs = _seed_film(roots["film"]) + (roots["__uploads__"] / "portal.png").write_bytes(_png() + b"-portal") + shot = _shot(title="Portal", glb=refs["glb"], voice=refs["voice"], screen=refs["screen"], environment=refs["env"]) + shot["worldSfx"] = [{ + "id": "tv", "kind": "media_portal", "start": 0, "end": 3, + "position": {"x": 0, "y": 1.15, "z": -1.2}, + "rotation": {"x": 0, "y": 0, "z": 0}, + "scale": 1.7, "intensity": 1, "color": "#88ccff", "seed": 1, + "sound": True, "volume": 0.25, + "sourceUrl": "/api/v1/uploads/portal.png", + }] + exported = client.post("/api/v1/scene-packages/export", json={"workspace": "film", "documents": [shot]}) + assert exported.status_code == 200, exported.text + with zipfile.ZipFile(io.BytesIO(exported.content)) as archive: + manifest = json.loads(archive.read("package.json")) + packed = json.loads(archive.read("documents/shot-1.json")) + portal_asset = next(item for item in manifest["assets"] if item["filename"] == "portal.png") + assert "worldSfx[0]" in "".join(portal_asset["uses"]) + assert packed["worldSfx"][0]["sourceUrl"].startswith("media/") + assert "sourceRef" not in packed["worldSfx"][0] + imported = _post_zip(client, "/api/v1/scene-packages/import", exported.content, workspace="lab") + assert imported.status_code == 200, imported.text + document = json.loads(next(roots["lab"].glob("*.world3d.scene.json")).read_text()) + portal = document["worldSfx"][0] + assert portal["kind"] == "media_portal" + assert portal["sourceUrl"].startswith("/api/v1/file/") + assert "workspace=lab" in portal["sourceUrl"] + assert "sourceRef" not in portal + filename = portal["sourceUrl"].split("/file/", 1)[1].split("?", 1)[0] + assert (roots["lab"] / filename).read_bytes() == _png() + b"-portal" + + +def test_example_portal_url_is_left_in_place(tmp_path: Path): + client, roots = _client(tmp_path) + refs = _seed_film(roots["film"]) + shot = _shot(title="Stock", glb=refs["glb"], voice=refs["voice"], screen=refs["screen"], environment=refs["env"]) + shot["worldSfx"][0]["kind"] = "media_portal" + shot["worldSfx"][0]["sourceUrl"] = "/examples/tv-head-face.png" + exported = client.post("/api/v1/scene-packages/export", json={"workspace": "film", "documents": [shot]}) + assert exported.status_code == 200, exported.text + with zipfile.ZipFile(io.BytesIO(exported.content)) as archive: + manifest = json.loads(archive.read("package.json")) + packed = json.loads(archive.read("documents/shot-1.json")) + assert all(item["filename"] != "tv-head-face.png" for item in manifest["assets"]) + assert packed["worldSfx"][0]["sourceUrl"] == "/examples/tv-head-face.png" + + +def test_source_url_only_slots_rebind_on_import(tmp_path: Path): + client, roots = _client(tmp_path) + (roots["film"] / "hero.glb").write_bytes(b"glb-shared") + (roots["film"] / "screen.png").write_bytes(_png() + b"-screen") + shot = _shot( + title="Bare", + glb=_source("film", "hero.glb", "asset_hero"), + voice=_source("film", "voice.wav", "asset_voice"), + screen=_source("film", "screen.png", "asset_screen"), + environment=_source("film", "env.png", "asset_env"), + ) + shot["slots"][0].pop("sourceRef") + shot["slots"][0]["screen"].pop("sourceRef") + shot["slots"][0].pop("speech") + shot["soundtrack"] = [] + (roots["film"] / "voice.wav").write_bytes(b"RIFF-voice") + (roots["film"] / "env.png").write_bytes(_png() + b"-env") + exported = client.post("/api/v1/scene-packages/export", json={"workspace": "film", "documents": [shot]}) + assert exported.status_code == 200, exported.text + with zipfile.ZipFile(io.BytesIO(exported.content)) as archive: + packed = json.loads(archive.read("documents/shot-1.json")) + assert packed["slots"][0]["sourceUrl"].startswith("media/") + assert packed["slots"][0]["sourceRef"]["assetId"].startswith("sha256:") + assert packed["slots"][0]["screen"]["sourceUrl"].startswith("media/") + imported = _post_zip(client, "/api/v1/scene-packages/import", exported.content, workspace="lab") + assert imported.status_code == 200, imported.text + document = json.loads(next(roots["lab"].glob("*.world3d.scene.json")).read_text()) + assert document["slots"][0]["sourceUrl"].startswith("/api/v1/file/") + assert "workspace=lab" in document["slots"][0]["sourceUrl"] + assert document["slots"][0]["screen"]["sourceUrl"].startswith("/api/v1/file/") + assert "workspace=lab" in document["slots"][0]["screen"]["sourceUrl"] + assert (roots["lab"] / document["slots"][0]["sourceRef"]["filename"]).is_file() + + +def test_import_avoids_sidecar_stem_collision(tmp_path: Path): + client, roots = _client(tmp_path) + refs = _seed_film(roots["film"]) + (roots["lab"] / "hero.glb").write_bytes(b"existing-lab-glb") + from services.asset_manifest import build_asset_manifest, write_asset_manifest + dest = roots["lab"] / "hero.glb" + write_asset_manifest(dest, build_asset_manifest( + dest, kind="model3d", workspace_id="lab", tool="seed", actor="user", + execution_mode="import", technical={"sha256": "0" * 64}, + )) + shot = _shot(title="Stem", glb=refs["glb"], voice=refs["voice"], screen={ + **_source("film", "hero.png", "asset_screen"), + }, environment=refs["env"]) + (roots["film"] / "hero.png").write_bytes(_png() + b"-hero-screen") + shot["slots"][0]["screen"]["sourceUrl"] = "/api/v1/file/hero.png?workspace=film" + shot["slots"][0]["screen"]["sourceRef"] = _source("film", "hero.png", "asset_screen") + exported = client.post("/api/v1/scene-packages/export", json={"workspace": "film", "documents": [shot]}) + assert exported.status_code == 200, exported.text + imported = _post_zip(client, "/api/v1/scene-packages/import", exported.content, workspace="lab") + assert imported.status_code == 200, imported.text + assert (roots["lab"] / "hero.glb").read_bytes() == b"existing-lab-glb" + document = json.loads(next(roots["lab"].glob("*.world3d.scene.json")).read_text()) + screen_name = document["slots"][0]["screen"]["sourceRef"]["filename"] + assert screen_name != "hero.glb" + assert (roots["lab"] / screen_name).is_file() + assert screen_name.endswith(".png") + + +def test_hash_reuse_ignores_sibling_that_shares_a_sidecar(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + client, roots = _client(tmp_path) + refs = _seed_film(roots["film"]) + digest = sha256_bytes(b"glb-shared") + dest = roots["lab"] / "hero.glb" + dest.write_bytes(b"glb-shared") + from services.asset_manifest import build_asset_manifest, sidecar_path, write_asset_manifest + write_asset_manifest(dest, build_asset_manifest( + dest, kind="model3d", workspace_id="lab", tool="seed", actor="user", + execution_mode="import", technical={"sha256": digest}, + )) + portrait = roots["lab"] / "hero.png" + portrait.write_bytes(_png() + b"-portrait") + original = Path.iterdir + + def png_first(self: Path): + if self.resolve() == roots["lab"].resolve(): + return iter((portrait, dest, sidecar_path(dest))) + return original(self) + + monkeypatch.setattr(Path, "iterdir", png_first) + shot = _shot(title="Reuse", glb=refs["glb"], voice=refs["voice"], screen=refs["screen"], environment=refs["env"]) + exported = client.post("/api/v1/scene-packages/export", json={"workspace": "film", "documents": [shot]}) + assert exported.status_code == 200, exported.text + imported = _post_zip(client, "/api/v1/scene-packages/import", exported.content, workspace="lab") + assert imported.status_code == 200, imported.text + document = json.loads(next(roots["lab"].glob("*.world3d.scene.json")).read_text()) + assert document["slots"][0]["sourceRef"]["filename"] == "hero.glb" + assert "hero.glb" in document["slots"][0]["sourceUrl"] + assert "workspace=lab" in document["slots"][0]["sourceUrl"] + assert find_existing_by_hash(roots["lab"], digest, len(b"glb-shared")) == dest + + +def test_service_helpers_without_http(tmp_path: Path): + film = tmp_path / "film" + lab = tmp_path / "lab" + film.mkdir() + lab.mkdir() + refs = _seed_film(film) + reader = make_workspace_reader(lambda name: str(tmp_path / name)) + shot = _shot(title="Solo", glb=refs["glb"], voice=refs["voice"], screen=refs["screen"], environment=refs["env"]) + archive = write_package_zip([shot], reader, workspace="film", title="Solo") + path = tmp_path / "solo.zip" + path.write_bytes(archive) + report = preflight_package(path) + assert report["ok"] is True + result = import_package(path, workspace="lab", workspace_dir=lambda name: str(tmp_path / name), reader=reader) + assert result["assets_created"] == 4 + glb = lab / "hero.glb" + assert find_existing_by_hash(lab, sha256_bytes(b"glb-shared"), len(b"glb-shared")) == glb + with pytest.raises(ScenePackageError): + require = __import__("services.scene_packages", fromlist=["require_workspace"]).require_workspace + require("../nope") + with pytest.raises(ScenePackageSecurity): + write_package_zip([ + _shot(title="X", glb={**refs["glb"], "url": "blob:temp"}, voice=refs["voice"], + screen=refs["screen"], environment=refs["env"]), + ], reader, workspace="film") diff --git a/tests/test_select_local_tests.py b/tests/test_select_local_tests.py new file mode 100644 index 000000000..fa31b727e --- /dev/null +++ b/tests/test_select_local_tests.py @@ -0,0 +1,199 @@ +"""Fail-closed coverage for the local pytest selector. No GitHub required.""" +from __future__ import annotations + +import json +import subprocess +import sys +from pathlib import Path + +from scripts.select_local_tests import ( + ManifestError, + check_partition, + discover_suite_files, + full_suite_paths, + group_paths, + load_manifest, + main, + select_paths, +) + + +ROOT = Path(__file__).resolve().parents[1] +SCRIPT = ROOT / "scripts" / "select_local_tests.py" +MANIFEST = ROOT / "scripts" / "ci_test_groups.json" + + +def _write_manifest(path: Path, payload: dict) -> Path: + path.write_text(json.dumps(payload), encoding="utf-8") + return path + + +def test_unknown_path_runs_union_of_all_groups(): + manifest = load_manifest(MANIFEST) + selected, reason, unknown = select_paths( + ["app/services/definitely-not-mapped.py"], + ROOT, + manifest, + ) + assert reason == "unknown-path" + assert unknown == ["app/services/definitely-not-mapped.py"] + assert selected == full_suite_paths(ROOT, manifest) + assert selected + assert set(selected) == set(discover_suite_files(ROOT)) + + +def test_known_test_file_is_mapped_to_itself(): + manifest = load_manifest(MANIFEST) + selected, reason, unknown = select_paths( + ["tests/test_ci_required.py"], + ROOT, + manifest, + ) + assert reason == "mapped" + assert unknown == [] + assert selected == ["tests/test_ci_required.py"] + + +def test_path_rule_maps_source_to_declared_tests(): + manifest = load_manifest(MANIFEST) + selected, reason, unknown = select_paths( + ["scripts/ci_required.py"], + ROOT, + manifest, + ) + assert reason == "mapped" + assert unknown == [] + assert selected == ["tests/test_ci_required.py"] + + +def test_empty_input_runs_full_suite_not_empty(): + manifest = load_manifest(MANIFEST) + selected, reason, unknown = select_paths([], ROOT, manifest) + assert reason == "empty-input" + assert unknown == [] + assert selected + assert selected == full_suite_paths(ROOT, manifest) + + +def test_known_plus_unknown_does_not_omit(): + manifest = load_manifest(MANIFEST) + selected, reason, unknown = select_paths( + ["tests/test_ci_required.py", "ui/src/not-a-python-mapping.tsx"], + ROOT, + manifest, + ) + assert reason == "unknown-path" + assert "ui/src/not-a-python-mapping.tsx" in unknown + assert set(selected) == set(discover_suite_files(ROOT)) + + +def test_broken_partition_falls_back_to_tests_directory(): + manifest = { + "groups": [ + { + "id": "python-a", + "job": "python-tests-a", + "name": "Python tests A", + "paths": ["tests/test_ci_required.py"], + } + ], + "path_rules": [], + } + selected, reason, unknown = select_paths(["mystery.py"], ROOT, manifest) + assert reason == "unknown-path" + assert selected == ["tests"] + assert unknown == ["mystery.py"] + + +def test_group_selection_assigns_ungrouped_files_to_the_first_shard(tmp_path: Path): + root = tmp_path / "repo" + (root / "tests").mkdir(parents=True) + (root / "tests" / "test_one.py").write_text("def test_ok():\n pass\n", encoding="utf-8") + (root / "tests" / "test_two.py").write_text("def test_ok():\n pass\n", encoding="utf-8") + (root / "tests" / "test_three.py").write_text("def test_ok():\n pass\n", encoding="utf-8") + manifest = { + "groups": [ + { + "id": "python-a", + "job": "python-tests-a", + "name": "Python tests A", + "paths": ["tests/test_one.py"], + }, + { + "id": "python-b", + "job": "python-tests-b", + "name": "Python tests B", + "paths": ["tests/test_three.py"], + }, + ] + } + assert group_paths("python-a", root, manifest) == ["tests/test_one.py", "tests/test_two.py"] + assert group_paths("python-b", root, manifest) == ["tests/test_three.py"] + try: + check_partition(root, manifest) + except ManifestError as exc: + assert "missing from groups" in str(exc) + assert "tests/test_two.py" in str(exc) + else: + raise AssertionError("stale manifest must still fail --check-partition") + + +def test_missing_manifest_is_not_an_empty_suite(tmp_path: Path): + missing = tmp_path / "absent.json" + code = main(["--manifest", str(missing), "tests/test_ci_required.py"]) + assert code == 2 + + +def test_cli_unknown_path_prints_full_suite(): + completed = subprocess.run( + [sys.executable, str(SCRIPT), "app/unknown_module.py"], + check=False, + capture_output=True, + text=True, + cwd=ROOT, + ) + assert completed.returncode == 0 + lines = [line for line in completed.stdout.splitlines() if line] + assert lines + assert "unknown path" in completed.stderr + assert "running full suite" in completed.stderr + assert "tests/test_ci_required.py" in lines + assert set(lines) == set(discover_suite_files(ROOT)) + + +def test_cli_group_is_non_empty_and_disjoint(): + a = subprocess.run( + [sys.executable, str(SCRIPT), "--group", "python-a"], + check=False, + capture_output=True, + text=True, + cwd=ROOT, + ) + b = subprocess.run( + [sys.executable, str(SCRIPT), "--group", "python-b"], + check=False, + capture_output=True, + text=True, + cwd=ROOT, + ) + assert a.returncode == 0 + assert b.returncode == 0 + paths_a = set(a.stdout.split()) + paths_b = set(b.stdout.split()) + assert paths_a + assert paths_b + assert paths_a.isdisjoint(paths_b) + assert paths_a | paths_b == set(discover_suite_files(ROOT)) + + +def test_cli_refuses_unknown_group(): + completed = subprocess.run( + [sys.executable, str(SCRIPT), "--group", "python-z"], + check=False, + capture_output=True, + text=True, + cwd=ROOT, + ) + assert completed.returncode == 2 + assert completed.stdout == "" + assert "unknown group" in completed.stderr diff --git a/tests/test_series_jobs.py b/tests/test_series_jobs.py index f7185fcb1..111f6f72e 100644 --- a/tests/test_series_jobs.py +++ b/tests/test_series_jobs.py @@ -6,6 +6,7 @@ import threading import time import types +import pytest from pathlib import Path from services.series_jobs import SeriesJobStore @@ -44,6 +45,29 @@ def test_render_queue_survives_store_recreation(tmp_path): assert second.recoverable()[0]["jobId"] == "series-render-1" +def test_render_endpoint_and_resumption_cannot_bypass_series_production_permissions(): + from fastapi import HTTPException + shot = {'id':'shot-1', 'productionMethod':'generated_video', 'attempts':[]} + episode = {'id':'episode-1', 'shots':[shot]} + series = {'id':'series-1', 'allowedProductionMethods':['animation_2d'], 'episodesById':{'episode-1':episode}} + namespace = {'copy':copy, 'HTTPException':HTTPException, '_series_library_lock':threading.RLock(), + '_series_library_workspace':lambda value: 'default', + '_read_series_workspace':lambda workspace: {'seriesById':{'series-1':series}}, + '_series_project_or_404':lambda library, key: library['seriesById'][key], + '_active_series_render_for_episode':lambda *args: None, + '_series_render_candidates':lambda episode, body: episode['shots'], + } + _load_launch_functions('start_series_episode_render', '_series_render_context', namespace=namespace) + with pytest.raises(HTTPException, match='not permitted') as denied: + namespace['start_series_episode_render']('series-1', 'episode-1', {}) + assert denied.value.status_code == 400 + with pytest.raises(ValueError, match='not permitted'): + namespace['_series_render_context']({'workspace':'default','seriesId':'series-1','episodeId':'episode-1'}, {'shotId':'shot-1'}) + shot['productionMethod'] = 'animation_2d' + with pytest.raises(HTTPException, match='No permitted'): + namespace['start_series_episode_render']('series-1', 'episode-1', {}) + + def test_discard_removes_checkpoint_not_output(tmp_path): output = Path(tmp_path) / "approved.mp4" output.write_bytes(b"approved") diff --git a/tests/test_series_lab_ui.py b/tests/test_series_lab_ui.py index 8073f4dff..d186a823c 100644 --- a/tests/test_series_lab_ui.py +++ b/tests/test_series_lab_ui.py @@ -23,7 +23,7 @@ def test_series_lab_is_top_level_immediately_after_story_lab(): def test_client_created_series_entities_use_browser_uuid(): model = source("model.ts") - assert "crypto.randomUUID()" in model + assert "randomUuid()" in model assert "Math.random()" not in model diff --git a/tests/test_series_planning.py b/tests/test_series_planning.py index 45a49bd61..09ac10360 100644 --- a/tests/test_series_planning.py +++ b/tests/test_series_planning.py @@ -63,6 +63,40 @@ def shot_result(count=8): } for index in range(count)]} +def test_shot_planning_respects_the_series_production_allowlist(): + series, episode = prepared() + series['allowedProductionMethods'] = ['animation_2d', 'imported_video'] + episode['script'] = normalize_planning_result('script', script_result(), series, episode)['script'] + result = shot_result() + for index, shot in enumerate(result['shots']): + shot['productionMethod'] = 'animation_2d' if index % 2 else 'imported_video' + normalized = normalize_planning_result('shots', result, series, episode) + assert {shot['productionMethod'] for shot in normalized['shots']} == {'animation_2d', 'imported_video'} + prompt, system = planning_prompt('shots', series, episode) + assert 'allowedProductionMethods' in prompt + assert 'choose productionMethod only from ["animation_2d", "imported_video"]' in system + result['shots'][0]['productionMethod'] = 'generated_video' + with pytest.raises(ValueError, match='not permitted'): + normalize_planning_result('shots', result, series, episode) + + +@pytest.mark.parametrize('method', ['animation_2d', 'animation_3d']) +def test_animation_shot_requires_or_inherits_its_canonical_environment(method): + series, episode = prepared() + series['allowedProductionMethods'] = [method] + episode['script'] = normalize_planning_result('script', script_result(), series, episode)['script'] + result = shot_result() + for shot in result['shots']: + shot.update(productionMethod=method, sceneId=episode['script'][0]['id'], locationId='', + visibleCharacterIds=[], speakingCharacterIds=[], primarySpeakerId='') + normalized = normalize_planning_result('shots', result, series, episode) + assert all(shot['locationId'] == 'loc_a' for shot in normalized['shots']) + assert all(shot['visibleCharacterIds'] == [] for shot in normalized['shots']) + episode['script'][0]['locationId'] = '' + with pytest.raises(ValueError, match='needs a canonical location'): + normalize_planning_result('shots', result, series, episode) + + def test_scopes_and_schemas_are_bounded(): assert planning_stages("outline") == ["outline"] assert planning_stages("complete")[-1] == "canon_delta" diff --git a/tests/test_series_production.py b/tests/test_series_production.py new file mode 100644 index 000000000..9b4392957 --- /dev/null +++ b/tests/test_series_production.py @@ -0,0 +1,122 @@ +import copy +import json +from pathlib import Path + +import pytest + +from services.series_library import ( + SeriesConflictError, create_series_episode, create_series_project, + normalize_series_project, series_for_episode_snapshot, +) +from services.series_production import ( + attach_series_import, normalize_production_methods, refresh_episode_references, series_shot_method, +) +from services.series_reference_router import route_shot_references +from services.series_render import apply_series_shot_duration + + +def project(): + path = Path(__file__).parents[1] / 'docs/series-lab/example-series-library-v1.json' + return normalize_series_project(json.loads(path.read_text())['seriesById']['series_signal'], 'series_signal', 'default') + + +def test_production_permissions_persist_and_do_not_silently_allow_other_methods(): + series = project() + assert series['allowedProductionMethods'] == ['generated_video'] + series['allowedProductionMethods'] = ['animation_2d', 'imported_video'] + saved = normalize_series_project(series, series['id'], 'default') + assert saved['allowedProductionMethods'] == series['allowedProductionMethods'] + assert series_shot_method(saved, {}) == 'animation_2d' + with pytest.raises(ValueError, match='not permitted'): + series_shot_method(saved, {'productionMethod': 'generated_video', 'order': 1}) + for invalid in ([], ['unknown'], 'animation_2d'): + with pytest.raises(ValueError): + normalize_production_methods(invalid) + + +def test_reference_import_updates_exact_owner_and_existing_episode_can_adopt_it(): + series = create_series_project('default') + series['canon'].update(worldSummary='Original lore', approval='approved', revision=2) + series['characters'] = [{'id':'character_a', 'name':'Ada', 'approval':'approved', 'referenceAssetIds':[], 'wardrobeVariants':[]}] + series['locations'] = [{'id':'location_a', 'name':'Lab', 'approval':'approved', 'referenceAssetIds':[], 'variants':[]}] + episode = create_series_episode(series) + series['episodesById'][episode['id']] = episode + asset = {'id':'asset_portrait', 'workspaceId':'default', 'kind':'character', 'uri':'assets/a/portrait.png', + 'ownerType':'character', 'ownerId':'character_a', 'isDerivedThumbnail':False, 'metadata':{'referenceRole':'primary_portrait'}} + attach_series_import(series, asset) + assert series['characters'][0]['primaryReferenceAssetId'] == asset['id'] + assert series['canon']['approval'] == 'draft' + with pytest.raises(ValueError, match='Approve'): + refresh_episode_references(series, episode['id'], series['revision']) + series['canon']['approval'] = series['characters'][0]['approval'] = 'approved' + series['characters'][0]['name'] = 'Later name' + series['canon']['worldSummary'] = 'Later lore' + updated = refresh_episode_references(series, episode['id'], series['revision']) + updated_episode = updated['episodesById'][episode['id']] + frozen = series_for_episode_snapshot(updated, updated_episode) + assert frozen['characters'][0]['name'] == 'Ada' + assert frozen['canon']['worldSummary'] == 'Original lore' + assert frozen['characters'][0]['primaryReferenceAssetId'] == asset['id'] + assert episode['canonSnapshot']['characters'][0]['referenceAssetIds'] == [] + manifest = route_shot_references(frozen, updated_episode, { + 'id':'shot_a', 'visibleCharacterIds':['character_a'], 'speakingCharacterIds':['character_a'], + 'primarySpeakerId':'character_a', 'renderStrategy':'references', + }) + assert asset['id'] in [item['assetId'] for item in manifest['selected']] + with pytest.raises(SeriesConflictError): + refresh_episode_references(updated, episode['id'], series['revision']) + + +def test_reference_refresh_keeps_takes_and_rejects_active_render(): + series = project() + episode = next(iter(series['episodesById'].values())) + shot = episode['shots'][0] + previous = copy.deepcopy(shot['attempts']) + series['canon']['approval'] = 'approved' + for other in episode['shots']: + for attempt in other['attempts']: + attempt['status'] = 'completed' + updated = refresh_episode_references(series, episode['id'], series['revision']) + assert updated['episodesById'][episode['id']]['shots'][0]['attempts'] == shot['attempts'] + assert len(previous) == len(shot['attempts']) + shot['attempts'].append({'id':'busy', 'status':'running'}) + with pytest.raises(SeriesConflictError, match='finish'): + refresh_episode_references(series, episode['id'], series['revision']) + + +@pytest.mark.parametrize('method', ['animation_2d', 'animation_3d', 'imported_video']) +def test_imported_finished_take_is_verified_append_only_and_keeps_method(method, monkeypatch): + series = project() + series['allowedProductionMethods'] = [method] + episode = next(iter(series['episodesById'].values())) + shot = episode['shots'][0] + shot['productionMethod'] = method + for attempt in shot['attempts']: + attempt['status'] = 'completed' + shot['approvedAttemptId'] = shot['attempts'][0]['id'] + previous = copy.deepcopy(shot['attempts']) + approved = shot.get('approvedAttemptId') + asset = {'id':'asset_external', 'kind':'video', 'uri':'assets/take.mp4', 'workspaceId':'default', + 'ownerType':'shot', 'ownerId':shot['id'], 'isDerivedThumbnail':False, 'metadata':{'lipSyncUpdate':True}} + monkeypatch.setattr('services.video_editor.probe_media', lambda path: {'duration':60, 'width':1280, 'height':720}) + attach_series_import(series, asset, as_take=True, source_path='verified.mp4') + updated = episode['shots'][0] + assert updated['attempts'][:-1] == previous + assert updated.get('approvedAttemptId') == approved + take = updated['attempts'][-1] + assert take['status'] == 'completed' and take['model'] == method + assert take['outputAssetIds'] == [asset['id']] + assert take['id'] != approved and take.get('reviewDecision') != 'approved' + assert asset['metadata']['lipSyncUpdate'] is True + assert asset['ownerType'] == 'attempt' and asset['ownerId'] == take['id'] + normalize_series_project(series, series['id'], 'default') + monkeypatch.setattr('services.video_editor.probe_media', lambda path: {'duration':.01}) + with pytest.raises(ValueError, match='shorter'): + attach_series_import(series, {**asset, 'ownerType':'shot', 'ownerId':shot['id']}, as_take=True, source_path='short.mp4') + + +def test_native_animation_duration_is_not_requantized_to_h3(): + shot = {'productionMethod':'animation_2d', 'durationSeconds':23.4, 'dialogueDuration':{'old':True}} + apply_series_shot_duration({}, shot) + assert shot['durationSeconds'] == 23.4 + assert 'dialogueDuration' not in shot diff --git a/tests/test_speech_analysis_cache.py b/tests/test_speech_analysis_cache.py new file mode 100644 index 000000000..b92463836 --- /dev/null +++ b/tests/test_speech_analysis_cache.py @@ -0,0 +1,258 @@ +"""Reuse isolation and lip-cue analysis without downloading models.""" +from __future__ import annotations + +import io +import json +import os +import subprocess +import threading +import time +import wave +from pathlib import Path +from types import SimpleNamespace + +import pytest +from services import scene3d_speech as speech +from services import vocal_isolation as vocals +from services.scene3d_speech import SpeechAnalysisError, SpeechAnalysisUnavailable +from services.speech_analysis_cache import ( + analysis_material, + isolation_material, + material_key, + remember, + reset_runtime_state, +) + + +@pytest.fixture(autouse=True) +def _cache_env(tmp_path, monkeypatch): + monkeypatch.setenv("SPEECH_ANALYSIS_CACHE_DIR", str(tmp_path / "speech-cache")) + reset_runtime_state() + yield + reset_runtime_state() + + +def wav(seconds=1, rate=16000): + output = io.BytesIO() + with wave.open(output, "wb") as audio: + audio.setnchannels(1) + audio.setsampwidth(2) + audio.setframerate(rate) + audio.writeframes(b"\0\0" * int(seconds * rate)) + return output.getvalue() + + +def _copy_worker(calls): + def run(args, **kwargs): + calls.append(args) + Path(args[3]).write_bytes(Path(args[2]).read_bytes()) + return SimpleNamespace(returncode=0) + return run + + +def test_three_shots_of_the_same_segment_isolate_once(monkeypatch): + monkeypatch.setattr(vocals, "isolation_capability", lambda: {"available": True, "reason": "ready"}) + calls = [] + monkeypatch.setattr(vocals.subprocess, "run", _copy_worker(calls)) + source = wav(1) + results = [vocals.isolate_voice(source) for _ in range(3)] + assert len(calls) == 1 + assert results == [source, source, source] + + +def test_simultaneous_shots_share_one_worker(monkeypatch): + monkeypatch.setattr(vocals, "isolation_capability", lambda: {"available": True, "reason": "ready"}) + calls = [] + entered = threading.Event() + release = threading.Event() + + def run(args, **kwargs): + calls.append(args) + entered.set() + assert release.wait(2) + Path(args[3]).write_bytes(Path(args[2]).read_bytes()) + return SimpleNamespace(returncode=0) + + monkeypatch.setattr(vocals.subprocess, "run", run) + source = wav(1) + results, errors = [None] * 3, [None] * 3 + + def shot(index): + try: + results[index] = vocals.isolate_voice(source) + except Exception as error: + errors[index] = error + + threads = [threading.Thread(target=shot, args=(i,)) for i in range(3)] + for thread in threads: + thread.start() + assert entered.wait(2) + time.sleep(0.05) + release.set() + for thread in threads: + thread.join(2) + assert errors == [None, None, None] + assert results == [source, source, source] + assert len(calls) == 1 + + +def test_analysis_reuses_isolation_and_keeps_cue_times(monkeypatch): + monkeypatch.setattr(vocals, "isolation_capability", lambda: {"available": True, "reason": "ready"}) + monkeypatch.setattr(speech, "rhubarb_executable", lambda: "/configured/rhubarb") + isolations, analyses = [], [] + + def run(args, **_kwargs): + if "-o" in args: + analyses.append(args) + Path(args[args.index("-o") + 1]).write_text( + json.dumps({"mouthCues": [{"start": 0, "end": 1, "value": "D"}]})) + return SimpleNamespace(returncode=0) + isolations.append(args) + Path(args[3]).write_bytes(Path(args[2]).read_bytes()) + return SimpleNamespace(returncode=0) + + monkeypatch.setattr(subprocess, "run", run) + source = wav(1) + first = speech.analyze_voice(source, isolate_vocals=True) + second = speech.analyze_voice(source, isolate_vocals=True) + third = speech.analyze_voice(source, isolate_vocals=True) + assert first == second == third + assert first["mouthCues"][0] == {"start": 0, "end": 1, "value": "D"} + assert first["duration"] == 1 + assert first["analysisSource"] == "isolated-vocals" + assert len(isolations) == 1 + assert len(analyses) == 1 + + +def test_different_audio_model_or_params_miss_cache(monkeypatch): + monkeypatch.setattr(vocals, "isolation_capability", lambda: {"available": True, "reason": "ready"}) + calls = [] + monkeypatch.setattr(vocals.subprocess, "run", _copy_worker(calls)) + vocals.isolate_voice(wav(1)) + vocals.isolate_voice(wav(2)) + monkeypatch.setattr(vocals, "MODEL_NAME", "other-roformer") + vocals.isolate_voice(wav(1)) + monkeypatch.setattr(vocals, "ISOLATION_PARAMS", {**vocals.ISOLATION_PARAMS, "overlap": 4}) + vocals.isolate_voice(wav(1)) + assert len(calls) == 4 + + +def test_distinct_windows_are_not_concatenated(monkeypatch): + monkeypatch.setattr(vocals, "isolation_capability", lambda: {"available": True, "reason": "ready"}) + calls = [] + monkeypatch.setattr(vocals.subprocess, "run", _copy_worker(calls)) + vocals.isolate_voice(wav(1)) + vocals.isolate_voice(wav(2)) + merged = isolation_material(wav(3), 3.0, vocals.isolation_key_material()) + key = material_key(merged) + root = Path(os.environ["SPEECH_ANALYSIS_CACHE_DIR"]) + assert not (root / key[:2] / f"{key}.wav").exists() + assert len(calls) == 2 + + +def test_failure_does_not_publish_a_partial_entry(monkeypatch, tmp_path): + monkeypatch.setattr(vocals, "isolation_capability", lambda: {"available": True, "reason": "ready"}) + + def run(args, **kwargs): + Path(args[3]).write_bytes(b"partial") + raise subprocess.TimeoutExpired(args, 900) + + monkeypatch.setattr(vocals.subprocess, "run", run) + with pytest.raises(SpeechAnalysisUnavailable, match="15 minutes"): + vocals.isolate_voice(wav()) + root = Path(os.environ["SPEECH_ANALYSIS_CACHE_DIR"]) + assert list(root.rglob("*.wav")) == [] + assert list(root.rglob("*.tmp")) == [] + assert remember({"k": "direct"}, lambda: b"ok", ".bin", root=tmp_path / "direct") == b"ok" + + +def test_failed_replace_does_not_leave_an_entry(tmp_path, monkeypatch): + def boom(*_args, **_kwargs): + raise OSError("disk") + monkeypatch.setattr(os, "replace", boom) + material = {"kind": "isolation", "n": 1} + assert remember(material, lambda: b"payload", ".bin", root=tmp_path) == b"payload" + key = material_key(material) + assert not (tmp_path / key[:2] / f"{key}.bin").exists() + assert list(tmp_path.rglob("*.tmp")) == [] + + +def test_abandoned_waiter_does_not_drop_shared_result(tmp_path): + started = threading.Event() + release = threading.Event() + material = {"kind": "shared", "n": 1} + + def compute(): + started.set() + assert release.wait(2) + return b"shared" + + first, second = [], [] + + def client(bucket): + bucket.append(remember(material, compute, ".bin", root=tmp_path)) + + dropped = threading.Thread(target=lambda: client(first), daemon=True) + kept = threading.Thread(target=lambda: client(second)) + dropped.start() + assert started.wait(2) + kept.start() + time.sleep(0.05) + release.set() + kept.join(2) + assert second == [b"shared"] + key = material_key(material) + assert (tmp_path / key[:2] / f"{key}.bin").read_bytes() == b"shared" + + +def test_eviction_skips_a_result_another_consumer_still_needs(tmp_path, monkeypatch): + monkeypatch.setenv("SPEECH_ANALYSIS_CACHE_MAX_ENTRIES", "1") + remember({"k": "old"}, lambda: b"old", ".bin", root=tmp_path) + hold, inside = threading.Event(), threading.Event() + + def compute(): + inside.set() + assert hold.wait(2) + return b"live" + + live = [] + thread = threading.Thread(target=lambda: live.append(remember({"k": "live"}, compute, ".bin", root=tmp_path))) + thread.start() + assert inside.wait(2) + remember({"k": "new"}, lambda: b"new", ".bin", root=tmp_path) + hold.set() + thread.join(2) + assert live == [b"live"] + live_key = material_key({"k": "live"}) + assert (tmp_path / live_key[:2] / f"{live_key}.bin").read_bytes() == b"live" + + +def test_missing_optional_model_gives_a_reason_without_download(monkeypatch, tmp_path): + monkeypatch.setattr(vocals, "MODEL_DIR", tmp_path) + monkeypatch.setattr(vocals.subprocess, "run", lambda *a, **k: pytest.fail("Must not start inference or download")) + capability = vocals.isolation_capability() + assert capability["available"] is False + assert capability["downloads"] is False + assert capability["reason"] == "optional_model_missing" + with pytest.raises(SpeechAnalysisUnavailable, match="already be installed"): + vocals.isolate_voice(wav()) + assert list(Path(os.environ["SPEECH_ANALYSIS_CACHE_DIR"]).rglob("*")) == [] + + +def test_ninety_second_cap_is_unchanged(): + with pytest.raises(SpeechAnalysisError, match="90 seconds"): + vocals.isolate_voice(wav(91)) + with pytest.raises(SpeechAnalysisError, match="90 seconds"): + speech.analyze_voice(wav(91)) + + +def test_analysis_key_includes_segment_and_isolation_model(): + model = vocals.isolation_key_material() + original = analysis_material(wav(1), 1.0, True, "/rhubarb", {"recognizer": "phonetic"}, model) + shifted = analysis_material(wav(2), 2.0, True, "/rhubarb", {"recognizer": "phonetic"}, model) + plain = analysis_material(wav(1), 1.0, False, "/rhubarb", {"recognizer": "phonetic"}) + other_model = analysis_material(wav(1), 1.0, True, "/rhubarb", {"recognizer": "phonetic"}, {**model, "model": "other"}) + assert original["segment"]["start"] == 0.0 + assert material_key(original) != material_key(shifted) + assert material_key(original) != material_key(plain) + assert material_key(original) != material_key(other_model) diff --git a/tests/test_speech_quality.py b/tests/test_speech_quality.py new file mode 100644 index 000000000..fb1f888a4 --- /dev/null +++ b/tests/test_speech_quality.py @@ -0,0 +1,79 @@ +import base64 +import json +from pathlib import Path + +import pytest + +from services import scene3d_speech as speech +from services.speech_analysis_request import speech_request +from services.character_kit_library import normalize_character_kit +from tests.test_scene3d_speech import wav + + +def test_script_envelope_preserves_unicode_and_rejects_unbounded_or_invalid_input(): + raw = wav() + data, options = speech_request(json.dumps({"wavBase64": base64.b64encode(raw).decode(), "dialogue": "¿Qué ocurrió?", "language": "es"}).encode(), "application/json") + assert data == raw and options == {"dialogue": "¿Qué ocurrió?", "language": "es"} + for body in ({"wavBase64": "!!!"}, {"wavBase64": "", "dialogue": "x" * 4001}, [], {"language": 42}): + with pytest.raises(speech.SpeechAnalysisError): + speech_request(json.dumps(body).encode(), "application/json") + + +def test_english_uses_audio_and_script_and_cache_separates_changed_scripts(tmp_path, monkeypatch): + monkeypatch.setenv("SPEECH_ANALYSIS_CACHE_DIR", str(tmp_path / "cache")) + monkeypatch.setattr(speech, "rhubarb_executable", lambda: "/configured/rhubarb") + calls = [] + + def run(command, **kwargs): + assert kwargs["shell"] is False and kwargs["timeout"] == 90 + transcript = Path(command[command.index("--dialogFile") + 1]).read_text() + calls.append((command[command.index("-r") + 1], transcript)) + Path(command[command.index("-o") + 1]).write_text(json.dumps({"mouthCues": [{"start": 0, "end": .5, "value": "A"}, {"start": .5, "end": 1, "value": "X"}]})) + return type("Completed", (), {"returncode": 0})() + + monkeypatch.setattr(speech.subprocess, "run", run) + for _ in range(2): + assert speech.analyze_voice(wav(), dialogue="Move now", language="en-US")["recognizer"] == "pocketSphinx" + speech.analyze_voice(wav(), dialogue="Different words", language="en") + assert speech.analyze_voice(wav(), dialogue="Vamos", language="es")["recognizer"] == "phonetic" + assert calls == [("pocketSphinx", "Move now"), ("pocketSphinx", "Different words"), ("phonetic", "Vamos")] + + +def test_library_roundtrips_extended_mouths_and_resting_still_without_approving_the_base(): + asset = {"id": "base", "name": "Base", "source": "/base.png", "reviewState": "pending", "alphaStatus": "transparent", "kind": "image"} + kit = {"id": "actor", "name": "Actor", "base": asset, "mouth": {state: {**asset, "id": state, "kind": "overlay"} + for state in ("closed", "pressed", "medium", "pucker", "bite", "tongue")}, + "restPose": {"asset": {**asset, "id": "rest", "source": "/rest.png"}, "fingerprint": "source-key"}} + result = normalize_character_kit(kit) + assert result["restPose"]["asset"]["source"] == "/rest.png" + assert result["base"]["reviewState"] == "pending" + assert set(result["mouth"]) == set(kit["mouth"]) + + +def test_offline_installer_respects_custom_binary_and_does_not_block_other_architectures(tmp_path, monkeypatch): + from services import install_speech_tools as installer + binary = tmp_path / "native-rhubarb" + binary.touch() + monkeypatch.setenv("RHUBARB_EXECUTABLE", str(binary)) + monkeypatch.setattr(installer.platform, "machine", lambda: "arm64") + assert installer.install() == binary + monkeypatch.delenv("RHUBARB_EXECUTABLE") + monkeypatch.setattr(installer.shutil, "which", lambda _: None) + monkeypatch.setattr(installer, "ROOT", tmp_path / "runtime") + assert installer.install() is None + assert not installer.ROOT.exists() + + +def test_offline_installer_rejects_modified_archive_before_unpacking(tmp_path, monkeypatch): + import io + from services import install_speech_tools as installer + monkeypatch.delenv("RHUBARB_EXECUTABLE", raising=False) + monkeypatch.setattr(installer.shutil, "which", lambda _: None) + monkeypatch.setattr(installer, "ROOT", tmp_path / "runtime") + monkeypatch.setattr(installer.platform, "system", lambda: "Linux") + monkeypatch.setattr(installer.platform, "machine", lambda: "x86_64") + monkeypatch.setattr(installer.urllib.request, "urlopen", lambda *args, **kwargs: io.BytesIO(b"changed archive")) + with pytest.raises(RuntimeError, match="checksum mismatch"): + installer.install() + assert not installer.bundled_executable().exists() + assert list(installer.ROOT.iterdir()) == [] diff --git a/tests/test_story_lab_audio_ui.py b/tests/test_story_lab_audio_ui.py index af9b0acc0..316647e13 100644 --- a/tests/test_story_lab_audio_ui.py +++ b/tests/test_story_lab_audio_ui.py @@ -74,9 +74,13 @@ def test_chained_music_and_director_workflows_expose_cancel_controls(): assert "cancelMusicQueue" in story assert "music.cancellingRequest" in music assert "cancelStoryMusicCandidatesJob(jobId)" in story - assert "api.cancelCanonicalTask(taskId, workspace)" in activity - assert "active && task.cancelable" in activity - assert "Cancelling…" in activity + activity_ui = activity + "\n".join( + path.read_text(encoding="utf-8") + for path in sorted((ROOT / "ui" / "src" / "features" / "activity").glob("*.ts*")) + ) + (ROOT / "ui" / "src" / "i18n" / "locales" / "en" / "activity.json").read_text(encoding="utf-8") + assert "api.cancelCanonicalTask(taskId, workspace)" in activity_ui + assert "active && task.cancelable" in activity_ui + assert "Cancelling…" in activity_ui def test_story_lab_frontend_wrappers_reach_terminal_state_before_dismissal(): diff --git a/tests/test_studio_video_commands.py b/tests/test_studio_video_commands.py new file mode 100644 index 000000000..d576a59cf --- /dev/null +++ b/tests/test_studio_video_commands.py @@ -0,0 +1,132 @@ +"""Studio video factory, resources and catalog without loading a model.""" + +from copy import deepcopy +from types import SimpleNamespace + +from fastapi import FastAPI, HTTPException +from fastapi.testclient import TestClient +from PIL import Image +import pytest + +from routers.image_generation_commands import create_image_generation_commands_router, image_command_handlers +from routers.studio_video_commands import video_command_catalog +from services.image_generation_runtime import create_image_generation_commands +from services.native_generation_operation import NativeGenerationOperation +from services.studio_video_resources import StudioVideoResources +from services.video_generation_spec import freeze_video_generation_spec, video_generation_schema +from tests.test_image_generation_commands import FakeNative, _run +from tests.test_video_generation_commands import T2V_DEFINITION, configured_service, video_command + + +def write_png(path): + path.parent.mkdir(parents=True, exist_ok=True) + Image.new("RGB", (64, 64), "red").save(path) + + +def test_catalog_describes_the_closed_wan_t2v_tool(): + catalog = video_command_catalog() + assert catalog["name"] == "generation.video" + assert catalog["version"] == 2 + assert catalog["videoModelTypes"] == ["t2v", "t2v_1.3B"] + assert catalog["inputSchema"]["properties"]["operation"]["const"] == "generation.video" + assert catalog["inputSchema"]["additionalProperties"] is False + assert video_generation_schema()["video_model_family"] == catalog["videoModelFamily"] + + +def test_runtime_factory_publishes_generation_video(tmp_path): + native = FakeNative(tmp_path) + runtime = { + "_durable_generation_queue": SimpleNamespace(upsert=lambda _job: None), + "_run_generation_with_preparation": lambda _job_id: None, + "_jobs": {}, + "register_generation_job": lambda *_args: None, + "_gen_lock": object(), + "_cancel_h3_idle_release": lambda: None, + "_active_gen_states": {}, + "_task_registry": native.registry, + "generate": native.prepare, + "_new_generation_job": native.make_job, + "_generation_task_fields": native.task_fields, + "execution_mode": SimpleNamespace(validate_generation=lambda _workspace: None), + "wgp": SimpleNamespace( + primary_settings={}, + get_model_def=lambda model: deepcopy(T2V_DEFINITION) if model == "t2v_1.3B" else None, + get_lora_search_dirs=lambda _model: [], + ), + "_check_model_downloaded": lambda _model: True, + "_workspace_dir": lambda workspace: str(tmp_path / workspace), + "_list_workspaces": lambda: [{"name": "video-test"}], + "_lora_is_compatible_with_model": lambda *_args: False, + } + service = create_image_generation_commands(runtime) + adapter = service.operations["generation.video"] + assert isinstance(adapter, NativeGenerationOperation) + assert adapter.catalog["name"] == "generation.video" + assert service.operations["generation.sfx"].catalog["name"] != "generation.video" if "generation.sfx" in service.operations else True + + app = FastAPI() + app.include_router(create_image_generation_commands_router(service)) + with TestClient(app) as client: + names = {item["name"] for item in client.get("/api/v1/generation/commands").json()["operations"]} + assert "generation.video" in names + assert "generation.image" in names + + +def test_real_resources_reject_a_foreign_or_missing_workspace_before_admission(tmp_path): + native = FakeNative(tmp_path) + for name in ("video-test", "source"): + (tmp_path / name).mkdir() + write_png(tmp_path / "source" / "frame.png") + resources = StudioVideoResources( + workspace_dir=lambda name: str(tmp_path / name), + uploads_dir=lambda: str(tmp_path / "uploads"), + list_workspaces=lambda: [{"name": "source"}, {"name": "video-test"}], + lora_search_dirs=lambda _model: [], + lora_compatible=lambda *_args: False, + ) + service, _ = configured_service(native, tmp_path, resources=resources) + lying = video_command("lying-frame", image_start="/api/v1/file/frame.png?workspace=video-test") + with pytest.raises(HTTPException) as missing_in_declared: + _run(service.submit(lying)) + assert missing_in_declared.value.status_code == 422 + assert native.registry("video-test").command_admission("lying-frame") is None + + missing = video_command("missing-source", image_start="/api/v1/file/absent.png?workspace=source") + with pytest.raises(HTTPException) as missing_error: + _run(service.submit(missing)) + assert missing_error.value.status_code == 422 + assert native.dispatch_calls == [] + + +def test_unsupported_start_frame_is_resolved_then_rejected_without_a_task(tmp_path): + native = FakeNative(tmp_path) + (tmp_path / "source").mkdir() + write_png(tmp_path / "source" / "frame.png") + resources = StudioVideoResources( + workspace_dir=lambda name: str(tmp_path / name), + uploads_dir=lambda: str(tmp_path / "uploads"), + list_workspaces=lambda: [{"name": "source"}, {"name": "video-test"}], + lora_search_dirs=lambda _model: [], + lora_compatible=lambda *_args: False, + ) + service, _ = configured_service(native, tmp_path, resources=resources) + request = video_command("start-frame", image_start="/api/v1/file/frame.png?workspace=source") + with pytest.raises(HTTPException) as rejected: + _run(service.submit(request)) + assert rejected.value.status_code == 422 + assert "does not accept image or video references" in str(rejected.value.detail) + assert native.registry("video-test").command_admission("start-frame") is None + + +def test_mcp_handler_omits_transport_operation_and_legacy_generate_remains(tmp_path): + native = FakeNative(tmp_path) + service, _ = configured_service(native, tmp_path) + handler = image_command_handlers(service)["generation.video"] + command = video_command() + arguments = {key: value for key, value in command.items() if key != "operation"} + result = _run(handler(arguments)) + assert result["receipt"]["operation"] == "generation.video" + with pytest.raises(HTTPException): + _run(handler({**arguments, "operation": "generation.video"})) + frozen = freeze_video_generation_spec(command) + assert frozen["original"]["operation"] == "generation.video" diff --git a/tests/test_user_diagnostics.py b/tests/test_user_diagnostics.py new file mode 100644 index 000000000..a01ff6581 --- /dev/null +++ b/tests/test_user_diagnostics.py @@ -0,0 +1,351 @@ +"""User diagnostics: facts, redaction, and no heavy-engine imports.""" +from __future__ import annotations + +import ast +import json +import os +import subprocess +import sys +from pathlib import Path + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from routers.user_diagnostics import create_user_diagnostics_router +from services.user_diagnostics import ( + collect_report, + correlate_error, + parse_gpu, + receipt_status, + snapshot, +) + +ROOT = Path(__file__).resolve().parents[1] +SERVICE = ROOT / "app" / "services" / "user_diagnostics.py" +ROUTER = ROOT / "app" / "routers" / "user_diagnostics.py" +BANNED_MODULES = { + "torch", "wgp", "pynvml", "flash_attn", "sageattention", "triton", +} +BANNED_FROM = { + "services.hardware_detect", "services.live_stats", "services.perf_recommend", +} +SECRETS = ( + "sk-test-h18-secret-9f3a2c1b", + "h18-cookie-value-do-not-export", + "h18-token-xyz-private", + "PRIVATE_PROMPT_do_not_include_in_pack", + "secret-lyrics-never-export", +) +FACT_KEYS = ( + "component", "driver", "backend", "ram_gb_observed", "vram_gb_observed", + "version", "repair_path", "available", "reasons", +) + +MISSING_ENGINES = { + name: {"present": False, "installed": False, "fingerprint_match": False} + for name in ("wangp", "hunyuan3d", "minimax_h3", "sam", "rigging") +} +READY_ENGINES = { + name: {"present": True, "installed": True, "fingerprint_match": True} + for name in ("wangp", "hunyuan3d", "minimax_h3", "sam", "rigging") +} + + +def _cpu_observe(**extra): + observe = { + "platform": "linux", + "architecture": "x64", + "gpu_csv": None, + "ram_gb": 32.0, + "cpu_count": 8, + "app_version": "0.9.0", + "git_revision": "deadbeef", + "ui_build_id": "missing", + "receipts": dict(MISSING_ENGINES), + } + observe.update(extra) + return observe + + +def _nvidia_observe(**extra): + observe = _cpu_observe( + gpu_csv="NVIDIA GeForce RTX 4090, 580.82.09, 24564", + ram_gb=64.0, + cpu_count=16, + receipts=dict(READY_ENGINES), + ) + observe.update(extra) + return observe + + +def _imported_names(path: Path) -> set[str]: + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + names: set[str] = set() + for node in ast.walk(tree): + if isinstance(node, ast.Import): + names.update(alias.name.split(".")[0] for alias in node.names) + names.update(alias.name for alias in node.names) + elif isinstance(node, ast.ImportFrom) and node.module: + names.add(node.module) + names.add(node.module.split(".")[0]) + return names + + +def test_source_does_not_import_heavy_engines(): + names = _imported_names(SERVICE) | _imported_names(ROUTER) + assert names.isdisjoint(BANNED_MODULES) + assert names.isdisjoint(BANNED_FROM) + + +def test_collect_report_subprocess_does_not_load_heavy_engines(): + script = ( + "import sys\n" + "banned = ('torch', 'wgp', 'pynvml', 'flash_attn', 'sageattention')\n" + "for name in banned:\n" + " sys.modules.pop(name, None)\n" + "from services.user_diagnostics import collect_report\n" + "collect_report(observe={" + "'gpu_csv': None, 'ram_gb': 32.0, 'cpu_count': 8, 'app_version': '0.9.0'," + "'git_revision': 'deadbeef', 'ui_build_id': 'missing'," + "'platform': 'linux', 'architecture': 'x64'," + "'receipts': {name: {'present': False, 'installed': False, 'fingerprint_match': False}" + " for name in ('wangp', 'hunyuan3d', 'minimax_h3', 'sam', 'rigging')}})\n" + "loaded = [name for name in banned if name in sys.modules]\n" + "raise SystemExit(0 if not loaded else 'loaded ' + ','.join(loaded))\n" + ) + result = subprocess.run( + [sys.executable, "-c", script], + cwd=str(ROOT), + env={**os.environ, "PYTHONPATH": str(ROOT / "app")}, + capture_output=True, text=True, timeout=30, + ) + assert result.returncode == 0, result.stdout + result.stderr + + +def test_cpu_host_explains_unavailable_generation_with_repair_path(): + pack = collect_report(observe=_cpu_observe()) + image = next(item for item in pack["availability"] if item["id"] == "generation.image") + assert image["available"] is False + assert image["component"] == "wangp" + assert image["backend"] == "none" + assert image["ram_gb_observed"] == 32.0 + assert image["vram_gb_observed"] is None + assert image["version"]["app"] == "0.9.0" + assert image["repair_path"]["id"] == "cpu_amd_recipe" + receipt = next(item for item in pack["availability"] if item["id"] == "generation.receipt") + assert receipt["available"] is True + assert receipt["repair_path"] is None + + +def test_nvidia_host_marks_published_ops_available_and_unpublished_model3d_not(): + pack = collect_report(observe=_nvidia_observe()) + by_id = {item["id"]: item for item in pack["availability"]} + assert by_id["generation.image"]["available"] is True + assert by_id["generation.image"]["driver"] == "580.82.09" + assert by_id["generation.image"]["vram_gb_observed"] == 24.0 + assert by_id["flux2_klein_4b"]["available"] is True + assert by_id["generation.model3d"]["available"] is False + assert by_id["generation.model3d"]["repair_path"]["id"] == "unpublished" + for item in pack["availability"]: + for key in FACT_KEYS: + assert key in item + + +def test_low_vram_blocks_large_model_with_existing_repair(): + pack = collect_report(observe=_nvidia_observe( + gpu_csv="NVIDIA GeForce RTX 3060, 570.124.06, 8192", + )) + by_id = {item["id"]: item for item in pack["availability"]} + assert by_id["flux2_klein_4b"]["available"] is True + assert by_id["ltx2_22B"]["available"] is False + assert by_id["ltx2_22B"]["repair_path"]["id"] == "lower_vram" + assert by_id["engine.minimax_h3"]["available"] is False + assert by_id["engine.minimax_h3"]["repair_path"]["id"] == "nvidia_driver" + + +def test_missing_receipt_uses_install_update_repair(): + pack = collect_report(observe=_nvidia_observe(receipts=dict(MISSING_ENGINES))) + image = next(item for item in pack["availability"] if item["id"] == "generation.image") + assert image["available"] is False + assert image["repair_path"]["id"] == "install_update" + + +def test_synthetic_secrets_and_prompts_never_appear_in_the_pack(): + task = { + "id": "task-h18", + "status": "failed", + "workflow": "generation.image", + "message": "CUDA out of memory api_key=sk-test-h18-secret-9f3a2c1b", + "workspace": "ws", + "backend_job_id": "job-1", + "prompt": "PRIVATE_PROMPT_do_not_include_in_pack", + "lyrics": "secret-lyrics-never-export", + "api_key": "sk-test-h18-secret-9f3a2c1b", + "cookie": "session=h18-cookie-value-do-not-export", + "authorization": "Bearer h18-token-xyz-private", + "metadata": { + "prompt": "PRIVATE_PROMPT_do_not_include_in_pack", + "oom_info": { + "is_oom": True, + "current_coefficient": 0.8, + "suggested_coefficient": 0.7, + }, + }, + } + receipt = { + "commandId": "intent-h18", + "operation": "generation.image", + "status": "queued", + "input": {"prompt": "PRIVATE_PROMPT_do_not_include_in_pack"}, + "result": {"task_id": "task-h18", "job_id": "job-1", "workspace": "ws"}, + } + pack = collect_report( + observe=_cpu_observe(), + task=task, + receipt=receipt, + error={"code": "oom", "message": "failed Cookie: session=h18-cookie-value-do-not-export"}, + ) + blob = json.dumps(pack, ensure_ascii=False) + for secret in SECRETS: + assert secret not in blob + assert pack["error"]["task_id"] == "task-h18" + assert pack["error"]["intent_id"] == "intent-h18" + assert pack["error"]["operation"] == "generation.image" + assert pack["error"]["job_id"] == "job-1" + assert pack["error"]["code"] == "oom" + assert pack["error"]["oom"]["is_oom"] is True + assert "prompt" not in json.dumps(pack["error"]) + assert pack["schema"] == "hocuspocus.user-diagnostics-report" + assert pack["build"]["app_version"] == "0.9.0" + assert pack["platform"]["os"] == "linux" + assert "engines" in pack["capabilities"] + + +def test_correlate_error_without_payload_is_none(): + assert correlate_error() is None + + +def test_parse_gpu_without_nvidia_smi_is_none_backend(): + parsed = parse_gpu(None) + assert parsed["backend"] == "none" + assert parsed["vram_gb"] is None + parsed = parse_gpu("NVIDIA RTX 4090, 580.82.09, 24564") + assert parsed["kind"] == "nvidia" + assert parsed["vram_gb"] == 24.0 + assert parsed["driver"] == "580.82.09" + + +def test_receipt_status_rejects_non_object_receipts(tmp_path, monkeypatch): + from services import runtime_profiles as profiles + app = tmp_path / "app" + env = app / "env" + env.mkdir(parents=True) + receipt = env / ".hocus-runtime-profile.json" + monkeypatch.setattr(profiles, "APP_DIR", app) + monkeypatch.setattr(profiles, "dependency_fingerprint", lambda engine, platform: "matching") + receipt.write_text(json.dumps(["corrupt"])) + assert receipt_status("wangp", "linux")["installed"] is False + receipt.write_text(json.dumps({ + "fingerprint": "matching", + "profile": "linux-x64-nvidia-wangp", + "cudaCalculation": True, + })) + assert receipt_status("wangp", "linux")["installed"] is True + + +def test_shared_environment_does_not_check_an_unsupported_platform_recipe(tmp_path, monkeypatch): + from services import runtime_profiles as profiles + app = tmp_path / "app" + env = app / "env" + env.mkdir(parents=True) + (env / ".hocus-runtime-profile.json").write_text(json.dumps({ + "fingerprint": "matching", "profile": "linux-x64-nvidia-wangp", "cudaCalculation": True, + })) + monkeypatch.setattr(profiles, "APP_DIR", app) + + def unsupported_fingerprint(*args): + raise AssertionError("There is no core recipe for Linux or Windows") + + monkeypatch.setattr(profiles, "dependency_fingerprint", unsupported_fingerprint) + for platform in ("linux", "win32"): + assert receipt_status("core", platform) == { + "present": False, "installed": False, "fingerprint_match": False, + } + + +def test_incomplete_recipe_is_reported_without_crashing_diagnostics(tmp_path, monkeypatch): + from services import runtime_profiles as profiles + app = tmp_path / "app" + env = app / "env" + env.mkdir(parents=True) + (env / ".hocus-runtime-profile.json").write_text(json.dumps({"fingerprint": "old"})) + monkeypatch.setattr(profiles, "APP_DIR", app) + # The installed receipt exists, but this checkout is missing recipe files. + assert receipt_status("wangp", "linux") == { + "present": True, "installed": False, "fingerprint_match": False, + } + + +def test_core_receipt_does_not_require_a_cuda_calculation(tmp_path, monkeypatch): + from services import runtime_profiles as profiles + app = tmp_path / "app" + env = app / "env" + env.mkdir(parents=True) + receipt = env / ".hocus-runtime-profile.json" + monkeypatch.setattr(profiles, "APP_DIR", app) + monkeypatch.setattr(profiles, "dependency_fingerprint", lambda engine, platform: "matching") + for cuda, expected in ((False, True), (True, False), (None, False)): + receipt.write_text(json.dumps({ + "fingerprint": "matching", "profile": "darwin-arm64-core-core", "cudaCalculation": cuda, + })) + assert receipt_status("core", "darwin")["installed"] is expected + + +def test_router_get_and_report_redact_loaded_task(): + def load_task(_task_id: str): + return { + "id": "task-h18", + "status": "failed", + "workflow": "generation.image", + "prompt": "PRIVATE_PROMPT_do_not_include_in_pack", + "api_key": "sk-test-h18-secret-9f3a2c1b", + "message": "model files missing", + } + + def load_receipt(_workspace: str, _intent_id: str): + return { + "commandId": "intent-h18", + "operation": "generation.image", + "result": {"task_id": "task-h18", "job_id": "job-9", "workspace": "ws"}, + } + + def collect(**kwargs): + return collect_report(observe=_cpu_observe(), **kwargs) + + app = FastAPI() + app.include_router(create_user_diagnostics_router( + collect=collect, load_task=load_task, load_receipt=load_receipt, + )) + client = TestClient(app) + snapshot_body = client.get("/api/v1/diagnostics").json() + assert snapshot_body["schema"] == "hocuspocus.user-diagnostics-report" + assert snapshot_body["error"] is None + report = client.post("/api/v1/diagnostics/report", json={ + "task_id": "task-h18", + "intent_id": "intent-h18", + "workspace": "ws", + }).json() + blob = json.dumps(report) + for secret in SECRETS: + assert secret not in blob + assert report["error"]["task_id"] == "task-h18" + assert report["error"]["intent_id"] == "intent-h18" + assert report["error"]["job_id"] == "job-9" + + +def test_snapshot_pack_shape_has_build_platform_and_capabilities(): + pack = snapshot(observe=_cpu_observe()) + assert set(pack["build"]) == {"app_version", "git_revision", "ui_build_id"} + assert pack["platform"]["backend"] == "none" + assert isinstance(pack["capabilities"]["engines"], list) + assert {engine["id"] for engine in pack["capabilities"]["engines"]} >= {"wangp", "hunyuan3d"} diff --git a/tests/test_video_editor_concat_regression.py b/tests/test_video_editor_concat_regression.py new file mode 100644 index 000000000..0cf618e06 --- /dev/null +++ b/tests/test_video_editor_concat_regression.py @@ -0,0 +1,200 @@ +"""Regression for the historical 193+197+200 → 589-frame Video Editor export. + +Evidence E10 (job video-edit-3fb2aa82bd8e on 10aa6f0d) received 590 frames and +wrote 589. On current development the drop is still in ``_normalise_clip``: +``probe_media`` rounds 193/30s to 6.4333, ``-t 6.433300`` plus ``fps=30`` +emits 192 frames, concat then yields 589. This module reproduces that case +with synthetic FFmpeg media and requires the export path to keep 590 decoded +frames. +""" + +from __future__ import annotations + +import json +import re +import shutil +import subprocess +from fractions import Fraction +from pathlib import Path + +import pytest + +from app.services import video_editor + + +pytestmark = pytest.mark.skipif( + shutil.which("ffmpeg") is None or shutil.which("ffprobe") is None, + reason="ffmpeg/ffprobe required", +) + + +def _run(command: list[str], *, timeout: int = 60) -> subprocess.CompletedProcess[str]: + result = subprocess.run( + command, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + timeout=timeout, + check=False, + ) + if result.returncode != 0: + raise AssertionError((result.stderr or result.stdout or "")[-1200:]) + return result + + +def write_color_clip( + path: Path, + frames: int, + *, + fps: int = 30, + color: str = "red", + audio: bool = True, + width: int = 320, + height: int = 240, +) -> None: + command = [ + "ffmpeg", "-y", "-hide_banner", "-loglevel", "error", + "-f", "lavfi", "-i", f"color=c={color}:s={width}x{height}:r={fps}", + ] + if audio: + duration = frames / fps + command += [ + "-f", "lavfi", "-i", + f"sine=frequency=440:sample_rate=48000:duration={duration:.10f}", + ] + command += [ + "-frames:v", str(frames), + "-c:v", "libx264", "-preset", "ultrafast", "-pix_fmt", "yuv420p", + ] + if audio: + command += ["-c:a", "aac", "-ar", "48000", "-ac", "2"] + command.append(str(path)) + _run(command) + + +def decoded_video_frames(path: Path) -> int: + result = subprocess.run( + [ + "ffmpeg", "-hide_banner", "-i", str(path), + "-map", "0:v:0", "-c:v", "rawvideo", "-f", "null", "-", + ], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + timeout=60, + check=False, + ) + matches = re.findall(r"frame=\s*(\d+)", result.stderr) + assert matches, result.stderr[-400:] + return int(matches[-1]) + + +def ffprobe_video(path: Path) -> dict: + result = _run([ + "ffprobe", "-v", "error", "-count_frames", + "-select_streams", "v:0", + "-show_entries", "format=duration:stream=nb_frames,nb_read_frames,duration,r_frame_rate", + "-of", "json", str(path), + ]) + return json.loads(result.stdout) + + +def test_four_decimal_duration_rounding_is_the_historical_589_trap(): + """Document the rounding that turned 193 frames into 192 before concat.""" + assert round(193 / 30, 4) == 6.4333 + assert round(6.4333 * 30) == 193 + # ``-t`` uses the truncated seconds, not round(duration * fps). + assert 6.4333 * 30 == pytest.approx(192.999, abs=0.001) + start, end, output = video_editor.plan_clip_frames( + source_frames=193, + source_fps=Fraction(30, 1), + output_fps=30, + trim_start=0, + trim_end=round(193 / 30, 4), + ) + assert (start, end, output) == (0, 193, 193) + + +def test_concat_193_197_200_keeps_590_decoded_frames(tmp_path: Path): + counts = (193, 197, 200) + colors = ("red", "green", "blue") + clips = [] + for frames, color in zip(counts, colors): + path = tmp_path / f"clip_{frames}.mp4" + write_color_clip(path, frames, color=color, audio=True) + assert decoded_video_frames(path) == frames + clips.append({"resolved_path": str(path), "transition": "none"}) + + output = tmp_path / "assembled.mp4" + result = video_editor.render_project( + clips, + str(output), + width=320, + height=240, + fps=30, + ) + + assert result["frames"] == 590 + assert result["duration"] == pytest.approx(590 / 30, abs=0.001) + assert decoded_video_frames(output) == 590 + probed = ffprobe_video(output) + stream = probed["streams"][0] + assert int(stream["nb_read_frames"]) == 590 + assert int(stream.get("nb_frames") or stream["nb_read_frames"]) == 590 + audio = video_editor.probe_audio_timing(str(output)) + assert audio is not None + assert audio["duration"] + (1 / 30) >= 590 / 30 + + +def test_failed_export_does_not_overwrite_previous_output(tmp_path: Path): + output = tmp_path / "keep.mp4" + previous = b"PREVIOUS-ARTIFACT" + output.write_bytes(previous) + + with pytest.raises(video_editor.VideoEditorError) as caught: + video_editor.render_project( + [{"resolved_path": str(tmp_path / "missing.mp4"), "transition": "none"}], + str(output), + width=320, + height=240, + fps=30, + ) + + assert caught.value.phase in {"probe", "normalise"} + assert "phase" in str(caught.value).lower() or caught.value.phase in str(caught.value) + assert output.read_bytes() == previous + + +def test_cancel_leaves_previous_output_and_skips_finalize(tmp_path: Path): + first = tmp_path / "one.mp4" + second = tmp_path / "two.mp4" + write_color_clip(first, 8, color="red") + write_color_clip(second, 8, color="blue") + output = tmp_path / "keep.mp4" + previous = b"PREVIOUS-ARTIFACT" + output.write_bytes(previous) + seen: list[str] = [] + + def abort() -> bool: + return len(seen) >= 1 + + def progress(_percent: int, message: str) -> None: + seen.append(message) + + with pytest.raises(video_editor.VideoEditorCancelled) as caught: + video_editor.render_project( + [ + {"resolved_path": str(first), "transition": "none"}, + {"resolved_path": str(second), "transition": "none"}, + ], + str(output), + width=320, + height=240, + fps=30, + progress=progress, + abort_callback=abort, + ) + + assert caught.value.phase in {"normalise", "concat", "validate"} + assert output.read_bytes() == previous + assert seen diff --git a/tests/test_video_editor_frame_accounting.py b/tests/test_video_editor_frame_accounting.py new file mode 100644 index 000000000..e2689046a --- /dev/null +++ b/tests/test_video_editor_frame_accounting.py @@ -0,0 +1,363 @@ +"""Exact frame/PTS accounting for Video Editor concat, trims, and audio.""" + +from __future__ import annotations + +import json +import re +import shutil +import subprocess +from fractions import Fraction +from pathlib import Path + +import pytest + +from app.services import video_editor + + +pytestmark = pytest.mark.skipif( + shutil.which("ffmpeg") is None or shutil.which("ffprobe") is None, + reason="ffmpeg/ffprobe required", +) + + +def _run(command: list[str], *, timeout: int = 60) -> subprocess.CompletedProcess[str]: + result = subprocess.run( + command, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + timeout=timeout, + check=False, + ) + if result.returncode != 0: + raise AssertionError((result.stderr or result.stdout or "")[-1200:]) + return result + + +def write_color_clip( + path: Path, + frames: int, + *, + fps: int = 30, + color: str = "red", + audio: bool = True, + width: int = 320, + height: int = 240, +) -> None: + command = [ + "ffmpeg", "-y", "-hide_banner", "-loglevel", "error", + "-f", "lavfi", "-i", f"color=c={color}:s={width}x{height}:r={fps}", + ] + if audio: + duration = frames / fps + command += [ + "-f", "lavfi", "-i", + f"sine=frequency=660:sample_rate=48000:duration={duration:.10f}", + ] + command += [ + "-frames:v", str(frames), + "-c:v", "libx264", "-preset", "ultrafast", "-pix_fmt", "yuv420p", + ] + if audio: + command += ["-c:a", "aac", "-ar", "48000", "-ac", "2"] + command.append(str(path)) + _run(command) + + +def write_sine(path: Path, seconds: float) -> None: + _run([ + "ffmpeg", "-y", "-hide_banner", "-loglevel", "error", + "-f", "lavfi", "-i", f"sine=frequency=220:sample_rate=48000:duration={seconds:.10f}", + "-c:a", "aac", "-ar", "48000", "-ac", "2", + str(path), + ]) + + +def decoded_video_frames(path: Path) -> int: + result = subprocess.run( + [ + "ffmpeg", "-hide_banner", "-i", str(path), + "-map", "0:v:0", "-c:v", "rawvideo", "-f", "null", "-", + ], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + timeout=60, + check=False, + ) + matches = re.findall(r"frame=\s*(\d+)", result.stderr) + assert matches, result.stderr[-400:] + return int(matches[-1]) + + +def decoded_audio_samples(path: Path, sample_rate: int = 48000) -> int: + result = subprocess.run( + [ + "ffmpeg", "-hide_banner", "-i", str(path), + "-vn", "-ac", "1", "-ar", str(sample_rate), "-f", "s16le", "-", + ], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + timeout=60, + check=False, + ) + assert result.returncode == 0, (result.stderr or b"")[-800:] + return len(result.stdout) // 2 + + +@pytest.mark.parametrize("fps,frames", [(24, 23), (30, 193), (60, 59)]) +def test_plan_clip_frames_keeps_a_full_span(fps: int, frames: int): + start, end, output = video_editor.plan_clip_frames( + source_frames=frames, + source_fps=Fraction(fps, 1), + output_fps=fps, + ) + assert (start, end, output) == (0, frames, frames) + + +def test_plan_clip_frames_snaps_non_integer_trims_to_nearest_source_frame(): + start, end, output = video_editor.plan_clip_frames( + source_frames=30, + source_fps=Fraction(30, 1), + output_fps=30, + trim_start=0.04, + trim_end=0.54, + ) + assert (start, end, output) == (1, 16, 15) + + +def test_probe_media_keeps_the_ui_duration_contract(tmp_path: Path): + path = tmp_path / "ui-probe.mp4" + write_color_clip(path, 193, fps=30, color="red") + media = video_editor.probe_media(str(path)) + assert set(media) >= { + "duration", "width", "height", "fps", "has_audio", "pixel_format", "has_alpha", + } + assert media["duration"] == round(193 / 30, 4) + assert media["fps"] == 30.0 + assembly = video_editor.probe_assembly_source(str(path)) + assert assembly["nb_frames"] == 193 + assert assembly["fps"] == Fraction(30, 1) + + +@pytest.mark.parametrize("fps", [24, 30, 60]) +def test_render_matches_decoded_frames_at_supported_rates(tmp_path: Path, fps: int): + counts = (12, 13) + clips = [] + for index, frames in enumerate(counts): + path = tmp_path / f"{fps}_{frames}.mp4" + write_color_clip(path, frames, fps=fps, color=("red", "green")[index], audio=True) + clips.append({"resolved_path": str(path), "transition": "none"}) + output = tmp_path / f"out_{fps}.mp4" + result = video_editor.render_project( + clips, str(output), width=320, height=240, fps=fps, + ) + expected = sum(counts) + assert result["frames"] == expected + assert decoded_video_frames(output) == expected + assert video_editor.count_decoded_video_frames(str(output)) == expected + + +def test_non_integer_trim_uses_nearest_frames_not_container_duration(tmp_path: Path): + source = tmp_path / "trim-src.mp4" + write_color_clip(source, 30, fps=30, color="blue") + output = tmp_path / "trimmed.mp4" + result = video_editor.render_project( + [{ + "resolved_path": str(source), + "trim_start": 0.04, + "trim_end": 0.54, + "transition": "none", + }], + str(output), + width=320, + height=240, + fps=30, + ) + assert result["frames"] == 15 + assert decoded_video_frames(output) == 15 + + +def test_short_duration_clip_keeps_whole_output_frames(tmp_path: Path): + source = tmp_path / "short-src.mp4" + write_color_clip(source, 10, fps=30, color="white") + output = tmp_path / "short.mp4" + result = video_editor.render_project( + [{ + "resolved_path": str(source), + "trim_start": 0, + "trim_end": 0.05, + "transition": "none", + }], + str(output), + width=320, + height=240, + fps=30, + ) + assert result["frames"] == 2 + assert decoded_video_frames(output) == 2 + + +def test_video_without_audio_gets_silence_and_exact_frames(tmp_path: Path): + first = tmp_path / "silent-a.mp4" + second = tmp_path / "silent-b.mp4" + write_color_clip(first, 10, color="red", audio=False) + write_color_clip(second, 11, color="green", audio=False) + output = tmp_path / "silent-out.mp4" + result = video_editor.render_project( + [ + {"resolved_path": str(first), "transition": "none"}, + {"resolved_path": str(second), "transition": "none"}, + ], + str(output), + width=320, + height=240, + fps=30, + ) + assert result["frames"] == 21 + assert decoded_video_frames(output) == 21 + audio = video_editor.probe_audio_timing(str(output)) + assert audio is not None + assert audio["duration"] + (1 / 30) >= 21 / 30 + + +def test_continuous_audio_sample_count_is_not_container_duration(tmp_path: Path): + first = tmp_path / "talk-a.mp4" + second = tmp_path / "talk-b.mp4" + write_color_clip(first, 16, color="red", audio=True) + write_color_clip(second, 14, color="blue", audio=True) + output = tmp_path / "talk-out.mp4" + result = video_editor.render_project( + [ + {"resolved_path": str(first), "transition": "none"}, + {"resolved_path": str(second), "transition": "none"}, + ], + str(output), + width=320, + height=240, + fps=30, + ) + assert result["frames"] == 30 + samples = decoded_audio_samples(output) + video_samples = round((30 / 30) * 48000) + assert samples >= video_samples - 1024 + audio = video_editor.probe_audio_timing(str(output)) + assert audio is not None + assert audio["duration"] + 0.05 >= 30 / 30 + container = json.loads(_run([ + "ffprobe", "-v", "error", "-show_entries", "format=duration", + "-of", "json", str(output), + ]).stdout) + # Acceptance: compare the audio stream, not container duration alone. + assert audio["duration"] > 0 + assert float(container["format"]["duration"]) > 0 + + +def test_crossfade_subtracts_whole_overlap_frames(tmp_path: Path): + first = tmp_path / "xfade-a.mp4" + second = tmp_path / "xfade-b.mp4" + write_color_clip(first, 15, color="red") + write_color_clip(second, 15, color="blue") + output = tmp_path / "xfade-out.mp4" + result = video_editor.render_project( + [ + { + "resolved_path": str(first), + "transition": "crossfade", + "transition_duration": 0.2, + }, + {"resolved_path": str(second), "transition": "none"}, + ], + str(output), + width=320, + height=240, + fps=30, + ) + assert result["frames"] == 24 + assert decoded_video_frames(output) == 24 + assert result["transitions"][0]["duration"] == pytest.approx(0.2) + + +def test_hard_cut_then_crossfade_keeps_expected_frames(tmp_path: Path): + """A concat cut must not leave timebase 1/1000000 for the following xfade.""" + clips = [] + for frames, color in ((15, "red"), (15, "green"), (15, "blue")): + path = tmp_path / f"cut-xfade-{color}.mp4" + write_color_clip(path, frames, color=color) + clips.append({ + "resolved_path": str(path), + "transition": "none" if color != "green" else "crossfade", + "transition_duration": 0.2, + }) + output = tmp_path / "cut-then-xfade.mp4" + result = video_editor.render_project( + clips, str(output), width=320, height=240, fps=30, + ) + assert result["frames"] == 39 + assert decoded_video_frames(output) == 39 + + +def test_time_card_then_crossfade_keeps_expected_frames(tmp_path: Path): + clips = [] + for frames, color, transition in ( + (15, "red", "later-clock"), + (15, "green", "crossfade"), + (15, "blue", "none"), + ): + path = tmp_path / f"card-xfade-{color}.mp4" + write_color_clip(path, frames, color=color) + clips.append({ + "resolved_path": str(path), + "transition": transition, + "transition_duration": 1.0 if transition == "later-clock" else 0.2, + "transition_text": "Luego", + }) + output = tmp_path / "card-then-xfade.mp4" + result = video_editor.render_project( + clips, str(output), width=320, height=240, fps=30, + ) + assert result["frames"] == 69 + assert decoded_video_frames(output) == 69 + + +def test_soundtrack_keeps_video_frames_and_compares_decoded_audio(tmp_path: Path): + clip = tmp_path / "picture.mp4" + score = tmp_path / "score.m4a" + write_color_clip(clip, 12, color="green", audio=True) + write_sine(score, 2.0) + output = tmp_path / "scored.mp4" + result = video_editor.render_project( + [{"resolved_path": str(clip), "transition": "none"}], + str(output), + width=320, + height=240, + fps=30, + soundtrack={ + "resolved_path": str(score), + "trim_start": 0, + "trim_end": 2, + "volume": 0.5, + "loop": False, + }, + ) + assert result["frames"] == 12 + assert decoded_video_frames(output) == 12 + samples = decoded_audio_samples(output) + assert samples >= round((12 / 30) * 48000) - 1024 + + +def test_export_error_includes_phase_and_output_facts(tmp_path: Path): + output = tmp_path / "failed.mp4" + with pytest.raises(video_editor.VideoEditorError) as caught: + video_editor.render_project( + [{"resolved_path": str(tmp_path / "nope.mp4"), "transition": "none"}], + str(output), + width=320, + height=240, + fps=30, + ) + error = caught.value + assert error.phase + assert error.output + assert error.phase in str(error) + assert not output.exists() or output.stat().st_size == 0 diff --git a/tests/test_video_editor_lipsync_timing.py b/tests/test_video_editor_lipsync_timing.py new file mode 100644 index 000000000..ba440dac5 --- /dev/null +++ b/tests/test_video_editor_lipsync_timing.py @@ -0,0 +1,103 @@ +"""Decoded flash/beep timing must survive AAC cuts and nested episode assembly.""" +import array +import json +import math +import shutil +import subprocess +import wave +from pathlib import Path + +import pytest + +from app.services import video_editor + +pytestmark = pytest.mark.skipif( + not shutil.which('ffmpeg') or not shutil.which('ffprobe'), reason='FFmpeg required', +) + + +def run(*args): + result = subprocess.run(args, capture_output=True, timeout=120) + assert result.returncode == 0, result.stderr.decode(errors='replace')[-1500:] + return result.stdout + + +def flash_beep(path: Path, frames: int, fps: int): + rate = 48000 + samples = array.array('h', ( + int(20000 * math.sin(2 * math.pi * 660 * i / rate)) + if 5 * rate // fps <= i < 8 * rate // fps else 0 + for i in range(frames * rate // fps) + )) + audio = path.with_suffix('.wav') + with wave.open(str(audio), 'wb') as output: + output.setparams((1, 2, rate, len(samples), 'NONE', 'not compressed')) + output.writeframes(samples.tobytes()) + run('ffmpeg', '-v', 'error', '-y', '-f', 'lavfi', '-i', + f"color=black:s=320x240:r={fps},drawbox=color=white:t=fill:enable='gte(n,5)*lt(n,8)'", + '-i', str(audio), '-frames:v', str(frames), '-c:v', 'libx264', + '-preset', 'ultrafast', '-c:a', 'aac', str(path)) + + +def assert_flash_beep_alignment(path: Path, fps: int, expected_frames: int, flashes: int): + streams = json.loads(run('ffprobe', '-v', 'error', '-show_entries', + 'stream=codec_type,start_time', '-of', 'json', str(path)))['streams'] + starts = {x['codec_type']: float(x.get('start_time', 0)) for x in streams} + pixels = run('ffmpeg', '-v', 'error', '-i', str(path), '-an', + '-vf', 'scale=1:1,format=gray', '-fps_mode', 'passthrough', '-f', 'rawvideo', '-') + assert len(pixels) == expected_frames + video_onsets = [starts['video'] + i / fps for i, x in enumerate(pixels) + if x > 128 and (i == 0 or pixels[i-1] <= 128)] + pcm = array.array('h', run('ffmpeg', '-v', 'error', '-i', str(path), + '-vn', '-ac', '1', '-ar', '48000', '-f', 's16le', '-')) + # 2 ms RMS bins detect the beep, ignoring the low-level AAC ringing. + block = 96 + active = [sum(x*x for x in pcm[i:i+block]) / block > 4000**2 + for i in range(0, len(pcm)-block, block)] + audio_onsets = [starts['audio'] + i * block / 48000 for i, x in enumerate(active) + if x and (i == 0 or not active[i-1])] + assert len(video_onsets) == len(audio_onsets) == flashes + assert max(abs(v-a) for v, a in zip(video_onsets, audio_onsets)) < .01 + + +@pytest.mark.parametrize('fps', [24, 25, 30, 50, 60]) +def test_aac_cuts_keep_speech_aligned_in_scene_and_episode(tmp_path, fps): + counts = [13, 17, 19] + clips = [] + for i, frames in enumerate(counts): + source = tmp_path / f'clip-{i}.mp4' + flash_beep(source, frames, fps) + clips.append({'resolved_path': str(source), 'transition': 'none', 'volume': 1}) + scene = tmp_path / 'scene.mp4' + video_editor.render_project(clips, str(scene), width=320, height=240, fps=fps) + assert_flash_beep_alignment(scene, fps, sum(counts), 3) + episode = tmp_path / 'episode.mp4' + video_editor.render_project([ + {'resolved_path': str(scene), 'transition': 'none'}, + {'resolved_path': str(scene), 'transition': 'none'}, + ], str(episode), width=320, height=240, fps=fps) + assert_flash_beep_alignment(episode, fps, 2 * sum(counts), 6) + + +def test_time_card_and_soundtrack_preserve_flash_beep_timing(tmp_path): + clips = [] + for i, frames in enumerate([13, 17, 19]): + source = tmp_path / f'clip-{i}.mp4' + flash_beep(source, frames, 30) + clips.append({'resolved_path': str(source), 'transition': 'none'}) + clips[0].update(transition='later-cinematic', transition_duration=.5, transition_text='') + silence = tmp_path / 'silence.wav' + with wave.open(str(silence), 'wb') as output: + output.setparams((1, 2, 48000, 48000, 'NONE', 'not compressed')) + output.writeframes(bytes(96000)) + movie = tmp_path / 'with-music.mp4' + video_editor.render_project(clips, str(movie), width=320, height=240, fps=30, + soundtrack={'resolved_path': str(silence), 'volume': 1}) + # Time cards may have bright text, so inspect the actual clock around the + # first and last beep rather than counting their deliberately bright pixels. + pcm = array.array('h', run('ffmpeg', '-v', 'error', '-i', str(movie), + '-vn', '-ac', '1', '-ar', '48000', '-f', 's16le', '-')) + for start in [5/30, (13+15+5)/30, (13+15+17+5)/30]: + window = pcm[round((start-.04)*48000):round((start+.04)*48000)] + first = next(i for i, x in enumerate(window) if abs(x) > 4000) + assert abs(first/48000 - .04) < .01 diff --git a/tests/test_video_editor_router.py b/tests/test_video_editor_router.py new file mode 100644 index 000000000..df28f037c --- /dev/null +++ b/tests/test_video_editor_router.py @@ -0,0 +1,435 @@ +"""ASGI contracts for the extracted Video Editor HTTP routers.""" + +from __future__ import annotations + +import ast +import os +import re +import threading +import time +from contextlib import contextmanager +from pathlib import Path +from unittest.mock import patch + +from fastapi import FastAPI, HTTPException +from fastapi.testclient import TestClient + +from routers.video_editor import ( + create_video_editor_jobs_router, + create_video_editor_router, + reset_video_editor_jobs, +) +from services.media_refs import parse_media_ref + + +PROBE = { + "duration": 2.5, + "width": 1280, + "height": 720, + "fps": 30.0, + "has_audio": True, + "pixel_format": "yuv420p", + "has_alpha": False, +} +AUDIO_PROBE = {"duration": 8.0, "has_audio": True} +EDITOR_HTTP_SURFACE = [ + ("POST", "/api/v1/video-editor/probe", "probe_video_editor_source"), + ("POST", "/api/v1/video-editor/probe-audio", "probe_video_editor_audio_source"), + ("GET", "/api/v1/video-editor/thumbnail", "serve_video_editor_thumbnail"), + ("POST", "/api/v1/video-editor/screenshot", "capture_video_editor_frame"), + ("POST", "/api/v1/video-editor/export", "start_video_editor_export"), +] +JOBS_HTTP_SURFACE = [ + ("GET", "/api/v1/video-editor/export/{job_id}", "get_video_editor_export"), + ("POST", "/api/v1/video-editor/export/{job_id}/cancel", "cancel_video_editor_export"), +] +ROUTER_SOURCE = Path(__file__).parents[1] / "app" / "routers" / "video_editor.py" + + +class DeferredThread: + instances: list["DeferredThread"] = [] + + def __init__(self, *, target, args=(), kwargs=None, **_ignored): + self.target = target + self.args = tuple(args) + self.kwargs = dict(kwargs or {}) + self.started = False + self.__class__.instances.append(self) + + def start(self) -> None: + self.started = True + + def run_now(self) -> None: + self.target(*self.args, **self.kwargs) + + +def _route_surface(router): + found = [] + for route in router.routes: + methods = sorted( + method for method in (route.methods or set()) if method not in {"HEAD", "OPTIONS"} + ) + for method in methods: + found.append((method, route.path, route.endpoint.__name__)) + return found + + +def _roots(tmp_path: Path) -> dict[str, Path]: + roots = { + "default": tmp_path / "outputs", + "film": tmp_path / "outputs" / "film", + "__uploads__": tmp_path / "uploads", + } + for path in roots.values(): + path.mkdir(parents=True, exist_ok=True) + return roots + + +def _harness(tmp_path: Path): + roots = _roots(tmp_path) + events: list[tuple] = [] + reset_video_editor_jobs() + DeferredThread.instances.clear() + + def workspace_dir(workspace=None): + ws = "default" 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." + ), + ) + path = roots.get(ws) + if path is None: + path = tmp_path / "outputs" / ws + path.mkdir(parents=True, exist_ok=True) + roots[ws] = path + return str(path) + + def resolve_input_path(value: str, workspace: str | None = None) -> str | None: + value, workspace = parse_media_ref(value, workspace) + if not value: + return None + name = value.rsplit("/", 1)[-1] + if value.startswith("/api/v1/uploads/"): + candidate = roots["__uploads__"] / name + return str(candidate) if candidate.is_file() else None + if value.startswith("/api/v1/file/"): + candidate = Path(workspace_dir(workspace or "default")) / name + return str(candidate) if candidate.is_file() else None + if os.path.isabs(value): + real = os.path.realpath(value) + allowed = [os.path.realpath(str(item)) for item in roots.values()] + if any(real == root or real.startswith(root + os.sep) for root in allowed): + return real if os.path.isfile(real) else None + return None + upload = roots["__uploads__"] / name + if upload.is_file(): + return str(upload) + candidate = Path(workspace_dir(workspace or "default")) / name + return str(candidate) if candidate.is_file() else None + + def publish(job: dict, adapter: str): + events.append(("publish", adapter, job.get("status"), job.get("phase"))) + return {"id": job["task_id"], "root_id": job["root_task_id"]} + + app = FastAPI() + app.include_router(create_video_editor_router( + workspace_dir=workspace_dir, + get_active_workspace=lambda: "default", + resolve_input_path=resolve_input_path, + publish_legacy_task=publish, + thumbnail_cache_dir=str(tmp_path / "thumbs"), + )) + app.include_router(create_video_editor_jobs_router()) + return TestClient(app), roots, events + + +def _export_body(source="clip.mp4", workspace="default", **updates): + body = { + "name": "Edited", + "workspace": workspace, + "width": 1280, + "height": 720, + "fps": 30, + "clips": [{"source": source, "transition": "none"}], + } + body.update(updates) + return body + + +def test_router_exposes_the_extracted_http_surface(tmp_path): + reset_video_editor_jobs() + editor = create_video_editor_router( + workspace_dir=lambda workspace=None: str(tmp_path), + get_active_workspace=lambda: "default", + resolve_input_path=lambda value, workspace=None: None, + publish_legacy_task=None, + thumbnail_cache_dir=str(tmp_path / "thumbs"), + ) + jobs = create_video_editor_jobs_router() + assert _route_surface(editor) == EDITOR_HTTP_SURFACE + assert _route_surface(jobs) == JOBS_HTTP_SURFACE + export = next(route for route in editor.routes if route.path == "/api/v1/video-editor/export") + assert export.status_code == 202 + + +def test_router_does_not_import_monolith_gradio_or_weights(): + tree = ast.parse(ROUTER_SOURCE.read_text(encoding="utf-8")) + modules: list[str] = [] + for node in ast.walk(tree): + if isinstance(node, ast.Import): + modules.extend(alias.name for alias in node.names) + elif isinstance(node, ast.ImportFrom) and node.module: + modules.append(node.module) + joined = " ".join(modules).lower() + assert all( + not module.startswith(("wgp", "launch", "gradio", "torch")) + and "launch_runtime" not in module + and "safetensors" not in module + for module in modules + ) + assert "gradio" not in joined + assert "torch" not in joined + + +def test_probe_and_probe_audio_return_media_contracts(tmp_path): + client, roots, _events = _harness(tmp_path) + (roots["film"] / "clip.mp4").write_bytes(b"fake-mp4") + (roots["film"] / "score.mp3").write_bytes(b"fake-mp3") + with patch("routers.video_editor.probe_media", return_value=PROBE) as probe: + response = client.post("/api/v1/video-editor/probe", json={ + "source": "clip.mp4", "workspace": "film", + }) + assert response.status_code == 200 + assert response.json() == PROBE + assert probe.call_args[0][0].endswith("film/clip.mp4") + with patch("routers.video_editor.probe_audio", return_value=AUDIO_PROBE) as probe_audio: + audio = client.post("/api/v1/video-editor/probe-audio", json={ + "source": "/api/v1/file/score.mp3?workspace=film", + "workspace": "film", + }) + assert audio.status_code == 200 + assert audio.json() == AUDIO_PROBE + assert probe_audio.call_args[0][0].endswith("film/score.mp3") + + +def test_missing_resource_and_wrong_workspace_are_rejected(tmp_path): + client, roots, _events = _harness(tmp_path) + (roots["film"] / "clip.mp4").write_bytes(b"fake-mp4") + (roots["film"] / "score.mp3").write_bytes(b"fake-mp3") + (roots["default"] / "notes.txt").write_bytes(b"text") + + missing = client.post("/api/v1/video-editor/probe", json={ + "source": "missing.mp4", "workspace": "film", + }) + assert missing.status_code == 400 + assert "could not be found" in missing.json()["detail"].lower() + + wrong = client.post("/api/v1/video-editor/probe", json={ + "source": "clip.mp4", "workspace": "default", + }) + assert wrong.status_code == 400 + assert "could not be found" in wrong.json()["detail"].lower() + + audio_wrong = client.post("/api/v1/video-editor/probe-audio", json={ + "source": "score.mp3", "workspace": "default", + }) + assert audio_wrong.status_code == 400 + assert "could not be found" in audio_wrong.json()["detail"].lower() + + unsupported = client.post("/api/v1/video-editor/probe", json={ + "source": "notes.txt", "workspace": "default", + }) + assert unsupported.status_code == 400 + assert "unsupported video format" in unsupported.json()["detail"].lower() + + thumb_missing = client.get("/api/v1/video-editor/thumbnail", params={"source": "missing.mp4"}) + assert thumb_missing.status_code == 400 + thumb_wrong = client.get( + "/api/v1/video-editor/thumbnail", + params={"source": "/api/v1/file/clip.mp4?workspace=default"}, + ) + assert thumb_wrong.status_code == 400 + + shot_missing = client.post("/api/v1/video-editor/screenshot", json={ + "source": "missing.mp4", "time": 0.2, "name": "frame", "workspace": "film", + }) + assert shot_missing.status_code == 400 + shot_wrong = client.post("/api/v1/video-editor/screenshot", json={ + "source": "clip.mp4", "time": 0.2, "name": "frame", "workspace": "default", + }) + assert shot_wrong.status_code == 400 + + +def test_thumbnail_and_screenshot_keep_response_shape(tmp_path): + client, roots, _events = _harness(tmp_path) + (roots["default"] / "clip.mp4").write_bytes(b"fake-mp4") + jpeg = tmp_path / "thumbs" / "preview.jpg" + jpeg.parent.mkdir(parents=True, exist_ok=True) + jpeg.write_bytes(b"jpeg-bytes") + + with patch("routers.video_editor.ensure_media_thumbnail", return_value=str(jpeg)): + thumbnail = client.get("/api/v1/video-editor/thumbnail", params={"source": "clip.mp4"}) + assert thumbnail.status_code == 200 + assert thumbnail.headers["content-type"].startswith("image/jpeg") + assert thumbnail.content == b"jpeg-bytes" + + def fake_extract(_source, output_path, _time): + Path(output_path).write_bytes(b"png") + return {"time": 0.2, "width": 1280, "height": 720} + + with patch("routers.video_editor.extract_frame", side_effect=fake_extract): + shot = client.post("/api/v1/video-editor/screenshot", json={ + "source": "clip.mp4", "time": 0.2, "name": "hero frame", "workspace": "default", + }) + assert shot.status_code == 200 + body = shot.json() + assert body["filename"].endswith("_hero_frame_frame.png") + assert body["url"] == f"/api/v1/file/{body['filename']}" + assert body["time"] == 0.2 + assert (roots["default"] / body["filename"]).is_file() + + +def test_export_validates_clips_and_missing_or_foreign_sources(tmp_path): + client, roots, _events = _harness(tmp_path) + (roots["film"] / "clip.mp4").write_bytes(b"fake-mp4") + empty = client.post("/api/v1/video-editor/export", json={"clips": [], "workspace": "film"}) + assert empty.status_code == 400 + assert empty.json()["detail"] == "Add at least one video clip" + bad_fps = client.post("/api/v1/video-editor/export", json=_export_body(workspace="film", fps=12)) + assert bad_fps.status_code == 400 + assert bad_fps.json()["detail"] == "Unsupported frame rate" + + with patch("routers.video_editor.threading.Thread", DeferredThread): + queued = client.post( + "/api/v1/video-editor/export", + json=_export_body(source="missing.mp4", workspace="film"), + ) + assert queued.status_code == 202 + DeferredThread.instances[-1].run_now() + failed = client.get(f"/api/v1/video-editor/export/{queued.json()['job_id']}") + assert failed.status_code == 200 + assert failed.json()["status"] == "failed" + assert "could not be found" in failed.json()["error"].lower() + + reset_video_editor_jobs() + DeferredThread.instances.clear() + with patch("routers.video_editor.threading.Thread", DeferredThread): + foreign = client.post( + "/api/v1/video-editor/export", + json=_export_body(source="clip.mp4", workspace="default"), + ) + DeferredThread.instances[-1].run_now() + assert client.get( + f"/api/v1/video-editor/export/{foreign.json()['job_id']}" + ).json()["status"] == "failed" + + +def test_export_queues_publishes_and_completes_without_ffmpeg(tmp_path): + client, roots, events = _harness(tmp_path) + (roots["film"] / "clip.mp4").write_bytes(b"fake-mp4") + + def fake_render(_clips, output_path, *, progress, **_settings): + progress(40, "Encoding editor timeline…") + Path(output_path).write_bytes(b"fake editor mp4") + return {"duration": 1.5, "clip_count": 1} + + with patch("routers.video_editor.threading.Thread", DeferredThread), patch( + "routers.video_editor.render_project", side_effect=fake_render, + ): + response = client.post("/api/v1/video-editor/export", json=_export_body(workspace="film")) + assert response.status_code == 202 + body = response.json() + assert body["status"] == "queued" + assert body["workspace"] == "film" + assert body["task_id"].startswith("task-video-editor-") + assert body["resource_requirements"] == ["local_cpu:ffmpeg"] + assert events[0][0:3] == ("publish", "video-editor", "queued") + DeferredThread.instances[-1].run_now() + + status = client.get(f"/api/v1/video-editor/export/{body['job_id']}") + assert status.status_code == 200 + completed = status.json() + assert completed["status"] == "completed" + assert completed["filename"].endswith(".mp4") + assert (roots["film"] / completed["filename"]).read_bytes() == b"fake editor mp4" + sidecar = Path(str(roots["film"] / completed["filename"])).with_suffix(".meta.json") + assert sidecar.is_file() + assert client.get("/api/v1/video-editor/export/missing-job").status_code == 404 + + +def test_queued_cancel_is_immediate_and_never_renders(tmp_path): + client, roots, _events = _harness(tmp_path) + (roots["default"] / "clip.mp4").write_bytes(b"fake-mp4") + rendered = [] + + def fake_render(_clips, output_path, **_kwargs): + rendered.append(output_path) + Path(output_path).write_bytes(b"should-not-write") + return {"duration": 1.0} + + with patch("routers.video_editor.threading.Thread", DeferredThread), patch( + "routers.video_editor.render_project", side_effect=fake_render, + ): + queued = client.post("/api/v1/video-editor/export", json=_export_body()) + job_id = queued.json()["job_id"] + cancelled = client.post(f"/api/v1/video-editor/export/{job_id}/cancel") + assert cancelled.status_code == 200 + assert cancelled.json()["status"] == "cancelled" + assert cancelled.json()["cancel_mode"] == "immediate" + DeferredThread.instances[-1].run_now() + assert rendered == [] + assert client.get(f"/api/v1/video-editor/export/{job_id}").json()["status"] == "cancelled" + assert client.post("/api/v1/video-editor/export/missing/cancel").status_code == 404 + + +def test_running_cancel_waits_for_ffmpeg_boundary_and_removes_output(tmp_path): + client, roots, _events = _harness(tmp_path) + (roots["default"] / "clip.mp4").write_bytes(b"fake-mp4") + render_started = threading.Event() + release_render = threading.Event() + next_step: list[bool] = [] + + def blocking_render(_clips, output_path, *, progress, **_settings): + Path(output_path).write_bytes(b"partial mp4") + Path(output_path).with_suffix(".meta.json").write_text("{}", encoding="utf-8") + progress(55, "Halfway through FFmpeg…") + render_started.set() + assert release_render.wait(timeout=2) + progress(75, "Current FFmpeg subprocess reached its safe boundary") + next_step.append(True) + return {"duration": 2.0} + + @contextmanager + def acquire(_lane, *, task_id, description, cancelled): + yield + + with patch("routers.video_editor.render_project", side_effect=blocking_render), patch( + "routers.video_editor.resource_scheduler.coordinator.acquire", acquire, + ): + queued = client.post("/api/v1/video-editor/export", json=_export_body()) + job_id = queued.json()["job_id"] + assert render_started.wait(timeout=2) + cancelling = client.post(f"/api/v1/video-editor/export/{job_id}/cancel") + assert cancelling.json()["status"] == "cancelling" + assert cancelling.json()["cancel_mode"] == "deferred" + release_render.set() + terminal = None + deadline = time.time() + 3 + while time.time() < deadline: + terminal = client.get(f"/api/v1/video-editor/export/{job_id}").json() + if terminal["status"] in {"cancelled", "failed", "completed"}: + break + time.sleep(0.02) + else: + raise AssertionError("export did not finish after cancel") + assert terminal["status"] == "cancelled" + assert terminal["cancel_mode"] == "deferred" + assert next_step == [] + assert list(roots["default"].glob("*_Edited.mp4")) == [] + assert list(roots["default"].glob("*_Edited.meta.json")) == [] diff --git a/tests/test_video_generation_commands.py b/tests/test_video_generation_commands.py new file mode 100644 index 000000000..0ca04b509 --- /dev/null +++ b/tests/test_video_generation_commands.py @@ -0,0 +1,263 @@ +"""Shared video admission, HTTP/MCP replay and receipt recovery without a provider.""" + +from concurrent.futures import ThreadPoolExecutor +from copy import deepcopy +from types import SimpleNamespace + +from fastapi import FastAPI, HTTPException +from fastapi.testclient import TestClient +import pytest + +from routers.image_generation_commands import ( + create_image_generation_commands_router, + image_command_catalog, + image_command_handlers, +) +from routers.wangp_mcp import create_wangp_mcp_router +from services.video_generation_commands import create_video_operation +from services.video_generation_spec import freeze_video_generation_spec +from tests.test_image_generation_commands import FakeNative, _command as image_command, _db_counts, _mcp_call, _run + + +T2V_DEFINITION = { + "architecture": "t2v_1.3B", + "image_outputs": False, + "audio_only": False, + "frames_minimum": 5, + "frames_steps": 4, + "guidance_max_phases": 3, + "inference_steps_min": 1, + "inference_steps_max": 100, + "sample_solvers": [("unipc", "unipc"), ("euler", "euler")], +} + + +def video_command(intent="video-test-intent", **params): + native = { + "model_type": "t2v_1.3B", + "prompt": ' A lantern over wet cobblestones.\n"Mañana" ', + "resolution": "832x480", + "video_length": 81, + "num_inference_steps": 30, + "guidance_scale": 5.0, + "seed": 42, + "negative_prompt": " blur ", + "generation_mode": "video", + "image_mode": 0, + } + native.update(params) + return { + "version": 2, + "operation": "generation.video", + "intent_id": intent, + "input": {"workspace": "video-test", "params": native}, + } + + +class RecordingResources: + def __init__(self, *, media=None, error=None): + self.media = media + self.error = error + self.media_calls = [] + + def prepare_media(self, params): + self.media_calls.append(deepcopy(params)) + if self.error is not None: + raise self.error + if self.media is not None: + prepared, identities = self.media + return deepcopy(prepared), deepcopy(identities) + return deepcopy(params), [] + + def prepare_loras(self, params, definition): + del params, definition + return [] + + +def configured_service(native, tmp_path, resources=None, downloaded=True): + service = native.service() + native_prepare = service.prepare + resources = resources or RecordingResources() + + async def prepare_request(request): + assert request.prepared_studio_video is True + return await native_prepare(request) + + runtime = { + "wgp": SimpleNamespace(get_model_def=lambda model: deepcopy(T2V_DEFINITION) if model in {"t2v", "t2v_1.3B"} else None), + "_check_model_downloaded": lambda _model: downloaded, + } + service.prepare = prepare_request + service.runtime_defaults = lambda: {"model_type": "wrong-image-model", "prompt": "global residue"} + service.operations["generation.video"] = create_video_operation( + runtime, resources=lambda: resources, execution_policy=lambda _workspace: None, + ) + return service, resources + + +def _video_app(service, tmp_path): + app = FastAPI() + app.include_router(create_image_generation_commands_router(service)) + app.include_router(create_wangp_mcp_router( + handlers=image_command_handlers(service), + command_operations=image_command_catalog( + [service.operations["generation.video"].catalog], + ), + journal_path=tmp_path / "mcp-journal.sqlite", + token_getter=lambda: "test-token", + )) + return TestClient(app) + + +def test_http_and_mcp_share_literal_video_admission(tmp_path): + native = FakeNative(tmp_path) + service, _ = configured_service(native, tmp_path) + command = video_command() + with _video_app(service, tmp_path) as client: + first = client.post( + "/api/v1/generation/commands", + json=command, + headers={"X-Hocus-UI-Surface": "wizard"}, + ) + assert first.status_code == 200, first.text + catalog = client.get("/api/v1/generation/commands").json() + listed = client.post( + "/api/v1/wangp/mcp", + headers={"Authorization": "Bearer test-token"}, + json={"jsonrpc": "2.0", "id": 2, "method": "tools/list"}, + ).json()["result"]["tools"] + names = {operation["name"] for operation in catalog["operations"]} + assert {"generation.image", "generation.video", "generation.receipt"} <= names + video_tool = next(tool for tool in listed if tool["name"] == "generation.video") + assert "operation" not in video_tool["inputSchema"]["properties"] + generate_tool = next(tool for tool in listed if tool["name"] == "generate") + assert "video" in generate_tool["inputSchema"]["properties"]["params"]["properties"]["generation_mode"]["enum"] + + mcp_arguments = {key: value for key, value in command.items() if key != "operation"} + replay = _run(image_command_handlers(service)["generation.video"](mcp_arguments)) + receipt = first.json()["receipt"] + assert replay == {"receipt": receipt, "replayed": True} + assert receipt["operation"] == "generation.video" + assert len(native.dispatch_calls) == 1 + job = native.dispatch_calls[0] + assert job["params"]["prompt"] == command["input"]["params"]["prompt"] + assert job["params"]["model_type"] == "t2v_1.3B" + assert job["params"]["generation_mode"] == "video" + assert job["params"]["image_mode"] == 0 + assert job["params"]["multi_prompts_gen_type"] == 2 + assert job["params"]["sliding_window_size"] == command["input"]["params"]["video_length"] + assert "wrong-image-model" not in job["params"].values() + entry = native.registry("video-test").command_admission(command["intent_id"]) + assert entry["original"] == command + assert entry["effective"]["runtime"]["params"]["prompt"] == command["input"]["params"]["prompt"] + assert entry["effective"]["runtime"]["provenance"]["capability"] == "generation.video" + assert entry["effective"]["runtime"]["provenance"]["actor"] == "wizard" + + +def test_wizard_and_mcp_effective_requests_match(tmp_path): + native = FakeNative(tmp_path) + service, _ = configured_service(native, tmp_path) + command = video_command() + wizard = _run(service.submit(command, trusted_tool="wizard")) + mcp_arguments = {key: value for key, value in command.items() if key != "operation"} + mcp = _run(image_command_handlers(service)["generation.video"](mcp_arguments)) + assert mcp["receipt"] == wizard["receipt"] + frozen = freeze_video_generation_spec(command) + entry = native.registry("video-test").command_admission(command["intent_id"]) + assert entry["effective"]["input"] == frozen["effective"]["input"] + assert entry["digest"] == frozen["fingerprint"] + assert len(native.dispatch_calls) == 1 + + +def test_same_intent_replays_and_new_intent_creates_another_generation(tmp_path): + native = FakeNative(tmp_path) + service, _ = configured_service(native, tmp_path) + with ThreadPoolExecutor(max_workers=4) as pool: + replies = list(pool.map(lambda _: _run(service.submit(video_command())), range(6))) + assert all(reply["receipt"] == replies[0]["receipt"] for reply in replies) + assert len(native.dispatch_calls) == 1 + second = _run(service.submit(video_command("another-deliberate-video"))) + assert second["receipt"]["taskIds"] != replies[0]["receipt"]["taskIds"] + assert _db_counts(native.registry("video-test"))["tasks"] == 2 + changed = video_command() + changed["input"]["params"]["prompt"] += " extra" + with pytest.raises(HTTPException) as conflict: + _run(service.submit(changed)) + assert conflict.value.status_code == 409 + with pytest.raises(HTTPException) as domain: + _run(service.submit(image_command("video-test-intent", workspace="video-test"))) + assert domain.value.status_code == 409 + + +def test_parameter_rejection_and_missing_model_add_no_tasks(tmp_path): + native = FakeNative(tmp_path) + service, _ = configured_service(native, tmp_path) + invalid = video_command("bad-params") + invalid["input"]["params"]["model_type"] = "t2v_2_2" + with pytest.raises(HTTPException) as rejected: + _run(service.submit(invalid)) + assert rejected.value.status_code == 422 + assert native.registry("video-test").command_admission("bad-params") is None + assert _db_counts(native.registry("video-test"))["tasks"] == 0 + + missing_native = FakeNative(tmp_path) + missing, _ = configured_service(missing_native, tmp_path, downloaded=False) + with pytest.raises(HTTPException) as unavailable: + _run(missing.submit(video_command("missing-model"))) + assert unavailable.value.status_code == 409 + assert missing_native.registry("video-test").command_admission("missing-model") is None + assert missing_native.dispatch_calls == [] + + +def test_cross_workspace_reference_adds_no_tasks(tmp_path): + native = FakeNative(tmp_path) + resources = RecordingResources(error=ValueError("The reference must name its actual source workspace")) + service, _ = configured_service(native, tmp_path, resources=resources) + request = video_command("foreign-ref", image_start="/api/v1/file/frame.png?workspace=source") + with pytest.raises(HTTPException) as rejected: + _run(service.submit(request)) + assert rejected.value.status_code == 422 + assert native.registry("video-test").command_admission("foreign-ref") is None + assert native.dispatch_calls == [] + assert resources.media_calls + + +def test_prepare_pins_literal_prompt_and_single_window(tmp_path): + from services.studio_video_preparation import prepare_studio_video + + resources = RecordingResources() + params = { + "workspace": "video-test", + "model_type": "t2v", + "prompt": 'A lantern over wet cobblestones.\n"Mañana"', + "resolution": "832x480", + "video_length": 161, + "num_inference_steps": 30, + "guidance_scale": 5.0, + "generation_mode": "video", + "image_mode": 0, + } + prepared, media = prepare_studio_video( + params, + model_definition=lambda _model: deepcopy(T2V_DEFINITION), + model_downloaded=lambda _model: True, + resources=resources, + execution_policy=lambda _workspace: None, + ) + assert prepared["prompt"] == params["prompt"] + assert prepared["multi_prompts_gen_type"] == 2 + assert prepared["sliding_window_size"] == 161 + assert media == [] + + +def test_lost_http_response_still_recovers_the_receipt(tmp_path): + native = FakeNative(tmp_path) + service, _ = configured_service(native, tmp_path) + command = video_command("lost-response") + first = _run(service.submit(command, trusted_tool="wizard")) + recovered = service.receipt("video-test", "lost-response") + assert recovered["receipt"] == first["receipt"] + assert recovered["task"]["id"] == first["receipt"]["result"]["task_id"] + replay = _run(service.submit(command, trusted_tool="external_agent")) + assert replay == {"receipt": first["receipt"], "replayed": True} + assert len(native.dispatch_calls) == 1 diff --git a/tests/test_video_generation_spec.py b/tests/test_video_generation_spec.py new file mode 100644 index 000000000..db4d2892d --- /dev/null +++ b/tests/test_video_generation_spec.py @@ -0,0 +1,175 @@ +"""Provider-free tests for the closed generation.video envelope.""" + +from copy import deepcopy +import hashlib +import json + +import pytest + +from services.studio_video_spec import freeze_studio_video_spec, studio_video_schema +from services.video_generation_spec import ( + STUDIO_VIDEO_DEFAULTS, + VIDEO_MODEL_TYPES, + VideoGenerationSpecError, + freeze_video_generation_spec, + video_generation_schema, +) + + +def command(intent="video-intent", **params): + native = { + "model_type": "t2v_1.3B", + "prompt": ' A lantern over wet cobblestones.\n"Mañana" ', + "resolution": "832x480", + "video_length": 81, + "num_inference_steps": 30, + "guidance_scale": 5.0, + "seed": 42, + "negative_prompt": " blur, text ", + } + native.update(params) + return { + "version": 2, + "operation": "generation.video", + "intent_id": intent, + "input": {"workspace": "video-test", "params": native}, + } + + +def test_freeze_preserves_literal_prompt_and_detaches_input(): + submitted = command() + before = deepcopy(submitted) + frozen = freeze_video_generation_spec(submitted) + + assert submitted == before + assert frozen["original"] == before + assert frozen["original"] is not submitted + assert frozen["original"]["input"]["params"]["prompt"] == before["input"]["params"]["prompt"] + assert frozen["effective"]["input"]["params"]["prompt"] == before["input"]["params"]["prompt"] + assert freeze_studio_video_spec(submitted)["fingerprint"] == frozen["fingerprint"] + + submitted["input"]["params"]["prompt"] = "changed" + assert frozen["original"]["input"]["params"]["prompt"] == before["input"]["params"]["prompt"] + + +def test_effective_defaults_are_video_selectors_and_omissions_survive(): + submitted = command() + omitted = ( + "generation_mode", "image_mode", "repeat_generation", "batch_size", + "prompt_enhancer", "activated_loras", "loras_multipliers", "seed", + "video_prompt_type", "image_prompt_type", "multi_prompts_gen_type", + ) + for key in omitted: + submitted["input"]["params"].pop(key, None) + frozen = freeze_video_generation_spec(submitted) + effective = frozen["effective"]["input"]["params"] + assert "generation_mode" not in submitted["input"]["params"] + for key in omitted: + assert effective[key] == STUDIO_VIDEO_DEFAULTS[key] + assert effective["negative_prompt"] == submitted["input"]["params"]["negative_prompt"] + + +def test_fingerprint_excludes_intent_and_covers_workspace_and_native_content(): + first = freeze_video_generation_spec(command("one")) + second = freeze_video_generation_spec(command("two")) + assert first["fingerprint"] == second["fingerprint"] + changed = command("three") + changed["input"]["params"]["prompt"] += " changed" + assert freeze_video_generation_spec(changed)["fingerprint"] != first["fingerprint"] + other = command("four") + other["input"]["workspace"] = "another-workspace" + assert freeze_video_generation_spec(other)["fingerprint"] != first["fingerprint"] + + +def test_fingerprint_is_canonical_sha256(): + frozen = freeze_video_generation_spec(command()) + content = {"version": 2, "operation": "generation.video", "input": frozen["effective"]["input"]} + expected = hashlib.sha256( + json.dumps(content, ensure_ascii=False, sort_keys=True, separators=(",", ":"), allow_nan=False).encode() + ).hexdigest() + assert frozen["fingerprint"] == expected + assert frozen["fingerprint_version"] == 2 + + +@pytest.mark.parametrize( + ("field", "value"), + [ + ("generation_mode", "image"), + ("generation_mode", "audio"), + ("image_mode", 1), + ("video_length", 1), + ("video_length", 0), + ("model_type", "pi_flux2"), + ("model_type", "t2v_2_2"), + ("model_type", "minimax_h3"), + ("prompt_enhancer", "cinematic"), + ("repeat_generation", 2), + ("multi_prompts_gen_type", 0), + ("multi_prompts_gen_type", 3), + ], +) +def test_closed_video_surface_rejects_other_families_and_modes(field, value): + with pytest.raises(VideoGenerationSpecError): + freeze_video_generation_spec(command(**{field: value})) + + +@pytest.mark.parametrize( + "value", + [ + "/etc/passwd", + "../frame.png", + "C:\\frame.png", + "https://example.test/frame.png", + "/api/v1/file/frame.png", + "/api/v1/file/../frame.png?workspace=video-test", + "/api/v1/uploads/frame.png?workspace=other", + ], +) +def test_image_start_must_be_canonical(value): + with pytest.raises(VideoGenerationSpecError): + freeze_video_generation_spec(command(image_start=value)) + + +def test_canonical_image_start_and_collection_are_retained(): + submitted = command() + submitted["input"]["workspace_collection_id"] = "collection-a" + submitted["input"]["params"]["image_start"] = "/api/v1/file/frame.png?workspace=source" + frozen = freeze_video_generation_spec(submitted) + assert frozen["effective"]["input"]["workspace_collection_id"] == "collection-a" + assert frozen["effective"]["input"]["params"]["image_start"] == submitted["input"]["params"]["image_start"] + + +def test_schema_announces_wan_t2v_family_only(): + schema = video_generation_schema() + assert schema == studio_video_schema() + assert schema["operation"] == "generation.video" + assert schema["video_model_family"] == "wan_t2v_2_1" + assert schema["video_model_types"] == sorted(VIDEO_MODEL_TYPES) + assert "t2v_2_2" not in schema["video_model_types"] + assert "minimax_h3" not in schema["video_model_types"] + + +@pytest.mark.parametrize("invalid", [None, [], "command", 2]) +def test_command_must_be_an_object(invalid): + with pytest.raises(VideoGenerationSpecError): + freeze_video_generation_spec(invalid) + + +def test_image_spec_still_rejects_generation_video_operation(): + from services.image_generation_spec import ImageGenerationSpecError, freeze_image_generation_spec + + with pytest.raises(ImageGenerationSpecError, match="operation"): + freeze_image_generation_spec({ + "version": 1, + "operation": "generation.video", + "intent_id": "keep-image-spec", + "input": { + "workspace": "workspace-a", + "model_type": "pi_flux2", + "prompt": "still an image command", + "resolution": "512x512", + "num_inference_steps": 1, + "seed": -1, + "guidance_scale": 1.0, + }, + }) diff --git a/tests/test_vocal_isolation.py b/tests/test_vocal_isolation.py index 0ba425725..c6d95b19d 100644 --- a/tests/test_vocal_isolation.py +++ b/tests/test_vocal_isolation.py @@ -5,12 +5,21 @@ import pytest from services import vocal_isolation as vocals from services.scene3d_speech import SpeechAnalysisUnavailable +from services.speech_analysis_cache import reset_runtime_state from services.vocal_isolation_worker import installed_separator from services.vocal_isolation_worker import inference_input import io import wave +@pytest.fixture(autouse=True) +def _speech_analysis_cache(tmp_path, monkeypatch): + monkeypatch.setenv("SPEECH_ANALYSIS_CACHE_DIR", str(tmp_path / "speech-cache")) + reset_runtime_state() + yield + reset_runtime_state() + + def wav(seconds=1): output = io.BytesIO() with wave.open(output, 'wb') as audio: diff --git a/tests/test_wangp_mcp.py b/tests/test_wangp_mcp.py index c1feaf29c..b318da74c 100644 --- a/tests/test_wangp_mcp.py +++ b/tests/test_wangp_mcp.py @@ -323,7 +323,8 @@ async def submit(request): assert normalize_submission_provenance({'tool': 'external_agent'})['tool'] == 'studio' -def test_mcp_is_reachable_before_spa_mount(tmp_path): +@pytest.mark.parametrize('path', ('/api/v1/mcp', '/api/v1/wangp/mcp')) +def test_mcp_is_reachable_before_spa_mount(tmp_path, path): from fastapi import FastAPI from fastapi.staticfiles import StaticFiles from fastapi.testclient import TestClient @@ -331,6 +332,43 @@ def test_mcp_is_reachable_before_spa_mount(tmp_path): app.include_router(create_wangp_mcp_router(handlers={}, journal_path=tmp_path / 'requests.db', token_getter=lambda: 'test-token')) app.mount('/', StaticFiles(directory=tmp_path), name='spa') with TestClient(app) as client: - response = client.post('/api/v1/wangp/mcp', headers={'Authorization': 'Bearer test-token'}, json={'jsonrpc': '2.0', 'id': 1, 'method': 'initialize'}) + response = client.post(path, headers={'Authorization': 'Bearer test-token'}, json={'jsonrpc': '2.0', 'id': 1, 'method': 'initialize'}) assert response.status_code == 200 assert response.json()['result']['protocolVersion'] == '2025-03-26' + assert response.json()['result']['serverInfo']['name'] == 'hocuspocus' + stream = client.get(path) + assert stream.status_code == 405 + assert stream.headers['allow'] == 'POST' + + +def test_canonical_and_legacy_urls_share_tools_and_request_journal(tmp_path): + from fastapi.testclient import TestClient + + calls = [] + + async def submit(request): + calls.append(await request.json()) + return {'job_id': 'task-a'} + + headers = {'Authorization': 'Bearer test-token'} + message = {'jsonrpc': '2.0', 'id': 1, 'method': 'tools/call', 'params': { + 'name': 'generate', 'arguments': {'request_id': 'same-intent', 'params': { + 'model_type': 'fake', 'prompt': 'literal', 'generation_mode': 'image', + }}, + }} + with TestClient(http_app(tmp_path / 'journal.db', submit)) as client: + replies, catalogs = [], [] + for path in ('/api/v1/wangp/mcp', '/api/v1/mcp'): + listed = client.post(path, headers=headers, json={'jsonrpc': '2.0', 'id': 2, 'method': 'tools/list'}) + assert listed.status_code == 200 + catalogs.append(listed.json()['result']) + reply = client.post(path, headers=headers, json=message) + assert reply.status_code == 200 + replies.append(reply.json()['result']) + denied = client.post(path, headers={**headers, 'Origin': 'https://foreign.invalid'}, json=message) + assert denied.status_code == 403 + assert catalogs[0] == catalogs[1] + assert replies[0] == replies[1] + assert not replies[0]['isError'] + assert json.loads(replies[0]['content'][0]['text']) == {'job_id': 'task-a'} + assert len(calls) == 1 diff --git a/tests/test_wizard_mcp_corpus.py b/tests/test_wizard_mcp_corpus.py new file mode 100644 index 000000000..27e3dc4f3 --- /dev/null +++ b/tests/test_wizard_mcp_corpus.py @@ -0,0 +1,427 @@ +"""Wizard/MCP corpus: published operations, refusals, replay identity, unpublished tools. + +Simulated admissions use the same FastAPI/MCP handlers as the Studio command +tests. A live catalog probe is read-only and never enqueues GPU work. +""" +from __future__ import annotations + +from copy import deepcopy +from concurrent.futures import ThreadPoolExecutor +import json +import os +from pathlib import Path +from urllib.error import HTTPError, URLError +from urllib.request import Request, urlopen + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from routers.image_generation_commands import ( + create_image_generation_commands_router, + image_command_catalog, + image_command_handlers, +) +from routers.studio_music_commands import music_command_catalog +from routers.studio_sfx_commands import sfx_command_catalog +from routers.studio_speech_commands import speech_command_catalog +from routers.tools_upscale_commands import tools_upscale_command_catalog +from routers.wangp_mcp import create_wangp_mcp_router +from services.native_generation_operation import NativeGenerationOperation +from services.studio_music_spec import freeze_studio_music_spec +from services.studio_sfx_spec import freeze_studio_sfx_spec +from services.studio_speech_spec import freeze_studio_speech_spec +from services.tools_upscale_spec import freeze_tools_upscale_spec +from tests.test_image_generation_commands import FakeNative, _command, _db_counts, _mcp_call, _run +from tests.test_studio_music_commands import music_command +from tests.test_studio_speech_commands import speech_command +from tests.test_tools_command_runtime import command as upscale_command + +ROOT = Path(__file__).resolve().parents[1] +CORPUS_PATH = ROOT / "tests/fixtures/wizard_mcp_corpus.json" +EVIDENCE = ROOT / "outputs/wizard-mcp-corpus-20260911" +LIVE_API = os.environ.get("HOCUS_WIZARD_MCP_LIVE_API", "http://127.0.0.1:42005") + + +def load_corpus(): + return json.loads(CORPUS_PATH.read_text(encoding="utf-8")) + + +def _attach(service, name, freeze_spec, catalog, *, use_defaults=True): + def freeze(command): + frozen = freeze_spec(command) + payload = frozen["effective"]["input"] + params = deepcopy(payload.get("params", payload)) + workspace = payload.get("workspace") or params.get("workspace") + return frozen, {**params, "workspace": workspace} + + service.operations[name] = NativeGenerationOperation( + freeze=freeze, + prepare=lambda params: (deepcopy(params), []), + catalog=catalog, + use_generation_defaults=use_defaults, + ) + + +def published_service(native): + service = native.service() + _attach(service, "generation.speech", freeze_studio_speech_spec, speech_command_catalog()) + _attach(service, "generation.music", freeze_studio_music_spec, music_command_catalog()) + _attach(service, "generation.sfx", freeze_studio_sfx_spec, sfx_command_catalog()) + _attach( + service, "tools.upscale", freeze_tools_upscale_spec, tools_upscale_command_catalog(), + use_defaults=False, + ) + return service + + +def published_catalog(service): + return image_command_catalog(adapter.catalog for adapter in service.operations.values()) + + +def corpus_client(native, tmp_path): + service = published_service(native) + catalog = published_catalog(service) + app = FastAPI() + app.include_router(create_image_generation_commands_router(service)) + app.include_router(create_wangp_mcp_router( + handlers=image_command_handlers(service), + command_operations=catalog, + journal_path=Path(tmp_path) / "mcp-journal.sqlite", + token_getter=lambda: "test-token", + )) + return TestClient(app), service, catalog + + +def sfx_command(intent="sfx-corpus-intent"): + return { + "version": 2, + "operation": "generation.sfx", + "intent_id": intent, + "input": { + "workspace": "workspace-a", + "params": { + "model_type": "mmaudio_v2", + "prompt": " Rain against glass.\n ", + "duration_seconds": 3, + "seed": 42, + }, + }, + } + + +def command_for(operation, intent): + builders = { + "generation.image": lambda: _command(intent), + "generation.speech": lambda: {**speech_command(intent), "input": { + **speech_command(intent)["input"], "workspace": "workspace-a", + }}, + "generation.music": lambda: {**music_command(intent), "input": { + **music_command(intent)["input"], "workspace": "workspace-a", + }}, + "generation.sfx": lambda: sfx_command(intent), + "tools.upscale": lambda: { + **upscale_command(intent), + "input": {**upscale_command(intent)["input"], "workspace": "workspace-a"}, + }, + } + return builders[operation]() + + +def mcp_args(command): + return {key: value for key, value in command.items() if key != "operation"} + + +def write_evidence(name, payload): + EVIDENCE.mkdir(parents=True, exist_ok=True) + path = EVIDENCE / name + path.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") + return path + + +def test_corpus_fixture_covers_required_kinds_and_languages(): + corpus = load_corpus() + kinds = {case["kind"] for case in corpus["cases"]} + langs = {case["lang"] for case in corpus["cases"]} + assert kinds >= {"intent", "negation", "ambiguous", "workspace_change", "retry", "compound", "unpublished", "error_recovery"} + assert langs == {"en", "es"} + assert corpus["expect_actions_not_prose"] is True + assert "generation.model3d" in corpus["unpublished_operations"] + assert "generation.model3d" not in corpus["published_operations"] + write_evidence("corpus-index.json", { + "id": corpus["id"], + "cases": [case["id"] for case in corpus["cases"]], + "published_operations": corpus["published_operations"], + "unpublished_operations": corpus["unpublished_operations"], + }) + + +def test_http_catalog_and_mcp_list_only_published_operations(tmp_path): + corpus = load_corpus() + native = FakeNative(tmp_path) + client, _service, catalog = corpus_client(native, tmp_path) + http = client.get("/api/v1/generation/commands") + assert http.status_code == 200 + names = [entry["name"] for entry in http.json()["operations"]] + assert names == [entry["name"] for entry in catalog] + assert set(corpus["published_operations"]) <= set(names) + assert "generation.model3d" not in names + listed = client.post( + "/api/v1/wangp/mcp", + headers={"Authorization": "Bearer test-token"}, + json={"jsonrpc": "2.0", "id": 1, "method": "tools/list"}, + ).json()["result"]["tools"] + tool_names = [tool["name"] for tool in listed] + for operation in corpus["published_operations"]: + assert operation in tool_names + assert "generation.model3d" not in tool_names + image = next(tool for tool in listed if tool["name"] == "generation.image") + assert "operation" not in image["inputSchema"]["properties"] + write_evidence("catalog-simulated.json", { + "http": names, + "mcp": tool_names, + "unpublished_absent": "generation.model3d" not in tool_names, + }) + + +def test_refusal_invalid_command_creates_no_task(tmp_path): + corpus = load_corpus() + case = next(item for item in corpus["cases"] if item["id"] == "en-invalid-extra-field") + native = FakeNative(tmp_path) + client, service, _catalog = corpus_client(native, tmp_path) + command = command_for("generation.image", "corpus-invalid") + command["input"][case["expect"]["invalid_extra_field"]] = "wizard" + response = client.post("/api/v1/generation/commands", json=command) + assert response.status_code == case["expect"]["http_status"] + assert response.json()["detail"]["code"] == "invalid_command" + assert native.dispatch_calls == [] + assert _db_counts(service.registry("workspace-a"))["tasks"] == 0 + + +def test_unpublished_tool_does_not_promise_success_or_create_a_task(tmp_path): + native = FakeNative(tmp_path) + client, service, _catalog = corpus_client(native, tmp_path) + payload = { + "jsonrpc": "2.0", + "id": 9, + "method": "tools/call", + "params": { + "name": "generation.model3d", + "arguments": { + "version": 2, + "intent_id": "corpus-video", + "input": {"workspace": "workspace-a", "params": {"prompt": "a clip"}}, + }, + }, + } + mcp = client.post("/api/v1/wangp/mcp", headers={"Authorization": "Bearer test-token"}, json=payload) + assert mcp.status_code == 200 + result = mcp.json()["result"] + assert result["isError"] is True + assert native.dispatch_calls == [] + assert _db_counts(service.registry("workspace-a"))["tasks"] == 0 + http = client.post("/api/v1/generation/commands", json={ + "version": 2, + "operation": "generation.model3d", + "intent_id": "corpus-video-http", + "input": {"workspace": "workspace-a", "params": {"model_type": "pi_flux2", "prompt": "a clip"}}, + }) + assert http.status_code == 422 + assert native.dispatch_calls == [] + write_evidence("unpublished-video.json", { + "mcp_is_error": True, + "http_status": http.status_code, + "tasks": _db_counts(service.registry("workspace-a"))["tasks"], + }) + + +def test_timeout_replay_and_two_clients_share_one_id(tmp_path): + native = FakeNative(tmp_path) + client, service, _catalog = corpus_client(native, tmp_path) + command = command_for("generation.image", "corpus-timeout") + first = client.post("/api/v1/generation/commands", json=command) + assert first.status_code == 200 + receipt = first.json()["receipt"] + assert first.json()["replayed"] is False + assert receipt["status"] == "queued" + job_id = receipt["result"]["job_id"] + task_ids = receipt["taskIds"] + replay = client.post("/api/v1/generation/commands", json=command) + assert replay.json()["replayed"] is True + assert replay.json()["receipt"]["result"]["job_id"] == job_id + mcp = _mcp_call(client, "generation.image", mcp_args(command), request_id=4).json()["result"] + assert mcp["isError"] is False + assert mcp["structuredContent"]["receipt"]["result"]["job_id"] == job_id + assert mcp["structuredContent"]["receipt"]["taskIds"] == task_ids + recovered = _mcp_call( + client, "generation.receipt", + {"version": 1, "input": {"workspace": "workspace-a", "intent_id": "corpus-timeout"}}, + request_id=5, + ).json()["result"] + assert recovered["structuredContent"]["receipt"]["result"]["job_id"] == job_id + assert len(native.dispatch_calls) == 1 + assert _db_counts(service.registry("workspace-a"))["tasks"] == 1 + write_evidence("replay-same-id.json", { + "job_id": job_id, + "task_ids": task_ids, + "http_replayed": True, + "mcp_same_id": True, + "dispatch_calls": 1, + }) + + +def test_concurrent_clients_admit_once_per_intent(tmp_path): + native = FakeNative(tmp_path) + _client, service, _catalog = corpus_client(native, tmp_path) + command = command_for("generation.image", "corpus-race") + with ThreadPoolExecutor(max_workers=4) as pool: + results = list(pool.map(lambda _: _run(service.submit(deepcopy(command))), range(4))) + ids = {result["receipt"]["result"]["job_id"] for result in results} + assert len(ids) == 1 + assert sum(not result["replayed"] for result in results) == 1 + assert len(native.dispatch_calls) == 1 + + +def test_published_modalities_admit_and_replay(tmp_path): + native = FakeNative(tmp_path) + client, service, _catalog = corpus_client(native, tmp_path) + corpus = load_corpus() + for case in corpus["cases"]: + expect = case["expect"] + if case["surface"] == "wizard" or not expect.get("creates_task"): + continue + operation = expect["operation"] + if operation == "generation.receipt": + continue + command = command_for(operation, f"corpus-{case['id']}") + http = client.post( + "/api/v1/generation/commands", json=command, + headers={"X-Hocus-UI-Surface": "wizard"}, + ) + assert http.status_code == 200, (case["id"], http.text) + body = http.json() + assert body["receipt"]["status"] == "queued" + assert body["receipt"]["operation"] == operation + mcp = _mcp_call(client, operation, mcp_args(command), request_id=hash(case["id"]) % 10_000 + 20) + result = mcp.json()["result"] + assert result["isError"] is False, case["id"] + assert result["structuredContent"]["receipt"]["result"]["job_id"] == body["receipt"]["result"]["job_id"] + if expect.get("replay_same_id") or expect.get("two_clients_same_id"): + assert result["structuredContent"]["replayed"] is True + counts = _db_counts(service.registry("workspace-a")) + assert counts["tasks"] >= 1 + assert counts["tasks"] == counts["task_command_admissions"] + + +def test_receipt_in_another_workspace_is_not_the_same_job(tmp_path): + native = FakeNative(tmp_path) + client, service, _catalog = corpus_client(native, tmp_path) + command = command_for("generation.image", "corpus-workspace") + admitted = client.post("/api/v1/generation/commands", json=command) + assert admitted.status_code == 200 + missing = client.get( + "/api/v1/generation/commands/receipt", + params={"workspace": "corpus-b", "intent_id": "corpus-workspace"}, + ) + assert missing.status_code == 404 + mcp = _mcp_call( + client, "generation.receipt", + {"version": 1, "input": {"workspace": "corpus-b", "intent_id": "corpus-workspace"}}, + request_id=44, + ).json()["result"] + assert mcp["isError"] is True + assert _db_counts(service.registry("workspace-a"))["tasks"] == 1 + assert _db_counts(service.registry("corpus-b"))["tasks"] == 0 + + +def test_changed_content_under_the_same_intent_conflicts(tmp_path): + native = FakeNative(tmp_path) + client, _service, _catalog = corpus_client(native, tmp_path) + command = command_for("generation.image", "corpus-conflict") + assert client.post("/api/v1/generation/commands", json=command).status_code == 200 + changed = deepcopy(command) + changed["input"]["prompt"] = "a different literal" + conflict = client.post("/api/v1/generation/commands", json=changed) + assert conflict.status_code == 409 + assert conflict.json()["detail"]["code"] == "intent_conflict" + assert len(native.dispatch_calls) == 1 + + +def _live_json(path, method="GET", body=None, headers=None, timeout=2.5): + request = Request( + LIVE_API.rstrip("/") + path, + data=None if body is None else json.dumps(body).encode(), + method=method, + headers={"Content-Type": "application/json", **(headers or {})}, + ) + try: + with urlopen(request, timeout=timeout) as response: + raw = response.read() + return response.status, json.loads(raw.decode()) if raw else {} + except HTTPError as error: + raw = error.read() + try: + payload = json.loads(raw.decode()) if raw else {} + except json.JSONDecodeError: + payload = {"detail": raw.decode("utf-8", "replace")[:300]} + return error.code, payload + except (URLError, TimeoutError, json.JSONDecodeError, OSError): + return None, None + + +def test_live_catalog_probe_is_read_only_and_separate_from_mock(): + corpus = load_corpus() + status, catalog = _live_json("/api/v1/generation/commands") + models_status, models = _live_json("/api/v1/models") + mcp_status, mcp = _live_json("/api/v1/settings/mcp") + unauthorized, _payload = _live_json( + "/api/v1/wangp/mcp", + method="POST", + body={"jsonrpc": "2.0", "id": 1, "method": "tools/list"}, + ) + downloaded = [] + if models_status == 200 and isinstance(models, dict): + downloaded = [ + item.get("model_type") + for item in models.get("models") or [] + if item.get("is_downloaded") + ] + live = { + "api": LIVE_API, + "catalog_status": status, + "operations": [entry.get("name") for entry in (catalog or {}).get("operations") or []] if status == 200 else [], + "mcp_settings_status": mcp_status, + "mcp_enabled": bool((mcp or {}).get("enabled")) if mcp_status == 200 else False, + "mcp_token_absent": mcp_status == 200 and "token" not in (mcp or {}), + "mcp_unauthorized_without_bearer": unauthorized, + "downloaded_models": downloaded, + "real_generation": "PENDING", + "reason": "Shared runtime already has installed models; H17 does not enqueue GPU jobs on it.", + } + write_evidence("live-probe.json", live) + write_evidence("matrix.json", { + "states": ["designed", "implemented", "simulated", "real_executed", "pending"], + "rows": corpus["matrix"], + "failures": corpus["failures"], + "live": { + "catalog": status == 200, + "generation": "PENDING", + }, + }) + if status is None: + write_evidence("real-circuit.json", { + "status": "PENDING", + "reason": f"No live API at {LIVE_API}; mock coverage remains the authority for this cut.", + }) + return + assert status == 200 + assert set(corpus["published_operations"]) <= set(live["operations"]) + assert "generation.model3d" not in live["operations"] + assert unauthorized in {401, 403, 503} + assert "token" not in (mcp or {}) + write_evidence("real-circuit.json", { + "status": "PARTIAL", + "read_only_catalog": True, + "generation": "PENDING", + "downloaded_models_sample": downloaded[:8], + "note": "Discovery against the already-running API. No generation POST.", + }) diff --git a/tests/test_wizard_workflow_executor.py b/tests/test_wizard_workflow_executor.py new file mode 100644 index 000000000..00dfdbac5 --- /dev/null +++ b/tests/test_wizard_workflow_executor.py @@ -0,0 +1,405 @@ +"""Isolated server-side image → upscale workflow executor. + +These checks never import ``_launch_runtime`` and never start a model worker. +They reuse FakeNative admission so the existing generation queue remains the +only dispatch path. +""" +from __future__ import annotations + +from copy import deepcopy +from pathlib import Path + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from routers.wizard_workflow_executor import create_wizard_workflow_executor_router +from routers.wangp_mcp import create_wangp_mcp_router +from services.native_generation_operation import NativeGenerationOperation +from services.tools_upscale_spec import freeze_tools_upscale_spec +from services.wizard_workflow_executor import ( + SERVER_OWNER, + STEP_IMAGE, + STEP_UPSCALE, + WORKFLOW_TYPE, + WizardWorkflowExecutor, + catalog, + command_handlers, +) +from services.wizard_workflows import read_workflows, write_workflows +from tests.test_image_generation_commands import FakeNative, _mcp_call +from routers.tools_upscale_commands import tools_upscale_command_catalog + + +WORKSPACE = "workspace-a" + + +def _snapshot(**overrides): + payload = { + "model_type": "pi_flux2", + "prompt": ' literal "mañana"\nsecond line ', + "resolution": "512x512", + "num_inference_steps": 1, + "seed": -1, + "guidance_scale": 1.0, + "upscaleMethod": "lanczos2", + } + payload.update(overrides) + return payload + + +def _start_body(workflow_id="wf-image-upscale", **overrides): + snapshot = _snapshot(**overrides.pop("snapshot", {})) if "snapshot" in overrides else _snapshot() + if "upscaleMethod" in overrides: + snapshot["upscaleMethod"] = overrides.pop("upscaleMethod") + body = { + "workspace": WORKSPACE, + "workflowId": workflow_id, + "userRequest": "Generate a poster and upscale it", + "inputSnapshot": snapshot, + } + body.update(overrides) + return body + + +def _tools_service(native: FakeNative): + service = native.service() + + def freeze(command): + frozen = freeze_tools_upscale_spec(command) + payload = frozen["effective"]["input"] + return frozen, {**deepcopy(payload["params"]), "workspace": payload["workspace"]} + + service.operations["tools.upscale"] = NativeGenerationOperation( + freeze=freeze, + prepare=lambda params: (params, []), + catalog=tools_upscale_command_catalog(), + prepare_request=native.prepare, + use_generation_defaults=False, + ) + return service + + +def _executor(tmp_path: Path, native: FakeNative | None = None): + native = native or FakeNative(tmp_path) + service = _tools_service(native) + + def workspace_dir(name: str) -> str: + path = Path(tmp_path) / name + path.mkdir(parents=True, exist_ok=True) + return str(path) + + def get_task(workspace: str, task_id: str): + if not task_id: + return None + return native.registry(workspace).get(task_id) + + executor = WizardWorkflowExecutor( + workspace_dir=workspace_dir, + submit_command=service.submit, + command_receipt=service.receipt, + get_task=get_task, + ) + return executor, native, service, workspace_dir + + +def _app(executor, tmp_path): + app = FastAPI() + app.include_router(create_wizard_workflow_executor_router(executor)) + app.include_router(create_wangp_mcp_router( + handlers=command_handlers(executor), + command_operations=catalog(), + journal_path=Path(tmp_path) / "mcp-journal.sqlite", + token_getter=lambda: "test-token", + )) + return TestClient(app) + + +def _complete(native: FakeNative, workspace: str, task_id: str, refs: list[str]): + registry = native.registry(workspace) + registry.update(task_id, status="running", force=True) + registry.update(task_id, status="completed", result_refs=refs, force=True) + + +def test_closing_tabs_after_image_admit_admits_upscale_once(tmp_path): + executor, native, _service, _workspace_dir = _executor(tmp_path) + client = _app(executor, tmp_path) + + started = client.post("/api/v1/wizard/workflows/executor", json=_start_body()) + assert started.status_code == 200, started.text + body = started.json() + workflow = body["workflow"] + assert workflow["state"] == "queued" + assert workflow["executorOwner"] == SERVER_OWNER + assert workflow["steps"][0]["state"] == "waiting" + assert workflow["steps"][0]["taskId"] + assert len(native.dispatch_calls) == 1 + assert native.dispatch_calls[0]["provenance"]["capability"] == "generation.image" + + image_task = workflow["steps"][0]["taskId"] + _complete(native, WORKSPACE, image_task, ["poster.png"]) + ticked = client.post("/api/v1/wizard/workflows/executor/reconcile", json={"workspace": WORKSPACE}) + assert ticked.status_code == 200, ticked.text + again = client.post("/api/v1/wizard/workflows/executor/reconcile", json={"workspace": WORKSPACE}) + assert again.status_code == 200 + + current = client.get( + f"/api/v1/wizard/workflows/executor/{workflow['workflowId']}", + params={"workspace": WORKSPACE}, + ).json()["workflow"] + assert current["steps"][0]["state"] == "completed" + assert current["steps"][1]["state"] == "waiting" + assert current["steps"][1]["kind"] == "tools.upscale" + assert len(native.dispatch_calls) == 2 + assert native.dispatch_calls[1]["provenance"]["capability"] == "tools.upscale" + assert native.dispatch_calls[1]["params"]["source"] == "/api/v1/file/poster.png?workspace=workspace-a" + assert native.dispatch_calls[1]["params"]["method"] == "lanczos2" + _complete(native, WORKSPACE, current["steps"][1]["taskId"], ["poster-up.png"]) + client.post("/api/v1/wizard/workflows/executor/reconcile", json={"workspace": WORKSPACE}) + finished = client.get( + f"/api/v1/wizard/workflows/executor/{workflow['workflowId']}", + params={"workspace": WORKSPACE}, + ).json()["workflow"] + assert finished["state"] == "completed" + assert finished["outputRefs"] == ["poster.png", "poster-up.png"] + assert len(native.dispatch_calls) == 2 + + +def test_restart_between_admit_and_save_reconciles_receipt_without_duplicate(tmp_path): + executor, native, _service, workspace_dir = _executor(tmp_path) + from asyncio import run + started = run(executor.start(_start_body("wf-restart"))) + workflow = started["workflow"] + assert len(native.dispatch_calls) == 1 + directory = workspace_dir(WORKSPACE) + collection = read_workflows(directory) + lost = deepcopy(collection) + step = lost["workflows"][0]["steps"][0] + step["state"] = "running" + step["taskId"] = "" + step["output"] = {} + lost["workflows"][0]["state"] = "running" + write_workflows(directory, lost, base_revision=int(collection["revision"])) + + restarted, _native, _ignored, _dir = _executor(tmp_path, native) + recovered = run(restarted.recover([WORKSPACE])) + assert recovered + restored = recovered[0]["workflow"] + assert restored["steps"][0]["taskId"] == workflow["steps"][0]["taskId"] + assert restored["steps"][0]["output"]["receipt"]["commandId"] + assert restored["steps"][0]["state"] == "waiting" + assert len(native.dispatch_calls) == 1 + run(restarted.reconcile(WORKSPACE)) + assert len(native.dispatch_calls) == 1 + + +def test_two_clients_answering_yield_one_winner_and_recoverable_conflict(tmp_path): + executor, native, _service, _workspace_dir = _executor(tmp_path) + client = _app(executor, tmp_path) + started = client.post( + "/api/v1/wizard/workflows/executor", + json=_start_body("wf-question", snapshot=_snapshot(upscaleMethod="")), + ) + assert started.status_code == 200, started.text + workflow = started.json()["workflow"] + _complete(native, WORKSPACE, workflow["steps"][0]["taskId"], ["choice.png"]) + paused = client.post("/api/v1/wizard/workflows/executor/reconcile", json={"workspace": WORKSPACE}) + assert paused.status_code == 200 + current = paused.json()["results"][0] + workflow = current["workflow"] + assert workflow["state"] == "awaiting_input" + assert workflow["pendingInput"]["fields"] == ["upscaleMethod"] + revision = current["revision"] + first = client.post("/api/v1/wizard/workflows/executor/answer", json={ + "workspace": WORKSPACE, + "workflowId": workflow["workflowId"], + "expectedRevision": revision, + "stepId": STEP_UPSCALE, + "answerVersion": 1, + "answer": {"upscaleMethod": "lanczos2"}, + }) + second = client.post("/api/v1/wizard/workflows/executor/answer", json={ + "workspace": WORKSPACE, + "workflowId": workflow["workflowId"], + "expectedRevision": revision, + "stepId": STEP_UPSCALE, + "answerVersion": 1, + "answer": {"upscaleMethod": "lanczos1.5"}, + }) + assert first.status_code == 200, first.text + assert second.status_code == 409 + detail = second.json()["detail"] + assert detail["code"] == "wizard_workflow_revision_conflict" + assert detail["recoverable"] is True + winner = first.json()["workflow"] + assert winner["pendingInput"]["answer"] == {"upscaleMethod": "lanczos2"} + assert winner["steps"][1]["state"] == "waiting" + assert len(native.dispatch_calls) == 2 + assert native.dispatch_calls[1]["params"]["method"] == "lanczos2" + + +def test_mcp_can_start_and_answer_the_same_circuit(tmp_path): + executor, native, _service, _workspace_dir = _executor(tmp_path) + client = _app(executor, tmp_path) + start = _mcp_call(client, "wizard.image_upscale", { + "version": 1, + "workspace": WORKSPACE, + "workflowId": "wf-mcp", + "userRequest": "poster then upscale", + "input": _snapshot(upscaleMethod=""), + }) + assert start.status_code == 200, start.text + result = start.json()["result"] + assert result["isError"] is False + workflow = result["structuredContent"]["workflow"] + _complete(native, WORKSPACE, workflow["steps"][0]["taskId"], ["mcp.png"]) + paused = client.post("/api/v1/wizard/workflows/executor/reconcile", json={"workspace": WORKSPACE}).json() + revision = paused["results"][0]["revision"] + answer = _mcp_call(client, "wizard.workflow_answer", { + "version": 1, + "workspace": WORKSPACE, + "workflowId": "wf-mcp", + "expectedRevision": revision, + "answer": {"upscaleMethod": "lanczos2"}, + }, request_id=2) + assert answer.status_code == 200, answer.text + payload = answer.json()["result"]["structuredContent"]["workflow"] + assert payload["steps"][1]["state"] == "waiting" + assert payload["pendingInput"]["answer"]["upscaleMethod"] == "lanczos2" + + +def test_old_checkpoints_are_not_migrated_or_rewritten(tmp_path): + executor, native, _service, workspace_dir = _executor(tmp_path) + directory = workspace_dir(WORKSPACE) + write_workflows(directory, { + "revision": 0, + "workflows": [{ + "workflowId": "legacy-rhythm", + "type": "create_rhythmic_3d_video", + "workspace": WORKSPACE, + "state": "waiting", + "currentStep": 0, + "steps": [{"stepId": "song", "kind": "generate_song", "state": "waiting", "input": {}}], + }], + }, base_revision=0) + from asyncio import run + recovered = run(executor.recover([WORKSPACE])) + assert recovered == [] + loaded = read_workflows(directory) + assert loaded["revision"] == 1 + assert loaded["workflows"][0]["type"] == "create_rhythmic_3d_video" + assert loaded["workflows"][0]["state"] == "waiting" + assert len(native.dispatch_calls) == 0 + + +def test_ui_lease_blocks_server_advance(tmp_path): + executor, native, _service, workspace_dir = _executor(tmp_path) + directory = workspace_dir(WORKSPACE) + write_workflows(directory, { + "revision": 0, + "workflows": [{ + "workflowId": "wf-ui", + "type": WORKFLOW_TYPE, + "workspace": WORKSPACE, + "state": "prepared", + "currentStep": 0, + "executorOwner": "ui", + "steps": [ + {"stepId": STEP_IMAGE, "kind": "generation.image", "state": "pending", "input": _snapshot()}, + {"stepId": STEP_UPSCALE, "kind": "tools.upscale", "state": "pending", "input": {}}, + ], + "inputSnapshot": _snapshot(), + }], + }, base_revision=0) + from asyncio import run + recovered = run(executor.recover([WORKSPACE])) + assert recovered == [] + assert len(native.dispatch_calls) == 0 + loaded = read_workflows(directory) + assert loaded["workflows"][0]["executorOwner"] == "ui" + + +def test_sibling_persist_during_submit_keeps_receipt(tmp_path): + executor, native, service, workspace_dir = _executor(tmp_path) + original_submit = service.submit + + async def racing_submit(command, **kwargs): + directory = workspace_dir(WORKSPACE) + collection = read_workflows(directory) + collection["workflows"].append({ + "workflowId": "wf-ui-sibling", + "type": "create_rhythmic_3d_video", + "workspace": WORKSPACE, + "state": "waiting", + "currentStep": 0, + "steps": [{"stepId": "song", "kind": "generate_song", "state": "waiting", "input": {}}], + }) + write_workflows(directory, collection, base_revision=int(collection["revision"])) + return await original_submit(command, **kwargs) + + executor._submit_command = racing_submit + from asyncio import run + started = run(executor.start(_start_body("wf-race"))) + workflow = started["workflow"] + assert workflow["steps"][0]["state"] == "waiting" + assert workflow["steps"][0]["taskId"] + assert workflow["steps"][0]["output"]["receipt"]["commandId"] + assert len(native.dispatch_calls) == 1 + loaded = read_workflows(workspace_dir(WORKSPACE)) + assert {item["workflowId"] for item in loaded["workflows"]} == {"wf-race", "wf-ui-sibling"} + saved = next(item for item in loaded["workflows"] if item["workflowId"] == "wf-race") + assert saved["steps"][0]["state"] == "waiting" + assert saved["steps"][0]["taskId"] == workflow["steps"][0]["taskId"] + + +def test_start_retry_reattaches_receipt_to_running_checkpoint(tmp_path): + executor, native, _service, workspace_dir = _executor(tmp_path) + from asyncio import run + first = run(executor.start(_start_body("wf-running"))) + directory = workspace_dir(WORKSPACE) + collection = read_workflows(directory) + lost = deepcopy(collection) + step = lost["workflows"][0]["steps"][0] + step["state"] = "running" + step["taskId"] = "" + step["output"] = {} + lost["workflows"][0]["state"] = "running" + write_workflows(directory, lost, base_revision=int(collection["revision"])) + + recovered = run(executor.start(_start_body("wf-running"))) + workflow = recovered["workflow"] + assert workflow["steps"][0]["taskId"] == first["workflow"]["steps"][0]["taskId"] + assert workflow["steps"][0]["output"]["receipt"]["commandId"] + assert workflow["steps"][0]["state"] == "waiting" + assert len(native.dispatch_calls) == 1 + + +def test_start_retry_advances_prepared_server_checkpoint(tmp_path): + executor, native, _service, workspace_dir = _executor(tmp_path) + write_workflows(workspace_dir(WORKSPACE), { + "revision": 0, + "workflows": [{ + "workflowId": "wf-prepared", + "type": WORKFLOW_TYPE, + "workspace": WORKSPACE, + "state": "prepared", + "currentStep": 0, + "executorOwner": SERVER_OWNER, + "steps": [ + {"stepId": STEP_IMAGE, "kind": "generation.image", "state": "pending", "input": _snapshot()}, + {"stepId": STEP_UPSCALE, "kind": "tools.upscale", "state": "pending", "input": {}}, + ], + "inputSnapshot": _snapshot(), + }], + }, base_revision=0) + from asyncio import run + started = run(executor.start(_start_body("wf-prepared"))) + workflow = started["workflow"] + assert workflow["state"] == "queued" + assert workflow["steps"][0]["state"] == "waiting" + assert workflow["steps"][0]["taskId"] + assert len(native.dispatch_calls) == 1 + + +def test_catalog_describes_start_and_answer_operations(): + names = [item["name"] for item in catalog()] + assert names == ["wizard.image_upscale", "wizard.workflow_answer"] diff --git a/tests/test_world3d_export.py b/tests/test_world3d_export.py new file mode 100644 index 000000000..39988b84e --- /dev/null +++ b/tests/test_world3d_export.py @@ -0,0 +1,411 @@ +"""Canonical World3D export admission, cancel, retry and optional real MP4. + +These checks never import ``_launch_runtime``. A headless worker is independent +of the HTTP client: closing the request does not cancel the task. Real paint +through the Video 3D stage is PENDING unless ffmpeg/playwright are present and +a renderer is injected; admission, cancel and idempotent intent still run. +""" +from __future__ import annotations + +from pathlib import Path +import json +import threading +import time + +from fastapi import FastAPI +from fastapi.testclient import TestClient +import pytest + +from routers.wangp_mcp import create_wangp_mcp_router +from routers.world3d_export import create_world3d_export_router +from services.scene_recording import probe_scene_recording_output +from services.task_manager import TaskRegistry, forget_task_registry +from services.world3d_export import ( + OPERATION, + World3DExportCancelled, + World3DExportService, + _OWNED_BROWSER_JS, + command_catalog, + command_handlers, + export_capabilities, + export_plan, + freeze_export_command, + mux_frame_sequence, + staging_dir, + unsupported_capabilities, + write_png, +) + + +WORKSPACE = "workspace-a" + + +def _document(**overrides): + document = { + "version": 1, "units": "meters", "up": "y", "width": 64, "height": 64, + "fps": 30, "duration": 2 / 30, "templateId": "two-shot", + "camera": {"family": "establishment", "eye": [0, 1.6, 4.2], "look": [0, 1, 0], "fov": 50}, + "light": {"kind": "directional", "direction": [-0.35, -1, -0.25], "intensity": 1.15, "color": "#fff4e5"}, + "slots": [{ + "id": "subject_1", "slot": "subject_1", "position": [0, 0, 0], "rotationY": 0, + "scale": 1, "sourceUrl": "", "media": "model3d", "clip": None, + }], + } + document.update(overrides) + return document + + +def _command(intent_id="world3d-intent-1", **input_overrides): + payload = {"workspace": WORKSPACE, "document": _document(), "refs": []} + payload.update(input_overrides) + return {"version": 1, "operation": OPERATION, "intent_id": intent_id, "input": payload} + + +def _paint(snapshot, staging, progress, cancelled, *, calls=None, gate=None, fail=False, hold=None): + if calls is not None: + calls.append(snapshot["plan"]["count"]) + if gate is not None: + gate.wait(2) + if hold is not None: + hold.wait(8) + if fail: + folder = Path(staging) / "frames" + write_png(folder / "frame_000001.png", 64, 64, (12, 24, 48)) + raise RuntimeError("forced export failure") + paths = [] + plan = snapshot["plan"] + folder = Path(staging) / "frames" + for index in range(plan["count"]): + if cancelled(): + raise World3DExportCancelled() + path = folder / f"frame_{index + 1:06d}.png" + write_png(path, plan["width"], plan["height"], (index * 40 % 200, 90, 160)) + paths.append(path) + progress(index + 1, plan["count"]) + return paths + + +def _service(tmp_path: Path, renderer=None): + def workspace_dir(name: str) -> str: + path = Path(tmp_path) / name + path.mkdir(parents=True, exist_ok=True) + return str(path) + + def registry_for(name: str): + return TaskRegistry(workspace_dir(name), interrupt_stale=False) + + return World3DExportService(workspace_dir=workspace_dir, registry_for=registry_for, renderer=renderer) + + +def _client(service, tmp_path: Path) -> TestClient: + app = FastAPI() + app.include_router(create_world3d_export_router(service)) + app.include_router(create_wangp_mcp_router( + handlers=command_handlers(service), command_operations=command_catalog(), + journal_path=str(Path(tmp_path) / "mcp-journal.sqlite"), token_getter=lambda: "test-token", + )) + return TestClient(app) + + +def _wait(registry, task_id, wanted, timeout=8.0): + deadline = time.time() + timeout + while time.time() < deadline: + task = registry.get(task_id) + if task and task["status"] in wanted: + return task + time.sleep(0.02) + raise AssertionError(registry.get(task_id)) + + +def _mcp(client: TestClient, name: str, arguments: dict, request_id=1): + return client.post( + "/api/v1/wangp/mcp", headers={"Authorization": "Bearer test-token"}, + json={"jsonrpc": "2.0", "id": request_id, "method": "tools/call", + "params": {"name": name, "arguments": arguments}}, + ) + + +def test_preflight_rejects_blob_urls_and_unknown_media(): + blob = _command(document=_document(slots=[{ + "id": "subject_1", "slot": "subject_1", "position": [0, 0, 0], "rotationY": 0, + "scale": 1, "sourceUrl": "blob:https://hocus.local/abc", "media": "model3d", "clip": None, + }])) + with pytest.raises(Exception) as error: + freeze_export_command(blob) + assert error.value.status_code == 422 + assert error.value.detail["code"] == "unsupported_capability" + unknown = _command(document=_document(slots=[{ + "id": "subject_1", "slot": "subject_1", "position": [0, 0, 0], "rotationY": 0, + "scale": 1, "sourceUrl": "", "media": "volumetric", "clip": None, + }])) + with pytest.raises(Exception) as error: + freeze_export_command(unknown) + assert error.value.detail["code"] == "unsupported_capability" + + +def test_voiced_duration_is_an_explicit_preflight_reject(): + document = _document(duration=181, sfx=[{"id": "spark", "kind": "sparks", "start": 0, "end": 1, + "sound": True, "volume": 0.4}]) + with pytest.raises(Exception) as error: + freeze_export_command(_command(document=document)) + assert error.value.detail["code"] == "unsupported_capability" + assert "voiced_duration" in error.value.detail["message"] + + +def test_short_voiced_scene_is_rejected_instead_of_silent_mp4(): + document = _document(duration=2, sfx=[{"id": "spark", "kind": "sparks", "start": 0, "end": 1, + "sound": True, "volume": 0.4}]) + assert "voiced_audio" in unsupported_capabilities(document) + with pytest.raises(Exception) as error: + freeze_export_command(_command(document=document)) + assert error.value.status_code == 422 + assert error.value.detail["code"] == "unsupported_capability" + assert "voiced_audio" in error.value.detail["message"] + spoken = _document(slots=[{ + "id": "subject_1", "slot": "subject_1", "position": [0, 0, 0], "rotationY": 0, + "scale": 1, "sourceUrl": "", "media": "model3d", "clip": None, + "speech": {"enabled": True, "audio": {"url": "/api/v1/file/voice.wav", "filename": "voice.wav"}}, + }]) + with pytest.raises(Exception) as error: + freeze_export_command(_command(document=spoken)) + assert error.value.detail["code"] == "unsupported_capability" + + +def test_publish_refuses_a_voiced_snapshot_even_if_preflight_is_bypassed(tmp_path): + service = _service(tmp_path, renderer=_paint) + snapshot = { + "workspace": WORKSPACE, + "document": _document(duration=2, soundtrack=[{"id": "bed", "audio": {"url": "/api/v1/file/bed.wav"}}]), + "refs": [], + "plan": export_plan(_document(duration=2)), + } + frames = [tmp_path / "frame_000001.png"] + write_png(frames[0], 64, 64, (1, 2, 3)) + + class _Token: + def is_cancelled(self): + return False + + with pytest.raises(RuntimeError, match="silent MP4"): + service._publish(snapshot, tmp_path, frames, WORKSPACE, service._registry(WORKSPACE), "task", _Token()) + assert list(Path(service.workspace_dir(WORKSPACE)).glob("*.mp4")) == [] + + +def test_owned_browser_script_uses_scene_clock_and_waits_for_assets(): + assert "world3d-render.html" in _OWNED_BROWSER_JS + assert "window.__world3dExport.load(scene, size)" in _OWNED_BROWSER_JS + assert "window.__world3dExport.frame(seconds)" in _OWNED_BROWSER_JS + assert "/src/" not in _OWNED_BROWSER_JS + + +def test_staging_dir_does_not_treat_dotdot_as_workspace_root(tmp_path): + escaped = staging_dir(str(tmp_path), "..") + assert escaped.resolve().parent == (tmp_path / ".world3d-export").resolve() + assert escaped.name != ".." + assert escaped.resolve() != tmp_path.resolve() + + +def test_admit_freezes_snapshot_as_one_canonical_task(tmp_path): + calls = [] + gate = threading.Event() + service = _service(tmp_path, renderer=lambda *args, **kwargs: _paint(*args, calls=calls, gate=gate, **kwargs)) + result = service.submit(_command()) + assert result["replayed"] is False + receipt = result["receipt"] + assert receipt["operation"] == OPERATION + assert receipt["status"] == "queued" + registry = service._registry(WORKSPACE) + stored = registry.command_admission("world3d-intent-1") + snapshot = stored["effective"]["input"]["snapshot"] + assert snapshot["document"]["templateId"] == "two-shot" + assert snapshot["plan"]["count"] == 2 + task = registry.get(receipt["taskIds"][0]) + assert task["status"] in {"queued", "running"} + assert task["cancelable"] is True + gate.set() + _wait(registry, task["id"], {"completed", "failed"}) + forget_task_registry(registry.workspace_dir) + + +def test_two_retries_of_the_same_intent_do_not_export_twice(tmp_path): + calls = [] + service = _service(tmp_path, renderer=lambda *args, **kwargs: _paint(*args, calls=calls, **kwargs)) + first = service.submit(_command("same-intent")) + second = service.submit(_command("same-intent")) + assert second["replayed"] is True + assert second["receipt"]["taskIds"] == first["receipt"]["taskIds"] + registry = service._registry(WORKSPACE) + task = _wait(registry, first["receipt"]["taskIds"][0], {"completed", "failed"}) + assert task["status"] == "completed" + assert calls == [2] + changed = _command("same-intent", document=_document(duration=3 / 30)) + with pytest.raises(Exception) as error: + service.submit(changed) + assert error.value.status_code == 409 + assert error.value.detail["code"] == "intent_conflict" + assert calls == [2] + + +def test_closing_http_client_does_not_cancel_the_worker(tmp_path): + hold = threading.Event() + service = _service(tmp_path, renderer=lambda *args, **kwargs: _paint(*args, hold=hold, **kwargs)) + client = _client(service, tmp_path) + posted = client.post("/api/v1/scenes/world3d/export", json=_command("ui-close")) + assert posted.status_code == 200, posted.text + task_id = posted.json()["receipt"]["taskIds"][0] + registry = service._registry(WORKSPACE) + live = _wait(registry, task_id, {"queued", "running"}) + assert live["status"] != "cancelled" + hold.set() + finished = _wait(registry, task_id, {"completed", "failed"}) + assert finished["status"] == "completed" + + +def test_cancel_keeps_the_document_and_recoverable_partials(tmp_path): + hold = threading.Event() + started = threading.Event() + + def renderer(snapshot, staging, progress, cancelled): + started.set() + write_png(Path(staging) / "frames" / "frame_000001.png", 64, 64, (1, 2, 3)) + hold.wait(8) + if cancelled(): + raise World3DExportCancelled() + return _paint(snapshot, staging, progress, cancelled) + + service = _service(tmp_path, renderer=renderer) + client = _client(service, tmp_path) + posted = client.post("/api/v1/scenes/world3d/export", json=_command("cancel-me")) + assert posted.status_code == 200, posted.text + started.wait(2) + cancelled = client.post("/api/v1/scenes/world3d/export/cancel", + json={"workspace": WORKSPACE, "intent_id": "cancel-me"}) + assert cancelled.status_code == 200, cancelled.text + hold.set() + registry = service._registry(WORKSPACE) + task = _wait(registry, posted.json()["receipt"]["taskIds"][0], {"cancelled"}) + assert task["status"] == "cancelled" + stored = registry.command_admission("cancel-me") + assert stored["effective"]["input"]["snapshot"]["document"]["slots"][0]["id"] == "subject_1" + staging = staging_dir(registry.workspace_dir, "cancel-me") + assert (staging / "snapshot.json").is_file() + assert (staging / "frames" / "frame_000001.png").is_file() + assert list(Path(registry.workspace_dir).glob("*.mp4")) == [] + + +def test_failure_keeps_document_and_partials_for_retry(tmp_path): + calls = [] + + def renderer(snapshot, staging, progress, cancelled): + calls.append("run") + if len(calls) == 1: + return _paint(snapshot, staging, progress, cancelled, fail=True) + return _paint(snapshot, staging, progress, cancelled) + + service = _service(tmp_path, renderer=renderer) + first = service.submit(_command("retry-fail")) + registry = service._registry(WORKSPACE) + failed = _wait(registry, first["receipt"]["taskIds"][0], {"failed"}) + assert failed["status"] == "failed" + staging = staging_dir(registry.workspace_dir, "retry-fail") + assert json.loads((staging / "snapshot.json").read_text())["document"]["version"] == 1 + assert (staging / "frames" / "frame_000001.png").is_file() + second = service.submit(_command("retry-fail")) + assert second["replayed"] is True + completed = _wait(registry, first["receipt"]["taskIds"][0], {"completed", "failed"}) + assert completed["status"] == "completed" + assert calls == ["run", "run"] + assert completed["result_refs"] + + +def test_http_and_mcp_share_the_same_receipt(tmp_path): + service = _service(tmp_path, renderer=_paint) + client = _client(service, tmp_path) + http = client.post("/api/v1/scenes/world3d/export", json=_command("shared-receipt")) + assert http.status_code == 200, http.text + mcp = _mcp(client, OPERATION, {key: value for key, value in _command("shared-receipt").items() if key != "operation"}) + assert mcp.status_code == 200, mcp.text + payload = mcp.json()["result"] + assert payload["isError"] is False + assert payload["structuredContent"]["receipt"] == http.json()["receipt"] + assert payload["structuredContent"]["replayed"] is True + catalog = client.get("/api/v1/scenes/world3d/export/commands").json() + names = [item["name"] for item in catalog["operations"]] + assert names == [OPERATION, f"{OPERATION}.receipt", f"{OPERATION}.cancel"] + + +def test_mcp_recover_decodable_mp4_or_mark_real_render_pending(tmp_path): + caps = export_capabilities() + service = _service(tmp_path, renderer=_paint if caps["ffmpeg"] else None) + client = _client(service, tmp_path) + admitted = _mcp(client, OPERATION, {key: value for key, value in _command("mcp-recover").items() if key != "operation"}) + assert admitted.status_code == 200, admitted.text + body = admitted.json()["result"]["structuredContent"] + task_id = body["receipt"]["taskIds"][0] + registry = service._registry(WORKSPACE) + if not caps["ffmpeg"]: + task = _wait(registry, task_id, {"failed", "completed"}) + assert caps["realRender"] == "pending" + assert task["status"] == "failed" + viewed = _mcp(client, f"{OPERATION}.receipt", + {"version": 1, "input": {"workspace": WORKSPACE, "intent_id": "mcp-recover"}}, request_id=2) + assert viewed.json()["result"]["structuredContent"]["receipt"]["taskIds"] == [task_id] + return + task = _wait(registry, task_id, {"completed", "failed"}) + assert task["status"] == "completed" + name = task["result_refs"][0] + output = Path(registry.workspace_dir) / name + metadata = probe_scene_recording_output(output) + video = next(item for item in metadata["streams"] if item.get("codec_type") == "video") + assert video["codec_name"] == "h264" + if not caps["playwright"]: + assert caps["realRender"] == "pending" + viewed = _mcp(client, f"{OPERATION}.receipt", + {"version": 1, "input": {"workspace": WORKSPACE, "intent_id": "mcp-recover"}}, request_id=2) + recovered = viewed.json()["result"]["structuredContent"] + assert recovered["task"]["id"] == task_id + assert recovered["task"]["result_refs"] == [name] + + +def test_capabilities_endpoint_matches_worker_preflight(tmp_path): + service = _service(tmp_path, renderer=_paint) + client = _client(service, tmp_path) + listed = client.get("/api/v1/scenes/world3d/export/capabilities").json() + assert listed["renderer"] == "world3d-export-flow" + assert listed["realRender"] in {"ready", "pending"} + assert listed["ffmpeg"] is export_capabilities()["ffmpeg"] + assert listed["maxVoicedDuration"] == 0 + assert listed["fps"] == [24, 30, 60] + + +def test_renderer_origin_uses_socket_address_and_preserves_explicit_configuration(tmp_path): + from routers.world3d_export import bind_world3d_renderer_origin + service = _service(tmp_path, renderer=_paint) + service.app_url = '' + app = FastAPI() + bind_world3d_renderer_origin(app, service) + app.include_router(create_world3d_export_router(service)) + client = TestClient(app, base_url='http://localhost:4192') + response = client.get('/api/v1/scenes/world3d/export/capabilities', headers={'host': 'untrusted.example'}) + assert response.status_code == 200 + assert service.app_url == 'http://127.0.0.1:4192' + service.app_url = 'http://127.0.0.1:8888' + client.get('/api/v1/scenes/world3d/export/capabilities') + assert service.app_url == 'http://127.0.0.1:8888' + + +def test_mux_validates_before_replacing_destination(tmp_path): + if not export_capabilities()["ffmpeg"]: + pytest.skip("ffmpeg is required to validate publication") + folder = tmp_path / "frames" + frames = [] + for index in range(2): + path = folder / f"frame_{index + 1:06d}.png" + write_png(path, 64, 64, (30, 60, 90)) + frames.append(path) + destination = tmp_path / "clip.mp4" + mux_frame_sequence(frames, destination, fps=30, duration=2 / 30) + assert destination.is_file() + video = next(item for item in probe_scene_recording_output(destination)["streams"] + if item.get("codec_type") == "video") + assert video["codec_name"] == "h264" diff --git a/tests/test_world3d_owned_render_smoke.py b/tests/test_world3d_owned_render_smoke.py new file mode 100644 index 000000000..ce7bf1278 --- /dev/null +++ b/tests/test_world3d_owned_render_smoke.py @@ -0,0 +1,73 @@ +"""Explicit CPU Chromium/FFmpeg smoke against the production build. + +RUN_WORLD3D_RENDER_SMOKE=1 pytest -q tests/test_world3d_owned_render_smoke.py +Requires ui build, Node, installed Playwright Chromium, ffmpeg and ffprobe. +""" +import base64 +import functools +import json +import os +from pathlib import Path +import struct +import threading +from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer + +import pytest +from services.scene_recording import probe_scene_recording_output +from services.world3d_export import export_plan, playwright_module, run_owned_browser, mux_frame_sequence +from tests.test_world3d_export import _document + + +def _triangle_glb(): + positions = struct.pack('<9f', -0.8, 0, 0, 0.8, 0, 0, 0, 1.6, 0) + data = {'asset': {'version': '2.0'}, 'scene': 0, 'scenes': [{'nodes': [0]}], 'nodes': [{'mesh': 0}], + 'meshes': [{'primitives': [{'attributes': {'POSITION': 0}, 'material': 0}]}], + 'materials': [{'doubleSided': True, 'emissiveFactor': [1, 0.05, 0.01]}], + 'buffers': [{'byteLength': len(positions), 'uri': 'data:application/octet-stream;base64,' + base64.b64encode(positions).decode()}], + 'bufferViews': [{'buffer': 0, 'byteLength': len(positions)}], + 'accessors': [{'bufferView': 0, 'componentType': 5126, 'count': 3, 'type': 'VEC3', 'min': [-0.8, 0, 0], 'max': [0.8, 1.6, 0]}]} + payload = json.dumps(data).encode() + payload += b' ' * (-len(payload) % 4) + return struct.pack(' + + + + + Action cinema sets + + + +
+

Action cinema sets

+

Loading…

+
+
+
+ + + diff --git a/ui/action-preview.ts b/ui/action-preview.ts new file mode 100644 index 000000000..6ccace50f --- /dev/null +++ b/ui/action-preview.ts @@ -0,0 +1,68 @@ +import { + ACTION_TEMPLATE_IDS, + actionCard, + applyActionTemplate, +} from './src/features/scene3d/actionTemplates.ts' +import { syncDressing } from './src/features/scene3d/dressing.ts' +import { + applyLight, + createWorld, + paintWorld, + placeSlot, + placeholderMesh, + pruneSlots, + renderWorld, + resizeWorld, +} from './src/features/scene3d/gpu.ts' + +const host = document.querySelector('#view') as HTMLDivElement +const caption = document.querySelector('#caption') as HTMLParagraphElement +const strip = document.querySelector('#strip') as HTMLDivElement +const params = new URLSearchParams(location.search) +const startId = ACTION_TEMPLATE_IDS.includes(params.get('shot') as typeof ACTION_TEMPLATE_IDS[number]) + ? params.get('shot') as typeof ACTION_TEMPLATE_IDS[number] + : ACTION_TEMPLATE_IDS[0] + +let current = applyActionTemplate(startId)! +const world = createWorld(host, current.light, current.camera.fov) +resizeWorld(world, host) +mount(current) + +for (const id of ACTION_TEMPLATE_IDS) { + const card = actionCard(id, 'en') + const button = document.createElement('button') + button.type = 'button' + button.textContent = card?.title ?? id + button.dataset.shot = id + button.setAttribute('aria-pressed', id === current.templateId ? 'true' : 'false') + button.addEventListener('click', () => select(id)) + strip.append(button) +} + +function mount(doc: typeof current) { + pruneSlots(world, doc.slots) + syncDressing(world, doc.dressing) + world.floor.visible = false + applyLight(world.dir, doc.light) + for (const slot of doc.slots) placeSlot(world, slot, placeholderMesh(slot), [], 1, true) +} + +function select(id: typeof ACTION_TEMPLATE_IDS[number]) { + current = applyActionTemplate(id)! + mount(current) + caption.textContent = `${actionCard(id, 'en')?.title} · ${current.dressing} · ${current.duration}s` + for (const button of strip.querySelectorAll('button')) { + button.setAttribute('aria-pressed', button.dataset.shot === id ? 'true' : 'false') + } +} + +select(startId) +const started = performance.now() +const tick = (now: number) => { + const seconds = ((now - started) / 1000) % current.duration + paintWorld(world, current, seconds) + renderWorld(world) + requestAnimationFrame(tick) +} +requestAnimationFrame(tick) +window.addEventListener('resize', () => resizeWorld(world, host)) diff --git a/ui/e2e/helpers/apiRoutes.ts b/ui/e2e/helpers/apiRoutes.ts index 9590b8e3d..3f3bc77df 100644 --- a/ui/e2e/helpers/apiRoutes.ts +++ b/ui/e2e/helpers/apiRoutes.ts @@ -354,6 +354,7 @@ function exactCatalog(): Record | { sse: true }> 'GET /api/v1/character-kits/speech/capabilities': json({ rhubarb: true, vocalIsolation: { available: false, model: 'BS-RoFormer', device: 'cpu', downloads: false, maxSeconds: 90, reason: 'optional_model_missing' }, }), + 'GET /api/v1/character-kits/speech/digest': json({ digest: '0'.repeat(64), bytes: 12 }), 'GET /api/v1/system-config': json(SYSTEM_CONFIG), 'GET /api/v1/services-config': json(SERVICES_CONFIG), 'GET /api/v1/llm/status': json({ @@ -369,6 +370,14 @@ function exactCatalog(): Record | { sse: true }> 'GET /api/v1/director/pipelines': json({ pipelines: [], total: 0 }), 'GET /api/v1/director/pipelines/active': json({ pipelines: [] }), 'GET /api/v1/system/preflight': json({ ok: true, checks: [] }), + 'GET /api/v1/system/capabilities': json({ + platform: 'linux', + arch: 'x86_64', + profile: 'linux-nvidia-local', + accelerators: { cuda: true, mps: false, metal: false }, + ui: { mode: 'nvidiaLocal', show_cuda_controls: true }, + capabilities: {}, + }), 'GET /api/v1/system-stats': json(SYSTEM_STATS), 'GET /api/v1/downloads/active': json({ downloads: [] }), 'GET /api/v1/loras/installed': json({ loras: [], manifest_last_check_at: null }), diff --git a/ui/e2e/helpers/speechFlow.ts b/ui/e2e/helpers/speechFlow.ts index e91fe0610..30ed3d6f7 100644 --- a/ui/e2e/helpers/speechFlow.ts +++ b/ui/e2e/helpers/speechFlow.ts @@ -25,6 +25,7 @@ export async function speechApp(page: Page) { } await route.fulfill({ json: library }) }) + await page.route('**/api/v1/character-kits/speech/digest*', route => route.fulfill({ json: { digest: '0'.repeat(64), bytes: 12 } })) await page.route('**/api/v1/character-kits/speech/profiles/**', route => route.fulfill({ status: 404, json: {} })) await page.route('**/api/v1/file/speech-test.glb*', route => route.fulfill({ contentType: 'model/gltf-binary', body: glb })) await page.route('**/api/v1/file/speech-test.wav*', route => route.fulfill({ contentType: 'audio/wav', body: wav })) diff --git a/ui/e2e/helpers/wizardMcpCorpus.ts b/ui/e2e/helpers/wizardMcpCorpus.ts new file mode 100644 index 000000000..0ab97a30b --- /dev/null +++ b/ui/e2e/helpers/wizardMcpCorpus.ts @@ -0,0 +1,215 @@ +import { readFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { expect, type Page } from '@playwright/test' + +export interface WizardMcpCorpusCase { + id: string + lang: 'en' | 'es' + kind: string + surface: 'wizard' | 'mcp' | 'both' + request?: string + proposal?: { reply?: string; actions: unknown[] } + expect: { + action_types?: string[] + forbidden_action_types?: string[] + creates_task?: boolean + promise_success?: boolean + unpublished?: boolean + mcp_is_error?: boolean + reply_must_not_match?: string[] + } +} + +export interface WizardMcpCorpus { + published_operations: string[] + unpublished_operations: string[] + cases: WizardMcpCorpusCase[] +} + +const corpusPath = join(dirname(fileURLToPath(import.meta.url)), '../../../tests/fixtures/wizard_mcp_corpus.json') + +export function loadWizardMcpCorpus(): WizardMcpCorpus { + return JSON.parse(readFileSync(corpusPath, 'utf8')) as WizardMcpCorpus +} + +export function wizardPanel(page: Page) { + return page.locator('.hp-agent-panel') +} + +export async function openWizard(page: Page) { + await page.evaluate(() => window.dispatchEvent(new Event('hocuspocus:wizard-open'))) + const panel = wizardPanel(page) + await expect(panel).toBeVisible() + return panel +} + +export async function mockWizardLlm(page: Page, corpus: WizardMcpCorpus) { + const proposals = Object.fromEntries( + corpus.cases + .filter(item => item.request && item.proposal) + .map(item => [item.request, item.proposal]), + ) + await page.route('**/api/v1/llm/generate', async route => { + if (route.request().method() !== 'POST') { + await route.fallback() + return + } + const body = route.request().postDataJSON() as { prompt?: string } + const prompt = String(body.prompt || '') + const matched = Object.entries(proposals).find(([request]) => prompt.includes(request)) + const proposal = matched?.[1] || { reply: 'No action.', actions: [] } + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ text: JSON.stringify(proposal) }), + }) + }) +} + +export async function mockPublishedCatalog(page: Page, corpus: WizardMcpCorpus) { + const operations = corpus.published_operations.map(name => ({ + name, + version: name === 'generation.image' ? 2 : 1, + mutation: name !== 'generation.receipt', + description: name, + inputSchema: { type: 'object', properties: {}, required: [] }, + })) + const admissions = new Map() + await page.route('**/api/v1/generation/commands', async route => { + const method = route.request().method() + if (method === 'GET') { + await route.fulfill({ json: { version: 2, operations } }) + return + } + if (method === 'POST') { + const body = route.request().postDataJSON() as { operation?: string; intent_id?: string } + if (body.operation === 'generation.video') { + await route.fulfill({ status: 422, json: { detail: { code: 'invalid_command', message: 'Unknown command operation' } } }) + return + } + const intent = String(body.intent_id || 'missing') + const existing = admissions.get(intent) + const job_id = existing?.job_id || `job-${intent}` + const task_id = existing?.task_id || `task-${intent}` + admissions.set(intent, { job_id, task_id, intent_id: intent }) + await route.fulfill({ + json: { + receipt: { + version: 1, + commandId: intent, + operation: body.operation, + status: 'queued', + entities: [], + artifacts: [], + taskIds: [task_id], + pipelineIds: [], + result: { job_id, task_id, workspace: 'default', status: 'queued' }, + }, + replayed: Boolean(existing), + }, + }) + return + } + await route.fallback() + }) + await page.route('**/api/v1/wangp/mcp', async route => { + if (route.request().method() !== 'POST') { + await route.fulfill({ status: 405, json: { detail: 'POST only' } }) + return + } + const message = route.request().postDataJSON() as { + id?: number + method?: string + params?: { name?: string; arguments?: { intent_id?: string; version?: number; input?: { intent_id?: string } } } + } + const auth = route.request().headers()['authorization'] + if (auth !== 'Bearer test-token') { + await route.fulfill({ status: 401, json: { detail: 'Invalid MCP credentials' } }) + return + } + if (message.method === 'tools/list') { + await route.fulfill({ + json: { + jsonrpc: '2.0', + id: message.id, + result: { + tools: [ + ...corpus.published_operations.map(name => ({ name, description: name })), + { name: 'models', description: 'Discover models' }, + ], + }, + }, + }) + return + } + if (message.method === 'tools/call' && message.params?.name === 'generation.video') { + await route.fulfill({ + json: { + jsonrpc: '2.0', + id: message.id, + result: { isError: true, content: [{ type: 'text', text: 'Unknown tool or invalid arguments' }] }, + }, + }) + return + } + if (message.method === 'tools/call' && message.params?.name === 'generation.image') { + const intent = String(message.params.arguments?.intent_id || 'mcp-intent') + const existing = admissions.get(intent) + const job_id = existing?.job_id || `job-${intent}` + const task_id = existing?.task_id || `task-${intent}` + admissions.set(intent, { job_id, task_id, intent_id: intent }) + await route.fulfill({ + json: { + jsonrpc: '2.0', + id: message.id, + result: { + isError: false, + structuredContent: { + receipt: { result: { job_id }, taskIds: [task_id], status: 'queued' }, + replayed: Boolean(existing), + }, + }, + }, + }) + return + } + if (message.method === 'tools/call' && message.params?.name === 'generation.receipt') { + const intent = String(message.params.arguments?.input?.intent_id || '') + const existing = admissions.get(intent) + if (!existing) { + await route.fulfill({ + json: { + jsonrpc: '2.0', + id: message.id, + result: { isError: true, content: [{ type: 'text', text: 'receipt_not_found' }] }, + }, + }) + return + } + await route.fulfill({ + json: { + jsonrpc: '2.0', + id: message.id, + result: { + isError: false, + structuredContent: { receipt: { result: { job_id: existing.job_id }, taskIds: [existing.task_id] } }, + }, + }, + }) + return + } + await route.fulfill({ + json: { jsonrpc: '2.0', id: message.id, result: { isError: true, content: [{ type: 'text', text: 'Unhandled' }] } }, + }) + }) +} + +export async function askWizard(page: Page, request: string) { + const panel = wizardPanel(page) + const input = panel.getByPlaceholder('Ask HocusPocus for a spell…') + await input.fill(request) + await panel.getByRole('button', { name: 'Ask to the Wizard', exact: true }).click() + await expect(input).toBeEnabled({ timeout: 20_000 }) + return (await panel.textContent()) || '' +} diff --git a/ui/e2e/specs/character-mouth-quality.spec.ts b/ui/e2e/specs/character-mouth-quality.spec.ts new file mode 100644 index 000000000..37b613ba2 --- /dev/null +++ b/ui/e2e/specs/character-mouth-quality.spec.ts @@ -0,0 +1,63 @@ +import { expect, test } from '@playwright/test' +import { closeApp, gotoApp } from '../helpers/gotoApp' +import { createCharacterKit, type CharacterKitLibrary } from '../../src/lib/characterKit' + +test('nine-mouth style saves a resting still and exports the shareable collection', async ({ page }) => { + const session = await gotoApp(page) + const baseImage = Buffer.from(await page.evaluate(() => { + const canvas = document.createElement('canvas'); canvas.width = 256; canvas.height = 256 + const context = canvas.getContext('2d')!; context.fillStyle = '#aaaaaa'; context.fillRect(0, 0, 256, 256) + return canvas.toDataURL('image/png').split(',')[1] + }), 'base64') + const kit = createCharacterKit('Resting face fixture') + kit.base = { id: 'base', name: 'Mouthless base', source: '/mouth-quality-base.png', kind: 'image', reviewState: 'approved', alphaStatus: 'opaque' } + kit.identityReference = { ...kit.base } + kit.anchors.base = { mouth: { offsetX: 0, offsetY: 0, scale: .6, rotation: 0 } } + let library: CharacterKitLibrary = { version: 1, revision: 1, activeId: kit.id, kits: { [kit.id]: kit } } + let restingImage: Buffer | undefined + await page.route('**/mouth-quality-base.png', route => route.fulfill({ contentType: 'image/png', body: baseImage })) + await page.route('**/mouth-quality-rest.png', route => route.fulfill({ contentType: 'image/png', body: restingImage! })) + await page.route('**/api/v1/upload', async route => { + const request = route.request() + const body = await new Request('http://test/upload', { method: 'POST', headers: { 'Content-Type': (await request.headerValue('content-type'))! }, body: request.postDataBuffer()! }).formData() + const file = body.get('file') as File + restingImage = Buffer.from(await file.arrayBuffer()) + await route.fulfill({ json: { url: '/mouth-quality-rest.png', filename: 'mouth-quality-rest.png', path: 'mouth-quality-rest.png' } }) + }) + await page.route('**/api/v1/character-kits/library**', async route => { + if (route.request().method() === 'PATCH') { + const body = route.request().postDataJSON() + expect(body.baseRevision).toBe(1) + expect(body.kit.base).toEqual(kit.base) + expect(Object.keys(body.kit.mouth)).toHaveLength(9) + expect(Object.values(body.kit.mouth).every((asset) => (asset as { reviewState: string }).reviewState === 'pending')).toBe(true) + expect(body.kit.restPose.asset.source).toBe('/mouth-quality-rest.png') + expect(body.kit.restPose.asset.reviewState).toBe('pending') + library = { ...library, revision: 2, kits: { [kit.id]: body.kit } } + } + await route.fulfill({ json: library }) + }) + await page.getByRole('tab', { name: 'Character Creator', exact: true }).click() + await page.locator('summary').filter({ hasText: 'Prepare 2D speech' }).click() + const workshop = page.getByRole('region', { name: 'Prepare 2D speech', exact: true }) + await workshop.getByRole('combobox', { name: 'Mouth style pack' }).selectOption('ruby-ink') + await expect(workshop.getByRole('region', { name: 'Existing mouths' }).locator('figure')).toHaveCount(9) + await workshop.getByRole('button', { name: 'Use pack', exact: true }).click() + await workshop.getByRole('button', { name: 'Save speech character', exact: true }).click() + await expect(workshop.getByText(/Character saved to this workspace/)).toBeVisible() + expect(restingImage).toBeDefined() + const pixels = await page.evaluate(async base64 => { + const bitmap = await createImageBitmap(await (await fetch(`data:image/png;base64,${base64}`)).blob()) + const canvas = document.createElement('canvas'); canvas.width = bitmap.width; canvas.height = bitmap.height + const context = canvas.getContext('2d')!; context.drawImage(bitmap, 0, 0); bitmap.close() + return { center: [...context.getImageData(128, 128, 1, 1).data], corner: [...context.getImageData(10, 10, 1, 1).data] } + }, restingImage!.toString('base64')) + expect(pixels.corner).toEqual([170, 170, 170, 255]) + expect(pixels.center).not.toEqual(pixels.corner) + const downloadPromise = page.waitForEvent('download') + await workshop.getByRole('button', { name: 'Download the 20 new styles', exact: true }).click() + const download = await downloadPromise + expect(download.suggestedFilename()).toBe('hocuspocus-20-mouth-styles.zip') + expect(await download.failure()).toBeNull() + await closeApp(page, session) +}) diff --git a/ui/e2e/specs/character-speech-workshop.spec.ts b/ui/e2e/specs/character-speech-workshop.spec.ts index 0db25cbae..972b1de79 100644 --- a/ui/e2e/specs/character-speech-workshop.spec.ts +++ b/ui/e2e/specs/character-speech-workshop.spec.ts @@ -37,8 +37,15 @@ test('Character Creator opens the manual speech workshop and saves reviewed draf const workshop = page.getByRole('region', { name: 'Prepare 2D speech', exact: true }) await expect(workshop).toBeVisible() await expect(workshop.getByRole('combobox', { name: 'Saved character' })).toHaveValue(kit.id) - await expect(workshop.getByText(/Manual workshop:/)).toBeVisible() + await expect(workshop.getByText(/Nothing is generated until you request it/)).toBeVisible() await expect(workshop.getByRole('button', { name: 'Generate / replace Open', exact: true })).toBeDisabled() + await expect(workshop.getByRole('button', { name: 'Blink', exact: true })).toBeHidden() + await workshop.getByRole('button', { name: 'Use pack', exact: true }).click() + await workshop.getByRole('button', { name: 'Play included voice sample' }).click() + const audio = workshop.locator('audio[src="/speech-examples/english-preview.mp3"]') + await expect.poll(() => audio.evaluate(element => (element as HTMLAudioElement).currentTime)).toBeGreaterThan(.3) + await workshop.getByRole('button', { name: 'Stop sample', exact: true }).click() + await expect.poll(() => audio.evaluate(element => (element as HTMLAudioElement).paused)).toBe(true) await workshop.getByRole('button', { name: 'I have reviewed this base image' }).click() await expect(workshop.getByText(/Unsaved changes/)).toBeVisible() await drawer.click() diff --git a/ui/e2e/specs/help.spec.ts b/ui/e2e/specs/help.spec.ts new file mode 100644 index 000000000..4edf0defd --- /dev/null +++ b/ui/e2e/specs/help.spec.ts @@ -0,0 +1,51 @@ +import { expect, test } from '@playwright/test' +import { closeApp, gotoApp } from '../helpers/gotoApp' + +for (const viewport of [{ width: 1280, height: 720 }, { width: 390, height: 844 }]) { + test(`Help loads on demand and supports keyboard navigation at ${viewport.width}px`, async ({ page }) => { + const session = await gotoApp(page) + await page.getByRole('button', { name: 'Close Ask to the Wizard' }).click() + await page.setViewportSize(viewport) + const opener = page.getByRole('button', { name: 'Open the HocusPocus tutorial' }) + await expect(page.getByRole('dialog', { name: 'How to use HocusPocus' })).toHaveCount(0) + await opener.focus() + await page.keyboard.press('Enter') + + const dialog = page.getByRole('dialog', { name: 'How to use HocusPocus' }) + await expect(dialog).toBeVisible() + const language = dialog.getByRole('combobox', { name: 'Tutorial language' }) + await expect(language).toBeFocused() + await page.keyboard.press('Shift+Tab') + await expect(dialog.getByRole('link', { name: 'Example outputs' })).toBeFocused() + await page.keyboard.press('Tab') + await expect(language).toBeFocused() + + for (const image of await dialog.getByRole('img').all()) { + await expect(image).toHaveJSProperty('complete', true) + await expect(image).not.toHaveJSProperty('naturalWidth', 0) + } + const panel = await dialog.locator(':scope > div').boundingBox() + expect(panel).not.toBeNull() + expect(panel!.x).toBeGreaterThanOrEqual(0) + expect(panel!.x + panel!.width).toBeLessThanOrEqual(viewport.width) + expect(panel!.y + panel!.height).toBeLessThanOrEqual(viewport.height) + + await dialog.getByRole('link', { name: 'Example outputs' }).click() + await expect(dialog.getByRole('heading', { name: 'Example outputs from this machine' })).toBeInViewport() + await language.selectOption('es') + const spanishDialog = page.getByRole('dialog', { name: 'Cómo usar HocusPocus' }) + await expect(spanishDialog).toBeVisible() + await expect(page.getByRole('button', { name: 'Abrir el tutorial de HocusPocus' })).toBeVisible() + await page.keyboard.press('Escape') + await expect(spanishDialog).toHaveCount(0) + const spanishOpener = page.getByRole('button', { name: 'Abrir el tutorial de HocusPocus' }) + await expect(spanishOpener).toBeFocused() + + await page.keyboard.press('Enter') + await expect(spanishDialog).toBeVisible() + await spanishDialog.getByRole('button', { name: 'Cerrar ayuda' }).click() + await expect(spanishDialog).toHaveCount(0) + await expect(spanishOpener).toBeFocused() + await closeApp(page, session) + }) +} diff --git a/ui/e2e/specs/production-review.spec.ts b/ui/e2e/specs/production-review.spec.ts new file mode 100644 index 000000000..6a59870ab --- /dev/null +++ b/ui/e2e/specs/production-review.spec.ts @@ -0,0 +1,53 @@ +import { expect, test } from '@playwright/test' +import { closeApp, gotoApp } from '../helpers/gotoApp' + +test('clicking Approve directly from edited notes persists both in order', async ({ page }) => { + const session = await gotoApp(page) + const saved = { + pipeline_id: 'review-click', status: 'completed', pipeline_type: 'short_film', + created_at: '2026-09-12T12:00:00Z', image_model: '', video_model: '', output_files: [], + clips: [{ index: 0, status: 'completed', video_filename: 'review.mp4', + video_prompt: 'Literal prompt', tag: '', review_notes: '', + video_attempts: [{ id: 'review-take', filename: 'review.mp4' }] }], + } + const writes: Array> = [] + let release!: () => void + const firstSave = new Promise(resolve => { release = resolve }) + await page.route('**/api/v1/director/pipelines**', async route => { + const pathname = new URL(route.request().url()).pathname + if (pathname.endsWith('/review')) { + const body = route.request().postDataJSON() + expect(body.workspace).toBe('default') + writes.push(body.commands) + if (writes.length === 1) await firstSave + for (const command of body.commands) { + if (command.type === 'note_clip') saved.clips[0].review_notes = command.notes + if (command.type === 'tag_clip') saved.clips[0].tag = command.tag + } + await route.fulfill({ json: saved }) + } else if (pathname.endsWith('/review-click')) { + await route.fulfill({ json: saved }) + } else if (pathname.endsWith('/pipelines')) { + await route.fulfill({ json: { total: 1, pipelines: [{ id: saved.pipeline_id, + ...saved, clip_count: 1, scene_description: 'Review click regression' }] } }) + } else await route.fallback() + }) + await page.route('**/api/v1/outputs/review.mp4*', route => route.fulfill({ status: 204 })) + try { + await page.getByRole('button', { name: 'Production', exact: true }).click() + await page.getByRole('tab', { name: 'Productions', exact: true }).click() + await page.locator('summary').filter({ hasText: 'Production review' }).click() + const review = page.getByRole('region', { name: 'Production review', exact: true }) + await review.getByRole('textbox', { name: 'Notes', exact: true }).fill('Keep this take') + // Native pointer events: blur starts the first save before the click lands. + await review.getByRole('button', { name: 'Approve', exact: true }).click() + await expect.poll(() => writes.length).toBe(1) + release() + await expect.poll(() => writes.length).toBe(2) + await expect.poll(() => saved.clips[0].tag).toBe('good') + expect(saved.clips[0].review_notes).toBe('Keep this take') + expect(writes[0]).toContainEqual(expect.objectContaining({ type: 'note_clip', notes: 'Keep this take' })) + expect(writes[1]).toContainEqual(expect.objectContaining({ type: 'tag_clip', tag: 'good' })) + } finally { release() } + await closeApp(page, session) +}) diff --git a/ui/e2e/specs/scene3d-editor-controls.spec.ts b/ui/e2e/specs/scene3d-editor-controls.spec.ts index 6889037ab..8d6935d9b 100644 --- a/ui/e2e/specs/scene3d-editor-controls.spec.ts +++ b/ui/e2e/specs/scene3d-editor-controls.spec.ts @@ -37,8 +37,8 @@ test('3D templates, playback speed and object transforms work in the editor', as // Drag the real X handle in the WebGL viewport, then verify the document field. const scene = applyScene3DTemplate('two-shot') - const canvas = workspace.locator('canvas').first() const viewport = workspace.getByRole('region', { name: '3D scene', exact: true }) + const canvas = viewport.locator('canvas[data-engine]') await viewport.focus() await page.keyboard.press('r') await expect(workspace.getByRole('button', { name: 'Rotate Y', exact: true })).toHaveAttribute('aria-pressed', 'true') diff --git a/ui/e2e/specs/scene3d-media-screen.spec.ts b/ui/e2e/specs/scene3d-media-screen.spec.ts index b7b2ccee9..1917a6118 100644 --- a/ui/e2e/specs/scene3d-media-screen.spec.ts +++ b/ui/e2e/specs/scene3d-media-screen.spec.ts @@ -31,7 +31,7 @@ test('a screen upload, dimensions and fit survive saving and reopening the shot' expect(slot.screen.sourceRef).toMatchObject({ filename: 'media-screen-test.png', url }) await controls.getByRole('button', { name: 'Remove screen', exact: true }).click() await expect(workspace.getByRole('button', { name: 'Remove screen', exact: true })).toHaveCount(0) - await workspace.locator('input[type=file][accept=".json,application/json"]').setInputFiles({ name: 'screen.world3d.json', mimeType: 'application/json', buffer: await readFile(path) }) + await workspace.getByLabel('Open shot JSON').setInputFiles({ name: 'screen.world3d.json', mimeType: 'application/json', buffer: await readFile(path) }) await expect(controls.getByLabel('Content fit', { exact: true })).toHaveValue('cover') await expect(controls.getByLabel('Screen style', { exact: true })).toHaveValue('billboard') await expect(controls.getByLabel('Width / aspect', { exact: true })).toHaveValue('8') diff --git a/ui/e2e/specs/series-character-save.spec.ts b/ui/e2e/specs/series-character-save.spec.ts new file mode 100644 index 000000000..2446944b8 --- /dev/null +++ b/ui/e2e/specs/series-character-save.spec.ts @@ -0,0 +1,61 @@ +import { readFileSync } from 'node:fs' +import { expect, test } from '@playwright/test' +import { closeApp, gotoApp } from '../helpers/gotoApp' +import { createCharacterKit, type CharacterKitLibrary } from '../../src/lib/characterKit' +import type { SeriesLibrary, SeriesProject } from '../../src/features/series/types' + +test('Series saves voice and pending mouths together, returns to the character and opens the next one', async ({ page }) => { + const session = await gotoApp(page) + let series: SeriesProject = (JSON.parse(readFileSync(new URL('../../../docs/series-lab/example-series-library-v1.json', import.meta.url), 'utf8')) as SeriesLibrary).seriesById.series_signal + const kit = createCharacterKit(series.characters[0].name) + kit.base = { id: 'base', name: 'Base', source: '/save-fixture.svg', kind: 'image', alphaStatus: 'transparent', reviewState: 'approved' } + let library: CharacterKitLibrary = { version: 1, revision: 1, activeId: kit.id, kits: { [kit.id]: kit } } + series = { ...series, characters: series.characters.map((character, index) => ({ ...character, referenceAssetIds: [], primaryReferenceAssetId: undefined, + voiceProfile: index === 0 ? { characterKitRef: { workspace: 'default', id: kit.id } } : {} })) } + let writes = 0 + await page.route('**/save-fixture.svg', route => route.fulfill({ contentType: 'image/svg+xml', body: '' })) + await page.route('**/api/v1/series/**', async route => { + const request = route.request(), path = new URL(request.url()).pathname + if (request.method() === 'PUT') { + const body = request.postDataJSON() + expect(body.baseRevision).toBe(series.revision) + series = { ...body.series, revision: series.revision + 1 } + } + const body = path.endsWith('/library') ? { schemaVersion: 1, workspace: 'default', seriesOrder: [series.id], seriesById: { [series.id]: series } } + : path.endsWith('/recovery') ? { jobs: [] } : series + await route.fulfill({ json: body }) + }) + await page.route('**/api/v1/character-kits/library**', async route => { + const request = route.request() + if (request.method() === 'PATCH') { + const body = request.postDataJSON() + expect(body.baseRevision).toBe(library.revision) + expect(body.kit.voice.voiceId).toBe('serena') + expect(Object.keys(body.kit.mouth).sort()).toEqual([ + 'bite', 'closed', 'medium', 'pressed', 'pucker', 'round', 'small', 'tongue', 'wide', + ]) + expect(Object.values(body.kit.mouth).every(mouth => (mouth as { reviewState: string }).reviewState === 'pending')).toBe(true) + library = { ...library, revision: library.revision + 1, kits: { ...library.kits, [kit.id]: body.kit } } + writes++ + } + await route.fulfill({ json: library }) + }) + await page.getByRole('tab', { name: 'Series Lab', exact: true }).click() + await page.getByRole('button', { name: '2 · Bible', exact: true }).click() + await page.getByRole('button', { name: 'Characters', exact: true }).click() + const first = page.getByRole('region', { name: `Voice and lip sync for ${series.characters[0].name}` }) + await first.getByRole('button', { name: 'Configure in Character Creator' }).click() + await page.getByRole('button', { name: 'Configure 2D mouth and lip sync' }).click() + const workshop = page.getByRole('region', { name: 'Prepare 2D speech', exact: true }) + await workshop.getByRole('button', { name: 'Use pack', exact: true }).click() + await page.getByTestId('character-voice').selectOption('serena') + await page.getByRole('button', { name: 'Save everything and return to Series Lab' }).click() + await expect(first).toBeVisible() + expect(writes).toBe(1) + expect(series.characters[0].voiceProfile?.characterKitRef?.id).toBe(kit.id) + const second = page.getByRole('region', { name: `Voice and lip sync for ${series.characters[1].name}` }) + await second.getByRole('button', { name: 'Configure in Character Creator' }).click() + await expect(page.getByTestId('character-name')).toHaveValue(series.characters[1].name) + expect(writes).toBe(1) + await closeApp(page, session) +}) diff --git a/ui/e2e/specs/series-shot-updates.spec.ts b/ui/e2e/specs/series-shot-updates.spec.ts new file mode 100644 index 000000000..9d5d37978 --- /dev/null +++ b/ui/e2e/specs/series-shot-updates.spec.ts @@ -0,0 +1,64 @@ +import { readFileSync } from 'node:fs' +import { expect, test } from '@playwright/test' +import { closeApp, gotoApp } from '../helpers/gotoApp' +import { createCharacterKit, type CharacterKitLibrary } from '../../src/lib/characterKit' +import type { SeriesLibrary } from '../../src/features/series/types' + +test('approved old takes expose draft regeneration and link directly to missing character assets', async ({ page }) => { + const session = await gotoApp(page) + const series = (JSON.parse(readFileSync(new URL('../../../docs/series-lab/example-series-library-v1.json', import.meta.url), 'utf8')) as SeriesLibrary).seriesById.series_signal + series.allowedProductionMethods = ['animation_2d'] + const episode = Object.values(series.episodesById)[0] + const template = episode.shots[0] + const library: CharacterKitLibrary = { version: 1, revision: 1, activeId: '', kits: {} } + for (const [index, character] of series.characters.entries()) { + const kit = createCharacterKit(character.name) + kit.base = { id: 'base', name: 'Base', source: '/fixture-body.svg', kind: 'image', alphaStatus: 'transparent', reviewState: 'pending' } + if (index !== 0) kit.base = undefined + kit.anchors.base = { mouth: { offsetX: 0, offsetY: -20, scale: .08, rotation: 0 } } + for (const state of ['closed', 'small', 'wide', 'round', 'pressed', 'medium', 'pucker', 'bite', 'tongue'] as const) kit.mouth[state] = { + id: state, name: state, source: `/character-kit-presets/mouths/minimal-line/${state}.png`, kind: 'overlay', alphaStatus: 'transparent', reviewState: 'approved', + } + library.kits[kit.id] = kit + character.voiceProfile = { characterKitRef: { workspace: 'default', id: kit.id } } + } + series.assets.video = { ...Object.values(series.assets)[0], id: 'video', kind: 'video', uri: 'fixture.mp4', + metadata: { productionMethod: 'animation_2d', sceneFilename: 'fixture.json' } } + series.assets['saved-master'] = { ...series.assets.video, id: 'saved-master', uri: 'fixture-master.mp4', + ownerType: 'episode', ownerId: episode.id } + episode.latestAssemblyAssetId = 'saved-master' + episode.shots = series.characters.map((character, index) => ({ ...template, id: `shot-${index}`, order: index + 1, + productionMethod: 'animation_2d', approvedAttemptId: `take-${index}`, visibleCharacterIds: [character.id], + dialogueBeats: [{ ...template.dialogueBeats[0], id: `beat-${index}`, characterId: character.id, text: 'Hello.' }], + attempts: [{ ...template.attempts[0], id: `take-${index}`, status: 'completed', outputAssetIds: ['video'] }] })) + await page.route('**/api/v1/series/**', async route => { + expect(route.request().method()).toBe('GET') + const path = new URL(route.request().url()).pathname + await route.fulfill({ json: path.endsWith('/library') + ? { schemaVersion: 1, workspace: 'default', seriesOrder: [series.id], seriesById: { [series.id]: series } } + : path.endsWith('/recovery') ? { jobs: [] } : series }) + }) + await page.route('**/api/v1/character-kits/library**', route => route.fulfill({ json: library })) + await page.route('**/fixture-body.svg', route => route.fulfill({ contentType: 'image/svg+xml', body: '' })) + await page.getByRole('tab', { name: 'Series Lab', exact: true }).click() + await page.getByRole('button', { name: '5 · Results', exact: true }).click() + await expect(page.getByRole('link', { name: 'Watch full episode' })).toHaveAttribute('href', /fixture-master\.mp4\?workspace=default/) + await expect(page.getByRole('link', { name: 'Download joined episode' })).toBeVisible() + await page.getByRole('button', { name: '4 · Shots', exact: true }).click() + const panel = page.getByRole('region', { name: '2D shots', exact: true }) + await expect(panel.getByText('2 2D shots already have video.')).toBeVisible() + await expect(panel.getByRole('button', { name: /^Generate all/ })).toHaveCount(0) + await expect(panel.getByRole('button', { name: 'Regenerate all (1)' })).toBeEnabled() + await expect(panel.getByText(/preserves recorded audio and edits/)).toBeVisible() + await expect(panel.getByText(/Save a valid base image/)).toBeVisible() + await expect(page.getByRole('button', { name: 'Regenerate this shot', exact: true })).toHaveCount(2) + await expect(page.getByRole('button', { name: 'Render selection (0)', exact: true })).toHaveCount(0) + await expect(page.getByRole('combobox', { name: /Shot 1 method/ })).toBeHidden() + await page.getByText('Edit shot', { exact: true }).first().click() + await expect(page.getByRole('combobox', { name: 'Shot 1 method', exact: true })).toBeVisible() + await page.getByText('Production settings', { exact: true }).click() + await expect(page.getByRole('checkbox', { name: /2D animation/ })).toBeVisible() + await panel.getByRole('button', { name: series.characters[1].name, exact: true }).click() + await expect(page.getByTestId('character-name')).toHaveValue(series.characters[1].name) + await closeApp(page, session) +}) diff --git a/ui/e2e/specs/tools-background-removal.spec.ts b/ui/e2e/specs/tools-background-removal.spec.ts index 63270557e..14c0adcc3 100644 --- a/ui/e2e/specs/tools-background-removal.spec.ts +++ b/ui/e2e/specs/tools-background-removal.spec.ts @@ -82,7 +82,7 @@ test('runs Remove Background from direct Tools and exposes the derived asset', a await page.getByRole('button', { name: 'Remove background', exact: true }).click() await page.getByRole('button', { name: 'Clear', exact: true }).click() await chooseLibraryFile(page, 'hero-no-background.png') - await expect(page.locator('aside').getByRole('img', { name: 'hero-no-background.png', exact: true })).toBeVisible() + await expect(page.getByTestId('direct-generation-workspace').getByRole('img', { name: 'hero-no-background.png', exact: true })).toBeVisible() } finally { await closeApp(page, session) } diff --git a/ui/e2e/specs/wizard-mcp-corpus.spec.ts b/ui/e2e/specs/wizard-mcp-corpus.spec.ts new file mode 100644 index 000000000..8a6bf2546 --- /dev/null +++ b/ui/e2e/specs/wizard-mcp-corpus.spec.ts @@ -0,0 +1,162 @@ +import { mkdirSync } from 'node:fs' +import { expect, test, type Page } from '@playwright/test' +import { closeApp, gotoApp } from '../helpers/gotoApp' +import { + askWizard, + loadWizardMcpCorpus, + mockPublishedCatalog, + mockWizardLlm, + openWizard, + wizardPanel, +} from '../helpers/wizardMcpCorpus' + +const corpus = loadWizardMcpCorpus() +const evidenceDir = process.env.HOCUS_WIZARD_MCP_EVIDENCE || '' + +async function snap(page: Page, name: string) { + const dest = test.info().outputPath(name) + await page.screenshot({ path: dest, fullPage: true }) + if (evidenceDir) { + mkdirSync(evidenceDir, { recursive: true }) + await page.screenshot({ path: `${evidenceDir}/${name}`, fullPage: true }) + } +} + +test('Wizard refusal does not POST a generation command', async ({ page }) => { + const session = await gotoApp(page) + const posts: string[] = [] + page.on('request', request => { + if (request.method() === 'POST' && new URL(request.url()).pathname === '/api/v1/generation/commands') { + posts.push(request.postData() || '') + } + }) + try { + await mockPublishedCatalog(page, corpus) + await mockWizardLlm(page, corpus) + const panel = await openWizard(page) + await snap(page, '01-wizard-open.png') + const refusal = corpus.cases.find(item => item.id === 'en-negation-do-not-generate') + const transcript = await askWizard(page, refusal!.request!) + await expect(panel).toContainText(/No action was executed|Actions not executed|No se ha ejecutado/) + expect(transcript).not.toContain('invented-boat.png') + expect(posts).toEqual([]) + await snap(page, '02-wizard-refusal.png') + } finally { + await closeApp(page, session) + } +}) + +test('unpublished Wizard tool shows a rejection instead of success', async ({ page }) => { + const session = await gotoApp(page) + try { + await mockPublishedCatalog(page, corpus) + await mockWizardLlm(page, corpus) + const panel = await openWizard(page) + const unpublished = corpus.cases.find(item => item.id === 'es-unpublished-unknown-action') + const transcript = await askWizard(page, unpublished!.request!) + await expect(panel).toContainText('Actions not executed') + await expect(panel).toContainText('generation_video') + expect(transcript).not.toMatch(/invented\.mp4/) + await snap(page, '03-wizard-unpublished.png') + } finally { + await closeApp(page, session) + } +}) + +test('queued receipt stays queued and is not a finished movie', async ({ page }) => { + const session = await gotoApp(page) + const messages = [ + { id: 'user-1', role: 'user', text: 'Prepare a Flux image of a lantern and generate it now.', createdAt: 1 }, + { + id: 'asst-1', + role: 'assistant', + text: '### Execution results\n- **Queued.** Submitted job-corpus-1. Task task-corpus-1.', + createdAt: 2, + }, + ] + try { + await page.addInitScript(values => { + localStorage.setItem('hocuspocus-agent-chat-v2:default', JSON.stringify(values)) + localStorage.setItem('hocuspocus-wizard-sidebar-collapsed', 'false') + }, messages) + await page.reload() + await openWizard(page) + const panel = wizardPanel(page) + await expect(panel).toContainText('Queued') + await expect(panel).toContainText('job-corpus-1') + await expect(panel).not.toContainText('invented.mp4') + await snap(page, '04-wizard-queued-receipt.png') + } finally { + await closeApp(page, session) + } +}) + +test('MCP client tour: published tools, replayed ID, unpublished error', async ({ page }) => { + const session = await gotoApp(page) + try { + await mockPublishedCatalog(page, corpus) + await mockWizardLlm(page, corpus) + await openWizard(page) + const report = await page.evaluate(async published => { + const headers = { Authorization: 'Bearer test-token', 'Content-Type': 'application/json' } + const listed = await (await fetch('/api/v1/wangp/mcp', { + method: 'POST', headers, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list' }), + })).json() + const tools = (listed.result.tools as Array<{ name: string }>).map(item => item.name) + const command = { + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { + name: 'generation.image', + arguments: { + version: 1, + intent_id: 'e2e-timeout', + input: { workspace: 'default', model_type: 'pi_flux2', prompt: 'a lantern', resolution: '512x512', num_inference_steps: 1, seed: 1, guidance_scale: 1 }, + }, + }, + } + const first = await (await fetch('/api/v1/wangp/mcp', { method: 'POST', headers, body: JSON.stringify(command) })).json() + const second = await (await fetch('/api/v1/wangp/mcp', { method: 'POST', headers, body: JSON.stringify({ ...command, id: 3 }) })).json() + const unpublished = await (await fetch('/api/v1/wangp/mcp', { + method: 'POST', headers, + body: JSON.stringify({ + jsonrpc: '2.0', id: 4, method: 'tools/call', + params: { name: 'generation.video', arguments: { version: 2, intent_id: 'e2e-video', input: { workspace: 'default' } } }, + }), + })).json() + const denied = await fetch('/api/v1/wangp/mcp', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ jsonrpc: '2.0', id: 5, method: 'tools/list' }), + }) + return { + tools, + published: published.every((name: string) => tools.includes(name)), + videoListed: tools.includes('generation.video'), + firstId: first.result.structuredContent.receipt.result.job_id, + secondId: second.result.structuredContent.receipt.result.job_id, + replayed: second.result.structuredContent.replayed, + unpublishedError: unpublished.result.isError, + unauthorized: denied.status, + } + }, corpus.published_operations) + expect(report.published).toBe(true) + expect(report.videoListed).toBe(false) + expect(report.firstId).toBe(report.secondId) + expect(report.replayed).toBe(true) + expect(report.unpublishedError).toBe(true) + expect(report.unauthorized).toBe(401) + await page.evaluate(result => { + const pre = document.createElement('pre') + pre.dataset.testid = 'mcp-corpus-report' + pre.textContent = JSON.stringify(result, null, 2) + document.body.appendChild(pre) + }, report) + await expect(page.locator('[data-testid="mcp-corpus-report"]')).toContainText(report.firstId) + await snap(page, '05-mcp-client-tour.png') + } finally { + await closeApp(page, session) + } +}) diff --git a/ui/face-pack-maker.html b/ui/face-pack-maker.html new file mode 100644 index 000000000..26f626664 --- /dev/null +++ b/ui/face-pack-maker.html @@ -0,0 +1,52 @@ + + + + + + Face pack maker + + + +
+

Generar un face pack (plano de cubo)

+

1) Copia el prompt de reposo al generador de imagen (1:1). 2) Edita esa still: solo boca (visemas) o solo ojos (expresiones). 3) Suelta los PNG aquí y descarga el 9×6. La herramienta ancla el color de piel al reposo.

+
+
+ + +

Reposo (canónico)

+ + +
+
+
+

Stills

+

Nombres: rest.png, A.pnghappy.png. Faltantes se alias (I=E, U=O, F=M, L=A). Neutral = rest.

+ +
+

+ + +

+
+ + + diff --git a/ui/face-pack-maker.ts b/ui/face-pack-maker.ts new file mode 100644 index 000000000..70f7fc916 --- /dev/null +++ b/ui/face-pack-maker.ts @@ -0,0 +1,157 @@ +import { EXPRESSIONS, VISEMES, type Expression, type Viseme } from './src/features/scene3d/speech/types.ts' +import { + EXPRESSION_EYES, + FACE_PLANE_REST_PROMPT, + VISEME_ALIASES, + VISEME_MOUTHS, + expressionPrompt, + fillFacePrompt, + parseFacePackStillName, + visemePrompt, +} from './src/features/scene3d/speech/facePackPrompts.ts' +import { composeFacePack } from './src/features/scene3d/speech/facePackAssemble.ts' + +const skinInput = document.querySelector('#skin') as HTMLInputElement +const restBox = document.querySelector('#rest-prompt') as HTMLTextAreaElement +const promptList = document.querySelector('#prompt-list') as HTMLDivElement +const slotsEl = document.querySelector('#slots') as HTMLDivElement +const status = document.querySelector('#status') as HTMLParagraphElement +const preview = document.querySelector('#preview') as HTMLCanvasElement +const downloadBtn = document.querySelector('#download') as HTMLButtonElement +const files = document.querySelector('#files') as HTMLInputElement +const stills = new Map() + +function skin() { + return skinInput.value.trim() || 'cream skin' +} + +function renderPrompts() { + restBox.value = fillFacePrompt(FACE_PLANE_REST_PROMPT, skin()) + promptList.replaceChildren() + for (const viseme of VISEMES) { + if (viseme === 'rest') continue + const alias = VISEME_ALIASES[viseme] + const block = document.createElement('div') + const area = document.createElement('textarea') + area.id = `p-${viseme}` + area.readOnly = true + area.value = alias ? `Alias of ${alias}. Optional. ${visemePrompt(viseme, skin())}` : visemePrompt(viseme, skin()) + const btn = document.createElement('button') + btn.type = 'button' + btn.textContent = `Copiar ${viseme}` + btn.dataset.copy = area.id + const label = document.createElement('label') + label.textContent = `Visema ${viseme} — ${VISEME_MOUTHS[viseme as Exclude].slice(22)}` + block.append(label, area, btn) + promptList.append(block) + } + for (const expression of EXPRESSIONS) { + if (expression === 'neutral') continue + const block = document.createElement('div') + const area = document.createElement('textarea') + area.id = `p-${expression}` + area.readOnly = true + area.value = expressionPrompt(expression, skin()) + const btn = document.createElement('button') + btn.type = 'button' + btn.textContent = `Copiar ${expression}` + btn.dataset.copy = area.id + const label = document.createElement('label') + label.textContent = `Expresión ${expression} — ${EXPRESSION_EYES[expression as Exclude].slice(40)}` + block.append(label, area, btn) + promptList.append(block) + } +} + +function drawSlot(key: string, img?: HTMLImageElement) { + let slot = document.querySelector(`[data-slot="${key}"]`) as HTMLDivElement | null + if (!slot) { + slot = document.createElement('div') + slot.className = 'slot' + slot.dataset.slot = key + slotsEl.append(slot) + } + slot.replaceChildren() + const title = document.createElement('strong') + title.textContent = key + slot.append(title) + if (img) { + const previewImg = document.createElement('img') + previewImg.src = img.src + previewImg.alt = key + slot.append(previewImg) + } +} + +function ensureSlots() { + drawSlot('rest', stills.get('rest')) + for (const viseme of VISEMES) { + if (viseme === 'rest') continue + drawSlot(viseme, stills.get(viseme)) + } + for (const expression of EXPRESSIONS) { + if (expression === 'neutral') continue + drawSlot(expression, stills.get(expression)) + } +} + +function build() { + const rest = stills.get('rest') + if (!rest) { + status.textContent = 'Falta rest.png' + return + } + const visemes: Partial> = {} + const expressions: Partial> = {} + for (const viseme of VISEMES) { + if (viseme !== 'rest' && stills.get(viseme)) visemes[viseme] = stills.get(viseme) + } + for (const expression of EXPRESSIONS) { + if (expression !== 'neutral' && stills.get(expression)) expressions[expression] = stills.get(expression) + } + const canvas = composeFacePack({ rest, visemes, expressions }) + const ctx = preview.getContext('2d') + if (!ctx) return + ctx.clearRect(0, 0, preview.width, preview.height) + ctx.drawImage(canvas, 0, 0, preview.width, preview.height) + downloadBtn.disabled = false + status.textContent = '9×6 listo. Color anclado al reposo.' +} + +function loadFile(file: File) { + const parsed = parseFacePackStillName(file.name) + if (!parsed) { + status.textContent = `Nombre no reconocido: ${file.name}` + return + } + const img = new Image() + img.onload = () => { + stills.set(parsed.kind === 'rest' ? 'rest' : parsed.id, img) + ensureSlots() + status.textContent = `Cargado ${parsed.kind === 'rest' ? 'rest' : parsed.id}` + } + img.src = URL.createObjectURL(file) +} + +renderPrompts() +ensureSlots() +skinInput.addEventListener('input', renderPrompts) +document.addEventListener('click', event => { + const btn = (event.target as HTMLElement).closest('button[data-copy]') as HTMLButtonElement | null + if (!btn?.dataset.copy) return + const area = document.getElementById(btn.dataset.copy) as HTMLTextAreaElement | null + if (area) void navigator.clipboard.writeText(area.value) +}) +files.addEventListener('change', () => { + for (const file of files.files ?? []) loadFile(file) +}) +document.querySelector('#build')!.addEventListener('click', build) +downloadBtn.addEventListener('click', () => { + preview.toBlob(blob => { + if (!blob) return + const a = document.createElement('a') + a.href = URL.createObjectURL(blob) + a.download = 'pack.png' + a.click() + }) +}) diff --git a/ui/face-pack-preview.html b/ui/face-pack-preview.html new file mode 100644 index 000000000..14391473a --- /dev/null +++ b/ui/face-pack-preview.html @@ -0,0 +1,67 @@ + + + + + + Experimental face pack lipsync + + + +
+

Experimental mascot lipsync

+

Loading…

+

+
+
+
+ +
+
+

Video previews

+

8 s each, synthetic vowels. Hangar/sea keep CRT + skull; roof is the cube-head and voxel skull.

+
+
+ +
Hangar talk
+
+
+ +
Sea talk
+
+
+ +
Voxel talk
+
+
+ +
Felt + clay
+
+
+ +
Pumpkin + oni
+
+
+ +
Cat + alien
+
+
+
+ + + diff --git a/ui/face-pack-preview.ts b/ui/face-pack-preview.ts new file mode 100644 index 000000000..e51ef3860 --- /dev/null +++ b/ui/face-pack-preview.ts @@ -0,0 +1,160 @@ +import { applyActionTemplate } from './src/features/scene3d/actionTemplates.ts' +import { syncDressing } from './src/features/scene3d/dressing.ts' +import { + applyLight, + createWorld, + fitGltf, + paintWorld, + placeSlot, + placeholderMesh, + pruneSlots, + renderWorld, + resizeWorld, +} from './src/features/scene3d/gpu.ts' +import { FACE_PACK_IDS, FACE_PACKS, talkingMascot, type FacePackId } from './src/features/scene3d/speech/facePackExamples.ts' +import { expressionAt, mouthAt } from './src/features/scene3d/speech/track.ts' +import { VISEMES } from './src/features/scene3d/speech/types.ts' +import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js' + +const SHOTS = ['hangar-talk', 'sea-talk', 'voxel-talk', 'felt-talk', 'pumpkin-talk', 'cat-talk'] as const +const LABELS: Record = { + 'hangar-talk': 'Hangar · CRT + skull', + 'sea-talk': 'Sea · CRT + skull', + 'voxel-talk': 'Roof · cube + voxel skull', + 'felt-talk': 'Hangar · felt + clay', + 'pumpkin-talk': 'Roof · pumpkin + oni', + 'cat-talk': 'Hangar · cat + alien', +} + +function remix(base: 'hangar-talk' | 'voxel-talk', left: FacePackId, right: FacePackId) { + const doc = applyActionTemplate(base)! + const [lead, reply] = doc.slots + return { + ...doc, + slots: [ + talkingMascot(lead.id, lead.slot, lead.position, left, { rotationY: lead.rotationY, scale: lead.scale, motion: lead.motion }), + talkingMascot(reply.id, reply.slot, reply.position, right, { rotationY: reply.rotationY, scale: reply.scale, motion: reply.motion }), + ], + } +} + +function shotDocument(id: typeof SHOTS[number]) { + if (id === 'felt-talk') return remix('hangar-talk', 'felt', 'clay') + if (id === 'pumpkin-talk') return remix('voxel-talk', 'pumpkin', 'oni') + if (id === 'cat-talk') return remix('hangar-talk', 'cat', 'alien') + return applyActionTemplate(id)! +} +const host = document.querySelector('#view') as HTMLDivElement +const caption = document.querySelector('#caption') as HTMLParagraphElement +const status = document.querySelector('#status') as HTMLParagraphElement +const strip = document.querySelector('#strip') as HTMLDivElement +const atlas = document.querySelector('#atlas') as HTMLDivElement +const voice = document.querySelector('#voice') as HTMLAudioElement +const params = new URLSearchParams(location.search) +const startId = SHOTS.includes(params.get('shot') as typeof SHOTS[number]) ? params.get('shot') as typeof SHOTS[number] : 'hangar-talk' +const freezeParam = params.get('t') +const freeze = freezeParam === null || freezeParam === '' ? Number.NaN : Number(freezeParam) + +let current = shotDocument(startId) +let playing = false +let started = 0 +const driven: { seconds: number | null } = { seconds: null } +const clock = { + setSceneSeconds(value: number | null) { driven.seconds = value }, + sceneReady: false, + viseme() { + const slot = current.slots[0] + return slot.speech ? VISEMES[mouthAt(slot.speech, sceneSeconds()).b] : 'rest' + }, +} +Object.assign(window, { facePack: clock }) + +function sceneSeconds(now = performance.now()) { + if (driven.seconds != null) return driven.seconds + if (Number.isFinite(freeze)) return Math.max(0, Math.min(current.duration - 0.01, freeze)) + return playing ? ((now - started) / 1000) % current.duration : 0 +} +const world = createWorld(host, current.light, current.camera.fov) +const loader = new GLTFLoader() +resizeWorld(world, host) + +for (const id of SHOTS) { + const button = document.createElement('button') + button.type = 'button' + button.textContent = LABELS[id] + button.dataset.shot = id + button.setAttribute('aria-pressed', id === startId ? 'true' : 'false') + button.addEventListener('click', () => select(id)) + strip.append(button) +} + +for (const id of FACE_PACK_IDS) { + const figure = document.createElement('figure') + const img = document.createElement('img') + img.src = FACE_PACKS[id].url + img.alt = `${id} visemes and expressions` + const cap = document.createElement('figcaption') + cap.textContent = `${id} · 9 visemes × 6 expressions` + figure.append(img, cap) + atlas.append(figure) +} + +function mount(doc: typeof current) { + pruneSlots(world, doc.slots) + syncDressing(world, doc.dressing) + world.floor.visible = false + applyLight(world.dir, doc.light) + playing = false + clock.sceneReady = false + let pending = 0 + for (const slot of doc.slots) { + placeSlot(world, slot, placeholderMesh(slot), [], 1, false) + if (!slot.sourceUrl) continue + pending++ + loader.load(slot.sourceUrl, gltf => { + if (current.slots.find(item => item.id === slot.id)?.sourceUrl !== slot.sourceUrl) { + gltf.scene.removeFromParent() + return + } + const baseScale = fitGltf(gltf.scene, slot) + placeSlot(world, slot, gltf.scene, gltf.animations, baseScale, true) + pending-- + if (pending === 0) { + clock.sceneReady = true + if (!Number.isFinite(freeze) && driven.seconds == null) { + playing = true + started = performance.now() + voice.currentTime = 0 + void voice.play().catch(() => undefined) + } + } + }) + } +} + +function select(id: typeof SHOTS[number]) { + current = shotDocument(id) + mount(current) + caption.textContent = `${LABELS[id]} · ${current.dressing} · ${current.duration}s · synthetic vowels` + for (const button of strip.querySelectorAll('button')) { + button.setAttribute('aria-pressed', button.dataset.shot === id ? 'true' : 'false') + } + voice.currentTime = 0 +} + +select(startId) +const tick = (now: number) => { + const seconds = sceneSeconds(now) + paintWorld(world, current, seconds) + renderWorld(world) + const labels = current.slots.map(slot => { + if (!slot.speech) return slot.id + const mouth = VISEMES[mouthAt(slot.speech, seconds).b] + return `${slot.id}: ${expressionAt(slot.speech, seconds)}/${mouth}` + }) + status.textContent = `${seconds.toFixed(2)}s · ${labels.join(' · ')}` + requestAnimationFrame(tick) +} +requestAnimationFrame(tick) +window.addEventListener('resize', () => resizeWorld(world, host)) +document.addEventListener('click', () => { void voice.play().catch(() => undefined) }, { once: true }) diff --git a/ui/package.json b/ui/package.json index 5f96c7279..f0375921a 100644 --- a/ui/package.json +++ b/ui/package.json @@ -12,7 +12,7 @@ "lint": "eslint .", "preview": "vite preview", "scene:review": "tsx scripts/scene-template-review.mjs", - "test": "tsx --tsconfig tsconfig.app.json --import ./tests/setupI18n.ts --test tests/*.test.mjs tests/*.test.tsx tests/*.test.ts", + "test": "tsx --tsconfig tsconfig.app.json --import ./tests/setupI18n.ts --test --test-concurrency=2 tests/*.test.mjs tests/*.test.tsx tests/*.test.ts", "i18n:check": "node scripts/check-i18n-catalogs.mjs", "test:e2e": "playwright test -c e2e/playwright.config.ts", "test:e2e:headed": "playwright test -c e2e/playwright.config.ts --headed", diff --git a/ui/public/character-kit-presets/mouths/STUDIO-20.txt b/ui/public/character-kit-presets/mouths/STUDIO-20.txt new file mode 100644 index 000000000..0bb66a6bb --- /dev/null +++ b/ui/public/character-kit-presets/mouths/STUDIO-20.txt @@ -0,0 +1,26 @@ +HocusPocus Studio 20 — reusable mouth collection + +20 original generated styles; 9 transparent PNG sprites per style, 512 x 512. +Created with OpenAI imagegen for HocusPocus. You may use, modify and redistribute +these new Studio 20 assets in personal and commercial animation projects. +This permission covers the Studio 20 artwork, not third-party character likenesses. + +Slot / Rhubarb / sound +closed X relaxed resting mouth (use also for listeners and silent scenes) +pressed A M/B/P, lips pressed together +small B EE and narrow consonants +medium C EH, moderate opening / transition +wide D AH, open jaw +round E O, rounded opening +pucker F OO/W, narrow rounded lips +bite G F/V, upper teeth on lower lip +tongue H L, tongue raised behind teeth + +All nine images use a shared square frame and centered pivot. Keep that frame: +do not trim each sprite independently or stretch a closed mouth to the height +of an open one. Calibrate one mouth box on the character, then apply placement +to all states. The base used for animation must have its old mouth removed. +Saving the character also creates a resting still, leaving the rig base intact. + +The six older four-state packs are retained for compatibility and are outside +this new Studio 20 collection. Four-state rigs still work through phonetic fallbacks. diff --git a/ui/public/character-kit-presets/mouths/cardboard-cut/bite.png b/ui/public/character-kit-presets/mouths/cardboard-cut/bite.png new file mode 100644 index 000000000..ef784d161 Binary files /dev/null and b/ui/public/character-kit-presets/mouths/cardboard-cut/bite.png differ diff --git a/ui/public/character-kit-presets/mouths/cardboard-cut/closed.png b/ui/public/character-kit-presets/mouths/cardboard-cut/closed.png new file mode 100644 index 000000000..0bb2abeb5 Binary files /dev/null and b/ui/public/character-kit-presets/mouths/cardboard-cut/closed.png differ diff --git a/ui/public/character-kit-presets/mouths/cardboard-cut/medium.png b/ui/public/character-kit-presets/mouths/cardboard-cut/medium.png new file mode 100644 index 000000000..d27a7cdb2 Binary files /dev/null and b/ui/public/character-kit-presets/mouths/cardboard-cut/medium.png differ diff --git a/ui/public/character-kit-presets/mouths/cardboard-cut/pressed.png b/ui/public/character-kit-presets/mouths/cardboard-cut/pressed.png new file mode 100644 index 000000000..2e4864003 Binary files /dev/null and b/ui/public/character-kit-presets/mouths/cardboard-cut/pressed.png differ diff --git a/ui/public/character-kit-presets/mouths/cardboard-cut/pucker.png b/ui/public/character-kit-presets/mouths/cardboard-cut/pucker.png new file mode 100644 index 000000000..4de999c82 Binary files /dev/null and b/ui/public/character-kit-presets/mouths/cardboard-cut/pucker.png differ diff --git a/ui/public/character-kit-presets/mouths/cardboard-cut/round.png b/ui/public/character-kit-presets/mouths/cardboard-cut/round.png new file mode 100644 index 000000000..e2e11ebb5 Binary files /dev/null and b/ui/public/character-kit-presets/mouths/cardboard-cut/round.png differ diff --git a/ui/public/character-kit-presets/mouths/cardboard-cut/small.png b/ui/public/character-kit-presets/mouths/cardboard-cut/small.png new file mode 100644 index 000000000..49a26980a Binary files /dev/null and b/ui/public/character-kit-presets/mouths/cardboard-cut/small.png differ diff --git a/ui/public/character-kit-presets/mouths/cardboard-cut/tongue.png b/ui/public/character-kit-presets/mouths/cardboard-cut/tongue.png new file mode 100644 index 000000000..ea739cad2 Binary files /dev/null and b/ui/public/character-kit-presets/mouths/cardboard-cut/tongue.png differ diff --git a/ui/public/character-kit-presets/mouths/cardboard-cut/wide.png b/ui/public/character-kit-presets/mouths/cardboard-cut/wide.png new file mode 100644 index 000000000..6b398e56b Binary files /dev/null and b/ui/public/character-kit-presets/mouths/cardboard-cut/wide.png differ diff --git a/ui/public/character-kit-presets/mouths/cel-anime/bite.png b/ui/public/character-kit-presets/mouths/cel-anime/bite.png new file mode 100644 index 000000000..2b21e2395 Binary files /dev/null and b/ui/public/character-kit-presets/mouths/cel-anime/bite.png differ diff --git a/ui/public/character-kit-presets/mouths/cel-anime/closed.png b/ui/public/character-kit-presets/mouths/cel-anime/closed.png new file mode 100644 index 000000000..9b3151bd6 Binary files /dev/null and b/ui/public/character-kit-presets/mouths/cel-anime/closed.png differ diff --git a/ui/public/character-kit-presets/mouths/cel-anime/medium.png b/ui/public/character-kit-presets/mouths/cel-anime/medium.png new file mode 100644 index 000000000..acdc656b6 Binary files /dev/null and b/ui/public/character-kit-presets/mouths/cel-anime/medium.png differ diff --git a/ui/public/character-kit-presets/mouths/cel-anime/pressed.png b/ui/public/character-kit-presets/mouths/cel-anime/pressed.png new file mode 100644 index 000000000..9c60d3302 Binary files /dev/null and b/ui/public/character-kit-presets/mouths/cel-anime/pressed.png differ diff --git a/ui/public/character-kit-presets/mouths/cel-anime/pucker.png b/ui/public/character-kit-presets/mouths/cel-anime/pucker.png new file mode 100644 index 000000000..097e7c935 Binary files /dev/null and b/ui/public/character-kit-presets/mouths/cel-anime/pucker.png differ diff --git a/ui/public/character-kit-presets/mouths/cel-anime/round.png b/ui/public/character-kit-presets/mouths/cel-anime/round.png new file mode 100644 index 000000000..ada758285 Binary files /dev/null and b/ui/public/character-kit-presets/mouths/cel-anime/round.png differ diff --git a/ui/public/character-kit-presets/mouths/cel-anime/small.png b/ui/public/character-kit-presets/mouths/cel-anime/small.png new file mode 100644 index 000000000..7cbf60b16 Binary files /dev/null and b/ui/public/character-kit-presets/mouths/cel-anime/small.png differ diff --git a/ui/public/character-kit-presets/mouths/cel-anime/tongue.png b/ui/public/character-kit-presets/mouths/cel-anime/tongue.png new file mode 100644 index 000000000..4866409e9 Binary files /dev/null and b/ui/public/character-kit-presets/mouths/cel-anime/tongue.png differ diff --git a/ui/public/character-kit-presets/mouths/cel-anime/wide.png b/ui/public/character-kit-presets/mouths/cel-anime/wide.png new file mode 100644 index 000000000..7be027780 Binary files /dev/null and b/ui/public/character-kit-presets/mouths/cel-anime/wide.png differ diff --git a/ui/public/character-kit-presets/mouths/chalk-doodle/bite.png b/ui/public/character-kit-presets/mouths/chalk-doodle/bite.png new file mode 100644 index 000000000..c4e001800 Binary files /dev/null and b/ui/public/character-kit-presets/mouths/chalk-doodle/bite.png differ diff --git a/ui/public/character-kit-presets/mouths/chalk-doodle/closed.png b/ui/public/character-kit-presets/mouths/chalk-doodle/closed.png new file mode 100644 index 000000000..2187df205 Binary files /dev/null and b/ui/public/character-kit-presets/mouths/chalk-doodle/closed.png differ diff --git a/ui/public/character-kit-presets/mouths/chalk-doodle/medium.png b/ui/public/character-kit-presets/mouths/chalk-doodle/medium.png new file mode 100644 index 000000000..46b57b24b Binary files /dev/null and b/ui/public/character-kit-presets/mouths/chalk-doodle/medium.png differ diff --git a/ui/public/character-kit-presets/mouths/chalk-doodle/pressed.png b/ui/public/character-kit-presets/mouths/chalk-doodle/pressed.png new file mode 100644 index 000000000..8ec2af25a Binary files /dev/null and b/ui/public/character-kit-presets/mouths/chalk-doodle/pressed.png differ diff --git a/ui/public/character-kit-presets/mouths/chalk-doodle/pucker.png b/ui/public/character-kit-presets/mouths/chalk-doodle/pucker.png new file mode 100644 index 000000000..16f6230c4 Binary files /dev/null and b/ui/public/character-kit-presets/mouths/chalk-doodle/pucker.png differ diff --git a/ui/public/character-kit-presets/mouths/chalk-doodle/round.png b/ui/public/character-kit-presets/mouths/chalk-doodle/round.png new file mode 100644 index 000000000..fbcd2d1b9 Binary files /dev/null and b/ui/public/character-kit-presets/mouths/chalk-doodle/round.png differ diff --git a/ui/public/character-kit-presets/mouths/chalk-doodle/small.png b/ui/public/character-kit-presets/mouths/chalk-doodle/small.png new file mode 100644 index 000000000..9277caa4a Binary files /dev/null and b/ui/public/character-kit-presets/mouths/chalk-doodle/small.png differ diff --git a/ui/public/character-kit-presets/mouths/chalk-doodle/tongue.png b/ui/public/character-kit-presets/mouths/chalk-doodle/tongue.png new file mode 100644 index 000000000..a4bc1aca5 Binary files /dev/null and b/ui/public/character-kit-presets/mouths/chalk-doodle/tongue.png differ diff --git a/ui/public/character-kit-presets/mouths/chalk-doodle/wide.png b/ui/public/character-kit-presets/mouths/chalk-doodle/wide.png new file mode 100644 index 000000000..9f0c67a8f Binary files /dev/null and b/ui/public/character-kit-presets/mouths/chalk-doodle/wide.png differ diff --git a/ui/public/character-kit-presets/mouths/clay-puppet/bite.png b/ui/public/character-kit-presets/mouths/clay-puppet/bite.png new file mode 100644 index 000000000..eae7ac4a7 Binary files /dev/null and b/ui/public/character-kit-presets/mouths/clay-puppet/bite.png differ diff --git a/ui/public/character-kit-presets/mouths/clay-puppet/closed.png b/ui/public/character-kit-presets/mouths/clay-puppet/closed.png new file mode 100644 index 000000000..4e7f48df9 Binary files /dev/null and b/ui/public/character-kit-presets/mouths/clay-puppet/closed.png differ diff --git a/ui/public/character-kit-presets/mouths/clay-puppet/medium.png b/ui/public/character-kit-presets/mouths/clay-puppet/medium.png new file mode 100644 index 000000000..328bb554a Binary files /dev/null and b/ui/public/character-kit-presets/mouths/clay-puppet/medium.png differ diff --git a/ui/public/character-kit-presets/mouths/clay-puppet/pressed.png b/ui/public/character-kit-presets/mouths/clay-puppet/pressed.png new file mode 100644 index 000000000..7bd96bad9 Binary files /dev/null and b/ui/public/character-kit-presets/mouths/clay-puppet/pressed.png differ diff --git a/ui/public/character-kit-presets/mouths/clay-puppet/pucker.png b/ui/public/character-kit-presets/mouths/clay-puppet/pucker.png new file mode 100644 index 000000000..b608d910c Binary files /dev/null and b/ui/public/character-kit-presets/mouths/clay-puppet/pucker.png differ diff --git a/ui/public/character-kit-presets/mouths/clay-puppet/round.png b/ui/public/character-kit-presets/mouths/clay-puppet/round.png new file mode 100644 index 000000000..2f16c9ae0 Binary files /dev/null and b/ui/public/character-kit-presets/mouths/clay-puppet/round.png differ diff --git a/ui/public/character-kit-presets/mouths/clay-puppet/small.png b/ui/public/character-kit-presets/mouths/clay-puppet/small.png new file mode 100644 index 000000000..1582b8690 Binary files /dev/null and b/ui/public/character-kit-presets/mouths/clay-puppet/small.png differ diff --git a/ui/public/character-kit-presets/mouths/clay-puppet/tongue.png b/ui/public/character-kit-presets/mouths/clay-puppet/tongue.png new file mode 100644 index 000000000..4ad0d6081 Binary files /dev/null and b/ui/public/character-kit-presets/mouths/clay-puppet/tongue.png differ diff --git a/ui/public/character-kit-presets/mouths/clay-puppet/wide.png b/ui/public/character-kit-presets/mouths/clay-puppet/wide.png new file mode 100644 index 000000000..69c6e93b6 Binary files /dev/null and b/ui/public/character-kit-presets/mouths/clay-puppet/wide.png differ diff --git a/ui/public/character-kit-presets/mouths/comic-halftone/bite.png b/ui/public/character-kit-presets/mouths/comic-halftone/bite.png new file mode 100644 index 000000000..6848bf17a Binary files /dev/null and b/ui/public/character-kit-presets/mouths/comic-halftone/bite.png differ diff --git a/ui/public/character-kit-presets/mouths/comic-halftone/closed.png b/ui/public/character-kit-presets/mouths/comic-halftone/closed.png new file mode 100644 index 000000000..43eda3cce Binary files /dev/null and b/ui/public/character-kit-presets/mouths/comic-halftone/closed.png differ diff --git a/ui/public/character-kit-presets/mouths/comic-halftone/medium.png b/ui/public/character-kit-presets/mouths/comic-halftone/medium.png new file mode 100644 index 000000000..25be08c18 Binary files /dev/null and b/ui/public/character-kit-presets/mouths/comic-halftone/medium.png differ diff --git a/ui/public/character-kit-presets/mouths/comic-halftone/pressed.png b/ui/public/character-kit-presets/mouths/comic-halftone/pressed.png new file mode 100644 index 000000000..70a418067 Binary files /dev/null and b/ui/public/character-kit-presets/mouths/comic-halftone/pressed.png differ diff --git a/ui/public/character-kit-presets/mouths/comic-halftone/pucker.png b/ui/public/character-kit-presets/mouths/comic-halftone/pucker.png new file mode 100644 index 000000000..a8ba1a378 Binary files /dev/null and b/ui/public/character-kit-presets/mouths/comic-halftone/pucker.png differ diff --git a/ui/public/character-kit-presets/mouths/comic-halftone/round.png b/ui/public/character-kit-presets/mouths/comic-halftone/round.png new file mode 100644 index 000000000..73cf38652 Binary files /dev/null and b/ui/public/character-kit-presets/mouths/comic-halftone/round.png differ diff --git a/ui/public/character-kit-presets/mouths/comic-halftone/small.png b/ui/public/character-kit-presets/mouths/comic-halftone/small.png new file mode 100644 index 000000000..0a851d93d Binary files /dev/null and b/ui/public/character-kit-presets/mouths/comic-halftone/small.png differ diff --git a/ui/public/character-kit-presets/mouths/comic-halftone/tongue.png b/ui/public/character-kit-presets/mouths/comic-halftone/tongue.png new file mode 100644 index 000000000..afd9fbe31 Binary files /dev/null and b/ui/public/character-kit-presets/mouths/comic-halftone/tongue.png differ diff --git a/ui/public/character-kit-presets/mouths/comic-halftone/wide.png b/ui/public/character-kit-presets/mouths/comic-halftone/wide.png new file mode 100644 index 000000000..02a5685de Binary files /dev/null and b/ui/public/character-kit-presets/mouths/comic-halftone/wide.png differ diff --git a/ui/public/character-kit-presets/mouths/flat-geometric/bite.png b/ui/public/character-kit-presets/mouths/flat-geometric/bite.png new file mode 100644 index 000000000..167cdb1f9 Binary files /dev/null and b/ui/public/character-kit-presets/mouths/flat-geometric/bite.png differ diff --git a/ui/public/character-kit-presets/mouths/flat-geometric/closed.png b/ui/public/character-kit-presets/mouths/flat-geometric/closed.png new file mode 100644 index 000000000..94bbfefb1 Binary files /dev/null and b/ui/public/character-kit-presets/mouths/flat-geometric/closed.png differ diff --git a/ui/public/character-kit-presets/mouths/flat-geometric/medium.png b/ui/public/character-kit-presets/mouths/flat-geometric/medium.png new file mode 100644 index 000000000..f01b049b1 Binary files /dev/null and b/ui/public/character-kit-presets/mouths/flat-geometric/medium.png differ diff --git a/ui/public/character-kit-presets/mouths/flat-geometric/pressed.png b/ui/public/character-kit-presets/mouths/flat-geometric/pressed.png new file mode 100644 index 000000000..0c91be079 Binary files /dev/null and b/ui/public/character-kit-presets/mouths/flat-geometric/pressed.png differ diff --git a/ui/public/character-kit-presets/mouths/flat-geometric/pucker.png b/ui/public/character-kit-presets/mouths/flat-geometric/pucker.png new file mode 100644 index 000000000..6f9d48ff8 Binary files /dev/null and b/ui/public/character-kit-presets/mouths/flat-geometric/pucker.png differ diff --git a/ui/public/character-kit-presets/mouths/flat-geometric/round.png b/ui/public/character-kit-presets/mouths/flat-geometric/round.png new file mode 100644 index 000000000..212d35373 Binary files /dev/null and b/ui/public/character-kit-presets/mouths/flat-geometric/round.png differ diff --git a/ui/public/character-kit-presets/mouths/flat-geometric/small.png b/ui/public/character-kit-presets/mouths/flat-geometric/small.png new file mode 100644 index 000000000..a97686ff0 Binary files /dev/null and b/ui/public/character-kit-presets/mouths/flat-geometric/small.png differ diff --git a/ui/public/character-kit-presets/mouths/flat-geometric/tongue.png b/ui/public/character-kit-presets/mouths/flat-geometric/tongue.png new file mode 100644 index 000000000..3a93e182c Binary files /dev/null and b/ui/public/character-kit-presets/mouths/flat-geometric/tongue.png differ diff --git a/ui/public/character-kit-presets/mouths/flat-geometric/wide.png b/ui/public/character-kit-presets/mouths/flat-geometric/wide.png new file mode 100644 index 000000000..66dee6a92 Binary files /dev/null and b/ui/public/character-kit-presets/mouths/flat-geometric/wide.png differ diff --git a/ui/public/character-kit-presets/mouths/manifest.json b/ui/public/character-kit-presets/mouths/manifest.json index 59b1e04d6..d0f489809 100644 --- a/ui/public/character-kit-presets/mouths/manifest.json +++ b/ui/public/character-kit-presets/mouths/manifest.json @@ -4,7 +4,12 @@ "closed", "small", "wide", - "round" + "round", + "pressed", + "medium", + "pucker", + "bite", + "tongue" ], "packs": [ { @@ -174,6 +179,1166 @@ "height": 855 } } + }, + { + "id": "ruby-ink", + "label": "Ruby ink", + "style": "cutout", + "collection": "studio-20", + "notes": "Clean bold ink cartoon with ruby-red sculpted lips, ivory teeth, coral tongue. Nine phonetic positions, transparent PNG, shared 512px frame and pivot.", + "provenance": { + "tool": "OpenAI imagegen", + "atlasSha256": "ba3f5858fbbf56c00e513195663e4fe37e2d9530bc863b14abd0a7c3b48b4f4d" + }, + "states": { + "closed": { + "file": "ruby-ink/closed.png", + "width": 512, + "height": 512 + }, + "small": { + "file": "ruby-ink/small.png", + "width": 512, + "height": 512 + }, + "wide": { + "file": "ruby-ink/wide.png", + "width": 512, + "height": 512 + }, + "round": { + "file": "ruby-ink/round.png", + "width": 512, + "height": 512 + }, + "pressed": { + "file": "ruby-ink/pressed.png", + "width": 512, + "height": 512 + }, + "medium": { + "file": "ruby-ink/medium.png", + "width": 512, + "height": 512 + }, + "pucker": { + "file": "ruby-ink/pucker.png", + "width": 512, + "height": 512 + }, + "bite": { + "file": "ruby-ink/bite.png", + "width": 512, + "height": 512 + }, + "tongue": { + "file": "ruby-ink/tongue.png", + "width": 512, + "height": 512 + } + } + }, + { + "id": "minimal-line", + "label": "Minimal line", + "style": "cutout", + "collection": "studio-20", + "notes": "Minimal flat cartoon: very thin black outline, NO outer fleshy lips, dark maroon interior, single ivory tooth band, coral tongue. Nine phonetic positions, transparent PNG, shared 512px frame and pivot.", + "provenance": { + "tool": "OpenAI imagegen", + "atlasSha256": "4b9d2efa283721d871ae86a4e262563c924cc7d6c3a6d2766360cf5a075ff686" + }, + "states": { + "closed": { + "file": "minimal-line/closed.png", + "width": 512, + "height": 512 + }, + "small": { + "file": "minimal-line/small.png", + "width": 512, + "height": 512 + }, + "wide": { + "file": "minimal-line/wide.png", + "width": 512, + "height": 512 + }, + "round": { + "file": "minimal-line/round.png", + "width": 512, + "height": 512 + }, + "pressed": { + "file": "minimal-line/pressed.png", + "width": 512, + "height": 512 + }, + "medium": { + "file": "minimal-line/medium.png", + "width": 512, + "height": 512 + }, + "pucker": { + "file": "minimal-line/pucker.png", + "width": 512, + "height": 512 + }, + "bite": { + "file": "minimal-line/bite.png", + "width": 512, + "height": 512 + }, + "tongue": { + "file": "minimal-line/tongue.png", + "width": 512, + "height": 512 + } + } + }, + { + "id": "storybook-gouache", + "label": "Storybook gouache", + "style": "cutout", + "collection": "studio-20", + "notes": "Children's picture book gouache, soft terracotta lip edge with subtle brush texture, aubergine interior, ivory teeth. Nine phonetic positions, transparent PNG, shared 512px frame and pivot.", + "provenance": { + "tool": "OpenAI imagegen", + "atlasSha256": "bc47e82c820b0394db29d3503e959e259d633dab66a5d3f1e556b486b01f40ea" + }, + "states": { + "closed": { + "file": "storybook-gouache/closed.png", + "width": 512, + "height": 512 + }, + "small": { + "file": "storybook-gouache/small.png", + "width": 512, + "height": 512 + }, + "wide": { + "file": "storybook-gouache/wide.png", + "width": 512, + "height": 512 + }, + "round": { + "file": "storybook-gouache/round.png", + "width": 512, + "height": 512 + }, + "pressed": { + "file": "storybook-gouache/pressed.png", + "width": 512, + "height": 512 + }, + "medium": { + "file": "storybook-gouache/medium.png", + "width": 512, + "height": 512 + }, + "pucker": { + "file": "storybook-gouache/pucker.png", + "width": 512, + "height": 512 + }, + "bite": { + "file": "storybook-gouache/bite.png", + "width": 512, + "height": 512 + }, + "tongue": { + "file": "storybook-gouache/tongue.png", + "width": 512, + "height": 512 + } + } + }, + { + "id": "cardboard-cut", + "label": "Cardboard cut", + "style": "cutout", + "collection": "studio-20", + "notes": "Layered colored cardstock, rough black cut-paper edges, flat wine interior, ivory paper teeth and pink paper tongue, NO skin. Nine phonetic positions, transparent PNG, shared 512px frame and pivot.", + "provenance": { + "tool": "OpenAI imagegen", + "atlasSha256": "5db9cba8b6926435a2915cc1ed52790d72655f36c876b89178b403905e06b707" + }, + "states": { + "closed": { + "file": "cardboard-cut/closed.png", + "width": 512, + "height": 512 + }, + "small": { + "file": "cardboard-cut/small.png", + "width": 512, + "height": 512 + }, + "wide": { + "file": "cardboard-cut/wide.png", + "width": 512, + "height": 512 + }, + "round": { + "file": "cardboard-cut/round.png", + "width": 512, + "height": 512 + }, + "pressed": { + "file": "cardboard-cut/pressed.png", + "width": 512, + "height": 512 + }, + "medium": { + "file": "cardboard-cut/medium.png", + "width": 512, + "height": 512 + }, + "pucker": { + "file": "cardboard-cut/pucker.png", + "width": 512, + "height": 512 + }, + "bite": { + "file": "cardboard-cut/bite.png", + "width": 512, + "height": 512 + }, + "tongue": { + "file": "cardboard-cut/tongue.png", + "width": 512, + "height": 512 + } + } + }, + { + "id": "velvet-puppet", + "label": "Velvet puppet", + "style": "cutout", + "collection": "studio-20", + "notes": "Handmade dark plum velvet felt puppet mouth, softly fuzzy fabric edges, pink felt tongue, cream felt teeth. Nine phonetic positions, transparent PNG, shared 512px frame and pivot.", + "provenance": { + "tool": "OpenAI imagegen", + "atlasSha256": "01caba99f968551c1a53bc932258ecef64aa311867fd9e1ca0caecc2218f9973" + }, + "states": { + "closed": { + "file": "velvet-puppet/closed.png", + "width": 512, + "height": 512 + }, + "small": { + "file": "velvet-puppet/small.png", + "width": 512, + "height": 512 + }, + "wide": { + "file": "velvet-puppet/wide.png", + "width": 512, + "height": 512 + }, + "round": { + "file": "velvet-puppet/round.png", + "width": 512, + "height": 512 + }, + "pressed": { + "file": "velvet-puppet/pressed.png", + "width": 512, + "height": 512 + }, + "medium": { + "file": "velvet-puppet/medium.png", + "width": 512, + "height": 512 + }, + "pucker": { + "file": "velvet-puppet/pucker.png", + "width": 512, + "height": 512 + }, + "bite": { + "file": "velvet-puppet/bite.png", + "width": 512, + "height": 512 + }, + "tongue": { + "file": "velvet-puppet/tongue.png", + "width": 512, + "height": 512 + } + } + }, + { + "id": "cel-anime", + "label": "Cel anime", + "style": "cutout", + "collection": "studio-20", + "notes": "Elegant restrained anime cel animation mouth, fine dark reddish outline, no external fleshy lips, burgundy cavity, subtle pink tongue, clean cream tooth band. Nine phonetic positions, transparent PNG, shared 512px frame and pivot.", + "provenance": { + "tool": "OpenAI imagegen", + "atlasSha256": "004e03f92895ef6afdc3d0c840a75c1fa54e7296651466060049b95c71776922" + }, + "states": { + "closed": { + "file": "cel-anime/closed.png", + "width": 512, + "height": 512 + }, + "small": { + "file": "cel-anime/small.png", + "width": 512, + "height": 512 + }, + "wide": { + "file": "cel-anime/wide.png", + "width": 512, + "height": 512 + }, + "round": { + "file": "cel-anime/round.png", + "width": 512, + "height": 512 + }, + "pressed": { + "file": "cel-anime/pressed.png", + "width": 512, + "height": 512 + }, + "medium": { + "file": "cel-anime/medium.png", + "width": 512, + "height": 512 + }, + "pucker": { + "file": "cel-anime/pucker.png", + "width": 512, + "height": 512 + }, + "bite": { + "file": "cel-anime/bite.png", + "width": 512, + "height": 512 + }, + "tongue": { + "file": "cel-anime/tongue.png", + "width": 512, + "height": 512 + } + } + }, + { + "id": "rubber-hose", + "label": "Rubber hose", + "style": "cutout", + "collection": "studio-20", + "notes": "1930s rubber-hose cartoon mouth, bold pure black forms, warm ivory teeth, gray-pink tongue, pie-cut rubbery expressive shapes. Nine phonetic positions, transparent PNG, shared 512px frame and pivot.", + "provenance": { + "tool": "OpenAI imagegen", + "atlasSha256": "de4a72802aae79dca7684e5ed7f54d23b430ef0c0909963149ece3d07190e611" + }, + "states": { + "closed": { + "file": "rubber-hose/closed.png", + "width": 512, + "height": 512 + }, + "small": { + "file": "rubber-hose/small.png", + "width": 512, + "height": 512 + }, + "wide": { + "file": "rubber-hose/wide.png", + "width": 512, + "height": 512 + }, + "round": { + "file": "rubber-hose/round.png", + "width": 512, + "height": 512 + }, + "pressed": { + "file": "rubber-hose/pressed.png", + "width": 512, + "height": 512 + }, + "medium": { + "file": "rubber-hose/medium.png", + "width": 512, + "height": 512 + }, + "pucker": { + "file": "rubber-hose/pucker.png", + "width": 512, + "height": 512 + }, + "bite": { + "file": "rubber-hose/bite.png", + "width": 512, + "height": 512 + }, + "tongue": { + "file": "rubber-hose/tongue.png", + "width": 512, + "height": 512 + } + } + }, + { + "id": "comic-halftone", + "label": "Comic halftone", + "style": "cutout", + "collection": "studio-20", + "notes": "Pop comic mouth with bold navy ink outline, coral lip edge, magenta halftone dot shading, ivory teeth and salmon tongue. Nine phonetic positions, transparent PNG, shared 512px frame and pivot.", + "provenance": { + "tool": "OpenAI imagegen", + "atlasSha256": "2f72a9d425c51dac4da51e21de8563abc48e2c07405261bf95645cff3ee437d9" + }, + "states": { + "closed": { + "file": "comic-halftone/closed.png", + "width": 512, + "height": 512 + }, + "small": { + "file": "comic-halftone/small.png", + "width": 512, + "height": 512 + }, + "wide": { + "file": "comic-halftone/wide.png", + "width": 512, + "height": 512 + }, + "round": { + "file": "comic-halftone/round.png", + "width": 512, + "height": 512 + }, + "pressed": { + "file": "comic-halftone/pressed.png", + "width": 512, + "height": 512 + }, + "medium": { + "file": "comic-halftone/medium.png", + "width": 512, + "height": 512 + }, + "pucker": { + "file": "comic-halftone/pucker.png", + "width": 512, + "height": 512 + }, + "bite": { + "file": "comic-halftone/bite.png", + "width": 512, + "height": 512 + }, + "tongue": { + "file": "comic-halftone/tongue.png", + "width": 512, + "height": 512 + } + } + }, + { + "id": "clay-puppet", + "label": "Clay puppet", + "style": "cutout", + "collection": "studio-20", + "notes": "Hand sculpted clay animation mouth parts, matte plum outer edge, deep burgundy clay interior, softly rounded ivory clay teeth and pink clay tongue, front view no shadows. Nine phonetic positions, transparent PNG, shared 512px frame and pivot.", + "provenance": { + "tool": "OpenAI imagegen", + "atlasSha256": "69bced323083d14f3d7142f3d700caeae84f4c0719afbba4737bb0fffc34b07c" + }, + "states": { + "closed": { + "file": "clay-puppet/closed.png", + "width": 512, + "height": 512 + }, + "small": { + "file": "clay-puppet/small.png", + "width": 512, + "height": 512 + }, + "wide": { + "file": "clay-puppet/wide.png", + "width": 512, + "height": 512 + }, + "round": { + "file": "clay-puppet/round.png", + "width": 512, + "height": 512 + }, + "pressed": { + "file": "clay-puppet/pressed.png", + "width": 512, + "height": 512 + }, + "medium": { + "file": "clay-puppet/medium.png", + "width": 512, + "height": 512 + }, + "pucker": { + "file": "clay-puppet/pucker.png", + "width": 512, + "height": 512 + }, + "bite": { + "file": "clay-puppet/bite.png", + "width": 512, + "height": 512 + }, + "tongue": { + "file": "clay-puppet/tongue.png", + "width": 512, + "height": 512 + } + } + }, + { + "id": "pixel-arcade", + "label": "Pixel arcade", + "style": "cutout", + "collection": "studio-20", + "notes": "Crisp low-resolution 16-bit pixel art mouth with black stepped outline and angular shapes, purple-black cavity, square ivory teeth and vivid pink tongue, no antialiasing aesthetic. Nine phonetic positions, transparent PNG, shared 512px frame and pivot.", + "provenance": { + "tool": "OpenAI imagegen", + "atlasSha256": "0fb47097f4442fca933472ed641aecc1849bbc07bb9db950f22008e7699e5089" + }, + "states": { + "closed": { + "file": "pixel-arcade/closed.png", + "width": 512, + "height": 512 + }, + "small": { + "file": "pixel-arcade/small.png", + "width": 512, + "height": 512 + }, + "wide": { + "file": "pixel-arcade/wide.png", + "width": 512, + "height": 512 + }, + "round": { + "file": "pixel-arcade/round.png", + "width": 512, + "height": 512 + }, + "pressed": { + "file": "pixel-arcade/pressed.png", + "width": 512, + "height": 512 + }, + "medium": { + "file": "pixel-arcade/medium.png", + "width": 512, + "height": 512 + }, + "pucker": { + "file": "pixel-arcade/pucker.png", + "width": 512, + "height": 512 + }, + "bite": { + "file": "pixel-arcade/bite.png", + "width": 512, + "height": 512 + }, + "tongue": { + "file": "pixel-arcade/tongue.png", + "width": 512, + "height": 512 + } + } + }, + { + "id": "chalk-doodle", + "label": "Chalk doodle", + "style": "cutout", + "collection": "studio-20", + "notes": "Chalk pastel doodle mouth, dark charcoal uneven outline, muted berry opening, off-white chalk tooth strip, pink chalk tongue, no external skin. Nine phonetic positions, transparent PNG, shared 512px frame and pivot.", + "provenance": { + "tool": "OpenAI imagegen", + "atlasSha256": "9f69b1242021ab7d8f10a39fe9d566f98a94e53cb9230a4b649e3a3effc3eaff" + }, + "states": { + "closed": { + "file": "chalk-doodle/closed.png", + "width": 512, + "height": 512 + }, + "small": { + "file": "chalk-doodle/small.png", + "width": 512, + "height": 512 + }, + "wide": { + "file": "chalk-doodle/wide.png", + "width": 512, + "height": 512 + }, + "round": { + "file": "chalk-doodle/round.png", + "width": 512, + "height": 512 + }, + "pressed": { + "file": "chalk-doodle/pressed.png", + "width": 512, + "height": 512 + }, + "medium": { + "file": "chalk-doodle/medium.png", + "width": 512, + "height": 512 + }, + "pucker": { + "file": "chalk-doodle/pucker.png", + "width": 512, + "height": 512 + }, + "bite": { + "file": "chalk-doodle/bite.png", + "width": 512, + "height": 512 + }, + "tongue": { + "file": "chalk-doodle/tongue.png", + "width": 512, + "height": 512 + } + } + }, + { + "id": "soft-manga", + "label": "Soft manga", + "style": "cutout", + "collection": "studio-20", + "notes": "Charming manga chibi mouth, no thick lips, delicate ink contour, rich reddish brown opening, tiny ivory tooth band, simple salmon tongue. Nine phonetic positions, transparent PNG, shared 512px frame and pivot.", + "provenance": { + "tool": "OpenAI imagegen", + "atlasSha256": "6e1cc52150455c748dd8c26c4d7ee3cf51bf6f8c8d0b0b27ef14afa04038d9c6" + }, + "states": { + "closed": { + "file": "soft-manga/closed.png", + "width": 512, + "height": 512 + }, + "small": { + "file": "soft-manga/small.png", + "width": 512, + "height": 512 + }, + "wide": { + "file": "soft-manga/wide.png", + "width": 512, + "height": 512 + }, + "round": { + "file": "soft-manga/round.png", + "width": 512, + "height": 512 + }, + "pressed": { + "file": "soft-manga/pressed.png", + "width": 512, + "height": 512 + }, + "medium": { + "file": "soft-manga/medium.png", + "width": 512, + "height": 512 + }, + "pucker": { + "file": "soft-manga/pucker.png", + "width": 512, + "height": 512 + }, + "bite": { + "file": "soft-manga/bite.png", + "width": 512, + "height": 512 + }, + "tongue": { + "file": "soft-manga/tongue.png", + "width": 512, + "height": 512 + } + } + }, + { + "id": "flat-geometric", + "label": "Flat geometric", + "style": "cutout", + "collection": "studio-20", + "notes": "Modern flat graphic animation, smoothly geometric mouth shape with uniform charcoal stroke, brick red inner rim, creamy tooth band, geometric coral tongue. Nine phonetic positions, transparent PNG, shared 512px frame and pivot.", + "provenance": { + "tool": "OpenAI imagegen", + "atlasSha256": "619d67618faeea51063c8a943a48832f8dddb8bad685c2a9ca4523434749ad42" + }, + "states": { + "closed": { + "file": "flat-geometric/closed.png", + "width": 512, + "height": 512 + }, + "small": { + "file": "flat-geometric/small.png", + "width": 512, + "height": 512 + }, + "wide": { + "file": "flat-geometric/wide.png", + "width": 512, + "height": 512 + }, + "round": { + "file": "flat-geometric/round.png", + "width": 512, + "height": 512 + }, + "pressed": { + "file": "flat-geometric/pressed.png", + "width": 512, + "height": 512 + }, + "medium": { + "file": "flat-geometric/medium.png", + "width": 512, + "height": 512 + }, + "pucker": { + "file": "flat-geometric/pucker.png", + "width": 512, + "height": 512 + }, + "bite": { + "file": "flat-geometric/bite.png", + "width": 512, + "height": 512 + }, + "tongue": { + "file": "flat-geometric/tongue.png", + "width": 512, + "height": 512 + } + } + }, + { + "id": "plush-stitch", + "label": "Plush stitch", + "style": "cutout", + "collection": "studio-20", + "notes": "Soft sewn plush toy mouth applique, burgundy cloth with clear cream stitched edge, pink cloth tongue, simple ivory fabric teeth. Nine phonetic positions, transparent PNG, shared 512px frame and pivot.", + "provenance": { + "tool": "OpenAI imagegen", + "atlasSha256": "1ba7ea4662c25ed9604861d3a6e48cbb5675ac3e002c9390ba1441ab80c672bb" + }, + "states": { + "closed": { + "file": "plush-stitch/closed.png", + "width": 512, + "height": 512 + }, + "small": { + "file": "plush-stitch/small.png", + "width": 512, + "height": 512 + }, + "wide": { + "file": "plush-stitch/wide.png", + "width": 512, + "height": 512 + }, + "round": { + "file": "plush-stitch/round.png", + "width": 512, + "height": 512 + }, + "pressed": { + "file": "plush-stitch/pressed.png", + "width": 512, + "height": 512 + }, + "medium": { + "file": "plush-stitch/medium.png", + "width": 512, + "height": 512 + }, + "pucker": { + "file": "plush-stitch/pucker.png", + "width": 512, + "height": 512 + }, + "bite": { + "file": "plush-stitch/bite.png", + "width": 512, + "height": 512 + }, + "tongue": { + "file": "plush-stitch/tongue.png", + "width": 512, + "height": 512 + } + } + }, + { + "id": "woodblock", + "label": "Woodblock", + "style": "cutout", + "collection": "studio-20", + "notes": "Vintage woodblock printed mouth, irregular dark indigo carved outlines, muted vermilion inner edge, cream paper tooth band and ochre pink tongue. Nine phonetic positions, transparent PNG, shared 512px frame and pivot.", + "provenance": { + "tool": "OpenAI imagegen", + "atlasSha256": "f10ef446de11880bf4e5fe9839e4a0775d209c35de78065246220b983ec573df" + }, + "states": { + "closed": { + "file": "woodblock/closed.png", + "width": 512, + "height": 512 + }, + "small": { + "file": "woodblock/small.png", + "width": 512, + "height": 512 + }, + "wide": { + "file": "woodblock/wide.png", + "width": 512, + "height": 512 + }, + "round": { + "file": "woodblock/round.png", + "width": 512, + "height": 512 + }, + "pressed": { + "file": "woodblock/pressed.png", + "width": 512, + "height": 512 + }, + "medium": { + "file": "woodblock/medium.png", + "width": 512, + "height": 512 + }, + "pucker": { + "file": "woodblock/pucker.png", + "width": 512, + "height": 512 + }, + "bite": { + "file": "woodblock/bite.png", + "width": 512, + "height": 512 + }, + "tongue": { + "file": "woodblock/tongue.png", + "width": 512, + "height": 512 + } + } + }, + { + "id": "neon-toon", + "label": "Neon toon", + "style": "cutout", + "collection": "studio-20", + "notes": "Cyber cartoon mouth, crisp midnight-purple outline, vivid magenta lip rim with thin cyan graphic accents, lavender-white teeth and pink tongue, NO glow outside mouth. Nine phonetic positions, transparent PNG, shared 512px frame and pivot.", + "provenance": { + "tool": "OpenAI imagegen", + "atlasSha256": "9086626caee3f349ed6f64f6c5af5cd5aa7bb12e396ce290242496bf3eb549a6" + }, + "states": { + "closed": { + "file": "neon-toon/closed.png", + "width": 512, + "height": 512 + }, + "small": { + "file": "neon-toon/small.png", + "width": 512, + "height": 512 + }, + "wide": { + "file": "neon-toon/wide.png", + "width": 512, + "height": 512 + }, + "round": { + "file": "neon-toon/round.png", + "width": 512, + "height": 512 + }, + "pressed": { + "file": "neon-toon/pressed.png", + "width": 512, + "height": 512 + }, + "medium": { + "file": "neon-toon/medium.png", + "width": 512, + "height": 512 + }, + "pucker": { + "file": "neon-toon/pucker.png", + "width": 512, + "height": 512 + }, + "bite": { + "file": "neon-toon/bite.png", + "width": 512, + "height": 512 + }, + "tongue": { + "file": "neon-toon/tongue.png", + "width": 512, + "height": 512 + } + } + }, + { + "id": "monochrome-ink", + "label": "Monochrome ink", + "style": "cutout", + "collection": "studio-20", + "notes": "Classic monochrome black-and-white cartoon mouth, expressive solid black cavity, grayscale lip edging, white teeth and mid-gray tongue, NO skin. Nine phonetic positions, transparent PNG, shared 512px frame and pivot.", + "provenance": { + "tool": "OpenAI imagegen", + "atlasSha256": "747540017cc1e373e9d26b1e28707004b08344c9ab773497ddec25a6faac95c7" + }, + "states": { + "closed": { + "file": "monochrome-ink/closed.png", + "width": 512, + "height": 512 + }, + "small": { + "file": "monochrome-ink/small.png", + "width": 512, + "height": 512 + }, + "wide": { + "file": "monochrome-ink/wide.png", + "width": 512, + "height": 512 + }, + "round": { + "file": "monochrome-ink/round.png", + "width": 512, + "height": 512 + }, + "pressed": { + "file": "monochrome-ink/pressed.png", + "width": 512, + "height": 512 + }, + "medium": { + "file": "monochrome-ink/medium.png", + "width": 512, + "height": 512 + }, + "pucker": { + "file": "monochrome-ink/pucker.png", + "width": 512, + "height": 512 + }, + "bite": { + "file": "monochrome-ink/bite.png", + "width": 512, + "height": 512 + }, + "tongue": { + "file": "monochrome-ink/tongue.png", + "width": 512, + "height": 512 + } + } + }, + { + "id": "warm-pastel", + "label": "Warm pastel", + "style": "cutout", + "collection": "studio-20", + "notes": "Friendly preschool flat pastel cartoon mouth, warm cocoa outline, peach pink thin lip edge, deep raspberry cavity, creamy rounded teeth and apricot tongue. Nine phonetic positions, transparent PNG, shared 512px frame and pivot.", + "provenance": { + "tool": "OpenAI imagegen", + "atlasSha256": "d715430ac1f7e246f22a104d90c49435faff2ad8fbe90b01ec7896c8cdd7850f" + }, + "states": { + "closed": { + "file": "warm-pastel/closed.png", + "width": 512, + "height": 512 + }, + "small": { + "file": "warm-pastel/small.png", + "width": 512, + "height": 512 + }, + "wide": { + "file": "warm-pastel/wide.png", + "width": 512, + "height": 512 + }, + "round": { + "file": "warm-pastel/round.png", + "width": 512, + "height": 512 + }, + "pressed": { + "file": "warm-pastel/pressed.png", + "width": 512, + "height": 512 + }, + "medium": { + "file": "warm-pastel/medium.png", + "width": 512, + "height": 512 + }, + "pucker": { + "file": "warm-pastel/pucker.png", + "width": 512, + "height": 512 + }, + "bite": { + "file": "warm-pastel/bite.png", + "width": 512, + "height": 512 + }, + "tongue": { + "file": "warm-pastel/tongue.png", + "width": 512, + "height": 512 + } + } + }, + { + "id": "sticker-bold", + "label": "Sticker bold", + "style": "cutout", + "collection": "studio-20", + "notes": "Bold sticker cartoon mouth with thick charcoal perimeter and small white outer keyline restricted to mouth, burnt-orange thin lip rim, egg-white teeth and rose tongue. Nine phonetic positions, transparent PNG, shared 512px frame and pivot.", + "provenance": { + "tool": "OpenAI imagegen", + "atlasSha256": "c741f1dd5863994483dbad70225fed54e91cd4b81b05da6910922a9a461898d6" + }, + "states": { + "closed": { + "file": "sticker-bold/closed.png", + "width": 512, + "height": 512 + }, + "small": { + "file": "sticker-bold/small.png", + "width": 512, + "height": 512 + }, + "wide": { + "file": "sticker-bold/wide.png", + "width": 512, + "height": 512 + }, + "round": { + "file": "sticker-bold/round.png", + "width": 512, + "height": 512 + }, + "pressed": { + "file": "sticker-bold/pressed.png", + "width": 512, + "height": 512 + }, + "medium": { + "file": "sticker-bold/medium.png", + "width": 512, + "height": 512 + }, + "pucker": { + "file": "sticker-bold/pucker.png", + "width": 512, + "height": 512 + }, + "bite": { + "file": "sticker-bold/bite.png", + "width": 512, + "height": 512 + }, + "tongue": { + "file": "sticker-bold/tongue.png", + "width": 512, + "height": 512 + } + } + }, + { + "id": "watercolor-rose", + "label": "Watercolor rose", + "style": "cutout", + "collection": "studio-20", + "notes": "Delicate rose watercolor mouth, wine outline with natural watercolor pigment variation INSIDE shapes only, pale ivory teeth and muted rose tongue, no surrounding skin wash. Nine phonetic positions, transparent PNG, shared 512px frame and pivot.", + "provenance": { + "tool": "OpenAI imagegen", + "atlasSha256": "0ac2bc3246eec54f2547239cad0136d3e90312684a20775edc639893a99418d4" + }, + "states": { + "closed": { + "file": "watercolor-rose/closed.png", + "width": 512, + "height": 512 + }, + "small": { + "file": "watercolor-rose/small.png", + "width": 512, + "height": 512 + }, + "wide": { + "file": "watercolor-rose/wide.png", + "width": 512, + "height": 512 + }, + "round": { + "file": "watercolor-rose/round.png", + "width": 512, + "height": 512 + }, + "pressed": { + "file": "watercolor-rose/pressed.png", + "width": 512, + "height": 512 + }, + "medium": { + "file": "watercolor-rose/medium.png", + "width": 512, + "height": 512 + }, + "pucker": { + "file": "watercolor-rose/pucker.png", + "width": 512, + "height": 512 + }, + "bite": { + "file": "watercolor-rose/bite.png", + "width": 512, + "height": 512 + }, + "tongue": { + "file": "watercolor-rose/tongue.png", + "width": 512, + "height": 512 + } + } } ] } diff --git a/ui/public/character-kit-presets/mouths/minimal-line/bite.png b/ui/public/character-kit-presets/mouths/minimal-line/bite.png new file mode 100644 index 000000000..66b4ae68f Binary files /dev/null and b/ui/public/character-kit-presets/mouths/minimal-line/bite.png differ diff --git a/ui/public/character-kit-presets/mouths/minimal-line/closed.png b/ui/public/character-kit-presets/mouths/minimal-line/closed.png new file mode 100644 index 000000000..b86130d71 Binary files /dev/null and b/ui/public/character-kit-presets/mouths/minimal-line/closed.png differ diff --git a/ui/public/character-kit-presets/mouths/minimal-line/medium.png b/ui/public/character-kit-presets/mouths/minimal-line/medium.png new file mode 100644 index 000000000..6a20220c6 Binary files /dev/null and b/ui/public/character-kit-presets/mouths/minimal-line/medium.png differ diff --git a/ui/public/character-kit-presets/mouths/minimal-line/pressed.png b/ui/public/character-kit-presets/mouths/minimal-line/pressed.png new file mode 100644 index 000000000..987e98d90 Binary files /dev/null and b/ui/public/character-kit-presets/mouths/minimal-line/pressed.png differ diff --git a/ui/public/character-kit-presets/mouths/minimal-line/pucker.png b/ui/public/character-kit-presets/mouths/minimal-line/pucker.png new file mode 100644 index 000000000..ea7561b6a Binary files /dev/null and b/ui/public/character-kit-presets/mouths/minimal-line/pucker.png differ diff --git a/ui/public/character-kit-presets/mouths/minimal-line/round.png b/ui/public/character-kit-presets/mouths/minimal-line/round.png new file mode 100644 index 000000000..c6f4879f2 Binary files /dev/null and b/ui/public/character-kit-presets/mouths/minimal-line/round.png differ diff --git a/ui/public/character-kit-presets/mouths/minimal-line/small.png b/ui/public/character-kit-presets/mouths/minimal-line/small.png new file mode 100644 index 000000000..77021da42 Binary files /dev/null and b/ui/public/character-kit-presets/mouths/minimal-line/small.png differ diff --git a/ui/public/character-kit-presets/mouths/minimal-line/tongue.png b/ui/public/character-kit-presets/mouths/minimal-line/tongue.png new file mode 100644 index 000000000..761780add Binary files /dev/null and b/ui/public/character-kit-presets/mouths/minimal-line/tongue.png differ diff --git a/ui/public/character-kit-presets/mouths/minimal-line/wide.png b/ui/public/character-kit-presets/mouths/minimal-line/wide.png new file mode 100644 index 000000000..0f76182be Binary files /dev/null and b/ui/public/character-kit-presets/mouths/minimal-line/wide.png differ diff --git a/ui/public/character-kit-presets/mouths/monochrome-ink/bite.png b/ui/public/character-kit-presets/mouths/monochrome-ink/bite.png new file mode 100644 index 000000000..7bd358dac Binary files /dev/null and b/ui/public/character-kit-presets/mouths/monochrome-ink/bite.png differ diff --git a/ui/public/character-kit-presets/mouths/monochrome-ink/closed.png b/ui/public/character-kit-presets/mouths/monochrome-ink/closed.png new file mode 100644 index 000000000..15b799a9a Binary files /dev/null and b/ui/public/character-kit-presets/mouths/monochrome-ink/closed.png differ diff --git a/ui/public/character-kit-presets/mouths/monochrome-ink/medium.png b/ui/public/character-kit-presets/mouths/monochrome-ink/medium.png new file mode 100644 index 000000000..8dbabf793 Binary files /dev/null and b/ui/public/character-kit-presets/mouths/monochrome-ink/medium.png differ diff --git a/ui/public/character-kit-presets/mouths/monochrome-ink/pressed.png b/ui/public/character-kit-presets/mouths/monochrome-ink/pressed.png new file mode 100644 index 000000000..f7da4bf35 Binary files /dev/null and b/ui/public/character-kit-presets/mouths/monochrome-ink/pressed.png differ diff --git a/ui/public/character-kit-presets/mouths/monochrome-ink/pucker.png b/ui/public/character-kit-presets/mouths/monochrome-ink/pucker.png new file mode 100644 index 000000000..5329d0fa9 Binary files /dev/null and b/ui/public/character-kit-presets/mouths/monochrome-ink/pucker.png differ diff --git a/ui/public/character-kit-presets/mouths/monochrome-ink/round.png b/ui/public/character-kit-presets/mouths/monochrome-ink/round.png new file mode 100644 index 000000000..7a5156f92 Binary files /dev/null and b/ui/public/character-kit-presets/mouths/monochrome-ink/round.png differ diff --git a/ui/public/character-kit-presets/mouths/monochrome-ink/small.png b/ui/public/character-kit-presets/mouths/monochrome-ink/small.png new file mode 100644 index 000000000..b07d86921 Binary files /dev/null and b/ui/public/character-kit-presets/mouths/monochrome-ink/small.png differ diff --git a/ui/public/character-kit-presets/mouths/monochrome-ink/tongue.png b/ui/public/character-kit-presets/mouths/monochrome-ink/tongue.png new file mode 100644 index 000000000..afecffa41 Binary files /dev/null and b/ui/public/character-kit-presets/mouths/monochrome-ink/tongue.png differ diff --git a/ui/public/character-kit-presets/mouths/monochrome-ink/wide.png b/ui/public/character-kit-presets/mouths/monochrome-ink/wide.png new file mode 100644 index 000000000..104d09f44 Binary files /dev/null and b/ui/public/character-kit-presets/mouths/monochrome-ink/wide.png differ diff --git a/ui/public/character-kit-presets/mouths/neon-toon/bite.png b/ui/public/character-kit-presets/mouths/neon-toon/bite.png new file mode 100644 index 000000000..65dd03f21 Binary files /dev/null and b/ui/public/character-kit-presets/mouths/neon-toon/bite.png differ diff --git a/ui/public/character-kit-presets/mouths/neon-toon/closed.png b/ui/public/character-kit-presets/mouths/neon-toon/closed.png new file mode 100644 index 000000000..3c5f2c147 Binary files /dev/null and b/ui/public/character-kit-presets/mouths/neon-toon/closed.png differ diff --git a/ui/public/character-kit-presets/mouths/neon-toon/medium.png b/ui/public/character-kit-presets/mouths/neon-toon/medium.png new file mode 100644 index 000000000..6511ccd2a Binary files /dev/null and b/ui/public/character-kit-presets/mouths/neon-toon/medium.png differ diff --git a/ui/public/character-kit-presets/mouths/neon-toon/pressed.png b/ui/public/character-kit-presets/mouths/neon-toon/pressed.png new file mode 100644 index 000000000..fccba55b6 Binary files /dev/null and b/ui/public/character-kit-presets/mouths/neon-toon/pressed.png differ diff --git a/ui/public/character-kit-presets/mouths/neon-toon/pucker.png b/ui/public/character-kit-presets/mouths/neon-toon/pucker.png new file mode 100644 index 000000000..b075808f2 Binary files /dev/null and b/ui/public/character-kit-presets/mouths/neon-toon/pucker.png differ diff --git a/ui/public/character-kit-presets/mouths/neon-toon/round.png b/ui/public/character-kit-presets/mouths/neon-toon/round.png new file mode 100644 index 000000000..524709e32 Binary files /dev/null and b/ui/public/character-kit-presets/mouths/neon-toon/round.png differ diff --git a/ui/public/character-kit-presets/mouths/neon-toon/small.png b/ui/public/character-kit-presets/mouths/neon-toon/small.png new file mode 100644 index 000000000..df8cf80ca Binary files /dev/null and b/ui/public/character-kit-presets/mouths/neon-toon/small.png differ diff --git a/ui/public/character-kit-presets/mouths/neon-toon/tongue.png b/ui/public/character-kit-presets/mouths/neon-toon/tongue.png new file mode 100644 index 000000000..49e90b2f9 Binary files /dev/null and b/ui/public/character-kit-presets/mouths/neon-toon/tongue.png differ diff --git a/ui/public/character-kit-presets/mouths/neon-toon/wide.png b/ui/public/character-kit-presets/mouths/neon-toon/wide.png new file mode 100644 index 000000000..12df2ca7a Binary files /dev/null and b/ui/public/character-kit-presets/mouths/neon-toon/wide.png differ diff --git a/ui/public/character-kit-presets/mouths/pixel-arcade/bite.png b/ui/public/character-kit-presets/mouths/pixel-arcade/bite.png new file mode 100644 index 000000000..eadf77ee4 Binary files /dev/null and b/ui/public/character-kit-presets/mouths/pixel-arcade/bite.png differ diff --git a/ui/public/character-kit-presets/mouths/pixel-arcade/closed.png b/ui/public/character-kit-presets/mouths/pixel-arcade/closed.png new file mode 100644 index 000000000..f08ec6f2b Binary files /dev/null and b/ui/public/character-kit-presets/mouths/pixel-arcade/closed.png differ diff --git a/ui/public/character-kit-presets/mouths/pixel-arcade/medium.png b/ui/public/character-kit-presets/mouths/pixel-arcade/medium.png new file mode 100644 index 000000000..8793380f6 Binary files /dev/null and b/ui/public/character-kit-presets/mouths/pixel-arcade/medium.png differ diff --git a/ui/public/character-kit-presets/mouths/pixel-arcade/pressed.png b/ui/public/character-kit-presets/mouths/pixel-arcade/pressed.png new file mode 100644 index 000000000..0492ec0cc Binary files /dev/null and b/ui/public/character-kit-presets/mouths/pixel-arcade/pressed.png differ diff --git a/ui/public/character-kit-presets/mouths/pixel-arcade/pucker.png b/ui/public/character-kit-presets/mouths/pixel-arcade/pucker.png new file mode 100644 index 000000000..fb0fe7aab Binary files /dev/null and b/ui/public/character-kit-presets/mouths/pixel-arcade/pucker.png differ diff --git a/ui/public/character-kit-presets/mouths/pixel-arcade/round.png b/ui/public/character-kit-presets/mouths/pixel-arcade/round.png new file mode 100644 index 000000000..18c659ef3 Binary files /dev/null and b/ui/public/character-kit-presets/mouths/pixel-arcade/round.png differ diff --git a/ui/public/character-kit-presets/mouths/pixel-arcade/small.png b/ui/public/character-kit-presets/mouths/pixel-arcade/small.png new file mode 100644 index 000000000..90cb4bd68 Binary files /dev/null and b/ui/public/character-kit-presets/mouths/pixel-arcade/small.png differ diff --git a/ui/public/character-kit-presets/mouths/pixel-arcade/tongue.png b/ui/public/character-kit-presets/mouths/pixel-arcade/tongue.png new file mode 100644 index 000000000..bb43fdf6a Binary files /dev/null and b/ui/public/character-kit-presets/mouths/pixel-arcade/tongue.png differ diff --git a/ui/public/character-kit-presets/mouths/pixel-arcade/wide.png b/ui/public/character-kit-presets/mouths/pixel-arcade/wide.png new file mode 100644 index 000000000..210dfd862 Binary files /dev/null and b/ui/public/character-kit-presets/mouths/pixel-arcade/wide.png differ diff --git a/ui/public/character-kit-presets/mouths/plush-stitch/bite.png b/ui/public/character-kit-presets/mouths/plush-stitch/bite.png new file mode 100644 index 000000000..a428fbdb0 Binary files /dev/null and b/ui/public/character-kit-presets/mouths/plush-stitch/bite.png differ diff --git a/ui/public/character-kit-presets/mouths/plush-stitch/closed.png b/ui/public/character-kit-presets/mouths/plush-stitch/closed.png new file mode 100644 index 000000000..3d33f0c84 Binary files /dev/null and b/ui/public/character-kit-presets/mouths/plush-stitch/closed.png differ diff --git a/ui/public/character-kit-presets/mouths/plush-stitch/medium.png b/ui/public/character-kit-presets/mouths/plush-stitch/medium.png new file mode 100644 index 000000000..a4374c0d0 Binary files /dev/null and b/ui/public/character-kit-presets/mouths/plush-stitch/medium.png differ diff --git a/ui/public/character-kit-presets/mouths/plush-stitch/pressed.png b/ui/public/character-kit-presets/mouths/plush-stitch/pressed.png new file mode 100644 index 000000000..842720509 Binary files /dev/null and b/ui/public/character-kit-presets/mouths/plush-stitch/pressed.png differ diff --git a/ui/public/character-kit-presets/mouths/plush-stitch/pucker.png b/ui/public/character-kit-presets/mouths/plush-stitch/pucker.png new file mode 100644 index 000000000..48b8b6625 Binary files /dev/null and b/ui/public/character-kit-presets/mouths/plush-stitch/pucker.png differ diff --git a/ui/public/character-kit-presets/mouths/plush-stitch/round.png b/ui/public/character-kit-presets/mouths/plush-stitch/round.png new file mode 100644 index 000000000..1e9697cf0 Binary files /dev/null and b/ui/public/character-kit-presets/mouths/plush-stitch/round.png differ diff --git a/ui/public/character-kit-presets/mouths/plush-stitch/small.png b/ui/public/character-kit-presets/mouths/plush-stitch/small.png new file mode 100644 index 000000000..3815e61fc Binary files /dev/null and b/ui/public/character-kit-presets/mouths/plush-stitch/small.png differ diff --git a/ui/public/character-kit-presets/mouths/plush-stitch/tongue.png b/ui/public/character-kit-presets/mouths/plush-stitch/tongue.png new file mode 100644 index 000000000..f859fab9c Binary files /dev/null and b/ui/public/character-kit-presets/mouths/plush-stitch/tongue.png differ diff --git a/ui/public/character-kit-presets/mouths/plush-stitch/wide.png b/ui/public/character-kit-presets/mouths/plush-stitch/wide.png new file mode 100644 index 000000000..6a93abbe3 Binary files /dev/null and b/ui/public/character-kit-presets/mouths/plush-stitch/wide.png differ diff --git a/ui/public/character-kit-presets/mouths/rubber-hose/bite.png b/ui/public/character-kit-presets/mouths/rubber-hose/bite.png new file mode 100644 index 000000000..f978895a7 Binary files /dev/null and b/ui/public/character-kit-presets/mouths/rubber-hose/bite.png differ diff --git a/ui/public/character-kit-presets/mouths/rubber-hose/closed.png b/ui/public/character-kit-presets/mouths/rubber-hose/closed.png new file mode 100644 index 000000000..99e8bbf17 Binary files /dev/null and b/ui/public/character-kit-presets/mouths/rubber-hose/closed.png differ diff --git a/ui/public/character-kit-presets/mouths/rubber-hose/medium.png b/ui/public/character-kit-presets/mouths/rubber-hose/medium.png new file mode 100644 index 000000000..e0472d39d Binary files /dev/null and b/ui/public/character-kit-presets/mouths/rubber-hose/medium.png differ diff --git a/ui/public/character-kit-presets/mouths/rubber-hose/pressed.png b/ui/public/character-kit-presets/mouths/rubber-hose/pressed.png new file mode 100644 index 000000000..da9df7716 Binary files /dev/null and b/ui/public/character-kit-presets/mouths/rubber-hose/pressed.png differ diff --git a/ui/public/character-kit-presets/mouths/rubber-hose/pucker.png b/ui/public/character-kit-presets/mouths/rubber-hose/pucker.png new file mode 100644 index 000000000..0fa0ae54c Binary files /dev/null and b/ui/public/character-kit-presets/mouths/rubber-hose/pucker.png differ diff --git a/ui/public/character-kit-presets/mouths/rubber-hose/round.png b/ui/public/character-kit-presets/mouths/rubber-hose/round.png new file mode 100644 index 000000000..c9aef6098 Binary files /dev/null and b/ui/public/character-kit-presets/mouths/rubber-hose/round.png differ diff --git a/ui/public/character-kit-presets/mouths/rubber-hose/small.png b/ui/public/character-kit-presets/mouths/rubber-hose/small.png new file mode 100644 index 000000000..588990918 Binary files /dev/null and b/ui/public/character-kit-presets/mouths/rubber-hose/small.png differ diff --git a/ui/public/character-kit-presets/mouths/rubber-hose/tongue.png b/ui/public/character-kit-presets/mouths/rubber-hose/tongue.png new file mode 100644 index 000000000..fc9739544 Binary files /dev/null and b/ui/public/character-kit-presets/mouths/rubber-hose/tongue.png differ diff --git a/ui/public/character-kit-presets/mouths/rubber-hose/wide.png b/ui/public/character-kit-presets/mouths/rubber-hose/wide.png new file mode 100644 index 000000000..14d32cd59 Binary files /dev/null and b/ui/public/character-kit-presets/mouths/rubber-hose/wide.png differ diff --git a/ui/public/character-kit-presets/mouths/ruby-ink/bite.png b/ui/public/character-kit-presets/mouths/ruby-ink/bite.png new file mode 100644 index 000000000..f7120da4a Binary files /dev/null and b/ui/public/character-kit-presets/mouths/ruby-ink/bite.png differ diff --git a/ui/public/character-kit-presets/mouths/ruby-ink/closed.png b/ui/public/character-kit-presets/mouths/ruby-ink/closed.png new file mode 100644 index 000000000..8d74d1d36 Binary files /dev/null and b/ui/public/character-kit-presets/mouths/ruby-ink/closed.png differ diff --git a/ui/public/character-kit-presets/mouths/ruby-ink/medium.png b/ui/public/character-kit-presets/mouths/ruby-ink/medium.png new file mode 100644 index 000000000..6ae73aaee Binary files /dev/null and b/ui/public/character-kit-presets/mouths/ruby-ink/medium.png differ diff --git a/ui/public/character-kit-presets/mouths/ruby-ink/pressed.png b/ui/public/character-kit-presets/mouths/ruby-ink/pressed.png new file mode 100644 index 000000000..0dbc5fc08 Binary files /dev/null and b/ui/public/character-kit-presets/mouths/ruby-ink/pressed.png differ diff --git a/ui/public/character-kit-presets/mouths/ruby-ink/pucker.png b/ui/public/character-kit-presets/mouths/ruby-ink/pucker.png new file mode 100644 index 000000000..17996876e Binary files /dev/null and b/ui/public/character-kit-presets/mouths/ruby-ink/pucker.png differ diff --git a/ui/public/character-kit-presets/mouths/ruby-ink/round.png b/ui/public/character-kit-presets/mouths/ruby-ink/round.png new file mode 100644 index 000000000..725e8eea2 Binary files /dev/null and b/ui/public/character-kit-presets/mouths/ruby-ink/round.png differ diff --git a/ui/public/character-kit-presets/mouths/ruby-ink/small.png b/ui/public/character-kit-presets/mouths/ruby-ink/small.png new file mode 100644 index 000000000..20c464285 Binary files /dev/null and b/ui/public/character-kit-presets/mouths/ruby-ink/small.png differ diff --git a/ui/public/character-kit-presets/mouths/ruby-ink/tongue.png b/ui/public/character-kit-presets/mouths/ruby-ink/tongue.png new file mode 100644 index 000000000..c4c675c2c Binary files /dev/null and b/ui/public/character-kit-presets/mouths/ruby-ink/tongue.png differ diff --git a/ui/public/character-kit-presets/mouths/ruby-ink/wide.png b/ui/public/character-kit-presets/mouths/ruby-ink/wide.png new file mode 100644 index 000000000..799bdf990 Binary files /dev/null and b/ui/public/character-kit-presets/mouths/ruby-ink/wide.png differ diff --git a/ui/public/character-kit-presets/mouths/soft-manga/bite.png b/ui/public/character-kit-presets/mouths/soft-manga/bite.png new file mode 100644 index 000000000..598cb85e5 Binary files /dev/null and b/ui/public/character-kit-presets/mouths/soft-manga/bite.png differ diff --git a/ui/public/character-kit-presets/mouths/soft-manga/closed.png b/ui/public/character-kit-presets/mouths/soft-manga/closed.png new file mode 100644 index 000000000..83357fb3a Binary files /dev/null and b/ui/public/character-kit-presets/mouths/soft-manga/closed.png differ diff --git a/ui/public/character-kit-presets/mouths/soft-manga/medium.png b/ui/public/character-kit-presets/mouths/soft-manga/medium.png new file mode 100644 index 000000000..a171af9a2 Binary files /dev/null and b/ui/public/character-kit-presets/mouths/soft-manga/medium.png differ diff --git a/ui/public/character-kit-presets/mouths/soft-manga/pressed.png b/ui/public/character-kit-presets/mouths/soft-manga/pressed.png new file mode 100644 index 000000000..8b6670555 Binary files /dev/null and b/ui/public/character-kit-presets/mouths/soft-manga/pressed.png differ diff --git a/ui/public/character-kit-presets/mouths/soft-manga/pucker.png b/ui/public/character-kit-presets/mouths/soft-manga/pucker.png new file mode 100644 index 000000000..78916f315 Binary files /dev/null and b/ui/public/character-kit-presets/mouths/soft-manga/pucker.png differ diff --git a/ui/public/character-kit-presets/mouths/soft-manga/round.png b/ui/public/character-kit-presets/mouths/soft-manga/round.png new file mode 100644 index 000000000..c77b3bcaa Binary files /dev/null and b/ui/public/character-kit-presets/mouths/soft-manga/round.png differ diff --git a/ui/public/character-kit-presets/mouths/soft-manga/small.png b/ui/public/character-kit-presets/mouths/soft-manga/small.png new file mode 100644 index 000000000..971ae6e94 Binary files /dev/null and b/ui/public/character-kit-presets/mouths/soft-manga/small.png differ diff --git a/ui/public/character-kit-presets/mouths/soft-manga/tongue.png b/ui/public/character-kit-presets/mouths/soft-manga/tongue.png new file mode 100644 index 000000000..6e6cbf126 Binary files /dev/null and b/ui/public/character-kit-presets/mouths/soft-manga/tongue.png differ diff --git a/ui/public/character-kit-presets/mouths/soft-manga/wide.png b/ui/public/character-kit-presets/mouths/soft-manga/wide.png new file mode 100644 index 000000000..320b2d7ae Binary files /dev/null and b/ui/public/character-kit-presets/mouths/soft-manga/wide.png differ diff --git a/ui/public/character-kit-presets/mouths/sticker-bold/bite.png b/ui/public/character-kit-presets/mouths/sticker-bold/bite.png new file mode 100644 index 000000000..684a5b864 Binary files /dev/null and b/ui/public/character-kit-presets/mouths/sticker-bold/bite.png differ diff --git a/ui/public/character-kit-presets/mouths/sticker-bold/closed.png b/ui/public/character-kit-presets/mouths/sticker-bold/closed.png new file mode 100644 index 000000000..75c08148b Binary files /dev/null and b/ui/public/character-kit-presets/mouths/sticker-bold/closed.png differ diff --git a/ui/public/character-kit-presets/mouths/sticker-bold/medium.png b/ui/public/character-kit-presets/mouths/sticker-bold/medium.png new file mode 100644 index 000000000..c28440836 Binary files /dev/null and b/ui/public/character-kit-presets/mouths/sticker-bold/medium.png differ diff --git a/ui/public/character-kit-presets/mouths/sticker-bold/pressed.png b/ui/public/character-kit-presets/mouths/sticker-bold/pressed.png new file mode 100644 index 000000000..533b6267b Binary files /dev/null and b/ui/public/character-kit-presets/mouths/sticker-bold/pressed.png differ diff --git a/ui/public/character-kit-presets/mouths/sticker-bold/pucker.png b/ui/public/character-kit-presets/mouths/sticker-bold/pucker.png new file mode 100644 index 000000000..eafbd9beb Binary files /dev/null and b/ui/public/character-kit-presets/mouths/sticker-bold/pucker.png differ diff --git a/ui/public/character-kit-presets/mouths/sticker-bold/round.png b/ui/public/character-kit-presets/mouths/sticker-bold/round.png new file mode 100644 index 000000000..790255067 Binary files /dev/null and b/ui/public/character-kit-presets/mouths/sticker-bold/round.png differ diff --git a/ui/public/character-kit-presets/mouths/sticker-bold/small.png b/ui/public/character-kit-presets/mouths/sticker-bold/small.png new file mode 100644 index 000000000..3b9b40fba Binary files /dev/null and b/ui/public/character-kit-presets/mouths/sticker-bold/small.png differ diff --git a/ui/public/character-kit-presets/mouths/sticker-bold/tongue.png b/ui/public/character-kit-presets/mouths/sticker-bold/tongue.png new file mode 100644 index 000000000..d7a763d9e Binary files /dev/null and b/ui/public/character-kit-presets/mouths/sticker-bold/tongue.png differ diff --git a/ui/public/character-kit-presets/mouths/sticker-bold/wide.png b/ui/public/character-kit-presets/mouths/sticker-bold/wide.png new file mode 100644 index 000000000..ede89d0e7 Binary files /dev/null and b/ui/public/character-kit-presets/mouths/sticker-bold/wide.png differ diff --git a/ui/public/character-kit-presets/mouths/storybook-gouache/bite.png b/ui/public/character-kit-presets/mouths/storybook-gouache/bite.png new file mode 100644 index 000000000..c42901bd2 Binary files /dev/null and b/ui/public/character-kit-presets/mouths/storybook-gouache/bite.png differ diff --git a/ui/public/character-kit-presets/mouths/storybook-gouache/closed.png b/ui/public/character-kit-presets/mouths/storybook-gouache/closed.png new file mode 100644 index 000000000..a0978e913 Binary files /dev/null and b/ui/public/character-kit-presets/mouths/storybook-gouache/closed.png differ diff --git a/ui/public/character-kit-presets/mouths/storybook-gouache/medium.png b/ui/public/character-kit-presets/mouths/storybook-gouache/medium.png new file mode 100644 index 000000000..9a14aabfe Binary files /dev/null and b/ui/public/character-kit-presets/mouths/storybook-gouache/medium.png differ diff --git a/ui/public/character-kit-presets/mouths/storybook-gouache/pressed.png b/ui/public/character-kit-presets/mouths/storybook-gouache/pressed.png new file mode 100644 index 000000000..9615d2838 Binary files /dev/null and b/ui/public/character-kit-presets/mouths/storybook-gouache/pressed.png differ diff --git a/ui/public/character-kit-presets/mouths/storybook-gouache/pucker.png b/ui/public/character-kit-presets/mouths/storybook-gouache/pucker.png new file mode 100644 index 000000000..182117afc Binary files /dev/null and b/ui/public/character-kit-presets/mouths/storybook-gouache/pucker.png differ diff --git a/ui/public/character-kit-presets/mouths/storybook-gouache/round.png b/ui/public/character-kit-presets/mouths/storybook-gouache/round.png new file mode 100644 index 000000000..001b5ad25 Binary files /dev/null and b/ui/public/character-kit-presets/mouths/storybook-gouache/round.png differ diff --git a/ui/public/character-kit-presets/mouths/storybook-gouache/small.png b/ui/public/character-kit-presets/mouths/storybook-gouache/small.png new file mode 100644 index 000000000..b67943337 Binary files /dev/null and b/ui/public/character-kit-presets/mouths/storybook-gouache/small.png differ diff --git a/ui/public/character-kit-presets/mouths/storybook-gouache/tongue.png b/ui/public/character-kit-presets/mouths/storybook-gouache/tongue.png new file mode 100644 index 000000000..7a51882bf Binary files /dev/null and b/ui/public/character-kit-presets/mouths/storybook-gouache/tongue.png differ diff --git a/ui/public/character-kit-presets/mouths/storybook-gouache/wide.png b/ui/public/character-kit-presets/mouths/storybook-gouache/wide.png new file mode 100644 index 000000000..b59afcb2f Binary files /dev/null and b/ui/public/character-kit-presets/mouths/storybook-gouache/wide.png differ diff --git a/ui/public/character-kit-presets/mouths/velvet-puppet/bite.png b/ui/public/character-kit-presets/mouths/velvet-puppet/bite.png new file mode 100644 index 000000000..1b3e1ce08 Binary files /dev/null and b/ui/public/character-kit-presets/mouths/velvet-puppet/bite.png differ diff --git a/ui/public/character-kit-presets/mouths/velvet-puppet/closed.png b/ui/public/character-kit-presets/mouths/velvet-puppet/closed.png new file mode 100644 index 000000000..5c8824282 Binary files /dev/null and b/ui/public/character-kit-presets/mouths/velvet-puppet/closed.png differ diff --git a/ui/public/character-kit-presets/mouths/velvet-puppet/medium.png b/ui/public/character-kit-presets/mouths/velvet-puppet/medium.png new file mode 100644 index 000000000..ca9d33fd3 Binary files /dev/null and b/ui/public/character-kit-presets/mouths/velvet-puppet/medium.png differ diff --git a/ui/public/character-kit-presets/mouths/velvet-puppet/pressed.png b/ui/public/character-kit-presets/mouths/velvet-puppet/pressed.png new file mode 100644 index 000000000..8f69f801c Binary files /dev/null and b/ui/public/character-kit-presets/mouths/velvet-puppet/pressed.png differ diff --git a/ui/public/character-kit-presets/mouths/velvet-puppet/pucker.png b/ui/public/character-kit-presets/mouths/velvet-puppet/pucker.png new file mode 100644 index 000000000..ec582e35e Binary files /dev/null and b/ui/public/character-kit-presets/mouths/velvet-puppet/pucker.png differ diff --git a/ui/public/character-kit-presets/mouths/velvet-puppet/round.png b/ui/public/character-kit-presets/mouths/velvet-puppet/round.png new file mode 100644 index 000000000..29866db43 Binary files /dev/null and b/ui/public/character-kit-presets/mouths/velvet-puppet/round.png differ diff --git a/ui/public/character-kit-presets/mouths/velvet-puppet/small.png b/ui/public/character-kit-presets/mouths/velvet-puppet/small.png new file mode 100644 index 000000000..fb2616e1c Binary files /dev/null and b/ui/public/character-kit-presets/mouths/velvet-puppet/small.png differ diff --git a/ui/public/character-kit-presets/mouths/velvet-puppet/tongue.png b/ui/public/character-kit-presets/mouths/velvet-puppet/tongue.png new file mode 100644 index 000000000..3acd18e3f Binary files /dev/null and b/ui/public/character-kit-presets/mouths/velvet-puppet/tongue.png differ diff --git a/ui/public/character-kit-presets/mouths/velvet-puppet/wide.png b/ui/public/character-kit-presets/mouths/velvet-puppet/wide.png new file mode 100644 index 000000000..7e338a577 Binary files /dev/null and b/ui/public/character-kit-presets/mouths/velvet-puppet/wide.png differ diff --git a/ui/public/character-kit-presets/mouths/warm-pastel/bite.png b/ui/public/character-kit-presets/mouths/warm-pastel/bite.png new file mode 100644 index 000000000..8faca4f42 Binary files /dev/null and b/ui/public/character-kit-presets/mouths/warm-pastel/bite.png differ diff --git a/ui/public/character-kit-presets/mouths/warm-pastel/closed.png b/ui/public/character-kit-presets/mouths/warm-pastel/closed.png new file mode 100644 index 000000000..78fd774ef Binary files /dev/null and b/ui/public/character-kit-presets/mouths/warm-pastel/closed.png differ diff --git a/ui/public/character-kit-presets/mouths/warm-pastel/medium.png b/ui/public/character-kit-presets/mouths/warm-pastel/medium.png new file mode 100644 index 000000000..1641d3b49 Binary files /dev/null and b/ui/public/character-kit-presets/mouths/warm-pastel/medium.png differ diff --git a/ui/public/character-kit-presets/mouths/warm-pastel/pressed.png b/ui/public/character-kit-presets/mouths/warm-pastel/pressed.png new file mode 100644 index 000000000..f6bddf4af Binary files /dev/null and b/ui/public/character-kit-presets/mouths/warm-pastel/pressed.png differ diff --git a/ui/public/character-kit-presets/mouths/warm-pastel/pucker.png b/ui/public/character-kit-presets/mouths/warm-pastel/pucker.png new file mode 100644 index 000000000..08f548213 Binary files /dev/null and b/ui/public/character-kit-presets/mouths/warm-pastel/pucker.png differ diff --git a/ui/public/character-kit-presets/mouths/warm-pastel/round.png b/ui/public/character-kit-presets/mouths/warm-pastel/round.png new file mode 100644 index 000000000..eca51dd6e Binary files /dev/null and b/ui/public/character-kit-presets/mouths/warm-pastel/round.png differ diff --git a/ui/public/character-kit-presets/mouths/warm-pastel/small.png b/ui/public/character-kit-presets/mouths/warm-pastel/small.png new file mode 100644 index 000000000..c3908f7f4 Binary files /dev/null and b/ui/public/character-kit-presets/mouths/warm-pastel/small.png differ diff --git a/ui/public/character-kit-presets/mouths/warm-pastel/tongue.png b/ui/public/character-kit-presets/mouths/warm-pastel/tongue.png new file mode 100644 index 000000000..16e7d3a6b Binary files /dev/null and b/ui/public/character-kit-presets/mouths/warm-pastel/tongue.png differ diff --git a/ui/public/character-kit-presets/mouths/warm-pastel/wide.png b/ui/public/character-kit-presets/mouths/warm-pastel/wide.png new file mode 100644 index 000000000..b96a6950f Binary files /dev/null and b/ui/public/character-kit-presets/mouths/warm-pastel/wide.png differ diff --git a/ui/public/character-kit-presets/mouths/watercolor-rose/bite.png b/ui/public/character-kit-presets/mouths/watercolor-rose/bite.png new file mode 100644 index 000000000..ba5cb445b Binary files /dev/null and b/ui/public/character-kit-presets/mouths/watercolor-rose/bite.png differ diff --git a/ui/public/character-kit-presets/mouths/watercolor-rose/closed.png b/ui/public/character-kit-presets/mouths/watercolor-rose/closed.png new file mode 100644 index 000000000..aac0f5e03 Binary files /dev/null and b/ui/public/character-kit-presets/mouths/watercolor-rose/closed.png differ diff --git a/ui/public/character-kit-presets/mouths/watercolor-rose/medium.png b/ui/public/character-kit-presets/mouths/watercolor-rose/medium.png new file mode 100644 index 000000000..233b9eeb2 Binary files /dev/null and b/ui/public/character-kit-presets/mouths/watercolor-rose/medium.png differ diff --git a/ui/public/character-kit-presets/mouths/watercolor-rose/pressed.png b/ui/public/character-kit-presets/mouths/watercolor-rose/pressed.png new file mode 100644 index 000000000..8dcd62ff5 Binary files /dev/null and b/ui/public/character-kit-presets/mouths/watercolor-rose/pressed.png differ diff --git a/ui/public/character-kit-presets/mouths/watercolor-rose/pucker.png b/ui/public/character-kit-presets/mouths/watercolor-rose/pucker.png new file mode 100644 index 000000000..d25e7e39c Binary files /dev/null and b/ui/public/character-kit-presets/mouths/watercolor-rose/pucker.png differ diff --git a/ui/public/character-kit-presets/mouths/watercolor-rose/round.png b/ui/public/character-kit-presets/mouths/watercolor-rose/round.png new file mode 100644 index 000000000..2814a88f7 Binary files /dev/null and b/ui/public/character-kit-presets/mouths/watercolor-rose/round.png differ diff --git a/ui/public/character-kit-presets/mouths/watercolor-rose/small.png b/ui/public/character-kit-presets/mouths/watercolor-rose/small.png new file mode 100644 index 000000000..dbaa59924 Binary files /dev/null and b/ui/public/character-kit-presets/mouths/watercolor-rose/small.png differ diff --git a/ui/public/character-kit-presets/mouths/watercolor-rose/tongue.png b/ui/public/character-kit-presets/mouths/watercolor-rose/tongue.png new file mode 100644 index 000000000..81c119ce2 Binary files /dev/null and b/ui/public/character-kit-presets/mouths/watercolor-rose/tongue.png differ diff --git a/ui/public/character-kit-presets/mouths/watercolor-rose/wide.png b/ui/public/character-kit-presets/mouths/watercolor-rose/wide.png new file mode 100644 index 000000000..12b74571c Binary files /dev/null and b/ui/public/character-kit-presets/mouths/watercolor-rose/wide.png differ diff --git a/ui/public/character-kit-presets/mouths/woodblock/bite.png b/ui/public/character-kit-presets/mouths/woodblock/bite.png new file mode 100644 index 000000000..406499bc4 Binary files /dev/null and b/ui/public/character-kit-presets/mouths/woodblock/bite.png differ diff --git a/ui/public/character-kit-presets/mouths/woodblock/closed.png b/ui/public/character-kit-presets/mouths/woodblock/closed.png new file mode 100644 index 000000000..a1bfd38cf Binary files /dev/null and b/ui/public/character-kit-presets/mouths/woodblock/closed.png differ diff --git a/ui/public/character-kit-presets/mouths/woodblock/medium.png b/ui/public/character-kit-presets/mouths/woodblock/medium.png new file mode 100644 index 000000000..3847b9f6d Binary files /dev/null and b/ui/public/character-kit-presets/mouths/woodblock/medium.png differ diff --git a/ui/public/character-kit-presets/mouths/woodblock/pressed.png b/ui/public/character-kit-presets/mouths/woodblock/pressed.png new file mode 100644 index 000000000..b6b260b18 Binary files /dev/null and b/ui/public/character-kit-presets/mouths/woodblock/pressed.png differ diff --git a/ui/public/character-kit-presets/mouths/woodblock/pucker.png b/ui/public/character-kit-presets/mouths/woodblock/pucker.png new file mode 100644 index 000000000..b14447298 Binary files /dev/null and b/ui/public/character-kit-presets/mouths/woodblock/pucker.png differ diff --git a/ui/public/character-kit-presets/mouths/woodblock/round.png b/ui/public/character-kit-presets/mouths/woodblock/round.png new file mode 100644 index 000000000..052373d4c Binary files /dev/null and b/ui/public/character-kit-presets/mouths/woodblock/round.png differ diff --git a/ui/public/character-kit-presets/mouths/woodblock/small.png b/ui/public/character-kit-presets/mouths/woodblock/small.png new file mode 100644 index 000000000..ee87621c2 Binary files /dev/null and b/ui/public/character-kit-presets/mouths/woodblock/small.png differ diff --git a/ui/public/character-kit-presets/mouths/woodblock/tongue.png b/ui/public/character-kit-presets/mouths/woodblock/tongue.png new file mode 100644 index 000000000..f6ed0e94e Binary files /dev/null and b/ui/public/character-kit-presets/mouths/woodblock/tongue.png differ diff --git a/ui/public/character-kit-presets/mouths/woodblock/wide.png b/ui/public/character-kit-presets/mouths/woodblock/wide.png new file mode 100644 index 000000000..c1454f926 Binary files /dev/null and b/ui/public/character-kit-presets/mouths/woodblock/wide.png differ diff --git a/ui/public/examples/cut-paper/BIBLIA.md b/ui/public/examples/cut-paper/BIBLIA.md new file mode 100644 index 000000000..09e326ce7 --- /dev/null +++ b/ui/public/examples/cut-paper/BIBLIA.md @@ -0,0 +1,64 @@ +# Tijeral — biblia corta (kit de recortables) + +Serie original de HocusPocus. **No** es un capítulo ni un elenco ajenos. +Técnica: cartulina fotografiada, marionetas planas, bocas intercambiables, +diálogo primero. Un gag de 78 s, no un episodio de 22 min. + +## Pueblo + +**Tijeral**, en la **Sierra Cartulina**. Tejados de teja recortada (festón), +calles de cartulina gris-azul, una fuente de discos apilados. Hay invierno, +pero la arquitectura es de pueblo de sierra (muros claros, madera recortada), +no un suburbio con parada de autobús como identidad. + +Paleta: marino, mostaza, oliva, óxido, crema, carbón, raya teal-crema. +Textura: cartulina scanned, borde irregular de tijera, sombra de papel entre +capas. + +## Elenco + +| Id | Nombre | Silueta | Voz | Notas | +|---|---|---|---|---| +| `nilo` | Nilo Carda | rectángulo muy alto y fino | grave, lenta, precisa | Peto marino, gafas de clip, pelo a tiras. Inventa cola. | +| `berta` | Berta Miga | pentágono bajo y ancho | nasal, rápida | Chubasquero **mostaza** (no naranja), moños de papel, pecas a perforadora. | +| `kito` | Kito Veleta | triángulo minúsculo + sombrero barco | aguda, frases cortas | Barco de papel a rayas teal-crema. **No** gorro de pompón. | +| `rami` | Rami Tambo | bloque cuadrado con tambor | ronca, pocas palabras | Chaleco oliva remendado, tambor de cartón. | +| `paca` | Doña Paca | trapecio muy alto | seca, adulta | Conserje del cole, delantal carbón, moño-rollo. | +| `lino` | Lino Horna | óvalo ancho | cálida | Panadero, delantal óxido, harina = papel rasgado blanco. | + +## Localizaciones + +1. Plaza nevada (fuente, tejados, nieve de papel rasgado). No es una parada. +2. Porche de la panadería. +3. Aula del cole. +4. Cocina de Doña Paca. +5. Sierra al fondo (montañas plegadas). + +Cámara frontal o ¾ muy plano. + +## Voces + +TTS local o WAV propio. Perfiles distintos (grave / nasal / aguda / ronca / +seca / cálida). **Prohibido** clonar actores. + +## Cara + +La cara es un **rectángulo frontal** pegado a la cabeza de papel (ojos, nariz, +boca). No un retrato redondo en un círculo. Cejas/ojos independientes de la +vocal. Bocas: `closed small wide round` (el compositor 2D); el mazo 9×6 +`rest M A E I O U F L` × expresiones se documenta en HOWTO para Video 3D. + +## Prohibido + +- Parka naranja icónica, gorro naranja+turquesa. +- Cuatro críos en una parada nevada como “el show”. +- `tv-head-humanoid.glb` como cuerpo. +- Voces clonadas, nombres de series ajenas. + +## Escena piloto (78 s) + +1. 0–4 s: plaza. +2. 6–38 s: Nilo y Berta discuten si la fuente está congelada o alguien pegó + papel cebolla. +3. 54–68 s: Kito entra de lado en un trineo de papel; el “hielo” se despega + (era un sticker). diff --git a/ui/public/examples/cut-paper/HOWTO.md b/ui/public/examples/cut-paper/HOWTO.md new file mode 100644 index 000000000..ea8893801 --- /dev/null +++ b/ui/public/examples/cut-paper/HOWTO.md @@ -0,0 +1,51 @@ +# Tijeral — how to make the cut-paper chapter in HocusPocus + +The example is a **Story Lab** chapter. Each beat opens **Video 2D** (Scene Animator) +so you can edit the shot. The assembled episode is **not** an MP4 baked outside Hocus. + +## Path (do this in the app) + +1. **Story Lab** → **Load Tijeral cut-paper example**. + Lore (world, cast, relationships, structure) is already filled. + Nilo, Berta, Kito, Rami, Paca and Lino are also inserted into the + Character Kit library of the current workspace (skipped if that id already + exists). Story characters link those kits (`characterKitRef`). +2. Open **Structure**. Each beat has **Open in Video 2D**. + - Plano 1 plaza → establishing shot + - Plano 2 cola fría → Nilo / Berta dialogue + - Plano 3 sticker → Kito slides in +3. In **Video 2D** you can move layers, swap mouths, attach speech, export MP4. + Each speaking puppet is **one transparent body** plus four small paper visemes + (`closed` `small` `wide` `round`) parented to that body. Do not stack opaque + full-face copies. Talking must not change the brows. +4. Optional: a later beat can use **Video 3D** (`sceneLink.editor = video3d`) if a shot needs depth. This gag stays 2D. +5. Voices: each library character has a local Qwen3 CustomVoice preset. + Example WAVs exist in **Spanish** (`vo-*-nilo-1.wav`) and **English** + (`vo-*-nilo-1-en.wav`). Import `shots/` or `shots/en/`. Mouth visemes use + Hocus audio analysis (`aligned-audio`) then **Export MP4** from Video 2.5D. + Do not clone actors. The planner treats spoken vowels in Spanish and English + (`you`/`see`/`hielo`); consonants are not silence. +6. MiniMax Image uses the linked kit still (`identityReference` or body) as + `subject_reference`. Bundled `/examples/` stills are uploaded first. + +## What you author vs what the kit ships + +| You (in Hocus) | Kit (bundled) | +|---|---| +| Edit lore in Story Lab | Bible + filled Story project | +| Open/edit each beat in Video 2D | Three `.maestro-scene.json` shots | +| Generate more puppets in Character Kit (style Recorte de papel) | Nilo, Berta, Kito stills | +| Record or Qwen-TTS the lines | Example WAVs to save time | +| Export MP4 from Video 2D | — | + +## Face = front card + +Square/rectangle glued on the paper head. Not a round portrait in a circle. +Not `tv-head-humanoid.glb`. + +## Files + +- `ui/src/features/cutPaper/characterKits.ts` — library characters + Qwen presets +- `ui/src/features/cutPaper/storyProject.ts` — Story Lab chapter +- `ui/src/features/cutPaper/pilot.ts` — Video 2D compilers +- `ui/public/examples/cut-paper/shots/` — one scene per beat diff --git a/ui/public/examples/cut-paper/PROVENANCE.md b/ui/public/examples/cut-paper/PROVENANCE.md new file mode 100644 index 000000000..1bae78be1 --- /dev/null +++ b/ui/public/examples/cut-paper/PROVENANCE.md @@ -0,0 +1,19 @@ +# Provenance — Tijeral cut-paper kit + +| Asset | Method | Notes | +|---|---|---| +| `cardboard.png` | Imagine `image_gen` | Seamless cream cardstock. | +| `puppets/nilo-canonical.jpg` | Imagine `image_gen` | Original paper inventor; square face card; navy dungarees. | +| `puppets/nilo-face.png` | Imagine `image_edit` from canonical | Square frontal face only. | +| `mouths/nilo-*.png` | Imagine `image_edit` from face | Mouth only; brows stay put. | +| `locations/plaza.png` | Imagine `image_edit` from cardboard + Nilo style | Paper plaza, not a bus stop. | +| `props/onion-paper.png` | Imagine `image_edit` from cardboard | Tracing-paper sticker. | +| remaining pieces | copies of canonical until the rest of the cast is signed off | Temporary. | +| `puppets/berta-canonical.jpg` | Imagine `image_gen` | Mustard slicker, square face. | +| `puppets/kito-canonical.jpg` | Imagine `image_gen` + `image_edit` | Paper-boat hat; name label removed. | +| `puppets/*-body.png` | rembg cutout from canonical | One transparent body per speaking puppet. | +| `mouths/paper-*.png` | Drawn paper visemes | Small cream+ink ovals (`closed` is empty). Not lipstick cards, not full-face copies. | +| `voices/vo-*-1.wav` | Qwen3 CustomVoice 12 Hz 1.7B | Spanish lines. Presets `dylan` / `serena` / `sohee`. | +| `voices/vo-*-en.wav` | Qwen3 CustomVoice 12 Hz 1.7B | English lines, same speakers. Speak-the-written-language. Not cloned actors. | + +No private GLB. No `tv-head-humanoid.glb`. diff --git a/ui/public/examples/cut-paper/brows/berta-neutral.png b/ui/public/examples/cut-paper/brows/berta-neutral.png new file mode 100644 index 000000000..c183912ee Binary files /dev/null and b/ui/public/examples/cut-paper/brows/berta-neutral.png differ diff --git a/ui/public/examples/cut-paper/brows/kito-neutral.png b/ui/public/examples/cut-paper/brows/kito-neutral.png new file mode 100644 index 000000000..30a68d04c Binary files /dev/null and b/ui/public/examples/cut-paper/brows/kito-neutral.png differ diff --git a/ui/public/examples/cut-paper/brows/nilo-neutral.png b/ui/public/examples/cut-paper/brows/nilo-neutral.png new file mode 100644 index 000000000..0c1574cb1 Binary files /dev/null and b/ui/public/examples/cut-paper/brows/nilo-neutral.png differ diff --git a/ui/public/examples/cut-paper/cardboard.png b/ui/public/examples/cut-paper/cardboard.png new file mode 100644 index 000000000..dbd804998 Binary files /dev/null and b/ui/public/examples/cut-paper/cardboard.png differ diff --git a/ui/public/examples/cut-paper/locations/plaza.png b/ui/public/examples/cut-paper/locations/plaza.png new file mode 100644 index 000000000..1d549f6ff Binary files /dev/null and b/ui/public/examples/cut-paper/locations/plaza.png differ diff --git a/ui/public/examples/cut-paper/mouths/berta-closed.png b/ui/public/examples/cut-paper/mouths/berta-closed.png new file mode 100644 index 000000000..c183912ee Binary files /dev/null and b/ui/public/examples/cut-paper/mouths/berta-closed.png differ diff --git a/ui/public/examples/cut-paper/mouths/berta-round.png b/ui/public/examples/cut-paper/mouths/berta-round.png new file mode 100644 index 000000000..29071b449 Binary files /dev/null and b/ui/public/examples/cut-paper/mouths/berta-round.png differ diff --git a/ui/public/examples/cut-paper/mouths/berta-small.png b/ui/public/examples/cut-paper/mouths/berta-small.png new file mode 100644 index 000000000..a3bc10ed2 Binary files /dev/null and b/ui/public/examples/cut-paper/mouths/berta-small.png differ diff --git a/ui/public/examples/cut-paper/mouths/berta-wide.png b/ui/public/examples/cut-paper/mouths/berta-wide.png new file mode 100644 index 000000000..c0758f7e6 Binary files /dev/null and b/ui/public/examples/cut-paper/mouths/berta-wide.png differ diff --git a/ui/public/examples/cut-paper/mouths/kito-closed.png b/ui/public/examples/cut-paper/mouths/kito-closed.png new file mode 100644 index 000000000..30a68d04c Binary files /dev/null and b/ui/public/examples/cut-paper/mouths/kito-closed.png differ diff --git a/ui/public/examples/cut-paper/mouths/kito-round.png b/ui/public/examples/cut-paper/mouths/kito-round.png new file mode 100644 index 000000000..29071b449 Binary files /dev/null and b/ui/public/examples/cut-paper/mouths/kito-round.png differ diff --git a/ui/public/examples/cut-paper/mouths/kito-small.png b/ui/public/examples/cut-paper/mouths/kito-small.png new file mode 100644 index 000000000..a3bc10ed2 Binary files /dev/null and b/ui/public/examples/cut-paper/mouths/kito-small.png differ diff --git a/ui/public/examples/cut-paper/mouths/kito-wide.png b/ui/public/examples/cut-paper/mouths/kito-wide.png new file mode 100644 index 000000000..c0758f7e6 Binary files /dev/null and b/ui/public/examples/cut-paper/mouths/kito-wide.png differ diff --git a/ui/public/examples/cut-paper/mouths/nilo-closed.png b/ui/public/examples/cut-paper/mouths/nilo-closed.png new file mode 100644 index 000000000..0c1574cb1 Binary files /dev/null and b/ui/public/examples/cut-paper/mouths/nilo-closed.png differ diff --git a/ui/public/examples/cut-paper/mouths/nilo-round.png b/ui/public/examples/cut-paper/mouths/nilo-round.png new file mode 100644 index 000000000..29071b449 Binary files /dev/null and b/ui/public/examples/cut-paper/mouths/nilo-round.png differ diff --git a/ui/public/examples/cut-paper/mouths/nilo-small.png b/ui/public/examples/cut-paper/mouths/nilo-small.png new file mode 100644 index 000000000..a3bc10ed2 Binary files /dev/null and b/ui/public/examples/cut-paper/mouths/nilo-small.png differ diff --git a/ui/public/examples/cut-paper/mouths/nilo-wide.png b/ui/public/examples/cut-paper/mouths/nilo-wide.png new file mode 100644 index 000000000..c0758f7e6 Binary files /dev/null and b/ui/public/examples/cut-paper/mouths/nilo-wide.png differ diff --git a/ui/public/examples/cut-paper/mouths/paper-closed.png b/ui/public/examples/cut-paper/mouths/paper-closed.png new file mode 100644 index 000000000..449fe64fc Binary files /dev/null and b/ui/public/examples/cut-paper/mouths/paper-closed.png differ diff --git a/ui/public/examples/cut-paper/mouths/paper-round.png b/ui/public/examples/cut-paper/mouths/paper-round.png new file mode 100644 index 000000000..d3a54c52b Binary files /dev/null and b/ui/public/examples/cut-paper/mouths/paper-round.png differ diff --git a/ui/public/examples/cut-paper/mouths/paper-small.png b/ui/public/examples/cut-paper/mouths/paper-small.png new file mode 100644 index 000000000..20353403e Binary files /dev/null and b/ui/public/examples/cut-paper/mouths/paper-small.png differ diff --git a/ui/public/examples/cut-paper/mouths/paper-wide.png b/ui/public/examples/cut-paper/mouths/paper-wide.png new file mode 100644 index 000000000..c33bd7f75 Binary files /dev/null and b/ui/public/examples/cut-paper/mouths/paper-wide.png differ diff --git a/ui/public/examples/cut-paper/props/onion-paper.png b/ui/public/examples/cut-paper/props/onion-paper.png new file mode 100644 index 000000000..874f50126 Binary files /dev/null and b/ui/public/examples/cut-paper/props/onion-paper.png differ diff --git a/ui/public/examples/cut-paper/puppets/berta-arm-back.png b/ui/public/examples/cut-paper/puppets/berta-arm-back.png new file mode 100644 index 000000000..00e1f2ee3 Binary files /dev/null and b/ui/public/examples/cut-paper/puppets/berta-arm-back.png differ diff --git a/ui/public/examples/cut-paper/puppets/berta-arm-front.png b/ui/public/examples/cut-paper/puppets/berta-arm-front.png new file mode 100644 index 000000000..00e1f2ee3 Binary files /dev/null and b/ui/public/examples/cut-paper/puppets/berta-arm-front.png differ diff --git a/ui/public/examples/cut-paper/puppets/berta-body.png b/ui/public/examples/cut-paper/puppets/berta-body.png new file mode 100644 index 000000000..bb3f86100 Binary files /dev/null and b/ui/public/examples/cut-paper/puppets/berta-body.png differ diff --git a/ui/public/examples/cut-paper/puppets/berta-canonical.jpg b/ui/public/examples/cut-paper/puppets/berta-canonical.jpg new file mode 100644 index 000000000..00e1f2ee3 Binary files /dev/null and b/ui/public/examples/cut-paper/puppets/berta-canonical.jpg differ diff --git a/ui/public/examples/cut-paper/puppets/berta-face.png b/ui/public/examples/cut-paper/puppets/berta-face.png new file mode 100644 index 000000000..c183912ee Binary files /dev/null and b/ui/public/examples/cut-paper/puppets/berta-face.png differ diff --git a/ui/public/examples/cut-paper/puppets/berta-hat.png b/ui/public/examples/cut-paper/puppets/berta-hat.png new file mode 100644 index 000000000..00e1f2ee3 Binary files /dev/null and b/ui/public/examples/cut-paper/puppets/berta-hat.png differ diff --git a/ui/public/examples/cut-paper/puppets/berta-head.png b/ui/public/examples/cut-paper/puppets/berta-head.png new file mode 100644 index 000000000..00e1f2ee3 Binary files /dev/null and b/ui/public/examples/cut-paper/puppets/berta-head.png differ diff --git a/ui/public/examples/cut-paper/puppets/berta-legs.png b/ui/public/examples/cut-paper/puppets/berta-legs.png new file mode 100644 index 000000000..00e1f2ee3 Binary files /dev/null and b/ui/public/examples/cut-paper/puppets/berta-legs.png differ diff --git a/ui/public/examples/cut-paper/puppets/berta-torso.png b/ui/public/examples/cut-paper/puppets/berta-torso.png new file mode 100644 index 000000000..00e1f2ee3 Binary files /dev/null and b/ui/public/examples/cut-paper/puppets/berta-torso.png differ diff --git a/ui/public/examples/cut-paper/puppets/kito-arm-back.png b/ui/public/examples/cut-paper/puppets/kito-arm-back.png new file mode 100644 index 000000000..503f0bf95 Binary files /dev/null and b/ui/public/examples/cut-paper/puppets/kito-arm-back.png differ diff --git a/ui/public/examples/cut-paper/puppets/kito-arm-front.png b/ui/public/examples/cut-paper/puppets/kito-arm-front.png new file mode 100644 index 000000000..503f0bf95 Binary files /dev/null and b/ui/public/examples/cut-paper/puppets/kito-arm-front.png differ diff --git a/ui/public/examples/cut-paper/puppets/kito-body.png b/ui/public/examples/cut-paper/puppets/kito-body.png new file mode 100644 index 000000000..c1474db79 Binary files /dev/null and b/ui/public/examples/cut-paper/puppets/kito-body.png differ diff --git a/ui/public/examples/cut-paper/puppets/kito-canonical.jpg b/ui/public/examples/cut-paper/puppets/kito-canonical.jpg new file mode 100644 index 000000000..503f0bf95 Binary files /dev/null and b/ui/public/examples/cut-paper/puppets/kito-canonical.jpg differ diff --git a/ui/public/examples/cut-paper/puppets/kito-face.png b/ui/public/examples/cut-paper/puppets/kito-face.png new file mode 100644 index 000000000..30a68d04c Binary files /dev/null and b/ui/public/examples/cut-paper/puppets/kito-face.png differ diff --git a/ui/public/examples/cut-paper/puppets/kito-hat.png b/ui/public/examples/cut-paper/puppets/kito-hat.png new file mode 100644 index 000000000..503f0bf95 Binary files /dev/null and b/ui/public/examples/cut-paper/puppets/kito-hat.png differ diff --git a/ui/public/examples/cut-paper/puppets/kito-head.png b/ui/public/examples/cut-paper/puppets/kito-head.png new file mode 100644 index 000000000..503f0bf95 Binary files /dev/null and b/ui/public/examples/cut-paper/puppets/kito-head.png differ diff --git a/ui/public/examples/cut-paper/puppets/kito-legs.png b/ui/public/examples/cut-paper/puppets/kito-legs.png new file mode 100644 index 000000000..503f0bf95 Binary files /dev/null and b/ui/public/examples/cut-paper/puppets/kito-legs.png differ diff --git a/ui/public/examples/cut-paper/puppets/kito-torso.png b/ui/public/examples/cut-paper/puppets/kito-torso.png new file mode 100644 index 000000000..503f0bf95 Binary files /dev/null and b/ui/public/examples/cut-paper/puppets/kito-torso.png differ diff --git a/ui/public/examples/cut-paper/puppets/nilo-arm-back.png b/ui/public/examples/cut-paper/puppets/nilo-arm-back.png new file mode 100644 index 000000000..4cb377ae8 Binary files /dev/null and b/ui/public/examples/cut-paper/puppets/nilo-arm-back.png differ diff --git a/ui/public/examples/cut-paper/puppets/nilo-arm-front.png b/ui/public/examples/cut-paper/puppets/nilo-arm-front.png new file mode 100644 index 000000000..4cb377ae8 Binary files /dev/null and b/ui/public/examples/cut-paper/puppets/nilo-arm-front.png differ diff --git a/ui/public/examples/cut-paper/puppets/nilo-body.png b/ui/public/examples/cut-paper/puppets/nilo-body.png new file mode 100644 index 000000000..e55ca49d9 Binary files /dev/null and b/ui/public/examples/cut-paper/puppets/nilo-body.png differ diff --git a/ui/public/examples/cut-paper/puppets/nilo-canonical.jpg b/ui/public/examples/cut-paper/puppets/nilo-canonical.jpg new file mode 100644 index 000000000..4cb377ae8 Binary files /dev/null and b/ui/public/examples/cut-paper/puppets/nilo-canonical.jpg differ diff --git a/ui/public/examples/cut-paper/puppets/nilo-face.png b/ui/public/examples/cut-paper/puppets/nilo-face.png new file mode 100644 index 000000000..0c1574cb1 Binary files /dev/null and b/ui/public/examples/cut-paper/puppets/nilo-face.png differ diff --git a/ui/public/examples/cut-paper/puppets/nilo-hat.png b/ui/public/examples/cut-paper/puppets/nilo-hat.png new file mode 100644 index 000000000..4cb377ae8 Binary files /dev/null and b/ui/public/examples/cut-paper/puppets/nilo-hat.png differ diff --git a/ui/public/examples/cut-paper/puppets/nilo-head.png b/ui/public/examples/cut-paper/puppets/nilo-head.png new file mode 100644 index 000000000..4cb377ae8 Binary files /dev/null and b/ui/public/examples/cut-paper/puppets/nilo-head.png differ diff --git a/ui/public/examples/cut-paper/puppets/nilo-legs.png b/ui/public/examples/cut-paper/puppets/nilo-legs.png new file mode 100644 index 000000000..4cb377ae8 Binary files /dev/null and b/ui/public/examples/cut-paper/puppets/nilo-legs.png differ diff --git a/ui/public/examples/cut-paper/puppets/nilo-torso.png b/ui/public/examples/cut-paper/puppets/nilo-torso.png new file mode 100644 index 000000000..4cb377ae8 Binary files /dev/null and b/ui/public/examples/cut-paper/puppets/nilo-torso.png differ diff --git a/ui/public/examples/cut-paper/script.en.txt b/ui/public/examples/cut-paper/script.en.txt new file mode 100644 index 000000000..b896b8693 --- /dev/null +++ b/ui/public/examples/cut-paper/script.en.txt @@ -0,0 +1,13 @@ +Tijeral · the fountain +78 s · 3 shots · dialogue first + +SHOT 1 0.0–4.0 Snowy plaza, paper-disc fountain. + +SHOT 2 4.0–54.0 Nilo (left) and Berta (right) at the fountain. + 6–16 NILO The fountain is not frozen. Someone stuck a square of tracing paper on it. + 17–24 BERTA Well it tastes like ice. I tried it. + 25–30 NILO Berta, that's glue. + 31–38 BERTA Cold glue. Like ice. + +SHOT 3 54.0–78.0 Kito slides in on a paper sled; the square peels off. + 62–68 KITO It was a sticker! diff --git a/ui/public/examples/cut-paper/script.es.txt b/ui/public/examples/cut-paper/script.es.txt new file mode 100644 index 000000000..df11dacff --- /dev/null +++ b/ui/public/examples/cut-paper/script.es.txt @@ -0,0 +1,13 @@ +Tijeral · la fuente +Duración 78 s · 3 planos · diálogo primero + +PLANO 1 0.0–4.0 Plaza nevada, fuente de discos de papel. + +PLANO 2 4.0–54.0 Nilo (izq) y Berta (der) ante la fuente. + 6–16 NILO La fuente no está congelada. Alguien le pegó un cuadrado de papel cebolla. + 17–24 BERTA Pues sabe a hielo. Lo probé. + 25–30 NILO Berta, eso es cola. + 31–38 BERTA Cola fría. Como hielo. + +PLANO 3 54.0–78.0 Kito entra de lado en un trineo de papel; el cuadrado se despega. + 62–68 KITO ¡Era un sticker! diff --git a/ui/public/examples/cut-paper/shots/01-plaza.maestro-scene.json b/ui/public/examples/cut-paper/shots/01-plaza.maestro-scene.json new file mode 100644 index 000000000..767bbd207 --- /dev/null +++ b/ui/public/examples/cut-paper/shots/01-plaza.maestro-scene.json @@ -0,0 +1,198 @@ +{ + "version": 1, + "name": "Tijeral · plano 1 plaza", + "width": 1280, + "height": 720, + "fps": 30, + "duration": 6, + "layers": [ + { + "id": "camera", + "name": "Camera", + "type": "camera", + "source": "", + "visible": true, + "locked": false, + "z": 100, + "transform": { + "x": 50, + "y": 50, + "scale": 1, + "opacity": 1, + "rotation": 0 + }, + "animation": { + "start": { + "x": 50, + "y": 50, + "scale": 1, + "opacity": 1, + "rotation": 0 + }, + "end": { + "x": 50, + "y": 50, + "scale": 1.04, + "opacity": 1, + "rotation": 0 + }, + "duration": 6, + "curve": "ease", + "keyframes": [ + { + "id": "camera-0", + "time": 0, + "x": 50, + "y": 50, + "scale": 1, + "opacity": 1, + "rotation": 0, + "curve": "ease" + }, + { + "id": "camera-6000", + "time": 6, + "x": 50, + "y": 50, + "scale": 1.04, + "opacity": 1, + "rotation": 0, + "curve": "ease" + } + ] + } + }, + { + "id": "location-plaza", + "name": "Location plaza", + "type": "image", + "source": "/examples/cut-paper/locations/plaza.png", + "visible": true, + "locked": false, + "z": 0, + "transform": { + "x": 50, + "y": 50, + "scale": 1, + "opacity": 1, + "rotation": 0 + }, + "animation": { + "start": { + "x": 50, + "y": 50, + "scale": 1, + "opacity": 1, + "rotation": 0 + }, + "end": { + "x": 50, + "y": 50, + "scale": 1, + "opacity": 1, + "rotation": 0 + }, + "duration": 6, + "curve": "hold", + "keyframes": [ + { + "id": "location-plaza-0", + "time": 0, + "x": 50, + "y": 50, + "scale": 1, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "location-plaza-6000", + "time": 6, + "x": 50, + "y": 50, + "scale": 1, + "opacity": 1, + "rotation": 0, + "curve": "hold" + } + ] + }, + "fill": true, + "parallax": 0.15 + }, + { + "id": "sticker-ice", + "name": "Papel cebolla", + "type": "image", + "source": "/examples/cut-paper/props/onion-paper.png", + "visible": true, + "locked": false, + "z": 8, + "transform": { + "x": 50, + "y": 58, + "scale": 0.22, + "opacity": 1, + "rotation": -6 + }, + "animation": { + "start": { + "x": 50, + "y": 58, + "scale": 0.22, + "opacity": 1, + "rotation": -6 + }, + "end": { + "x": 50, + "y": 58, + "scale": 0.22, + "opacity": 1, + "rotation": -6 + }, + "duration": 6, + "curve": "ease", + "keyframes": [ + { + "id": "ice-0", + "time": 0, + "x": 50, + "y": 58, + "scale": 0.22, + "opacity": 1, + "rotation": -6, + "curve": "hold" + }, + { + "id": "sticker-ice-6000", + "time": 6, + "x": 50, + "y": 58, + "scale": 0.22, + "opacity": 1, + "rotation": -6, + "curve": "hold" + } + ] + }, + "parallax": 0.4 + } + ], + "generationPolicy": "provided_only", + "dialogueBeats": [], + "texts": [ + { + "id": "title", + "text": "Tijeral", + "start": 0.4, + "end": 3.6, + "preset": "rise", + "x": 50, + "y": 12, + "size": 7, + "color": "#1d2b5a", + "rotation": 0 + } + ], + "audioTracks": [] +} \ No newline at end of file diff --git a/ui/public/examples/cut-paper/shots/02-talk.maestro-scene.json b/ui/public/examples/cut-paper/shots/02-talk.maestro-scene.json new file mode 100644 index 000000000..848e9c86c --- /dev/null +++ b/ui/public/examples/cut-paper/shots/02-talk.maestro-scene.json @@ -0,0 +1,2453 @@ +{ + "version": 1, + "name": "Tijeral · plano 2 cola fría", + "width": 1280, + "height": 720, + "fps": 30, + "duration": 40, + "layers": [ + { + "id": "camera", + "name": "Camera", + "type": "camera", + "source": "", + "visible": true, + "locked": false, + "z": 100, + "transform": { + "x": 50, + "y": 50, + "scale": 1, + "opacity": 1, + "rotation": 0 + }, + "animation": { + "start": { + "x": 50, + "y": 50, + "scale": 1, + "opacity": 1, + "rotation": 0 + }, + "end": { + "x": 50, + "y": 50, + "scale": 1.04, + "opacity": 1, + "rotation": 0 + }, + "duration": 40, + "curve": "ease", + "keyframes": [ + { + "id": "camera-0", + "time": 0, + "x": 50, + "y": 50, + "scale": 1, + "opacity": 1, + "rotation": 0, + "curve": "ease" + }, + { + "id": "camera-40000", + "time": 40, + "x": 50, + "y": 50, + "scale": 1.04, + "opacity": 1, + "rotation": 0, + "curve": "ease" + } + ] + } + }, + { + "id": "location-plaza", + "name": "Location plaza", + "type": "image", + "source": "/examples/cut-paper/locations/plaza.png", + "visible": true, + "locked": false, + "z": 0, + "transform": { + "x": 50, + "y": 50, + "scale": 1, + "opacity": 1, + "rotation": 0 + }, + "animation": { + "start": { + "x": 50, + "y": 50, + "scale": 1, + "opacity": 1, + "rotation": 0 + }, + "end": { + "x": 50, + "y": 50, + "scale": 1, + "opacity": 1, + "rotation": 0 + }, + "duration": 40, + "curve": "hold", + "keyframes": [ + { + "id": "location-plaza-0", + "time": 0, + "x": 50, + "y": 50, + "scale": 1, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "location-plaza-40000", + "time": 40, + "x": 50, + "y": 50, + "scale": 1, + "opacity": 1, + "rotation": 0, + "curve": "hold" + } + ] + }, + "fill": true, + "parallax": 0.15 + }, + { + "id": "puppet-nilo", + "name": "Nilo Carda pose", + "type": "image", + "source": "/examples/cut-paper/puppets/nilo-body.png", + "visible": true, + "locked": false, + "z": 32, + "transform": { + "x": 36, + "y": 62, + "scale": 0.42, + "opacity": 1, + "rotation": 0 + }, + "animation": { + "start": { + "x": 36, + "y": 62, + "scale": 0.42, + "opacity": 1, + "rotation": 0 + }, + "end": { + "x": 36, + "y": 62, + "scale": 0.42, + "opacity": 1, + "rotation": 0 + }, + "duration": 40, + "curve": "hold", + "keyframes": [ + { + "id": "puppet-nilo-0", + "time": 0, + "x": 36, + "y": 62, + "scale": 0.42, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-40000", + "time": 40, + "x": 36, + "y": 62, + "scale": 0.42, + "opacity": 1, + "rotation": 0, + "curve": "hold" + } + ] + }, + "parallax": 1, + "effects": { + "shadow": 0.25, + "blendMode": "normal", + "mask": "none", + "maskRadius": 12, + "blur": 0, + "brightness": 1, + "contrast": 1, + "saturation": 1, + "hue": 0, + "glow": 0 + } + }, + { + "id": "puppet-nilo-mouth-closed", + "name": "Nilo Carda mouth closed", + "type": "image", + "source": "/examples/cut-paper/mouths/paper-closed.png", + "visible": true, + "locked": false, + "z": 40, + "transform": { + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0 + }, + "animation": { + "start": { + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 1, + "rotation": 0 + }, + "end": { + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 1, + "rotation": 0 + }, + "duration": 40, + "curve": "hold", + "keyframes": [ + { + "id": "puppet-nilo-mouth-closed-dialogue-0", + "time": 0, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-closed-dialogue-6000", + "time": 6, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-closed-dialogue-6140", + "time": 6.14, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-closed-dialogue-6560", + "time": 6.5600000000000005, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-closed-dialogue-6820", + "time": 6.82, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-closed-dialogue-7020", + "time": 7.02, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-closed-dialogue-7720", + "time": 7.72, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-closed-dialogue-8280", + "time": 8.28, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-closed-dialogue-8660", + "time": 8.66, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-closed-dialogue-8720", + "time": 8.72, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-closed-dialogue-9020", + "time": 9.02, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-closed-dialogue-9080", + "time": 9.08, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-closed-dialogue-9540", + "time": 9.54, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-closed-dialogue-9660", + "time": 9.66, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-closed-dialogue-9920", + "time": 9.92, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-closed-dialogue-10440", + "time": 10.440000000000001, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-closed-dialogue-25000", + "time": 25, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-closed-dialogue-26737", + "time": 26.737000000000002, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-closed-40000", + "time": 40, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 1, + "rotation": 0, + "curve": "hold" + } + ] + }, + "relationship": { + "type": "parent", + "targetLayerId": "puppet-nilo" + }, + "faceBinding": { + "poseLayerId": "puppet-nilo", + "role": "mouth", + "state": "closed" + } + }, + { + "id": "puppet-nilo-mouth-small", + "name": "Nilo Carda mouth small", + "type": "image", + "source": "/examples/cut-paper/mouths/paper-small.png", + "visible": true, + "locked": false, + "z": 40, + "transform": { + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0 + }, + "animation": { + "start": { + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0 + }, + "end": { + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0 + }, + "duration": 40, + "curve": "hold", + "keyframes": [ + { + "id": "puppet-nilo-mouth-small-dialogue-0", + "time": 0, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-small-dialogue-6000", + "time": 6, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-small-dialogue-6140", + "time": 6.14, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-small-dialogue-6560", + "time": 6.5600000000000005, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-small-dialogue-6820", + "time": 6.82, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-small-dialogue-7020", + "time": 7.02, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-small-dialogue-7720", + "time": 7.72, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-small-dialogue-8280", + "time": 8.28, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-small-dialogue-8660", + "time": 8.66, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-small-dialogue-8720", + "time": 8.72, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-small-dialogue-9020", + "time": 9.02, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-small-dialogue-9080", + "time": 9.08, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-small-dialogue-9540", + "time": 9.54, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-small-dialogue-9660", + "time": 9.66, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-small-dialogue-9920", + "time": 9.92, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-small-dialogue-10440", + "time": 10.440000000000001, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-small-dialogue-25000", + "time": 25, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-small-dialogue-26737", + "time": 26.737000000000002, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-small-40000", + "time": 40, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + } + ] + }, + "relationship": { + "type": "parent", + "targetLayerId": "puppet-nilo" + }, + "faceBinding": { + "poseLayerId": "puppet-nilo", + "role": "mouth", + "state": "small" + } + }, + { + "id": "puppet-nilo-mouth-wide", + "name": "Nilo Carda mouth wide", + "type": "image", + "source": "/examples/cut-paper/mouths/paper-wide.png", + "visible": true, + "locked": false, + "z": 40, + "transform": { + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0 + }, + "animation": { + "start": { + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0 + }, + "end": { + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0 + }, + "duration": 40, + "curve": "hold", + "keyframes": [ + { + "id": "puppet-nilo-mouth-wide-dialogue-0", + "time": 0, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-wide-dialogue-6000", + "time": 6, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-wide-dialogue-6140", + "time": 6.14, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-wide-dialogue-6560", + "time": 6.5600000000000005, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-wide-dialogue-6820", + "time": 6.82, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-wide-dialogue-7020", + "time": 7.02, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-wide-dialogue-7720", + "time": 7.72, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-wide-dialogue-8280", + "time": 8.28, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-wide-dialogue-8660", + "time": 8.66, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-wide-dialogue-8720", + "time": 8.72, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-wide-dialogue-9020", + "time": 9.02, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-wide-dialogue-9080", + "time": 9.08, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-wide-dialogue-9540", + "time": 9.54, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-wide-dialogue-9660", + "time": 9.66, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-wide-dialogue-9920", + "time": 9.92, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-wide-dialogue-10440", + "time": 10.440000000000001, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-wide-dialogue-25000", + "time": 25, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-wide-dialogue-26737", + "time": 26.737000000000002, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-wide-40000", + "time": 40, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + } + ] + }, + "relationship": { + "type": "parent", + "targetLayerId": "puppet-nilo" + }, + "faceBinding": { + "poseLayerId": "puppet-nilo", + "role": "mouth", + "state": "wide" + } + }, + { + "id": "puppet-nilo-mouth-round", + "name": "Nilo Carda mouth round", + "type": "image", + "source": "/examples/cut-paper/mouths/paper-round.png", + "visible": true, + "locked": false, + "z": 40, + "transform": { + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0 + }, + "animation": { + "start": { + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0 + }, + "end": { + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0 + }, + "duration": 40, + "curve": "hold", + "keyframes": [ + { + "id": "puppet-nilo-mouth-round-dialogue-0", + "time": 0, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-round-dialogue-6000", + "time": 6, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-round-dialogue-6140", + "time": 6.14, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-round-dialogue-6560", + "time": 6.5600000000000005, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-round-dialogue-6820", + "time": 6.82, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-round-dialogue-7020", + "time": 7.02, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-round-dialogue-7720", + "time": 7.72, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-round-dialogue-8280", + "time": 8.28, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-round-dialogue-8660", + "time": 8.66, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-round-dialogue-8720", + "time": 8.72, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-round-dialogue-9020", + "time": 9.02, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-round-dialogue-9080", + "time": 9.08, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-round-dialogue-9540", + "time": 9.54, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-round-dialogue-9660", + "time": 9.66, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-round-dialogue-9920", + "time": 9.92, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-round-dialogue-10440", + "time": 10.440000000000001, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-round-dialogue-25000", + "time": 25, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-round-dialogue-26737", + "time": 26.737000000000002, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-round-40000", + "time": 40, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + } + ] + }, + "relationship": { + "type": "parent", + "targetLayerId": "puppet-nilo" + }, + "faceBinding": { + "poseLayerId": "puppet-nilo", + "role": "mouth", + "state": "round" + } + }, + { + "id": "puppet-berta", + "name": "Berta Miga pose", + "type": "image", + "source": "/examples/cut-paper/puppets/berta-body.png", + "visible": true, + "locked": false, + "z": 42, + "transform": { + "x": 62, + "y": 64, + "scale": 0.38, + "opacity": 1, + "rotation": 0 + }, + "animation": { + "start": { + "x": 62, + "y": 64, + "scale": 0.38, + "opacity": 1, + "rotation": 0 + }, + "end": { + "x": 62, + "y": 64, + "scale": 0.38, + "opacity": 1, + "rotation": 0 + }, + "duration": 40, + "curve": "hold", + "keyframes": [ + { + "id": "puppet-berta-0", + "time": 0, + "x": 62, + "y": 64, + "scale": 0.38, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-40000", + "time": 40, + "x": 62, + "y": 64, + "scale": 0.38, + "opacity": 1, + "rotation": 0, + "curve": "hold" + } + ] + }, + "parallax": 1, + "effects": { + "shadow": 0.25, + "blendMode": "normal", + "mask": "none", + "maskRadius": 12, + "blur": 0, + "brightness": 1, + "contrast": 1, + "saturation": 1, + "hue": 0, + "glow": 0 + } + }, + { + "id": "puppet-berta-mouth-closed", + "name": "Berta Miga mouth closed", + "type": "image", + "source": "/examples/cut-paper/mouths/paper-closed.png", + "visible": true, + "locked": false, + "z": 50, + "transform": { + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0 + }, + "animation": { + "start": { + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 1, + "rotation": 0 + }, + "end": { + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 1, + "rotation": 0 + }, + "duration": 40, + "curve": "hold", + "keyframes": [ + { + "id": "puppet-berta-mouth-closed-dialogue-0", + "time": 0, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-closed-dialogue-17000", + "time": 17, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-closed-dialogue-17400", + "time": 17.4, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-closed-dialogue-17880", + "time": 17.88, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-closed-dialogue-18060", + "time": 18.06, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-closed-dialogue-18660", + "time": 18.66, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-closed-dialogue-18920", + "time": 18.92, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-closed-dialogue-19180", + "time": 19.18, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-closed-dialogue-19620", + "time": 19.62, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-closed-dialogue-31000", + "time": 31, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-closed-dialogue-31380", + "time": 31.38, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-closed-dialogue-32100", + "time": 32.1, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-closed-dialogue-32300", + "time": 32.3, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-closed-dialogue-32640", + "time": 32.64, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-closed-dialogue-33320", + "time": 33.32, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-closed-40000", + "time": 40, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 1, + "rotation": 0, + "curve": "hold" + } + ] + }, + "relationship": { + "type": "parent", + "targetLayerId": "puppet-berta" + }, + "faceBinding": { + "poseLayerId": "puppet-berta", + "role": "mouth", + "state": "closed" + } + }, + { + "id": "puppet-berta-mouth-small", + "name": "Berta Miga mouth small", + "type": "image", + "source": "/examples/cut-paper/mouths/paper-small.png", + "visible": true, + "locked": false, + "z": 50, + "transform": { + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0 + }, + "animation": { + "start": { + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0 + }, + "end": { + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0 + }, + "duration": 40, + "curve": "hold", + "keyframes": [ + { + "id": "puppet-berta-mouth-small-dialogue-0", + "time": 0, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-small-dialogue-17000", + "time": 17, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-small-dialogue-17400", + "time": 17.4, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-small-dialogue-17880", + "time": 17.88, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-small-dialogue-18060", + "time": 18.06, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-small-dialogue-18660", + "time": 18.66, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-small-dialogue-18920", + "time": 18.92, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-small-dialogue-19180", + "time": 19.18, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-small-dialogue-19620", + "time": 19.62, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-small-dialogue-31000", + "time": 31, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-small-dialogue-31380", + "time": 31.38, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-small-dialogue-32100", + "time": 32.1, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-small-dialogue-32300", + "time": 32.3, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-small-dialogue-32640", + "time": 32.64, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-small-dialogue-33320", + "time": 33.32, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-small-40000", + "time": 40, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + } + ] + }, + "relationship": { + "type": "parent", + "targetLayerId": "puppet-berta" + }, + "faceBinding": { + "poseLayerId": "puppet-berta", + "role": "mouth", + "state": "small" + } + }, + { + "id": "puppet-berta-mouth-wide", + "name": "Berta Miga mouth wide", + "type": "image", + "source": "/examples/cut-paper/mouths/paper-wide.png", + "visible": true, + "locked": false, + "z": 50, + "transform": { + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0 + }, + "animation": { + "start": { + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0 + }, + "end": { + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0 + }, + "duration": 40, + "curve": "hold", + "keyframes": [ + { + "id": "puppet-berta-mouth-wide-dialogue-0", + "time": 0, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-wide-dialogue-17000", + "time": 17, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-wide-dialogue-17400", + "time": 17.4, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-wide-dialogue-17880", + "time": 17.88, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-wide-dialogue-18060", + "time": 18.06, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-wide-dialogue-18660", + "time": 18.66, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-wide-dialogue-18920", + "time": 18.92, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-wide-dialogue-19180", + "time": 19.18, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-wide-dialogue-19620", + "time": 19.62, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-wide-dialogue-31000", + "time": 31, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-wide-dialogue-31380", + "time": 31.38, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-wide-dialogue-32100", + "time": 32.1, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-wide-dialogue-32300", + "time": 32.3, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-wide-dialogue-32640", + "time": 32.64, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-wide-dialogue-33320", + "time": 33.32, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-wide-40000", + "time": 40, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + } + ] + }, + "relationship": { + "type": "parent", + "targetLayerId": "puppet-berta" + }, + "faceBinding": { + "poseLayerId": "puppet-berta", + "role": "mouth", + "state": "wide" + } + }, + { + "id": "puppet-berta-mouth-round", + "name": "Berta Miga mouth round", + "type": "image", + "source": "/examples/cut-paper/mouths/paper-round.png", + "visible": true, + "locked": false, + "z": 50, + "transform": { + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0 + }, + "animation": { + "start": { + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0 + }, + "end": { + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0 + }, + "duration": 40, + "curve": "hold", + "keyframes": [ + { + "id": "puppet-berta-mouth-round-dialogue-0", + "time": 0, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-round-dialogue-17000", + "time": 17, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-round-dialogue-17400", + "time": 17.4, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-round-dialogue-17880", + "time": 17.88, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-round-dialogue-18060", + "time": 18.06, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-round-dialogue-18660", + "time": 18.66, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-round-dialogue-18920", + "time": 18.92, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-round-dialogue-19180", + "time": 19.18, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-round-dialogue-19620", + "time": 19.62, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-round-dialogue-31000", + "time": 31, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-round-dialogue-31380", + "time": 31.38, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-round-dialogue-32100", + "time": 32.1, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-round-dialogue-32300", + "time": 32.3, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-round-dialogue-32640", + "time": 32.64, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-round-dialogue-33320", + "time": 33.32, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-round-40000", + "time": 40, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + } + ] + }, + "relationship": { + "type": "parent", + "targetLayerId": "puppet-berta" + }, + "faceBinding": { + "poseLayerId": "puppet-berta", + "role": "mouth", + "state": "round" + } + }, + { + "id": "sticker-ice", + "name": "Papel cebolla", + "type": "image", + "source": "/examples/cut-paper/props/onion-paper.png", + "visible": true, + "locked": false, + "z": 8, + "transform": { + "x": 50, + "y": 58, + "scale": 0.22, + "opacity": 1, + "rotation": -6 + }, + "animation": { + "start": { + "x": 50, + "y": 58, + "scale": 0.22, + "opacity": 1, + "rotation": -6 + }, + "end": { + "x": 50, + "y": 58, + "scale": 0.22, + "opacity": 1, + "rotation": -6 + }, + "duration": 40, + "curve": "ease", + "keyframes": [ + { + "id": "ice-0", + "time": 0, + "x": 50, + "y": 58, + "scale": 0.22, + "opacity": 1, + "rotation": -6, + "curve": "hold" + }, + { + "id": "sticker-ice-40000", + "time": 40, + "x": 50, + "y": 58, + "scale": 0.22, + "opacity": 1, + "rotation": -6, + "curve": "hold" + } + ] + }, + "parallax": 0.4 + } + ], + "generationPolicy": "provided_only", + "dialogueBeats": [ + { + "id": "nilo-1-0", + "text": "La", + "start": 6, + "end": 6.14, + "mouthLayerIds": [ + "puppet-nilo-mouth-closed", + "puppet-nilo-mouth-small", + "puppet-nilo-mouth-wide", + "puppet-nilo-mouth-round" + ], + "audioTrackId": "vo-nilo-1", + "confidence": "aligned-audio" + }, + { + "id": "nilo-1-1", + "text": "fuente", + "start": 6.14, + "end": 6.5600000000000005, + "mouthLayerIds": [ + "puppet-nilo-mouth-closed", + "puppet-nilo-mouth-small", + "puppet-nilo-mouth-wide", + "puppet-nilo-mouth-round" + ], + "audioTrackId": "vo-nilo-1", + "confidence": "aligned-audio" + }, + { + "id": "nilo-1-2", + "text": "no", + "start": 6.5600000000000005, + "end": 6.82, + "mouthLayerIds": [ + "puppet-nilo-mouth-closed", + "puppet-nilo-mouth-small", + "puppet-nilo-mouth-wide", + "puppet-nilo-mouth-round" + ], + "audioTrackId": "vo-nilo-1", + "confidence": "aligned-audio" + }, + { + "id": "nilo-1-3", + "text": "está", + "start": 6.82, + "end": 7.02, + "mouthLayerIds": [ + "puppet-nilo-mouth-closed", + "puppet-nilo-mouth-small", + "puppet-nilo-mouth-wide", + "puppet-nilo-mouth-round" + ], + "audioTrackId": "vo-nilo-1", + "confidence": "aligned-audio" + }, + { + "id": "nilo-1-4", + "text": "congelada", + "start": 7.02, + "end": 7.72, + "mouthLayerIds": [ + "puppet-nilo-mouth-closed", + "puppet-nilo-mouth-small", + "puppet-nilo-mouth-wide", + "puppet-nilo-mouth-round" + ], + "audioTrackId": "vo-nilo-1", + "confidence": "aligned-audio" + }, + { + "id": "nilo-1-5", + "text": "Alguien", + "start": 8.28, + "end": 8.66, + "mouthLayerIds": [ + "puppet-nilo-mouth-closed", + "puppet-nilo-mouth-small", + "puppet-nilo-mouth-wide", + "puppet-nilo-mouth-round" + ], + "audioTrackId": "vo-nilo-1", + "confidence": "aligned-audio" + }, + { + "id": "nilo-1-6", + "text": "le", + "start": 8.66, + "end": 8.72, + "mouthLayerIds": [ + "puppet-nilo-mouth-closed", + "puppet-nilo-mouth-small", + "puppet-nilo-mouth-wide", + "puppet-nilo-mouth-round" + ], + "audioTrackId": "vo-nilo-1", + "confidence": "aligned-audio" + }, + { + "id": "nilo-1-7", + "text": "pegó", + "start": 8.72, + "end": 9.02, + "mouthLayerIds": [ + "puppet-nilo-mouth-closed", + "puppet-nilo-mouth-small", + "puppet-nilo-mouth-wide", + "puppet-nilo-mouth-round" + ], + "audioTrackId": "vo-nilo-1", + "confidence": "aligned-audio" + }, + { + "id": "nilo-1-8", + "text": "un", + "start": 9.02, + "end": 9.08, + "mouthLayerIds": [ + "puppet-nilo-mouth-closed", + "puppet-nilo-mouth-small", + "puppet-nilo-mouth-wide", + "puppet-nilo-mouth-round" + ], + "audioTrackId": "vo-nilo-1", + "confidence": "aligned-audio" + }, + { + "id": "nilo-1-9", + "text": "cuadrado", + "start": 9.08, + "end": 9.54, + "mouthLayerIds": [ + "puppet-nilo-mouth-closed", + "puppet-nilo-mouth-small", + "puppet-nilo-mouth-wide", + "puppet-nilo-mouth-round" + ], + "audioTrackId": "vo-nilo-1", + "confidence": "aligned-audio" + }, + { + "id": "nilo-1-10", + "text": "de", + "start": 9.54, + "end": 9.66, + "mouthLayerIds": [ + "puppet-nilo-mouth-closed", + "puppet-nilo-mouth-small", + "puppet-nilo-mouth-wide", + "puppet-nilo-mouth-round" + ], + "audioTrackId": "vo-nilo-1", + "confidence": "aligned-audio" + }, + { + "id": "nilo-1-11", + "text": "papel", + "start": 9.66, + "end": 9.92, + "mouthLayerIds": [ + "puppet-nilo-mouth-closed", + "puppet-nilo-mouth-small", + "puppet-nilo-mouth-wide", + "puppet-nilo-mouth-round" + ], + "audioTrackId": "vo-nilo-1", + "confidence": "aligned-audio" + }, + { + "id": "nilo-1-12", + "text": "cebolla", + "start": 9.92, + "end": 10.440000000000001, + "mouthLayerIds": [ + "puppet-nilo-mouth-closed", + "puppet-nilo-mouth-small", + "puppet-nilo-mouth-wide", + "puppet-nilo-mouth-round" + ], + "audioTrackId": "vo-nilo-1", + "confidence": "aligned-audio" + }, + { + "id": "berta-1-0", + "text": "Pues", + "start": 17, + "end": 17.4, + "mouthLayerIds": [ + "puppet-berta-mouth-closed", + "puppet-berta-mouth-small", + "puppet-berta-mouth-wide", + "puppet-berta-mouth-round" + ], + "audioTrackId": "vo-berta-1", + "confidence": "aligned-audio" + }, + { + "id": "berta-1-1", + "text": "sabe", + "start": 17.4, + "end": 17.88, + "mouthLayerIds": [ + "puppet-berta-mouth-closed", + "puppet-berta-mouth-small", + "puppet-berta-mouth-wide", + "puppet-berta-mouth-round" + ], + "audioTrackId": "vo-berta-1", + "confidence": "aligned-audio" + }, + { + "id": "berta-1-2", + "text": "a", + "start": 17.88, + "end": 18.06, + "mouthLayerIds": [ + "puppet-berta-mouth-closed", + "puppet-berta-mouth-small", + "puppet-berta-mouth-wide", + "puppet-berta-mouth-round" + ], + "audioTrackId": "vo-berta-1", + "confidence": "aligned-audio" + }, + { + "id": "berta-1-3", + "text": "hielo", + "start": 18.06, + "end": 18.66, + "mouthLayerIds": [ + "puppet-berta-mouth-closed", + "puppet-berta-mouth-small", + "puppet-berta-mouth-wide", + "puppet-berta-mouth-round" + ], + "audioTrackId": "vo-berta-1", + "confidence": "aligned-audio" + }, + { + "id": "berta-1-4", + "text": "Lo", + "start": 18.92, + "end": 19.18, + "mouthLayerIds": [ + "puppet-berta-mouth-closed", + "puppet-berta-mouth-small", + "puppet-berta-mouth-wide", + "puppet-berta-mouth-round" + ], + "audioTrackId": "vo-berta-1", + "confidence": "aligned-audio" + }, + { + "id": "berta-1-5", + "text": "probé", + "start": 19.18, + "end": 19.62, + "mouthLayerIds": [ + "puppet-berta-mouth-closed", + "puppet-berta-mouth-small", + "puppet-berta-mouth-wide", + "puppet-berta-mouth-round" + ], + "audioTrackId": "vo-berta-1", + "confidence": "aligned-audio" + }, + { + "id": "nilo-2-0", + "text": "Berta, eso es cola.", + "start": 25, + "end": 26.737000000000002, + "mouthLayerIds": [ + "puppet-nilo-mouth-closed", + "puppet-nilo-mouth-small", + "puppet-nilo-mouth-wide", + "puppet-nilo-mouth-round" + ], + "audioTrackId": "vo-nilo-2", + "confidence": "aligned-audio" + }, + { + "id": "berta-2-0", + "text": "Cola", + "start": 31, + "end": 31.38, + "mouthLayerIds": [ + "puppet-berta-mouth-closed", + "puppet-berta-mouth-small", + "puppet-berta-mouth-wide", + "puppet-berta-mouth-round" + ], + "audioTrackId": "vo-berta-2", + "confidence": "aligned-audio" + }, + { + "id": "berta-2-1", + "text": "fría", + "start": 31.38, + "end": 32.1, + "mouthLayerIds": [ + "puppet-berta-mouth-closed", + "puppet-berta-mouth-small", + "puppet-berta-mouth-wide", + "puppet-berta-mouth-round" + ], + "audioTrackId": "vo-berta-2", + "confidence": "aligned-audio" + }, + { + "id": "berta-2-2", + "text": "Como", + "start": 32.3, + "end": 32.64, + "mouthLayerIds": [ + "puppet-berta-mouth-closed", + "puppet-berta-mouth-small", + "puppet-berta-mouth-wide", + "puppet-berta-mouth-round" + ], + "audioTrackId": "vo-berta-2", + "confidence": "aligned-audio" + }, + { + "id": "berta-2-3", + "text": "hielo", + "start": 32.64, + "end": 33.32, + "mouthLayerIds": [ + "puppet-berta-mouth-closed", + "puppet-berta-mouth-small", + "puppet-berta-mouth-wide", + "puppet-berta-mouth-round" + ], + "audioTrackId": "vo-berta-2", + "confidence": "aligned-audio" + } + ], + "texts": [], + "audioTracks": [ + { + "id": "vo-nilo-1", + "filename": "vo-nilo-nilo-1.wav", + "name": "nilo · La fuente no está congel", + "kind": "speech", + "startTime": 6, + "volume": 1 + }, + { + "id": "vo-berta-1", + "filename": "vo-berta-berta-1.wav", + "name": "berta · Pues sabe a hielo. Lo pr", + "kind": "speech", + "startTime": 17, + "volume": 1 + }, + { + "id": "vo-nilo-2", + "filename": "vo-nilo-nilo-2.wav", + "name": "nilo · Berta, eso es cola.", + "kind": "speech", + "startTime": 25, + "volume": 1 + }, + { + "id": "vo-berta-2", + "filename": "vo-berta-berta-2.wav", + "name": "berta · Cola fría. Como hielo.", + "kind": "speech", + "startTime": 31, + "volume": 1 + } + ] +} \ No newline at end of file diff --git a/ui/public/examples/cut-paper/shots/03-sticker.maestro-scene.json b/ui/public/examples/cut-paper/shots/03-sticker.maestro-scene.json new file mode 100644 index 000000000..fcf28464e --- /dev/null +++ b/ui/public/examples/cut-paper/shots/03-sticker.maestro-scene.json @@ -0,0 +1,1423 @@ +{ + "version": 1, + "name": "Tijeral · plano 3 sticker", + "width": 1280, + "height": 720, + "fps": 30, + "duration": 26, + "layers": [ + { + "id": "camera", + "name": "Camera", + "type": "camera", + "source": "", + "visible": true, + "locked": false, + "z": 100, + "transform": { + "x": 50, + "y": 50, + "scale": 1, + "opacity": 1, + "rotation": 0 + }, + "animation": { + "start": { + "x": 50, + "y": 50, + "scale": 1, + "opacity": 1, + "rotation": 0 + }, + "end": { + "x": 50, + "y": 50, + "scale": 1.04, + "opacity": 1, + "rotation": 0 + }, + "duration": 26, + "curve": "ease", + "keyframes": [ + { + "id": "camera-0", + "time": 0, + "x": 50, + "y": 50, + "scale": 1, + "opacity": 1, + "rotation": 0, + "curve": "ease" + }, + { + "id": "camera-26000", + "time": 26, + "x": 50, + "y": 50, + "scale": 1.04, + "opacity": 1, + "rotation": 0, + "curve": "ease" + } + ] + } + }, + { + "id": "location-plaza", + "name": "Location plaza", + "type": "image", + "source": "/examples/cut-paper/locations/plaza.png", + "visible": true, + "locked": false, + "z": 0, + "transform": { + "x": 50, + "y": 50, + "scale": 1, + "opacity": 1, + "rotation": 0 + }, + "animation": { + "start": { + "x": 50, + "y": 50, + "scale": 1, + "opacity": 1, + "rotation": 0 + }, + "end": { + "x": 50, + "y": 50, + "scale": 1, + "opacity": 1, + "rotation": 0 + }, + "duration": 26, + "curve": "hold", + "keyframes": [ + { + "id": "location-plaza-0", + "time": 0, + "x": 50, + "y": 50, + "scale": 1, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "location-plaza-26000", + "time": 26, + "x": 50, + "y": 50, + "scale": 1, + "opacity": 1, + "rotation": 0, + "curve": "hold" + } + ] + }, + "fill": true, + "parallax": 0.15 + }, + { + "id": "puppet-nilo", + "name": "Nilo Carda pose", + "type": "image", + "source": "/examples/cut-paper/puppets/nilo-body.png", + "visible": true, + "locked": false, + "z": 32, + "transform": { + "x": 36, + "y": 62, + "scale": 0.42, + "opacity": 1, + "rotation": 0 + }, + "animation": { + "start": { + "x": 36, + "y": 62, + "scale": 0.42, + "opacity": 1, + "rotation": 0 + }, + "end": { + "x": 36, + "y": 62, + "scale": 0.42, + "opacity": 1, + "rotation": 0 + }, + "duration": 26, + "curve": "hold", + "keyframes": [ + { + "id": "puppet-nilo-0", + "time": 0, + "x": 36, + "y": 62, + "scale": 0.42, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-26000", + "time": 26, + "x": 36, + "y": 62, + "scale": 0.42, + "opacity": 1, + "rotation": 0, + "curve": "hold" + } + ] + }, + "parallax": 1, + "effects": { + "shadow": 0.25, + "blendMode": "normal", + "mask": "none", + "maskRadius": 12, + "blur": 0, + "brightness": 1, + "contrast": 1, + "saturation": 1, + "hue": 0, + "glow": 0 + } + }, + { + "id": "puppet-nilo-mouth-closed", + "name": "Nilo Carda mouth closed", + "type": "image", + "source": "/examples/cut-paper/mouths/paper-closed.png", + "visible": true, + "locked": false, + "z": 40, + "transform": { + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0 + }, + "animation": { + "start": { + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0 + }, + "end": { + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0 + }, + "duration": 26, + "curve": "hold", + "keyframes": [ + { + "id": "puppet-nilo-mouth-closed-0", + "time": 0, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-closed-26000", + "time": 26, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + } + ] + }, + "relationship": { + "type": "parent", + "targetLayerId": "puppet-nilo" + }, + "faceBinding": { + "poseLayerId": "puppet-nilo", + "role": "mouth", + "state": "closed" + } + }, + { + "id": "puppet-nilo-mouth-small", + "name": "Nilo Carda mouth small", + "type": "image", + "source": "/examples/cut-paper/mouths/paper-small.png", + "visible": true, + "locked": false, + "z": 40, + "transform": { + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0 + }, + "animation": { + "start": { + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0 + }, + "end": { + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0 + }, + "duration": 26, + "curve": "hold", + "keyframes": [ + { + "id": "puppet-nilo-mouth-small-0", + "time": 0, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-small-26000", + "time": 26, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + } + ] + }, + "relationship": { + "type": "parent", + "targetLayerId": "puppet-nilo" + }, + "faceBinding": { + "poseLayerId": "puppet-nilo", + "role": "mouth", + "state": "small" + } + }, + { + "id": "puppet-nilo-mouth-wide", + "name": "Nilo Carda mouth wide", + "type": "image", + "source": "/examples/cut-paper/mouths/paper-wide.png", + "visible": true, + "locked": false, + "z": 40, + "transform": { + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0 + }, + "animation": { + "start": { + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0 + }, + "end": { + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0 + }, + "duration": 26, + "curve": "hold", + "keyframes": [ + { + "id": "puppet-nilo-mouth-wide-0", + "time": 0, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-wide-26000", + "time": 26, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + } + ] + }, + "relationship": { + "type": "parent", + "targetLayerId": "puppet-nilo" + }, + "faceBinding": { + "poseLayerId": "puppet-nilo", + "role": "mouth", + "state": "wide" + } + }, + { + "id": "puppet-nilo-mouth-round", + "name": "Nilo Carda mouth round", + "type": "image", + "source": "/examples/cut-paper/mouths/paper-round.png", + "visible": true, + "locked": false, + "z": 40, + "transform": { + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0 + }, + "animation": { + "start": { + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0 + }, + "end": { + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0 + }, + "duration": 26, + "curve": "hold", + "keyframes": [ + { + "id": "puppet-nilo-mouth-round-0", + "time": 0, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-round-26000", + "time": 26, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + } + ] + }, + "relationship": { + "type": "parent", + "targetLayerId": "puppet-nilo" + }, + "faceBinding": { + "poseLayerId": "puppet-nilo", + "role": "mouth", + "state": "round" + } + }, + { + "id": "puppet-berta", + "name": "Berta Miga pose", + "type": "image", + "source": "/examples/cut-paper/puppets/berta-body.png", + "visible": true, + "locked": false, + "z": 42, + "transform": { + "x": 62, + "y": 64, + "scale": 0.38, + "opacity": 1, + "rotation": 0 + }, + "animation": { + "start": { + "x": 62, + "y": 64, + "scale": 0.38, + "opacity": 1, + "rotation": 0 + }, + "end": { + "x": 62, + "y": 64, + "scale": 0.38, + "opacity": 1, + "rotation": 0 + }, + "duration": 26, + "curve": "hold", + "keyframes": [ + { + "id": "puppet-berta-0", + "time": 0, + "x": 62, + "y": 64, + "scale": 0.38, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-26000", + "time": 26, + "x": 62, + "y": 64, + "scale": 0.38, + "opacity": 1, + "rotation": 0, + "curve": "hold" + } + ] + }, + "parallax": 1, + "effects": { + "shadow": 0.25, + "blendMode": "normal", + "mask": "none", + "maskRadius": 12, + "blur": 0, + "brightness": 1, + "contrast": 1, + "saturation": 1, + "hue": 0, + "glow": 0 + } + }, + { + "id": "puppet-berta-mouth-closed", + "name": "Berta Miga mouth closed", + "type": "image", + "source": "/examples/cut-paper/mouths/paper-closed.png", + "visible": true, + "locked": false, + "z": 50, + "transform": { + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0 + }, + "animation": { + "start": { + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0 + }, + "end": { + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0 + }, + "duration": 26, + "curve": "hold", + "keyframes": [ + { + "id": "puppet-berta-mouth-closed-0", + "time": 0, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-closed-26000", + "time": 26, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + } + ] + }, + "relationship": { + "type": "parent", + "targetLayerId": "puppet-berta" + }, + "faceBinding": { + "poseLayerId": "puppet-berta", + "role": "mouth", + "state": "closed" + } + }, + { + "id": "puppet-berta-mouth-small", + "name": "Berta Miga mouth small", + "type": "image", + "source": "/examples/cut-paper/mouths/paper-small.png", + "visible": true, + "locked": false, + "z": 50, + "transform": { + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0 + }, + "animation": { + "start": { + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0 + }, + "end": { + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0 + }, + "duration": 26, + "curve": "hold", + "keyframes": [ + { + "id": "puppet-berta-mouth-small-0", + "time": 0, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-small-26000", + "time": 26, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + } + ] + }, + "relationship": { + "type": "parent", + "targetLayerId": "puppet-berta" + }, + "faceBinding": { + "poseLayerId": "puppet-berta", + "role": "mouth", + "state": "small" + } + }, + { + "id": "puppet-berta-mouth-wide", + "name": "Berta Miga mouth wide", + "type": "image", + "source": "/examples/cut-paper/mouths/paper-wide.png", + "visible": true, + "locked": false, + "z": 50, + "transform": { + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0 + }, + "animation": { + "start": { + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0 + }, + "end": { + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0 + }, + "duration": 26, + "curve": "hold", + "keyframes": [ + { + "id": "puppet-berta-mouth-wide-0", + "time": 0, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-wide-26000", + "time": 26, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + } + ] + }, + "relationship": { + "type": "parent", + "targetLayerId": "puppet-berta" + }, + "faceBinding": { + "poseLayerId": "puppet-berta", + "role": "mouth", + "state": "wide" + } + }, + { + "id": "puppet-berta-mouth-round", + "name": "Berta Miga mouth round", + "type": "image", + "source": "/examples/cut-paper/mouths/paper-round.png", + "visible": true, + "locked": false, + "z": 50, + "transform": { + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0 + }, + "animation": { + "start": { + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0 + }, + "end": { + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0 + }, + "duration": 26, + "curve": "hold", + "keyframes": [ + { + "id": "puppet-berta-mouth-round-0", + "time": 0, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-round-26000", + "time": 26, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + } + ] + }, + "relationship": { + "type": "parent", + "targetLayerId": "puppet-berta" + }, + "faceBinding": { + "poseLayerId": "puppet-berta", + "role": "mouth", + "state": "round" + } + }, + { + "id": "puppet-kito", + "name": "Kito Veleta pose", + "type": "image", + "source": "/examples/cut-paper/puppets/kito-body.png", + "visible": true, + "locked": false, + "z": 52, + "transform": { + "x": 118, + "y": 70, + "scale": 0.22399999999999998, + "opacity": 1, + "rotation": 0 + }, + "animation": { + "start": { + "x": 118, + "y": 70, + "scale": 0.22399999999999998, + "opacity": 1, + "rotation": 0 + }, + "end": { + "x": 52, + "y": 70, + "scale": 0.22399999999999998, + "opacity": 1, + "rotation": 0 + }, + "duration": 26, + "curve": "ease", + "keyframes": [ + { + "id": "puppet-kito-0", + "time": 0, + "x": 118, + "y": 70, + "scale": 0.22399999999999998, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-kito-2000", + "time": 2, + "x": 118, + "y": 70, + "scale": 0.22399999999999998, + "opacity": 1, + "rotation": 0, + "curve": "ease" + }, + { + "id": "puppet-kito-9000", + "time": 9, + "x": 52, + "y": 70, + "scale": 0.22399999999999998, + "opacity": 1, + "rotation": 0, + "curve": "ease" + }, + { + "id": "puppet-kito-26000", + "time": 26, + "x": 52, + "y": 70, + "scale": 0.22399999999999998, + "opacity": 1, + "rotation": 0, + "curve": "hold" + } + ] + }, + "parallax": 1, + "effects": { + "shadow": 0.25, + "blendMode": "normal", + "mask": "none", + "maskRadius": 12, + "blur": 0, + "brightness": 1, + "contrast": 1, + "saturation": 1, + "hue": 0, + "glow": 0 + } + }, + { + "id": "puppet-kito-mouth-closed", + "name": "Kito Veleta mouth closed", + "type": "image", + "source": "/examples/cut-paper/mouths/paper-closed.png", + "visible": true, + "locked": false, + "z": 60, + "transform": { + "x": 118, + "y": 71.43136, + "scale": 0.03584, + "opacity": 0, + "rotation": 0 + }, + "animation": { + "start": { + "x": 118, + "y": 71.43136, + "scale": 0.03584, + "opacity": 1, + "rotation": 0 + }, + "end": { + "x": 118, + "y": 71.43136, + "scale": 0.03584, + "opacity": 1, + "rotation": 0 + }, + "duration": 26, + "curve": "hold", + "keyframes": [ + { + "id": "puppet-kito-mouth-closed-dialogue-0", + "time": 0, + "x": 118, + "y": 71.43136, + "scale": 0.03584, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-kito-mouth-closed-dialogue-10000", + "time": 10, + "x": 118, + "y": 71.43136, + "scale": 0.03584, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-kito-mouth-closed-dialogue-10300", + "time": 10.3, + "x": 118, + "y": 71.43136, + "scale": 0.03584, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-kito-mouth-closed-dialogue-10520", + "time": 10.52, + "x": 118, + "y": 71.43136, + "scale": 0.03584, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-kito-mouth-closed-dialogue-10960", + "time": 10.96, + "x": 118, + "y": 71.43136, + "scale": 0.03584, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-kito-mouth-closed-26000", + "time": 26, + "x": 118, + "y": 71.43136, + "scale": 0.03584, + "opacity": 1, + "rotation": 0, + "curve": "hold" + } + ] + }, + "relationship": { + "type": "parent", + "targetLayerId": "puppet-kito" + }, + "faceBinding": { + "poseLayerId": "puppet-kito", + "role": "mouth", + "state": "closed" + } + }, + { + "id": "puppet-kito-mouth-small", + "name": "Kito Veleta mouth small", + "type": "image", + "source": "/examples/cut-paper/mouths/paper-small.png", + "visible": true, + "locked": false, + "z": 60, + "transform": { + "x": 118, + "y": 71.43136, + "scale": 0.03584, + "opacity": 0, + "rotation": 0 + }, + "animation": { + "start": { + "x": 118, + "y": 71.43136, + "scale": 0.03584, + "opacity": 0, + "rotation": 0 + }, + "end": { + "x": 118, + "y": 71.43136, + "scale": 0.03584, + "opacity": 0, + "rotation": 0 + }, + "duration": 26, + "curve": "hold", + "keyframes": [ + { + "id": "puppet-kito-mouth-small-dialogue-0", + "time": 0, + "x": 118, + "y": 71.43136, + "scale": 0.03584, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-kito-mouth-small-dialogue-10000", + "time": 10, + "x": 118, + "y": 71.43136, + "scale": 0.03584, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-kito-mouth-small-dialogue-10300", + "time": 10.3, + "x": 118, + "y": 71.43136, + "scale": 0.03584, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-kito-mouth-small-dialogue-10520", + "time": 10.52, + "x": 118, + "y": 71.43136, + "scale": 0.03584, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-kito-mouth-small-dialogue-10960", + "time": 10.96, + "x": 118, + "y": 71.43136, + "scale": 0.03584, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-kito-mouth-small-26000", + "time": 26, + "x": 118, + "y": 71.43136, + "scale": 0.03584, + "opacity": 0, + "rotation": 0, + "curve": "hold" + } + ] + }, + "relationship": { + "type": "parent", + "targetLayerId": "puppet-kito" + }, + "faceBinding": { + "poseLayerId": "puppet-kito", + "role": "mouth", + "state": "small" + } + }, + { + "id": "puppet-kito-mouth-wide", + "name": "Kito Veleta mouth wide", + "type": "image", + "source": "/examples/cut-paper/mouths/paper-wide.png", + "visible": true, + "locked": false, + "z": 60, + "transform": { + "x": 118, + "y": 71.43136, + "scale": 0.03584, + "opacity": 0, + "rotation": 0 + }, + "animation": { + "start": { + "x": 118, + "y": 71.43136, + "scale": 0.03584, + "opacity": 0, + "rotation": 0 + }, + "end": { + "x": 118, + "y": 71.43136, + "scale": 0.03584, + "opacity": 0, + "rotation": 0 + }, + "duration": 26, + "curve": "hold", + "keyframes": [ + { + "id": "puppet-kito-mouth-wide-dialogue-0", + "time": 0, + "x": 118, + "y": 71.43136, + "scale": 0.03584, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-kito-mouth-wide-dialogue-10000", + "time": 10, + "x": 118, + "y": 71.43136, + "scale": 0.03584, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-kito-mouth-wide-dialogue-10300", + "time": 10.3, + "x": 118, + "y": 71.43136, + "scale": 0.03584, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-kito-mouth-wide-dialogue-10520", + "time": 10.52, + "x": 118, + "y": 71.43136, + "scale": 0.03584, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-kito-mouth-wide-dialogue-10960", + "time": 10.96, + "x": 118, + "y": 71.43136, + "scale": 0.03584, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-kito-mouth-wide-26000", + "time": 26, + "x": 118, + "y": 71.43136, + "scale": 0.03584, + "opacity": 0, + "rotation": 0, + "curve": "hold" + } + ] + }, + "relationship": { + "type": "parent", + "targetLayerId": "puppet-kito" + }, + "faceBinding": { + "poseLayerId": "puppet-kito", + "role": "mouth", + "state": "wide" + } + }, + { + "id": "puppet-kito-mouth-round", + "name": "Kito Veleta mouth round", + "type": "image", + "source": "/examples/cut-paper/mouths/paper-round.png", + "visible": true, + "locked": false, + "z": 60, + "transform": { + "x": 118, + "y": 71.43136, + "scale": 0.03584, + "opacity": 0, + "rotation": 0 + }, + "animation": { + "start": { + "x": 118, + "y": 71.43136, + "scale": 0.03584, + "opacity": 0, + "rotation": 0 + }, + "end": { + "x": 118, + "y": 71.43136, + "scale": 0.03584, + "opacity": 0, + "rotation": 0 + }, + "duration": 26, + "curve": "hold", + "keyframes": [ + { + "id": "puppet-kito-mouth-round-dialogue-0", + "time": 0, + "x": 118, + "y": 71.43136, + "scale": 0.03584, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-kito-mouth-round-dialogue-10000", + "time": 10, + "x": 118, + "y": 71.43136, + "scale": 0.03584, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-kito-mouth-round-dialogue-10300", + "time": 10.3, + "x": 118, + "y": 71.43136, + "scale": 0.03584, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-kito-mouth-round-dialogue-10520", + "time": 10.52, + "x": 118, + "y": 71.43136, + "scale": 0.03584, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-kito-mouth-round-dialogue-10960", + "time": 10.96, + "x": 118, + "y": 71.43136, + "scale": 0.03584, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-kito-mouth-round-26000", + "time": 26, + "x": 118, + "y": 71.43136, + "scale": 0.03584, + "opacity": 0, + "rotation": 0, + "curve": "hold" + } + ] + }, + "relationship": { + "type": "parent", + "targetLayerId": "puppet-kito" + }, + "faceBinding": { + "poseLayerId": "puppet-kito", + "role": "mouth", + "state": "round" + } + }, + { + "id": "sticker-ice", + "name": "Papel cebolla", + "type": "image", + "source": "/examples/cut-paper/props/onion-paper.png", + "visible": true, + "locked": false, + "z": 8, + "transform": { + "x": 50, + "y": 58, + "scale": 0.22, + "opacity": 1, + "rotation": -6 + }, + "animation": { + "start": { + "x": 50, + "y": 58, + "scale": 0.22, + "opacity": 1, + "rotation": -6 + }, + "end": { + "x": 78, + "y": 82, + "scale": 0.18, + "opacity": 0, + "rotation": 18 + }, + "duration": 26, + "curve": "ease", + "keyframes": [ + { + "id": "ice-0", + "time": 0, + "x": 50, + "y": 58, + "scale": 0.22, + "opacity": 1, + "rotation": -6, + "curve": "hold" + }, + { + "id": "ice-1", + "time": 2, + "x": 50, + "y": 58, + "scale": 0.22, + "opacity": 1, + "rotation": -6, + "curve": "ease" + }, + { + "id": "ice-2", + "time": 9, + "x": 78, + "y": 82, + "scale": 0.18, + "opacity": 0, + "rotation": 18, + "curve": "ease" + }, + { + "id": "ice-3", + "time": 26, + "x": 78, + "y": 82, + "scale": 0.18, + "opacity": 0, + "rotation": 18, + "curve": "hold" + } + ] + }, + "parallax": 0.4 + } + ], + "generationPolicy": "provided_only", + "dialogueBeats": [ + { + "id": "kito-1-0", + "text": "Era", + "start": 10, + "end": 10.3, + "mouthLayerIds": [ + "puppet-kito-mouth-closed", + "puppet-kito-mouth-small", + "puppet-kito-mouth-wide", + "puppet-kito-mouth-round" + ], + "audioTrackId": "vo-kito-1", + "confidence": "aligned-audio" + }, + { + "id": "kito-1-1", + "text": "un", + "start": 10.3, + "end": 10.52, + "mouthLayerIds": [ + "puppet-kito-mouth-closed", + "puppet-kito-mouth-small", + "puppet-kito-mouth-wide", + "puppet-kito-mouth-round" + ], + "audioTrackId": "vo-kito-1", + "confidence": "aligned-audio" + }, + { + "id": "kito-1-2", + "text": "sticker", + "start": 10.52, + "end": 10.96, + "mouthLayerIds": [ + "puppet-kito-mouth-closed", + "puppet-kito-mouth-small", + "puppet-kito-mouth-wide", + "puppet-kito-mouth-round" + ], + "audioTrackId": "vo-kito-1", + "confidence": "aligned-audio" + } + ], + "audioTracks": [ + { + "id": "vo-kito-1", + "filename": "vo-kito-kito-1.wav", + "name": "kito · sticker", + "kind": "speech", + "startTime": 10, + "volume": 1 + } + ] +} \ No newline at end of file diff --git a/ui/public/examples/cut-paper/shots/en/01-plaza.maestro-scene.json b/ui/public/examples/cut-paper/shots/en/01-plaza.maestro-scene.json new file mode 100644 index 000000000..ee3dccb35 --- /dev/null +++ b/ui/public/examples/cut-paper/shots/en/01-plaza.maestro-scene.json @@ -0,0 +1,198 @@ +{ + "version": 1, + "name": "Tijeral · shot 1 plaza", + "width": 1280, + "height": 720, + "fps": 30, + "duration": 6, + "layers": [ + { + "id": "camera", + "name": "Camera", + "type": "camera", + "source": "", + "visible": true, + "locked": false, + "z": 100, + "transform": { + "x": 50, + "y": 50, + "scale": 1, + "opacity": 1, + "rotation": 0 + }, + "animation": { + "start": { + "x": 50, + "y": 50, + "scale": 1, + "opacity": 1, + "rotation": 0 + }, + "end": { + "x": 50, + "y": 50, + "scale": 1.04, + "opacity": 1, + "rotation": 0 + }, + "duration": 6, + "curve": "ease", + "keyframes": [ + { + "id": "camera-0", + "time": 0, + "x": 50, + "y": 50, + "scale": 1, + "opacity": 1, + "rotation": 0, + "curve": "ease" + }, + { + "id": "camera-6000", + "time": 6, + "x": 50, + "y": 50, + "scale": 1.04, + "opacity": 1, + "rotation": 0, + "curve": "ease" + } + ] + } + }, + { + "id": "location-plaza", + "name": "Location plaza", + "type": "image", + "source": "/examples/cut-paper/locations/plaza.png", + "visible": true, + "locked": false, + "z": 0, + "transform": { + "x": 50, + "y": 50, + "scale": 1, + "opacity": 1, + "rotation": 0 + }, + "animation": { + "start": { + "x": 50, + "y": 50, + "scale": 1, + "opacity": 1, + "rotation": 0 + }, + "end": { + "x": 50, + "y": 50, + "scale": 1, + "opacity": 1, + "rotation": 0 + }, + "duration": 6, + "curve": "hold", + "keyframes": [ + { + "id": "location-plaza-0", + "time": 0, + "x": 50, + "y": 50, + "scale": 1, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "location-plaza-6000", + "time": 6, + "x": 50, + "y": 50, + "scale": 1, + "opacity": 1, + "rotation": 0, + "curve": "hold" + } + ] + }, + "fill": true, + "parallax": 0.15 + }, + { + "id": "sticker-ice", + "name": "Papel cebolla", + "type": "image", + "source": "/examples/cut-paper/props/onion-paper.png", + "visible": true, + "locked": false, + "z": 8, + "transform": { + "x": 50, + "y": 58, + "scale": 0.22, + "opacity": 1, + "rotation": -6 + }, + "animation": { + "start": { + "x": 50, + "y": 58, + "scale": 0.22, + "opacity": 1, + "rotation": -6 + }, + "end": { + "x": 50, + "y": 58, + "scale": 0.22, + "opacity": 1, + "rotation": -6 + }, + "duration": 6, + "curve": "ease", + "keyframes": [ + { + "id": "ice-0", + "time": 0, + "x": 50, + "y": 58, + "scale": 0.22, + "opacity": 1, + "rotation": -6, + "curve": "hold" + }, + { + "id": "sticker-ice-6000", + "time": 6, + "x": 50, + "y": 58, + "scale": 0.22, + "opacity": 1, + "rotation": -6, + "curve": "hold" + } + ] + }, + "parallax": 0.4 + } + ], + "generationPolicy": "provided_only", + "dialogueBeats": [], + "texts": [ + { + "id": "title", + "text": "Tijeral", + "start": 0.4, + "end": 3.6, + "preset": "rise", + "x": 50, + "y": 12, + "size": 7, + "color": "#1d2b5a", + "rotation": 0 + } + ], + "audioTracks": [] +} \ No newline at end of file diff --git a/ui/public/examples/cut-paper/shots/en/02-talk.maestro-scene.json b/ui/public/examples/cut-paper/shots/en/02-talk.maestro-scene.json new file mode 100644 index 000000000..43b718cea --- /dev/null +++ b/ui/public/examples/cut-paper/shots/en/02-talk.maestro-scene.json @@ -0,0 +1,2763 @@ +{ + "version": 1, + "name": "Tijeral · shot 2 cold glue", + "width": 1280, + "height": 720, + "fps": 30, + "duration": 40, + "layers": [ + { + "id": "camera", + "name": "Camera", + "type": "camera", + "source": "", + "visible": true, + "locked": false, + "z": 100, + "transform": { + "x": 50, + "y": 50, + "scale": 1, + "opacity": 1, + "rotation": 0 + }, + "animation": { + "start": { + "x": 50, + "y": 50, + "scale": 1, + "opacity": 1, + "rotation": 0 + }, + "end": { + "x": 50, + "y": 50, + "scale": 1.04, + "opacity": 1, + "rotation": 0 + }, + "duration": 40, + "curve": "ease", + "keyframes": [ + { + "id": "camera-0", + "time": 0, + "x": 50, + "y": 50, + "scale": 1, + "opacity": 1, + "rotation": 0, + "curve": "ease" + }, + { + "id": "camera-40000", + "time": 40, + "x": 50, + "y": 50, + "scale": 1.04, + "opacity": 1, + "rotation": 0, + "curve": "ease" + } + ] + } + }, + { + "id": "location-plaza", + "name": "Location plaza", + "type": "image", + "source": "/examples/cut-paper/locations/plaza.png", + "visible": true, + "locked": false, + "z": 0, + "transform": { + "x": 50, + "y": 50, + "scale": 1, + "opacity": 1, + "rotation": 0 + }, + "animation": { + "start": { + "x": 50, + "y": 50, + "scale": 1, + "opacity": 1, + "rotation": 0 + }, + "end": { + "x": 50, + "y": 50, + "scale": 1, + "opacity": 1, + "rotation": 0 + }, + "duration": 40, + "curve": "hold", + "keyframes": [ + { + "id": "location-plaza-0", + "time": 0, + "x": 50, + "y": 50, + "scale": 1, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "location-plaza-40000", + "time": 40, + "x": 50, + "y": 50, + "scale": 1, + "opacity": 1, + "rotation": 0, + "curve": "hold" + } + ] + }, + "fill": true, + "parallax": 0.15 + }, + { + "id": "puppet-nilo", + "name": "Nilo Carda pose", + "type": "image", + "source": "/examples/cut-paper/puppets/nilo-body.png", + "visible": true, + "locked": false, + "z": 32, + "transform": { + "x": 36, + "y": 62, + "scale": 0.42, + "opacity": 1, + "rotation": 0 + }, + "animation": { + "start": { + "x": 36, + "y": 62, + "scale": 0.42, + "opacity": 1, + "rotation": 0 + }, + "end": { + "x": 36, + "y": 62, + "scale": 0.42, + "opacity": 1, + "rotation": 0 + }, + "duration": 40, + "curve": "hold", + "keyframes": [ + { + "id": "puppet-nilo-0", + "time": 0, + "x": 36, + "y": 62, + "scale": 0.42, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-40000", + "time": 40, + "x": 36, + "y": 62, + "scale": 0.42, + "opacity": 1, + "rotation": 0, + "curve": "hold" + } + ] + }, + "parallax": 1, + "effects": { + "shadow": 0.25, + "blendMode": "normal", + "mask": "none", + "maskRadius": 12, + "blur": 0, + "brightness": 1, + "contrast": 1, + "saturation": 1, + "hue": 0, + "glow": 0 + } + }, + { + "id": "puppet-nilo-mouth-closed", + "name": "Nilo Carda mouth closed", + "type": "image", + "source": "/examples/cut-paper/mouths/paper-closed.png", + "visible": true, + "locked": false, + "z": 40, + "transform": { + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0 + }, + "animation": { + "start": { + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 1, + "rotation": 0 + }, + "end": { + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 1, + "rotation": 0 + }, + "duration": 40, + "curve": "hold", + "keyframes": [ + { + "id": "puppet-nilo-mouth-closed-dialogue-0", + "time": 0, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-closed-dialogue-6000", + "time": 6, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-closed-dialogue-6200", + "time": 6.2, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-closed-dialogue-6600", + "time": 6.6, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-closed-dialogue-7140", + "time": 7.14, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-closed-dialogue-7420", + "time": 7.42, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-closed-dialogue-8100", + "time": 8.1, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-closed-dialogue-8980", + "time": 8.98, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-closed-dialogue-9580", + "time": 9.58, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-closed-dialogue-10160", + "time": 10.16, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-closed-dialogue-10380", + "time": 10.379999999999999, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-closed-dialogue-10780", + "time": 10.780000000000001, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-closed-dialogue-11200", + "time": 11.2, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-closed-dialogue-11720", + "time": 11.719999999999999, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-closed-dialogue-12080", + "time": 12.08, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-closed-dialogue-12460", + "time": 12.46, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-closed-dialogue-12580", + "time": 12.58, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-closed-dialogue-25000", + "time": 25, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-closed-dialogue-25900", + "time": 25.9, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-closed-dialogue-26140", + "time": 26.14, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-closed-dialogue-26280", + "time": 26.28, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-closed-dialogue-26480", + "time": 26.48, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-closed-40000", + "time": 40, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 1, + "rotation": 0, + "curve": "hold" + } + ] + }, + "relationship": { + "type": "parent", + "targetLayerId": "puppet-nilo" + }, + "faceBinding": { + "poseLayerId": "puppet-nilo", + "role": "mouth", + "state": "closed" + } + }, + { + "id": "puppet-nilo-mouth-small", + "name": "Nilo Carda mouth small", + "type": "image", + "source": "/examples/cut-paper/mouths/paper-small.png", + "visible": true, + "locked": false, + "z": 40, + "transform": { + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0 + }, + "animation": { + "start": { + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0 + }, + "end": { + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0 + }, + "duration": 40, + "curve": "hold", + "keyframes": [ + { + "id": "puppet-nilo-mouth-small-dialogue-0", + "time": 0, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-small-dialogue-6000", + "time": 6, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-small-dialogue-6200", + "time": 6.2, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-small-dialogue-6600", + "time": 6.6, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-small-dialogue-7140", + "time": 7.14, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-small-dialogue-7420", + "time": 7.42, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-small-dialogue-8100", + "time": 8.1, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-small-dialogue-8980", + "time": 8.98, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-small-dialogue-9580", + "time": 9.58, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-small-dialogue-10160", + "time": 10.16, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-small-dialogue-10380", + "time": 10.379999999999999, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-small-dialogue-10780", + "time": 10.780000000000001, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-small-dialogue-11200", + "time": 11.2, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-small-dialogue-11720", + "time": 11.719999999999999, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-small-dialogue-12080", + "time": 12.08, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-small-dialogue-12460", + "time": 12.46, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-small-dialogue-12580", + "time": 12.58, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-small-dialogue-25000", + "time": 25, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-small-dialogue-25900", + "time": 25.9, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-small-dialogue-26140", + "time": 26.14, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-small-dialogue-26280", + "time": 26.28, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-small-dialogue-26480", + "time": 26.48, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-small-40000", + "time": 40, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + } + ] + }, + "relationship": { + "type": "parent", + "targetLayerId": "puppet-nilo" + }, + "faceBinding": { + "poseLayerId": "puppet-nilo", + "role": "mouth", + "state": "small" + } + }, + { + "id": "puppet-nilo-mouth-wide", + "name": "Nilo Carda mouth wide", + "type": "image", + "source": "/examples/cut-paper/mouths/paper-wide.png", + "visible": true, + "locked": false, + "z": 40, + "transform": { + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0 + }, + "animation": { + "start": { + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0 + }, + "end": { + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0 + }, + "duration": 40, + "curve": "hold", + "keyframes": [ + { + "id": "puppet-nilo-mouth-wide-dialogue-0", + "time": 0, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-wide-dialogue-6000", + "time": 6, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-wide-dialogue-6200", + "time": 6.2, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-wide-dialogue-6600", + "time": 6.6, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-wide-dialogue-7140", + "time": 7.14, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-wide-dialogue-7420", + "time": 7.42, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-wide-dialogue-8100", + "time": 8.1, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-wide-dialogue-8980", + "time": 8.98, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-wide-dialogue-9580", + "time": 9.58, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-wide-dialogue-10160", + "time": 10.16, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-wide-dialogue-10380", + "time": 10.379999999999999, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-wide-dialogue-10780", + "time": 10.780000000000001, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-wide-dialogue-11200", + "time": 11.2, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-wide-dialogue-11720", + "time": 11.719999999999999, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-wide-dialogue-12080", + "time": 12.08, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-wide-dialogue-12460", + "time": 12.46, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-wide-dialogue-12580", + "time": 12.58, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-wide-dialogue-25000", + "time": 25, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-wide-dialogue-25900", + "time": 25.9, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-wide-dialogue-26140", + "time": 26.14, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-wide-dialogue-26280", + "time": 26.28, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-wide-dialogue-26480", + "time": 26.48, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-wide-40000", + "time": 40, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + } + ] + }, + "relationship": { + "type": "parent", + "targetLayerId": "puppet-nilo" + }, + "faceBinding": { + "poseLayerId": "puppet-nilo", + "role": "mouth", + "state": "wide" + } + }, + { + "id": "puppet-nilo-mouth-round", + "name": "Nilo Carda mouth round", + "type": "image", + "source": "/examples/cut-paper/mouths/paper-round.png", + "visible": true, + "locked": false, + "z": 40, + "transform": { + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0 + }, + "animation": { + "start": { + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0 + }, + "end": { + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0 + }, + "duration": 40, + "curve": "hold", + "keyframes": [ + { + "id": "puppet-nilo-mouth-round-dialogue-0", + "time": 0, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-round-dialogue-6000", + "time": 6, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-round-dialogue-6200", + "time": 6.2, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-round-dialogue-6600", + "time": 6.6, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-round-dialogue-7140", + "time": 7.14, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-round-dialogue-7420", + "time": 7.42, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-round-dialogue-8100", + "time": 8.1, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-round-dialogue-8980", + "time": 8.98, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-round-dialogue-9580", + "time": 9.58, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-round-dialogue-10160", + "time": 10.16, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-round-dialogue-10380", + "time": 10.379999999999999, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-round-dialogue-10780", + "time": 10.780000000000001, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-round-dialogue-11200", + "time": 11.2, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-round-dialogue-11720", + "time": 11.719999999999999, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-round-dialogue-12080", + "time": 12.08, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-round-dialogue-12460", + "time": 12.46, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-round-dialogue-12580", + "time": 12.58, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-round-dialogue-25000", + "time": 25, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-round-dialogue-25900", + "time": 25.9, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-round-dialogue-26140", + "time": 26.14, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-round-dialogue-26280", + "time": 26.28, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-round-dialogue-26480", + "time": 26.48, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-round-40000", + "time": 40, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + } + ] + }, + "relationship": { + "type": "parent", + "targetLayerId": "puppet-nilo" + }, + "faceBinding": { + "poseLayerId": "puppet-nilo", + "role": "mouth", + "state": "round" + } + }, + { + "id": "puppet-berta", + "name": "Berta Miga pose", + "type": "image", + "source": "/examples/cut-paper/puppets/berta-body.png", + "visible": true, + "locked": false, + "z": 42, + "transform": { + "x": 62, + "y": 64, + "scale": 0.38, + "opacity": 1, + "rotation": 0 + }, + "animation": { + "start": { + "x": 62, + "y": 64, + "scale": 0.38, + "opacity": 1, + "rotation": 0 + }, + "end": { + "x": 62, + "y": 64, + "scale": 0.38, + "opacity": 1, + "rotation": 0 + }, + "duration": 40, + "curve": "hold", + "keyframes": [ + { + "id": "puppet-berta-0", + "time": 0, + "x": 62, + "y": 64, + "scale": 0.38, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-40000", + "time": 40, + "x": 62, + "y": 64, + "scale": 0.38, + "opacity": 1, + "rotation": 0, + "curve": "hold" + } + ] + }, + "parallax": 1, + "effects": { + "shadow": 0.25, + "blendMode": "normal", + "mask": "none", + "maskRadius": 12, + "blur": 0, + "brightness": 1, + "contrast": 1, + "saturation": 1, + "hue": 0, + "glow": 0 + } + }, + { + "id": "puppet-berta-mouth-closed", + "name": "Berta Miga mouth closed", + "type": "image", + "source": "/examples/cut-paper/mouths/paper-closed.png", + "visible": true, + "locked": false, + "z": 50, + "transform": { + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0 + }, + "animation": { + "start": { + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 1, + "rotation": 0 + }, + "end": { + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 1, + "rotation": 0 + }, + "duration": 40, + "curve": "hold", + "keyframes": [ + { + "id": "puppet-berta-mouth-closed-dialogue-0", + "time": 0, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-closed-dialogue-17000", + "time": 17, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-closed-dialogue-17280", + "time": 17.28, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-closed-dialogue-17640", + "time": 17.64, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-closed-dialogue-17980", + "time": 17.98, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-closed-dialogue-18320", + "time": 18.32, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-closed-dialogue-18720", + "time": 18.72, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-closed-dialogue-19380", + "time": 19.38, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-closed-dialogue-19540", + "time": 19.54, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-closed-dialogue-19880", + "time": 19.88, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-closed-dialogue-20080", + "time": 20.08, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-closed-dialogue-31000", + "time": 31, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-closed-dialogue-31360", + "time": 31.36, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-closed-dialogue-31880", + "time": 31.88, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-closed-dialogue-32220", + "time": 32.22, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-closed-dialogue-32880", + "time": 32.88, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-closed-dialogue-33240", + "time": 33.24, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-closed-40000", + "time": 40, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 1, + "rotation": 0, + "curve": "hold" + } + ] + }, + "relationship": { + "type": "parent", + "targetLayerId": "puppet-berta" + }, + "faceBinding": { + "poseLayerId": "puppet-berta", + "role": "mouth", + "state": "closed" + } + }, + { + "id": "puppet-berta-mouth-small", + "name": "Berta Miga mouth small", + "type": "image", + "source": "/examples/cut-paper/mouths/paper-small.png", + "visible": true, + "locked": false, + "z": 50, + "transform": { + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0 + }, + "animation": { + "start": { + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0 + }, + "end": { + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0 + }, + "duration": 40, + "curve": "hold", + "keyframes": [ + { + "id": "puppet-berta-mouth-small-dialogue-0", + "time": 0, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-small-dialogue-17000", + "time": 17, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-small-dialogue-17280", + "time": 17.28, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-small-dialogue-17640", + "time": 17.64, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-small-dialogue-17980", + "time": 17.98, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-small-dialogue-18320", + "time": 18.32, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-small-dialogue-18720", + "time": 18.72, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-small-dialogue-19380", + "time": 19.38, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-small-dialogue-19540", + "time": 19.54, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-small-dialogue-19880", + "time": 19.88, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-small-dialogue-20080", + "time": 20.08, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-small-dialogue-31000", + "time": 31, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-small-dialogue-31360", + "time": 31.36, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-small-dialogue-31880", + "time": 31.88, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-small-dialogue-32220", + "time": 32.22, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-small-dialogue-32880", + "time": 32.88, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-small-dialogue-33240", + "time": 33.24, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-small-40000", + "time": 40, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + } + ] + }, + "relationship": { + "type": "parent", + "targetLayerId": "puppet-berta" + }, + "faceBinding": { + "poseLayerId": "puppet-berta", + "role": "mouth", + "state": "small" + } + }, + { + "id": "puppet-berta-mouth-wide", + "name": "Berta Miga mouth wide", + "type": "image", + "source": "/examples/cut-paper/mouths/paper-wide.png", + "visible": true, + "locked": false, + "z": 50, + "transform": { + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0 + }, + "animation": { + "start": { + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0 + }, + "end": { + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0 + }, + "duration": 40, + "curve": "hold", + "keyframes": [ + { + "id": "puppet-berta-mouth-wide-dialogue-0", + "time": 0, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-wide-dialogue-17000", + "time": 17, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-wide-dialogue-17280", + "time": 17.28, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-wide-dialogue-17640", + "time": 17.64, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-wide-dialogue-17980", + "time": 17.98, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-wide-dialogue-18320", + "time": 18.32, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-wide-dialogue-18720", + "time": 18.72, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-wide-dialogue-19380", + "time": 19.38, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-wide-dialogue-19540", + "time": 19.54, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-wide-dialogue-19880", + "time": 19.88, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-wide-dialogue-20080", + "time": 20.08, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-wide-dialogue-31000", + "time": 31, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-wide-dialogue-31360", + "time": 31.36, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-wide-dialogue-31880", + "time": 31.88, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-wide-dialogue-32220", + "time": 32.22, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-wide-dialogue-32880", + "time": 32.88, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-wide-dialogue-33240", + "time": 33.24, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-wide-40000", + "time": 40, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + } + ] + }, + "relationship": { + "type": "parent", + "targetLayerId": "puppet-berta" + }, + "faceBinding": { + "poseLayerId": "puppet-berta", + "role": "mouth", + "state": "wide" + } + }, + { + "id": "puppet-berta-mouth-round", + "name": "Berta Miga mouth round", + "type": "image", + "source": "/examples/cut-paper/mouths/paper-round.png", + "visible": true, + "locked": false, + "z": 50, + "transform": { + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0 + }, + "animation": { + "start": { + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0 + }, + "end": { + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0 + }, + "duration": 40, + "curve": "hold", + "keyframes": [ + { + "id": "puppet-berta-mouth-round-dialogue-0", + "time": 0, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-round-dialogue-17000", + "time": 17, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-round-dialogue-17280", + "time": 17.28, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-round-dialogue-17640", + "time": 17.64, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-round-dialogue-17980", + "time": 17.98, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-round-dialogue-18320", + "time": 18.32, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-round-dialogue-18720", + "time": 18.72, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-round-dialogue-19380", + "time": 19.38, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-round-dialogue-19540", + "time": 19.54, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-round-dialogue-19880", + "time": 19.88, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-round-dialogue-20080", + "time": 20.08, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-round-dialogue-31000", + "time": 31, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-round-dialogue-31360", + "time": 31.36, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-round-dialogue-31880", + "time": 31.88, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-round-dialogue-32220", + "time": 32.22, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-round-dialogue-32880", + "time": 32.88, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-round-dialogue-33240", + "time": 33.24, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-round-40000", + "time": 40, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + } + ] + }, + "relationship": { + "type": "parent", + "targetLayerId": "puppet-berta" + }, + "faceBinding": { + "poseLayerId": "puppet-berta", + "role": "mouth", + "state": "round" + } + }, + { + "id": "sticker-ice", + "name": "Papel cebolla", + "type": "image", + "source": "/examples/cut-paper/props/onion-paper.png", + "visible": true, + "locked": false, + "z": 8, + "transform": { + "x": 50, + "y": 58, + "scale": 0.22, + "opacity": 1, + "rotation": -6 + }, + "animation": { + "start": { + "x": 50, + "y": 58, + "scale": 0.22, + "opacity": 1, + "rotation": -6 + }, + "end": { + "x": 50, + "y": 58, + "scale": 0.22, + "opacity": 1, + "rotation": -6 + }, + "duration": 40, + "curve": "ease", + "keyframes": [ + { + "id": "ice-0", + "time": 0, + "x": 50, + "y": 58, + "scale": 0.22, + "opacity": 1, + "rotation": -6, + "curve": "hold" + }, + { + "id": "sticker-ice-40000", + "time": 40, + "x": 50, + "y": 58, + "scale": 0.22, + "opacity": 1, + "rotation": -6, + "curve": "hold" + } + ] + }, + "parallax": 0.4 + } + ], + "generationPolicy": "provided_only", + "dialogueBeats": [ + { + "id": "nilo-1-0", + "text": "The", + "start": 6, + "end": 6.2, + "mouthLayerIds": [ + "puppet-nilo-mouth-closed", + "puppet-nilo-mouth-small", + "puppet-nilo-mouth-wide", + "puppet-nilo-mouth-round" + ], + "audioTrackId": "vo-nilo-1", + "confidence": "aligned-audio" + }, + { + "id": "nilo-1-1", + "text": "fountain", + "start": 6.2, + "end": 6.6, + "mouthLayerIds": [ + "puppet-nilo-mouth-closed", + "puppet-nilo-mouth-small", + "puppet-nilo-mouth-wide", + "puppet-nilo-mouth-round" + ], + "audioTrackId": "vo-nilo-1", + "confidence": "aligned-audio" + }, + { + "id": "nilo-1-2", + "text": "is", + "start": 6.6, + "end": 7.14, + "mouthLayerIds": [ + "puppet-nilo-mouth-closed", + "puppet-nilo-mouth-small", + "puppet-nilo-mouth-wide", + "puppet-nilo-mouth-round" + ], + "audioTrackId": "vo-nilo-1", + "confidence": "aligned-audio" + }, + { + "id": "nilo-1-3", + "text": "not", + "start": 7.14, + "end": 7.42, + "mouthLayerIds": [ + "puppet-nilo-mouth-closed", + "puppet-nilo-mouth-small", + "puppet-nilo-mouth-wide", + "puppet-nilo-mouth-round" + ], + "audioTrackId": "vo-nilo-1", + "confidence": "aligned-audio" + }, + { + "id": "nilo-1-4", + "text": "frozen", + "start": 7.42, + "end": 8.1, + "mouthLayerIds": [ + "puppet-nilo-mouth-closed", + "puppet-nilo-mouth-small", + "puppet-nilo-mouth-wide", + "puppet-nilo-mouth-round" + ], + "audioTrackId": "vo-nilo-1", + "confidence": "aligned-audio" + }, + { + "id": "nilo-1-5", + "text": "Someone", + "start": 8.98, + "end": 9.58, + "mouthLayerIds": [ + "puppet-nilo-mouth-closed", + "puppet-nilo-mouth-small", + "puppet-nilo-mouth-wide", + "puppet-nilo-mouth-round" + ], + "audioTrackId": "vo-nilo-1", + "confidence": "aligned-audio" + }, + { + "id": "nilo-1-6", + "text": "stuck", + "start": 9.58, + "end": 10.16, + "mouthLayerIds": [ + "puppet-nilo-mouth-closed", + "puppet-nilo-mouth-small", + "puppet-nilo-mouth-wide", + "puppet-nilo-mouth-round" + ], + "audioTrackId": "vo-nilo-1", + "confidence": "aligned-audio" + }, + { + "id": "nilo-1-7", + "text": "a", + "start": 10.16, + "end": 10.379999999999999, + "mouthLayerIds": [ + "puppet-nilo-mouth-closed", + "puppet-nilo-mouth-small", + "puppet-nilo-mouth-wide", + "puppet-nilo-mouth-round" + ], + "audioTrackId": "vo-nilo-1", + "confidence": "aligned-audio" + }, + { + "id": "nilo-1-8", + "text": "square", + "start": 10.379999999999999, + "end": 10.780000000000001, + "mouthLayerIds": [ + "puppet-nilo-mouth-closed", + "puppet-nilo-mouth-small", + "puppet-nilo-mouth-wide", + "puppet-nilo-mouth-round" + ], + "audioTrackId": "vo-nilo-1", + "confidence": "aligned-audio" + }, + { + "id": "nilo-1-9", + "text": "of", + "start": 10.780000000000001, + "end": 11.2, + "mouthLayerIds": [ + "puppet-nilo-mouth-closed", + "puppet-nilo-mouth-small", + "puppet-nilo-mouth-wide", + "puppet-nilo-mouth-round" + ], + "audioTrackId": "vo-nilo-1", + "confidence": "aligned-audio" + }, + { + "id": "nilo-1-10", + "text": "tracing", + "start": 11.2, + "end": 11.719999999999999, + "mouthLayerIds": [ + "puppet-nilo-mouth-closed", + "puppet-nilo-mouth-small", + "puppet-nilo-mouth-wide", + "puppet-nilo-mouth-round" + ], + "audioTrackId": "vo-nilo-1", + "confidence": "aligned-audio" + }, + { + "id": "nilo-1-11", + "text": "paper", + "start": 11.719999999999999, + "end": 12.08, + "mouthLayerIds": [ + "puppet-nilo-mouth-closed", + "puppet-nilo-mouth-small", + "puppet-nilo-mouth-wide", + "puppet-nilo-mouth-round" + ], + "audioTrackId": "vo-nilo-1", + "confidence": "aligned-audio" + }, + { + "id": "nilo-1-12", + "text": "on", + "start": 12.08, + "end": 12.46, + "mouthLayerIds": [ + "puppet-nilo-mouth-closed", + "puppet-nilo-mouth-small", + "puppet-nilo-mouth-wide", + "puppet-nilo-mouth-round" + ], + "audioTrackId": "vo-nilo-1", + "confidence": "aligned-audio" + }, + { + "id": "nilo-1-13", + "text": "it", + "start": 12.46, + "end": 12.58, + "mouthLayerIds": [ + "puppet-nilo-mouth-closed", + "puppet-nilo-mouth-small", + "puppet-nilo-mouth-wide", + "puppet-nilo-mouth-round" + ], + "audioTrackId": "vo-nilo-1", + "confidence": "aligned-audio" + }, + { + "id": "berta-1-0", + "text": "Well", + "start": 17, + "end": 17.28, + "mouthLayerIds": [ + "puppet-berta-mouth-closed", + "puppet-berta-mouth-small", + "puppet-berta-mouth-wide", + "puppet-berta-mouth-round" + ], + "audioTrackId": "vo-berta-1", + "confidence": "aligned-audio" + }, + { + "id": "berta-1-1", + "text": "it", + "start": 17.28, + "end": 17.64, + "mouthLayerIds": [ + "puppet-berta-mouth-closed", + "puppet-berta-mouth-small", + "puppet-berta-mouth-wide", + "puppet-berta-mouth-round" + ], + "audioTrackId": "vo-berta-1", + "confidence": "aligned-audio" + }, + { + "id": "berta-1-2", + "text": "tastes", + "start": 17.64, + "end": 17.98, + "mouthLayerIds": [ + "puppet-berta-mouth-closed", + "puppet-berta-mouth-small", + "puppet-berta-mouth-wide", + "puppet-berta-mouth-round" + ], + "audioTrackId": "vo-berta-1", + "confidence": "aligned-audio" + }, + { + "id": "berta-1-3", + "text": "like", + "start": 17.98, + "end": 18.32, + "mouthLayerIds": [ + "puppet-berta-mouth-closed", + "puppet-berta-mouth-small", + "puppet-berta-mouth-wide", + "puppet-berta-mouth-round" + ], + "audioTrackId": "vo-berta-1", + "confidence": "aligned-audio" + }, + { + "id": "berta-1-4", + "text": "ice", + "start": 18.32, + "end": 18.72, + "mouthLayerIds": [ + "puppet-berta-mouth-closed", + "puppet-berta-mouth-small", + "puppet-berta-mouth-wide", + "puppet-berta-mouth-round" + ], + "audioTrackId": "vo-berta-1", + "confidence": "aligned-audio" + }, + { + "id": "berta-1-5", + "text": "I", + "start": 19.38, + "end": 19.54, + "mouthLayerIds": [ + "puppet-berta-mouth-closed", + "puppet-berta-mouth-small", + "puppet-berta-mouth-wide", + "puppet-berta-mouth-round" + ], + "audioTrackId": "vo-berta-1", + "confidence": "aligned-audio" + }, + { + "id": "berta-1-6", + "text": "tried", + "start": 19.54, + "end": 19.88, + "mouthLayerIds": [ + "puppet-berta-mouth-closed", + "puppet-berta-mouth-small", + "puppet-berta-mouth-wide", + "puppet-berta-mouth-round" + ], + "audioTrackId": "vo-berta-1", + "confidence": "aligned-audio" + }, + { + "id": "berta-1-7", + "text": "it", + "start": 19.88, + "end": 20.08, + "mouthLayerIds": [ + "puppet-berta-mouth-closed", + "puppet-berta-mouth-small", + "puppet-berta-mouth-wide", + "puppet-berta-mouth-round" + ], + "audioTrackId": "vo-berta-1", + "confidence": "aligned-audio" + }, + { + "id": "nilo-2-0", + "text": "Berta", + "start": 25, + "end": 25.9, + "mouthLayerIds": [ + "puppet-nilo-mouth-closed", + "puppet-nilo-mouth-small", + "puppet-nilo-mouth-wide", + "puppet-nilo-mouth-round" + ], + "audioTrackId": "vo-nilo-2", + "confidence": "aligned-audio" + }, + { + "id": "nilo-2-1", + "text": "that's", + "start": 25.9, + "end": 26.14, + "mouthLayerIds": [ + "puppet-nilo-mouth-closed", + "puppet-nilo-mouth-small", + "puppet-nilo-mouth-wide", + "puppet-nilo-mouth-round" + ], + "audioTrackId": "vo-nilo-2", + "confidence": "aligned-audio" + }, + { + "id": "nilo-2-2", + "text": "glue", + "start": 26.28, + "end": 26.48, + "mouthLayerIds": [ + "puppet-nilo-mouth-closed", + "puppet-nilo-mouth-small", + "puppet-nilo-mouth-wide", + "puppet-nilo-mouth-round" + ], + "audioTrackId": "vo-nilo-2", + "confidence": "aligned-audio" + }, + { + "id": "berta-2-0", + "text": "Cold", + "start": 31, + "end": 31.36, + "mouthLayerIds": [ + "puppet-berta-mouth-closed", + "puppet-berta-mouth-small", + "puppet-berta-mouth-wide", + "puppet-berta-mouth-round" + ], + "audioTrackId": "vo-berta-2", + "confidence": "aligned-audio" + }, + { + "id": "berta-2-1", + "text": "glue", + "start": 31.36, + "end": 31.88, + "mouthLayerIds": [ + "puppet-berta-mouth-closed", + "puppet-berta-mouth-small", + "puppet-berta-mouth-wide", + "puppet-berta-mouth-round" + ], + "audioTrackId": "vo-berta-2", + "confidence": "aligned-audio" + }, + { + "id": "berta-2-2", + "text": "Like", + "start": 32.22, + "end": 32.88, + "mouthLayerIds": [ + "puppet-berta-mouth-closed", + "puppet-berta-mouth-small", + "puppet-berta-mouth-wide", + "puppet-berta-mouth-round" + ], + "audioTrackId": "vo-berta-2", + "confidence": "aligned-audio" + }, + { + "id": "berta-2-3", + "text": "ice", + "start": 32.88, + "end": 33.24, + "mouthLayerIds": [ + "puppet-berta-mouth-closed", + "puppet-berta-mouth-small", + "puppet-berta-mouth-wide", + "puppet-berta-mouth-round" + ], + "audioTrackId": "vo-berta-2", + "confidence": "aligned-audio" + } + ], + "texts": [], + "audioTracks": [ + { + "id": "vo-nilo-1", + "filename": "vo-nilo-nilo-1-en.wav", + "name": "nilo · The fountain is not froz", + "kind": "speech", + "startTime": 6, + "volume": 1 + }, + { + "id": "vo-berta-1", + "filename": "vo-berta-berta-1-en.wav", + "name": "berta · Well it tastes like ice.", + "kind": "speech", + "startTime": 17, + "volume": 1 + }, + { + "id": "vo-nilo-2", + "filename": "vo-nilo-nilo-2-en.wav", + "name": "nilo · Berta, that's glue.", + "kind": "speech", + "startTime": 25, + "volume": 1 + }, + { + "id": "vo-berta-2", + "filename": "vo-berta-berta-2-en.wav", + "name": "berta · Cold glue. Like ice.", + "kind": "speech", + "startTime": 31, + "volume": 1 + } + ] +} \ No newline at end of file diff --git a/ui/public/examples/cut-paper/shots/en/03-sticker.maestro-scene.json b/ui/public/examples/cut-paper/shots/en/03-sticker.maestro-scene.json new file mode 100644 index 000000000..138299ec5 --- /dev/null +++ b/ui/public/examples/cut-paper/shots/en/03-sticker.maestro-scene.json @@ -0,0 +1,1477 @@ +{ + "version": 1, + "name": "Tijeral · shot 3 sticker", + "width": 1280, + "height": 720, + "fps": 30, + "duration": 26, + "layers": [ + { + "id": "camera", + "name": "Camera", + "type": "camera", + "source": "", + "visible": true, + "locked": false, + "z": 100, + "transform": { + "x": 50, + "y": 50, + "scale": 1, + "opacity": 1, + "rotation": 0 + }, + "animation": { + "start": { + "x": 50, + "y": 50, + "scale": 1, + "opacity": 1, + "rotation": 0 + }, + "end": { + "x": 50, + "y": 50, + "scale": 1.04, + "opacity": 1, + "rotation": 0 + }, + "duration": 26, + "curve": "ease", + "keyframes": [ + { + "id": "camera-0", + "time": 0, + "x": 50, + "y": 50, + "scale": 1, + "opacity": 1, + "rotation": 0, + "curve": "ease" + }, + { + "id": "camera-26000", + "time": 26, + "x": 50, + "y": 50, + "scale": 1.04, + "opacity": 1, + "rotation": 0, + "curve": "ease" + } + ] + } + }, + { + "id": "location-plaza", + "name": "Location plaza", + "type": "image", + "source": "/examples/cut-paper/locations/plaza.png", + "visible": true, + "locked": false, + "z": 0, + "transform": { + "x": 50, + "y": 50, + "scale": 1, + "opacity": 1, + "rotation": 0 + }, + "animation": { + "start": { + "x": 50, + "y": 50, + "scale": 1, + "opacity": 1, + "rotation": 0 + }, + "end": { + "x": 50, + "y": 50, + "scale": 1, + "opacity": 1, + "rotation": 0 + }, + "duration": 26, + "curve": "hold", + "keyframes": [ + { + "id": "location-plaza-0", + "time": 0, + "x": 50, + "y": 50, + "scale": 1, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "location-plaza-26000", + "time": 26, + "x": 50, + "y": 50, + "scale": 1, + "opacity": 1, + "rotation": 0, + "curve": "hold" + } + ] + }, + "fill": true, + "parallax": 0.15 + }, + { + "id": "puppet-nilo", + "name": "Nilo Carda pose", + "type": "image", + "source": "/examples/cut-paper/puppets/nilo-body.png", + "visible": true, + "locked": false, + "z": 32, + "transform": { + "x": 36, + "y": 62, + "scale": 0.42, + "opacity": 1, + "rotation": 0 + }, + "animation": { + "start": { + "x": 36, + "y": 62, + "scale": 0.42, + "opacity": 1, + "rotation": 0 + }, + "end": { + "x": 36, + "y": 62, + "scale": 0.42, + "opacity": 1, + "rotation": 0 + }, + "duration": 26, + "curve": "hold", + "keyframes": [ + { + "id": "puppet-nilo-0", + "time": 0, + "x": 36, + "y": 62, + "scale": 0.42, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-26000", + "time": 26, + "x": 36, + "y": 62, + "scale": 0.42, + "opacity": 1, + "rotation": 0, + "curve": "hold" + } + ] + }, + "parallax": 1, + "effects": { + "shadow": 0.25, + "blendMode": "normal", + "mask": "none", + "maskRadius": 12, + "blur": 0, + "brightness": 1, + "contrast": 1, + "saturation": 1, + "hue": 0, + "glow": 0 + } + }, + { + "id": "puppet-nilo-mouth-closed", + "name": "Nilo Carda mouth closed", + "type": "image", + "source": "/examples/cut-paper/mouths/paper-closed.png", + "visible": true, + "locked": false, + "z": 40, + "transform": { + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0 + }, + "animation": { + "start": { + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0 + }, + "end": { + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0 + }, + "duration": 26, + "curve": "hold", + "keyframes": [ + { + "id": "puppet-nilo-mouth-closed-0", + "time": 0, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-closed-26000", + "time": 26, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + } + ] + }, + "relationship": { + "type": "parent", + "targetLayerId": "puppet-nilo" + }, + "faceBinding": { + "poseLayerId": "puppet-nilo", + "role": "mouth", + "state": "closed" + } + }, + { + "id": "puppet-nilo-mouth-small", + "name": "Nilo Carda mouth small", + "type": "image", + "source": "/examples/cut-paper/mouths/paper-small.png", + "visible": true, + "locked": false, + "z": 40, + "transform": { + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0 + }, + "animation": { + "start": { + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0 + }, + "end": { + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0 + }, + "duration": 26, + "curve": "hold", + "keyframes": [ + { + "id": "puppet-nilo-mouth-small-0", + "time": 0, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-small-26000", + "time": 26, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + } + ] + }, + "relationship": { + "type": "parent", + "targetLayerId": "puppet-nilo" + }, + "faceBinding": { + "poseLayerId": "puppet-nilo", + "role": "mouth", + "state": "small" + } + }, + { + "id": "puppet-nilo-mouth-wide", + "name": "Nilo Carda mouth wide", + "type": "image", + "source": "/examples/cut-paper/mouths/paper-wide.png", + "visible": true, + "locked": false, + "z": 40, + "transform": { + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0 + }, + "animation": { + "start": { + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0 + }, + "end": { + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0 + }, + "duration": 26, + "curve": "hold", + "keyframes": [ + { + "id": "puppet-nilo-mouth-wide-0", + "time": 0, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-wide-26000", + "time": 26, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + } + ] + }, + "relationship": { + "type": "parent", + "targetLayerId": "puppet-nilo" + }, + "faceBinding": { + "poseLayerId": "puppet-nilo", + "role": "mouth", + "state": "wide" + } + }, + { + "id": "puppet-nilo-mouth-round", + "name": "Nilo Carda mouth round", + "type": "image", + "source": "/examples/cut-paper/mouths/paper-round.png", + "visible": true, + "locked": false, + "z": 40, + "transform": { + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0 + }, + "animation": { + "start": { + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0 + }, + "end": { + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0 + }, + "duration": 26, + "curve": "hold", + "keyframes": [ + { + "id": "puppet-nilo-mouth-round-0", + "time": 0, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-round-26000", + "time": 26, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + } + ] + }, + "relationship": { + "type": "parent", + "targetLayerId": "puppet-nilo" + }, + "faceBinding": { + "poseLayerId": "puppet-nilo", + "role": "mouth", + "state": "round" + } + }, + { + "id": "puppet-berta", + "name": "Berta Miga pose", + "type": "image", + "source": "/examples/cut-paper/puppets/berta-body.png", + "visible": true, + "locked": false, + "z": 42, + "transform": { + "x": 62, + "y": 64, + "scale": 0.38, + "opacity": 1, + "rotation": 0 + }, + "animation": { + "start": { + "x": 62, + "y": 64, + "scale": 0.38, + "opacity": 1, + "rotation": 0 + }, + "end": { + "x": 62, + "y": 64, + "scale": 0.38, + "opacity": 1, + "rotation": 0 + }, + "duration": 26, + "curve": "hold", + "keyframes": [ + { + "id": "puppet-berta-0", + "time": 0, + "x": 62, + "y": 64, + "scale": 0.38, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-26000", + "time": 26, + "x": 62, + "y": 64, + "scale": 0.38, + "opacity": 1, + "rotation": 0, + "curve": "hold" + } + ] + }, + "parallax": 1, + "effects": { + "shadow": 0.25, + "blendMode": "normal", + "mask": "none", + "maskRadius": 12, + "blur": 0, + "brightness": 1, + "contrast": 1, + "saturation": 1, + "hue": 0, + "glow": 0 + } + }, + { + "id": "puppet-berta-mouth-closed", + "name": "Berta Miga mouth closed", + "type": "image", + "source": "/examples/cut-paper/mouths/paper-closed.png", + "visible": true, + "locked": false, + "z": 50, + "transform": { + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0 + }, + "animation": { + "start": { + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0 + }, + "end": { + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0 + }, + "duration": 26, + "curve": "hold", + "keyframes": [ + { + "id": "puppet-berta-mouth-closed-0", + "time": 0, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-closed-26000", + "time": 26, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + } + ] + }, + "relationship": { + "type": "parent", + "targetLayerId": "puppet-berta" + }, + "faceBinding": { + "poseLayerId": "puppet-berta", + "role": "mouth", + "state": "closed" + } + }, + { + "id": "puppet-berta-mouth-small", + "name": "Berta Miga mouth small", + "type": "image", + "source": "/examples/cut-paper/mouths/paper-small.png", + "visible": true, + "locked": false, + "z": 50, + "transform": { + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0 + }, + "animation": { + "start": { + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0 + }, + "end": { + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0 + }, + "duration": 26, + "curve": "hold", + "keyframes": [ + { + "id": "puppet-berta-mouth-small-0", + "time": 0, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-small-26000", + "time": 26, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + } + ] + }, + "relationship": { + "type": "parent", + "targetLayerId": "puppet-berta" + }, + "faceBinding": { + "poseLayerId": "puppet-berta", + "role": "mouth", + "state": "small" + } + }, + { + "id": "puppet-berta-mouth-wide", + "name": "Berta Miga mouth wide", + "type": "image", + "source": "/examples/cut-paper/mouths/paper-wide.png", + "visible": true, + "locked": false, + "z": 50, + "transform": { + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0 + }, + "animation": { + "start": { + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0 + }, + "end": { + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0 + }, + "duration": 26, + "curve": "hold", + "keyframes": [ + { + "id": "puppet-berta-mouth-wide-0", + "time": 0, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-wide-26000", + "time": 26, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + } + ] + }, + "relationship": { + "type": "parent", + "targetLayerId": "puppet-berta" + }, + "faceBinding": { + "poseLayerId": "puppet-berta", + "role": "mouth", + "state": "wide" + } + }, + { + "id": "puppet-berta-mouth-round", + "name": "Berta Miga mouth round", + "type": "image", + "source": "/examples/cut-paper/mouths/paper-round.png", + "visible": true, + "locked": false, + "z": 50, + "transform": { + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0 + }, + "animation": { + "start": { + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0 + }, + "end": { + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0 + }, + "duration": 26, + "curve": "hold", + "keyframes": [ + { + "id": "puppet-berta-mouth-round-0", + "time": 0, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-round-26000", + "time": 26, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + } + ] + }, + "relationship": { + "type": "parent", + "targetLayerId": "puppet-berta" + }, + "faceBinding": { + "poseLayerId": "puppet-berta", + "role": "mouth", + "state": "round" + } + }, + { + "id": "puppet-kito", + "name": "Kito Veleta pose", + "type": "image", + "source": "/examples/cut-paper/puppets/kito-body.png", + "visible": true, + "locked": false, + "z": 52, + "transform": { + "x": 118, + "y": 70, + "scale": 0.22399999999999998, + "opacity": 1, + "rotation": 0 + }, + "animation": { + "start": { + "x": 118, + "y": 70, + "scale": 0.22399999999999998, + "opacity": 1, + "rotation": 0 + }, + "end": { + "x": 52, + "y": 70, + "scale": 0.22399999999999998, + "opacity": 1, + "rotation": 0 + }, + "duration": 26, + "curve": "ease", + "keyframes": [ + { + "id": "puppet-kito-0", + "time": 0, + "x": 118, + "y": 70, + "scale": 0.22399999999999998, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-kito-2000", + "time": 2, + "x": 118, + "y": 70, + "scale": 0.22399999999999998, + "opacity": 1, + "rotation": 0, + "curve": "ease" + }, + { + "id": "puppet-kito-9000", + "time": 9, + "x": 52, + "y": 70, + "scale": 0.22399999999999998, + "opacity": 1, + "rotation": 0, + "curve": "ease" + }, + { + "id": "puppet-kito-26000", + "time": 26, + "x": 52, + "y": 70, + "scale": 0.22399999999999998, + "opacity": 1, + "rotation": 0, + "curve": "hold" + } + ] + }, + "parallax": 1, + "effects": { + "shadow": 0.25, + "blendMode": "normal", + "mask": "none", + "maskRadius": 12, + "blur": 0, + "brightness": 1, + "contrast": 1, + "saturation": 1, + "hue": 0, + "glow": 0 + } + }, + { + "id": "puppet-kito-mouth-closed", + "name": "Kito Veleta mouth closed", + "type": "image", + "source": "/examples/cut-paper/mouths/paper-closed.png", + "visible": true, + "locked": false, + "z": 60, + "transform": { + "x": 118, + "y": 71.43136, + "scale": 0.03584, + "opacity": 0, + "rotation": 0 + }, + "animation": { + "start": { + "x": 118, + "y": 71.43136, + "scale": 0.03584, + "opacity": 1, + "rotation": 0 + }, + "end": { + "x": 118, + "y": 71.43136, + "scale": 0.03584, + "opacity": 1, + "rotation": 0 + }, + "duration": 26, + "curve": "hold", + "keyframes": [ + { + "id": "puppet-kito-mouth-closed-dialogue-0", + "time": 0, + "x": 118, + "y": 71.43136, + "scale": 0.03584, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-kito-mouth-closed-dialogue-10000", + "time": 10, + "x": 118, + "y": 71.43136, + "scale": 0.03584, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-kito-mouth-closed-dialogue-10180", + "time": 10.18, + "x": 118, + "y": 71.43136, + "scale": 0.03584, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-kito-mouth-closed-dialogue-10360", + "time": 10.36, + "x": 118, + "y": 71.43136, + "scale": 0.03584, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-kito-mouth-closed-dialogue-10460", + "time": 10.46, + "x": 118, + "y": 71.43136, + "scale": 0.03584, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-kito-mouth-closed-dialogue-10800", + "time": 10.8, + "x": 118, + "y": 71.43136, + "scale": 0.03584, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-kito-mouth-closed-26000", + "time": 26, + "x": 118, + "y": 71.43136, + "scale": 0.03584, + "opacity": 1, + "rotation": 0, + "curve": "hold" + } + ] + }, + "relationship": { + "type": "parent", + "targetLayerId": "puppet-kito" + }, + "faceBinding": { + "poseLayerId": "puppet-kito", + "role": "mouth", + "state": "closed" + } + }, + { + "id": "puppet-kito-mouth-small", + "name": "Kito Veleta mouth small", + "type": "image", + "source": "/examples/cut-paper/mouths/paper-small.png", + "visible": true, + "locked": false, + "z": 60, + "transform": { + "x": 118, + "y": 71.43136, + "scale": 0.03584, + "opacity": 0, + "rotation": 0 + }, + "animation": { + "start": { + "x": 118, + "y": 71.43136, + "scale": 0.03584, + "opacity": 0, + "rotation": 0 + }, + "end": { + "x": 118, + "y": 71.43136, + "scale": 0.03584, + "opacity": 0, + "rotation": 0 + }, + "duration": 26, + "curve": "hold", + "keyframes": [ + { + "id": "puppet-kito-mouth-small-dialogue-0", + "time": 0, + "x": 118, + "y": 71.43136, + "scale": 0.03584, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-kito-mouth-small-dialogue-10000", + "time": 10, + "x": 118, + "y": 71.43136, + "scale": 0.03584, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-kito-mouth-small-dialogue-10180", + "time": 10.18, + "x": 118, + "y": 71.43136, + "scale": 0.03584, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-kito-mouth-small-dialogue-10360", + "time": 10.36, + "x": 118, + "y": 71.43136, + "scale": 0.03584, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-kito-mouth-small-dialogue-10460", + "time": 10.46, + "x": 118, + "y": 71.43136, + "scale": 0.03584, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-kito-mouth-small-dialogue-10800", + "time": 10.8, + "x": 118, + "y": 71.43136, + "scale": 0.03584, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-kito-mouth-small-26000", + "time": 26, + "x": 118, + "y": 71.43136, + "scale": 0.03584, + "opacity": 0, + "rotation": 0, + "curve": "hold" + } + ] + }, + "relationship": { + "type": "parent", + "targetLayerId": "puppet-kito" + }, + "faceBinding": { + "poseLayerId": "puppet-kito", + "role": "mouth", + "state": "small" + } + }, + { + "id": "puppet-kito-mouth-wide", + "name": "Kito Veleta mouth wide", + "type": "image", + "source": "/examples/cut-paper/mouths/paper-wide.png", + "visible": true, + "locked": false, + "z": 60, + "transform": { + "x": 118, + "y": 71.43136, + "scale": 0.03584, + "opacity": 0, + "rotation": 0 + }, + "animation": { + "start": { + "x": 118, + "y": 71.43136, + "scale": 0.03584, + "opacity": 0, + "rotation": 0 + }, + "end": { + "x": 118, + "y": 71.43136, + "scale": 0.03584, + "opacity": 0, + "rotation": 0 + }, + "duration": 26, + "curve": "hold", + "keyframes": [ + { + "id": "puppet-kito-mouth-wide-dialogue-0", + "time": 0, + "x": 118, + "y": 71.43136, + "scale": 0.03584, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-kito-mouth-wide-dialogue-10000", + "time": 10, + "x": 118, + "y": 71.43136, + "scale": 0.03584, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-kito-mouth-wide-dialogue-10180", + "time": 10.18, + "x": 118, + "y": 71.43136, + "scale": 0.03584, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-kito-mouth-wide-dialogue-10360", + "time": 10.36, + "x": 118, + "y": 71.43136, + "scale": 0.03584, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-kito-mouth-wide-dialogue-10460", + "time": 10.46, + "x": 118, + "y": 71.43136, + "scale": 0.03584, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-kito-mouth-wide-dialogue-10800", + "time": 10.8, + "x": 118, + "y": 71.43136, + "scale": 0.03584, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-kito-mouth-wide-26000", + "time": 26, + "x": 118, + "y": 71.43136, + "scale": 0.03584, + "opacity": 0, + "rotation": 0, + "curve": "hold" + } + ] + }, + "relationship": { + "type": "parent", + "targetLayerId": "puppet-kito" + }, + "faceBinding": { + "poseLayerId": "puppet-kito", + "role": "mouth", + "state": "wide" + } + }, + { + "id": "puppet-kito-mouth-round", + "name": "Kito Veleta mouth round", + "type": "image", + "source": "/examples/cut-paper/mouths/paper-round.png", + "visible": true, + "locked": false, + "z": 60, + "transform": { + "x": 118, + "y": 71.43136, + "scale": 0.03584, + "opacity": 0, + "rotation": 0 + }, + "animation": { + "start": { + "x": 118, + "y": 71.43136, + "scale": 0.03584, + "opacity": 0, + "rotation": 0 + }, + "end": { + "x": 118, + "y": 71.43136, + "scale": 0.03584, + "opacity": 0, + "rotation": 0 + }, + "duration": 26, + "curve": "hold", + "keyframes": [ + { + "id": "puppet-kito-mouth-round-dialogue-0", + "time": 0, + "x": 118, + "y": 71.43136, + "scale": 0.03584, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-kito-mouth-round-dialogue-10000", + "time": 10, + "x": 118, + "y": 71.43136, + "scale": 0.03584, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-kito-mouth-round-dialogue-10180", + "time": 10.18, + "x": 118, + "y": 71.43136, + "scale": 0.03584, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-kito-mouth-round-dialogue-10360", + "time": 10.36, + "x": 118, + "y": 71.43136, + "scale": 0.03584, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-kito-mouth-round-dialogue-10460", + "time": 10.46, + "x": 118, + "y": 71.43136, + "scale": 0.03584, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-kito-mouth-round-dialogue-10800", + "time": 10.8, + "x": 118, + "y": 71.43136, + "scale": 0.03584, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-kito-mouth-round-26000", + "time": 26, + "x": 118, + "y": 71.43136, + "scale": 0.03584, + "opacity": 0, + "rotation": 0, + "curve": "hold" + } + ] + }, + "relationship": { + "type": "parent", + "targetLayerId": "puppet-kito" + }, + "faceBinding": { + "poseLayerId": "puppet-kito", + "role": "mouth", + "state": "round" + } + }, + { + "id": "sticker-ice", + "name": "Papel cebolla", + "type": "image", + "source": "/examples/cut-paper/props/onion-paper.png", + "visible": true, + "locked": false, + "z": 8, + "transform": { + "x": 50, + "y": 58, + "scale": 0.22, + "opacity": 1, + "rotation": -6 + }, + "animation": { + "start": { + "x": 50, + "y": 58, + "scale": 0.22, + "opacity": 1, + "rotation": -6 + }, + "end": { + "x": 78, + "y": 82, + "scale": 0.18, + "opacity": 0, + "rotation": 18 + }, + "duration": 26, + "curve": "ease", + "keyframes": [ + { + "id": "ice-0", + "time": 0, + "x": 50, + "y": 58, + "scale": 0.22, + "opacity": 1, + "rotation": -6, + "curve": "hold" + }, + { + "id": "ice-1", + "time": 2, + "x": 50, + "y": 58, + "scale": 0.22, + "opacity": 1, + "rotation": -6, + "curve": "ease" + }, + { + "id": "ice-2", + "time": 9, + "x": 78, + "y": 82, + "scale": 0.18, + "opacity": 0, + "rotation": 18, + "curve": "ease" + }, + { + "id": "ice-3", + "time": 26, + "x": 78, + "y": 82, + "scale": 0.18, + "opacity": 0, + "rotation": 18, + "curve": "hold" + } + ] + }, + "parallax": 0.4 + } + ], + "generationPolicy": "provided_only", + "dialogueBeats": [ + { + "id": "kito-1-0", + "text": "It", + "start": 10, + "end": 10.18, + "mouthLayerIds": [ + "puppet-kito-mouth-closed", + "puppet-kito-mouth-small", + "puppet-kito-mouth-wide", + "puppet-kito-mouth-round" + ], + "audioTrackId": "vo-kito-1", + "confidence": "aligned-audio" + }, + { + "id": "kito-1-1", + "text": "was", + "start": 10.18, + "end": 10.36, + "mouthLayerIds": [ + "puppet-kito-mouth-closed", + "puppet-kito-mouth-small", + "puppet-kito-mouth-wide", + "puppet-kito-mouth-round" + ], + "audioTrackId": "vo-kito-1", + "confidence": "aligned-audio" + }, + { + "id": "kito-1-2", + "text": "a", + "start": 10.36, + "end": 10.46, + "mouthLayerIds": [ + "puppet-kito-mouth-closed", + "puppet-kito-mouth-small", + "puppet-kito-mouth-wide", + "puppet-kito-mouth-round" + ], + "audioTrackId": "vo-kito-1", + "confidence": "aligned-audio" + }, + { + "id": "kito-1-3", + "text": "sticker", + "start": 10.46, + "end": 10.8, + "mouthLayerIds": [ + "puppet-kito-mouth-closed", + "puppet-kito-mouth-small", + "puppet-kito-mouth-wide", + "puppet-kito-mouth-round" + ], + "audioTrackId": "vo-kito-1", + "confidence": "aligned-audio" + } + ], + "audioTracks": [ + { + "id": "vo-kito-1", + "filename": "vo-kito-kito-1-en.wav", + "name": "kito · sticker", + "kind": "speech", + "startTime": 10, + "volume": 1 + } + ] +} \ No newline at end of file diff --git a/ui/public/examples/cut-paper/shots/en/tijeral-la-fuente.maestro-scene.json b/ui/public/examples/cut-paper/shots/en/tijeral-la-fuente.maestro-scene.json new file mode 100644 index 000000000..d5c51ba7a --- /dev/null +++ b/ui/public/examples/cut-paper/shots/en/tijeral-la-fuente.maestro-scene.json @@ -0,0 +1,3287 @@ +{ + "version": 1, + "name": "Tijeral · the fountain", + "width": 1280, + "height": 720, + "fps": 30, + "duration": 78, + "layers": [ + { + "id": "camera", + "name": "Camera", + "type": "camera", + "source": "", + "visible": true, + "locked": false, + "z": 100, + "transform": { + "x": 50, + "y": 50, + "scale": 1, + "opacity": 1, + "rotation": 0 + }, + "animation": { + "start": { + "x": 50, + "y": 50, + "scale": 1, + "opacity": 1, + "rotation": 0 + }, + "end": { + "x": 50, + "y": 50, + "scale": 1.04, + "opacity": 1, + "rotation": 0 + }, + "duration": 78, + "curve": "ease", + "keyframes": [ + { + "id": "camera-0", + "time": 0, + "x": 50, + "y": 50, + "scale": 1, + "opacity": 1, + "rotation": 0, + "curve": "ease" + }, + { + "id": "camera-78000", + "time": 78, + "x": 50, + "y": 50, + "scale": 1.04, + "opacity": 1, + "rotation": 0, + "curve": "ease" + } + ] + } + }, + { + "id": "location-plaza", + "name": "Location plaza", + "type": "image", + "source": "/examples/cut-paper/locations/plaza.png", + "visible": true, + "locked": false, + "z": 0, + "transform": { + "x": 50, + "y": 50, + "scale": 1, + "opacity": 1, + "rotation": 0 + }, + "animation": { + "start": { + "x": 50, + "y": 50, + "scale": 1, + "opacity": 1, + "rotation": 0 + }, + "end": { + "x": 50, + "y": 50, + "scale": 1, + "opacity": 1, + "rotation": 0 + }, + "duration": 78, + "curve": "hold", + "keyframes": [ + { + "id": "location-plaza-0", + "time": 0, + "x": 50, + "y": 50, + "scale": 1, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "location-plaza-78000", + "time": 78, + "x": 50, + "y": 50, + "scale": 1, + "opacity": 1, + "rotation": 0, + "curve": "hold" + } + ] + }, + "fill": true, + "parallax": 0.15 + }, + { + "id": "puppet-nilo", + "name": "Nilo Carda pose", + "type": "image", + "source": "/examples/cut-paper/puppets/nilo-body.png", + "visible": true, + "locked": false, + "z": 32, + "transform": { + "x": 36, + "y": 62, + "scale": 0.42, + "opacity": 1, + "rotation": 0 + }, + "animation": { + "start": { + "x": 36, + "y": 62, + "scale": 0.42, + "opacity": 1, + "rotation": 0 + }, + "end": { + "x": 36, + "y": 62, + "scale": 0.42, + "opacity": 1, + "rotation": 0 + }, + "duration": 78, + "curve": "hold", + "keyframes": [ + { + "id": "puppet-nilo-0", + "time": 0, + "x": 36, + "y": 62, + "scale": 0.42, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-78000", + "time": 78, + "x": 36, + "y": 62, + "scale": 0.42, + "opacity": 1, + "rotation": 0, + "curve": "hold" + } + ] + }, + "parallax": 1, + "effects": { + "shadow": 0.25, + "blendMode": "normal", + "mask": "none", + "maskRadius": 12, + "blur": 0, + "brightness": 1, + "contrast": 1, + "saturation": 1, + "hue": 0, + "glow": 0 + } + }, + { + "id": "puppet-nilo-mouth-closed", + "name": "Nilo Carda mouth closed", + "type": "image", + "source": "/examples/cut-paper/mouths/paper-closed.png", + "visible": true, + "locked": false, + "z": 40, + "transform": { + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0 + }, + "animation": { + "start": { + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0 + }, + "end": { + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0 + }, + "duration": 78, + "curve": "hold", + "keyframes": [ + { + "id": "puppet-nilo-mouth-closed-dialogue-0", + "time": 0, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-closed-dialogue-6000", + "time": 6, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-closed-dialogue-6200", + "time": 6.2, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-closed-dialogue-6600", + "time": 6.6, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-closed-dialogue-7140", + "time": 7.14, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-closed-dialogue-7420", + "time": 7.42, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-closed-dialogue-8100", + "time": 8.1, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-closed-dialogue-8980", + "time": 8.98, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-closed-dialogue-9580", + "time": 9.58, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-closed-dialogue-10160", + "time": 10.16, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-closed-dialogue-10380", + "time": 10.379999999999999, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-closed-dialogue-10780", + "time": 10.780000000000001, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-closed-dialogue-11200", + "time": 11.2, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-closed-dialogue-11720", + "time": 11.719999999999999, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-closed-dialogue-12080", + "time": 12.08, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-closed-dialogue-12460", + "time": 12.46, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-closed-dialogue-12580", + "time": 12.58, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-closed-dialogue-25000", + "time": 25, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-closed-dialogue-25900", + "time": 25.9, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-closed-dialogue-26140", + "time": 26.14, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-closed-dialogue-26280", + "time": 26.28, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-closed-dialogue-26480", + "time": 26.48, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 1, + "rotation": 0, + "curve": "hold" + } + ] + }, + "relationship": { + "type": "parent", + "targetLayerId": "puppet-nilo" + }, + "faceBinding": { + "poseLayerId": "puppet-nilo", + "role": "mouth", + "state": "closed" + } + }, + { + "id": "puppet-nilo-mouth-small", + "name": "Nilo Carda mouth small", + "type": "image", + "source": "/examples/cut-paper/mouths/paper-small.png", + "visible": true, + "locked": false, + "z": 40, + "transform": { + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0 + }, + "animation": { + "start": { + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0 + }, + "end": { + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0 + }, + "duration": 78, + "curve": "hold", + "keyframes": [ + { + "id": "puppet-nilo-mouth-small-dialogue-0", + "time": 0, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-small-dialogue-6000", + "time": 6, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-small-dialogue-6200", + "time": 6.2, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-small-dialogue-6600", + "time": 6.6, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-small-dialogue-7140", + "time": 7.14, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-small-dialogue-7420", + "time": 7.42, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-small-dialogue-8100", + "time": 8.1, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-small-dialogue-8980", + "time": 8.98, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-small-dialogue-9580", + "time": 9.58, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-small-dialogue-10160", + "time": 10.16, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-small-dialogue-10380", + "time": 10.379999999999999, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-small-dialogue-10780", + "time": 10.780000000000001, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-small-dialogue-11200", + "time": 11.2, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-small-dialogue-11720", + "time": 11.719999999999999, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-small-dialogue-12080", + "time": 12.08, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-small-dialogue-12460", + "time": 12.46, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-small-dialogue-12580", + "time": 12.58, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-small-dialogue-25000", + "time": 25, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-small-dialogue-25900", + "time": 25.9, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-small-dialogue-26140", + "time": 26.14, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-small-dialogue-26280", + "time": 26.28, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-small-dialogue-26480", + "time": 26.48, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + } + ] + }, + "relationship": { + "type": "parent", + "targetLayerId": "puppet-nilo" + }, + "faceBinding": { + "poseLayerId": "puppet-nilo", + "role": "mouth", + "state": "small" + } + }, + { + "id": "puppet-nilo-mouth-wide", + "name": "Nilo Carda mouth wide", + "type": "image", + "source": "/examples/cut-paper/mouths/paper-wide.png", + "visible": true, + "locked": false, + "z": 40, + "transform": { + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0 + }, + "animation": { + "start": { + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0 + }, + "end": { + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0 + }, + "duration": 78, + "curve": "hold", + "keyframes": [ + { + "id": "puppet-nilo-mouth-wide-dialogue-0", + "time": 0, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-wide-dialogue-6000", + "time": 6, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-wide-dialogue-6200", + "time": 6.2, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-wide-dialogue-6600", + "time": 6.6, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-wide-dialogue-7140", + "time": 7.14, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-wide-dialogue-7420", + "time": 7.42, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-wide-dialogue-8100", + "time": 8.1, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-wide-dialogue-8980", + "time": 8.98, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-wide-dialogue-9580", + "time": 9.58, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-wide-dialogue-10160", + "time": 10.16, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-wide-dialogue-10380", + "time": 10.379999999999999, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-wide-dialogue-10780", + "time": 10.780000000000001, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-wide-dialogue-11200", + "time": 11.2, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-wide-dialogue-11720", + "time": 11.719999999999999, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-wide-dialogue-12080", + "time": 12.08, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-wide-dialogue-12460", + "time": 12.46, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-wide-dialogue-12580", + "time": 12.58, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-wide-dialogue-25000", + "time": 25, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-wide-dialogue-25900", + "time": 25.9, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-wide-dialogue-26140", + "time": 26.14, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-wide-dialogue-26280", + "time": 26.28, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-wide-dialogue-26480", + "time": 26.48, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + } + ] + }, + "relationship": { + "type": "parent", + "targetLayerId": "puppet-nilo" + }, + "faceBinding": { + "poseLayerId": "puppet-nilo", + "role": "mouth", + "state": "wide" + } + }, + { + "id": "puppet-nilo-mouth-round", + "name": "Nilo Carda mouth round", + "type": "image", + "source": "/examples/cut-paper/mouths/paper-round.png", + "visible": true, + "locked": false, + "z": 40, + "transform": { + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0 + }, + "animation": { + "start": { + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0 + }, + "end": { + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0 + }, + "duration": 78, + "curve": "hold", + "keyframes": [ + { + "id": "puppet-nilo-mouth-round-dialogue-0", + "time": 0, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-round-dialogue-6000", + "time": 6, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-round-dialogue-6200", + "time": 6.2, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-round-dialogue-6600", + "time": 6.6, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-round-dialogue-7140", + "time": 7.14, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-round-dialogue-7420", + "time": 7.42, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-round-dialogue-8100", + "time": 8.1, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-round-dialogue-8980", + "time": 8.98, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-round-dialogue-9580", + "time": 9.58, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-round-dialogue-10160", + "time": 10.16, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-round-dialogue-10380", + "time": 10.379999999999999, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-round-dialogue-10780", + "time": 10.780000000000001, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-round-dialogue-11200", + "time": 11.2, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-round-dialogue-11720", + "time": 11.719999999999999, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-round-dialogue-12080", + "time": 12.08, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-round-dialogue-12460", + "time": 12.46, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-round-dialogue-12580", + "time": 12.58, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-round-dialogue-25000", + "time": 25, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-round-dialogue-25900", + "time": 25.9, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-round-dialogue-26140", + "time": 26.14, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-round-dialogue-26280", + "time": 26.28, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-round-dialogue-26480", + "time": 26.48, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + } + ] + }, + "relationship": { + "type": "parent", + "targetLayerId": "puppet-nilo" + }, + "faceBinding": { + "poseLayerId": "puppet-nilo", + "role": "mouth", + "state": "round" + } + }, + { + "id": "puppet-berta", + "name": "Berta Miga pose", + "type": "image", + "source": "/examples/cut-paper/puppets/berta-body.png", + "visible": true, + "locked": false, + "z": 42, + "transform": { + "x": 62, + "y": 64, + "scale": 0.38, + "opacity": 1, + "rotation": 0 + }, + "animation": { + "start": { + "x": 62, + "y": 64, + "scale": 0.38, + "opacity": 1, + "rotation": 0 + }, + "end": { + "x": 62, + "y": 64, + "scale": 0.38, + "opacity": 1, + "rotation": 0 + }, + "duration": 78, + "curve": "hold", + "keyframes": [ + { + "id": "puppet-berta-0", + "time": 0, + "x": 62, + "y": 64, + "scale": 0.38, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-78000", + "time": 78, + "x": 62, + "y": 64, + "scale": 0.38, + "opacity": 1, + "rotation": 0, + "curve": "hold" + } + ] + }, + "parallax": 1, + "effects": { + "shadow": 0.25, + "blendMode": "normal", + "mask": "none", + "maskRadius": 12, + "blur": 0, + "brightness": 1, + "contrast": 1, + "saturation": 1, + "hue": 0, + "glow": 0 + } + }, + { + "id": "puppet-berta-mouth-closed", + "name": "Berta Miga mouth closed", + "type": "image", + "source": "/examples/cut-paper/mouths/paper-closed.png", + "visible": true, + "locked": false, + "z": 50, + "transform": { + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0 + }, + "animation": { + "start": { + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0 + }, + "end": { + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0 + }, + "duration": 78, + "curve": "hold", + "keyframes": [ + { + "id": "puppet-berta-mouth-closed-dialogue-0", + "time": 0, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-closed-dialogue-17000", + "time": 17, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-closed-dialogue-17280", + "time": 17.28, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-closed-dialogue-17640", + "time": 17.64, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-closed-dialogue-17980", + "time": 17.98, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-closed-dialogue-18320", + "time": 18.32, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-closed-dialogue-18720", + "time": 18.72, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-closed-dialogue-19380", + "time": 19.38, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-closed-dialogue-19540", + "time": 19.54, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-closed-dialogue-19880", + "time": 19.88, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-closed-dialogue-20080", + "time": 20.08, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-closed-dialogue-31000", + "time": 31, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-closed-dialogue-31360", + "time": 31.36, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-closed-dialogue-31880", + "time": 31.88, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-closed-dialogue-32220", + "time": 32.22, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-closed-dialogue-32880", + "time": 32.88, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-closed-dialogue-33240", + "time": 33.24, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 1, + "rotation": 0, + "curve": "hold" + } + ] + }, + "relationship": { + "type": "parent", + "targetLayerId": "puppet-berta" + }, + "faceBinding": { + "poseLayerId": "puppet-berta", + "role": "mouth", + "state": "closed" + } + }, + { + "id": "puppet-berta-mouth-small", + "name": "Berta Miga mouth small", + "type": "image", + "source": "/examples/cut-paper/mouths/paper-small.png", + "visible": true, + "locked": false, + "z": 50, + "transform": { + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0 + }, + "animation": { + "start": { + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0 + }, + "end": { + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0 + }, + "duration": 78, + "curve": "hold", + "keyframes": [ + { + "id": "puppet-berta-mouth-small-dialogue-0", + "time": 0, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-small-dialogue-17000", + "time": 17, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-small-dialogue-17280", + "time": 17.28, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-small-dialogue-17640", + "time": 17.64, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-small-dialogue-17980", + "time": 17.98, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-small-dialogue-18320", + "time": 18.32, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-small-dialogue-18720", + "time": 18.72, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-small-dialogue-19380", + "time": 19.38, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-small-dialogue-19540", + "time": 19.54, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-small-dialogue-19880", + "time": 19.88, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-small-dialogue-20080", + "time": 20.08, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-small-dialogue-31000", + "time": 31, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-small-dialogue-31360", + "time": 31.36, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-small-dialogue-31880", + "time": 31.88, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-small-dialogue-32220", + "time": 32.22, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-small-dialogue-32880", + "time": 32.88, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-small-dialogue-33240", + "time": 33.24, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + } + ] + }, + "relationship": { + "type": "parent", + "targetLayerId": "puppet-berta" + }, + "faceBinding": { + "poseLayerId": "puppet-berta", + "role": "mouth", + "state": "small" + } + }, + { + "id": "puppet-berta-mouth-wide", + "name": "Berta Miga mouth wide", + "type": "image", + "source": "/examples/cut-paper/mouths/paper-wide.png", + "visible": true, + "locked": false, + "z": 50, + "transform": { + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0 + }, + "animation": { + "start": { + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0 + }, + "end": { + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0 + }, + "duration": 78, + "curve": "hold", + "keyframes": [ + { + "id": "puppet-berta-mouth-wide-dialogue-0", + "time": 0, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-wide-dialogue-17000", + "time": 17, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-wide-dialogue-17280", + "time": 17.28, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-wide-dialogue-17640", + "time": 17.64, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-wide-dialogue-17980", + "time": 17.98, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-wide-dialogue-18320", + "time": 18.32, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-wide-dialogue-18720", + "time": 18.72, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-wide-dialogue-19380", + "time": 19.38, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-wide-dialogue-19540", + "time": 19.54, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-wide-dialogue-19880", + "time": 19.88, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-wide-dialogue-20080", + "time": 20.08, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-wide-dialogue-31000", + "time": 31, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-wide-dialogue-31360", + "time": 31.36, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-wide-dialogue-31880", + "time": 31.88, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-wide-dialogue-32220", + "time": 32.22, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-wide-dialogue-32880", + "time": 32.88, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-wide-dialogue-33240", + "time": 33.24, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + } + ] + }, + "relationship": { + "type": "parent", + "targetLayerId": "puppet-berta" + }, + "faceBinding": { + "poseLayerId": "puppet-berta", + "role": "mouth", + "state": "wide" + } + }, + { + "id": "puppet-berta-mouth-round", + "name": "Berta Miga mouth round", + "type": "image", + "source": "/examples/cut-paper/mouths/paper-round.png", + "visible": true, + "locked": false, + "z": 50, + "transform": { + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0 + }, + "animation": { + "start": { + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0 + }, + "end": { + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0 + }, + "duration": 78, + "curve": "hold", + "keyframes": [ + { + "id": "puppet-berta-mouth-round-dialogue-0", + "time": 0, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-round-dialogue-17000", + "time": 17, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-round-dialogue-17280", + "time": 17.28, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-round-dialogue-17640", + "time": 17.64, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-round-dialogue-17980", + "time": 17.98, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-round-dialogue-18320", + "time": 18.32, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-round-dialogue-18720", + "time": 18.72, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-round-dialogue-19380", + "time": 19.38, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-round-dialogue-19540", + "time": 19.54, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-round-dialogue-19880", + "time": 19.88, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-round-dialogue-20080", + "time": 20.08, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-round-dialogue-31000", + "time": 31, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-round-dialogue-31360", + "time": 31.36, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-round-dialogue-31880", + "time": 31.88, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-round-dialogue-32220", + "time": 32.22, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-round-dialogue-32880", + "time": 32.88, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-round-dialogue-33240", + "time": 33.24, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + } + ] + }, + "relationship": { + "type": "parent", + "targetLayerId": "puppet-berta" + }, + "faceBinding": { + "poseLayerId": "puppet-berta", + "role": "mouth", + "state": "round" + } + }, + { + "id": "puppet-kito", + "name": "Kito Veleta pose", + "type": "image", + "source": "/examples/cut-paper/puppets/kito-body.png", + "visible": true, + "locked": false, + "z": 52, + "transform": { + "x": 118, + "y": 70, + "scale": 0.22399999999999998, + "opacity": 1, + "rotation": 0 + }, + "animation": { + "start": { + "x": 118, + "y": 70, + "scale": 0.22399999999999998, + "opacity": 1 + }, + "end": { + "x": 52, + "y": 70, + "scale": 0.22399999999999998, + "opacity": 1 + }, + "duration": 78, + "curve": "ease", + "keyframes": [ + { + "id": "puppet-kito-0", + "time": 0, + "x": 118, + "y": 70, + "scale": 0.22399999999999998, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-kito-54000", + "time": 54, + "x": 118, + "y": 70, + "scale": 0.22399999999999998, + "opacity": 1, + "rotation": 0, + "curve": "ease" + }, + { + "id": "puppet-kito-61000", + "time": 61, + "x": 52, + "y": 70, + "scale": 0.22399999999999998, + "opacity": 1, + "rotation": 0, + "curve": "ease" + }, + { + "id": "puppet-kito-78000", + "time": 78, + "x": 52, + "y": 70, + "scale": 0.22399999999999998, + "opacity": 1, + "rotation": 0, + "curve": "hold" + } + ] + }, + "parallax": 1, + "effects": { + "shadow": 0.25, + "blendMode": "normal", + "mask": "none", + "maskRadius": 12, + "blur": 0, + "brightness": 1, + "contrast": 1, + "saturation": 1, + "hue": 0, + "glow": 0 + } + }, + { + "id": "puppet-kito-mouth-closed", + "name": "Kito Veleta mouth closed", + "type": "image", + "source": "/examples/cut-paper/mouths/paper-closed.png", + "visible": true, + "locked": false, + "z": 60, + "transform": { + "x": 118, + "y": 71.43136, + "scale": 0.03584, + "opacity": 0, + "rotation": 0 + }, + "animation": { + "start": { + "x": 118, + "y": 71.43136, + "scale": 0.03584, + "opacity": 0, + "rotation": 0 + }, + "end": { + "x": 118, + "y": 71.43136, + "scale": 0.03584, + "opacity": 0, + "rotation": 0 + }, + "duration": 78, + "curve": "hold", + "keyframes": [ + { + "id": "puppet-kito-mouth-closed-dialogue-0", + "time": 0, + "x": 118, + "y": 71.43136, + "scale": 0.03584, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-kito-mouth-closed-dialogue-62000", + "time": 62, + "x": 118, + "y": 71.43136, + "scale": 0.03584, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-kito-mouth-closed-dialogue-62180", + "time": 62.18, + "x": 118, + "y": 71.43136, + "scale": 0.03584, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-kito-mouth-closed-dialogue-62360", + "time": 62.36, + "x": 118, + "y": 71.43136, + "scale": 0.03584, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-kito-mouth-closed-dialogue-62460", + "time": 62.46, + "x": 118, + "y": 71.43136, + "scale": 0.03584, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-kito-mouth-closed-dialogue-62800", + "time": 62.8, + "x": 118, + "y": 71.43136, + "scale": 0.03584, + "opacity": 1, + "rotation": 0, + "curve": "hold" + } + ] + }, + "relationship": { + "type": "parent", + "targetLayerId": "puppet-kito" + }, + "faceBinding": { + "poseLayerId": "puppet-kito", + "role": "mouth", + "state": "closed" + } + }, + { + "id": "puppet-kito-mouth-small", + "name": "Kito Veleta mouth small", + "type": "image", + "source": "/examples/cut-paper/mouths/paper-small.png", + "visible": true, + "locked": false, + "z": 60, + "transform": { + "x": 118, + "y": 71.43136, + "scale": 0.03584, + "opacity": 0, + "rotation": 0 + }, + "animation": { + "start": { + "x": 118, + "y": 71.43136, + "scale": 0.03584, + "opacity": 0, + "rotation": 0 + }, + "end": { + "x": 118, + "y": 71.43136, + "scale": 0.03584, + "opacity": 0, + "rotation": 0 + }, + "duration": 78, + "curve": "hold", + "keyframes": [ + { + "id": "puppet-kito-mouth-small-dialogue-0", + "time": 0, + "x": 118, + "y": 71.43136, + "scale": 0.03584, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-kito-mouth-small-dialogue-62000", + "time": 62, + "x": 118, + "y": 71.43136, + "scale": 0.03584, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-kito-mouth-small-dialogue-62180", + "time": 62.18, + "x": 118, + "y": 71.43136, + "scale": 0.03584, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-kito-mouth-small-dialogue-62360", + "time": 62.36, + "x": 118, + "y": 71.43136, + "scale": 0.03584, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-kito-mouth-small-dialogue-62460", + "time": 62.46, + "x": 118, + "y": 71.43136, + "scale": 0.03584, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-kito-mouth-small-dialogue-62800", + "time": 62.8, + "x": 118, + "y": 71.43136, + "scale": 0.03584, + "opacity": 0, + "rotation": 0, + "curve": "hold" + } + ] + }, + "relationship": { + "type": "parent", + "targetLayerId": "puppet-kito" + }, + "faceBinding": { + "poseLayerId": "puppet-kito", + "role": "mouth", + "state": "small" + } + }, + { + "id": "puppet-kito-mouth-wide", + "name": "Kito Veleta mouth wide", + "type": "image", + "source": "/examples/cut-paper/mouths/paper-wide.png", + "visible": true, + "locked": false, + "z": 60, + "transform": { + "x": 118, + "y": 71.43136, + "scale": 0.03584, + "opacity": 0, + "rotation": 0 + }, + "animation": { + "start": { + "x": 118, + "y": 71.43136, + "scale": 0.03584, + "opacity": 0, + "rotation": 0 + }, + "end": { + "x": 118, + "y": 71.43136, + "scale": 0.03584, + "opacity": 0, + "rotation": 0 + }, + "duration": 78, + "curve": "hold", + "keyframes": [ + { + "id": "puppet-kito-mouth-wide-dialogue-0", + "time": 0, + "x": 118, + "y": 71.43136, + "scale": 0.03584, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-kito-mouth-wide-dialogue-62000", + "time": 62, + "x": 118, + "y": 71.43136, + "scale": 0.03584, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-kito-mouth-wide-dialogue-62180", + "time": 62.18, + "x": 118, + "y": 71.43136, + "scale": 0.03584, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-kito-mouth-wide-dialogue-62360", + "time": 62.36, + "x": 118, + "y": 71.43136, + "scale": 0.03584, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-kito-mouth-wide-dialogue-62460", + "time": 62.46, + "x": 118, + "y": 71.43136, + "scale": 0.03584, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-kito-mouth-wide-dialogue-62800", + "time": 62.8, + "x": 118, + "y": 71.43136, + "scale": 0.03584, + "opacity": 0, + "rotation": 0, + "curve": "hold" + } + ] + }, + "relationship": { + "type": "parent", + "targetLayerId": "puppet-kito" + }, + "faceBinding": { + "poseLayerId": "puppet-kito", + "role": "mouth", + "state": "wide" + } + }, + { + "id": "puppet-kito-mouth-round", + "name": "Kito Veleta mouth round", + "type": "image", + "source": "/examples/cut-paper/mouths/paper-round.png", + "visible": true, + "locked": false, + "z": 60, + "transform": { + "x": 118, + "y": 71.43136, + "scale": 0.03584, + "opacity": 0, + "rotation": 0 + }, + "animation": { + "start": { + "x": 118, + "y": 71.43136, + "scale": 0.03584, + "opacity": 0, + "rotation": 0 + }, + "end": { + "x": 118, + "y": 71.43136, + "scale": 0.03584, + "opacity": 0, + "rotation": 0 + }, + "duration": 78, + "curve": "hold", + "keyframes": [ + { + "id": "puppet-kito-mouth-round-dialogue-0", + "time": 0, + "x": 118, + "y": 71.43136, + "scale": 0.03584, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-kito-mouth-round-dialogue-62000", + "time": 62, + "x": 118, + "y": 71.43136, + "scale": 0.03584, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-kito-mouth-round-dialogue-62180", + "time": 62.18, + "x": 118, + "y": 71.43136, + "scale": 0.03584, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-kito-mouth-round-dialogue-62360", + "time": 62.36, + "x": 118, + "y": 71.43136, + "scale": 0.03584, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-kito-mouth-round-dialogue-62460", + "time": 62.46, + "x": 118, + "y": 71.43136, + "scale": 0.03584, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-kito-mouth-round-dialogue-62800", + "time": 62.8, + "x": 118, + "y": 71.43136, + "scale": 0.03584, + "opacity": 0, + "rotation": 0, + "curve": "hold" + } + ] + }, + "relationship": { + "type": "parent", + "targetLayerId": "puppet-kito" + }, + "faceBinding": { + "poseLayerId": "puppet-kito", + "role": "mouth", + "state": "round" + } + }, + { + "id": "sticker-ice", + "name": "Papel cebolla", + "type": "image", + "source": "/examples/cut-paper/props/onion-paper.png", + "visible": true, + "locked": false, + "z": 8, + "transform": { + "x": 50, + "y": 58, + "scale": 0.22, + "opacity": 1, + "rotation": -6 + }, + "animation": { + "start": { + "x": 50, + "y": 58, + "scale": 0.22, + "opacity": 1, + "rotation": -6 + }, + "end": { + "x": 78, + "y": 82, + "scale": 0.18, + "opacity": 0, + "rotation": 18 + }, + "duration": 78, + "curve": "ease", + "keyframes": [ + { + "id": "ice-0", + "time": 0, + "x": 50, + "y": 58, + "scale": 0.22, + "opacity": 1, + "rotation": -6, + "curve": "hold" + }, + { + "id": "ice-1", + "time": 54, + "x": 50, + "y": 58, + "scale": 0.22, + "opacity": 1, + "rotation": -6, + "curve": "ease" + }, + { + "id": "ice-2", + "time": 61, + "x": 78, + "y": 82, + "scale": 0.18, + "opacity": 0, + "rotation": 18, + "curve": "ease" + }, + { + "id": "ice-3", + "time": 78, + "x": 78, + "y": 82, + "scale": 0.18, + "opacity": 0, + "rotation": 18, + "curve": "hold" + } + ] + }, + "parallax": 0.4 + } + ], + "generationPolicy": "provided_only", + "dialogueBeats": [ + { + "id": "nilo-1-0", + "text": "The", + "start": 6, + "end": 6.2, + "mouthLayerIds": [ + "puppet-nilo-mouth-closed", + "puppet-nilo-mouth-small", + "puppet-nilo-mouth-wide", + "puppet-nilo-mouth-round" + ], + "audioTrackId": "vo-nilo-1", + "confidence": "aligned-audio" + }, + { + "id": "nilo-1-1", + "text": "fountain", + "start": 6.2, + "end": 6.6, + "mouthLayerIds": [ + "puppet-nilo-mouth-closed", + "puppet-nilo-mouth-small", + "puppet-nilo-mouth-wide", + "puppet-nilo-mouth-round" + ], + "audioTrackId": "vo-nilo-1", + "confidence": "aligned-audio" + }, + { + "id": "nilo-1-2", + "text": "is", + "start": 6.6, + "end": 7.14, + "mouthLayerIds": [ + "puppet-nilo-mouth-closed", + "puppet-nilo-mouth-small", + "puppet-nilo-mouth-wide", + "puppet-nilo-mouth-round" + ], + "audioTrackId": "vo-nilo-1", + "confidence": "aligned-audio" + }, + { + "id": "nilo-1-3", + "text": "not", + "start": 7.14, + "end": 7.42, + "mouthLayerIds": [ + "puppet-nilo-mouth-closed", + "puppet-nilo-mouth-small", + "puppet-nilo-mouth-wide", + "puppet-nilo-mouth-round" + ], + "audioTrackId": "vo-nilo-1", + "confidence": "aligned-audio" + }, + { + "id": "nilo-1-4", + "text": "frozen", + "start": 7.42, + "end": 8.1, + "mouthLayerIds": [ + "puppet-nilo-mouth-closed", + "puppet-nilo-mouth-small", + "puppet-nilo-mouth-wide", + "puppet-nilo-mouth-round" + ], + "audioTrackId": "vo-nilo-1", + "confidence": "aligned-audio" + }, + { + "id": "nilo-1-5", + "text": "Someone", + "start": 8.98, + "end": 9.58, + "mouthLayerIds": [ + "puppet-nilo-mouth-closed", + "puppet-nilo-mouth-small", + "puppet-nilo-mouth-wide", + "puppet-nilo-mouth-round" + ], + "audioTrackId": "vo-nilo-1", + "confidence": "aligned-audio" + }, + { + "id": "nilo-1-6", + "text": "stuck", + "start": 9.58, + "end": 10.16, + "mouthLayerIds": [ + "puppet-nilo-mouth-closed", + "puppet-nilo-mouth-small", + "puppet-nilo-mouth-wide", + "puppet-nilo-mouth-round" + ], + "audioTrackId": "vo-nilo-1", + "confidence": "aligned-audio" + }, + { + "id": "nilo-1-7", + "text": "a", + "start": 10.16, + "end": 10.379999999999999, + "mouthLayerIds": [ + "puppet-nilo-mouth-closed", + "puppet-nilo-mouth-small", + "puppet-nilo-mouth-wide", + "puppet-nilo-mouth-round" + ], + "audioTrackId": "vo-nilo-1", + "confidence": "aligned-audio" + }, + { + "id": "nilo-1-8", + "text": "square", + "start": 10.379999999999999, + "end": 10.780000000000001, + "mouthLayerIds": [ + "puppet-nilo-mouth-closed", + "puppet-nilo-mouth-small", + "puppet-nilo-mouth-wide", + "puppet-nilo-mouth-round" + ], + "audioTrackId": "vo-nilo-1", + "confidence": "aligned-audio" + }, + { + "id": "nilo-1-9", + "text": "of", + "start": 10.780000000000001, + "end": 11.2, + "mouthLayerIds": [ + "puppet-nilo-mouth-closed", + "puppet-nilo-mouth-small", + "puppet-nilo-mouth-wide", + "puppet-nilo-mouth-round" + ], + "audioTrackId": "vo-nilo-1", + "confidence": "aligned-audio" + }, + { + "id": "nilo-1-10", + "text": "tracing", + "start": 11.2, + "end": 11.719999999999999, + "mouthLayerIds": [ + "puppet-nilo-mouth-closed", + "puppet-nilo-mouth-small", + "puppet-nilo-mouth-wide", + "puppet-nilo-mouth-round" + ], + "audioTrackId": "vo-nilo-1", + "confidence": "aligned-audio" + }, + { + "id": "nilo-1-11", + "text": "paper", + "start": 11.719999999999999, + "end": 12.08, + "mouthLayerIds": [ + "puppet-nilo-mouth-closed", + "puppet-nilo-mouth-small", + "puppet-nilo-mouth-wide", + "puppet-nilo-mouth-round" + ], + "audioTrackId": "vo-nilo-1", + "confidence": "aligned-audio" + }, + { + "id": "nilo-1-12", + "text": "on", + "start": 12.08, + "end": 12.46, + "mouthLayerIds": [ + "puppet-nilo-mouth-closed", + "puppet-nilo-mouth-small", + "puppet-nilo-mouth-wide", + "puppet-nilo-mouth-round" + ], + "audioTrackId": "vo-nilo-1", + "confidence": "aligned-audio" + }, + { + "id": "nilo-1-13", + "text": "it", + "start": 12.46, + "end": 12.58, + "mouthLayerIds": [ + "puppet-nilo-mouth-closed", + "puppet-nilo-mouth-small", + "puppet-nilo-mouth-wide", + "puppet-nilo-mouth-round" + ], + "audioTrackId": "vo-nilo-1", + "confidence": "aligned-audio" + }, + { + "id": "berta-1-0", + "text": "Well", + "start": 17, + "end": 17.28, + "mouthLayerIds": [ + "puppet-berta-mouth-closed", + "puppet-berta-mouth-small", + "puppet-berta-mouth-wide", + "puppet-berta-mouth-round" + ], + "audioTrackId": "vo-berta-1", + "confidence": "aligned-audio" + }, + { + "id": "berta-1-1", + "text": "it", + "start": 17.28, + "end": 17.64, + "mouthLayerIds": [ + "puppet-berta-mouth-closed", + "puppet-berta-mouth-small", + "puppet-berta-mouth-wide", + "puppet-berta-mouth-round" + ], + "audioTrackId": "vo-berta-1", + "confidence": "aligned-audio" + }, + { + "id": "berta-1-2", + "text": "tastes", + "start": 17.64, + "end": 17.98, + "mouthLayerIds": [ + "puppet-berta-mouth-closed", + "puppet-berta-mouth-small", + "puppet-berta-mouth-wide", + "puppet-berta-mouth-round" + ], + "audioTrackId": "vo-berta-1", + "confidence": "aligned-audio" + }, + { + "id": "berta-1-3", + "text": "like", + "start": 17.98, + "end": 18.32, + "mouthLayerIds": [ + "puppet-berta-mouth-closed", + "puppet-berta-mouth-small", + "puppet-berta-mouth-wide", + "puppet-berta-mouth-round" + ], + "audioTrackId": "vo-berta-1", + "confidence": "aligned-audio" + }, + { + "id": "berta-1-4", + "text": "ice", + "start": 18.32, + "end": 18.72, + "mouthLayerIds": [ + "puppet-berta-mouth-closed", + "puppet-berta-mouth-small", + "puppet-berta-mouth-wide", + "puppet-berta-mouth-round" + ], + "audioTrackId": "vo-berta-1", + "confidence": "aligned-audio" + }, + { + "id": "berta-1-5", + "text": "I", + "start": 19.38, + "end": 19.54, + "mouthLayerIds": [ + "puppet-berta-mouth-closed", + "puppet-berta-mouth-small", + "puppet-berta-mouth-wide", + "puppet-berta-mouth-round" + ], + "audioTrackId": "vo-berta-1", + "confidence": "aligned-audio" + }, + { + "id": "berta-1-6", + "text": "tried", + "start": 19.54, + "end": 19.88, + "mouthLayerIds": [ + "puppet-berta-mouth-closed", + "puppet-berta-mouth-small", + "puppet-berta-mouth-wide", + "puppet-berta-mouth-round" + ], + "audioTrackId": "vo-berta-1", + "confidence": "aligned-audio" + }, + { + "id": "berta-1-7", + "text": "it", + "start": 19.88, + "end": 20.08, + "mouthLayerIds": [ + "puppet-berta-mouth-closed", + "puppet-berta-mouth-small", + "puppet-berta-mouth-wide", + "puppet-berta-mouth-round" + ], + "audioTrackId": "vo-berta-1", + "confidence": "aligned-audio" + }, + { + "id": "nilo-2-0", + "text": "Berta", + "start": 25, + "end": 25.9, + "mouthLayerIds": [ + "puppet-nilo-mouth-closed", + "puppet-nilo-mouth-small", + "puppet-nilo-mouth-wide", + "puppet-nilo-mouth-round" + ], + "audioTrackId": "vo-nilo-2", + "confidence": "aligned-audio" + }, + { + "id": "nilo-2-1", + "text": "that's", + "start": 25.9, + "end": 26.14, + "mouthLayerIds": [ + "puppet-nilo-mouth-closed", + "puppet-nilo-mouth-small", + "puppet-nilo-mouth-wide", + "puppet-nilo-mouth-round" + ], + "audioTrackId": "vo-nilo-2", + "confidence": "aligned-audio" + }, + { + "id": "nilo-2-2", + "text": "glue", + "start": 26.28, + "end": 26.48, + "mouthLayerIds": [ + "puppet-nilo-mouth-closed", + "puppet-nilo-mouth-small", + "puppet-nilo-mouth-wide", + "puppet-nilo-mouth-round" + ], + "audioTrackId": "vo-nilo-2", + "confidence": "aligned-audio" + }, + { + "id": "berta-2-0", + "text": "Cold", + "start": 31, + "end": 31.36, + "mouthLayerIds": [ + "puppet-berta-mouth-closed", + "puppet-berta-mouth-small", + "puppet-berta-mouth-wide", + "puppet-berta-mouth-round" + ], + "audioTrackId": "vo-berta-2", + "confidence": "aligned-audio" + }, + { + "id": "berta-2-1", + "text": "glue", + "start": 31.36, + "end": 31.88, + "mouthLayerIds": [ + "puppet-berta-mouth-closed", + "puppet-berta-mouth-small", + "puppet-berta-mouth-wide", + "puppet-berta-mouth-round" + ], + "audioTrackId": "vo-berta-2", + "confidence": "aligned-audio" + }, + { + "id": "berta-2-2", + "text": "Like", + "start": 32.22, + "end": 32.88, + "mouthLayerIds": [ + "puppet-berta-mouth-closed", + "puppet-berta-mouth-small", + "puppet-berta-mouth-wide", + "puppet-berta-mouth-round" + ], + "audioTrackId": "vo-berta-2", + "confidence": "aligned-audio" + }, + { + "id": "berta-2-3", + "text": "ice", + "start": 32.88, + "end": 33.24, + "mouthLayerIds": [ + "puppet-berta-mouth-closed", + "puppet-berta-mouth-small", + "puppet-berta-mouth-wide", + "puppet-berta-mouth-round" + ], + "audioTrackId": "vo-berta-2", + "confidence": "aligned-audio" + }, + { + "id": "kito-1-0", + "text": "It", + "start": 62, + "end": 62.18, + "mouthLayerIds": [ + "puppet-kito-mouth-closed", + "puppet-kito-mouth-small", + "puppet-kito-mouth-wide", + "puppet-kito-mouth-round" + ], + "audioTrackId": "vo-kito-1", + "confidence": "aligned-audio" + }, + { + "id": "kito-1-1", + "text": "was", + "start": 62.18, + "end": 62.36, + "mouthLayerIds": [ + "puppet-kito-mouth-closed", + "puppet-kito-mouth-small", + "puppet-kito-mouth-wide", + "puppet-kito-mouth-round" + ], + "audioTrackId": "vo-kito-1", + "confidence": "aligned-audio" + }, + { + "id": "kito-1-2", + "text": "a", + "start": 62.36, + "end": 62.46, + "mouthLayerIds": [ + "puppet-kito-mouth-closed", + "puppet-kito-mouth-small", + "puppet-kito-mouth-wide", + "puppet-kito-mouth-round" + ], + "audioTrackId": "vo-kito-1", + "confidence": "aligned-audio" + }, + { + "id": "kito-1-3", + "text": "sticker", + "start": 62.46, + "end": 62.8, + "mouthLayerIds": [ + "puppet-kito-mouth-closed", + "puppet-kito-mouth-small", + "puppet-kito-mouth-wide", + "puppet-kito-mouth-round" + ], + "audioTrackId": "vo-kito-1", + "confidence": "aligned-audio" + } + ], + "texts": [ + { + "id": "title", + "text": "Tijeral", + "start": 0.4, + "end": 3.6, + "preset": "rise", + "x": 50, + "y": 12, + "size": 7, + "color": "#1d2b5a", + "rotation": 0 + } + ], + "audioTracks": [ + { + "id": "vo-nilo-1", + "filename": "vo-nilo-nilo-1-en.wav", + "name": "nilo · The fountain is not froz", + "kind": "speech", + "startTime": 6, + "volume": 1 + }, + { + "id": "vo-berta-1", + "filename": "vo-berta-berta-1-en.wav", + "name": "berta · Well it tastes like ice.", + "kind": "speech", + "startTime": 17, + "volume": 1 + }, + { + "id": "vo-nilo-2", + "filename": "vo-nilo-nilo-2-en.wav", + "name": "nilo · Berta, that's glue.", + "kind": "speech", + "startTime": 25, + "volume": 1 + }, + { + "id": "vo-berta-2", + "filename": "vo-berta-berta-2-en.wav", + "name": "berta · Cold glue. Like ice.", + "kind": "speech", + "startTime": 31, + "volume": 1 + }, + { + "id": "vo-kito-1", + "filename": "vo-kito-kito-1-en.wav", + "name": "kito · It was a sticker!", + "kind": "speech", + "startTime": 62, + "volume": 1 + } + ] +} \ No newline at end of file diff --git a/ui/public/examples/cut-paper/tijeral-la-fuente.maestro-scene.json b/ui/public/examples/cut-paper/tijeral-la-fuente.maestro-scene.json new file mode 100644 index 000000000..a71a79d06 --- /dev/null +++ b/ui/public/examples/cut-paper/tijeral-la-fuente.maestro-scene.json @@ -0,0 +1,2923 @@ +{ + "version": 1, + "name": "Tijeral · la fuente", + "width": 1280, + "height": 720, + "fps": 30, + "duration": 78, + "layers": [ + { + "id": "camera", + "name": "Camera", + "type": "camera", + "source": "", + "visible": true, + "locked": false, + "z": 100, + "transform": { + "x": 50, + "y": 50, + "scale": 1, + "opacity": 1, + "rotation": 0 + }, + "animation": { + "start": { + "x": 50, + "y": 50, + "scale": 1, + "opacity": 1, + "rotation": 0 + }, + "end": { + "x": 50, + "y": 50, + "scale": 1.04, + "opacity": 1, + "rotation": 0 + }, + "duration": 78, + "curve": "ease", + "keyframes": [ + { + "id": "camera-0", + "time": 0, + "x": 50, + "y": 50, + "scale": 1, + "opacity": 1, + "rotation": 0, + "curve": "ease" + }, + { + "id": "camera-78000", + "time": 78, + "x": 50, + "y": 50, + "scale": 1.04, + "opacity": 1, + "rotation": 0, + "curve": "ease" + } + ] + } + }, + { + "id": "location-plaza", + "name": "Location plaza", + "type": "image", + "source": "/examples/cut-paper/locations/plaza.png", + "visible": true, + "locked": false, + "z": 0, + "transform": { + "x": 50, + "y": 50, + "scale": 1, + "opacity": 1, + "rotation": 0 + }, + "animation": { + "start": { + "x": 50, + "y": 50, + "scale": 1, + "opacity": 1, + "rotation": 0 + }, + "end": { + "x": 50, + "y": 50, + "scale": 1, + "opacity": 1, + "rotation": 0 + }, + "duration": 78, + "curve": "hold", + "keyframes": [ + { + "id": "location-plaza-0", + "time": 0, + "x": 50, + "y": 50, + "scale": 1, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "location-plaza-78000", + "time": 78, + "x": 50, + "y": 50, + "scale": 1, + "opacity": 1, + "rotation": 0, + "curve": "hold" + } + ] + }, + "fill": true, + "parallax": 0.15 + }, + { + "id": "puppet-nilo", + "name": "Nilo Carda pose", + "type": "image", + "source": "/examples/cut-paper/puppets/nilo-body.png", + "visible": true, + "locked": false, + "z": 32, + "transform": { + "x": 36, + "y": 62, + "scale": 0.42, + "opacity": 1, + "rotation": 0 + }, + "animation": { + "start": { + "x": 36, + "y": 62, + "scale": 0.42, + "opacity": 1, + "rotation": 0 + }, + "end": { + "x": 36, + "y": 62, + "scale": 0.42, + "opacity": 1, + "rotation": 0 + }, + "duration": 78, + "curve": "hold", + "keyframes": [ + { + "id": "puppet-nilo-0", + "time": 0, + "x": 36, + "y": 62, + "scale": 0.42, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-78000", + "time": 78, + "x": 36, + "y": 62, + "scale": 0.42, + "opacity": 1, + "rotation": 0, + "curve": "hold" + } + ] + }, + "parallax": 1, + "effects": { + "shadow": 0.25, + "blendMode": "normal", + "mask": "none", + "maskRadius": 12, + "blur": 0, + "brightness": 1, + "contrast": 1, + "saturation": 1, + "hue": 0, + "glow": 0 + } + }, + { + "id": "puppet-nilo-mouth-closed", + "name": "Nilo Carda mouth closed", + "type": "image", + "source": "/examples/cut-paper/mouths/paper-closed.png", + "visible": true, + "locked": false, + "z": 40, + "transform": { + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0 + }, + "animation": { + "start": { + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0 + }, + "end": { + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0 + }, + "duration": 78, + "curve": "hold", + "keyframes": [ + { + "id": "puppet-nilo-mouth-closed-dialogue-0", + "time": 0, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-closed-dialogue-6000", + "time": 6, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-closed-dialogue-6140", + "time": 6.14, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-closed-dialogue-6560", + "time": 6.5600000000000005, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-closed-dialogue-6820", + "time": 6.82, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-closed-dialogue-7020", + "time": 7.02, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-closed-dialogue-7720", + "time": 7.72, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-closed-dialogue-8280", + "time": 8.28, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-closed-dialogue-8660", + "time": 8.66, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-closed-dialogue-8720", + "time": 8.72, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-closed-dialogue-9020", + "time": 9.02, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-closed-dialogue-9080", + "time": 9.08, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-closed-dialogue-9540", + "time": 9.54, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-closed-dialogue-9660", + "time": 9.66, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-closed-dialogue-9920", + "time": 9.92, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-closed-dialogue-10440", + "time": 10.440000000000001, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-closed-dialogue-25000", + "time": 25, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-closed-dialogue-26737", + "time": 26.737000000000002, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 1, + "rotation": 0, + "curve": "hold" + } + ] + }, + "relationship": { + "type": "parent", + "targetLayerId": "puppet-nilo" + }, + "faceBinding": { + "poseLayerId": "puppet-nilo", + "role": "mouth", + "state": "closed" + } + }, + { + "id": "puppet-nilo-mouth-small", + "name": "Nilo Carda mouth small", + "type": "image", + "source": "/examples/cut-paper/mouths/paper-small.png", + "visible": true, + "locked": false, + "z": 40, + "transform": { + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0 + }, + "animation": { + "start": { + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0 + }, + "end": { + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0 + }, + "duration": 78, + "curve": "hold", + "keyframes": [ + { + "id": "puppet-nilo-mouth-small-dialogue-0", + "time": 0, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-small-dialogue-6000", + "time": 6, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-small-dialogue-6140", + "time": 6.14, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-small-dialogue-6560", + "time": 6.5600000000000005, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-small-dialogue-6820", + "time": 6.82, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-small-dialogue-7020", + "time": 7.02, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-small-dialogue-7720", + "time": 7.72, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-small-dialogue-8280", + "time": 8.28, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-small-dialogue-8660", + "time": 8.66, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-small-dialogue-8720", + "time": 8.72, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-small-dialogue-9020", + "time": 9.02, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-small-dialogue-9080", + "time": 9.08, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-small-dialogue-9540", + "time": 9.54, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-small-dialogue-9660", + "time": 9.66, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-small-dialogue-9920", + "time": 9.92, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-small-dialogue-10440", + "time": 10.440000000000001, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-small-dialogue-25000", + "time": 25, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-small-dialogue-26737", + "time": 26.737000000000002, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + } + ] + }, + "relationship": { + "type": "parent", + "targetLayerId": "puppet-nilo" + }, + "faceBinding": { + "poseLayerId": "puppet-nilo", + "role": "mouth", + "state": "small" + } + }, + { + "id": "puppet-nilo-mouth-wide", + "name": "Nilo Carda mouth wide", + "type": "image", + "source": "/examples/cut-paper/mouths/paper-wide.png", + "visible": true, + "locked": false, + "z": 40, + "transform": { + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0 + }, + "animation": { + "start": { + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0 + }, + "end": { + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0 + }, + "duration": 78, + "curve": "hold", + "keyframes": [ + { + "id": "puppet-nilo-mouth-wide-dialogue-0", + "time": 0, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-wide-dialogue-6000", + "time": 6, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-wide-dialogue-6140", + "time": 6.14, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-wide-dialogue-6560", + "time": 6.5600000000000005, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-wide-dialogue-6820", + "time": 6.82, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-wide-dialogue-7020", + "time": 7.02, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-wide-dialogue-7720", + "time": 7.72, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-wide-dialogue-8280", + "time": 8.28, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-wide-dialogue-8660", + "time": 8.66, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-wide-dialogue-8720", + "time": 8.72, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-wide-dialogue-9020", + "time": 9.02, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-wide-dialogue-9080", + "time": 9.08, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-wide-dialogue-9540", + "time": 9.54, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-wide-dialogue-9660", + "time": 9.66, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-wide-dialogue-9920", + "time": 9.92, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-wide-dialogue-10440", + "time": 10.440000000000001, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-wide-dialogue-25000", + "time": 25, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-wide-dialogue-26737", + "time": 26.737000000000002, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + } + ] + }, + "relationship": { + "type": "parent", + "targetLayerId": "puppet-nilo" + }, + "faceBinding": { + "poseLayerId": "puppet-nilo", + "role": "mouth", + "state": "wide" + } + }, + { + "id": "puppet-nilo-mouth-round", + "name": "Nilo Carda mouth round", + "type": "image", + "source": "/examples/cut-paper/mouths/paper-round.png", + "visible": true, + "locked": false, + "z": 40, + "transform": { + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0 + }, + "animation": { + "start": { + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0 + }, + "end": { + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0 + }, + "duration": 78, + "curve": "hold", + "keyframes": [ + { + "id": "puppet-nilo-mouth-round-dialogue-0", + "time": 0, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-round-dialogue-6000", + "time": 6, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-round-dialogue-6140", + "time": 6.14, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-round-dialogue-6560", + "time": 6.5600000000000005, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-round-dialogue-6820", + "time": 6.82, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-round-dialogue-7020", + "time": 7.02, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-round-dialogue-7720", + "time": 7.72, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-round-dialogue-8280", + "time": 8.28, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-round-dialogue-8660", + "time": 8.66, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-round-dialogue-8720", + "time": 8.72, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-round-dialogue-9020", + "time": 9.02, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-round-dialogue-9080", + "time": 9.08, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-round-dialogue-9540", + "time": 9.54, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-round-dialogue-9660", + "time": 9.66, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-round-dialogue-9920", + "time": 9.92, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-round-dialogue-10440", + "time": 10.440000000000001, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-round-dialogue-25000", + "time": 25, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-nilo-mouth-round-dialogue-26737", + "time": 26.737000000000002, + "x": 36.3612, + "y": 54.629, + "scale": 0.05039999999999999, + "opacity": 0, + "rotation": 0, + "curve": "hold" + } + ] + }, + "relationship": { + "type": "parent", + "targetLayerId": "puppet-nilo" + }, + "faceBinding": { + "poseLayerId": "puppet-nilo", + "role": "mouth", + "state": "round" + } + }, + { + "id": "puppet-berta", + "name": "Berta Miga pose", + "type": "image", + "source": "/examples/cut-paper/puppets/berta-body.png", + "visible": true, + "locked": false, + "z": 42, + "transform": { + "x": 62, + "y": 64, + "scale": 0.38, + "opacity": 1, + "rotation": 0 + }, + "animation": { + "start": { + "x": 62, + "y": 64, + "scale": 0.38, + "opacity": 1, + "rotation": 0 + }, + "end": { + "x": 62, + "y": 64, + "scale": 0.38, + "opacity": 1, + "rotation": 0 + }, + "duration": 78, + "curve": "hold", + "keyframes": [ + { + "id": "puppet-berta-0", + "time": 0, + "x": 62, + "y": 64, + "scale": 0.38, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-78000", + "time": 78, + "x": 62, + "y": 64, + "scale": 0.38, + "opacity": 1, + "rotation": 0, + "curve": "hold" + } + ] + }, + "parallax": 1, + "effects": { + "shadow": 0.25, + "blendMode": "normal", + "mask": "none", + "maskRadius": 12, + "blur": 0, + "brightness": 1, + "contrast": 1, + "saturation": 1, + "hue": 0, + "glow": 0 + } + }, + { + "id": "puppet-berta-mouth-closed", + "name": "Berta Miga mouth closed", + "type": "image", + "source": "/examples/cut-paper/mouths/paper-closed.png", + "visible": true, + "locked": false, + "z": 50, + "transform": { + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0 + }, + "animation": { + "start": { + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0 + }, + "end": { + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0 + }, + "duration": 78, + "curve": "hold", + "keyframes": [ + { + "id": "puppet-berta-mouth-closed-dialogue-0", + "time": 0, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-closed-dialogue-17000", + "time": 17, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-closed-dialogue-17400", + "time": 17.4, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-closed-dialogue-17880", + "time": 17.88, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-closed-dialogue-18060", + "time": 18.06, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-closed-dialogue-18660", + "time": 18.66, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-closed-dialogue-18920", + "time": 18.92, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-closed-dialogue-19180", + "time": 19.18, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-closed-dialogue-19620", + "time": 19.62, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-closed-dialogue-31000", + "time": 31, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-closed-dialogue-31380", + "time": 31.38, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-closed-dialogue-32100", + "time": 32.1, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-closed-dialogue-32300", + "time": 32.3, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-closed-dialogue-32640", + "time": 32.64, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-closed-dialogue-33320", + "time": 33.32, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 1, + "rotation": 0, + "curve": "hold" + } + ] + }, + "relationship": { + "type": "parent", + "targetLayerId": "puppet-berta" + }, + "faceBinding": { + "poseLayerId": "puppet-berta", + "role": "mouth", + "state": "closed" + } + }, + { + "id": "puppet-berta-mouth-small", + "name": "Berta Miga mouth small", + "type": "image", + "source": "/examples/cut-paper/mouths/paper-small.png", + "visible": true, + "locked": false, + "z": 50, + "transform": { + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0 + }, + "animation": { + "start": { + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0 + }, + "end": { + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0 + }, + "duration": 78, + "curve": "hold", + "keyframes": [ + { + "id": "puppet-berta-mouth-small-dialogue-0", + "time": 0, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-small-dialogue-17000", + "time": 17, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-small-dialogue-17400", + "time": 17.4, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-small-dialogue-17880", + "time": 17.88, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-small-dialogue-18060", + "time": 18.06, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-small-dialogue-18660", + "time": 18.66, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-small-dialogue-18920", + "time": 18.92, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-small-dialogue-19180", + "time": 19.18, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-small-dialogue-19620", + "time": 19.62, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-small-dialogue-31000", + "time": 31, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-small-dialogue-31380", + "time": 31.38, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-small-dialogue-32100", + "time": 32.1, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-small-dialogue-32300", + "time": 32.3, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-small-dialogue-32640", + "time": 32.64, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-small-dialogue-33320", + "time": 33.32, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + } + ] + }, + "relationship": { + "type": "parent", + "targetLayerId": "puppet-berta" + }, + "faceBinding": { + "poseLayerId": "puppet-berta", + "role": "mouth", + "state": "small" + } + }, + { + "id": "puppet-berta-mouth-wide", + "name": "Berta Miga mouth wide", + "type": "image", + "source": "/examples/cut-paper/mouths/paper-wide.png", + "visible": true, + "locked": false, + "z": 50, + "transform": { + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0 + }, + "animation": { + "start": { + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0 + }, + "end": { + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0 + }, + "duration": 78, + "curve": "hold", + "keyframes": [ + { + "id": "puppet-berta-mouth-wide-dialogue-0", + "time": 0, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-wide-dialogue-17000", + "time": 17, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-wide-dialogue-17400", + "time": 17.4, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-wide-dialogue-17880", + "time": 17.88, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-wide-dialogue-18060", + "time": 18.06, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-wide-dialogue-18660", + "time": 18.66, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-wide-dialogue-18920", + "time": 18.92, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-wide-dialogue-19180", + "time": 19.18, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-wide-dialogue-19620", + "time": 19.62, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-wide-dialogue-31000", + "time": 31, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-wide-dialogue-31380", + "time": 31.38, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-wide-dialogue-32100", + "time": 32.1, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-wide-dialogue-32300", + "time": 32.3, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-wide-dialogue-32640", + "time": 32.64, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-wide-dialogue-33320", + "time": 33.32, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + } + ] + }, + "relationship": { + "type": "parent", + "targetLayerId": "puppet-berta" + }, + "faceBinding": { + "poseLayerId": "puppet-berta", + "role": "mouth", + "state": "wide" + } + }, + { + "id": "puppet-berta-mouth-round", + "name": "Berta Miga mouth round", + "type": "image", + "source": "/examples/cut-paper/mouths/paper-round.png", + "visible": true, + "locked": false, + "z": 50, + "transform": { + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0 + }, + "animation": { + "start": { + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0 + }, + "end": { + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0 + }, + "duration": 78, + "curve": "hold", + "keyframes": [ + { + "id": "puppet-berta-mouth-round-dialogue-0", + "time": 0, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-round-dialogue-17000", + "time": 17, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-round-dialogue-17400", + "time": 17.4, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-round-dialogue-17880", + "time": 17.88, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-round-dialogue-18060", + "time": 18.06, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-round-dialogue-18660", + "time": 18.66, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-round-dialogue-18920", + "time": 18.92, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-round-dialogue-19180", + "time": 19.18, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-round-dialogue-19620", + "time": 19.62, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-round-dialogue-31000", + "time": 31, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-round-dialogue-31380", + "time": 31.38, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-round-dialogue-32100", + "time": 32.1, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-round-dialogue-32300", + "time": 32.3, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-round-dialogue-32640", + "time": 32.64, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-berta-mouth-round-dialogue-33320", + "time": 33.32, + "x": 62.0646, + "y": 57.711, + "scale": 0.041800000000000004, + "opacity": 0, + "rotation": 0, + "curve": "hold" + } + ] + }, + "relationship": { + "type": "parent", + "targetLayerId": "puppet-berta" + }, + "faceBinding": { + "poseLayerId": "puppet-berta", + "role": "mouth", + "state": "round" + } + }, + { + "id": "puppet-kito", + "name": "Kito Veleta pose", + "type": "image", + "source": "/examples/cut-paper/puppets/kito-body.png", + "visible": true, + "locked": false, + "z": 52, + "transform": { + "x": 118, + "y": 70, + "scale": 0.22399999999999998, + "opacity": 1, + "rotation": 0 + }, + "animation": { + "start": { + "x": 118, + "y": 70, + "scale": 0.22399999999999998, + "opacity": 1 + }, + "end": { + "x": 52, + "y": 70, + "scale": 0.22399999999999998, + "opacity": 1 + }, + "duration": 78, + "curve": "ease", + "keyframes": [ + { + "id": "puppet-kito-0", + "time": 0, + "x": 118, + "y": 70, + "scale": 0.22399999999999998, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-kito-54000", + "time": 54, + "x": 118, + "y": 70, + "scale": 0.22399999999999998, + "opacity": 1, + "rotation": 0, + "curve": "ease" + }, + { + "id": "puppet-kito-61000", + "time": 61, + "x": 52, + "y": 70, + "scale": 0.22399999999999998, + "opacity": 1, + "rotation": 0, + "curve": "ease" + }, + { + "id": "puppet-kito-78000", + "time": 78, + "x": 52, + "y": 70, + "scale": 0.22399999999999998, + "opacity": 1, + "rotation": 0, + "curve": "hold" + } + ] + }, + "parallax": 1, + "effects": { + "shadow": 0.25, + "blendMode": "normal", + "mask": "none", + "maskRadius": 12, + "blur": 0, + "brightness": 1, + "contrast": 1, + "saturation": 1, + "hue": 0, + "glow": 0 + } + }, + { + "id": "puppet-kito-mouth-closed", + "name": "Kito Veleta mouth closed", + "type": "image", + "source": "/examples/cut-paper/mouths/paper-closed.png", + "visible": true, + "locked": false, + "z": 60, + "transform": { + "x": 118, + "y": 71.43136, + "scale": 0.03584, + "opacity": 0, + "rotation": 0 + }, + "animation": { + "start": { + "x": 118, + "y": 71.43136, + "scale": 0.03584, + "opacity": 0, + "rotation": 0 + }, + "end": { + "x": 118, + "y": 71.43136, + "scale": 0.03584, + "opacity": 0, + "rotation": 0 + }, + "duration": 78, + "curve": "hold", + "keyframes": [ + { + "id": "puppet-kito-mouth-closed-dialogue-0", + "time": 0, + "x": 118, + "y": 71.43136, + "scale": 0.03584, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-kito-mouth-closed-dialogue-62000", + "time": 62, + "x": 118, + "y": 71.43136, + "scale": 0.03584, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-kito-mouth-closed-dialogue-62300", + "time": 62.3, + "x": 118, + "y": 71.43136, + "scale": 0.03584, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-kito-mouth-closed-dialogue-62520", + "time": 62.52, + "x": 118, + "y": 71.43136, + "scale": 0.03584, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-kito-mouth-closed-dialogue-62960", + "time": 62.96, + "x": 118, + "y": 71.43136, + "scale": 0.03584, + "opacity": 1, + "rotation": 0, + "curve": "hold" + } + ] + }, + "relationship": { + "type": "parent", + "targetLayerId": "puppet-kito" + }, + "faceBinding": { + "poseLayerId": "puppet-kito", + "role": "mouth", + "state": "closed" + } + }, + { + "id": "puppet-kito-mouth-small", + "name": "Kito Veleta mouth small", + "type": "image", + "source": "/examples/cut-paper/mouths/paper-small.png", + "visible": true, + "locked": false, + "z": 60, + "transform": { + "x": 118, + "y": 71.43136, + "scale": 0.03584, + "opacity": 0, + "rotation": 0 + }, + "animation": { + "start": { + "x": 118, + "y": 71.43136, + "scale": 0.03584, + "opacity": 0, + "rotation": 0 + }, + "end": { + "x": 118, + "y": 71.43136, + "scale": 0.03584, + "opacity": 0, + "rotation": 0 + }, + "duration": 78, + "curve": "hold", + "keyframes": [ + { + "id": "puppet-kito-mouth-small-dialogue-0", + "time": 0, + "x": 118, + "y": 71.43136, + "scale": 0.03584, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-kito-mouth-small-dialogue-62000", + "time": 62, + "x": 118, + "y": 71.43136, + "scale": 0.03584, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-kito-mouth-small-dialogue-62300", + "time": 62.3, + "x": 118, + "y": 71.43136, + "scale": 0.03584, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-kito-mouth-small-dialogue-62520", + "time": 62.52, + "x": 118, + "y": 71.43136, + "scale": 0.03584, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-kito-mouth-small-dialogue-62960", + "time": 62.96, + "x": 118, + "y": 71.43136, + "scale": 0.03584, + "opacity": 0, + "rotation": 0, + "curve": "hold" + } + ] + }, + "relationship": { + "type": "parent", + "targetLayerId": "puppet-kito" + }, + "faceBinding": { + "poseLayerId": "puppet-kito", + "role": "mouth", + "state": "small" + } + }, + { + "id": "puppet-kito-mouth-wide", + "name": "Kito Veleta mouth wide", + "type": "image", + "source": "/examples/cut-paper/mouths/paper-wide.png", + "visible": true, + "locked": false, + "z": 60, + "transform": { + "x": 118, + "y": 71.43136, + "scale": 0.03584, + "opacity": 0, + "rotation": 0 + }, + "animation": { + "start": { + "x": 118, + "y": 71.43136, + "scale": 0.03584, + "opacity": 0, + "rotation": 0 + }, + "end": { + "x": 118, + "y": 71.43136, + "scale": 0.03584, + "opacity": 0, + "rotation": 0 + }, + "duration": 78, + "curve": "hold", + "keyframes": [ + { + "id": "puppet-kito-mouth-wide-dialogue-0", + "time": 0, + "x": 118, + "y": 71.43136, + "scale": 0.03584, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-kito-mouth-wide-dialogue-62000", + "time": 62, + "x": 118, + "y": 71.43136, + "scale": 0.03584, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-kito-mouth-wide-dialogue-62300", + "time": 62.3, + "x": 118, + "y": 71.43136, + "scale": 0.03584, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-kito-mouth-wide-dialogue-62520", + "time": 62.52, + "x": 118, + "y": 71.43136, + "scale": 0.03584, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-kito-mouth-wide-dialogue-62960", + "time": 62.96, + "x": 118, + "y": 71.43136, + "scale": 0.03584, + "opacity": 0, + "rotation": 0, + "curve": "hold" + } + ] + }, + "relationship": { + "type": "parent", + "targetLayerId": "puppet-kito" + }, + "faceBinding": { + "poseLayerId": "puppet-kito", + "role": "mouth", + "state": "wide" + } + }, + { + "id": "puppet-kito-mouth-round", + "name": "Kito Veleta mouth round", + "type": "image", + "source": "/examples/cut-paper/mouths/paper-round.png", + "visible": true, + "locked": false, + "z": 60, + "transform": { + "x": 118, + "y": 71.43136, + "scale": 0.03584, + "opacity": 0, + "rotation": 0 + }, + "animation": { + "start": { + "x": 118, + "y": 71.43136, + "scale": 0.03584, + "opacity": 0, + "rotation": 0 + }, + "end": { + "x": 118, + "y": 71.43136, + "scale": 0.03584, + "opacity": 0, + "rotation": 0 + }, + "duration": 78, + "curve": "hold", + "keyframes": [ + { + "id": "puppet-kito-mouth-round-dialogue-0", + "time": 0, + "x": 118, + "y": 71.43136, + "scale": 0.03584, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-kito-mouth-round-dialogue-62000", + "time": 62, + "x": 118, + "y": 71.43136, + "scale": 0.03584, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-kito-mouth-round-dialogue-62300", + "time": 62.3, + "x": 118, + "y": 71.43136, + "scale": 0.03584, + "opacity": 1, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-kito-mouth-round-dialogue-62520", + "time": 62.52, + "x": 118, + "y": 71.43136, + "scale": 0.03584, + "opacity": 0, + "rotation": 0, + "curve": "hold" + }, + { + "id": "puppet-kito-mouth-round-dialogue-62960", + "time": 62.96, + "x": 118, + "y": 71.43136, + "scale": 0.03584, + "opacity": 0, + "rotation": 0, + "curve": "hold" + } + ] + }, + "relationship": { + "type": "parent", + "targetLayerId": "puppet-kito" + }, + "faceBinding": { + "poseLayerId": "puppet-kito", + "role": "mouth", + "state": "round" + } + }, + { + "id": "sticker-ice", + "name": "Papel cebolla", + "type": "image", + "source": "/examples/cut-paper/props/onion-paper.png", + "visible": true, + "locked": false, + "z": 8, + "transform": { + "x": 50, + "y": 58, + "scale": 0.22, + "opacity": 1, + "rotation": -6 + }, + "animation": { + "start": { + "x": 50, + "y": 58, + "scale": 0.22, + "opacity": 1, + "rotation": -6 + }, + "end": { + "x": 78, + "y": 82, + "scale": 0.18, + "opacity": 0, + "rotation": 18 + }, + "duration": 78, + "curve": "ease", + "keyframes": [ + { + "id": "ice-0", + "time": 0, + "x": 50, + "y": 58, + "scale": 0.22, + "opacity": 1, + "rotation": -6, + "curve": "hold" + }, + { + "id": "ice-1", + "time": 54, + "x": 50, + "y": 58, + "scale": 0.22, + "opacity": 1, + "rotation": -6, + "curve": "ease" + }, + { + "id": "ice-2", + "time": 61, + "x": 78, + "y": 82, + "scale": 0.18, + "opacity": 0, + "rotation": 18, + "curve": "ease" + }, + { + "id": "ice-3", + "time": 78, + "x": 78, + "y": 82, + "scale": 0.18, + "opacity": 0, + "rotation": 18, + "curve": "hold" + } + ] + }, + "parallax": 0.4 + } + ], + "generationPolicy": "provided_only", + "dialogueBeats": [ + { + "id": "nilo-1-0", + "text": "La", + "start": 6, + "end": 6.14, + "mouthLayerIds": [ + "puppet-nilo-mouth-closed", + "puppet-nilo-mouth-small", + "puppet-nilo-mouth-wide", + "puppet-nilo-mouth-round" + ], + "audioTrackId": "vo-nilo-1", + "confidence": "aligned-audio" + }, + { + "id": "nilo-1-1", + "text": "fuente", + "start": 6.14, + "end": 6.5600000000000005, + "mouthLayerIds": [ + "puppet-nilo-mouth-closed", + "puppet-nilo-mouth-small", + "puppet-nilo-mouth-wide", + "puppet-nilo-mouth-round" + ], + "audioTrackId": "vo-nilo-1", + "confidence": "aligned-audio" + }, + { + "id": "nilo-1-2", + "text": "no", + "start": 6.5600000000000005, + "end": 6.82, + "mouthLayerIds": [ + "puppet-nilo-mouth-closed", + "puppet-nilo-mouth-small", + "puppet-nilo-mouth-wide", + "puppet-nilo-mouth-round" + ], + "audioTrackId": "vo-nilo-1", + "confidence": "aligned-audio" + }, + { + "id": "nilo-1-3", + "text": "está", + "start": 6.82, + "end": 7.02, + "mouthLayerIds": [ + "puppet-nilo-mouth-closed", + "puppet-nilo-mouth-small", + "puppet-nilo-mouth-wide", + "puppet-nilo-mouth-round" + ], + "audioTrackId": "vo-nilo-1", + "confidence": "aligned-audio" + }, + { + "id": "nilo-1-4", + "text": "congelada", + "start": 7.02, + "end": 7.72, + "mouthLayerIds": [ + "puppet-nilo-mouth-closed", + "puppet-nilo-mouth-small", + "puppet-nilo-mouth-wide", + "puppet-nilo-mouth-round" + ], + "audioTrackId": "vo-nilo-1", + "confidence": "aligned-audio" + }, + { + "id": "nilo-1-5", + "text": "Alguien", + "start": 8.28, + "end": 8.66, + "mouthLayerIds": [ + "puppet-nilo-mouth-closed", + "puppet-nilo-mouth-small", + "puppet-nilo-mouth-wide", + "puppet-nilo-mouth-round" + ], + "audioTrackId": "vo-nilo-1", + "confidence": "aligned-audio" + }, + { + "id": "nilo-1-6", + "text": "le", + "start": 8.66, + "end": 8.72, + "mouthLayerIds": [ + "puppet-nilo-mouth-closed", + "puppet-nilo-mouth-small", + "puppet-nilo-mouth-wide", + "puppet-nilo-mouth-round" + ], + "audioTrackId": "vo-nilo-1", + "confidence": "aligned-audio" + }, + { + "id": "nilo-1-7", + "text": "pegó", + "start": 8.72, + "end": 9.02, + "mouthLayerIds": [ + "puppet-nilo-mouth-closed", + "puppet-nilo-mouth-small", + "puppet-nilo-mouth-wide", + "puppet-nilo-mouth-round" + ], + "audioTrackId": "vo-nilo-1", + "confidence": "aligned-audio" + }, + { + "id": "nilo-1-8", + "text": "un", + "start": 9.02, + "end": 9.08, + "mouthLayerIds": [ + "puppet-nilo-mouth-closed", + "puppet-nilo-mouth-small", + "puppet-nilo-mouth-wide", + "puppet-nilo-mouth-round" + ], + "audioTrackId": "vo-nilo-1", + "confidence": "aligned-audio" + }, + { + "id": "nilo-1-9", + "text": "cuadrado", + "start": 9.08, + "end": 9.54, + "mouthLayerIds": [ + "puppet-nilo-mouth-closed", + "puppet-nilo-mouth-small", + "puppet-nilo-mouth-wide", + "puppet-nilo-mouth-round" + ], + "audioTrackId": "vo-nilo-1", + "confidence": "aligned-audio" + }, + { + "id": "nilo-1-10", + "text": "de", + "start": 9.54, + "end": 9.66, + "mouthLayerIds": [ + "puppet-nilo-mouth-closed", + "puppet-nilo-mouth-small", + "puppet-nilo-mouth-wide", + "puppet-nilo-mouth-round" + ], + "audioTrackId": "vo-nilo-1", + "confidence": "aligned-audio" + }, + { + "id": "nilo-1-11", + "text": "papel", + "start": 9.66, + "end": 9.92, + "mouthLayerIds": [ + "puppet-nilo-mouth-closed", + "puppet-nilo-mouth-small", + "puppet-nilo-mouth-wide", + "puppet-nilo-mouth-round" + ], + "audioTrackId": "vo-nilo-1", + "confidence": "aligned-audio" + }, + { + "id": "nilo-1-12", + "text": "cebolla", + "start": 9.92, + "end": 10.440000000000001, + "mouthLayerIds": [ + "puppet-nilo-mouth-closed", + "puppet-nilo-mouth-small", + "puppet-nilo-mouth-wide", + "puppet-nilo-mouth-round" + ], + "audioTrackId": "vo-nilo-1", + "confidence": "aligned-audio" + }, + { + "id": "berta-1-0", + "text": "Pues", + "start": 17, + "end": 17.4, + "mouthLayerIds": [ + "puppet-berta-mouth-closed", + "puppet-berta-mouth-small", + "puppet-berta-mouth-wide", + "puppet-berta-mouth-round" + ], + "audioTrackId": "vo-berta-1", + "confidence": "aligned-audio" + }, + { + "id": "berta-1-1", + "text": "sabe", + "start": 17.4, + "end": 17.88, + "mouthLayerIds": [ + "puppet-berta-mouth-closed", + "puppet-berta-mouth-small", + "puppet-berta-mouth-wide", + "puppet-berta-mouth-round" + ], + "audioTrackId": "vo-berta-1", + "confidence": "aligned-audio" + }, + { + "id": "berta-1-2", + "text": "a", + "start": 17.88, + "end": 18.06, + "mouthLayerIds": [ + "puppet-berta-mouth-closed", + "puppet-berta-mouth-small", + "puppet-berta-mouth-wide", + "puppet-berta-mouth-round" + ], + "audioTrackId": "vo-berta-1", + "confidence": "aligned-audio" + }, + { + "id": "berta-1-3", + "text": "hielo", + "start": 18.06, + "end": 18.66, + "mouthLayerIds": [ + "puppet-berta-mouth-closed", + "puppet-berta-mouth-small", + "puppet-berta-mouth-wide", + "puppet-berta-mouth-round" + ], + "audioTrackId": "vo-berta-1", + "confidence": "aligned-audio" + }, + { + "id": "berta-1-4", + "text": "Lo", + "start": 18.92, + "end": 19.18, + "mouthLayerIds": [ + "puppet-berta-mouth-closed", + "puppet-berta-mouth-small", + "puppet-berta-mouth-wide", + "puppet-berta-mouth-round" + ], + "audioTrackId": "vo-berta-1", + "confidence": "aligned-audio" + }, + { + "id": "berta-1-5", + "text": "probé", + "start": 19.18, + "end": 19.62, + "mouthLayerIds": [ + "puppet-berta-mouth-closed", + "puppet-berta-mouth-small", + "puppet-berta-mouth-wide", + "puppet-berta-mouth-round" + ], + "audioTrackId": "vo-berta-1", + "confidence": "aligned-audio" + }, + { + "id": "nilo-2-0", + "text": "Berta, eso es cola.", + "start": 25, + "end": 26.737000000000002, + "mouthLayerIds": [ + "puppet-nilo-mouth-closed", + "puppet-nilo-mouth-small", + "puppet-nilo-mouth-wide", + "puppet-nilo-mouth-round" + ], + "audioTrackId": "vo-nilo-2", + "confidence": "aligned-audio" + }, + { + "id": "berta-2-0", + "text": "Cola", + "start": 31, + "end": 31.38, + "mouthLayerIds": [ + "puppet-berta-mouth-closed", + "puppet-berta-mouth-small", + "puppet-berta-mouth-wide", + "puppet-berta-mouth-round" + ], + "audioTrackId": "vo-berta-2", + "confidence": "aligned-audio" + }, + { + "id": "berta-2-1", + "text": "fría", + "start": 31.38, + "end": 32.1, + "mouthLayerIds": [ + "puppet-berta-mouth-closed", + "puppet-berta-mouth-small", + "puppet-berta-mouth-wide", + "puppet-berta-mouth-round" + ], + "audioTrackId": "vo-berta-2", + "confidence": "aligned-audio" + }, + { + "id": "berta-2-2", + "text": "Como", + "start": 32.3, + "end": 32.64, + "mouthLayerIds": [ + "puppet-berta-mouth-closed", + "puppet-berta-mouth-small", + "puppet-berta-mouth-wide", + "puppet-berta-mouth-round" + ], + "audioTrackId": "vo-berta-2", + "confidence": "aligned-audio" + }, + { + "id": "berta-2-3", + "text": "hielo", + "start": 32.64, + "end": 33.32, + "mouthLayerIds": [ + "puppet-berta-mouth-closed", + "puppet-berta-mouth-small", + "puppet-berta-mouth-wide", + "puppet-berta-mouth-round" + ], + "audioTrackId": "vo-berta-2", + "confidence": "aligned-audio" + }, + { + "id": "kito-1-0", + "text": "Era", + "start": 62, + "end": 62.3, + "mouthLayerIds": [ + "puppet-kito-mouth-closed", + "puppet-kito-mouth-small", + "puppet-kito-mouth-wide", + "puppet-kito-mouth-round" + ], + "audioTrackId": "vo-kito-1", + "confidence": "aligned-audio" + }, + { + "id": "kito-1-1", + "text": "un", + "start": 62.3, + "end": 62.52, + "mouthLayerIds": [ + "puppet-kito-mouth-closed", + "puppet-kito-mouth-small", + "puppet-kito-mouth-wide", + "puppet-kito-mouth-round" + ], + "audioTrackId": "vo-kito-1", + "confidence": "aligned-audio" + }, + { + "id": "kito-1-2", + "text": "sticker", + "start": 62.52, + "end": 62.96, + "mouthLayerIds": [ + "puppet-kito-mouth-closed", + "puppet-kito-mouth-small", + "puppet-kito-mouth-wide", + "puppet-kito-mouth-round" + ], + "audioTrackId": "vo-kito-1", + "confidence": "aligned-audio" + } + ], + "texts": [ + { + "id": "title", + "text": "Tijeral", + "start": 0.4, + "end": 3.6, + "preset": "rise", + "x": 50, + "y": 12, + "size": 7, + "color": "#1d2b5a", + "rotation": 0 + } + ], + "audioTracks": [ + { + "id": "vo-nilo-1", + "filename": "vo-nilo-nilo-1.wav", + "name": "nilo · La fuente no está congel", + "kind": "speech", + "startTime": 6, + "volume": 1 + }, + { + "id": "vo-berta-1", + "filename": "vo-berta-berta-1.wav", + "name": "berta · Pues sabe a hielo. Lo pr", + "kind": "speech", + "startTime": 17, + "volume": 1 + }, + { + "id": "vo-nilo-2", + "filename": "vo-nilo-nilo-2.wav", + "name": "nilo · Berta, eso es cola.", + "kind": "speech", + "startTime": 25, + "volume": 1 + }, + { + "id": "vo-berta-2", + "filename": "vo-berta-berta-2.wav", + "name": "berta · Cola fría. Como hielo.", + "kind": "speech", + "startTime": 31, + "volume": 1 + }, + { + "id": "vo-kito-1", + "filename": "vo-kito-kito-1.wav", + "name": "kito · ¡Era un sticker!", + "kind": "speech", + "startTime": 62, + "volume": 1 + } + ] +} \ No newline at end of file diff --git a/ui/public/examples/cut-paper/voices/vo-berta-berta-1-en.wav b/ui/public/examples/cut-paper/voices/vo-berta-berta-1-en.wav new file mode 100644 index 000000000..d2a0236aa Binary files /dev/null and b/ui/public/examples/cut-paper/voices/vo-berta-berta-1-en.wav differ diff --git a/ui/public/examples/cut-paper/voices/vo-berta-berta-1.wav b/ui/public/examples/cut-paper/voices/vo-berta-berta-1.wav new file mode 100644 index 000000000..bce504a84 Binary files /dev/null and b/ui/public/examples/cut-paper/voices/vo-berta-berta-1.wav differ diff --git a/ui/public/examples/cut-paper/voices/vo-berta-berta-2-en.wav b/ui/public/examples/cut-paper/voices/vo-berta-berta-2-en.wav new file mode 100644 index 000000000..8c176aa45 Binary files /dev/null and b/ui/public/examples/cut-paper/voices/vo-berta-berta-2-en.wav differ diff --git a/ui/public/examples/cut-paper/voices/vo-berta-berta-2.wav b/ui/public/examples/cut-paper/voices/vo-berta-berta-2.wav new file mode 100644 index 000000000..9e72b636c Binary files /dev/null and b/ui/public/examples/cut-paper/voices/vo-berta-berta-2.wav differ diff --git a/ui/public/examples/cut-paper/voices/vo-kito-kito-1-en.wav b/ui/public/examples/cut-paper/voices/vo-kito-kito-1-en.wav new file mode 100644 index 000000000..a4b7f6c91 Binary files /dev/null and b/ui/public/examples/cut-paper/voices/vo-kito-kito-1-en.wav differ diff --git a/ui/public/examples/cut-paper/voices/vo-kito-kito-1.wav b/ui/public/examples/cut-paper/voices/vo-kito-kito-1.wav new file mode 100644 index 000000000..7a18da2cd Binary files /dev/null and b/ui/public/examples/cut-paper/voices/vo-kito-kito-1.wav differ diff --git a/ui/public/examples/cut-paper/voices/vo-nilo-nilo-1-en.wav b/ui/public/examples/cut-paper/voices/vo-nilo-nilo-1-en.wav new file mode 100644 index 000000000..9f8700561 Binary files /dev/null and b/ui/public/examples/cut-paper/voices/vo-nilo-nilo-1-en.wav differ diff --git a/ui/public/examples/cut-paper/voices/vo-nilo-nilo-1.wav b/ui/public/examples/cut-paper/voices/vo-nilo-nilo-1.wav new file mode 100644 index 000000000..3d15167ae Binary files /dev/null and b/ui/public/examples/cut-paper/voices/vo-nilo-nilo-1.wav differ diff --git a/ui/public/examples/cut-paper/voices/vo-nilo-nilo-2-en.wav b/ui/public/examples/cut-paper/voices/vo-nilo-nilo-2-en.wav new file mode 100644 index 000000000..5abad2a4d Binary files /dev/null and b/ui/public/examples/cut-paper/voices/vo-nilo-nilo-2-en.wav differ diff --git a/ui/public/examples/cut-paper/voices/vo-nilo-nilo-2.wav b/ui/public/examples/cut-paper/voices/vo-nilo-nilo-2.wav new file mode 100644 index 000000000..f9150db40 Binary files /dev/null and b/ui/public/examples/cut-paper/voices/vo-nilo-nilo-2.wav differ diff --git a/ui/public/examples/face-pack/HOWTO.md b/ui/public/examples/face-pack/HOWTO.md new file mode 100644 index 000000000..aa48b0caa --- /dev/null +++ b/ui/public/examples/face-pack/HOWTO.md @@ -0,0 +1,63 @@ +# How to make a mascot face pack + +Bundled examples in this folder (`*-pack.png`, `*-visemes.png`, `*-talk.mp4`, +`neutral-vowels.wav`) are **CC0** — free for anyone. See `LICENSE`. + +A face pack is a PNG the TV-head screen samples while someone talks. +**Mouth (viseme) and expression are independent.** Happy + A is still happy; +only the mouth changes. + +## Layout (required) + +One PNG, no divider lines, no text, no extra panels. + +| | 9 columns, left → right | +|---|---| +| **Visemes** | `rest` `M` `A` `E` `I` `O` `U` `F` `L` | +| **Rows** (top → bottom) | `neutral` `happy` `angry` `worried` `surprised` `sleepy` | + +- Square tiles. Same face position, scale and background in every cell. +- Tile size 64–256 px. A 128 px tile makes a 1152×768 sheet. +- Width must be `9 × tile`. Height must be `6 × tile`. +- Face only (no body, no scene). Flat single-color background. +- `rest` is a closed or almost-closed mouth. Do not bake a smile into `rest` + unless that is the character at rest. + +The engine looks up `column = viseme`, `row = expression`. Talking never +moves the row by itself. + +## Cube-front plane (required look) + +The tile is the **front face of a cube**, not a round portrait. Skin fills the +square edge to edge; only eyes, nose and mouth. Prompts: +Character Creator → **Lipsync face (cube plane)**. Prompts live in +`ui/src/features/scene3d/speech/facePackPrompts.ts`. + +CLI: `python3 ui/scripts/assemble_face_pack_from_dir.py stills/ -o pack.png` +with files named `rest.png`, `A.png`, `happy.png`, … + +## Two ways to author + +### A. Draw the full 9×6 sheet (54 cells) + +Best result. For each expression, draw all nine mouths with that same +expression (eyes/brows frozen, mouth only changes). + +### B. Draw 9 visemes + 6 expressions (15 stills) + +1. Nine visemes on the **neutral** face (mouth only). +2. Six expressions with the **rest** mouth (eyes/brows only). +3. Composite: copy the viseme mouth onto each expression, same crop. + `ui/scripts/assemble_face_packs.py` does this for the bundled packs. + +## Load it in Video 3D + +Put the PNG on a TV-head (`headfront` plane) as `speech.facePack`. +Bundled examples live next to this file. Voice and lip-sync → **Mascot face** +picks a bundled pack; **Expression while talking** holds the row while +vowels walk the columns. + +## Check + +`validFacePackSize(width, height)` rejects sheets that are not 9×6 or whose +tiles exceed 256 px. diff --git a/ui/public/examples/face-pack/LICENSE b/ui/public/examples/face-pack/LICENSE new file mode 100644 index 000000000..081da70dc --- /dev/null +++ b/ui/public/examples/face-pack/LICENSE @@ -0,0 +1,15 @@ +CC0 1.0 Universal + +The bundled mascot face examples in this folder are dedicated to the public +domain under CC0 1.0. You may copy, modify, redistribute and use them, +including commercially, without asking permission and without attribution. + +Covered files: +- `*-pack.png` and `*-visemes.png` (talking-face atlases) +- `*-talk.mp4` (example lipsync clips) +- `neutral-vowels.wav` (synthetic vowels) + +Not covered: +- `/examples/tv-head-humanoid.glb` (separate bundled rig) + +https://creativecommons.org/publicdomain/zero/1.0/ diff --git a/ui/public/examples/face-pack/PROVENANCE.md b/ui/public/examples/face-pack/PROVENANCE.md new file mode 100644 index 000000000..e5341af7b --- /dev/null +++ b/ui/public/examples/face-pack/PROVENANCE.md @@ -0,0 +1,35 @@ +# Example mascot face packs + +Procedural talking faces. Not a recording or clone of a person. +**License:** `LICENSE` in this folder (CC0 1.0). Free to use for anyone. + +## Packs + +9 visemes `rest M A E I O U F L` × 6 expressions +`neutral happy angry worried surprised sleepy`. + +Classic: `tv`, `skull`, `voxel`, `anime`, `cubeskull`. +Cube-front (skin fills the square): `felt`, `clay`, `pixel`, `porcelain`, +`cat`, `oni`, `stencil`, `alien`, `pumpkin`, `ice`, `mushroom`, `vector`, +`halftone`, `steampunk`, `gummy`. + +Images: Grok Imagine, 2026-09-11. Canonical stills, then mouth/expression +edits. Sheets assembled in `ui/scripts/assemble_face_packs.py` (128 px tiles). + +## Audio and example clips + +- `neutral-vowels.wav`, 8 s mono 22.05 kHz PCM. Synthetic formant vowels + (A E I O U). SHA-256 + `66e633eefa7f4d34dd96139e5b298a47392562a493d5e833ecba4f759a51951e`. +- `hangar-talk.mp4`, `sea-talk.mp4`, `voxel-talk.mp4` — CRT/skull and voxel + two-shots. +- `felt-talk.mp4`, `pumpkin-talk.mp4`, `cat-talk.mp4` — cube-front two-shots. + +Soundtrack is the WAV; character speech is silent so vowels are not doubled. + +## Product + +- Video 3D → Voice and lip-sync: pick a bundled pack. +- Character Creator → Lipsync face (cube plane): prompts + stills → 9×6. +- `HOWTO.md` for the grid. Expression stays on a row; vowels walk columns. +- Rig for the walker shots: `/examples/tv-head-humanoid.glb` (not CC0). diff --git a/ui/public/examples/face-pack/alien-pack.png b/ui/public/examples/face-pack/alien-pack.png new file mode 100644 index 000000000..74d419380 Binary files /dev/null and b/ui/public/examples/face-pack/alien-pack.png differ diff --git a/ui/public/examples/face-pack/alien-visemes.png b/ui/public/examples/face-pack/alien-visemes.png new file mode 100644 index 000000000..3be056af7 Binary files /dev/null and b/ui/public/examples/face-pack/alien-visemes.png differ diff --git a/ui/public/examples/face-pack/anime-pack.png b/ui/public/examples/face-pack/anime-pack.png new file mode 100644 index 000000000..5f42b9149 Binary files /dev/null and b/ui/public/examples/face-pack/anime-pack.png differ diff --git a/ui/public/examples/face-pack/anime-visemes.png b/ui/public/examples/face-pack/anime-visemes.png new file mode 100644 index 000000000..b2a88dc0b Binary files /dev/null and b/ui/public/examples/face-pack/anime-visemes.png differ diff --git a/ui/public/examples/face-pack/cat-pack.png b/ui/public/examples/face-pack/cat-pack.png new file mode 100644 index 000000000..b0110e608 Binary files /dev/null and b/ui/public/examples/face-pack/cat-pack.png differ diff --git a/ui/public/examples/face-pack/cat-talk.mp4 b/ui/public/examples/face-pack/cat-talk.mp4 new file mode 100644 index 000000000..9bf7c0104 Binary files /dev/null and b/ui/public/examples/face-pack/cat-talk.mp4 differ diff --git a/ui/public/examples/face-pack/cat-visemes.png b/ui/public/examples/face-pack/cat-visemes.png new file mode 100644 index 000000000..f9ad48ab8 Binary files /dev/null and b/ui/public/examples/face-pack/cat-visemes.png differ diff --git a/ui/public/examples/face-pack/clay-pack.png b/ui/public/examples/face-pack/clay-pack.png new file mode 100644 index 000000000..f58819157 Binary files /dev/null and b/ui/public/examples/face-pack/clay-pack.png differ diff --git a/ui/public/examples/face-pack/clay-visemes.png b/ui/public/examples/face-pack/clay-visemes.png new file mode 100644 index 000000000..6dd3d4dac Binary files /dev/null and b/ui/public/examples/face-pack/clay-visemes.png differ diff --git a/ui/public/examples/face-pack/cubeskull-pack.png b/ui/public/examples/face-pack/cubeskull-pack.png new file mode 100644 index 000000000..5ec1c53a2 Binary files /dev/null and b/ui/public/examples/face-pack/cubeskull-pack.png differ diff --git a/ui/public/examples/face-pack/cubeskull-visemes.png b/ui/public/examples/face-pack/cubeskull-visemes.png new file mode 100644 index 000000000..fab7ef502 Binary files /dev/null and b/ui/public/examples/face-pack/cubeskull-visemes.png differ diff --git a/ui/public/examples/face-pack/felt-pack.png b/ui/public/examples/face-pack/felt-pack.png new file mode 100644 index 000000000..aab6030c7 Binary files /dev/null and b/ui/public/examples/face-pack/felt-pack.png differ diff --git a/ui/public/examples/face-pack/felt-talk.mp4 b/ui/public/examples/face-pack/felt-talk.mp4 new file mode 100644 index 000000000..ae35265d1 Binary files /dev/null and b/ui/public/examples/face-pack/felt-talk.mp4 differ diff --git a/ui/public/examples/face-pack/felt-visemes.png b/ui/public/examples/face-pack/felt-visemes.png new file mode 100644 index 000000000..4cbe80883 Binary files /dev/null and b/ui/public/examples/face-pack/felt-visemes.png differ diff --git a/ui/public/examples/face-pack/gummy-pack.png b/ui/public/examples/face-pack/gummy-pack.png new file mode 100644 index 000000000..099c915a7 Binary files /dev/null and b/ui/public/examples/face-pack/gummy-pack.png differ diff --git a/ui/public/examples/face-pack/gummy-visemes.png b/ui/public/examples/face-pack/gummy-visemes.png new file mode 100644 index 000000000..fdf5f0f36 Binary files /dev/null and b/ui/public/examples/face-pack/gummy-visemes.png differ diff --git a/ui/public/examples/face-pack/halftone-pack.png b/ui/public/examples/face-pack/halftone-pack.png new file mode 100644 index 000000000..eb523801a Binary files /dev/null and b/ui/public/examples/face-pack/halftone-pack.png differ diff --git a/ui/public/examples/face-pack/halftone-visemes.png b/ui/public/examples/face-pack/halftone-visemes.png new file mode 100644 index 000000000..4063ae8c3 Binary files /dev/null and b/ui/public/examples/face-pack/halftone-visemes.png differ diff --git a/ui/public/examples/face-pack/hangar-talk.mp4 b/ui/public/examples/face-pack/hangar-talk.mp4 new file mode 100644 index 000000000..5e8873909 Binary files /dev/null and b/ui/public/examples/face-pack/hangar-talk.mp4 differ diff --git a/ui/public/examples/face-pack/ice-pack.png b/ui/public/examples/face-pack/ice-pack.png new file mode 100644 index 000000000..1dc582e7c Binary files /dev/null and b/ui/public/examples/face-pack/ice-pack.png differ diff --git a/ui/public/examples/face-pack/ice-visemes.png b/ui/public/examples/face-pack/ice-visemes.png new file mode 100644 index 000000000..df5ceb4b3 Binary files /dev/null and b/ui/public/examples/face-pack/ice-visemes.png differ diff --git a/ui/public/examples/face-pack/mushroom-pack.png b/ui/public/examples/face-pack/mushroom-pack.png new file mode 100644 index 000000000..0f9e8b49c Binary files /dev/null and b/ui/public/examples/face-pack/mushroom-pack.png differ diff --git a/ui/public/examples/face-pack/mushroom-visemes.png b/ui/public/examples/face-pack/mushroom-visemes.png new file mode 100644 index 000000000..7bf1866ed Binary files /dev/null and b/ui/public/examples/face-pack/mushroom-visemes.png differ diff --git a/ui/public/examples/face-pack/neutral-vowels.wav b/ui/public/examples/face-pack/neutral-vowels.wav new file mode 100644 index 000000000..0c476848c Binary files /dev/null and b/ui/public/examples/face-pack/neutral-vowels.wav differ diff --git a/ui/public/examples/face-pack/oni-pack.png b/ui/public/examples/face-pack/oni-pack.png new file mode 100644 index 000000000..3f4cb6d18 Binary files /dev/null and b/ui/public/examples/face-pack/oni-pack.png differ diff --git a/ui/public/examples/face-pack/oni-visemes.png b/ui/public/examples/face-pack/oni-visemes.png new file mode 100644 index 000000000..e9783c3c4 Binary files /dev/null and b/ui/public/examples/face-pack/oni-visemes.png differ diff --git a/ui/public/examples/face-pack/pixel-pack.png b/ui/public/examples/face-pack/pixel-pack.png new file mode 100644 index 000000000..415021e02 Binary files /dev/null and b/ui/public/examples/face-pack/pixel-pack.png differ diff --git a/ui/public/examples/face-pack/pixel-visemes.png b/ui/public/examples/face-pack/pixel-visemes.png new file mode 100644 index 000000000..ee305fe90 Binary files /dev/null and b/ui/public/examples/face-pack/pixel-visemes.png differ diff --git a/ui/public/examples/face-pack/porcelain-pack.png b/ui/public/examples/face-pack/porcelain-pack.png new file mode 100644 index 000000000..b22a7072f Binary files /dev/null and b/ui/public/examples/face-pack/porcelain-pack.png differ diff --git a/ui/public/examples/face-pack/porcelain-visemes.png b/ui/public/examples/face-pack/porcelain-visemes.png new file mode 100644 index 000000000..2d058fe0b Binary files /dev/null and b/ui/public/examples/face-pack/porcelain-visemes.png differ diff --git a/ui/public/examples/face-pack/pumpkin-pack.png b/ui/public/examples/face-pack/pumpkin-pack.png new file mode 100644 index 000000000..251fccc44 Binary files /dev/null and b/ui/public/examples/face-pack/pumpkin-pack.png differ diff --git a/ui/public/examples/face-pack/pumpkin-talk.mp4 b/ui/public/examples/face-pack/pumpkin-talk.mp4 new file mode 100644 index 000000000..69acb286a Binary files /dev/null and b/ui/public/examples/face-pack/pumpkin-talk.mp4 differ diff --git a/ui/public/examples/face-pack/pumpkin-visemes.png b/ui/public/examples/face-pack/pumpkin-visemes.png new file mode 100644 index 000000000..8e146dd89 Binary files /dev/null and b/ui/public/examples/face-pack/pumpkin-visemes.png differ diff --git a/ui/public/examples/face-pack/sea-talk.mp4 b/ui/public/examples/face-pack/sea-talk.mp4 new file mode 100644 index 000000000..8e473397e Binary files /dev/null and b/ui/public/examples/face-pack/sea-talk.mp4 differ diff --git a/ui/public/examples/face-pack/skull-pack.png b/ui/public/examples/face-pack/skull-pack.png new file mode 100644 index 000000000..fcaca7c53 Binary files /dev/null and b/ui/public/examples/face-pack/skull-pack.png differ diff --git a/ui/public/examples/face-pack/skull-visemes.png b/ui/public/examples/face-pack/skull-visemes.png new file mode 100644 index 000000000..dcf767016 Binary files /dev/null and b/ui/public/examples/face-pack/skull-visemes.png differ diff --git a/ui/public/examples/face-pack/steampunk-pack.png b/ui/public/examples/face-pack/steampunk-pack.png new file mode 100644 index 000000000..5344a68ec Binary files /dev/null and b/ui/public/examples/face-pack/steampunk-pack.png differ diff --git a/ui/public/examples/face-pack/steampunk-visemes.png b/ui/public/examples/face-pack/steampunk-visemes.png new file mode 100644 index 000000000..73b66fcf2 Binary files /dev/null and b/ui/public/examples/face-pack/steampunk-visemes.png differ diff --git a/ui/public/examples/face-pack/stencil-pack.png b/ui/public/examples/face-pack/stencil-pack.png new file mode 100644 index 000000000..e794c5606 Binary files /dev/null and b/ui/public/examples/face-pack/stencil-pack.png differ diff --git a/ui/public/examples/face-pack/stencil-visemes.png b/ui/public/examples/face-pack/stencil-visemes.png new file mode 100644 index 000000000..9c1a8bdcf Binary files /dev/null and b/ui/public/examples/face-pack/stencil-visemes.png differ diff --git a/ui/public/examples/face-pack/tv-pack.png b/ui/public/examples/face-pack/tv-pack.png new file mode 100644 index 000000000..203ed5278 Binary files /dev/null and b/ui/public/examples/face-pack/tv-pack.png differ diff --git a/ui/public/examples/face-pack/tv-visemes.png b/ui/public/examples/face-pack/tv-visemes.png new file mode 100644 index 000000000..356c7fe31 Binary files /dev/null and b/ui/public/examples/face-pack/tv-visemes.png differ diff --git a/ui/public/examples/face-pack/vector-pack.png b/ui/public/examples/face-pack/vector-pack.png new file mode 100644 index 000000000..97de977e4 Binary files /dev/null and b/ui/public/examples/face-pack/vector-pack.png differ diff --git a/ui/public/examples/face-pack/vector-visemes.png b/ui/public/examples/face-pack/vector-visemes.png new file mode 100644 index 000000000..7165f85ae Binary files /dev/null and b/ui/public/examples/face-pack/vector-visemes.png differ diff --git a/ui/public/examples/face-pack/voxel-pack.png b/ui/public/examples/face-pack/voxel-pack.png new file mode 100644 index 000000000..0d545f1a2 Binary files /dev/null and b/ui/public/examples/face-pack/voxel-pack.png differ diff --git a/ui/public/examples/face-pack/voxel-talk.mp4 b/ui/public/examples/face-pack/voxel-talk.mp4 new file mode 100644 index 000000000..6247e939d Binary files /dev/null and b/ui/public/examples/face-pack/voxel-talk.mp4 differ diff --git a/ui/public/examples/face-pack/voxel-visemes.png b/ui/public/examples/face-pack/voxel-visemes.png new file mode 100644 index 000000000..f7762ffb3 Binary files /dev/null and b/ui/public/examples/face-pack/voxel-visemes.png differ diff --git a/ui/public/help/activity.jpg b/ui/public/help/activity.jpg new file mode 100644 index 000000000..11d2f5880 Binary files /dev/null and b/ui/public/help/activity.jpg differ diff --git a/ui/public/help/character-creator.jpg b/ui/public/help/character-creator.jpg new file mode 100644 index 000000000..d3c4a83ce Binary files /dev/null and b/ui/public/help/character-creator.jpg differ diff --git a/ui/public/help/direct-image.jpg b/ui/public/help/direct-image.jpg new file mode 100644 index 000000000..4ce2726e2 Binary files /dev/null and b/ui/public/help/direct-image.jpg differ diff --git a/ui/public/help/direct-music.jpg b/ui/public/help/direct-music.jpg new file mode 100644 index 000000000..9f85f7e57 Binary files /dev/null and b/ui/public/help/direct-music.jpg differ diff --git a/ui/public/help/direct-video.jpg b/ui/public/help/direct-video.jpg new file mode 100644 index 000000000..9d46f0334 Binary files /dev/null and b/ui/public/help/direct-video.jpg differ diff --git a/ui/public/help/director.jpg b/ui/public/help/director.jpg new file mode 100644 index 000000000..2380e0ea8 Binary files /dev/null and b/ui/public/help/director.jpg differ diff --git a/ui/public/help/example-image.jpg b/ui/public/help/example-image.jpg new file mode 100644 index 000000000..ed37d8018 Binary files /dev/null and b/ui/public/help/example-image.jpg differ diff --git a/ui/public/help/example-video.jpg b/ui/public/help/example-video.jpg new file mode 100644 index 000000000..bd0f31cc1 Binary files /dev/null and b/ui/public/help/example-video.jpg differ diff --git a/ui/public/help/media.jpg b/ui/public/help/media.jpg new file mode 100644 index 000000000..6b56bf2a3 Binary files /dev/null and b/ui/public/help/media.jpg differ diff --git a/ui/public/help/mobile.jpg b/ui/public/help/mobile.jpg new file mode 100644 index 000000000..5135b9573 Binary files /dev/null and b/ui/public/help/mobile.jpg differ diff --git a/ui/public/help/settings.jpg b/ui/public/help/settings.jpg new file mode 100644 index 000000000..6c0adaa99 Binary files /dev/null and b/ui/public/help/settings.jpg differ diff --git a/ui/public/help/story-lab.jpg b/ui/public/help/story-lab.jpg new file mode 100644 index 000000000..bc7ccfff8 Binary files /dev/null and b/ui/public/help/story-lab.jpg differ diff --git a/ui/public/help/video-3d.jpg b/ui/public/help/video-3d.jpg new file mode 100644 index 000000000..d46ad343f Binary files /dev/null and b/ui/public/help/video-3d.jpg differ diff --git a/ui/public/help/wizard.jpg b/ui/public/help/wizard.jpg new file mode 100644 index 000000000..dfc1a5ea1 Binary files /dev/null and b/ui/public/help/wizard.jpg differ diff --git a/ui/scripts/assemble_face_pack_from_dir.py b/ui/scripts/assemble_face_pack_from_dir.py new file mode 100644 index 000000000..6a332c6a2 --- /dev/null +++ b/ui/scripts/assemble_face_pack_from_dir.py @@ -0,0 +1,98 @@ +#!/usr/bin/env python3 +"""Build a 9×6 face pack from stills named rest/A/happy/… (cube-front planes).""" +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from assemble_face_packs import EXPRESSIONS, TILE, VISEMES, match_skin, paste_mouth, write_png # noqa: E402 + +ALIASES = {'I': 'E', 'U': 'O', 'F': 'M', 'L': 'A'} +STEMS = {name.lower(): name for name in (*VISEMES, *EXPRESSIONS, 'rest', 'neutral', 'plane', 'canonical')} + + +def index_dir(folder: Path) -> dict[str, Path]: + found: dict[str, Path] = {} + for path in folder.iterdir(): + if not path.is_file() or path.suffix.lower() not in {'.png', '.jpg', '.jpeg', '.webp'}: + continue + stem = path.stem.lower() + for prefix in ('viseme-', 'viseme_', 'mouth-', 'mouth_', 'expr-', 'expr_', 'expression-', 'expression_'): + if stem.startswith(prefix): + stem = stem[len(prefix):] + break + key = STEMS.get(stem) + if key == 'neutral' or key == 'plane' or key == 'canonical': + key = 'rest' + if key: + found[key] = path + return found + + +def load_named(path: Path) -> bytes: + # Reuse ffmpeg decode via a fake numeric loader: decode here + import subprocess + proc = subprocess.run( + ['ffmpeg', '-v', 'error', '-i', str(path), '-vf', f'scale={TILE}:{TILE}', '-f', 'rawvideo', '-pix_fmt', 'rgb24', 'pipe:1'], + check=True, stdout=subprocess.PIPE, + ) + if len(proc.stdout) != TILE * TILE * 3: + raise RuntimeError(f'{path} decoded to {len(proc.stdout)} bytes') + return proc.stdout + + +def resolve(found: dict[str, Path]) -> tuple[dict[str, bytes], dict[str, bytes]]: + if 'rest' not in found: + raise SystemExit('need rest.png (the cube-front plane)') + rest = load_named(found['rest']) + visemes: dict[str, bytes] = {'rest': rest} + for viseme in VISEMES: + if viseme == 'rest': + continue + src = found.get(viseme) or found.get(ALIASES.get(viseme, viseme)) + visemes[viseme] = load_named(src) if src else rest + expressions: dict[str, bytes] = {'neutral': rest} + for expression in EXPRESSIONS: + if expression == 'neutral': + continue + src = found.get(expression) + expressions[expression] = load_named(src) if src else rest + return visemes, expressions + + +def write_pack(visemes: dict[str, bytes], expressions: dict[str, bytes], dest: Path, mouth=(64.0, 92.0, 30.0, 18.0)) -> None: + rest = visemes['rest'] + visemes = {key: match_skin(tile, rest) for key, tile in visemes.items()} + expressions = {key: match_skin(tile, rest) for key, tile in expressions.items()} + width, height = TILE * 9, TILE * 6 + canvas = bytearray(width * height * 3) + cx, cy, rx, ry = mouth + for row, expression in enumerate(EXPRESSIONS): + base = expressions[expression] + for col, viseme in enumerate(VISEMES): + tile = base if viseme == 'rest' else paste_mouth(base, visemes[viseme], cx, cy, rx, ry) + for y in range(TILE): + dst = ((row * TILE + y) * width + col * TILE) * 3 + src = y * TILE * 3 + canvas[dst:dst + TILE * 3] = tile[src:src + TILE * 3] + dest.parent.mkdir(parents=True, exist_ok=True) + write_png(dest, width, height, bytes(canvas)) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('folder', type=Path) + parser.add_argument('-o', '--out', type=Path, help='PNG path (default: folder/pack.png)') + args = parser.parse_args() + found = index_dir(args.folder) + visemes, expressions = resolve(found) + dest = args.out or (args.folder / 'pack.png') + write_pack(visemes, expressions, dest) + print(dest, dest.stat().st_size) + return 0 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/ui/scripts/assemble_face_packs.py b/ui/scripts/assemble_face_packs.py new file mode 100644 index 000000000..8a08d50cc --- /dev/null +++ b/ui/scripts/assemble_face_packs.py @@ -0,0 +1,221 @@ +#!/usr/bin/env python3 +"""Assemble 9×6 viseme/expression face packs and a synthetic vowel WAV.""" +from __future__ import annotations + +import math +import struct +import subprocess +import sys +import wave +import zlib +from pathlib import Path + +TILE = 128 +COLS, ROWS = 9, 6 +VISEMES = ('rest', 'M', 'A', 'E', 'I', 'O', 'U', 'F', 'L') +EXPRESSIONS = ('neutral', 'happy', 'angry', 'worried', 'surprised', 'sleepy') +SRC = Path.home() / '.grok/sessions/%2Fhome%2Fina%2Fpinokio%2Fapi%2FMaestro-next.git/01a08b0b-218e-70c3-8a34-9524f3f316f3/images' +OUT = Path(__file__).resolve().parents[1] / 'public/examples/face-pack' +FFMPEG = 'ffmpeg' + +TV_VISEMES = {'rest': 1, 'M': 11, 'A': 7, 'E': 6, 'I': 13, 'O': 9, 'U': 16, 'F': 15, 'L': 19} +TV_EXPR = {'neutral': 1, 'happy': 18, 'angry': 23, 'worried': 22, 'surprised': 24, 'sleepy': 25} +SK_VISEMES = {'rest': 2, 'M': 3, 'A': 5, 'E': 8, 'I': 12, 'O': 10, 'U': 17, 'F': 14, 'L': 21} +SK_EXPR = {'neutral': 2, 'happy': 28, 'angry': 26, 'worried': 29, 'surprised': 27, 'sleepy': 30} +VOXEL_VISEMES = {'rest': 32, 'M': 32, 'A': 39, 'E': 43, 'I': 43, 'O': 41, 'U': 41, 'F': 32, 'L': 39} +VOXEL_EXPR = {'neutral': 32, 'happy': 49, 'angry': 48, 'worried': 58, 'surprised': 53, 'sleepy': 55} +ANIME_VISEMES = {'rest': 33, 'M': 35, 'A': 36, 'E': 44, 'I': 44, 'O': 45, 'U': 45, 'F': 35, 'L': 36} +ANIME_EXPR = {'neutral': 33, 'happy': 51, 'angry': 50, 'worried': 59, 'surprised': 56, 'sleepy': 52} +CUBE_VISEMES = {'rest': 31, 'M': 37, 'A': 38, 'E': 40, 'I': 40, 'O': 42, 'U': 42, 'F': 37, 'L': 38} +CUBE_EXPR = {'neutral': 31, 'happy': 46, 'angry': 47, 'worried': 60, 'surprised': 57, 'sleepy': 54} +FELT_VISEMES = {'rest': 111, 'M': 134, 'A': 133, 'E': 132, 'I': 132, 'O': 131, 'U': 137, 'F': 134, 'L': 133} +FELT_EXPR = {'neutral': 111, 'happy': 135, 'angry': 139, 'worried': 136, 'surprised': 138, 'sleepy': 140} +PLANE_KITS = { + 'clay': ({'rest': 115, 'M': 115, 'A': 142, 'E': 142, 'I': 142, 'O': 152, 'U': 152, 'F': 115, 'L': 142}, {'neutral': 115, 'happy': 167, 'angry': 185, 'worried': 115, 'surprised': 200, 'sleepy': 115}), + 'pixel': ({'rest': 116, 'M': 116, 'A': 143, 'E': 143, 'I': 143, 'O': 170, 'U': 170, 'F': 116, 'L': 143}, {'neutral': 116, 'happy': 209, 'angry': 197, 'worried': 116, 'surprised': 183, 'sleepy': 116}), + 'porcelain': ({'rest': 118, 'M': 118, 'A': 144, 'E': 144, 'I': 144, 'O': 172, 'U': 172, 'F': 118, 'L': 144}, {'neutral': 118, 'happy': 181, 'angry': 196, 'worried': 118, 'surprised': 157, 'sleepy': 118}), + 'cat': ({'rest': 119, 'M': 119, 'A': 141, 'E': 141, 'I': 141, 'O': 160, 'U': 160, 'F': 119, 'L': 141}, {'neutral': 119, 'happy': 171, 'angry': 188, 'worried': 119, 'surprised': 119, 'sleepy': 119}), + 'oni': ({'rest': 120, 'M': 120, 'A': 147, 'E': 147, 'I': 147, 'O': 201, 'U': 201, 'F': 120, 'L': 147}, {'neutral': 120, 'happy': 182, 'angry': 186, 'worried': 120, 'surprised': 174, 'sleepy': 120}), + 'stencil': ({'rest': 121, 'M': 121, 'A': 150, 'E': 150, 'I': 150, 'O': 175, 'U': 175, 'F': 121, 'L': 150}, {'neutral': 121, 'happy': 189, 'angry': 161, 'worried': 121, 'surprised': 203, 'sleepy': 121}), + 'alien': ({'rest': 122, 'M': 122, 'A': 146, 'E': 146, 'I': 146, 'O': 163, 'U': 163, 'F': 122, 'L': 146}, {'neutral': 122, 'happy': 177, 'angry': 202, 'worried': 122, 'surprised': 187, 'sleepy': 122}), + 'pumpkin': ({'rest': 123, 'M': 123, 'A': 199, 'E': 199, 'I': 199, 'O': 159, 'U': 159, 'F': 123, 'L': 199}, {'neutral': 123, 'happy': 173, 'angry': 190, 'worried': 123, 'surprised': 145, 'sleepy': 123}), + 'ice': ({'rest': 124, 'M': 124, 'A': 148, 'E': 148, 'I': 148, 'O': 205, 'U': 205, 'F': 124, 'L': 148}, {'neutral': 124, 'happy': 178, 'angry': 193, 'worried': 124, 'surprised': 162, 'sleepy': 124}), + 'mushroom': ({'rest': 126, 'M': 126, 'A': 149, 'E': 149, 'I': 149, 'O': 192, 'U': 192, 'F': 126, 'L': 149}, {'neutral': 126, 'happy': 176, 'angry': 165, 'worried': 126, 'surprised': 126, 'sleepy': 126}), + 'vector': ({'rest': 127, 'M': 127, 'A': 151, 'E': 151, 'I': 151, 'O': 164, 'U': 164, 'F': 127, 'L': 151}, {'neutral': 127, 'happy': 191, 'angry': 179, 'worried': 127, 'surprised': 127, 'sleepy': 127}), + 'halftone': ({'rest': 128, 'M': 128, 'A': 153, 'E': 153, 'I': 153, 'O': 180, 'U': 180, 'F': 128, 'L': 153}, {'neutral': 128, 'happy': 204, 'angry': 210, 'worried': 128, 'surprised': 194, 'sleepy': 128}), + 'steampunk': ({'rest': 129, 'M': 129, 'A': 154, 'E': 154, 'I': 154, 'O': 169, 'U': 169, 'F': 129, 'L': 154}, {'neutral': 129, 'happy': 184, 'angry': 206, 'worried': 129, 'surprised': 195, 'sleepy': 129}), + 'gummy': ({'rest': 130, 'M': 130, 'A': 207, 'E': 207, 'I': 207, 'O': 198, 'U': 198, 'F': 130, 'L': 207}, {'neutral': 130, 'happy': 168, 'angry': 155, 'worried': 130, 'surprised': 130, 'sleepy': 130}), +} + + +def run(cmd: list[str]) -> None: + subprocess.run(cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + + +def load_tile(index: int, crop: str) -> bytes: + src = SRC / f'{index}.jpg' + vf = f'{crop},scale=128:128' if crop else 'scale=128:128' + proc = subprocess.run( + [FFMPEG, '-v', 'error', '-i', str(src), '-vf', vf, '-f', 'rawvideo', '-pix_fmt', 'rgb24', 'pipe:1'], + check=True, stdout=subprocess.PIPE, + ) + if len(proc.stdout) != TILE * TILE * 3: + raise RuntimeError(f'{src} decoded to {len(proc.stdout)} bytes') + return proc.stdout + + +def _luma(r: int, g: int, b: int) -> float: + return 0.2126 * r + 0.7152 * g + 0.0722 * b + + +def _border_mean(rgb: bytes) -> tuple[float, float, float]: + sr = sg = sb = n = 0 + for y in range(TILE): + for x in range(TILE): + if 10 <= x < TILE - 10 and 10 <= y < TILE - 10: + continue + i = (y * TILE + x) * 3 + r, g, b = rgb[i], rgb[i + 1], rgb[i + 2] + if _luma(r, g, b) < 28: + continue + sr += r + sg += g + sb += b + n += 1 + if n < 16: + return (1.0, 1.0, 1.0) + return (sr / n, sg / n, sb / n) + + +def match_skin(tile: bytes, ref: bytes) -> bytes: + tr, tg, tb = _border_mean(tile) + rr, rg, rb = _border_mean(ref) + if tr < 1 or tg < 1 or tb < 1: + return tile + kr, kg, kb = rr / tr, rg / tg, rb / tb + out = bytearray(tile) + for i in range(0, len(out), 3): + r, g, b = out[i], out[i + 1], out[i + 2] + if _luma(r, g, b) < 28: + continue + out[i] = max(0, min(255, int(r * kr))) + out[i + 1] = max(0, min(255, int(g * kg))) + out[i + 2] = max(0, min(255, int(b * kb))) + return bytes(out) + + +def paste_mouth(base: bytes, viseme: bytes, cx: float, cy: float, rx: float, ry: float) -> bytes: + out = bytearray(base) + for y in range(TILE): + ny = (y + 0.5 - cy) / ry + for x in range(TILE): + nx = (x + 0.5 - cx) / rx + d = nx * nx + ny * ny + if d > 1.2: + continue + a = 1.0 if d <= 0.92 else max(0.0, 1.0 - (d - 0.92) / 0.28) + i = (y * TILE + x) * 3 + for c in range(3): + out[i + c] = int(out[i + c] * (1 - a) + viseme[i + c] * a) + return bytes(out) + + +def write_png(path: Path, width: int, height: int, rgb: bytes) -> None: + def chunk(tag: bytes, data: bytes) -> bytes: + return struct.pack('>I', len(data)) + tag + data + struct.pack('>I', zlib.crc32(tag + data) & 0xFFFFFFFF) + + raw = b''.join(b'\x00' + rgb[y * width * 3:(y + 1) * width * 3] for y in range(height)) + path.write_bytes( + b'\x89PNG\r\n\x1a\n' + + chunk(b'IHDR', struct.pack('>IIBBBBB', width, height, 8, 2, 0, 0, 0)) + + chunk(b'IDAT', zlib.compress(raw, 9)) + + chunk(b'IEND', b'') + ) + + +def assemble(name: str, visemes: dict[str, int], expressions: dict[str, int], crop: str, mouth: tuple[float, float, float, float]) -> None: + vis_tiles = {key: load_tile(index, crop) for key, index in visemes.items()} + expr_tiles = {key: load_tile(index, crop) for key, index in expressions.items()} + rest = vis_tiles['rest'] + vis_tiles = {key: match_skin(tile, rest) for key, tile in vis_tiles.items()} + expr_tiles = {key: match_skin(tile, rest) for key, tile in expr_tiles.items()} + width, height = TILE * COLS, TILE * ROWS + canvas = bytearray(width * height * 3) + cx, cy, rx, ry = mouth + for row, expression in enumerate(EXPRESSIONS): + base = expr_tiles[expression] + for col, viseme in enumerate(VISEMES): + tile = base if viseme == 'rest' else paste_mouth(base, vis_tiles[viseme], cx, cy, rx, ry) + for y in range(TILE): + dst = ((row * TILE + y) * width + col * TILE) * 3 + src = y * TILE * 3 + canvas[dst:dst + TILE * 3] = tile[src:src + TILE * 3] + write_png(OUT / f'{name}-pack.png', width, height, bytes(canvas)) + rest_row = bytearray(TILE * COLS * TILE * 3) + for col, viseme in enumerate(VISEMES): + tile = rest if viseme == 'rest' else paste_mouth(rest, vis_tiles[viseme], cx, cy, rx, ry) + for y in range(TILE): + dst = (y * TILE * COLS + col * TILE) * 3 + src = y * TILE * 3 + rest_row[dst:dst + TILE * 3] = tile[src:src + TILE * 3] + write_png(OUT / f'{name}-visemes.png', TILE * COLS, TILE, bytes(rest_row)) + + +def synth_vowels(path: Path) -> None: + sr = 22050 + samples: list[float] = [0.0] * int(sr * 8) + + def osc(freq: float, i: int) -> float: + return math.sin(2 * math.pi * freq * i / sr) + + def put(start: float, dur: float, f1: float, f2: float, amp: float = 0.2) -> None: + n0 = int(start * sr) + n = int(dur * sr) + for i in range(n): + t = i / sr + env = min(1.0, t * 40) * min(1.0, (dur - t) * 18) + s = 0.55 * osc(f1, i) + 0.32 * osc(f2, i) + 0.13 * osc(f1 * 2, i) + samples[n0 + i] += amp * env * s + + # Two identical vowel passes: CRT 0–4 s, skull 4–8 s. + for base in (0.0, 4.0): + put(base + 0.35, 0.5, 700, 1200, 0.22) # A + put(base + 1.05, 0.5, 530, 1840, 0.2) # E + put(base + 1.75, 0.5, 270, 2290, 0.18) # I + put(base + 2.45, 0.5, 570, 840, 0.22) # O + put(base + 3.15, 0.5, 300, 870, 0.2) # U + + with wave.open(str(path), 'w') as wav: + wav.setnchannels(1) + wav.setsampwidth(2) + wav.setframerate(sr) + frames = b''.join(struct.pack(' int: + if not (SRC / '1.jpg').is_file(): + print('missing Imagine sources', file=sys.stderr) + return 1 + OUT.mkdir(parents=True, exist_ok=True) + assemble('tv', TV_VISEMES, TV_EXPR, crop='crop=520:600:252:210', mouth=(64, 84, 36, 22)) + assemble('skull', SK_VISEMES, SK_EXPR, crop='crop=iw*0.72:ih*0.72:(iw-iw*0.72)/2:(ih-ih*0.72)/2', mouth=(64, 90, 44, 32)) + assemble('voxel', VOXEL_VISEMES, VOXEL_EXPR, crop='crop=iw*0.78:ih*0.78:(iw-iw*0.78)/2:(ih-ih*0.78)/2', mouth=(72, 88, 34, 22)) + assemble('anime', ANIME_VISEMES, ANIME_EXPR, crop='crop=iw*0.86:ih*0.86:(iw-iw*0.86)/2:(ih-ih*0.86)/2', mouth=(64, 92, 34, 18)) + assemble('cubeskull', CUBE_VISEMES, CUBE_EXPR, crop='crop=iw*0.72:ih*0.72:(iw-iw*0.72)/2:(ih-ih*0.72)/2', mouth=(64, 92, 42, 30)) + assemble('felt', FELT_VISEMES, FELT_EXPR, crop='', mouth=(64, 92, 30, 18)) + if not (OUT / 'neutral-vowels.wav').is_file(): + synth_vowels(OUT / 'neutral-vowels.wav') + for name in ( + 'tv-pack.png', 'skull-pack.png', 'voxel-pack.png', 'anime-pack.png', 'cubeskull-pack.png', + 'tv-visemes.png', 'skull-visemes.png', 'voxel-visemes.png', 'anime-visemes.png', 'cubeskull-visemes.png', + 'neutral-vowels.wav', + ): + path = OUT / name + if path.is_file(): + print(name, path.stat().st_size) + return 0 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/ui/scripts/check-i18n-catalogs.mjs b/ui/scripts/check-i18n-catalogs.mjs index 3f49efc8d..1cd809a4e 100644 --- a/ui/scripts/check-i18n-catalogs.mjs +++ b/ui/scripts/check-i18n-catalogs.mjs @@ -4,7 +4,7 @@ import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..') -const NAMESPACES = ['common', 'navigation', 'settings', 'wizard', 'activity', 'extraInfo', 'storyLab', 'director', 'seriesLab', 'videoEditor', 'workspaces', 'styleSheet', 'projects', 'auditDev', 'scene3d', 'scene3dEditor', 'shell', 'characters', 'comics', 'studio'] +const NAMESPACES = ['common', 'navigation', 'settings', 'wizard', 'activity', 'extraInfo', 'storyLab', 'director', 'seriesLab', 'videoEditor', 'workspaces', 'styleSheet', 'projects', 'auditDev', 'scene3d', 'scene3dEditor', 'shell', 'characters', 'comics', 'studio', 'generationInspector', 'help'] const LANGUAGES = ['en', 'es'] function load(language, namespace) { @@ -33,6 +33,7 @@ export function catalogReport() { const PILOT_FILES = [ 'src/components/MainContent/TabFilter.tsx', + 'src/components/Help/HelpOverlay.tsx', 'src/components/MainContent/MainContent.tsx', 'src/components/MainContent/MediaFeedItem.tsx', 'src/components/MainContent/VideoExtraInfoDialog.tsx', diff --git a/ui/scripts/check-wizard-intent-live.ts b/ui/scripts/check-wizard-intent-live.ts new file mode 100644 index 000000000..1b7be863f --- /dev/null +++ b/ui/scripts/check-wizard-intent-live.ts @@ -0,0 +1,62 @@ +import assert from 'node:assert/strict' +import { buildAgentTurnPrompt, HOCUSPOCUS_AGENT_SYSTEM_PROMPT, type AgentConversationEntry } from '../src/features/agent/agentKnowledge' +import { parseAgentTurn, wizardLlmRequestSchema, type AgentAppSnapshot } from '../src/features/agent/agentActions' +import { validateWizardPlan } from '../src/features/agent/wizardVisualPolicy' + +// Calls the configured LLM only. No planned action or media job is executed. +const baseUrl = String(process.env.HOCUSPOCUS_BASE_URL || '').replace(/\/$/, '') +if (!/^https?:\/\/(?:127\.0\.0\.1|localhost|\[::1\])(?::\d+)?$/i.test(baseUrl)) { + throw new Error('Set HOCUSPOCUS_BASE_URL to the exact loopback HocusPocus URL.') +} +const app = { interface_language: 'es', current: { media_filter: 'series' }, available_video_models: [], + context: { location: { tab: 'series_lab' }, labs: { series: { series_id: '', episode_id: '' } } }, +} as unknown as AgentAppSnapshot +const history: AgentConversationEntry[] = [ + { role: 'user', text: 'quiero hacer una serie de animacion' }, + { role: 'assistant', text: '¿Cuál es la premisa, el público, el estilo visual y la duración objetivo del primer episodio de tu serie de animación?' }, +] +const cases: Array<{ id: string; history: AgentConversationEntry[]; request: string; kind: string }> = [ + { id: 'creative-direction-after-question', history, + request: 'quiero que nos inspiremos en south park en cuanto al estilo, pero que sea sobre los investigadores de inteligencia artificial americanos, para el lore y demas podemos inspirarnos en la serie "silicon valley"', kind: 'action' }, + { id: 'indirect-direction-after-question', history, + request: 'Los protagonistas serían unos científicos que compiten por inventar la próxima gran IA en California. Los imagino como recortes de papel, con humor ácido sobre sus egos y los inversores. Todavía no se me ha ocurrido cómo llamarla.', kind: 'action' }, + { id: 'discussion-before-saving', history, + request: 'Una sátira de investigadores de IA, dibujada con recortes de papel. Antes de guardar nada quiero que conversemos sobre una posible premisa; todavía no crees el proyecto.', kind: 'conversation' }, + { id: 'no-creative-direction', history: [], request: 'quiero hacer una serie de animacion', kind: 'clarification' }, +] +for (const item of cases) { + const response = await fetch(`${baseUrl}/api/v1/llm/generate`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, signal: AbortSignal.timeout(60_000), + body: JSON.stringify({ system_prompt: HOCUSPOCUS_AGENT_SYSTEM_PROMPT, + prompt: buildAgentTurnPrompt('intent_contract_check', [...item.history, { role: 'user', text: item.request }], [], app), + max_new_tokens: 3_200, temperature: .1, json_schema: wizardLlmRequestSchema() }), + }) + assert.equal(response.status, 200, item.id) + const raw = String((await response.json() as { text?: string }).text || '') + const turn = validateWizardPlan(false, parseAgentTurn(raw)) + assert.equal(turn.intent?.kind, item.kind, `${item.id}: ${raw}`) + assert.equal(turn.rejections?.length || 0, 0, `${item.id}: ${JSON.stringify(turn.rejections)}`) + if (item.kind === 'action') { + assert.equal(turn.intent?.execution, 'prepare', item.id) + const episode = turn.actions.find(action => action.type === 'create_series_episode') + assert.ok(episode, `${item.id}: missing first draft`) + assert.equal(episode.createIfMissing, true, item.id) + assert.equal(episode.knownUniverse, false, `${item.id}: style references are not the existing universe`) + for (const field of ['seriesTitle', 'seriesPremise', 'worldSummary', 'visualStyle', 'episodeTitle', 'episodePremise'] as const) { + assert.ok(episode[field].trim(), `${item.id}: empty ${field}`) + } + assert.ok(episode.characters.length >= 3 && episode.locations.length >= 1 && episode.outlineBeats.length >= 3, item.id) + assert.ok(turn.actions.every(action => action.type === 'create_series_episode' + || action.type === 'open_tab' || action.type === 'open_series_section'), `${item.id}: unexpected extra work`) + console.log(JSON.stringify({ id: item.id, series: episode.seriesTitle, premise: episode.seriesPremise, + episode: episode.episodeTitle, episodePremise: episode.episodePremise, executed: false })) + } else { + assert.equal(turn.intent?.execution, 'none', item.id) + if (item.kind === 'conversation') { + assert.deepEqual(turn.actions, [], item.id) + assert.ok(turn.reply.length > 40, item.id) + } else assert.ok(turn.intent?.question, item.id) + console.log(JSON.stringify({ id: item.id, kind: turn.intent?.kind, executed: false })) + } +} +console.log('4 live intent checks passed; no series, episode or media was created.') diff --git a/ui/scripts/export_cut_paper_hocus.mjs b/ui/scripts/export_cut_paper_hocus.mjs new file mode 100644 index 000000000..bfbd5df1d --- /dev/null +++ b/ui/scripts/export_cut_paper_hocus.mjs @@ -0,0 +1,98 @@ +#!/usr/bin/env node +/** Open each Tijeral shot in Video 2.5D and click HocusPocus Export MP4. */ +import { chromium } from 'playwright' +import { mkdir, writeFile } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +const root = join(dirname(fileURLToPath(import.meta.url)), '..') +const outDir = process.env.HOCUS_EXPORT_DIR || join(root, '..', 'outputs', 'tijeral-clips') +const base = process.env.HOCUS_UI || 'http://127.0.0.1:4210' +const lang = process.env.HOCUS_LANG === 'en' ? 'en' : 'es' +const shotDir = lang === 'en' ? join(root, 'public/examples/cut-paper/shots/en') : join(root, 'public/examples/cut-paper/shots') +const suffix = lang === 'en' ? '-en' : '' +const shots = [ + [`01-plaza${suffix}`, join(shotDir, '01-plaza.maestro-scene.json')], + [`02-talk${suffix}`, join(shotDir, '02-talk.maestro-scene.json')], + [`03-sticker${suffix}`, join(shotDir, '03-sticker.maestro-scene.json')], +] + +await mkdir(outDir, { recursive: true }) +const browser = await chromium.launch({ + headless: true, + args: ['--use-gl=angle', '--enable-webgl', '--ignore-gpu-blocklist'], +}) +const context = await browser.newContext({ acceptDownloads: true, viewport: { width: 1600, height: 1000 } }) +await context.addInitScript(() => { + window.localStorage.setItem('hocuspocus-ui-language', 'en') + window.localStorage.setItem('hocuspocus_welcome_seen_v1', '1') +}) +const page = await context.newPage() +page.on('console', msg => { + if (msg.type() === 'error') console.error('PAGE', msg.text()) +}) +await page.emulateMedia({ reducedMotion: 'reduce' }) +page.setDefaultTimeout(120_000) + +async function dismissChrome() { + for (const name of [/skip/i, /enter the studio/i, /entrar al estudio/i]) { + const button = page.getByRole('button', { name }) + if (await button.count()) await button.first().click({ timeout: 2500, force: true }).catch(() => {}) + } + await page.locator('.hp-intro-root').waitFor({ state: 'hidden', timeout: 15000 }).catch(() => {}) + await page.evaluate(() => { + document.querySelectorAll('.hp-intro-root').forEach(node => node.remove()) + document.querySelectorAll('div.fixed.inset-0').forEach(node => { + const text = node.textContent || '' + if (/What's new|Enter the studio|HocusPocus is starting/.test(text) && node instanceof HTMLElement) { + node.style.display = 'none' + node.remove() + } + }) + }) +} + +try { + await page.goto(base, { waitUntil: 'domcontentloaded' }) + await dismissChrome() + await page.waitForTimeout(600) + await dismissChrome() + await page.getByRole('button', { name: /Studios|Estudios/i }).click({ timeout: 20000, force: true }) + await page.getByRole('tab', { name: /Video 2\.5D|2\.5D/i }).click({ timeout: 20000, force: true }) + await page.getByRole('button', { name: /Export MP4|Exportar MP4/i }).waitFor({ timeout: 30000 }) + console.log('animator open') + + for (const [name, file] of shots) { + console.log('import', name) + const jsonInput = page.locator('input[accept="application/json,.json"]').last() + await jsonInput.setInputFiles(file) + await page.waitForTimeout(1500) + await page.waitForFunction(() => { + const images = [...document.querySelectorAll('img')] + const ours = images.filter(img => /cut-paper/.test(img.src)) + return ours.length > 0 && ours.every(img => img.complete && img.naturalWidth > 8) + }, { timeout: 30000 }).catch(() => console.warn('images not all loaded', name)) + await page.screenshot({ path: join(outDir, `hocus-${name}.png`) }) + const dest = join(outDir, `${name}.mp4`) + const pending = page.waitForResponse( + res => res.url().includes('/api/v1/scenes/recordings') && res.request().method() === 'POST', + { timeout: 300000 }, + ) + await page.getByRole('button', { name: /Export MP4|Exportar MP4/i }).click() + const res = await pending + if (!res.ok()) throw new Error(`${name} export HTTP ${res.status()}: ${await res.text()}`) + const body = await res.json() + const url = body.url || (body.name ? `/api/v1/file/${encodeURIComponent(body.name)}` : '') + if (!url) throw new Error(`${name} export returned no file: ${JSON.stringify(body)}`) + const fileRes = await page.request.get(url.startsWith('http') ? url : new URL(url, base).toString()) + if (!fileRes.ok()) throw new Error(`${name} download HTTP ${fileRes.status()}`) + await writeFile(dest, Buffer.from(await fileRes.body())) + console.log('exported', name, dest, body.name || '') + } + console.log('done') +} catch (error) { + await page.screenshot({ path: join(outDir, 'export-debug.png'), fullPage: true }).catch(() => {}) + throw error +} finally { + await browser.close().catch(() => {}) +} diff --git a/ui/scripts/generate_cut_paper_voices.mjs b/ui/scripts/generate_cut_paper_voices.mjs new file mode 100644 index 000000000..ddefb146f --- /dev/null +++ b/ui/scripts/generate_cut_paper_voices.mjs @@ -0,0 +1,57 @@ +#!/usr/bin/env node +/** Generate Tijeral example lines with local Qwen3 CustomVoice. Requires the model installed. */ +import { writeFile } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +const root = join(dirname(fileURLToPath(import.meta.url)), '..') +const api = process.env.HOCUSPOCUS_API || 'http://127.0.0.1:42006' +const dest = join(root, 'public/examples/cut-paper/voices') +const lines = [ + ['nilo', 'dylan', 'vo-nilo-nilo-1.wav', 'La fuente no está congelada. Alguien le pegó un cuadrado de papel cebolla.', 12], + ['berta', 'serena', 'vo-berta-berta-1.wav', 'Pues sabe a hielo. Lo probé.', 8], + ['nilo', 'dylan', 'vo-nilo-nilo-2.wav', 'Berta, eso es cola.', 6], + ['berta', 'serena', 'vo-berta-berta-2.wav', 'Cola fría. Como hielo.', 8], + ['kito', 'sohee', 'vo-kito-kito-1.wav', '¡Era un sticker!', 6], +] + +async function wait(ms) { await new Promise(resolve => setTimeout(resolve, ms)) } + +for (const [who, voiceId, filename, prompt, duration] of lines) { + const submitted = await fetch(`${api}/api/v1/generate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model_type: 'qwen3_tts_customvoice', + generation_mode: 'audio', + prompt, + video_length: 0, + image_mode: 0, + multi_prompts_gen_type: 2, + duration_seconds: duration, + _audio_sub_mode: 'speech', + workspace: process.env.HOCUSPOCUS_WORKSPACE || 'default', + model_mode: voiceId, + }), + }) + if (!submitted.ok) throw new Error(`${who} submit HTTP ${submitted.status}: ${await submitted.text()}`) + const { job_id: jobId } = await submitted.json() + console.log('queued', filename, jobId) + const deadline = Date.now() + 20 * 60_000 + let status + while (Date.now() < deadline) { + const res = await fetch(`${api}/api/v1/status/${encodeURIComponent(jobId)}`) + if (!res.ok) throw new Error(`${filename} status HTTP ${res.status}`) + status = await res.json() + if (status.status === 'completed' || status.status === 'failed' || status.status === 'cancelled') break + await wait(2000) + } + if (status?.status !== 'completed') throw new Error(`${filename} ${status?.status}: ${status?.error || status?.message || 'timeout'}`) + const file = (status.output_files || []).find(name => /\.(wav|mp3|m4a)$/i.test(name)) + if (!file) throw new Error(`${filename} completed without audio`) + const audio = await fetch(`${api}/api/v1/file/${encodeURIComponent(file.split(/[\\/]/).pop())}`) + if (!audio.ok) throw new Error(`${filename} download HTTP ${audio.status}`) + await writeFile(join(dest, filename), Buffer.from(await audio.arrayBuffer())) + console.log('wrote', filename) +} +console.log('done') diff --git a/ui/scripts/record_face_pack_preview.mjs b/ui/scripts/record_face_pack_preview.mjs new file mode 100644 index 000000000..82584624f --- /dev/null +++ b/ui/scripts/record_face_pack_preview.mjs @@ -0,0 +1,53 @@ +#!/usr/bin/env node +import { chromium } from 'playwright' +import { mkdir, rm, writeFile } from 'fs/promises' +import { join } from 'path' +import { spawnSync } from 'child_process' + +const HOST = process.env.FACE_PACK_PREVIEW || 'http://127.0.0.1:4199' +const OUT = process.env.FACE_PACK_OUT || '/tmp/hocus-action-sets-20260911/outputs/face-pack-preview' +const PUB = '/tmp/hocus-action-sets-20260911/ui/public/examples/face-pack' +const WAV = join(PUB, 'neutral-vowels.wav') +const FPS = 12 +const DURATION = 8 +const SHOTS = process.env.FACE_PACK_SHOTS + ? process.env.FACE_PACK_SHOTS.split(',') + : ['felt-talk', 'pumpkin-talk', 'cat-talk'] + +const ffmpeg = (...args) => { + const result = spawnSync('ffmpeg', ['-y', '-hide_banner', '-loglevel', 'error', ...args], { stdio: 'inherit' }) + if (result.status !== 0) throw new Error(`ffmpeg ${args.join(' ')}`) +} + +const browser = await chromium.launch({ args: ['--use-gl=angle', '--use-angle=gl'] }) +for (const shot of SHOTS) { + const frames = join(OUT, `frames-${shot}`) + await rm(frames, { recursive: true, force: true }) + await mkdir(frames, { recursive: true }) + const page = await browser.newPage({ viewport: { width: 1280, height: 720 } }) + const errors = [] + page.on('pageerror', error => errors.push(error.message)) + await page.goto(`${HOST}/face-pack-preview.html?shot=${shot}&video=1`, { waitUntil: 'networkidle', timeout: 30000 }) + await page.waitForFunction(() => window.facePack?.sceneReady === true, null, { timeout: 15000 }) + await page.waitForTimeout(800) + await page.evaluate(() => window.facePack.setSceneSeconds(0.55)) + await page.waitForFunction(() => window.facePack.viseme() === 'A', null, { timeout: 5000 }) + const seen = new Set() + for (let i = 0; i < FPS * DURATION; i++) { + const seconds = i / FPS + await page.evaluate(value => window.facePack.setSceneSeconds(value), seconds) + await page.waitForTimeout(20) + seen.add(await page.evaluate(() => window.facePack.viseme())) + const png = await page.locator('#view canvas').screenshot() + await writeFile(join(frames, `f-${String(i).padStart(4, '0')}.png`), png) + } + await page.close() + if (errors.length) throw new Error(`${shot}: ${errors.join('; ')}`) + if (seen.size < 3) throw new Error(`${shot}: visemes did not change (${[...seen]})`) + const mp4 = join(PUB, `${shot}.mp4`) + ffmpeg('-framerate', String(FPS), '-i', join(frames, 'f-%04d.png'), '-i', WAV, + '-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-c:a', 'aac', '-b:a', '96k', '-t', String(DURATION), + '-movflags', '+faststart', mp4) + console.log(shot, 'visemes', [...seen].join(','), mp4) +} +await browser.close() diff --git a/ui/scripts/write-cut-paper-scene.mjs b/ui/scripts/write-cut-paper-scene.mjs new file mode 100644 index 000000000..30670880c --- /dev/null +++ b/ui/scripts/write-cut-paper-scene.mjs @@ -0,0 +1,18 @@ +import { mkdirSync, writeFileSync } from 'node:fs' +import { compileCutPaperPilotScene, compileCutPaperShot } from '../src/features/cutPaper/pilot.ts' +import { serializeSceneFile } from '../src/lib/sceneFile.ts' + +const root = new URL('../public/examples/cut-paper/', import.meta.url) +mkdirSync(new URL('shots/', root), { recursive: true }) +mkdirSync(new URL('shots/en/', root), { recursive: true }) +for (const locale of ['es', 'en']) { + const full = compileCutPaperPilotScene(locale) + if (locale === 'es') writeFileSync(new URL('tijeral-la-fuente.maestro-scene.json', root), serializeSceneFile(full)) + else writeFileSync(new URL('shots/en/tijeral-la-fuente.maestro-scene.json', root), serializeSceneFile(full)) + for (const shot of ['plaza', 'talk', 'sticker']) { + const scene = compileCutPaperShot(shot, locale) + const name = shot === 'plaza' ? '01-plaza' : shot === 'talk' ? '02-talk' : '03-sticker' + writeFileSync(new URL(`${locale === 'en' ? 'shots/en/' : 'shots/'}${name}.maestro-scene.json`, root), serializeSceneFile(scene)) + console.log(locale, name, scene.duration, scene.audioTracks?.[0]?.filename) + } +} diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 569c36b0d..8699282b8 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -1,7 +1,7 @@ import { lazy, Suspense, useCallback, useEffect, useState } from 'react' import { Menu, Settings } from 'lucide-react' -import { Sidebar } from './components/Sidebar/Sidebar' import { WizardSidebar } from './components/Sidebar/WizardSidebar' +import { WorkspaceEventBridge } from './components/Sidebar/WorkspaceEventBridge' import { MainContent } from './components/MainContent/MainContent' import { LoraBrowser } from './components/LoraBrowser/LoraBrowser' import { StorageDashboard } from './components/StorageDashboard/StorageDashboard' @@ -9,6 +9,7 @@ import { RetakeDialog } from './components/RetakeDialog' import { OomRecoveryBanner } from './components/OomRecoveryBanner' import { DownloadStatusBanner } from './components/DownloadStatusBanner' import { PreflightBanner } from './components/PreflightBanner' +import { PlatformModeBanner } from './components/PlatformModeBanner' import { ActivityFooter } from './components/ActivityFooter' import { GalleryReadyToast } from './components/MainContent/GalleryReadyToast' import { WelcomeModal } from './components/WelcomeModal' @@ -18,6 +19,8 @@ import { BrandIdentity } from './components/BrandIdentity' import { HocusPocusIntro } from './components/HocusPocusIntro' import { LanAuthGate } from './components/LanAuthGate' import { ExecutionModeBanner } from './components/ExecutionModeBanner' +import { SeriesNativeBatchBanner } from './features/series/SeriesNativeBatchBanner' +import { catalogFromOutputs, GenerationInspectorHost } from './features/generation-inspector' import { useStore } from './stores/useStore' import { useIsMobile } from './lib/useIsMobile' @@ -30,13 +33,18 @@ const DirectorDashboard = lazy(() => import('./components/DirectorDashboard/Dire // Settings is a drawer that boots closed, and the two panels behind it are // the largest thing in the app that nobody sees on load — hardware and // service configuration, plus the theme catalogue. Loading it on first open -// keeps all of that out of the initial chunk. The open event is handled in -// Sidebar.tsx and lands in the store, so nothing here needs to be mounted to -// receive it. +// keeps all of that out of the initial chunk. The open event is handled by +// WorkspaceEventBridge (always mounted) and lands in the store. Direct +// generation only mounts while that workspace is visible, so the listener +// cannot live there. const SettingsDrawer = lazy(() => import('./components/SettingsDrawer/SettingsDrawer').then(module => ({ default: module.SettingsDrawer, }))) +const HelpOverlay = lazy(() => import('./components/Help/HelpOverlay').then(module => ({ + default: module.HelpOverlay, +}))) + export function LazySettingsDrawer({ open }: { open: boolean }) { // Loads on the first open and then stays mounted. The drawer slides itself // with a transform driven by the store, so unmounting it on close would @@ -61,6 +69,23 @@ export function LazyDirectorOverlay({ open }: { open: boolean }) { } +export function LazyHelpOverlay() { + const [open, setOpen] = useState(false) + const [everOpened, setEverOpened] = useState(false) + useEffect(() => { + const openHelp = () => { + setEverOpened(true) + setOpen(true) + } + window.addEventListener('hocuspocus:help-open', openHelp) + return () => window.removeEventListener('hocuspocus:help-open', openHelp) + }, []) + if (!everOpened) return null + return + setOpen(false)} /> + +} + function AppContent() { const [introComplete, setIntroComplete] = useState(false) const completeIntro = useCallback(() => setIntroComplete(true), []) @@ -79,10 +104,11 @@ function AppContent() { const dashboardOpen = useStore(s => s.dashboardOpen) const settingsOpen = useStore(s => s.settingsOpen) const runtimeIdentity = useStore(s => s.systemStats?.runtime) - const toggleSidebar = useStore(s => s.toggleSidebar) - const setSidebarOpen = useStore(s => s.setSidebarOpen) const toggleSettings = useStore(s => s.toggleSettings) const appVersion = useStore(s => s.systemConfig?.app_version) + const activeWorkspace = useStore(s => s.activeWorkspace) + const outputs = useStore(s => s.outputs) + const params = useStore(s => s.params) const isMobile = useIsMobile() useEffect(() => { @@ -189,18 +215,20 @@ function AppContent() { return (
+ {/* Mobile header */} {isMobile && (
- )} - {activeTasks.length} active · durable per workspace - -
- -
- {roots.map(task => { - const taskChildren = childrenByRoot.get(task.root_id) || [] - const activeChild = taskChildren.find(child => ACTIVE.has(child.status)) - const active = ACTIVE.has(task.status) - const recipe = generationRecipe(task) - const prompt = generationPrompt(task) - const initiator = generationInitiator(task) - const visualState = canonicalTaskVisualState(task.status) - const controlFailure = controlFailures[task.id] - const updatedAt = formatAppTimestamp(task.updated_at) - const taskEta = formatEta(estimatedRemainingSeconds(task, clock)) - return ( -
-
- {visualState === 'active' - ? - : visualState === 'error' - ? - : visualState === 'cancelled' - ? - : } -
-
- {task.title} -
- {elapsed(task, clock)} - {active && taskEta && ETA {taskEta}} - {updatedAt && {updatedAt}} - {phaseLabel(task)} - {active && task.cancelable && ( - - )} - {!active && canResumeCanonicalTask(task) && ( - - )} - {!active && ( - - )} -
-
-

- {task.error?.message || task.detail || task.message} -

- {recipe &&

{recipe}

} - {initiator &&

Started by {initiator}

} - {prompt && ( -
- Prompt - - -
- )} - {resources(task) &&

{resources(task)}

} - {active && activeChild && ( -

- Active subtask: {phaseLabel(activeChild)} - {formatEta(estimatedRemainingSeconds(activeChild, clock)) && ` · ETA ${formatEta(estimatedRemainingSeconds(activeChild, clock))}`} -

- )} - {controlFailure && ( -
- {controlFailure.action[0].toUpperCase() + controlFailure.action.slice(1)} failed: {controlFailure.message} - -
- )} -

- {task.server_origin && server {task.server_origin}} - attempt {task.attempt}/{task.max_attempts} - {!!task.token_usage?.total && {task.token_usage.total.toLocaleString()} tokens · {task.token_usage.prompt || 0} input · {task.token_usage.completion || 0} output} - -

- {taskChildren.length > 0 && ( -
- {taskChildren.map(child => { - const childRecipe = generationRecipe(child) - const childResources = resources(child) - const childPrompt = generationPrompt(child) - const childInitiator = generationInitiator(child) - return ( -
-

- {phaseLabel(child)} · {elapsed(child, clock)} · {child.message} - {ACTIVE.has(child.status) && formatEta(estimatedRemainingSeconds(child, clock)) && ` · ETA ${formatEta(estimatedRemainingSeconds(child, clock))}`} -

-

- {childRecipe && {childRecipe}} - {childInitiator && Started by {childInitiator}} - {child.server_origin && server {child.server_origin}} - {childResources && {childResources}} - attempt {child.attempt}/{child.max_attempts} - {!!child.token_usage?.total && ( - - {child.token_usage.total.toLocaleString()} tokens · {child.token_usage.prompt || 0} input · {child.token_usage.completion || 0} output - - )} - -

- {childPrompt && ( - - )} -
- ) - })} -
- )} - {active && ( -
-
-
0 ? 2 : 0)}%` }} /> -
- {task.total > 0 ? `${task.current}/${task.total}` : `${Math.round(percent(task))}%`} -
- )} -
-
-
- ) - })} -
-
, - document.body, - )} - - - -
- {primary && {phaseLabel(primary)}} - {primary && {elapsed(primary, clock)}} - {primaryEta && ETA {primaryEta}} - {primaryActiveChild && Subtask {phaseLabel(primaryActiveChild)}{primaryChildEta ? ` · ETA ${primaryChildEta}` : ''}} - {primary?.model && {primary.model}} - {primary && generationInitiator(primary) && {generationInitiator(primary)}} - {primary && generationPrompt(primary) && ( - - )} - {primaryMessage} -
- - {isActive && primary && ( -
-
-
0 ? 2 : 0)}%` }} /> -
- {primary.total > 0 ? `${primary.current}/${primary.total}` : `${Math.round(percent(primary))}%`} -
- )} - {primary && ACTIVE.has(primary.status) && primary.cancelable && ( - - )} - + runControl(task, action, panel.openPanel)} + onCopyId={copyId} + onCopyPrompt={copyPrompt} + /> + runControl(task, action, panel.openPanel)} + /> ) } diff --git a/ui/src/components/DirectorDashboard/DirectorDashboard.tsx b/ui/src/components/DirectorDashboard/DirectorDashboard.tsx index 72b83feb0..89c14307d 100644 --- a/ui/src/components/DirectorDashboard/DirectorDashboard.tsx +++ b/ui/src/components/DirectorDashboard/DirectorDashboard.tsx @@ -6,6 +6,14 @@ import { getOutputReference } from '../../lib/outputReference' import type { H3SegmentState, PipelineClipState, SavedPipelineState } from '../../types' import { ModalShell } from '../common/ModalShell' import i18n, { useUiTranslation } from '../../i18n' +import { ProductionReviewHost } from '../../features/production-review/ProductionReviewHost' +import { reviewCopy } from '../../features/production-review/copy' + +function applySavedReview(pipeline: SavedPipelineState, workspace: string) { + useStore.setState(state => state.activeWorkspace === workspace + && state.dashboardSelectedPipeline?.pipeline_id === pipeline.pipeline_id + ? { dashboardSelectedPipeline: pipeline } : {}) +} /** Safely coerce any value to a displayable string */ function safeStr(val: unknown): string { @@ -1153,6 +1161,11 @@ function DirectorDashboardInner() { )}
+
+ {reviewCopy().title} + applySavedReview(pipeline, activeWorkspace)} /> +
{/* LLM Log */}

{t('dashboard.llmLog')}

diff --git a/ui/src/components/Help/HelpOverlay.tsx b/ui/src/components/Help/HelpOverlay.tsx new file mode 100644 index 000000000..160ac6605 --- /dev/null +++ b/ui/src/components/Help/HelpOverlay.tsx @@ -0,0 +1,90 @@ +import { CircleHelp, X } from 'lucide-react' +import { setUiLanguage, useUiTranslation, type UiLanguage } from '../../i18n' +import { ModalShell } from '../common/ModalShell' + +const SECTIONS = [ + { id: 'start', image: '/help/direct-image.jpg', imageKey: 'directImage' }, + { id: 'wizard', image: '/help/wizard.jpg', imageKey: 'wizard' }, + { id: 'direct', image: '/help/direct-video.jpg', imageKey: 'directVideo' }, + { id: 'queue', image: '/help/activity.jpg', imageKey: 'activity' }, + { id: 'studios', image: '/help/story-lab.jpg', imageKey: 'storyLab' }, + { id: 'faces', image: '/help/character-creator.jpg', imageKey: 'characterCreator' }, + { id: 'tijeral', image: '/help/story-lab.jpg', imageKey: 'storyLab' }, + { id: 'video3d', image: '/help/video-3d.jpg', imageKey: 'video3d' }, + { id: 'production', image: '/help/director.jpg', imageKey: 'director' }, + { id: 'media', image: '/help/media.jpg', imageKey: 'media' }, + { id: 'settings', image: '/help/settings.jpg', imageKey: 'settings' }, + { id: 'examples' }, +] as const + +export function HelpOverlay({ open, onClose }: { open: boolean; onClose: () => void }) { + const { t, i18n } = useUiTranslation('help') + const language: UiLanguage = String(i18n.resolvedLanguage || i18n.language).startsWith('es') ? 'es' : 'en' + + if (!open) return null + + return ( + { if (event.target === event.currentTarget) onClose() }} + > +
+
+ +

{t('title')}

+ + +
+
+

{t('draft')}

+ + {SECTIONS.map(section => ( +
+

{t(`${section.id}.title` as const)}

+ {String(t(`${section.id}.body` as const)).split('\n\n').map((paragraph, index) => ( +

{paragraph}

+ ))} + {'image' in section && section.image ? ( +
+ {t(`images.${section.imageKey}` +
+ ) : null} + {section.id === 'examples' ? ( +
+
+ {t('images.exampleImage')} +
+
+ {t('images.exampleVideo')} +
+
+ ) : null} +
+ ))} +
+
+
+ ) +} diff --git a/ui/src/components/MainContent/MainContent.tsx b/ui/src/components/MainContent/MainContent.tsx index 481293489..2b61b62f8 100644 --- a/ui/src/components/MainContent/MainContent.tsx +++ b/ui/src/components/MainContent/MainContent.tsx @@ -1,6 +1,7 @@ import { lazy, Suspense, useRef, useCallback, useState, useEffect, useLayoutEffect, useMemo, type JSX } from 'react' import { Film, Play, Square, Loader2, X, BookMarked, ChevronDown, ChevronUp, RefreshCw } from 'lucide-react' import { TabFilter } from './TabFilter' +import { visibleWorkspaceSurface } from '../../lib/navigationCategories' import { ThumbnailGallery } from './ThumbnailGallery' import { GalleryViewSwitcher } from './GalleryViewSwitcher' import { MediaFeedItem } from './MediaFeedItem' @@ -23,6 +24,8 @@ import { } from './mediaFeedSizing' const GalleryLayouts = lazy(() => import('./GalleryLayouts')) +const DirectGenerationWorkspace = lazy(() => import('../Sidebar/Sidebar').then(module => ({ default: module.DirectGenerationWorkspace }))) +const DirectorWorkspace = lazy(() => import('../Sidebar/DirectorChat').then(module => ({ default: module.DirectorChat }))) const SceneAnimatorPanel = lazy(() => import('../Sidebar/SceneAnimatorPanel') .then(module => ({ default: module.SceneAnimatorPanel }))) const Scene3DEditorPanel = lazy(() => import('../../features/scene3d/Scene3DEditorPanel') @@ -302,6 +305,11 @@ export function MainContent() { const selectedOutput = useStore(s => s.selectedOutput) const setMediaFilter = useStore(s => s.setMediaFilter) const mediaFilter = useStore(s => s.mediaFilter) + const sidebarMode = useStore(s => s.sidebarMode) + const sidebarOpen = useStore(s => s.sidebarOpen) + const settingsOpen = useStore(s => s.settingsOpen) + const dashboardOpen = useStore(s => s.dashboardOpen) + const workspaceSurface = visibleWorkspaceSurface({ mediaFilter, sidebarMode, sidebarOpen, settingsOpen, dashboardOpen }) const developerMode = useStore(s => s.developerMode) const setGalleryFeedAtTop = useStore(s => s.setGalleryFeedAtTop) const visibleJobs = jobs.filter(job => jobFitsGalleryFilter(job, mediaFilter)) @@ -606,9 +614,18 @@ export function MainContent() {
{/* Content area: feed + thumbnails */} -
+
}> - {mediaFilter === 'assets' ? ( + {workspaceSurface === 'generate' && ( +
+ +
+ )} + {workspaceSurface === 'director' ? ( +
+ +
+ ) : mediaFilter === 'assets' ? ( ) : mediaFilter === 'projects' ? ( diff --git a/ui/src/components/MainContent/TabFilter.tsx b/ui/src/components/MainContent/TabFilter.tsx index 91fda0bd3..73da9f7dc 100644 --- a/ui/src/components/MainContent/TabFilter.tsx +++ b/ui/src/components/MainContent/TabFilter.tsx @@ -1,11 +1,12 @@ import { useEffect, useRef, useState, type ReactNode } from 'react' import { - Activity, BookOpen, Boxes, Clapperboard, FolderKanban, Languages, + Activity, BookOpen, Boxes, CircleHelp, Clapperboard, FolderKanban, Languages, Library, MonitorPlay, Search, Settings, Sparkles, Video, WandSparkles, X, } from 'lucide-react' import { setUiLanguage, useUiTranslation, type UiLanguage } from '../../i18n' import { - categoryForMediaFilter, type NavigationCategory, WIZARD_NAVIGATION_EVENT, + categoryForMediaFilter, DIRECT_GENERATION_MEDIA, + revealDirectorWorkspace, type NavigationCategory, WIZARD_NAVIGATION_EVENT, } from '../../lib/navigationCategories' import { useStore } from '../../stores/useStore' import type { GenerationMode, MediaFilter } from '../../types' @@ -27,15 +28,6 @@ const PRIMARY_DESTINATIONS = { activity: { value: 'runs' as const }, } -const DIRECT_GENERATION_MEDIA: Record = { - image: 'images', - video: 'videos', - audio: 'audio', - model3d: 'model3d', - avatar: 'avatars', - tools: 'all', -} - function PrimaryButton({ active, expanded, icon, label, onClick, ariaLabel, category, buttonRef }: { active?: boolean expanded?: boolean @@ -93,6 +85,7 @@ function NavigationBar({ category, title, items, activeValue, barRef }: { catego export function TabFilter() { const { t, i18n } = useUiTranslation('navigation') const { t: tSettings } = useUiTranslation('settings') + const { t: tHelp } = useUiTranslation('help') const mediaFilter = useStore(s => s.mediaFilter) const developerMode = useStore(s => s.developerMode) const generationMode = useStore(s => s.generationMode) @@ -214,7 +207,7 @@ export function TabFilter() { const state = useStore.getState() state.setSettingsOpen(false) state.setDashboardOpen(false) - if (filter === 'character-replacement') state.setSidebarOpen(false) + state.setSidebarOpen(false) state.setMediaFilter(filter) setActiveCategory(category) setExpandedCategory(category) @@ -228,6 +221,7 @@ export function TabFilter() { locallySelectedFilterRef.current = filter state.setMediaFilter(filter) state.setSidebarMode('studio') + state.setSidebarOpen(true) window.dispatchEvent(new Event('hocuspocus:studio-open')) setActiveCategory('direct-generation') setExpandedCategory('direct-generation') @@ -255,7 +249,7 @@ export function TabFilter() { const state = useStore.getState() state.setSettingsOpen(false) state.setDashboardOpen(false) - state.setSidebarMode('director') + revealDirectorWorkspace(state) window.dispatchEvent(new Event('maestro:director-open')) setActiveCategory('production') setExpandedCategory('production') @@ -348,6 +342,9 @@ export function TabFilter() { + diff --git a/ui/src/components/PlatformModeBanner.tsx b/ui/src/components/PlatformModeBanner.tsx new file mode 100644 index 000000000..e00b7bed4 --- /dev/null +++ b/ui/src/components/PlatformModeBanner.tsx @@ -0,0 +1,40 @@ +import { useEffect, useState } from 'react' +import { Monitor } from 'lucide-react' +import { fetchSystemCapabilities, type SystemCapabilities } from '../api/system' +import { useUiTranslation } from '../i18n' + +const MODE_KEYS = { + macosCoreRemote: 'capabilities.macosCoreRemote', + macosIntel: 'capabilities.macosIntel', + coreRemote: 'capabilities.coreRemote', +} as const + +/** Persistent platform mode for Apple Silicon / core-remote hosts. */ +export function PlatformModeBanner() { + const { t } = useUiTranslation('shell') + const [snapshot, setSnapshot] = useState(null) + + useEffect(() => { + let cancelled = false + fetchSystemCapabilities() + .then(value => { if (!cancelled) setSnapshot(value) }) + .catch(() => { /* older backend / transient */ }) + return () => { cancelled = true } + }, []) + + const mode = snapshot?.ui.mode + const key = mode && mode in MODE_KEYS ? MODE_KEYS[mode as keyof typeof MODE_KEYS] : null + if (!key) return null + + return ( +
+ + {t(key)} + +
+ ) +} diff --git a/ui/src/components/SettingsDrawer/ProductionProfileSettings.tsx b/ui/src/components/SettingsDrawer/ProductionProfileSettings.tsx new file mode 100644 index 000000000..2be762f8f --- /dev/null +++ b/ui/src/components/SettingsDrawer/ProductionProfileSettings.tsx @@ -0,0 +1,379 @@ +import { useState } from 'react' +import { useUiTranslation } from '../../i18n' +import { useStore, getFamiliesForMode } from '../../stores/useStore' +import { + MINIMAX_IMAGE_MODELS, + MINIMAX_MUSIC_MODELS, + defaultImageModel, + defaultModel3dModel, + defaultTextBaseUrl, + defaultTextModel, + downloadedModelOptions, + keepCurrentOption, + listedTextModels, + model3dProfileOptions, + textModelOptions, +} from '../../lib/productionProfileCatalog' + +function CatalogSelect({ + value, options, disabled, onChange, emptyLabel, +}: { + value: string + options: { id: string; label: string }[] + disabled?: boolean + onChange: (value: string) => void + emptyLabel?: string +}) { + const listed = keepCurrentOption(options, value) + return ( + + ) +} + +export function ProductionProfileSettings() { + const { t } = useUiTranslation('settings') + const savedProductionProfile = useStore(s => s.productionProfile) + const productionProfileConfigured = useStore(s => s.productionProfileConfigured) + const productionProfileLoading = useStore(s => s.productionProfileLoading) + const updateProductionProfile = useStore(s => s.updateProductionProfile) + const llmModels = useStore(s => s.llmModels) + const loadLlmModels = useStore(s => s.loadLlmModels) + const installedModels = useStore(s => s.models) + const families = useStore(s => s.families) + const [productionProfileDraft, setProductionProfile] = useState(null) + const [refreshing, setRefreshing] = useState(false) + const productionProfile = productionProfileDraft ?? savedProductionProfile + const imageFamilies = getFamiliesForMode('image', families).map(family => family.id) + const videoFamilies = getFamiliesForMode('video', families).map(family => family.id) + const model3dFamilies = getFamiliesForMode('model3d', families).map(family => family.id) + const hunyuanOptions = downloadedModelOptions(installedModels, model3dFamilies) + + const loadRemoteTextModels = async () => { + setRefreshing(true) + try { + const provider = productionProfile.text.provider + await loadLlmModels({ + provider, + url: productionProfile.text.base_url, + }) + const listed = listedTextModels(useStore.getState().llmModels, provider) + setProductionProfile(current => { + const draft = current ?? savedProductionProfile + if (listed.some(option => option.id === draft.text.model)) return draft + return { + ...draft, + text: { ...draft.text, model: listed[0]?.id || '' }, + } + }) + } finally { + setRefreshing(false) + } + } + + return ( +
+
+

+ {t('services.profileTitle')} +

+

{t('services.profileHint')}

+
+
+ + + {(productionProfile.text.provider === 'ollama' || productionProfile.text.provider === 'remote') && ( + + )} + + + +
+ +
+ + + + +
+

+ {productionProfileConfigured ? t('services.profileSaved') : t('services.profileDefaults')} + {' '}{t('services.profileSizeHint')} +

+
+ +
+
+ ) +} diff --git a/ui/src/components/SettingsDrawer/ServicesSettingsPanel.tsx b/ui/src/components/SettingsDrawer/ServicesSettingsPanel.tsx index f759c0fe1..79349705d 100644 --- a/ui/src/components/SettingsDrawer/ServicesSettingsPanel.tsx +++ b/ui/src/components/SettingsDrawer/ServicesSettingsPanel.tsx @@ -5,6 +5,7 @@ import { useStore } from '../../stores/useStore' import { testLlmConnection } from '../../api/client' const McpSettingsPanel = lazy(() => import('./McpSettingsPanel').then(module => ({ default: module.McpSettingsPanel }))) +const ProductionProfileSettings = lazy(() => import('./ProductionProfileSettings').then(module => ({ default: module.ProductionProfileSettings }))) function ApiKeyField({ label, maskedValue, isSet, onSave }: { label: string @@ -269,10 +270,6 @@ export function ServicesSettingsPanel() { const servicesConfig = useStore(s => s.servicesConfig) const servicesConfigLoading = useStore(s => s.servicesConfigLoading) const updateConfig = useStore(s => s.updateServicesConfig) - const savedProductionProfile = useStore(s => s.productionProfile) - const productionProfileConfigured = useStore(s => s.productionProfileConfigured) - const productionProfileLoading = useStore(s => s.productionProfileLoading) - const updateProductionProfile = useStore(s => s.updateProductionProfile) const systemConfig = useStore(s => s.systemConfig) const updateSystemConfig = useStore(s => s.updateSystemConfig) const llmStatus = useStore(s => s.llmStatus) @@ -285,8 +282,6 @@ export function ServicesSettingsPanel() { }) const pendingLlmConfig = useRef>(Promise.resolve()) const [llmConfigSaving, setLlmConfigSaving] = useState(false) - const [productionProfileDraft, setProductionProfile] = useState(null) - const productionProfile = productionProfileDraft ?? savedProductionProfile if (servicesConfigLoading && !servicesConfig) { return
{tCommon('status.loading')}
} @@ -305,8 +300,16 @@ export function ServicesSettingsPanel() { const handleRefreshModels = async () => { setRefreshing(true) - await loadLlmModels() - setRefreshing(false) + try { + await pendingLlmConfig.current + const latest = useStore.getState().servicesConfig + await loadLlmModels({ + provider: latest?.llm_provider || provider, + url: latest?.llm_remote_url, + }) + } finally { + setRefreshing(false) + } } const resetLlmTest = () => { @@ -354,249 +357,7 @@ export function ServicesSettingsPanel() { {/* LLM Provider */}
-
-
-

- {t('services.profileTitle')} -

-

- {t('services.profileHint')} -

-
-
- - - - - -
- -
- - - - -
-

- {productionProfileConfigured ? t('services.profileSaved') : t('services.profileDefaults')} - {' '}{t('services.profileSizeHint')} -

-
- -
-
+

{t('services.llmTitle')}

@@ -696,7 +457,7 @@ export function ServicesSettingsPanel() { {!isLocal && ( )}
- + {alsoDeletes.length > 0 && (

Shared weights — also deletes: {alsoDeletes.join(', ')} @@ -1009,6 +1025,7 @@ export function SystemSettingsPanel() { const updateConfig = useStore(s => s.updateSystemConfig) const servicesConfig = useStore(s => s.servicesConfig) const updateServicesConfig = useStore(s => s.updateServicesConfig) + const cudaControls = showsCudaControls(usePlatformCapabilities()) // Detected VRAM is used in the VRAM coefficient subtext (see below) // so the "Max VRAM target: ~X GB of Y GB" line shows real numbers // instead of a hardcoded 24 GB. AutoPerformanceCard populates this @@ -1206,14 +1223,12 @@ export function SystemSettingsPanel() {


- {/* Auto-tune card always visible. The fields below are - conditionally hidden based on autoOn. */} - + {cudaControls && } {/* Auto ON: collapse the advanced fields under an expander. The expander defaults closed — power users who want to peek at what auto picked can open it without leaving the page. */} - {autoOn ? ( + {cudaControls && (autoOn ? (
) : ( - // Auto OFF: show fields directly + a "Reset to auto-tune" - // affordance below them. The Reset button just toggles auto - // back ON, which triggers the apply endpoint via the card. <> {renderAdvancedFields()} - )} + ))}
diff --git a/ui/src/components/Sidebar/DirectorChat.tsx b/ui/src/components/Sidebar/DirectorChat.tsx index d396384df..d0044bd88 100644 --- a/ui/src/components/Sidebar/DirectorChat.tsx +++ b/ui/src/components/Sidebar/DirectorChat.tsx @@ -1,6 +1,6 @@ import { DirectorModelPicker } from './DirectorModelPicker' import { lazy, Suspense, useState, useCallback, useRef, useMemo, useEffect } from 'react' -import { Upload, Loader2, Music, RotateCcw, Check, X, ChevronRight, ChevronDown, ImageIcon, Play, Film, Mic, Sparkles, Send, Users, FileText, Clock, BookOpen, Zap } from 'lucide-react' +import { Upload, Loader2, Music, RotateCcw, Check, X, ChevronRight, ChevronDown, ImageIcon, Play, Film, Mic, Sparkles, Send, Users, FileText, Clock, BookOpen, Zap, Download } from 'lucide-react' import { useStore, resolveResolution } from '../../stores/useStore' import { fetchModelOptions } from '../../api/client' import { MINIMAX_IMAGE_API_MODEL } from '../../lib/externalModels' @@ -2134,6 +2134,15 @@ function AnalysisSummary({ const speakerCount = new Set( (analysis.lyrics || []).map(l => l.speaker).filter(Boolean) ).size + const downloadSrt = () => { + if (!analysis.lyrics_srt) return + const url = URL.createObjectURL(new Blob([analysis.lyrics_srt], { type: 'application/x-subrip;charset=utf-8' })) + const anchor = document.createElement('a') + anchor.href = url + anchor.download = 'lyrics-timeline.srt' + anchor.click() + URL.revokeObjectURL(url) + } return (
@@ -2145,6 +2154,18 @@ function AnalysisSummary({ {warning}

))} + {analysis.lyric_timing && !isShortFilm && ( +
+ + Source-audio lyric timeline · {Math.round(analysis.lyric_timing.coverage * 100)}% word coverage + + {analysis.lyrics_srt && ( + + )} +
+ )} {help && ( @@ -155,6 +158,24 @@ export function ModelSelector() { ) } +function selectorModelHelp(model: ModelDef, t: TFunction<'studio'>): string { + const h3 = h3CatalogEntry(model.model_type) + if (h3) { + return [t(`h3Catalog.${h3.variant}Hint`), t('h3Catalog.memory')].join('\n\n') + } + const catalog = resolveModelCatalog(model) + return [ + t(`modelCatalog.${catalog.variant}Hint`), + t(`modelCatalog.capability.${catalog.capability}`), + catalog.requirements.vram_gb != null ? t('modelCatalog.vram', { vram: catalog.requirements.vram_gb }) : '', + catalog.requirements.ram_gb != null ? t('modelCatalog.ram', { ram: catalog.requirements.ram_gb }) : '', + catalog.requirements.storage_gb != null + ? t('modelCatalog.storage', { storage: catalog.requirements.storage_gb }) + : '', + t('modelCatalog.limit'), + ].filter(Boolean).join('\n\n') +} + function ModelBadges({ model }: { model: { model_type: string diff --git a/ui/src/components/Sidebar/SceneAnimatorPanel.tsx b/ui/src/components/Sidebar/SceneAnimatorPanel.tsx index d2d72ec49..79ab9b6a6 100644 --- a/ui/src/components/Sidebar/SceneAnimatorPanel.tsx +++ b/ui/src/components/Sidebar/SceneAnimatorPanel.tsx @@ -1,11 +1,13 @@ import { sceneAudioWav, supportsSceneAac } from '../../features/sceneFx/audioExport' import { paintSceneFx } from '../../features/sceneFx/paint' +import { waitForSceneImages } from '../../lib/sceneMediaReady' import { mixFxAudio } from '../../features/sceneFx/mix' import { encodeSpeechAudio } from '../../features/scene3d/speech/encodeAudio' import { presentSceneDocument, useSceneDocumentHandoff } from '../../features/sceneFx/handoff' import { galleryWorkspaceEpoch, galleryWorkspaceName } from '../../stores/gallerySlice' import { SceneFxControls } from '../../features/sceneFx/SceneFxControls' import { SceneFxOverlay } from '../../features/sceneFx/SceneFxOverlay' +import { isRetroLook } from '../../features/sceneFx/retroPaint' import { adoptPreparedSceneDocument, withFxShowcase } from '../../features/sceneFx/showcase' import { KineticTextControls } from '../common/KineticTextControls' import { KineticTextOverlay } from '../common/KineticTextOverlay' @@ -41,6 +43,7 @@ import { TemplateComposerDialog } from '../../features/sceneTemplates/TemplateCo import type { SceneRecipe } from '../../lib/sceneRecipe' import { sceneToRecipe } from '../../lib/sceneToRecipe' import { parseSceneFile, sceneFileName, serializeSceneFile } from '../../lib/sceneFile' +import { SceneHandoffRecovery } from '../../lib/sceneRecovery' import { SceneLibraryDialog } from './SceneLibraryDialog' import { PENDING_SCENE_KEY } from '../../lib/sceneOutput' import { loadAgentLibraryScene } from '../../lib/agentSceneOpen' @@ -49,7 +52,7 @@ import { assessNarrativeAsset } from '../../lib/assetSuitability' import { getSceneClipTime } from '../../lib/sceneClip' import { sanitizeSceneMotion } from '../../lib/sceneMotion' import { applySceneRhythmToLayer, buildSceneRhythmMap, type SceneRhythmCueSource, type SceneRhythmProfile } from '../../lib/sceneRhythm' -import { applyCutoutDialogue, bindCutoutFaceToPose, ensureCutoutFacePlayback, findCutoutMouthLayers, isCutoutFaceLayer, normalizeFaceBinding, planCutoutDialogue, rebuildCutoutDialogueLayers, type SceneDialogueBeat } from '../../lib/cutoutDialogue' +import { applyCutoutDialogue, bindCutoutFaceToPose, ensureCutoutFacePlayback, findCutoutMouthLayers, isCutoutFaceLayer, normalizeAlignedCutoutUnits, normalizeFaceBinding, planAlignedCutoutDialogue, planCutoutDialogue, rebuildCutoutDialogueLayers, type SceneDialogueBeat } from '../../lib/cutoutDialogue' import { captureCharacterFaceAnchor, characterKitAssetFromLayer, claimUnusedCharacterKitId, createCharacterKit, emptyCharacterKitLibrary, mountCharacterKitLayers, syncMountedCharacterKitLayers, syncSceneCharacterKits, type CharacterKit, type CharacterKitAlphaStatus, type CharacterMouthState } from '../../lib/characterKit' import { consumeFaceRigHandoff, FACE_RIG_HANDOFF_EVENT, kitFromFaceRigHandoff } from '../../lib/characterKitHandoff' import { rememberCharacterKitLibrary, rememberVideo3dScene } from '../../features/agent/wizardLabSession' @@ -58,6 +61,7 @@ import { applySceneCopilotProposal, buildSceneCopilotSystemPrompt, buildSceneSco import { evaluateSceneLayer, getSceneEvents, getSceneKeyframes, getSceneLayerTiming, mapSceneAnimationPoints, normalizeSceneEvents, normalizeSceneKeyframes, sceneLayerMotionProgress, sceneProgressFromSeconds, sceneTimeToLayerTime, withNormalizedSceneTiming, withSceneKeyframes } from '../../lib/sceneTimeline' import { normalizeSeamOccluder, paintSeamOccluder, seamOccluderDataUri, type SeamOccluderKind } from '../../lib/seamOccluder' import type { AudioAnalysisResult, Scene, SceneAnimationEvent, SceneAtmosphereKind, SceneBlendMode, SceneCurve, SceneFrameRate, SceneKeyframe, SceneLayer, SceneLayerType, SceneMask } from '../../types' +import { canonicalSceneFps } from '../../lib/sceneFps.ts' import { SceneTimeline } from './SceneTimeline' import { CylinderPanoramaComparison } from './CylinderPanoramaComparison' import { CharacterKitLibraryPanel } from '../../features/characters/CharacterKitLibraryPanel' @@ -511,10 +515,12 @@ export function SceneAnimatorPanel() { const workspace = useStore(s => s.activeWorkspace) const setGenerationMode = useStore(s => s.setGenerationMode) const setSidebarMode = useStore(s => s.setSidebarMode) + const setMediaFilter = useStore(s => s.setMediaFilter) const setSidebarOpen = useStore(s => s.setSidebarOpen) const selectedSpeechModel = useStore(s => s.selectedModelPerAudioSubMode.speech ?? 'kugelaudio_0_open') const [scene, setScene] = useState(blankScene) const sceneRef = useRef(scene) + const sceneRecovery = useRef(new SceneHandoffRecovery()) const [selectedId, setSelectedId] = useState(null) const [addOpen, setAddOpen] = useState(false) const [templateComposerOpen, setTemplateComposerOpen] = useState(false) @@ -605,6 +611,7 @@ export function SceneAnimatorPanel() { const [clipDurationsByLayer, setClipDurationsByLayer] = useState>({}) const canvasRef = useRef(null) + const retroCanvasRef = useRef(null) const animationRef = useRef(null) const recordingAnimationRef = useRef(null) const mediaRecorderRef = useRef(null) @@ -644,7 +651,7 @@ export function SceneAnimatorPanel() { return [t('animator.suggestionDefault1'), t('animator.suggestionDefault2')] })() : [] const composition = { ...DEFAULT_COMPOSITION, ...scene.composition } - const fps: SceneFrameRate = scene.fps === 60 ? 60 : 30 + const fps: SceneFrameRate = canonicalSceneFps(scene.fps) const snapCoordinate = (value: number) => composition.snap ? Math.round(value / Math.max(1, composition.gridSize)) * Math.max(1, composition.gridSize) : value const generatedModels = outputs.filter(output => output.type === 'model3d' && /\.glb$/i.test(output.name)) const generatedMedia = outputs.filter(output => output.type === 'image' || output.type === 'video') @@ -1780,7 +1787,7 @@ export function SceneAnimatorPanel() { const previousObjectUrls = new Set(sceneRef.current.layers.flatMap(layer => [layer.source, layer.thumbnail].filter((value): value is string => Boolean(value?.startsWith('blob:'))))) previousObjectUrls.forEach(url => URL.revokeObjectURL(url)) const missingAssets = layers.filter(layer => layer.type !== 'camera' && layer.missingAsset).length - generationRef.current += 1; pendingBindRef.current = null; localFilesRef.current = {}; pastScenesRef.current = []; futureScenesRef.current = []; lastHistoryAtRef.current = 0; replaceScene({ ...blankScene(), ...incoming, texts: parseKineticTexts(incoming.texts), name: typeof incoming.name === 'string' && incoming.name.trim() ? incoming.name : 'Imported scene', width, height, fps: incoming.fps === 60 ? 60 : 30, duration, layers, composition }); setHistoryRevision(value => value + 1); setSelectedId(layers[0]?.id ?? null); setSelectedKeyframeId(null); setSelectedEventId(null); setProgress(0); setMessage(successMessage ?? `${t('animator.imported', { count: layers.length })}${missingAssets ? t('animator.reassignMissing', { count: missingAssets }) : ''}`); setJsonOpen(false) + generationRef.current += 1; pendingBindRef.current = null; localFilesRef.current = {}; pastScenesRef.current = []; futureScenesRef.current = []; lastHistoryAtRef.current = 0; replaceScene({ ...blankScene(), ...incoming, texts: parseKineticTexts(incoming.texts), name: typeof incoming.name === 'string' && incoming.name.trim() ? incoming.name : 'Imported scene', width, height, fps: canonicalSceneFps(incoming.fps), duration, layers, composition }); setHistoryRevision(value => value + 1); setSelectedId(layers[0]?.id ?? null); setSelectedKeyframeId(null); setSelectedEventId(null); setProgress(0); setMessage(successMessage ?? `${t('animator.imported', { count: layers.length })}${missingAssets ? t('animator.reassignMissing', { count: missingAssets }) : ''}`); setJsonOpen(false) return true } catch (error) { setMessage(error instanceof Error ? error.message : t('animator.invalidSceneJson')); return false } } @@ -1906,6 +1913,16 @@ export function SceneAnimatorPanel() { paintKineticTexts(context, canvas.width, canvas.height, sceneSeconds, current.texts) return true } + const sceneSecondsNow = progress * scene.duration + const retroLive = (scene.sfx ?? []).some(cue => isRetroLook(cue.kind) && sceneSecondsNow >= cue.start && sceneSecondsNow < cue.end) + useEffect(() => { + if (!retroLive) return + const canvas = retroCanvasRef.current + if (!canvas) return + canvas.width = scene.width + canvas.height = scene.height + paintScene(canvas, progress) + }) // Compatibility fallback for browsers without WebCodecs. Chromium uses the // deterministic MP4 path below so slow WebGL frames never change timing. const recordCompatibilityWebm = (): Promise => new Promise((resolve, reject) => { @@ -1913,7 +1930,7 @@ export function SceneAnimatorPanel() { if (playing) { const error = new Error(t('animator.waitPreview')); setMessage(error.message); reject(error); return } prepareFacePlayback() const current = sceneRef.current - const currentFps: SceneFrameRate = current.fps === 60 ? 60 : 30 + const currentFps: SceneFrameRate = canonicalSceneFps(current.fps) if (!current.sfx?.length && !current.layers.some(layer => layer.visible && isVisualLayer(layer))) { const error = new Error(t('animator.addVisibleLayer')); setMessage(error.message); reject(error); return } if (!('MediaRecorder' in window)) { const error = new Error(t('animator.cannotRecord')); setMessage(error.message); reject(error); return } const canvas = document.createElement('canvas'); canvas.width = current.width; canvas.height = current.height; const context = canvas.getContext('2d'); if (!context) { reject(new Error('Could not create a recording canvas.')); return } @@ -2118,7 +2135,7 @@ export function SceneAnimatorPanel() { return recordCompatibilityWebm() } const current = sceneRef.current - const fps: SceneFrameRate = current.fps === 60 ? 60 : 30 + const fps: SceneFrameRate = canonicalSceneFps(current.fps) if (!current.sfx?.length && !current.layers.some(layer => layer.visible && isVisualLayer(layer))) throw new Error(t('animator.addVisibleLayer')) const canvas = document.createElement('canvas') canvas.width = current.width @@ -2137,11 +2154,12 @@ export function SceneAnimatorPanel() { throw new Error('This browser cannot encode a deterministic H.264 MP4 at the selected resolution.') } - const fxAudio = await supportsSceneAac() ? await mixFxAudio(current.sfx, current.duration) : undefined + const mixedFx = await mixFxAudio(current.sfx, current.duration) + const fxAudio = mixedFx && (await supportsSceneAac(mixedFx.numberOfChannels >= 2 ? 2 : 1)) ? mixedFx : undefined const target = new ArrayBufferTarget() const muxer = new Muxer({ target, - ...(fxAudio ? { audio: { codec: 'aac' as const, sampleRate: fxAudio.sampleRate, numberOfChannels: 1 } } : {}), + ...(fxAudio ? { audio: { codec: 'aac' as const, sampleRate: fxAudio.sampleRate, numberOfChannels: Math.min(2, Math.max(1, fxAudio.numberOfChannels || 1)) } } : {}), video: { codec: 'avc', width: current.width, height: current.height, frameRate: fps }, fastStart: 'in-memory', firstTimestampBehavior: 'strict', @@ -2192,12 +2210,13 @@ export function SceneAnimatorPanel() { updateScene(() => adopted.document) return } - sessionStorage.setItem('hocuspocus:scene-before-command:' + Date.now(), JSON.stringify(sceneRef.current)) + sceneRecovery.current.backup(sessionStorage, workspace, sceneRef.current) if (!importScene(JSON.stringify(adopted.document))) throw new Error('The prepared 2D scene could not be opened.') }) const publishRecording = async (blob: Blob, current: Scene) => { const context = recipeContextRef.current - const buffer = !(await supportsSceneAac()) ? await mixFxAudio(current.sfx, current.duration) : undefined + const mixedPublish = await mixFxAudio(current.sfx, current.duration) + const buffer = mixedPublish && !(await supportsSceneAac(mixedPublish.numberOfChannels >= 2 ? 2 : 1)) ? mixedPublish : undefined const serverAudio = buffer ? sceneAudioWav(buffer) : undefined const saved = await saveSceneRecording(blob, { scene: current, @@ -2225,6 +2244,7 @@ export function SceneAnimatorPanel() { .finally(() => setPublishing(false)) } const waitForModelViewers = async () => { + await waitForSceneImages(canvasRef.current, sceneRef.current.layers) const root = canvasRef.current if (!root) return const deadline = Date.now() + 25000 @@ -2296,6 +2316,7 @@ export function SceneAnimatorPanel() { })) const persisted = { ...current, layers } const saved = await saveSceneOutput(persisted, preview.toDataURL('image/png'), workspace) + sceneRecovery.current.markSaved(workspace, persisted) replaceScene(persisted); localFilesRef.current = {}; await loadOutputs() setMessage(t('animator.sceneSaved', { name: saved.name })) return saved.name @@ -2453,7 +2474,7 @@ export function SceneAnimatorPanel() { const sendImageToPanoramaLoop = () => { if (!selected || selected.type !== 'image' || !selected.source) return window.sessionStorage.setItem('hocuspocus:panorama-loop-source', JSON.stringify({ url: selected.source, name: selected.name })) - setGenerationMode('image'); setSidebarMode('studio'); setSidebarOpen(true) + setGenerationMode('image'); setSidebarMode('studio'); setMediaFilter('images'); setSidebarOpen(true) } const attachSceneAudio = (filename: string, name = filename, kind: 'speech' | 'music' | 'sfx' | 'audio' = 'audio', prompt?: string, model?: string) => { if (!filename) return @@ -2856,24 +2877,20 @@ export function SceneAnimatorPanel() { if (!segments.length) throw new Error('No spoken regions were found in this track.') // Use actual word boundaries whenever Whisper provides them. Older // analyses remain valid: they fall back to one plan per segment. - const units = segments.flatMap(segment => segment.words?.length + const units = normalizeAlignedCutoutUnits(segments.flatMap(segment => segment.words?.length ? segment.words.map(word => ({ text: word.text, start: word.start, end: word.end })) - : [{ text: segment.text, start: segment.start, end: segment.end }]) - .filter(unit => unit.end > unit.start && unit.start + track.startTime < scene.duration) - const plans = units.map(unit => planCutoutDialogue(unit.text, Math.max(0, unit.start + track.startTime), Math.min(scene.duration, unit.end + track.startTime), fps)) - const framesByLayer: Record = {} - for (const plan of plans) { - const next = applyCutoutDialogue(mouthLayers, plan) - for (const [layerId, frames] of Object.entries(next)) framesByLayer[layerId] = [...(framesByLayer[layerId] ?? []), ...frames] - } - const beatIds = plans.map(() => uid()) + : [{ text: segment.text, start: segment.start, end: segment.end }]), track.startTime, scene.duration) + if (!units.length) throw new Error('No spoken regions were found in this track.') + const plan = planAlignedCutoutDialogue(units, fps) + const framesByLayer = applyCutoutDialogue(mouthLayers, plan) + const beatIds = units.map(() => uid()) updateScene(current => ({ ...current, layers: current.layers.map(layer => framesByLayer[layer.id] ? { ...layer, animation: { ...layer.animation, keyframes: framesByLayer[layer.id], duration: current.duration, curve: 'hold' } } : layer), - dialogueBeats: [...(current.dialogueBeats ?? []).filter(beat => !beat.mouthLayerIds.some(id => Object.keys(framesByLayer).includes(id))), ...plans.map((plan, index) => ({ id: beatIds[index], text: units[index].text, start: plan.start, end: plan.end, mouthLayerIds: Object.keys(framesByLayer), audioTrackId: track.id, confidence: 'aligned-audio' as const }))], + dialogueBeats: [...(current.dialogueBeats ?? []).filter(beat => !beat.mouthLayerIds.some(id => Object.keys(framesByLayer).includes(id))), ...units.map((unit, index) => ({ id: beatIds[index], ...unit, mouthLayerIds: Object.keys(framesByLayer), audioTrackId: track.id, confidence: 'aligned-audio' as const }))], })) - setCutoutDialogueText(segments.map(segment => segment.text).join(' ')); setCutoutDialogueStart(plans[0].start); setCutoutDialogueEnd(plans.at(-1)!.end) - setSelectedId(primary.id); setProgress(plans[0].start / scene.duration) + setCutoutDialogueText(segments.map(segment => segment.text).join(' ')); setCutoutDialogueStart(plan.start); setCutoutDialogueEnd(plan.end) + setSelectedId(primary.id); setProgress(plan.start / scene.duration) setMessage(t('animator.detectedSpeech', { count: units.length, name: track.name })) } catch (error) { setMessage(error instanceof Error ? error.message : t('animator.speechAnalyzeFailed')) @@ -3058,7 +3075,7 @@ export function SceneAnimatorPanel() {
updateScene(current => ({ ...current, name: event.target.value }))} aria-label={t('animator.sceneNameAria')} className="w-44 rounded border border-transparent bg-transparent px-1 py-0.5 text-xs font-medium hover:border-border focus:border-accent-blue focus:outline-none" />{scene.width}×{scene.height}
{lastAutosaveAt ? t('animator.autosaved', { time: new Date(lastAutosaveAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) }) : t('animator.autosaveWaiting')}
-
{RESOLUTIONS.map(([label, width, height]) => )}{t('animator.frameRate')}{([30, 60] as SceneFrameRate[]).map(rate => )}
+
{RESOLUTIONS.map(([label, width, height]) => )}{t('animator.frameRate')}{([24, 30, 60] as SceneFrameRate[]).map(rate => )}
@@ -3075,8 +3092,9 @@ export function SceneAnimatorPanel() { {(composition.safeArea === 'action' || composition.safeArea === 'all') &&
{t('animator.actionSafeBadge')}
} {(composition.safeArea === 'title' || composition.safeArea === 'all') &&
{t('animator.titleSafeBadge')}
} {(composition.safeArea === 'vertical' || composition.safeArea === 'all') &&
{t('animator.verticalBadge')}
} - - + {retroLive &&
- {isDirector ? : studioControls} - - - - ) - } - - if (toolsCollapsed) { - return ( - - ) - } - - // Desktop: static sidebar return ( - +
) } + +export function Sidebar() { + return +} diff --git a/ui/src/components/Sidebar/WorkspaceEventBridge.tsx b/ui/src/components/Sidebar/WorkspaceEventBridge.tsx new file mode 100644 index 000000000..ab1ecba13 --- /dev/null +++ b/ui/src/components/Sidebar/WorkspaceEventBridge.tsx @@ -0,0 +1,61 @@ +import { useEffect } from 'react' +import { useStore } from '../../stores/useStore' +import { revealDirectorWorkspace } from '../../lib/navigationCategories' + +function setToolsSidebarCollapsed(collapsed: boolean) { + window.localStorage.setItem('hocuspocus-tools-sidebar-collapsed', String(collapsed)) +} + +/** Always-mounted host for navigation events. Direct generation and Director + * only mount while their workspace is visible, so these listeners cannot + * live in those panels. */ +export function WorkspaceEventBridge() { + const setSidebarOpen = useStore(s => s.setSidebarOpen) + const setSidebarMode = useStore(s => s.setSidebarMode) + const setSettingsOpen = useStore(s => s.setSettingsOpen) + const setDashboardOpen = useStore(s => s.setDashboardOpen) + + useEffect(() => { + const openImageSubmission = () => { + setToolsSidebarCollapsed(false) + setSidebarOpen(true) + } + const openSpeechSubmission = () => { + setToolsSidebarCollapsed(false) + setSidebarOpen(true) + } + window.addEventListener('hocuspocus:studio-image-open', openImageSubmission) + window.addEventListener('hocuspocus:studio-speech-open', openSpeechSubmission) + return () => { + window.removeEventListener('hocuspocus:studio-image-open', openImageSubmission) + window.removeEventListener('hocuspocus:studio-speech-open', openSpeechSubmission) + } + }, [setSidebarOpen]) + + useEffect(() => { + const openStudio = () => { + setSidebarMode('studio') + setToolsSidebarCollapsed(false) + setSidebarOpen(true) + } + const openSettings = () => { + setDashboardOpen(false) + setSidebarOpen(false) + setSettingsOpen(true) + } + const openDirector = () => { + revealDirectorWorkspace(useStore.getState()) + setToolsSidebarCollapsed(false) + } + window.addEventListener('hocuspocus:studio-open', openStudio) + window.addEventListener('hocuspocus:settings-open', openSettings) + window.addEventListener('maestro:director-open', openDirector) + return () => { + window.removeEventListener('hocuspocus:studio-open', openStudio) + window.removeEventListener('hocuspocus:settings-open', openSettings) + window.removeEventListener('maestro:director-open', openDirector) + } + }, [setDashboardOpen, setSettingsOpen, setSidebarMode, setSidebarOpen]) + + return null +} diff --git a/ui/src/components/common/KineticTextControls.tsx b/ui/src/components/common/KineticTextControls.tsx index ea2558526..6d0814f73 100644 --- a/ui/src/components/common/KineticTextControls.tsx +++ b/ui/src/components/common/KineticTextControls.tsx @@ -1,5 +1,6 @@ import { useUiTranslation } from '../../i18n' import { KINETIC_PRESETS, parseKineticTexts, type KineticText } from '../../lib/kineticText' +import { randomUuid } from '../../lib/uuid' export function KineticTextControls({ cues = [], duration, disabled, onChange }: { cues?: KineticText[]; duration: number; disabled?: boolean; onChange: (cues: KineticText[]) => void @@ -22,7 +23,7 @@ export function KineticTextControls({ cues = [], duration, disabled, onChange }:
)} - + } diff --git a/ui/src/features/activity/ActivityCompactBar.tsx b/ui/src/features/activity/ActivityCompactBar.tsx new file mode 100644 index 000000000..6da5867d1 --- /dev/null +++ b/ui/src/features/activity/ActivityCompactBar.tsx @@ -0,0 +1,243 @@ +import { AlertCircle, CheckCircle2, ChevronDown, ChevronUp, CircleSlash2, ListVideo, Loader2 } from 'lucide-react' +import type { RefObject } from 'react' +import type { CanonicalTask } from '../../api/client' +import { canonicalTaskVisualState } from '../../lib/canonicalTaskEvents' +import { useStore } from '../../stores/useStore' +import { useUiTranslation } from '../../i18n' +import type { ActivityGroup, ActivityTaskLike } from './lineage' +import { isLiveStatus, taskProgressPercent } from './lineage' +import type { TaskControlAction } from './executionDetail' +import { + estimatedRemainingSeconds, + formatElapsed, + formatEta, + generationInitiator, + generationPrompt, + generationRecipe, + truncatePrompt, +} from './taskPresentation' +import { translatedPhase as phaseText } from './taskPresentation' + +interface ActivityCompactBarProps { + detailsOpen: boolean + liveCount: number + clock: number + primary?: CanonicalTask + primaryGroup: ActivityGroup | null + busyIds: Set + toggleRef: RefObject + onToggle: () => void + onCopyPrompt: (task: CanonicalTask) => void + onControl: (task: CanonicalTask, action: TaskControlAction) => void +} + +function ToggleIcon({ isActive, hasError, visual }: { isActive: boolean; hasError: boolean; visual: string }) { + if (isActive) return + if (hasError) return + if (visual === 'cancelled') return + return +} + +type Translate = (key: string, options?: object) => string + +function asTranslate(t: unknown): Translate { + return t as Translate +} + +function CompactSubtask({ child, clock, t }: { child?: ActivityTaskLike; clock: number; t: Translate }) { + if (!child) return null + const eta = formatEta(estimatedRemainingSeconds(child, clock)) + return ( + + {t('subtask', { phase: phaseText(t, child) })} + {eta ? ` · ${t('eta', { value: eta })}` : ''} + + ) +} + +function CompactToggle({ + detailsOpen, + liveCount, + isActive, + hasError, + visual, + toggleRef, + onToggle, + t, +}: { + detailsOpen: boolean + liveCount: number + isActive: boolean + hasError: boolean + visual: string + toggleRef: RefObject + onToggle: () => void + t: Translate +}) { + return ( + + ) +} + +function CompactSummary({ + primary, + clock, + t, + onCopyPrompt, +}: { + primary?: CanonicalTask + clock: number + t: Translate + onCopyPrompt: (task: CanonicalTask) => void +}) { + if (!primary) return null + const eta = formatEta(estimatedRemainingSeconds(primary, clock)) + const prompt = generationPrompt(primary) + const initiator = generationInitiator(primary) + return ( + <> + {phaseText(t, primary)} + {formatElapsed(primary, clock)} + {eta ? {t('eta', { value: eta })} : null} + {primary.model ? {primary.model} : null} + {initiator ? {initiator} : null} + {prompt ? ( + + ) : null} + + ) +} + +function CompactProgress({ primary }: { primary?: CanonicalTask }) { + if (!primary) return null + const percent = taskProgressPercent(primary) + const label = primary.total > 0 ? `${primary.current}/${primary.total}` : `${Math.round(percent)}%` + return ( +
+
+
0 ? 2 : 0)}%` }} /> +
+ {label} +
+ ) +} + +function CompactCancel({ + primary, + busyIds, + onControl, + t, + tCommon, +}: { + primary?: CanonicalTask + busyIds: Set + onControl: (task: CanonicalTask, action: TaskControlAction) => void + t: Translate + tCommon: Translate +}) { + if (!primary) return null + if (!isLiveStatus(primary.status)) return null + if (!primary.cancelable) return null + const busy = busyIds.has(primary.id) + return ( + + ) +} + +function compactHasError(isActive: boolean, primary?: CanonicalTask, primaryGroup: ActivityGroup | null = null): boolean { + if (isActive) return false + if (primary?.status === 'failed') return true + if (primary?.status === 'interrupted') return true + return primaryGroup?.readingState === 'failed' +} + +function compactMessage(primary: CanonicalTask | undefined, fallback: string): string { + if (primary?.error?.message) return primary.error.message + if (primary?.detail) return primary.detail + if (primary?.message) return primary.message + return fallback +} + +function compactVisual(primary?: CanonicalTask): string { + if (!primary) return 'neutral' + return canonicalTaskVisualState(primary.status) +} + +function liveChild(group: ActivityGroup | null, primary?: CanonicalTask): ActivityTaskLike | undefined { + if (!group) return undefined + return group.jobs.map(job => job.task).find(child => isLiveStatus(child.status) && child.id !== primary?.id) +} + +function CompactWorkspaces({ t }: { t: Translate }) { + const setVideoWorkflowsOpen = useStore(state => state.setDashboardOpen) + return ( + + ) +} + +export function ActivityCompactBar({ + detailsOpen, + liveCount, + clock, + primary, + primaryGroup, + busyIds, + toggleRef, + onToggle, + onCopyPrompt, + onControl, +}: ActivityCompactBarProps) { + const { t: tCommonRaw } = useUiTranslation('common') + const { t: tActivityRaw } = useUiTranslation('activity') + const tCommon = asTranslate(tCommonRaw) + const tActivity = asTranslate(tActivityRaw) + const isActive = liveCount > 0 + const hasError = compactHasError(isActive, primary, primaryGroup) + const message = compactMessage(primary, tActivity('ready')) + const messageClass = hasError ? 'text-red-400' : isActive ? 'text-text-secondary' : 'text-text-muted' + return ( + <> + +
+ + + {message} +
+ {isActive ? : null} + + + + ) +} diff --git a/ui/src/features/activity/ActivityDetailsPanel.tsx b/ui/src/features/activity/ActivityDetailsPanel.tsx new file mode 100644 index 000000000..c12ed94ba --- /dev/null +++ b/ui/src/features/activity/ActivityDetailsPanel.tsx @@ -0,0 +1,111 @@ +import { Eraser } from 'lucide-react' +import { createPortal } from 'react-dom' +import type { RefObject } from 'react' +import type { CanonicalTask } from '../../api/client' +import { useUiTranslation } from '../../i18n' +import { ActivityExecutionDetail, type TaskControlAction, type TaskControlFailure } from './executionDetail' +import type { ActivityGroup } from './lineage' +import { openActivityArtifact, openActivityProject } from './openTargets' + +interface ActivityDetailsPanelProps { + open: boolean + groups: ActivityGroup[] + liveCount: number + historicalCount: number + clock: number + selectedGroupId: string | null + expandedGroupIds: Set + inspectedAttemptByGroup: Record + busyIds: Set + controlFailures: Record + panelNode: RefObject + onClose: () => void + onClearHistory: () => void + onSelect: (groupId: string) => void + onToggleExpand: (groupId: string) => void + onInspectPrevious: (group: ActivityGroup) => void + onControl: (task: CanonicalTask, action: TaskControlAction) => void + onCopyId: (task: CanonicalTask) => void + onCopyPrompt: (task: CanonicalTask) => void +} + +export function ActivityDetailsPanel({ + open, + groups, + liveCount, + historicalCount, + clock, + selectedGroupId, + expandedGroupIds, + inspectedAttemptByGroup, + busyIds, + controlFailures, + panelNode, + onClose, + onClearHistory, + onSelect, + onToggleExpand, + onInspectPrevious, + onControl, + onCopyId, + onCopyPrompt, +}: ActivityDetailsPanelProps) { + const { t: tCommon } = useUiTranslation('common') + const { t: tActivity } = useUiTranslation('activity') + if (!open) return null + if (!groups.length) return null + return createPortal( + , + document.body, + ) +} diff --git a/ui/src/features/activity/activityHistory.ts b/ui/src/features/activity/activityHistory.ts new file mode 100644 index 000000000..4642a4850 --- /dev/null +++ b/ui/src/features/activity/activityHistory.ts @@ -0,0 +1,27 @@ +const HIDDEN_HISTORY_STORAGE_PREFIX = 'maestro-activity-hidden-v1:' + +export function hiddenHistoryStorageKey(workspace: string): string { + return `${HIDDEN_HISTORY_STORAGE_PREFIX}${workspace}` +} + +export function readHiddenHistory(workspace: string): Set { + try { + const raw = window.localStorage.getItem(hiddenHistoryStorageKey(workspace)) + const parsed = raw ? JSON.parse(raw) : [] + if (!Array.isArray(parsed)) return new Set() + return new Set(parsed.filter(value => typeof value === 'string')) + } catch { + return new Set() + } +} + +export function writeHiddenHistory(workspace: string, ids: Set): void { + try { + const key = hiddenHistoryStorageKey(workspace) + if (ids.size) window.localStorage.setItem(key, JSON.stringify([...ids])) + else window.localStorage.removeItem(key) + } catch { + // Hiding history is still useful for the current session if storage is + // blocked (private browsing, disabled cookies, or a quota error). + } +} diff --git a/ui/src/features/activity/executionDetail.tsx b/ui/src/features/activity/executionDetail.tsx new file mode 100644 index 000000000..f087cb0d2 --- /dev/null +++ b/ui/src/features/activity/executionDetail.tsx @@ -0,0 +1,574 @@ +import { AlertCircle, CheckCircle2, CircleSlash2, Copy, Loader2 } from 'lucide-react' +import { canResumeCanonicalTask, canonicalTaskVisualState } from '../../lib/canonicalTaskEvents' +import { formatAppAction, formatAppTimestamp } from '../../lib/locale' +import { useUiTranslation } from '../../i18n' +import type { CanonicalTask } from '../../api/client' +import { + isLiveStatus, + taskProgressPercent, + type ActivityAttempt, + type ActivityGroup, + type ActivityJob, + type ActivityReadingState, + type ActivityTaskLike, +} from './lineage' +import { + estimatedRemainingSeconds, + formatElapsed, + formatEta, + generationInitiator, + generationPrompt, + generationRecipe, + resourceSummary, + translatedPhase, + truncatePrompt, +} from './taskPresentation' + +export type TaskControlAction = 'cancel' | 'resume' | 'dismiss' +export interface TaskControlFailure { + action: TaskControlAction + message: string +} + +interface ActivityExecutionDetailProps { + group: ActivityGroup + clock: number + selected: boolean + expanded: boolean + inspectedAttemptId?: string + busyIds: Set + controlFailures: Record + onSelect: () => void + onToggleExpand: () => void + onInspectPrevious: () => void + onControl: (task: CanonicalTask, action: TaskControlAction) => void + onCopyId: (task: CanonicalTask) => void + onCopyPrompt: (task: CanonicalTask) => void + onOpenArtifact: (name: string) => void + onOpenProject: () => void +} + +type Translate = (key: string, options?: object) => string + +function asTranslate(t: unknown): Translate { + return t as Translate +} + +function readingClass(state: ActivityReadingState): string { + if (state === 'failed') return 'text-red-400' + if (state === 'running') return 'text-accent-blue' + if (state === 'partial') return 'text-amber-300' + if (state === 'completed') return 'text-emerald-400' + if (state === 'admitted') return 'text-violet-300' + if (state === 'prepared') return 'text-violet-300' + return 'text-text-muted' +} + +function StatusIcon({ status }: { status: string }) { + const visual = canonicalTaskVisualState(status) + if (visual === 'active') return + if (visual === 'error') return + if (visual === 'cancelled') return + return +} + +function phaseText(t: Translate, task: ActivityTaskLike): string { + return translatedPhase(t, task) +} + +function AttemptRow({ attempt, inspected }: { attempt: ActivityAttempt; inspected: boolean }) { + const { t: tRaw } = useUiTranslation('activity') + const t = asTranslate(tRaw) + const extra = attempt.error || attempt.message + return ( +

+ {t('lineage.previousAttempt', { n: attempt.attempt })} + {extra ? ` · ${extra}` : ''} + {attempt.resultRefs.length ? ` · ${attempt.resultRefs.join(', ')}` : ''} +

+ ) +} + +function EtaSuffix({ task, clock, t }: { task: ActivityTaskLike; clock: number; t: Translate }) { + if (!isLiveStatus(task.status)) return null + const eta = formatEta(estimatedRemainingSeconds(task, clock)) + if (!eta) return null + return <>{` · ${t('eta', { value: eta })}`} +} + +function TokenSpan({ task, t }: { task: ActivityTaskLike; t: Translate }) { + if (!task.token_usage?.total) return null + return ( + + {t('tokens', { + total: task.token_usage.total.toLocaleString(), + prompt: task.token_usage.prompt || 0, + completion: task.token_usage.completion || 0, + })} + + ) +} + +function JobHeadline({ job, clock, t }: { job: ActivityJob; clock: number; t: Translate }) { + const child = job.task + return ( +

+ {t(`lineage.reading.${job.readingState}`)} + {' · '} + {phaseText(t, child)} + {' · '} + {formatElapsed(child, clock)} + {' · '} + {child.message} + +

+ ) +} + +function JobMeta({ + child, + t, + onCopyId, +}: { + child: ActivityTaskLike + t: Translate + onCopyId: (task: CanonicalTask) => void +}) { + const recipe = generationRecipe(child) + const resources = resourceSummary(child) + const initiator = generationInitiator(child) + return ( +

+ {recipe ? {recipe} : null} + {initiator ? {t('startedBy', { name: initiator })} : null} + {child.server_origin ? {t('server', { origin: child.server_origin })} : null} + {resources ? {t(`resources.${resources.kind}`, { value: resources.value })} : null} + {t('attempt', { current: child.attempt || 1, max: child.max_attempts || 1 })} + + +

+ ) +} + +function JobPrompt({ + child, + t, + onCopyPrompt, +}: { + child: ActivityTaskLike + t: Translate + onCopyPrompt: (task: CanonicalTask) => void +}) { + const prompt = generationPrompt(child) + if (!prompt) return null + return ( + + ) +} + +function JobRow({ + job, + clock, + inspectedAttemptId, + onCopyId, + onCopyPrompt, +}: { + job: ActivityJob + clock: number + inspectedAttemptId?: string + onCopyId: (task: CanonicalTask) => void + onCopyPrompt: (task: CanonicalTask) => void +}) { + const { t: tRaw } = useUiTranslation('activity') + const t = asTranslate(tRaw) + const child = job.task + const previous = job.attempts.filter(attempt => attempt.id !== `${child.id}:${child.attempt || 1}`) + return ( +
+ + + + {previous.map(attempt => ( + + ))} +
+ ) +} + +function GroupTaskControls({ + task, + active, + busyIds, + onControl, + t, + tCommon, +}: { + task: CanonicalTask + active: boolean + busyIds: Set + onControl: (task: CanonicalTask, action: TaskControlAction) => void + t: Translate + tCommon: Translate +}) { + if (active && task.cancelable) { + return ( + + ) + } + if (!active && canResumeCanonicalTask(task)) { + return ( + + ) + } + if (!active) { + return ( + + ) + } + return null +} + +function GroupTitleRow({ + group, + task, + clock, + active, + busyIds, + onSelect, + onControl, + t, + tCommon, +}: { + group: ActivityGroup + task: CanonicalTask + clock: number + active: boolean + busyIds: Set + onSelect: () => void + onControl: (task: CanonicalTask, action: TaskControlAction) => void + t: Translate + tCommon: Translate +}) { + const updatedAt = formatAppTimestamp(task.updated_at) + const taskEta = formatEta(estimatedRemainingSeconds(task, clock)) + return ( +
+ +
+ {t(`lineage.reading.${group.readingState}`)} + {formatElapsed(task, clock)} + {active && taskEta ? {t('eta', { value: taskEta })} : null} + {updatedAt ? {updatedAt} : null} + {phaseText(t, task)} + +
+
+ ) +} + +function GroupPrompt({ task, t, onCopyPrompt }: { task: CanonicalTask; t: Translate; onCopyPrompt: (task: CanonicalTask) => void }) { + const prompt = generationPrompt(task) + if (!prompt) return null + return ( +
+ {t('prompt')} + + +
+ ) +} + +function GroupActiveChild({ child, clock, t }: { child?: ActivityTaskLike; clock: number; t: Translate }) { + if (!child) return null + const eta = formatEta(estimatedRemainingSeconds(child, clock)) + return ( +

+ {t('activeSubtask', { phase: phaseText(t, child) })} + {eta ? ` · ${t('eta', { value: eta })}` : ''} +

+ ) +} + +function GroupControlFailure({ + task, + failure, + busyIds, + onControl, + t, + tCommon, +}: { + task: CanonicalTask + failure?: TaskControlFailure + busyIds: Set + onControl: (task: CanonicalTask, action: TaskControlAction) => void + t: Translate + tCommon: Translate +}) { + if (!failure) return null + return ( +
+ {t('controlFailed', { action: failure.action[0].toUpperCase() + failure.action.slice(1), message: failure.message })} + +
+ ) +} + +function GroupIdentity({ task, t, onCopyId }: { task: CanonicalTask; t: Translate; onCopyId: (task: CanonicalTask) => void }) { + return ( +

+ {task.server_origin ? {t('server', { origin: task.server_origin })} : null} + {t('attempt', { current: task.attempt, max: task.max_attempts })} + + +

+ ) +} + +function GroupActions({ + group, + inspected, + expanded, + onOpenArtifact, + onOpenProject, + onInspectPrevious, + onToggleExpand, + t, +}: { + group: ActivityGroup + inspected?: ActivityAttempt + expanded: boolean + onOpenArtifact: (name: string) => void + onOpenProject: () => void + onInspectPrevious: () => void + onToggleExpand: () => void + t: Translate +}) { + const canToggle = group.jobs.length > 1 || Boolean(group.previousAttempt) || group.artifacts.length > 0 + return ( +
+ {group.artifacts.map(name => ( + + ))} + {group.readingState === 'admitted' && !group.hasArtifact ? {t('lineage.admittedWaiting')} : null} + {group.project ? ( + + ) : null} + {group.previousAttempt ? ( + + ) : null} + {canToggle ? ( + + ) : null} +
+ ) +} + +function GroupProgressBar({ task, active }: { task: CanonicalTask; active: boolean }) { + if (!active) return null + const percent = taskProgressPercent(task) + const label = task.total > 0 ? `${task.current}/${task.total}` : `${Math.round(percent)}%` + return ( +
+
+
0 ? 2 : 0)}%` }} /> +
+ {label} +
+ ) +} + +function inspectedAttempt(group: ActivityGroup, inspectedAttemptId?: string): ActivityAttempt | undefined { + if (group.previousAttempt && group.previousAttempt.id === inspectedAttemptId) return group.previousAttempt + return group.jobs.flatMap(job => job.attempts).find(attempt => attempt.id === inspectedAttemptId) +} + +function GroupCopy({ + recipe, + initiator, + resources, + t, +}: { + recipe: string + initiator: string + resources: ReturnType + t: Translate +}) { + return ( + <> + {recipe ?

{recipe}

: null} + {initiator ?

{t('startedBy', { name: initiator })}

: null} + {resources ?

{t(`resources.${resources.kind}`, { value: resources.value })}

: null} + + ) +} + +function GroupChildren({ + jobs, + clock, + inspectedAttemptId, + onCopyId, + onCopyPrompt, +}: { + jobs: ActivityJob[] + clock: number + inspectedAttemptId?: string + onCopyId: (task: CanonicalTask) => void + onCopyPrompt: (task: CanonicalTask) => void +}) { + if (!jobs.length) return null + return ( +
+ {jobs.map(job => ( + + ))} +
+ ) +} + +function GroupPrevious({ expanded, inspected }: { expanded: boolean; inspected?: ActivityAttempt }) { + if (!expanded) return null + if (!inspected) return null + return ( +
+ +
+ ) +} + +function GroupBody(props: ActivityExecutionDetailProps & { task: CanonicalTask; t: Translate; tCommon: Translate }) { + const { task, t, tCommon } = props + const children = props.group.jobs.filter(job => job.id !== task.id) + const activeChild = props.group.jobs.map(job => job.task).find(child => isLiveStatus(child.status) && child.id !== task.id) + const active = isLiveStatus(task.status) + const inspected = inspectedAttempt(props.group, props.inspectedAttemptId) + const failed = task.status === 'failed' || task.status === 'interrupted' + return ( +
+ +

+ {t('lineage.progressLabel')} {Math.round(props.group.progress)}% + {' · '} + {t('lineage.resultLabel')} {t(`lineage.reading.${props.group.readingState}`)} + {props.group.jobs.length > 1 ? ` · ${t('lineage.jobs', { count: props.group.jobs.length })}` : ''} +

+

+ {task.error?.message || task.detail || task.message} +

+ + + {active ? : null} + {props.group.recoveryReason ?

{t('lineage.recoveryReason', { reason: props.group.recoveryReason })}

: null} + + + + + + +
+ ) +} + +export function ActivityExecutionDetail(props: ActivityExecutionDetailProps) { + const { t: tRaw } = useUiTranslation('activity') + const { t: tCommonRaw } = useUiTranslation('common') + const t = asTranslate(tRaw) + const tCommon = asTranslate(tCommonRaw) + const task = props.group.primary as CanonicalTask + const border = props.selected ? 'border-accent-blue/70' : 'border-border' + return ( +
+
+ + +
+
+ ) +} diff --git a/ui/src/features/activity/lineage.ts b/ui/src/features/activity/lineage.ts new file mode 100644 index 000000000..9cdb678a7 --- /dev/null +++ b/ui/src/features/activity/lineage.ts @@ -0,0 +1,506 @@ +export const LIVE_TASK_STATUSES = new Set(['created', 'queued', 'waiting_resource', 'running']) +export const FAILED_TASK_STATUSES = new Set(['failed', 'interrupted', 'cancelled']) + +export type ActivityReadingState = + | 'prepared' + | 'admitted' + | 'running' + | 'failed' + | 'partial' + | 'completed' + +export interface ActivityTaskLike { + id: string + root_id: string + parent_id?: string | null + kind?: string + title?: string + workflow?: string + status: string + phase?: string + message?: string + detail?: string + current?: number + total?: number + progress?: number + created_at: number + queued_at?: number | null + started_at?: number | null + updated_at: number + completed_at?: number | null + attempt?: number + max_attempts?: number + backend_job_id?: string + pipeline_id?: string + result_refs?: string[] + error?: { message?: string; retryable?: boolean } | null + metadata?: Record + workspace?: string + resumable?: boolean + recoverable?: boolean + cancelable?: boolean + provider?: string + model?: string + resource_requirements?: string[] + acquired_resources?: string[] + server_origin?: string + token_usage?: { prompt?: number; completion?: number; total?: number; calls?: number } +} + +export interface ActivityAttempt { + id: string + taskId: string + attempt: number + readingState: ActivityReadingState + status: string + message: string + error: string + resultRefs: string[] + createdAt: number + updatedAt: number +} + +export interface ActivityJob { + id: string + title: string + readingState: ActivityReadingState + task: ActivityTaskLike + attempts: ActivityAttempt[] +} + +export interface ActivityProjectTarget { + kind: string + id: string + title: string +} + +export interface ActivityGroup { + id: string + intentId: string + receiptId: string + rootId: string + workspace: string + title: string + readingState: ActivityReadingState + progress: number + hasArtifact: boolean + createdAt: number + primary: ActivityTaskLike + jobs: ActivityJob[] + artifacts: string[] + previousAttempt?: ActivityAttempt + recoveryReason: string + project?: ActivityProjectTarget +} + +export interface ActivityFocusRequest { + taskId?: string + intentId?: string + receiptId?: string + inspectPreviousAttempt?: boolean +} + +export interface ActivityChrome { + selectedId: string | null + expandedIds: readonly string[] + inspectedAttemptByGroup: Readonly> +} + +const ARTIFACT_KIND = /generat|image|video|audio|render|export|speech|music|sfx|tool|model/ + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value) +} + +function text(value: unknown): string { + return typeof value === 'string' && value.trim() ? value.trim() : '' +} + +function metadataOf(task: ActivityTaskLike): Record { + return isRecord(task.metadata) ? task.metadata : {} +} + +export function taskIntentId(task: ActivityTaskLike): string { + const metadata = metadataOf(task) + const receipt = isRecord(metadata.receipt) ? metadata.receipt : {} + const command = isRecord(metadata.command) ? metadata.command : {} + return text(metadata.intent_id) + || text(metadata.intentId) + || text(metadata.command_id) + || text(metadata.commandId) + || text(receipt.commandId) + || text(receipt.intent_id) + || text(command.command_id) + || text(command.commandId) +} + +export function taskReceiptId(task: ActivityTaskLike): string { + const metadata = metadataOf(task) + const receipt = isRecord(metadata.receipt) ? metadata.receipt : {} + return text(metadata.receipt_id) + || text(metadata.receiptId) + || text(receipt.commandId) + || taskIntentId(task) +} + +export function taskWorkspace(task: ActivityTaskLike): string { + const metadata = metadataOf(task) + return text(task.workspace) || text(metadata.workspace) || text(metadata.workspace_id) +} + +export function taskArtifactRefs(task: ActivityTaskLike): string[] { + const metadata = metadataOf(task) + const fromTask = Array.isArray(task.result_refs) + ? task.result_refs.filter((item): item is string => typeof item === 'string' && Boolean(item.trim())) + : [] + const extra = [metadata.artifact_ids, metadata.output_files, metadata.outputNames] + .flatMap(value => Array.isArray(value) ? value : []) + .filter((item): item is string => typeof item === 'string' && Boolean(item.trim())) + return [...new Set([...fromTask, ...extra])] +} + +export function taskExpectsArtifact(task: ActivityTaskLike): boolean { + const metadata = metadataOf(task) + if (metadata.expects_artifact === false || metadata.expectsArtifact === false) return false + if (metadata.expects_artifact === true || metadata.expectsArtifact === true) return true + const kind = `${task.kind || ''} ${task.workflow || ''}`.toLowerCase() + return ARTIFACT_KIND.test(kind) +} + +export function isLiveStatus(status: string): boolean { + return LIVE_TASK_STATUSES.has(status) +} + +export function taskHasArtifact(task: ActivityTaskLike): boolean { + return taskArtifactRefs(task).length > 0 +} + +export function taskReadingState(task: ActivityTaskLike): ActivityReadingState { + if (FAILED_TASK_STATUSES.has(task.status)) return 'failed' + if (task.status === 'running' || task.status === 'waiting_resource') return 'running' + if (task.status === 'queued') return 'admitted' + if (task.status === 'created') { + return task.backend_job_id || taskIntentId(task) ? 'admitted' : 'prepared' + } + if (task.status === 'completed') { + if (taskExpectsArtifact(task) && !taskHasArtifact(task)) return 'partial' + return 'completed' + } + return 'admitted' +} + +export function taskProgressPercent(task: ActivityTaskLike): number { + const total = Number(task.total || 0) + const current = Number(task.current || 0) + if (total > 0) return Math.max(0, Math.min(100, (current / total) * 100)) + return Math.max(0, Math.min(100, Number(task.progress || 0) * 100)) +} + +function uniqueTasks(tasks: ActivityTaskLike[]): ActivityTaskLike[] { + const byId = new Map() + for (const task of tasks) byId.set(task.id, task) + return [...byId.values()] +} + +function rootOf(task: ActivityTaskLike, byId: Map): ActivityTaskLike { + const seen = new Set() + let cursor = task + while (cursor.parent_id && byId.has(cursor.parent_id) && !seen.has(cursor.id)) { + seen.add(cursor.id) + cursor = byId.get(cursor.parent_id) as ActivityTaskLike + } + return byId.get(cursor.root_id) || cursor +} + +export function activityRequestKey(task: ActivityTaskLike, byId: Map): string { + const root = rootOf(task, byId) + const intent = taskIntentId(root) || taskIntentId(task) + if (intent) return `intent:${intent}` + return `root:${root.root_id || root.id}` +} + +function firstText(...values: unknown[]): string { + for (const value of values) { + const next = text(value) + if (next) return next + } + return '' +} + +function finiteNumber(value: unknown, fallback: number): number { + const parsed = Number(value) + if (Number.isFinite(parsed)) return parsed + return fallback +} + +const READING_STATES = new Set(['prepared', 'admitted', 'running', 'failed', 'partial', 'completed']) + +function asReadingState(value: string): ActivityReadingState | undefined { + if (READING_STATES.has(value)) return value as ActivityReadingState + return undefined +} + +function failedDetail(task: ActivityTaskLike): string { + if (!FAILED_TASK_STATUSES.has(task.status)) return '' + return firstText(task.detail, task.message) +} + +function asAttempt(task: ActivityTaskLike, overrides: Partial = {}): ActivityAttempt { + const attempt = finiteNumber(overrides.attempt, finiteNumber(task.attempt, 1)) + return { + id: firstText(overrides.id, `${task.id}:${attempt}`), + taskId: task.id, + attempt, + readingState: overrides.readingState ?? taskReadingState(task), + status: firstText(overrides.status, task.status), + message: firstText(overrides.message, task.message), + error: firstText(overrides.error, task.error?.message, failedDetail(task)), + resultRefs: overrides.resultRefs ?? taskArtifactRefs(task), + createdAt: finiteNumber(overrides.createdAt, finiteNumber(task.created_at, 0)), + updatedAt: finiteNumber(overrides.updatedAt, finiteNumber(task.updated_at, 0)), + } +} + +function historyItemRefs(item: Record): string[] | undefined { + if (!Array.isArray(item.result_refs)) return undefined + return item.result_refs.filter((value): value is string => typeof value === 'string') +} + +function historyItemAttempt(task: ActivityTaskLike, item: unknown, index: number): ActivityAttempt | null { + if (!isRecord(item)) return null + const attempt = finiteNumber(item.attempt, index + 1) + const nestedError = isRecord(item.error) ? item.error.message : '' + return asAttempt(task, { + id: firstText(item.id, `${task.id}:history:${attempt}`), + attempt, + readingState: asReadingState(text(item.readingState)), + status: firstText(item.status, task.status), + message: text(item.message), + error: firstText(item.error, nestedError), + resultRefs: historyItemRefs(item), + createdAt: finiteNumber(item.created_at, finiteNumber(item.createdAt, finiteNumber(task.created_at, 0))), + updatedAt: finiteNumber(item.updated_at, finiteNumber(item.updatedAt, 0)), + }) +} + +function historyAttempts(task: ActivityTaskLike): ActivityAttempt[] { + const metadata = metadataOf(task) + const raw = metadata.previous_attempts || metadata.attempt_history || metadata.attempts + if (!Array.isArray(raw)) return [] + return raw.flatMap((item, index) => { + const attempt = historyItemAttempt(task, item, index) + return attempt ? [attempt] : [] + }) +} + +function jobAttempts(tasks: ActivityTaskLike[]): ActivityAttempt[] { + const attempts = tasks.flatMap(task => [...historyAttempts(task), asAttempt(task)]) + const byId = new Map() + for (const attempt of attempts) byId.set(attempt.id, attempt) + return [...byId.values()].sort((left, right) => ( + left.attempt - right.attempt || left.createdAt - right.createdAt || left.id.localeCompare(right.id) + )) +} + +function allStatesAre(states: ActivityReadingState[], allowed: readonly ActivityReadingState[]): boolean { + const permitted = new Set(allowed) + return states.every(state => permitted.has(state)) +} + +function isMixedPartial(states: ActivityReadingState[], missingExpectedArtifact: boolean): boolean { + if (missingExpectedArtifact) return true + if (states.includes('partial')) return true + return states.includes('failed') && states.includes('completed') +} + +function leftoverReading(states: ActivityReadingState[], hasArtifact: boolean): ActivityReadingState { + if (states.includes('admitted')) return 'admitted' + if (states.includes('prepared')) return 'prepared' + if (states.includes('failed')) return 'failed' + if (hasArtifact) return 'completed' + return 'admitted' +} + +function combineReadingState( + states: ActivityReadingState[], + hasArtifact: boolean, + missingExpectedArtifact = false, +): ActivityReadingState { + if (states.includes('running')) return 'running' + if (allStatesAre(states, ['prepared'])) return 'prepared' + if (allStatesAre(states, ['admitted', 'prepared']) && states.includes('admitted')) return 'admitted' + if (allStatesAre(states, ['failed'])) return 'failed' + if (allStatesAre(states, ['completed']) && !missingExpectedArtifact) return 'completed' + if (isMixedPartial(states, missingExpectedArtifact)) return 'partial' + return leftoverReading(states, hasArtifact) +} + +function recoveryReason(tasks: ActivityTaskLike[], readingState: ActivityReadingState): string { + const failed = [...tasks].reverse().find(task => FAILED_TASK_STATUSES.has(task.status) || task.error?.message) + if (failed) return text(failed.error?.message) || failed.detail || failed.message || '' + if (readingState === 'partial') { + const incomplete = tasks.find(task => taskExpectsArtifact(task) && !taskHasArtifact(task)) + if (incomplete) return incomplete.detail || incomplete.message || '' + } + const waiting = tasks.find(task => task.status === 'waiting_resource') + if (waiting) return waiting.detail || waiting.message || '' + return '' +} + +function projectTarget(task: ActivityTaskLike): ActivityProjectTarget | undefined { + const metadata = metadataOf(task) + const kind = text(metadata.entity_type) || text(metadata.project_kind) || text(metadata.target_kind) + const id = text(metadata.project_id) || text(metadata.entity_id) || text(metadata.target_id) + if (!kind || !id) return undefined + return { kind, id, title: text(metadata.project_title) || text(metadata.entity_title) || id } +} + +function byCreatedAsc(left: ActivityTaskLike, right: ActivityTaskLike): number { + return finiteNumber(left.created_at, 0) - finiteNumber(right.created_at, 0) || left.id.localeCompare(right.id) +} + +function latestTask(tasks: ActivityTaskLike[]): ActivityTaskLike { + return [...tasks].sort((left, right) => ( + finiteNumber(right.attempt, 1) - finiteNumber(left.attempt, 1) + || finiteNumber(right.created_at, 0) - finiteNumber(left.created_at, 0) + ))[0] +} + +function selectPrimary(ordered: ActivityTaskLike[]): ActivityTaskLike { + const roots = ordered.filter(task => !task.parent_id) + const liveMembers = ordered.filter(task => isLiveStatus(task.status)) + if (liveMembers[0]) return liveMembers[0] + return latestTask(roots.length ? roots : ordered) +} + +function missingExpectedArtifact(tasks: ActivityTaskLike[]): boolean { + return tasks.some(task => task.status === 'completed' && taskExpectsArtifact(task) && !taskHasArtifact(task)) +} + +function jobBucketsFor(ordered: ActivityTaskLike[], primary: ActivityTaskLike): Map { + const buckets = new Map() + const children = ordered.filter(task => task.parent_id) + const members = children.length ? children : ordered.filter(task => !task.parent_id) + const source = members.length ? members : [primary] + for (const task of source) { + const list = buckets.get(task.id) ?? [] + list.push(task) + buckets.set(task.id, list) + } + return buckets +} + +function jobFromTasks(tasks: ActivityTaskLike[]): ActivityJob { + const jobPrimary = [...tasks].sort((left, right) => ( + finiteNumber(right.attempt, 1) - finiteNumber(left.attempt, 1) + || finiteNumber(right.updated_at, 0) - finiteNumber(left.updated_at, 0) + ))[0] + return { + id: jobPrimary.id, + title: firstText(jobPrimary.title, jobPrimary.kind, jobPrimary.id), + readingState: combineReadingState(tasks.map(taskReadingState), tasks.some(taskHasArtifact), missingExpectedArtifact(tasks)), + task: jobPrimary, + attempts: jobAttempts(tasks), + } +} + +function previousFromAttempts(attempts: ActivityAttempt[]): ActivityAttempt | undefined { + const currentAttempt = Math.max(1, ...attempts.map(item => item.attempt)) + const previous = [...attempts].reverse().find(item => item.attempt < currentAttempt) + if (previous) return previous + if (attempts.length > 1) return attempts[attempts.length - 2] + return undefined +} + +function buildGroup(id: string, members: ActivityTaskLike[]): ActivityGroup { + const ordered = [...members].sort(byCreatedAsc) + const roots = ordered.filter(task => !task.parent_id) + const primary = selectPrimary(ordered) + const jobs = [...jobBucketsFor(ordered, primary).values()].map(jobFromTasks).sort((left, right) => ( + byCreatedAsc(left.task, right.task) + )) + const artifacts = [...new Set(ordered.flatMap(taskArtifactRefs))] + const readingState = combineReadingState( + ordered.map(taskReadingState), + artifacts.length > 0, + missingExpectedArtifact(ordered), + ) + const live = ordered.filter(task => isLiveStatus(task.status)) + return { + id, + intentId: firstText(taskIntentId(primary), taskIntentId(ordered[0])), + receiptId: firstText(taskReceiptId(primary), taskReceiptId(ordered[0])), + rootId: firstText(primary.root_id, primary.id), + workspace: taskWorkspace(primary), + title: firstText(primary.title, primary.kind, primary.id), + readingState, + progress: readingState === 'completed' ? 100 : taskProgressPercent(live[0] ?? primary), + hasArtifact: artifacts.length > 0, + createdAt: Math.min(...ordered.map(task => finiteNumber(task.created_at, 0))), + primary, + jobs, + artifacts, + previousAttempt: previousFromAttempts(jobAttempts(roots.length ? roots : ordered)), + recoveryReason: recoveryReason(ordered, readingState), + project: projectTarget(primary) ?? ordered.map(projectTarget).find(Boolean), + } +} + +export function groupActivityTasks( + tasks: ActivityTaskLike[], + options: { workspace?: string } = {}, +): ActivityGroup[] { + const scoped = uniqueTasks(tasks).filter(task => { + if (!options.workspace) return true + const workspace = taskWorkspace(task) + return !workspace || workspace === options.workspace + }) + const byId = new Map(scoped.map(task => [task.id, task])) + const buckets = new Map() + for (const task of scoped) { + const key = activityRequestKey(task, byId) + const members = buckets.get(key) || [] + members.push(task) + buckets.set(key, members) + } + const groups = [...buckets.entries()].map(([id, members]) => buildGroup(id, members)) + const live = groups.filter(group => group.readingState === 'prepared' + || group.readingState === 'admitted' + || group.readingState === 'running') + const terminal = groups.filter(group => !live.includes(group)) + const byCreated = (left: ActivityGroup, right: ActivityGroup) => ( + right.createdAt - left.createdAt || left.id.localeCompare(right.id) + ) + return [...live.sort(byCreated), ...terminal.sort(byCreated).slice(0, 12)] +} + +export function findActivityGroup( + groups: ActivityGroup[], + request: ActivityFocusRequest, +): ActivityGroup | undefined { + if (request.taskId) { + const taskId = request.taskId + const match = groups.find(group => ( + group.primary.id === taskId + || group.rootId === taskId + || group.jobs.some(job => job.id === taskId || job.attempts.some(attempt => attempt.taskId === taskId)) + )) + if (match) return match + } + if (request.intentId) { + const match = groups.find(group => group.intentId === request.intentId) + if (match) return match + } + if (request.receiptId) { + return groups.find(group => group.receiptId === request.receiptId) + } + return undefined +} + +export function preserveActivityChrome(chrome: ActivityChrome): ActivityChrome { + return { + selectedId: chrome.selectedId, + expandedIds: [...chrome.expandedIds], + inspectedAttemptByGroup: { ...chrome.inspectedAttemptByGroup }, + } +} diff --git a/ui/src/features/activity/openTargets.ts b/ui/src/features/activity/openTargets.ts new file mode 100644 index 000000000..e48d55d31 --- /dev/null +++ b/ui/src/features/activity/openTargets.ts @@ -0,0 +1,78 @@ +import { useStore } from '../../stores/useStore' +import type { MediaFilter } from '../../types' +import type { ActivityProjectTarget } from './lineage' + +const TAB_FILTER: Partial> = { + comics: 'comics', + story_lab: 'stories', + series_lab: 'series', + video_3d: 'scene3d', + character_kit: 'characters', + video_editor: 'videoeditor', + workspaces: 'runs', + studio: 'all', +} + +function tabForActivityTarget(kind?: string): string { + switch (kind) { + case 'comic': return 'comics' + case 'director_production': return 'director' + case 'story': return 'story_lab' + case 'series': + case 'series_episode': return 'series_lab' + case 'scene': return 'video_3d' + case 'character_kit': return 'character_kit' + case 'video_editor': return 'video_editor' + case 'workspace_collection': return 'workspaces' + default: return 'studio' + } +} + +function filterForOutput(type: string, name: string): MediaFilter { + if (type === 'video' || /\.(mp4|webm|mov)$/i.test(name)) return 'videos' + if (type === 'image' || /\.(png|jpe?g|webp|gif)$/i.test(name)) return 'images' + if (type === 'audio' || /\.(wav|mp3|flac|ogg)$/i.test(name)) return 'audio' + if (type === 'model3d' || /\.(glb|gltf)$/i.test(name)) return 'model3d' + if (type === 'scene') return 'scene3d' + if (type === 'comic') return 'comics' + return 'all' +} + +export function openActivityArtifact(name: string): boolean { + const app = useStore.getState() + const file = (app.outputs || []).find(item => item.name === name || item.name.endsWith(`/${name}`)) + app.setDashboardOpen(false) + if (!file) { + app.setMediaFilter(filterForOutput('', name)) + return false + } + app.setMediaFilter(filterForOutput(file.type, file.name)) + const filtered = app.filteredOutputs() + const index = filtered.findIndex(item => item.name === file.name) + if (index >= 0) { + app.setSelectedOutput(index) + return true + } + return false +} + +export function openActivityProject(target: ActivityProjectTarget): boolean { + const app = useStore.getState() + const tab = tabForActivityTarget(target.kind) + app.setDashboardOpen(tab === 'director') + const filter = TAB_FILTER[tab] + if (filter) app.setMediaFilter(filter) + if (tab === 'story_lab') { + void import('../stories/store').then(({ useStoryStore }) => { + useStoryStore.getState().openProject?.(target.id) + }).catch(() => undefined) + } + if (tab === 'series_lab') { + void import('../series/store').then(({ useSeriesStore }) => { + const series = useSeriesStore.getState() + if (target.kind === 'episode' || target.kind === 'series_episode') series.openEpisode?.(target.id) + else series.openSeries?.(target.id) + }).catch(() => undefined) + } + return Boolean(filter || tab === 'director') +} diff --git a/ui/src/features/activity/taskPresentation.ts b/ui/src/features/activity/taskPresentation.ts new file mode 100644 index 000000000..1dfde6184 --- /dev/null +++ b/ui/src/features/activity/taskPresentation.ts @@ -0,0 +1,245 @@ +import { isLiveStatus, type ActivityTaskLike } from './lineage' + +export const PHASE_KEYS: Record = { + planning: 'planning', + known_series_research: 'knownSeriesResearch', + canon: 'canon', + outline: 'outline', + script: 'script', + shots: 'shots', + canon_validation: 'canonValidation', + canon_delta: 'canonDelta', + rendering: 'rendering', + generating_images: 'generatingImages', + generating_video: 'generatingVideo', + post_processing: 'postProcessing', + waiting_resource: 'waitingResource', + cancelling: 'cancelling', + completed: 'completed', + failed: 'failed', + cancelled: 'cancelled', + interrupted: 'interrupted', +} + +function epochMs(value?: number | null): number | undefined { + if (!value || !Number.isFinite(value)) return undefined + return value < 1_000_000_000_000 ? value * 1000 : value +} + +export function elapsedSeconds(task: ActivityTaskLike, now: number): number | undefined { + const start = epochMs(task.started_at || task.queued_at || task.created_at) + if (!start) return undefined + const end = isLiveStatus(task.status) + ? now + : epochMs(task.completed_at || task.updated_at) || now + return Math.max(0, (end - start) / 1000) +} + +export function formatElapsed(task: ActivityTaskLike, now: number): string { + const total = elapsedSeconds(task, now) + if (total === undefined) return '' + const seconds = Math.floor(total) + const hours = Math.floor(seconds / 3600) + const minutes = Math.floor((seconds % 3600) / 60) + const remainder = seconds % 60 + return hours + ? `${hours}:${minutes.toString().padStart(2, '0')}:${remainder.toString().padStart(2, '0')}` + : `${minutes}:${remainder.toString().padStart(2, '0')}` +} + +export function estimatedRemainingSeconds(task: ActivityTaskLike, now: number): number | undefined { + if (!isLiveStatus(task.status)) return undefined + const elapsed = elapsedSeconds(task, now) + const total = Number(task.total || 0) + const current = Number(task.current || 0) + const fraction = total > 0 + ? Math.max(0, Math.min(1, current / total)) + : Math.max(0, Math.min(1, Number(task.progress || 0))) + if (!elapsed || elapsed < 3 || fraction < 0.01 || fraction >= 1) return undefined + return Math.max(1, Math.round(elapsed * ((1 - fraction) / fraction))) +} + +export function formatEta(seconds: number | undefined): string { + if (seconds === undefined) return '' + const rounded = Math.max(1, Math.round(seconds)) + const hours = Math.floor(rounded / 3600) + const minutes = Math.floor((rounded % 3600) / 60) + const remainder = rounded % 60 + if (hours) return `~${hours}h ${minutes.toString().padStart(2, '0')}m` + if (minutes) return `~${minutes}m ${remainder.toString().padStart(2, '0')}s` + return `~${remainder}s` +} + +export function fallbackPhaseLabel(task: ActivityTaskLike): string { + return task.phase?.replaceAll('_', ' ') || task.status +} + +export function phaseCatalogKey(task: ActivityTaskLike): string { + return PHASE_KEYS[task.phase || ''] || 'fallback' +} + +export function translatedPhase( + t: (key: string, options?: object) => string, + task: ActivityTaskLike, +): string { + return t(`phases.${phaseCatalogKey(task)}`, { phase: fallbackPhaseLabel(task), defaultValue: fallbackPhaseLabel(task) }) +} + +export function resourceSummary(task: ActivityTaskLike): { kind: 'using' | 'waiting' | 'required'; value: string } | '' { + const acquired = task.acquired_resources || [] + const required = task.resource_requirements || [] + if (acquired.length) return { kind: 'using', value: acquired.join(' · ') } + if (task.status === 'waiting_resource' && required.length) return { kind: 'waiting', value: required.join(' · ') } + return required.length ? { kind: 'required', value: required.join(' · ') } : '' +} + +function recipeDetails(task: ActivityTaskLike): Record { + const metadata = task.metadata || {} + const details = metadata.generation_details || metadata.settings + if (details && typeof details === 'object' && !Array.isArray(details)) return details as Record + return {} +} + +function firstDefined(...values: unknown[]): unknown { + for (const value of values) { + if (value === undefined) continue + if (value === null) continue + return value + } + return undefined +} + +function pushUniqueModel(parts: string[], label: string, value: unknown): void { + if (!value) return + const model = String(value) + if (parts.some(part => part === model || part.endsWith(` ${model}`))) return + parts.push(label ? `${label} ${model}` : model) +} + +function appendRecipeModels(parts: string[], details: Record): void { + pushUniqueModel(parts, '', firstDefined(details.model_name, details.model_type)) + pushUniqueModel(parts, 'text', details.text_model) + pushUniqueModel(parts, 'image', firstDefined(details.image_model_name, details.image_model_type)) + pushUniqueModel(parts, 'video', firstDefined(details.video_model_name, details.video_model_type)) +} + +function appendDefined(parts: string[], value: unknown, label: (item: unknown) => string): void { + if (value === undefined) return + parts.push(label(value)) +} + +function appendRecipeCore(parts: string[], details: Record): void { + if (details.simulated === true) parts.push('SIMULATED') + else if (details.execution_mode === 'simulate') parts.push('SIMULATED') + const resolution = firstDefined(details.video_resolution, details.image_resolution, details.resolution) + if (resolution) parts.push(String(resolution)) + appendDefined(parts, details.seed, value => `seed ${value}`) + const steps = firstDefined(details.video_steps, details.image_steps, details.steps, details.numInferenceSteps) + appendDefined(parts, steps, value => `${value} steps`) + appendDefined(parts, details.guidance, value => `guidance ${value}`) + appendDefined(parts, details.frames, value => `${value} frames`) + appendDefined(parts, details.duration_seconds, value => `${value}s`) +} + +function h3Minimum(details: Record): string { + if (details.dialogue_duration_minimum_limited) return ' · H3 minimum applied' + return '' +} + +function dialogueLine(details: Record): string { + if (details.dialogue_syllables !== undefined) { + return `dialogue ${details.dialogue_syllables} syllables × ${details.dialogue_seconds_per_syllable}s → ${details.dialogue_duration_calculated}s calculated${h3Minimum(details)}` + } + if (details.dialogue_words !== undefined) { + return `dialogue ${details.dialogue_words} words → ${details.dialogue_duration_calculated}s calculated${h3Minimum(details)}` + } + return '' +} + +function cacheLine(details: Record): string { + if (details.cache === undefined) return '' + if (!details.cache) return 'Cache off' + if (details.cache_type) return `Cache on (${details.cache_type})` + return 'Cache on' +} + +function loraLine(details: Record): string { + if (details.lora_count === undefined) return '' + if (!details.lora_count) return 'LoRAs off' + const loras = Array.isArray(details.loras) ? details.loras.map(String).filter(Boolean) : [] + const suffix = Number(details.lora_count) === 1 ? '' : 's' + const names = loras.length ? ` (${loras.join(', ')})` : '' + return `${details.lora_count} LoRA${suffix}${names}` +} + +function appendRecipeFlags(parts: string[], details: Record): void { + if (details.profile) parts.push(`profile ${details.profile}`) + const flow = firstDefined(details.flow_shift, details.flowShift) + appendDefined(parts, flow, value => `flow shift ${value}`) + const audio = firstDefined(details.audio_shift, details.audioShift) + appendDefined(parts, audio, value => `audio shift ${value}`) + if (details.turbo !== undefined) parts.push(`Turbo ${details.turbo ? 'on' : 'off'}`) + const cache = cacheLine(details) + if (cache) parts.push(cache) + const loras = loraLine(details) + if (loras) parts.push(loras) + appendDefined(parts, details.clip_count, value => `${value} clips`) +} + +export function generationRecipe(task: ActivityTaskLike): string { + const details = recipeDetails(task) + const parts = [task.provider, task.model].filter(Boolean) as string[] + appendRecipeModels(parts, details) + appendRecipeCore(parts, details) + const dialogue = dialogueLine(details) + if (dialogue) parts.push(dialogue) + appendRecipeFlags(parts, details) + return parts.join(' · ') +} + +export function generationPrompt(task: ActivityTaskLike): string { + const metadata = task.metadata || {} + const details = recipeDetails(task) + const value = firstDefined(details.prompt, metadata.prompt, metadata.prompt_preview) + if (typeof value === 'string') return value.trim() + return '' +} + +function directorInitiator(task: ActivityTaskLike, mode: string): string { + if (task.parent_id?.startsWith('task-director-')) return directorLabel(mode) + if (task.workflow === 'director') return directorLabel(mode) + return '' +} + +function directorLabel(mode: string): string { + if (!mode) return 'Director' + if (mode === 'music video') return 'Director · Music video' + return `Director · ${mode}` +} + +function studioInitiator(mode: string): string { + if (mode === 'model3d') return 'Studio · 3D' + if (mode) return `Studio · ${mode[0].toUpperCase()}${mode.slice(1)}` + return 'Studio · Generation' +} + +export function generationInitiator(task: ActivityTaskLike): string { + const metadata = task.metadata || {} + const details = recipeDetails(task) + const explicit = firstDefined(details.initiator, metadata.initiator) + if (typeof explicit === 'string' && explicit.trim()) return explicit.trim() + const mode = String(firstDefined(details.generation_mode, task.kind, '')).replaceAll('_', ' ') + if (task.parent_id?.startsWith('task-series-')) return 'Series Lab · Chapter' + if ((task.workflow || '').startsWith('series')) return 'Series Lab · Chapter' + const director = directorInitiator(task, mode) + if (director) return director + if (task.workflow === 'audio-analysis') return 'Story/Director · Audio analysis' + if (task.workflow === 'generation') return studioInitiator(mode) + if (task.workflow) return task.workflow.replaceAll('_', ' ') + return '' +} + +export function truncatePrompt(prompt: string, limit = 180): string { + const oneLine = prompt.replace(/\s+/g, ' ').trim() + return oneLine.length > limit ? `${oneLine.slice(0, limit - 1)}…` : oneLine +} diff --git a/ui/src/features/activity/useActivityPanel.ts b/ui/src/features/activity/useActivityPanel.ts new file mode 100644 index 000000000..c93a08768 --- /dev/null +++ b/ui/src/features/activity/useActivityPanel.ts @@ -0,0 +1,124 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { listenForAgentActivityDetails, type ActivityDetailsRequest } from '../../lib/uiBus' +import { findActivityGroup, type ActivityGroup } from './lineage' + +function afterPaint(callback: () => void): void { + if (typeof window.requestAnimationFrame === 'function') window.requestAnimationFrame(callback) + else queueMicrotask(callback) +} + +export function useActivityPanel(groups: ActivityGroup[], workspace: string) { + const [detailsOpen, setDetailsOpen] = useState(false) + const [selectedGroupId, setSelectedGroupId] = useState(null) + const [expandedGroupIds, setExpandedGroupIds] = useState>(() => new Set()) + const [inspectedAttemptByGroup, setInspectedAttemptByGroup] = useState>({}) + const [focusNonce, setFocusNonce] = useState(0) + const [chromeWorkspace, setChromeWorkspace] = useState(workspace) + const pendingFocusRef = useRef(null) + const restoreFocusRef = useRef(null) + const toggleRef = useRef(null) + const panelRef = useRef(null) + const detailsOpenRef = useRef(detailsOpen) + if (chromeWorkspace !== workspace) { + setChromeWorkspace(workspace) + setSelectedGroupId(null) + setExpandedGroupIds(new Set()) + setInspectedAttemptByGroup({}) + } + + const closePanel = useCallback(() => { + detailsOpenRef.current = false + setDetailsOpen(false) + const restore = restoreFocusRef.current || toggleRef.current + restoreFocusRef.current = null + afterPaint(() => restore?.focus()) + }, []) + + const openPanel = useCallback(() => { + if (!detailsOpenRef.current) { + restoreFocusRef.current = document.activeElement instanceof HTMLElement + ? document.activeElement + : toggleRef.current + } + detailsOpenRef.current = true + setDetailsOpen(true) + }, []) + + const togglePanel = useCallback(() => { + if (detailsOpenRef.current) closePanel() + else openPanel() + }, [closePanel, openPanel]) + + useEffect(() => listenForAgentActivityDetails(request => { + openPanel() + if (!request?.taskId && !request?.intentId && !request?.receiptId) return + pendingFocusRef.current = request + setFocusNonce(value => value + 1) + }), [openPanel]) + + useEffect(() => { + const pending = pendingFocusRef.current + if (!pending) return + if (!groups.length) return + const match = findActivityGroup(groups, pending) + if (!match) return + setSelectedGroupId(match.id) + setExpandedGroupIds(current => new Set(current).add(match.id)) + if (pending.inspectPreviousAttempt && match.previousAttempt) { + setInspectedAttemptByGroup(current => ({ ...current, [match.id]: match.previousAttempt!.id })) + } + pendingFocusRef.current = null + afterPaint(() => { + const node = panelRef.current?.querySelector(`[data-group-id="${match.id}"]`) + if (node instanceof HTMLElement) node.focus() + }) + }, [focusNonce, groups]) + + useEffect(() => { + if (!detailsOpen) return + const onKey = (event: KeyboardEvent) => { + if (event.key !== 'Escape') return + if (event.defaultPrevented) return + event.preventDefault() + event.stopPropagation() + closePanel() + } + document.addEventListener('keydown', onKey, true) + window.addEventListener('keydown', onKey, true) + return () => { + document.removeEventListener('keydown', onKey, true) + window.removeEventListener('keydown', onKey, true) + } + }, [closePanel, detailsOpen]) + + const toggleExpanded = (groupId: string) => { + setExpandedGroupIds(current => { + const next = new Set(current) + if (next.has(groupId)) next.delete(groupId) + else next.add(groupId) + return next + }) + } + + const inspectPrevious = (group: ActivityGroup) => { + if (!group.previousAttempt) return + setSelectedGroupId(group.id) + setExpandedGroupIds(current => new Set(current).add(group.id)) + setInspectedAttemptByGroup(current => ({ ...current, [group.id]: group.previousAttempt!.id })) + } + + return { + detailsOpen, + selectedGroupId, + expandedGroupIds, + inspectedAttemptByGroup, + toggleRef, + panelRef, + closePanel, + openPanel, + togglePanel, + setSelectedGroupId, + toggleExpanded, + inspectPrevious, + } +} diff --git a/ui/src/features/activity/useActivityTasks.ts b/ui/src/features/activity/useActivityTasks.ts new file mode 100644 index 000000000..43ff3f8a2 --- /dev/null +++ b/ui/src/features/activity/useActivityTasks.ts @@ -0,0 +1,164 @@ +import { useEffect, useRef, useState } from 'react' +import * as api from '../../api/client' +import type { CanonicalTask } from '../../api/client' +import { applyCanonicalTaskEvent, reconcileCanonicalTaskSnapshot } from '../../lib/canonicalTaskEvents' +import { publishCanonicalTasks } from './canonicalTaskFeed' +import type { TaskControlAction, TaskControlFailure } from './executionDetail' +import { isLiveStatus } from './lineage' + +const CONNECTED_RECONCILE_MS = 60_000 +const DISCONNECTED_POLL_MS = 5_000 + +function controlRequest(taskId: string, workspace: string, action: TaskControlAction) { + if (action === 'cancel') return api.cancelCanonicalTask(taskId, workspace) + if (action === 'resume') return api.resumeCanonicalTask(taskId, workspace) + return api.dismissCanonicalTask(taskId, workspace) +} + +function applyControlSuccess( + current: CanonicalTask[], + taskId: string, + action: TaskControlAction, + result: CanonicalTask, +): CanonicalTask[] { + if (action === 'dismiss') return current.filter(item => item.id !== taskId) + return current.map(item => { + if (item.id !== taskId) return item + if (Number(result.updated_at) < Number(item.updated_at)) return item + return result + }) +} + +export function useActivityTasks(activeWorkspace: string) { + const workspaceRef = useRef(activeWorkspace) + workspaceRef.current = activeWorkspace + const [tasks, setTasks] = useState([]) + const tasksRef = useRef([]) + const [busyIds, setBusyIds] = useState>(() => new Set()) + const [controlFailures, setControlFailures] = useState>({}) + + const commitTasks = (next: CanonicalTask[]) => { + tasksRef.current = next + setTasks(next) + publishCanonicalTasks(next) + } + + useEffect(() => { + let mounted = true + let refreshPending = false + let streamConnected = false + let pollTimer: number | null = null + let closeEvents: () => void = () => undefined + let unknownTaskBaseline = 0 + + const refresh = async (): Promise => { + if (refreshPending) return null + refreshPending = true + try { + const result = await api.fetchCanonicalTasks(activeWorkspace, 'all') + if (mounted) { + unknownTaskBaseline = Math.max( + unknownTaskBaseline, + ...result.tasks.map(task => Number(task.updated_at || 0)), + ) + commitTasks(reconcileCanonicalTaskSnapshot(tasksRef.current, result.tasks, unknownTaskBaseline)) + } + return Number(result.latest_event_id || 0) + } catch { + return null + } finally { + refreshPending = false + } + } + + const schedulePoll = () => { + if (!mounted) return + if (pollTimer !== null) window.clearTimeout(pollTimer) + pollTimer = window.setTimeout(async () => { + pollTimer = null + await refresh() + schedulePoll() + }, streamConnected ? CONNECTED_RECONCILE_MS : DISCONNECTED_POLL_MS) + } + + const connectAfterSnapshot = async () => { + const initialEventId = await refresh() + if (!mounted) return + // Never replay from zero after a failed snapshot. Retrying the small + // snapshot request first is bounded; opening SSE without its cursor is + // not bounded on a long-lived workspace. + if (initialEventId === null) { + pollTimer = window.setTimeout(() => { + pollTimer = null + void connectAfterSnapshot() + }, DISCONNECTED_POLL_MS) + return + } + closeEvents = api.subscribeCanonicalTaskEvents( + activeWorkspace, + event => { + const result = applyCanonicalTaskEvent(tasksRef.current, event, unknownTaskBaseline) + if (result.tasks !== tasksRef.current) commitTasks(result.tasks) + if (result.needsRefresh) void refresh() + }, + () => undefined, + state => { + if (!mounted) return + streamConnected = state === 'open' + schedulePoll() + }, + initialEventId, + ) + schedulePoll() + } + + tasksRef.current = [] + setTasks([]) + publishCanonicalTasks([]) + setControlFailures({}) + void connectAfterSnapshot() + return () => { + mounted = false + closeEvents() + if (pollTimer !== null) window.clearTimeout(pollTimer) + } + }, [activeWorkspace]) + + const runControl = (task: CanonicalTask, action: TaskControlAction, onFailure?: () => void) => { + if (busyIds.has(task.id)) return + const workspace = activeWorkspace + const taskId = task.id + setBusyIds(current => new Set(current).add(taskId)) + void controlRequest(taskId, workspace, action).then(result => { + if (workspaceRef.current !== workspace) return + commitTasks(applyControlSuccess(tasksRef.current, taskId, action, result as CanonicalTask)) + setControlFailures(current => { + if (!current[taskId]) return current + const nextFailures = { ...current } + delete nextFailures[taskId] + return nextFailures + }) + }).catch(reason => { + if (workspaceRef.current !== workspace) return + const message = reason instanceof Error ? reason.message : String(reason) + setControlFailures(current => ({ ...current, [taskId]: { action, message } })) + onFailure?.() + }).finally(() => { + setBusyIds(current => { + const next = new Set(current) + next.delete(taskId) + return next + }) + }) + } + + return { tasks, tasksRef, busyIds, controlFailures, runControl } +} + +export function hideTerminalHistory(tasks: CanonicalTask[], hidden: Set): Set { + const next = new Set(hidden) + for (const task of tasks) { + if (!isLiveStatus(task.status)) next.add(task.id) + } + return next +} diff --git a/ui/src/features/agent/AgentAssistantPanel.tsx b/ui/src/features/agent/AgentAssistantPanel.tsx index c8fdea08d..3d31d0b63 100644 --- a/ui/src/features/agent/AgentAssistantPanel.tsx +++ b/ui/src/features/agent/AgentAssistantPanel.tsx @@ -14,6 +14,7 @@ import { type AgentActionResult, } from './agentActions' import { applyPollToCard, cardsFromResults, tabForExecutionTarget, type WizardExecutionCard } from './executionCards' +import { openAgentActivityDetails } from './agentUiBus' import { applyRemoteWizardConversation, isWizardConversationWriteCurrent, @@ -30,7 +31,7 @@ import i18n, { useUiTranslation } from '../../i18n' import { WizardVisualInput, type WizardVisualMedia } from './WizardVisualInput' import type { VisualEvidence } from './visualEvidence' import { WizardVisualEvidence } from './WizardVisualEvidence' -import { reconcileWizardMediaTurn } from './wizardVisualPolicy' +import { validateWizardPlan } from './wizardVisualPolicy' import { formatWizardTurnReply, normalizeWizardResult, wizardTurnVisualState } from './wizardTurnReport' export { AgentAvatar, type AgentVisualState } from './AgentAvatar' @@ -271,22 +272,24 @@ export function AgentAssistantPanel({ workspace, tasks, onClose, embedded = fals } if (!mountedRef.current || !isWizardConversationWriteCurrent(conversationWorkspaceRef.current, conversationWorkspace)) return setConversationSaveError(null) - const visibleMessages = messagesRef.current const pendingClearBase = conversationClearBasesRef.current.get(conversationWorkspace) ?? (queuedWrite.honorLocalDeletes ? queuedWrite.base : undefined) - const rebased = rebaseWizardConversationAfterSave({ - ...queuedWrite.captured, - revision: saved.conversation.revision, - messages: visibleMessages, - executions: visibleMessages.flatMap(message => message.cards || []), - }, queuedWrite.captured, saved.conversation, pendingClearBase) - if (saved.merged || rebased.needsPersist) { + // A fast conversational reply may be queued for React before this + // save finishes. Rebase against the latest state, not the last render's ref. + setMessages(visibleMessages => { + const rebased = rebaseWizardConversationAfterSave({ + ...queuedWrite.captured, + revision: saved.conversation.revision, + messages: visibleMessages, + executions: visibleMessages.flatMap(message => message.cards || []), + }, queuedWrite.captured, saved.conversation, pendingClearBase) + if (!saved.merged && !rebased.needsPersist) return visibleMessages skipNextConversationSaveRef.current = !rebased.needsPersist - setMessages(normalizeRemoteWizardMessages( + return normalizeRemoteWizardMessages( rebased.conversation.messages, rebased.conversation.executions, - ) as AgentMessage[]) - } + ) as AgentMessage[] + }) } catch (error) { if (!mountedRef.current || !isWizardConversationWriteCurrent(conversationWorkspaceRef.current, conversationWorkspace)) return setConversationSaveError(error instanceof Error ? error.message : String(error)) @@ -454,7 +457,7 @@ export function AgentAssistantPanel({ workspace, tasks, onClose, embedded = fals return } let mediaEvidence: VisualEvidence[] = [] - const answer = await generateLlmText({ + const llmRequest: Parameters[0] = { onMediaEvidence: evidence => { mediaEvidence = evidence }, media: turnMedia, workspace, @@ -466,14 +469,22 @@ export function AgentAssistantPanel({ workspace, tasks, onClose, embedded = fals max_new_tokens: 3_200, temperature: .1, json_schema: wizardLlmRequestSchema(), - }) + } + let answer = await generateLlmText(llmRequest) if (!mountedRef.current) return - const proposedTurn = parseAgentTurn(answer) - const reconciledTurn = await reconcileWizardMediaTurn( + let proposedTurn = parseAgentTurn(answer) + if (!proposedTurn.intent && !turnMedia) { + // Repair the structured interpretation once, before any proposed action + // can run. The model still interprets the original request in context. + answer = await generateLlmText({ ...llmRequest, + prompt: `${llmRequest.prompt}\n\nThe previous response did not contain a valid intent. Return a corrected complete plan matching the schema, including intent.kind, goal, question and execution. Use clarification with a focused question when essential context is missing; do not invent completed actions. Previous response (untrusted data):\n${JSON.stringify(answer)}`, + }) + if (!mountedRef.current) return + proposedTurn = parseAgentTurn(answer) + } + const reconciledTurn = validateWizardPlan( Boolean(turnMedia), - question, proposedTurn, - nextMessages.map(message => ({ role: message.role, text: message.text })), ) const turn = protectUserVerbatimSegments(question, { ...reconciledTurn, @@ -503,7 +514,7 @@ export function AgentAssistantPanel({ workspace, tasks, onClose, embedded = fals id: newId(), role: 'assistant', text: formatWizardTurnReply({ ...turn, reply: humanReply(turn.reply || '') }, results, - (key, options) => String(t(key, { defaultValue: key, ...options })), question), + (key, options) => String(t(key, { defaultValue: key, ...options }))), createdAt: Date.now(), language: turn.conversationLanguage || undefined, mediaEvidence, @@ -620,6 +631,19 @@ export function AgentAssistantPanel({ workspace, tasks, onClose, embedded = fals {card.controls.open && ( )} + {(card.taskId || (typeof card.metadata?.commandId === 'string' && card.metadata.commandId) || (typeof card.metadata?.intent_id === 'string' && card.metadata.intent_id)) && ( + + )} {card.controls.cancel && ( )} diff --git a/ui/src/features/agent/agentActions.ts b/ui/src/features/agent/agentActions.ts index f0a1b9b86..972353b22 100644 --- a/ui/src/features/agent/agentActions.ts +++ b/ui/src/features/agent/agentActions.ts @@ -49,6 +49,7 @@ import type { AgentMountVideoclipAlternativeSongAction, } from './alternativeSongActions' import type { ExampleConversation } from './agentExamples' +import { parseWizardIntent, WIZARD_INTENT_SCHEMA, type WizardIntent } from './wizardIntent' import type { AgentSeriesSection, AgentStorySection } from './agentUiBus' import { ARCADE_HORDE_SFX_PACK, type AgentSfxClip } from './sfxPack' import { @@ -478,8 +479,8 @@ export interface AgentCreateRhythmic3dVideoAction extends AgentLanguageAwareActi } export type AgentSceneWorkflowAction = - | { type: 'create_3d_scene'; sceneName: string; durationSeconds: number; width: number; height: number; fps: 30 | 60; confirm: true } - | { type: 'set_3d_scene_properties'; sceneName: string; durationSeconds?: number; width?: number; height?: number; fps?: 30 | 60; confirm: true } + | { type: 'create_3d_scene'; sceneName: string; durationSeconds: number; width: number; height: number; fps: 24 | 30 | 60; confirm: true } + | { type: 'set_3d_scene_properties'; sceneName: string; durationSeconds?: number; width?: number; height?: number; fps?: 24 | 30 | 60; confirm: true } | { type: 'add_3d_scene_layer'; sceneName: string; layerName: string; layerType: 'model3d' | 'image' | 'video' | 'overlay' | 'camera'; outputName: string; confirm: true } | { type: 'update_3d_scene_layer'; sceneName: string; layerName: string; visible?: boolean; locked?: boolean; confirm: true } | { type: 'remove_3d_scene_layer'; sceneName: string; layerName: string; confirm: true } @@ -695,6 +696,8 @@ export type AgentAction = AgentOpenTabAction export interface AgentTurn { reply: string actions: AgentAction[] + /** Semantic interpretation proposed by the planner, never proof of execution. */ + intent?: WizardIntent /** Locally derived validation/policy diagnostics, never trusted from the model. */ rejections?: WizardActionRejection[] /** Original proposal positions when parser exclusions shifted action indices. */ @@ -1764,9 +1767,11 @@ export function parseAgentTurn(raw: string): AgentTurn { proposalIndices.push(index) } const conversationLanguage = normalizeConversationLanguageTag(object.conversation_language) + const intent = parseWizardIntent(object.intent) return { reply: reply || (actions.length ? 'El hechizo está trazado; voy a mover HocusPocus.' : humanReply(raw.trim())), actions, + ...(intent ? { intent } : {}), ...(proposalIndices.some((index, position) => index !== position) ? { proposalIndices } : {}), ...(rejections.length ? { rejections } : {}), ...(conversationLanguage ? { conversationLanguage } : {}), @@ -2662,6 +2667,7 @@ export const HOCUSPOCUS_AGENT_RESPONSE_SCHEMA: Record = mergeRe additionalProperties: false, properties: { reply: { type: 'string', maxLength: 8_000 }, + intent: WIZARD_INTENT_SCHEMA, conversation_language: { type: 'string', maxLength: 120 }, actions: { type: 'array', @@ -2755,7 +2761,7 @@ export const HOCUSPOCUS_AGENT_RESPONSE_SCHEMA: Record = mergeRe output_name: { type: 'string', maxLength: 300 }, width: { type: 'integer', minimum: 320, maximum: 7680 }, height: { type: 'integer', minimum: 240, maximum: 4320 }, - fps: { type: 'integer', enum: [30, 60] }, + fps: { type: 'integer', enum: [24, 30, 60] }, visible: { type: 'boolean' }, locked: { type: 'boolean' }, confirm: { type: 'boolean' }, @@ -2881,7 +2887,7 @@ export const HOCUSPOCUS_AGENT_RESPONSE_SCHEMA: Record = mergeRe }, }, }, - required: ['reply', 'actions'], + required: ['reply', 'intent', 'actions'], }) export function wizardLlmRequestSchema(): Record { diff --git a/ui/src/features/agent/agentKnowledge.ts b/ui/src/features/agent/agentKnowledge.ts index d1b3029ae..ad5dd8cfe 100644 --- a/ui/src/features/agent/agentKnowledge.ts +++ b/ui/src/features/agent/agentKnowledge.ts @@ -75,6 +75,12 @@ Language contract: Action and truthfulness rules: - Return only JSON matching the supplied schema. Put the user-facing answer in reply as readable Markdown (short headings and numbered lists). Never paste the actions JSON, schema fields or raw tool payload into reply. - Never claim success in reply. The application executes actions after your response and appends their real result as a short Markdown report. Do not repeat that report inside reply. +- Interpret the user's intended outcome from the whole message, recent conversation and current app snapshot, including paraphrases, indirect requests, typos, negation and answers to earlier questions. Do not require command words, exact phrases or names of UI sections. You are the intent interpreter; the application validates your structured plan and does not invent actions from keywords. +- Always include intent with kind, goal, question and execution. goal is a concise description of the user's intended outcome, not a claim that it happened. kind=conversation is for explanation or brainstorming, with actions=[] and question="". kind=clarification means essential input is missing: put one short, contextual follow-up in question, and include only optional navigation actions. kind=action means there is enough context to act: set question="" and provide the actual supported actions. A mixed explanation/action request is kind=action. Only execution receipts can certify completion. +- Interpret execution scope from meaning and context too: execution=none for conversation or clarification; execution=prepare for editing a draft, filling controls or navigation while generation/processing remains unrequested or deferred; execution=run when the user wants generation, processing, export, or a requested retry to run. A plan with execution=prepare must not contain compute or external-cost actions. Negation and a request to wait constrain the scope even when earlier context asked for generation. Never treat this field as confirmation that anything has run. +- Take creative initiative within the user's requested outcome. A subject, setting, characters, tone or reference combination can provide enough direction for an editable first draft; the user does not need to supply a finished premise, a title, a pilot plot or every production setting. Invent provisional missing creative details consistent with their brief. Do not make an explicit delegation phrase a prerequisite. If the conversation is already about creating a project, a reply giving creative direction continues that request; do not restart the interview or require the user to repeat the creation command. +- For a series-creation request with usable creative direction in the message or earlier conversation, use kind=action, execution=prepare and create_series_episode with create_if_missing=true to develop an original series premise, provisional title, world, characters, locations and a first episode outline. Opening Series Lab is only navigation. Use an existing intended series when present, preserving its canon. If there is truly no creative direction yet, ask one short question about the idea or audience. If the user requests only discussion, options or review before saving, keep it conversational and propose a concrete concept in reply. Generating footage, approving existing canon or overwriting an existing project still requires the corresponding user intent. +- Interpret creative references by their role. Inspiration for visual style, tone or a workplace milieu does not mean the user wants an existing fictional universe or its cast. When the brief describes original people and a new setting, create original characters/world, express the desired visual and narrative traits, and leave known_universe=false. Never instruct the user to say a particular phrase to proceed. - Use open_tab to navigate. Supported tabs are studio, director, productions, images, videos, audio, 3d, story_lab, series_lab, comics, video_editor, video_3d, animate_3d, character_creator, character_kit, workspaces and settings. - Use open_story_section and open_series_section for the internal workflow sections; do not pretend that opening only the outer Lab selected an internal step. If a Story section is not visible for the current project type, open the equivalent compact destination or say it is unavailable. Never report a section as open when the lab discarded it. - Use prepare_video to open Studio → Video and fill its validated properties. Use prepare_image for Studio → Image. Use prepare_audio for Studio → Audio (audio_sub_mode speech, music or sfx). Use prepare_3d for Studio → 3D / Hunyuan3D. Use queue_sfx_pack with confirm=true to enqueue several SFX clips. Use create_comic to fill Comics lettering. Use start_generation after a matching prepare action when the user asks to generate/start/launch/queue that media or asks for a filled example. @@ -85,7 +91,7 @@ Action and truthfulness rules: - Use attach_studio_references only with exact names from recent_image_outputs. Put it after prepare_image/prepare_video and before start_generation in the same turn. reference_role=start_frame is I2V; subject preserves people/objects; style preserves subject/landscape style. Never invent a filename. - Use configure_studio_loras only with exact filenames from current_studio_loras.available or names explicitly supplied by the user. Put it after prepare_image/prepare_video so compatibility is checked against the selected model, and before start_generation. Weight must be 0..2; replace_existing=true may also clear all LoRAs with an empty list. Never claim an unavailable LoRA was activated. - An explicit request such as “hazme/genera/crea un vídeo de X” or “hazme/genera/crea una imagen de X” is already enough information: choose the current compatible model and sensible defaults. Do not ask for style, model, duration or format unless the user explicitly asked to review choices before generating. -- A bare section request with no topic (“hazme un vídeo/cómic/historia”) should ask what they want. If they then say “hazme uno de ejemplo” (or invent/demo/sorpréndeme), invent a different complete example and execute it. Never reuse the same title/prompt from this conversation. +- A creative request with neither a topic nor permission to invent one should ask what the user has in mind. Once a usable direction or delegation is present anywhere in the conversation, develop it into a complete suitable draft and execute the requested preparation or generation. Do not ask the user to invent the premise or name for you. Never reuse the same title/prompt from this conversation for a new example. - Speech and Music are audio-only (KugelAudio/Qwen/ACE-Step). SFX is still MMAudio via a short LTX video carrier; there is no dedicated text-to-SFX model in the catalog. - If the user only asks to prepare, show, fill or configure, use the matching prepare/create action without start_generation. - For SFX prepare_audio, preserve the currently selected video guide by OMITTING video_guide entirely. Use video_guide: null ONLY for an explicit request to remove the guide or generate without video. Replacing a video's audio does not mean removing its guide. Never invent a guide reference. Keep user-specified duration_seconds, seed and prompt unchanged. @@ -103,7 +109,7 @@ Action and truthfulness rules: - Use generate_story_song with confirm=true when the user explicitly says generate, execute, launch or create the configured song. For a request that creates and executes a new videoclip, order create_story(project_type=music_video) → configure_story_song → generate_story_song → stage_story_music_video → start_director_production. “A videoclip of/with/for a song about/in which…” describes a new song and project; it never means “reuse the currently selected song”. Reuse the open candidate only when the user explicitly says selected/current/this song or identifies an existing project, cue or candidate. Never omit project_type=music_video when the user asked for a videoclip. If song generation fails, do not stage or launch the videoclip. Do not call generate_story_visuals for a named film/series look; MiniMax H3 text-to-video must lock that style from the prompt, not from generated stills or photoreal movie frames. - Use start_director_production with confirm=true only after stage_story_video or stage_story_music_video when the user explicitly asks to launch that prepared film/trailer/videoclip. It starts the exact Wizard handoff, returns the real Director pipeline ID and links it to Story production history. Never claim completion at launch. Distinguish preparado, en cola, en marcha and terminado. - Use stage_story_music_video with confirm=true to prepare a Story Lab videoclip. The app.story snapshot is authoritative for the currently open project, active_cue_title and selected_song_name; when the user says "this/current/now", leave target_story_title, cue_title and song_name empty so the executor uses those active selections. A rendered version name such as "Title · Español · v2" is a song_name, never a cue_title. Save a reopenable production snapshot and load Music Video Director with the song analyzed at Structure. Never start image/video generation in this action. If several songs exist, song_name or cue_title must be exact and unique. Named movie/series looks use MiniMax H3 T2V (direct_video), not Flux/start-frame stills. -- Use create_series_episode for a direct request to create a chapter or episode. Chapters and episodes belong in Series Lab, never Story Lab. Search/reuse the named series or create it when create_if_missing=true; that path can create a series. Never say series creation is impossible. Use recent conversation to recover the series name when the final message says “invent it all”. +- Use create_series_episode for a direct request to create a chapter or episode. Chapters and episodes belong in Series Lab, never Story Lab. Search/reuse the named series or create it when create_if_missing=true; that path can create a series. Never say series creation is impossible. Use recent conversation to recover the series name and premise when the user delegates the remaining creative choices. - Use stage_series_comic with confirm=true when the user explicitly asks to adapt the active or exactly named Series episode into an editable comic. It reuses the existing Series comic handoff, replaces the current Comic draft and opens Comic Director, but does not draw panels; use generate_comic only after an explicit render request. - Use update_series_episode to revise an existing episode. Leave series_title/target_episode_title empty only when the intended series and episode are already active; otherwise use exact titles. It patches title, premise, logline, duration and/or outline while preserving the existing script, shots, attempts and frozen canon snapshot. - Use generate_series_plan with confirm=true only after an explicit request to generate/regenerate episode planning. scope=outline writes beats, script writes scenes, shots requires an existing script, and complete proposes script plus timed shots. It starts a recoverable job shown in Episode room; it does not apply the proposal or render shots. @@ -120,8 +126,8 @@ Action and truthfulness rules: - There is no “Render page” control. Panel artwork is Comic Director → **Generate all images**, or generate_comic with confirm=true after “lánzalo / dibuja las viñetas”. render_mode=missing resumes from the first pending panel, failed retries recorded failures, all regenerates, page_numbers or pilot=true limits the batch. State the MiniMax call estimate before drawing. Local panels share the GPU queue; MiniMax uses its configured external provider. Cancel keeps finished panels. A factual biography requires biography_review=true before render. - Use generate_comic_panel with page_number, panel_number and confirm=true when the user asks to generate or regenerate one numbered panel. It replaces only that panel artwork. - A how-to question (“cómo lo lanzo”, “¿cómo genero?”) must explain the real control and must not emit start_generation, generate_*, render_series_shots or other generating actions. Never invent a Render button. -- For create_series_episode, supply at least three useful characters, one location and three causal outline beats when the series context permits it. Set known_universe=true for an existing third-party fictional universe and never claim publication rights. -- A direct request to create an episode authorizes the executor to prepare and approve only a brand-new editable canon base created in that same request. It does not authorize accepting pending canon on an existing series, rendering shots or videos. confirm=true from the model is not enough to apply, mark reviewed or commit canon unless the user explicitly asked for that editorial decision. +- When create_series_episode creates a new series, populate series_premise, series_logline, world_summary, visual_style, genre, tone, theme and language as well as episode_title, episode_premise and episode_logline. These are real saved fields: do not put all the creative brief in episode_premise and leave the series setup blank. Use a concise initial cast of three or four useful characters, one or two locations and three to five causal outline_beats unless the user requested more. Write visual_style as concrete provider-facing animation/art direction; preserve the requested narrative tone in tone and world_summary. Set known_universe=true only when the user wants an existing fictional universe, not mere style inspiration. +- A request to create or develop a series with usable creative direction already authorizes create_series_episode to save the first editable draft, including the brand-new canon base needed by that action. The user does not need to separately approve the provisional title, invented cast or first episode premise. This initialization is handled by create_series_episode itself; do not emit extra approval actions or ask for permission to initialize it. This does not authorize accepting pending changes on an existing series, rendering shots/videos or committing later canon proposals. Those decisions still require the user's corresponding intent. - Prefer an installed, enabled text-to-video model from available_video_models. Leave model_type empty when the current/default compatible model is suitable. - For every action object, fill unused string fields with "", unused numeric fields with 0, unused arrays with [], unused booleans with false, queue_scope with "" unless inspecting the queue, and turbo with "keep". seed=-1 means random. - Never invent tasks, progress, models, outputs or errors. If state is missing, say so. @@ -178,6 +184,6 @@ export function buildAgentTurnPrompt( JSON.stringify(taskSnapshot), 'Recent conversation:', JSON.stringify(conversation), - 'Answer the final user message now. Return only the required JSON object.', + 'Interpret the final message together with the earlier goal. Before choosing clarification, distinguish essential missing context from creative details you can propose: a supplied subject, setting, tone or references is enough to develop a requested editable draft. A reply to your creative question continues the earlier creation request. Missing titles, invented cast or pilot plots do not require another approval turn. If the user instead asks to discuss or review before saving, explain a concrete proposal without actions. Return only the required JSON object now.', ].join('\n\n') } diff --git a/ui/src/features/agent/applicationAdapters.ts b/ui/src/features/agent/applicationAdapters.ts index baabe6f79..8f20326cb 100644 --- a/ui/src/features/agent/applicationAdapters.ts +++ b/ui/src/features/agent/applicationAdapters.ts @@ -1,4 +1,5 @@ import { useStore } from '../../stores/useStore' +import { DIRECT_GENERATION_MEDIA, revealDirectorWorkspace, visibleWorkspaceSurface } from '../../lib/navigationCategories' import i18n from '../../i18n' import type { CommandResult } from '../../lib/commandContract' import { rememberedCharacterKitLibrary } from '../characters/session' @@ -209,11 +210,11 @@ function isTabOpen(tab: AgentTab): boolean { if (tab === 'settings') return state.settingsOpen && !state.dashboardOpen if (tab === 'productions') return state.dashboardOpen && !state.settingsOpen if (tab === 'director') { - return state.sidebarMode === 'director' && state.sidebarOpen + return visibleWorkspaceSurface(state) === 'director' && !state.settingsOpen && !state.dashboardOpen } if (tab === 'studio') { - return state.sidebarMode === 'studio' && state.sidebarOpen + return visibleWorkspaceSurface(state) === 'generate' && !state.settingsOpen && !state.dashboardOpen } const mediaFilter = TAB_TARGETS[tab] @@ -235,13 +236,13 @@ async function navigate(tab: AgentTab): Promise { } else if (tab === 'director') { state.setSettingsOpen(false) state.setDashboardOpen(false) - state.setSidebarMode('director') - state.setSidebarOpen(true) + revealDirectorWorkspace(state) window.dispatchEvent(new Event('maestro:director-open')) } else if (tab === 'studio') { state.setSettingsOpen(false) state.setDashboardOpen(false) state.setSidebarMode('studio') + state.setMediaFilter(DIRECT_GENERATION_MEDIA[state.generationMode] || 'videos') state.setSidebarOpen(true) } else { const mediaFilter = TAB_TARGETS[tab] diff --git a/ui/src/features/agent/capabilityRegistry.ts b/ui/src/features/agent/capabilityRegistry.ts index 1f0ee90b2..6453c2fde 100644 --- a/ui/src/features/agent/capabilityRegistry.ts +++ b/ui/src/features/agent/capabilityRegistry.ts @@ -1,4 +1,5 @@ import type { CommandResult } from '../../lib/commandContract' +import { canonicalSceneFps } from '../../lib/sceneFps.ts' import type { AgentAction, AgentApply3dRhythmAction, @@ -699,10 +700,41 @@ defineCapability({ defineCapability({ name: 'create_series_episode', title: 'Create a filled Series Lab episode', - description: 'Create or resolve a series, save its editable canon and create one exact episode with its canonical episode ID.', - useWhen: 'The user asks for a new, filled episode in Series Lab.', - parameters: ['series_title', 'episode_title', 'episode_premise', 'create_if_missing', 'characters', 'locations', 'outline_beats'], - inputSchema: { type: 'object', additionalProperties: false, properties: { type: { const: 'create_series_episode' }, series_title: { type: 'string', maxLength: 300 }, episode_premise: { type: 'string', maxLength: 3_000 }, create_if_missing: { type: 'boolean' } }, required: ['type', 'series_title', 'episode_premise'] }, + description: 'Create or resolve a series, save its premise, visual style and editable world, and create a first episode outline with its canonical episode ID.', + useWhen: 'The user wants to develop a series or episode, including creative direction supplied in reply to an earlier question. Invent missing draft titles and plot details from that direction.', + parameters: ['series_title', 'series_premise', 'series_logline', 'world_summary', 'visual_style', 'genre', 'tone', 'theme', 'language', + 'episode_title', 'episode_premise', 'episode_logline', 'ending', 'target_duration_seconds', 'create_if_missing', 'known_universe', + 'characters', 'locations', 'outline_beats'], + inputSchema: { + type: 'object', additionalProperties: false, + properties: { + type: { const: 'create_series_episode' }, + series_title: { type: 'string', maxLength: 300 }, + series_premise: { type: 'string', maxLength: 3_000 }, series_logline: { type: 'string', maxLength: 2_000 }, + world_summary: { type: 'string', maxLength: 3_000 }, visual_style: { type: 'string', maxLength: 2_000 }, + genre: { type: 'string', maxLength: 300 }, tone: { type: 'string', maxLength: 500 }, + theme: { type: 'string', maxLength: 1_000 }, language: { type: 'string', maxLength: 120 }, + episode_title: { type: 'string', maxLength: 300 }, episode_premise: { type: 'string', maxLength: 3_000 }, + episode_logline: { type: 'string', maxLength: 2_000 }, ending: { type: 'string', maxLength: 2_000 }, + target_duration_seconds: { type: 'number', minimum: 0, maximum: 3_600 }, + create_if_missing: { type: 'boolean' }, known_universe: { type: 'boolean' }, + characters: { type: 'array', maxItems: 16, items: { + type: 'object', additionalProperties: false, + properties: { + name: { type: 'string', maxLength: 160 }, role: { type: 'string', maxLength: 300 }, + personality: { type: 'string', maxLength: 1_000 }, desire: { type: 'string', maxLength: 1_000 }, + flaw: { type: 'string', maxLength: 1_000 }, appearance: { type: 'string', maxLength: 1_000 }, + voice: { type: 'string', maxLength: 1_000 }, + }, required: ['name'], + } }, + locations: { type: 'array', maxItems: 16, items: { + type: 'object', additionalProperties: false, + properties: { name: { type: 'string', maxLength: 160 }, purpose: { type: 'string', maxLength: 1_000 }, description: { type: 'string', maxLength: 1_500 } }, + required: ['name'], + } }, + outline_beats: { type: 'array', maxItems: 24, items: { type: 'string', maxLength: 1_500 } }, + }, required: ['type', 'series_title', 'episode_premise'], + }, risk: 'edit', confirmation: 'none', progress: 'Creando el episodio editable de Series Lab…', resolve(raw) { const fields = seriesEpisodeFields(raw) @@ -1098,8 +1130,8 @@ function sceneWorkflowAction(type: AgentSceneWorkflowAction['type'], raw: Record if (raw.confirm !== true) return null const sceneName = text(raw.scene_name, 300) if (!sceneName) return null - if (type === 'create_3d_scene') return { type, sceneName, durationSeconds: boundedNumber(raw.duration_seconds, 1, 300, 5), width: boundedNumber(raw.width, 320, 7680, 1280), height: boundedNumber(raw.height, 240, 4320, 720), fps: raw.fps === 60 ? 60 : 30, confirm: true } - if (type === 'set_3d_scene_properties') return { type, sceneName, durationSeconds: raw.duration_seconds === undefined ? undefined : boundedNumber(raw.duration_seconds, 1, 300, 5), width: raw.width === undefined ? undefined : boundedNumber(raw.width, 320, 7680, 1280), height: raw.height === undefined ? undefined : boundedNumber(raw.height, 240, 4320, 720), fps: raw.fps === undefined ? undefined : raw.fps === 60 ? 60 : 30, confirm: true } + if (type === 'create_3d_scene') return { type, sceneName, durationSeconds: boundedNumber(raw.duration_seconds, 1, 300, 5), width: boundedNumber(raw.width, 320, 7680, 1280), height: boundedNumber(raw.height, 240, 4320, 720), fps: canonicalSceneFps(raw.fps), confirm: true } + if (type === 'set_3d_scene_properties') return { type, sceneName, durationSeconds: raw.duration_seconds === undefined ? undefined : boundedNumber(raw.duration_seconds, 1, 300, 5), width: raw.width === undefined ? undefined : boundedNumber(raw.width, 320, 7680, 1280), height: raw.height === undefined ? undefined : boundedNumber(raw.height, 240, 4320, 720), fps: raw.fps === undefined ? undefined : canonicalSceneFps(raw.fps), confirm: true } const layerName = text(raw.layer_name, 300) if (type === 'add_3d_scene_layer') { const layerType = text(raw.layer_type, 30) as Extract['layerType'] diff --git a/ui/src/features/agent/programmaticVideo.ts b/ui/src/features/agent/programmaticVideo.ts index 0d93105db..165fece8a 100644 --- a/ui/src/features/agent/programmaticVideo.ts +++ b/ui/src/features/agent/programmaticVideo.ts @@ -73,10 +73,10 @@ export function reconcileProgrammaticVideoRequest(request: string, turn: AgentTu export function registerProgrammaticVideoCapability(register: typeof defineCapability) { register({ name: 'prepare_programmatic_video', title: 'Prepare programmatic Video3D', - description: 'Open the visible Video3D recipe form without running any generator, planning model, render or export. Existing assets only by default. For the built-in SFX showcase, set scene_command EXACTLY to {"version":1,"operation":"scenes.effects.showcase","input":{"dimension":"2d","sound":true}} (or dimension 3d). For the magic/anime showcase add collection="anime" (12 effects, 36 seconds). The server supplies all 30 timed effects by default; never add effects, duration_seconds or prompts to this input. This opens an editable scene, with no video export. scenes.effects.apply and scenes.speech.prepare require the exact existing document.', + description: 'Open the visible Video3D recipe form without running any generator, planning model, render or export. Existing assets only by default. For the built-in SFX showcase, set scene_command EXACTLY to {"version":1,"operation":"scenes.effects.showcase","input":{"dimension":"2d","sound":true}} (or dimension 3d). For the magic/anime showcase add collection="anime" (12 effects, 36 seconds). For retro consoles/VHS add collection="retro" (10 effects, 30 seconds). The server supplies all catalog timed effects by default; never add effects, duration_seconds or prompts to this input. This opens an editable scene, with no video export. scenes.effects.apply and scenes.speech.prepare require the exact existing document.', useWhen: 'The user asks to compose/edit video with Video3D, the compositor, without generative video, or only supplied assets. Prefer this to prepare_video/start_generation or Director. Preserve literal dialogue and lyrics. Never claim a prepared form is a rendered video.', parameters: ['intent', 'output_names', 'scene_command'], - inputSchema: { type: 'object', additionalProperties: false, properties: { type: { const: 'prepare_programmatic_video' }, intent: { type: 'string', minLength: 1, maxLength: 12000 }, scene_command: { type: 'object', description: 'Shared scene command: version=1, operation=scenes.effects.apply (input document,cues,replace), scenes.effects.showcase (input accepts ONLY dimension="2d" or "3d",sound:boolean,collection="all" or "anime",document; document optional to retain an existing scene), or scenes.speech.prepare (input document,slot_id,clip_id,workspace,audio_filename,text,start,end,offset,isolate_vocals optional boolean for installed-only local voice isolation). Supply the exact current document, never invent its objects or resource names.' }, output_names: { type: 'array', maxItems: 32, items: { type: 'string', maxLength: 300 } } }, required: ['type', 'intent'] }, + inputSchema: { type: 'object', additionalProperties: false, properties: { type: { const: 'prepare_programmatic_video' }, intent: { type: 'string', minLength: 1, maxLength: 12000 }, scene_command: { type: 'object', description: 'Shared scene command: version=1, operation=scenes.effects.apply (input document,cues,replace), scenes.effects.showcase (input accepts ONLY dimension="2d" or "3d",sound:boolean,collection="all" or "anime" or "retro",document; document optional to retain an existing scene), or scenes.speech.prepare (input document,slot_id,clip_id,workspace,audio_filename,text,start,end,offset,isolate_vocals optional boolean for installed-only local voice isolation). Supply the exact current document, never invent its objects or resource names.' }, output_names: { type: 'array', maxItems: 32, items: { type: 'string', maxLength: 300 } } }, required: ['type', 'intent'] }, risk: 'edit', confirmation: 'none', progress: 'Preparando el compositor sin lanzar generación…', resolve(raw) { if (typeof raw.intent !== 'string' || !raw.intent.trim()) return null diff --git a/ui/src/features/agent/videoGenerationAdapter.ts b/ui/src/features/agent/videoGenerationAdapter.ts new file mode 100644 index 000000000..e91ededb8 --- /dev/null +++ b/ui/src/features/agent/videoGenerationAdapter.ts @@ -0,0 +1,159 @@ +/** + * HTTP adapter for typed generation.video. + * + * Posts the closed envelope to /api/v1/generation/commands. It does not read + * useStore or applicationAdapters; Studio button wiring remains pending. + */ +import { BASE } from '../../api/http' +import { + buildVideoGenerationCommand, + effectiveVideoRequestsMatch, + mcpArgumentsFromCommand, + videoGenerationPresentation, + VIDEO_GENERATION_OPERATION, + type AgentGenerationVideoAction, + type VideoGenerationCommand, + type VideoGenerationPresentation, +} from './videoGenerationCapability' +import type { GenerationSubmissionContext } from '../studio/generationProvenance' + +export interface VideoGenerationReceipt { + version: 1 + commandId: string + operation: typeof VIDEO_GENERATION_OPERATION + status: 'queued' + taskIds: string[] + result: { + job_id: string + task_id: string + workspace: string + status: 'queued' + } + commandVersion?: 2 + fingerprintVersion?: 2 + contentFingerprint?: string +} + +export interface VideoGenerationSubmitResult { + receipt: VideoGenerationReceipt + replayed: boolean + command: VideoGenerationCommand + presentation: VideoGenerationPresentation + mcpArguments: Record + message: string + taskId: string +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value) +} + +function errorMessage(payload: unknown, fallback: string): string { + if (!isRecord(payload)) return fallback + const detail = payload.detail + if (typeof detail === 'string' && detail.trim()) return detail + if (isRecord(detail) && typeof detail.message === 'string' && detail.message.trim()) { + return detail.message + } + return fallback +} + +async function readJson(response: Response): Promise { + return response.json().catch(() => undefined) +} + +function receiptRecord(value: unknown): Record | null { + if (!isRecord(value)) return null + if ('receipt' in value && isRecord(value.receipt)) return value.receipt + return value +} + +function asReceipt(value: unknown, command: VideoGenerationCommand): VideoGenerationReceipt { + const receipt = receiptRecord(value) + if (!receipt + || receipt.operation !== VIDEO_GENERATION_OPERATION + || receipt.commandId !== command.intent_id + || !isRecord(receipt.result) + || receipt.result.workspace !== command.input.workspace) { + throw new Error( + isRecord(value) + ? 'generation.video receipt does not match the submitted command' + : 'generation.video receipt is missing', + ) + } + return receipt as unknown as VideoGenerationReceipt +} + +function wizardHeaders(context?: GenerationSubmissionContext): Record { + const headers: Record = { + 'content-type': 'application/json', + 'X-Hocus-UI-Surface': 'wizard', + } + if (!context?.workflowId && !context?.runId) return headers + headers['X-Hocus-UI-Context'] = JSON.stringify({ + ...(context.workflowId ? { workflowId: context.workflowId } : {}), + ...(context.runId ? { runId: context.runId } : {}), + }) + return headers +} + +function queuedMessage(replayed: boolean, presentation: VideoGenerationPresentation): string { + if (replayed) return `Reused generation.video admission in ${presentation.workspace}` + return `Queued generation.video in ${presentation.workspace} with ${presentation.modelType} ${presentation.resolution} ${presentation.videoLength} frames` +} + +export function createVideoGenerationAdapter(options: { fetch?: typeof fetch } = {}) { + const send = options.fetch ?? globalThis.fetch.bind(globalThis) + + return { + buildCommand: buildVideoGenerationCommand, + presentation: videoGenerationPresentation, + mcpArguments: mcpArgumentsFromCommand, + matchesMcp(command: VideoGenerationCommand, mcpArguments: Record) { + return effectiveVideoRequestsMatch(command, mcpArguments) + }, + async submit( + action: AgentGenerationVideoAction, + context?: GenerationSubmissionContext, + ): Promise { + const command = buildVideoGenerationCommand(action) + const presentation = videoGenerationPresentation(action) + const response = await send(`${BASE}/api/v1/generation/commands`, { + method: 'POST', + headers: wizardHeaders(context), + body: JSON.stringify(command), + }) + const payload = await readJson(response) + if (!response.ok) { + throw new Error(errorMessage(payload, `generation.video failed (${response.status})`)) + } + const receipt = asReceipt(payload, command) + const replayed = isRecord(payload) && payload.replayed === true + return { + receipt, + replayed, + command, + presentation, + mcpArguments: mcpArgumentsFromCommand(command), + message: queuedMessage(replayed, presentation), + taskId: receipt.result.task_id, + } + }, + async recover(workspace: string, intentId: string): Promise { + const params = new URLSearchParams({ workspace, intent_id: intentId }) + const response = await send(`${BASE}/api/v1/generation/commands/receipt?${params}`) + const payload = await readJson(response) + if (!response.ok) { + throw new Error(errorMessage(payload, `generation.video receipt failed (${response.status})`)) + } + return asReceipt(payload, { + version: 2, + operation: VIDEO_GENERATION_OPERATION, + intent_id: intentId, + input: { workspace, params: {} }, + }) + }, + } +} + +export type VideoGenerationAdapter = ReturnType diff --git a/ui/src/features/agent/videoGenerationCapability.ts b/ui/src/features/agent/videoGenerationCapability.ts new file mode 100644 index 000000000..cce0990aa --- /dev/null +++ b/ui/src/features/agent/videoGenerationCapability.ts @@ -0,0 +1,333 @@ +/** + * Wizard capability for typed generation.video. + * + * This module is tested on its own. Store/applicationAdapters wiring is a + * later integration; Wizard and MCP can still POST /api/v1/generation/commands. + */ +import { stableSerialize } from '../../lib/commandContract' +import { assertCanonicalAudioReference } from '../../lib/canonicalAudioReference' +import { + VIDEO_GENERATION_OPERATION, VIDEO_GENERATION_SCHEMA_VERSION, VIDEO_MODEL_TYPES, + WORKSPACE, MAX_PROMPT, MAX_INTENT, modelType, resolutionValue, + detachedVideoGenerationCommand, type VideoGenerationCommand, type VideoModelType, +} from '../../lib/videoGenerationCommand' +export { + VIDEO_GENERATION_OPERATION, VIDEO_GENERATION_SCHEMA_VERSION, VIDEO_MODEL_TYPES, VIDEO_MODEL_FAMILY, + assertVideoGenerationCommand, detachedVideoGenerationCommand, + type VideoGenerationCommand, type VideoModelType, +} from '../../lib/videoGenerationCommand' + +export const VIDEO_GENERATION_DEFAULTS = { + modelType: 't2v_1.3B' as const, + resolution: '832x480', + videoLength: 81, + numInferenceSteps: 30, + guidanceScale: 5, + seed: -1, + fps: 16, +} + +export interface AgentGenerationVideoAction { + type: 'generation_video' + intentId: string + workspace: string + prompt: string + modelType: VideoModelType + resolution: string + videoLength: number + numInferenceSteps: number + guidanceScale: number + seed?: number + negativePrompt?: string + imageStart?: string | null + workspaceCollectionId?: string + confirm: true +} + +export interface VideoGenerationPresentation { + destination: 'studio' + anchors: string[] + workspace: string + modelType: VideoModelType + resolution: string + videoLength: number + prompt: string + imageStart?: string | null +} + +type OptionalField = { ok: true, value?: T } | { ok: false } + +interface ResolvedVideoFields { + prompt: string + workspace: string + intent: string + selectedModel: VideoModelType + resolution: string + videoLength: number + steps: number + guidance: number +} + +export function alignWanT2vFrames(frames: number): number { + const minimum = 5 + const step = 4 + const bounded = Math.max(minimum, Math.min(10_000, Math.round(frames))) + const delta = (bounded - minimum) % step + if (!delta) return bounded + if (delta >= step / 2) { + const raised = bounded + (step - delta) + return raised > 10_000 ? bounded - delta : raised + } + const lowered = bounded - delta + return lowered < minimum ? bounded + (step - delta) : lowered +} + +export function framesFromDurationSeconds(seconds: number, fps = VIDEO_GENERATION_DEFAULTS.fps): number { + return alignWanT2vFrames(seconds * fps) +} + +function finiteNumber(value: unknown, minimum: number, maximum: number, integer = false): number | undefined { + if (typeof value !== 'number' || !Number.isFinite(value)) return undefined + const bounded = Math.max(minimum, Math.min(maximum, value)) + return integer ? Math.round(bounded) : bounded +} + +function literalPrompt(value: unknown): string | null { + if (typeof value !== 'string' || value.length > MAX_PROMPT || !value.trim()) return null + return value +} + +function workspaceName(value: unknown): string | null { + return typeof value === 'string' && WORKSPACE.test(value) ? value : null +} + +function intentId(value: unknown): string | null { + return typeof value === 'string' && value.trim() !== '' && value.length <= MAX_INTENT ? value : null +} + +function defaultedModel(value: unknown): VideoModelType | null { + return value === undefined ? VIDEO_GENERATION_DEFAULTS.modelType : modelType(value) +} + +function defaultedResolution(value: unknown): string | null { + return value === undefined ? VIDEO_GENERATION_DEFAULTS.resolution : resolutionValue(value) +} + +function defaultedVideoLength(raw: Record): number | undefined { + if (raw.video_length !== undefined) return finiteNumber(raw.video_length, 5, 10_000, true) + if (typeof raw.duration_seconds === 'number') return framesFromDurationSeconds(raw.duration_seconds) + return VIDEO_GENERATION_DEFAULTS.videoLength +} + +function defaultedSteps(value: unknown): number | undefined { + if (value === undefined) return VIDEO_GENERATION_DEFAULTS.numInferenceSteps + return finiteNumber(value, 1, 1000, true) +} + +function defaultedGuidance(value: unknown): number | undefined { + if (value === undefined) return VIDEO_GENERATION_DEFAULTS.guidanceScale + return finiteNumber(value, 0, 1000) +} + +function optionalImageStart(value: unknown): OptionalField { + if (value === undefined) return { ok: true } + if (value === null || value === '') return { ok: true, value } + if (typeof value !== 'string') return { ok: false } + try { + assertCanonicalAudioReference(value, 'image_start', 'video') + return { ok: true, value } + } catch { + return { ok: false } + } +} + +function optionalCollectionId(value: unknown): OptionalField { + if (value === undefined || value === null) return { ok: true } + if (typeof value === 'string' && value.trim() !== '' && value.length <= 200) { + return { ok: true, value } + } + return { ok: false } +} + +function optionalSeed(value: unknown): number | undefined { + if (typeof value === 'number' && Number.isSafeInteger(value)) return value + return undefined +} + +function optionalNegativePrompt(value: unknown): string | undefined { + if (typeof value === 'string' && value.length <= MAX_PROMPT) return value + return undefined +} + +function requiredResolvedFields(raw: Record): ResolvedVideoFields | null { + const prompt = literalPrompt(raw.prompt) + const workspace = workspaceName(raw.workspace) + const intent = intentId(raw.intent_id) + const selectedModel = defaultedModel(raw.model_type) + const resolution = defaultedResolution(raw.resolution) + if (!prompt || !workspace || !intent || !selectedModel || !resolution) return null + const videoLength = defaultedVideoLength(raw) + const steps = defaultedSteps(raw.num_inference_steps) + const guidance = defaultedGuidance(raw.guidance_scale) + if (videoLength === undefined || steps === undefined || guidance === undefined) return null + return { prompt, workspace, intent, selectedModel, resolution, videoLength, steps, guidance } +} + +function videoActionFromResolved( + fields: ResolvedVideoFields, + extras: { + seed?: number + negativePrompt?: string + imageStart?: string | null + collectionId?: string + }, +): AgentGenerationVideoAction { + const action: AgentGenerationVideoAction = { + type: 'generation_video', + intentId: fields.intent, + workspace: fields.workspace, + prompt: fields.prompt, + modelType: fields.selectedModel, + resolution: fields.resolution, + videoLength: alignWanT2vFrames(fields.videoLength), + numInferenceSteps: fields.steps, + guidanceScale: fields.guidance, + confirm: true, + } + if (extras.seed !== undefined) action.seed = extras.seed + if (extras.negativePrompt !== undefined) action.negativePrompt = extras.negativePrompt + if (extras.imageStart !== undefined) action.imageStart = extras.imageStart + if (extras.collectionId !== undefined) action.workspaceCollectionId = extras.collectionId + return action +} + +export function resolveVideoGenerationAction(raw: Record): AgentGenerationVideoAction | null { + if (raw.confirm !== true) return null + const fields = requiredResolvedFields(raw) + const imageStart = optionalImageStart(raw.image_start) + const collection = optionalCollectionId(raw.workspace_collection_id) + if (!fields || !imageStart.ok || !collection.ok) return null + return videoActionFromResolved(fields, { + seed: optionalSeed(raw.seed), + negativePrompt: optionalNegativePrompt(raw.negative_prompt), + imageStart: imageStart.value, + collectionId: collection.value, + }) +} + +export function validateVideoGenerationAction(action: AgentGenerationVideoAction): string[] { + if (action.confirm !== true) return ['confirmation is required'] + if (!action.prompt.trim()) return ['a literal prompt is required'] + if (!WORKSPACE.test(action.workspace)) return ['an explicit output workspace is required'] + if (!VIDEO_MODEL_TYPES.includes(action.modelType)) return ['choose t2v or t2v_1.3B'] + return [] +} + +export function videoGenerationPresentation(action: AgentGenerationVideoAction): VideoGenerationPresentation { + const presentation: VideoGenerationPresentation = { + destination: 'studio', + anchors: ['video', 'generate', 'destination'], + workspace: action.workspace, + modelType: action.modelType, + resolution: action.resolution, + videoLength: action.videoLength, + prompt: action.prompt, + } + if (action.imageStart !== undefined) presentation.imageStart = action.imageStart + return presentation +} + +function commandParamsFromAction(action: AgentGenerationVideoAction): Record { + const params: Record = { + prompt: action.prompt, + model_type: action.modelType, + resolution: action.resolution, + video_length: action.videoLength, + num_inference_steps: action.numInferenceSteps, + guidance_scale: action.guidanceScale, + generation_mode: 'video', + image_mode: 0, + multi_prompts_gen_type: 2, + } + if (action.seed !== undefined) params.seed = action.seed + if (action.negativePrompt !== undefined) params.negative_prompt = action.negativePrompt + if (action.imageStart !== undefined) params.image_start = action.imageStart + return params +} + +export function buildVideoGenerationCommand(action: AgentGenerationVideoAction): VideoGenerationCommand { + const errors = validateVideoGenerationAction(action) + if (errors.length) throw new Error(errors[0]) + const input: VideoGenerationCommand['input'] = { + workspace: action.workspace, + params: commandParamsFromAction(action), + } + if (action.workspaceCollectionId !== undefined) { + input.workspace_collection_id = action.workspaceCollectionId + } + return detachedVideoGenerationCommand({ + version: VIDEO_GENERATION_SCHEMA_VERSION, + operation: VIDEO_GENERATION_OPERATION, + intent_id: action.intentId, + input, + }) +} + +export function mcpArgumentsFromCommand(command: VideoGenerationCommand): Record { + const arguments_ = { ...command } as Record + delete arguments_.operation + return arguments_ +} + +export function effectiveVideoRequestsMatch( + wizardCommand: VideoGenerationCommand, + mcpArguments: Record, +): boolean { + return stableSerialize(mcpArgumentsFromCommand(wizardCommand)) === stableSerialize(mcpArguments) +} + +export const videoGenerationCapability = { + name: 'generation_video' as const, + title: 'Generate Wan 2.1 Text2Video', + description: 'Admit a typed generation.video job for an installed t2v or t2v_1.3B model in an explicit workspace.', + useWhen: 'The user explicitly asks to generate video with the shared Wizard/MCP command.', + parameters: [ + 'intent_id', 'workspace', 'prompt', 'model_type', 'resolution', 'video_length', + 'duration_seconds', 'num_inference_steps', 'guidance_scale', 'seed', + 'negative_prompt', 'image_start', 'workspace_collection_id', 'confirm', + ], + inputSchema: { + type: 'object', + additionalProperties: false, + properties: { + type: { const: 'generation_video' }, + intent_id: { type: 'string', minLength: 1, maxLength: 160 }, + workspace: { type: 'string', minLength: 1, maxLength: 240 }, + prompt: { type: 'string', minLength: 1, maxLength: MAX_PROMPT }, + model_type: { type: 'string', enum: [...VIDEO_MODEL_TYPES] }, + resolution: { type: 'string' }, + video_length: { type: 'integer', minimum: 5, maximum: 10_000 }, + duration_seconds: { type: 'number', exclusiveMinimum: 0 }, + num_inference_steps: { type: 'integer', minimum: 1, maximum: 1000 }, + guidance_scale: { type: 'number' }, + seed: { type: 'integer' }, + negative_prompt: { type: 'string' }, + image_start: { type: ['string', 'null'] }, + workspace_collection_id: { type: 'string', minLength: 1, maxLength: 200 }, + confirm: { const: true }, + }, + required: ['type', 'intent_id', 'workspace', 'prompt', 'confirm'], + }, + risk: 'compute' as const, + confirmation: 'required' as const, + progress: 'Admitting Wan Text2Video…', + resolve: resolveVideoGenerationAction, + validate: validateVideoGenerationAction, + presentation: { destination: 'studio' as const, anchors: ['video', 'generate', 'destination'], replay: 'atomic' as const }, +} + +export function registerVideoGenerationCapability( + register: (definition: typeof videoGenerationCapability) => unknown, +): void { + register(videoGenerationCapability) +} diff --git a/ui/src/features/agent/wizardContext.ts b/ui/src/features/agent/wizardContext.ts index 72ae7d310..3fc4d4f71 100644 --- a/ui/src/features/agent/wizardContext.ts +++ b/ui/src/features/agent/wizardContext.ts @@ -1,4 +1,5 @@ import { useStore } from '../../stores/useStore' +import { visibleWorkspaceSurface } from '../../lib/navigationCategories' import { emptyCharacterKitLibrary } from '../../lib/characterKit' import { comicArtworkInventory } from '../comics/generateArtwork' import { useComicStore } from '../comics/store' @@ -840,10 +841,11 @@ export function comicLabSnapshot() { function inferredLocation(state: ReturnType): WizardContextLocation { if (state.settingsOpen) return { area: 'settings', tab: 'settings', section: state.settingsTab || '' } if (state.dashboardOpen) return { area: 'productions', tab: 'productions', section: 'queue' } - if (state.sidebarMode === 'director' && state.sidebarOpen) { + const surface = visibleWorkspaceSurface(state) + if (surface === 'director') { return { area: 'director', tab: 'director', section: state.directorStep || '' } } - if (state.sidebarMode === 'studio' && state.sidebarOpen) { + if (surface === 'generate') { const section = state.generationMode === 'audio' ? state.audioSubMode : state.generationMode === 'avatar' ? state.editSubMode : state.generationMode diff --git a/ui/src/features/agent/wizardIntent.ts b/ui/src/features/agent/wizardIntent.ts new file mode 100644 index 000000000..2d3aca897 --- /dev/null +++ b/ui/src/features/agent/wizardIntent.ts @@ -0,0 +1,38 @@ +/** The LLM interprets the request in context; application code validates the plan. */ +export interface WizardIntent { + kind: 'conversation' | 'clarification' | 'action' + goal: string + question: string + execution: 'none' | 'prepare' | 'run' +} + +export const WIZARD_INTENT_SCHEMA = { + type: 'object', + additionalProperties: false, + properties: { + kind: { type: 'string', enum: ['conversation', 'clarification', 'action'] }, + goal: { type: 'string', minLength: 1, maxLength: 2_000 }, + question: { type: 'string', maxLength: 2_000 }, + execution: { type: 'string', enum: ['none', 'prepare', 'run'] }, + }, + required: ['kind', 'goal', 'question', 'execution'], +} + +export function parseWizardIntent(value: unknown): WizardIntent | null { + if (!value || typeof value !== 'object') return null + const raw = value as Record + if (raw.kind !== 'conversation' && raw.kind !== 'clarification' && raw.kind !== 'action') return null + if (typeof raw.goal !== 'string' || !raw.goal.trim() || raw.goal.length > 2_000) return null + if (typeof raw.question !== 'string' || raw.question.length > 2_000) return null + if (raw.kind === 'clarification' && !raw.question.trim()) return null + if (raw.execution !== 'none' && raw.execution !== 'prepare' && raw.execution !== 'run') return null + if (raw.kind === 'action' && raw.execution === 'none') return null + // Conversation and clarification never authorize work. Normalize redundant + // model fields conservatively instead of losing a useful follow-up question. + return { + kind: raw.kind, + goal: raw.goal.trim(), + question: raw.kind === 'clarification' ? raw.question.trim() : '', + execution: raw.kind === 'action' ? raw.execution as 'prepare' | 'run' : 'none', + } +} diff --git a/ui/src/features/agent/wizardTurnReport.ts b/ui/src/features/agent/wizardTurnReport.ts index 92dc894c0..cf7c6def3 100644 --- a/ui/src/features/agent/wizardTurnReport.ts +++ b/ui/src/features/agent/wizardTurnReport.ts @@ -3,7 +3,7 @@ import { stableSerialize } from './agentContract' import type { AgentVisualState } from './AgentAvatar' export type WizardRejectionCode = 'invalid_action' | 'invalid_action_list' | 'action_limit' - | 'preparation_required' | 'duplicate_generation' | 'request_policy' | 'visual_evidence_only' + | 'preparation_required' | 'duplicate_generation' | 'request_policy' | 'visual_evidence_only' | 'invalid_intent' export interface WizardActionRejection { index: number @@ -41,16 +41,6 @@ export function withWizardRejections(before: AgentTurn, after: AgentTurn, type Translate = (key: string, options?: Record) => string -/** Conservative presentation policy, not an authorization or execution classifier. */ -function allowsExplanation(request: string): boolean { - // JS `\b` is ASCII-only. Fold accents so "Qué" / "por qué" keep a word boundary. - const text = request.trim().replace(/^[¿¡]+/, '').normalize('NFD').replace(/[\u0300-\u036f]/g, '') - if (/^(?:hola|hello|hi|gracias|thanks)[\s!.]*$/i.test(text)) return true - // An informational prefix does not erase a later imperative in a mixed turn. - if (/(?:[,;.!?\n]|\b(?:and|then|also|y|luego|despu[eé]s))\s*(?:(?:please|por favor)[,\s]+)?(?:create|generate|make|update|delete|remove|add|save|export|start|retry|run|open|select|crea\w*|genera\w*|haz\w*|actualiza\w*|elimina\w*|borra\w*|a[nñ]ade\w*|guarda\w*|exporta\w*|inicia\w*|reintenta\w*|ejecuta\w*|abre|selecciona\w*)\b/i.test(text)) return false - return /^(?:(?:please|por favor)[,\s]+)?(?:how\b|what\b|which\b|why\b|where\b|explain\b|describe\b|tell me (?:about|how|what|why)\b|(?:can|could) you (?:explain|describe)\b|c[oó]mo\b|qu[eé]\b|cu[aá]l\b|por qu[eé]\b|d[oó]nde\b|explica(?:me|rme)?\b|describe\b|descr[ií]beme\b|(?:puedes|podr[ií]as) explica(?:r|rme)\b)/i.test(text) -} - export function wizardResultState(result: AgentActionResult) { const states = [result.commandResult?.status, result.report?.state] if (states.includes('failed')) return 'failed' @@ -79,14 +69,17 @@ export function wizardTurnVisualState(turn: AgentTurn, results: AgentActionResul const states = results.map(wizardResultState) if (turn.rejections?.length || states.some(state => ['failed', 'partial'].includes(state))) return 'error' if (states.some(state => state === 'queued' || state === 'running')) return 'acting' + if (turn.intent?.kind === 'clarification') return 'idle' return states.length && states.every(state => state === 'completed') ? 'success' : 'idle' } /** Free-form model prose cannot certify the result of an action-bearing turn. */ -export function formatWizardTurnReply(turn: AgentTurn, results: AgentActionResult[], t: Translate, request = ''): string { +export function formatWizardTurnReply(turn: AgentTurn, results: AgentActionResult[], t: Translate): string { const hasActions = Boolean(turn.actions.length || results.length || turn.rejections?.length) - const explanation = !hasActions && allowsExplanation(request) + const explanation = !hasActions && turn.intent?.kind === 'conversation' + const question = turn.intent?.kind === 'clarification' ? turn.intent.question : '' const paragraphs: string[] = [] + if (question) paragraphs.push(question) if (explanation && turn.reply) paragraphs.push(turn.reply) if (results.length) { const lines = results.map(result => { @@ -94,7 +87,7 @@ export function formatWizardTurnReply(turn: AgentTurn, results: AgentActionResul return `- **${label}.** ${result.message}` }) paragraphs.push(`### ${t('actionReport')}\n${lines.join('\n')}`) - } else if (!explanation) paragraphs.push(t('noActionReceipt')) + } else if (!explanation && !question) paragraphs.push(t('noActionReceipt')) if (turn.rejections?.length) { const lines = turn.rejections.map(rejection => `- ${t('rejectedAction', { action: rejection.actionType, diff --git a/ui/src/features/agent/wizardVisualPolicy.ts b/ui/src/features/agent/wizardVisualPolicy.ts index b847fe392..ddf4ad9cd 100644 --- a/ui/src/features/agent/wizardVisualPolicy.ts +++ b/ui/src/features/agent/wizardVisualPolicy.ts @@ -1,10 +1,26 @@ -import { reconcileAgentTurnWithRequest } from './agentActions' -import { withWizardRejections } from './wizardTurnReport' +import type { AgentTurn } from './agentActions' +import { getCapability } from './capabilityRegistry' +import { isExpensiveAction } from './agentContract' +import { rejectedWizardAction, withWizardRejections } from './wizardTurnReport' -/** A visual answer is evidence only; even an LLM's confirm:true cannot grant action authority. */ -export async function reconcileWizardMediaTurn(hasVisualMedia: boolean, - ...args: Parameters) { - const before = args[1] - const after = hasVisualMedia ? { ...before, actions: [] } : await reconcileAgentTurnWithRequest(...args) +/** Validate the interpreted intent. Never infer or manufacture actions from words in the request. */ +export function validateWizardPlan(hasVisualMedia: boolean, before: AgentTurn): AgentTurn { + let after = before + if (!before.intent) { + after = { ...before, actions: [], rejections: [ + ...(before.rejections || []), rejectedWizardAction({ type: 'intent' }, 0, 'invalid_intent'), + ] } + } else if (hasVisualMedia || before.intent.kind === 'conversation') { + // Visual analysis remains evidence only, as in the existing media contract. + after = { ...before, actions: [] } + } else if (before.intent.kind === 'clarification') { + after = { ...before, actions: before.actions.filter(action => + action.type === 'open_tab' || action.type === 'open_story_section' || action.type === 'open_series_section') } + } else if (before.intent.execution === 'prepare') { + after = { ...before, actions: before.actions.filter(action => { + const risk = getCapability(action.type)?.risk + return risk !== 'compute' && risk !== 'external_cost' && !isExpensiveAction(action.type) + }) } + } return withWizardRejections(before, after, hasVisualMedia ? 'visual_evidence_only' : 'request_policy') } diff --git a/ui/src/features/agent/wizardWorkflowRuntime.ts b/ui/src/features/agent/wizardWorkflowRuntime.ts index 1010488ca..91002c294 100644 --- a/ui/src/features/agent/wizardWorkflowRuntime.ts +++ b/ui/src/features/agent/wizardWorkflowRuntime.ts @@ -3,6 +3,7 @@ import { saveWizardWorkflows, type WizardWorkflowCollectionPayload, } from '../../api/client' +import { BASE } from '../../api/http' import type { CanonicalTaskEvent } from '../../lib/canonicalTaskEvents' import { cardFromReport, type WizardExecutionCard } from './executionCards' import { executionKey, executionReport } from './agentContract' @@ -61,6 +62,25 @@ export interface WizardWorkflowAnswerOptions { stepId?: string } +export const SERVER_WORKFLOW_OWNER = 'server' + +export class WizardWorkflowAnswerConflict extends Error { + readonly recoverable = true + readonly expectedRevision?: number + readonly currentRevision?: number + + constructor(message: string, expected?: number, current?: number) { + super(message) + this.name = 'WizardWorkflowAnswerConflict' + this.expectedRevision = expected + this.currentRevision = current + } +} + +export function isServerOwnedWorkflow(workflow: Pick): boolean { + return workflow.executorOwner === SERVER_WORKFLOW_OWNER +} + export interface WizardWorkflowStepRecord { stepId: string kind: string @@ -99,6 +119,9 @@ export interface WizardWorkflowRecord { cancelRequested: boolean resumeRequested: boolean pendingInput: WizardWorkflowPendingInput | null + executorOwner: string + leaseToken: string + leaseExpiresAt: number } export interface WizardWorkflowCollection { @@ -396,6 +419,9 @@ function normalizeWorkflow(value: unknown): WizardWorkflowRecord | null { cancelRequested: raw.cancelRequested === true, resumeRequested: raw.resumeRequested === true, pendingInput, + executorOwner: String(raw.executorOwner || ''), + leaseToken: String(raw.leaseToken || ''), + leaseExpiresAt: Math.max(0, Number(raw.leaseExpiresAt) || 0), } } @@ -486,6 +512,7 @@ export class WizardWorkflowRuntime { if (this.opened) { for (const workflow of this.collection.workflows) { if (workflow.type !== definition.type || workflow.workspace !== this.workspace) continue + if (isServerOwnedWorkflow(workflow)) continue const step = workflow.steps[workflow.currentStep] if (workflow.state === 'prepared' || workflow.state === 'retrying' || (workflow.state === 'running' && step?.state !== 'waiting' && step?.state !== 'awaiting_input')) { @@ -553,6 +580,7 @@ export class WizardWorkflowRuntime { processedEventIds: [], attempts: 0, createdAt: now, updatedAt: now, recoverableError: '', cancelRequested: false, resumeRequested: false, pendingInput: null, + executorOwner: '', leaseToken: '', leaseExpiresAt: 0, } this.collection.workflows.push(workflow) await this.persist() @@ -569,6 +597,7 @@ export class WizardWorkflowRuntime { const matches = this.collection.workflows.filter(workflow => { const step = workflow.steps[workflow.currentStep] return workflow.workspace === this.workspace + && !isServerOwnedWorkflow(workflow) && step?.state === 'waiting' && step.taskId === event.task_id && !workflow.processedEventIds.includes(event.event_id) @@ -647,6 +676,10 @@ export class WizardWorkflowRuntime { } if (!isRecord(answer)) throw new Error('Input answer must be a JSON object.') validateInputAnswer(pending, answer) + if (isServerOwnedWorkflow(workflow)) { + await this.answerOnServer(workflow, answer, answerOptions) + return + } const now = Date.now() step.input = applyDeclaredFields(step.input, pending.fields, answer) @@ -671,6 +704,45 @@ export class WizardWorkflowRuntime { return this.get(workflowId) as WizardWorkflowRecord } + private async answerOnServer( + workflow: WizardWorkflowRecord, + answer: Record, + options: WizardWorkflowAnswerOptions | undefined, + ): Promise { + const response = await fetch(`${BASE}/api/v1/wizard/workflows/executor/answer`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + workspace: workflow.workspace, + workflowId: workflow.workflowId, + expectedRevision: this.collection.revision, + stepId: options?.stepId || workflow.pendingInput?.stepId, + answerVersion: options?.version, + answer, + }), + }) + const payload = await response.json().catch(() => null) as { + detail?: { message?: string; expectedRevision?: number; currentRevision?: number } + workflow?: unknown + revision?: number + } | null + if (response.status === 409) { + throw new WizardWorkflowAnswerConflict( + String(payload?.detail?.message || 'Wizard workflow revision conflict'), + payload?.detail?.expectedRevision, + payload?.detail?.currentRevision, + ) + } + if (!response.ok) throw new Error('Could not answer the server-owned Wizard workflow.') + const record = normalizeWorkflow(payload?.workflow) + if (!record) throw new Error('Could not answer the server-owned Wizard workflow.') + this.collection.revision = Math.max(0, Number(payload?.revision) || this.collection.revision) + const index = this.collection.workflows.findIndex(item => item.workflowId === record.workflowId) + if (index >= 0) this.collection.workflows[index] = record + else this.collection.workflows.push(record) + this.emit(record) + } + async resume( workflowId: string, answer?: Record, diff --git a/ui/src/features/asset-picker/previewPlayer.tsx b/ui/src/features/asset-picker/previewPlayer.tsx index a2eba98a9..c72d7aa16 100644 --- a/ui/src/features/asset-picker/previewPlayer.tsx +++ b/ui/src/features/asset-picker/previewPlayer.tsx @@ -2,8 +2,13 @@ import { useEffect, useRef, useState } from 'react' import { Play } from 'lucide-react' import { useUiTranslation } from '../../i18n' import type { PickerItem } from './types.ts' - -const PREVIEW_BYTE_LIMIT = 80 * 1024 * 1024 +import { + PREVIEW_BYTE_LIMIT, + getSharedPreviewPool, + needsFullPreview, + type PreviewResourcePool, + type PreviewSession, +} from './previewResources.ts' function pauseMedia(element: HTMLMediaElement | null) { if (!element) return @@ -25,17 +30,48 @@ function GlbPreview({ url }: { url: string }) { return () => { cancelled = true } }, [url]) if (!ready) return

{t('explorer.loading')}

- return + return +} + +export function AssetPreviewPlayer({ + item, + pool, +}: { + item: PickerItem + pool?: PreviewResourcePool +}) { + return } -export function AssetPreviewPlayer({ item }: { item: PickerItem }) { +function PreviewPlayerBody({ + item, + pool, +}: { + item: PickerItem + pool?: PreviewResourcePool +}) { const { t } = useUiTranslation('common') const videoRef = useRef(null) const audioRef = useRef(null) - const [armedUrl, setArmedUrl] = useState('') + const sessionRef = useRef(null) + const resources = pool ?? getSharedPreviewPool() + const [armed, setArmed] = useState(false) + const [playUrl, setPlayUrl] = useState(item.url) const [failed, setFailed] = useState(false) - const armed = armedUrl === item.url const large = item.sizeBytes > PREVIEW_BYTE_LIMIT + const sourceUrl = item.url + const workspaceId = item.ref.workspaceId + const mediaKind = item.kind + const sizeBytes = item.sizeBytes + + useEffect(() => { + const session = resources.createSession({ scope: 'picker' }) + sessionRef.current = session + return () => { + session.dispose() + sessionRef.current = null + } + }, [resources]) useEffect(() => { const video = videoRef.current @@ -44,7 +80,27 @@ export function AssetPreviewPlayer({ item }: { item: PickerItem }) { pauseMedia(video) pauseMedia(audio) } - }, [item.url, armed]) + }, [armed, playUrl]) + + useEffect(() => { + const session = sessionRef.current + if (!session || !needsFullPreview({ kind: mediaKind }, armed)) return + let cancelled = false + void session.acquire({ + sourceUrl, + workspaceId, + layer: 'full', + mediaKind, + sizeBytes, + }).then(lease => { + if (cancelled || !lease) return + setPlayUrl(lease.playUrl) + }) + return () => { + cancelled = true + session.cancelCurrent() + } + }, [armed, sourceUrl, workspaceId, mediaKind, sizeBytes, resources]) if (failed) { return

{t('explorer.previewFailed')}

@@ -63,7 +119,7 @@ export function AssetPreviewPlayer({ item }: { item: PickerItem }) { type="button" data-testid="asset-preview-arm" aria-label={item.kind === 'model3d' ? t('explorer.view3d') : t('explorer.playPreview')} - onClick={() => { setFailed(false); setArmedUrl(item.url) }} + onClick={() => { setFailed(false); setArmed(true); setPlayUrl(item.url) }} className="relative flex h-full w-full items-center justify-center" > {item.thumbnailUrl ? : null} @@ -79,7 +135,8 @@ export function AssetPreviewPlayer({ item }: { item: PickerItem }) {