From 7d4c759f8a494ae5ed5d945f0593a8335654e365 Mon Sep 17 00:00:00 2001 From: vveerrgg Date: Mon, 9 Nov 2026 12:39:24 -0700 Subject: [PATCH 01/12] Release tooling: install-path test (clean ComfyUI, CPU), one release command, site deploy on publish, stability note; private-methods scrub; demo zip re-stamped to a publishable 0.1.1 - scripts/install_path_check.py + .github/workflows/install-path.yml: every LSDub node registers in ComfyUI v0.42.0 with only the user requirements; Align ctc from an empty models folder (four pinned files, no safetensors, no phonemizer); a Dub at defaults; a Dub with the four mouth Combos. PRs to main, release/** pushes, weekly, by hand; publish.yml calls it first. - scripts/release.py X.Y.Z [--dry-run]: CONTRIBUTING, Releases. - publish.yml: dispatches lsdub-release to the microsite after a good publish (skips with a notice without MICROSITE_DISPATCH_TOKEN). - tests/test_registry_rules.py: refuses ledger dirs, docs/research|plans and the private lane names in the shipped tree. - fetch_demo.py pointed at a zip that was never published (pinned a431085d, the v0.1.0 asset is 7981fef8): every user's demo fetch failed the checksum. Re-rooted as lsdub-demo-0.1.1 (same files, reproducible build), tag demo-0.1.1 (to be attached). - Engine tree f36b7410 vendored (methods scrub; two comments reworded). --- .github/workflows/ci.yml | 2 +- .github/workflows/install-path.yml | 73 +++++++ .github/workflows/publish.yml | 21 ++ CONTRIBUTING.md | 29 +++ README.md | 21 +- lsdub_engine/VERSION | 4 +- lsdub_engine/audits/qa.py | 2 +- scripts/fetch_demo.py | 6 +- scripts/install_path_check.py | 324 +++++++++++++++++++++++++++++ scripts/release.py | 261 +++++++++++++++++++++++ tests/engine/test_watermark.py | 2 +- tests/test_registry_rules.py | 26 ++- 12 files changed, 760 insertions(+), 11 deletions(-) create mode 100644 .github/workflows/install-path.yml create mode 100644 scripts/install_path_check.py create mode 100644 scripts/release.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 43dff2e..c6c6a06 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,7 +2,7 @@ name: CI on: pull_request: push: - branches: [main, rack] + branches: [main, rack, "release/**"] permissions: contents: read jobs: diff --git a/.github/workflows/install-path.yml b/.github/workflows/install-path.yml new file mode 100644 index 0000000..28c36cd --- /dev/null +++ b/.github/workflows/install-path.yml @@ -0,0 +1,73 @@ +# The install-path test: LSDub in a clean ComfyUI the way a user gets it (ComfyUI's requirements + requirements.txt, no dev +# extras), CPU only. Every LSDub node registers; Align with aligner ctc from an empty models folder (the real recogniser +# download); a Dub at defaults; a Dub with the mouth Combos. scripts/install_path_check.py does the work; it runs the same on a +# laptop. publish.yml calls this workflow first, so a tag that fails it is never published. +name: Install path +on: + pull_request: + branches: [main] + push: + branches: ["release/**"] # scripts/release.py waits for this run before it tags + workflow_dispatch: + workflow_call: + schedule: + - cron: "17 6 * * 1" # weekly: ComfyUI's pin stays put, but PyPI and the Hub do not +permissions: + contents: read +concurrency: + group: install-path-${{ github.ref }} + cancel-in-progress: true +env: + COMFYUI_REPO: https://github.com/comfyanonymous/ComfyUI.git + COMFYUI_COMMIT: 355cd6b14c92c19ac3188fe528e47a3f21ef5178 # v0.42.0 + RECOGNISER_REVISION: ae45363bf3413b374fecd9dc8bc1df0e24c3b7f4 # facebook/wav2vec2-lv-60-espeak-cv-ft, as lsdub_engine/_models.py pins it + # only used if scripts/fetch_demo.py itself fails (that still fails the job): the released v0.1.0 demo zip at its real checksum + DEMO_FALLBACK_URL: https://github.com/HumanjavaEnterprises/comfyui-lsdub/releases/download/v0.1.0/lsdub-demo-0.1.0.zip + DEMO_FALLBACK_SHA: 7981fef8f453b0bf29e47b3ffc00b3adcba1d22d8dbe259504f66c2beb00de17 +jobs: + install-path: + runs-on: ubuntu-latest + timeout-minutes: 150 + steps: + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 + with: + path: pack + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: "3.12" + - name: System deps (what the README asks a user for) + run: sudo apt-get update && sudo apt-get install -y ffmpeg libgles2 libegl1 + - name: The pinned recogniser revision is the one the pack pins + run: grep -q "$RECOGNISER_REVISION" pack/lsdub_engine/_models.py + - name: ComfyUI at the pinned commit + run: | + git init -q comfy && cd comfy + git fetch -q --depth 1 "$COMFYUI_REPO" "$COMFYUI_COMMIT" && git checkout -q FETCH_HEAD + - name: A user's venv (ComfyUI requirements + requirements.txt, CPU torch) + run: | + python -m venv venv + venv/bin/pip install -q --upgrade pip + venv/bin/pip install -q torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cpu + venv/bin/pip install -q -r comfy/requirements.txt -r pack/requirements.txt + venv/bin/pip freeze | grep -iE '^(torch|transformers|huggingface.hub|mediapipe|numpy|phonemizer)=' || true + if venv/bin/python -c "import phonemizer" 2>/dev/null; then echo "::error::phonemizer is installed; the test needs a venv without it"; exit 1; fi + - name: Recogniser cache (fetched from the Hub once per revision, then served to the same client path) + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + with: + path: hf-mirror + key: lsdub-recogniser-${{ env.RECOGNISER_REVISION }} + - name: Demo fallback zip + run: curl -sSfL -o demo-fallback.zip "$DEMO_FALLBACK_URL" + - name: Install path (nodes, align ctc from empty, dub at defaults, dub with the mouth Combos) + run: | + venv/bin/python pack/scripts/install_path_check.py --comfyui comfy --pack pack \ + --hf-mirror hf-mirror --save-mirror hf-mirror \ + --demo-fallback demo-fallback.zip --demo-fallback-sha "$DEMO_FALLBACK_SHA" + - name: ComfyUI log + if: always() + run: tail -n 300 comfy/install-path-comfyui.log || true + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + if: failure() + with: + name: install-path-log + path: comfy/install-path-comfyui.log diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index be6c0b1..c4329eb 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -5,7 +5,11 @@ on: permissions: contents: read jobs: + install-path: # the gate: a tag whose tree fails the clean-ComfyUI install test is never published + if: github.repository == 'HumanjavaEnterprises/comfyui-lsdub' + uses: ./.github/workflows/install-path.yml publish: + needs: install-path runs-on: ubuntu-latest if: github.repository == 'HumanjavaEnterprises/comfyui-lsdub' steps: @@ -18,3 +22,20 @@ jobs: with: personal_access_token: ${{ secrets.REGISTRY_ACCESS_TOKEN }} # set this secret in the repo settings skip_checkout: 'true' + site: # after a good publish: ask the microsite to deploy its main branch (it serves /changelog) + needs: publish + runs-on: ubuntu-latest + steps: + - name: Dispatch lsdub-release to the microsite + env: + TOKEN: ${{ secrets.MICROSITE_DISPATCH_TOKEN }} + TAG: ${{ github.ref_name }} + run: | + if [ -z "$TOKEN" ]; then + echo "::notice title=Site deploy skipped::MICROSITE_DISPATCH_TOKEN is not set, so the microsite was not asked to deploy. Add it (CONTRIBUTING.md, Releases) or deploy the site by hand." + exit 0 + fi + curl -sSf -X POST -H "Authorization: Bearer $TOKEN" -H "Accept: application/vnd.github+json" \ + https://api.github.com/repos/HumanjavaEnterprises/lsdub.web.microsite.src/dispatches \ + -d "{\"event_type\":\"lsdub-release\",\"client_payload\":{\"tag\":\"$TAG\"}}" + echo "::notice title=Site deploy requested::lsdub-release $TAG sent to the microsite" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index db1f292..7f5b94e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -29,3 +29,32 @@ By contributing you agree your work is licensed under the Apache License 2.0, th - No `## Unreleased` heading in the file; add the section when the version is released. - The heading names the version in pyproject.toml. `python scripts/release_notes.py` checks all of this; CI runs it (`tests/test_release_notes.py`) and the publish job fails without it. + +## Stability policy +- Releases are batched; patch versions only for breakage. Features go into the next minor version. +- Defaults stay byte-identical across a minor series unless the changelog says otherwise. +- Every release passes the install-path test (`.github/workflows/install-path.yml`, `scripts/install_path_check.py`) on a + clean ComfyUI before it is tagged. Run it yourself: `python scripts/install_path_check.py --comfyui PATH/TO/ComfyUI` with + that ComfyUI's venv (its requirements plus `requirements.txt`, nothing else). +- Models are pinned by revision and sha256. +- 0.2.0 to 0.2.2 landed in one day while the install-path test did not yet exist; it has run on every release since. + +## Releases (maintainers) +One command, from a clean pack checkout on main with the engine and the microsite checked out beside it: + + python scripts/release.py X.Y.Z --dry-run # every check, in a throwaway worktree; pushes nothing + python scripts/release.py X.Y.Z + +Before it: write the `## X.Y.Z` section in CHANGELOG.md (see Release notes) and the matching `
` in the +microsite's `docs/changelog.html` on its main. The command refuses unless both trees are clean and on main and engine CI is +green; bumps the version, the Icon/Banner URLs and CITATION.cff; regenerates the node tables and the bug form; vendors the +engine; runs the pack checks; pushes `release/X.Y.Z` and waits for CI and Install path; then fast-forwards main, tags `vX.Y.Z`, +watches the publish (which runs Install path again first), confirms the Registry lists the version and checks the site +deploy. Any failure before the tag stops with a plain message and leaves nothing changed remotely. + +Repository secrets: +- `REGISTRY_ACCESS_TOKEN` (here): the Comfy Registry publisher key, already set. +- `MICROSITE_DISPATCH_TOKEN` (here): a fine-grained GitHub token, resource owner HumanjavaEnterprises, repository access only + `lsdub.web.microsite.src`, permission Contents: Read and write (what `POST /repos/.../dispatches` needs). Without it the + publish job's site step prints a notice and skips, and `release.py` starts the deploy itself with your `gh` login. +- `CLOUDFLARE_API_TOKEN` (in the microsite repo): see its README, Deploys. diff --git a/README.md b/README.md index 1e2d24f..f106f0c 100644 --- a/README.md +++ b/README.md @@ -202,12 +202,29 @@ Python SDK (`pip install comfy-sdk`). Qwen-Image-Edit Lightning LoRA). - **Cost:** cloud runs use your Comfy credits. +## Stability and releases +LSDub aims to be stable software that is easy to install and build on, not a stream of updates. + +- Releases are batched. A patch version (0.2.x) only fixes breakage; new features wait for the next minor version. +- Across a minor series the defaults stay byte-identical: the same inputs at the defaults give the same render, unless the + changelog says otherwise. +- Every release passes the install-path test before it is tagged: a clean ComfyUI on CPU, only this pack's + requirements, every node registered, the phone recogniser downloaded into an empty models folder, and a Dub at the + defaults and with the mouth switches on the demo clip. +- Models are pinned by revision and checked by sha256 ([MODELS.md](MODELS.md)). +- What changed: [the changelog](https://lsdub.sndbnch.app/changelog). Something broke: the + [issue forms](https://github.com/HumanjavaEnterprises/comfyui-lsdub/issues/new/choose). + +In honesty: 0.2.0, 0.2.1 and 0.2.2 landed in one day, before the install-path test existed. Two of them fixed breakage a +clean install would have caught (the phone recogniser in a fresh ComfyUI, and the Dub with the mouth switches on). The test +now runs on every pull request, before every publish, and weekly. + ## Troubleshooting - **The first Align run sits there.** It is downloading Whisper large-v3 (about 3 GB) with no progress bar in the UI. Let it finish once. -- **The demo download fails.** Get `lsdub-demo-0.1.0.zip` from the GitHub Releases page by hand and run - `python scripts/fetch_demo.py --zip lsdub-demo-0.1.0.zip`. +- **The demo download fails.** Get `lsdub-demo-0.1.1.zip` from the GitHub Releases page by hand and run + `python scripts/fetch_demo.py --zip lsdub-demo-0.1.1.zip`. - **Align warns about the lyrics.** If what is sung matches the lyrics poorly (below 0.5), it warns and places the lyrics on the voice. Below 0.2 it stops and asks you to check the lyrics: they probably belong to another song. - **A shot failed to dub.** It is reported, and by default the cut keeps its original frames. Run workflow 3 on the diff --git a/lsdub_engine/VERSION b/lsdub_engine/VERSION index 2e2a75f..291b2f7 100644 --- a/lsdub_engine/VERSION +++ b/lsdub_engine/VERSION @@ -1,4 +1,4 @@ pack_version: 0.2.2 uncommitted: 0 -tree_sha256: 13cb87b83854a0f23de5f00ff3184811a78e3f004cd7fa6f5b01cb714066cb46 -tests_sha256: 70cb132afd1ac5ac9aef39a95d11e23041668f2776c9d64f187b0a7fb48be82f +tree_sha256: f36b7410d7dc3461fba57844603c81a5ba5ec37adb160148f9dc50595e4f19ba +tests_sha256: 59f5e441dbefa42caf1faf35824ac93cfec99b3e58b280a0e522d2f0f8759edb diff --git a/lsdub_engine/audits/qa.py b/lsdub_engine/audits/qa.py index 860675c..6c4a410 100644 --- a/lsdub_engine/audits/qa.py +++ b/lsdub_engine/audits/qa.py @@ -526,7 +526,7 @@ def asked_travel(s): EV_KEYS = ("onset_hit", "onset_n", "closure_hit", "closure_n", "spurious", "spurious_take", "open_ratio", "open_n") -LAG_WINDOW_S = 0.2 # a mouth rise this near a sung onset, either side, is that onset's rise (G10 lane 04's per-onset reading) +LAG_WINDOW_S = 0.2 # a mouth rise this near a sung onset, either side, is that onset's rise (the per-onset reading) def vocal_events(vocal, align=None, phone_models=None): diff --git a/scripts/fetch_demo.py b/scripts/fetch_demo.py index 37472db..3bf5bbd 100644 --- a/scripts/fetch_demo.py +++ b/scripts/fetch_demo.py @@ -20,10 +20,10 @@ import urllib.request import zipfile -VERSION = '0.1.0' -TAG = f'v{VERSION}' +VERSION = '0.1.1' +TAG = 'demo-0.1.1' URL = f'https://github.com/HumanjavaEnterprises/comfyui-lsdub/releases/download/{TAG}/lsdub-demo-{VERSION}.zip' -SHA256 = 'a431085d931f2b983b620d31710ac868a459d461a6dcb158177d199637790edb' +SHA256 = '0b15dfcf069b6dc2cac25bbe312137eda435e4ac350cce6b61436419fd2b518c' def unpack(zf, dst): diff --git a/scripts/install_path_check.py b/scripts/install_path_check.py new file mode 100644 index 0000000..3056ec3 --- /dev/null +++ b/scripts/install_path_check.py @@ -0,0 +1,324 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: Apache-2.0 +# Copyright 2026 Humanjava Enterprises. LSDub: https://github.com/HumanjavaEnterprises/comfyui-lsdub +"""The install-path test: LSDub in a clean ComfyUI, the way a user gets it. CPU only. CI runs it on every PR to main, before +every publish, weekly, and by hand (.github/workflows/install-path.yml); scripts/release.py waits for it. + + python scripts/install_path_check.py --comfyui PATH/TO/ComfyUI [--pack .] [--port 8199] [--demo-zip lsdub-demo-0.1.0.zip] + [--hf-mirror DIR] [--save-mirror DIR] [--steps nodes,align,dub,mouth] + +Run it with the ComfyUI venv's python (ComfyUI's requirements + this pack's requirements.txt, nothing else). It: + 1. copies the pack (this checkout, without .git) into ComfyUI/custom_nodes/comfyui-lsdub and fetches the demo assets; + 2. starts ComfyUI headless (--cpu) and checks that every LSDub node in the source registered; + 3. align: LSDub Align with aligner ctc from an EMPTY models/lsdub/wav2vec2-espeak. The recogniser is downloaded for real + (exactly the four pinned files at the pinned revision, no safetensors), and phonemizer is never needed; + 4. dub: the demo workflow (BYO Clip and Song) at its defaults: Dub, Verify, Save Cut; + 5. mouth: the same Dub with Mouth Tweaks warp_events vocal, warp_hold nucleus-follow, warp_closures aligned-press, + warp_aperture p95 (the Dub's auto path). +A dubbed shot that errors fails the test even though the Dub itself carries on with the source (on_fail=source). + +--hf-mirror DIR serves the four recogniser files from DIR on a local Hugging Face endpoint (CI's cache, so the files are +fetched from the Hub once per revision); the client path (hf_hub_download into the empty folder) is unchanged. +--save-mirror DIR copies the downloaded files there after a real download, for that cache. Exit 0 = pass.""" +import argparse +import glob +import hashlib +import http.server +import json +import os +import re +import shutil +import subprocess +import sys +import threading +import time +import urllib.error +import urllib.request + +REPO = 'facebook/wav2vec2-lv-60-espeak-cv-ft' +RECOGNISER_DIR = os.path.join('lsdub', 'wav2vec2-espeak') +MOUTH = dict(warp_events='vocal', warp_hold='nucleus-follow', warp_closures='aligned-press', warp_aperture='p95') +BAD_LOG = [(re.compile(r'phonemizer', re.I), 'phonemizer was needed'), + (re.compile(r'phone network not loaded'), 'the phone recogniser did not load (vocal events fell back to flux)'), + (re.compile(r'shot \d+ failed: '), 'a dubbed shot raised (the Dub fell back to the source)'), + (re.compile(r'IMPORT FAILED.*lsdub', re.I), 'the pack failed to import')] + + +class Fail(SystemExit): + def __init__(self, msg): super().__init__(f'INSTALL-PATH FAIL: {msg}') + + +def say(msg): print(f'[install-path] {msg}', flush=True) + + +def pinned(pack): + """the recogniser's pinned revision and file shas, read from the pack under test (lsdub_engine/_models.py).""" + src = open(os.path.join(pack, 'lsdub_engine', '_models.py'), encoding='utf-8').read() + i = src.index(f'"{REPO}"') + rev = re.search(r'"revision":\s*"([0-9a-f]{40})"', src[i:]).group(1) + files = dict(re.findall(r'"([\w.]+)":\s*"([0-9a-f]{64})"', src[i:src.index('}', src.index('"files"', i)) + 1])) + return rev, files + + +def stand_in(fd, z, sha): + """unpack a demo zip the way fetch_demo.py does, with the zip's own checksum (only after fetch_demo itself has failed).""" + h = hashlib.sha256(open(z, 'rb').read()).hexdigest() + if h != sha: raise Fail(f'demo stand-in {z}: sha256 {h} != {sha}') + src = open(fd[1], encoding='utf-8').read() + tmp = fd[1] + '.standin.py' + import zipfile + root = {n.split('/')[0] for n in zipfile.ZipFile(z).namelist()}.pop() # lsdub-demo- + src = re.sub(r"^VERSION = '.*'$", f"VERSION = '{root.rsplit('-', 1)[-1]}'", src, flags=re.M) + open(tmp, 'w', encoding='utf-8').write(re.sub(r"^SHA256 = '.*'$", f"SHA256 = '{sha}'", src, flags=re.M)) + try: subprocess.run([fd[0], tmp] + fd[2:] + ['--zip', z], check=True, stdout=subprocess.DEVNULL) + finally: os.remove(tmp) + + +def node_ids(pack): + ids = set() + for p in glob.glob(os.path.join(pack, '*.py')): + ids |= set(re.findall(r"node_id='(LSDub\w+)'", open(p, encoding='utf-8').read())) + return ids + + +# ------------------------------------------------------------------------------------------------ the local hub mirror +def serve_mirror(folder, rev, files): + class H(http.server.BaseHTTPRequestHandler): + def log_message(self, *a): pass + + def _file(self): + m = re.match(rf'^/{re.escape(REPO)}/resolve/([^/]+)/([\w.]+)$', self.path.split('?')[0]) + if not m or m.group(1) not in (rev, 'main') or m.group(2) not in files: return None + return os.path.join(folder, m.group(2)) + + def _head(self, p): + self.send_response(200) + self.send_header('X-Repo-Commit', rev) + self.send_header('ETag', f'"{files[os.path.basename(p)]}"') + self.send_header('Content-Length', str(os.path.getsize(p))) + self.send_header('Content-Type', 'application/octet-stream') + self.end_headers() + + def do_HEAD(self): + p = self._file() + if not p or not os.path.isfile(p): + self.send_error(404) + return + self._head(p) + + def do_GET(self): + p = self._file() + if not p or not os.path.isfile(p): + self.send_error(404) + return + self._head(p) + with open(p, 'rb') as f: shutil.copyfileobj(f, self.wfile) + + srv = http.server.ThreadingHTTPServer(('127.0.0.1', 0), H) + threading.Thread(target=srv.serve_forever, daemon=True).start() + return f'http://127.0.0.1:{srv.server_address[1]}' + + +# ------------------------------------------------------------------------------------------------ ComfyUI +def api(port, path, body=None, timeout=30): + req = urllib.request.Request(f'http://127.0.0.1:{port}{path}', data=json.dumps(body).encode() if body is not None else None, + headers={'Content-Type': 'application/json'}) + with urllib.request.urlopen(req, timeout=timeout) as r: # noqa: S310 (loopback) + return json.loads(r.read()) + + +def run_prompt(port, prompt, label, timeout): + try: + pid = api(port, '/prompt', {'prompt': prompt, 'client_id': 'install-path'})['prompt_id'] + except urllib.error.HTTPError as e: + raise Fail(f'{label}: ComfyUI refused the workflow: {e.read().decode(errors="replace")[:2000]}') from None + t0 = time.time() + while time.time() - t0 < timeout: + h = api(port, f'/history/{pid}').get(pid) + if h and h.get('status', {}).get('completed') is not None and (h['status'].get('completed') or h['status'].get('status_str') == 'error'): + if h['status'].get('status_str') != 'success': + msgs = [m for m in h['status'].get('messages', []) if m[0] == 'execution_error'] + raise Fail(f'{label}: the workflow failed: {json.dumps(msgs)[:3000]}') + say(f'{label}: done in {time.time() - t0:.0f} s') + return h['outputs'] + time.sleep(3) + raise Fail(f'{label}: no result after {timeout} s') + + +def texts(outputs, node): + t = outputs.get(node, {}).get('text', []) + return '\n'.join(t if isinstance(t, list) else [str(t)]) + + +def workflow(name, aligner='sphinx', mouth=False, align_only=False): + w = { + '1': ('LSDubProject', dict(name=name, root='', mode='run', fps=25, song_offset=0, preset='landing', seed=0, force_shots='', backend='local')), + '2': ('LoadVideo', dict(file='lsdub_demo_landing_clip.mp4')), + '3': ('LSDubVideoSlot', dict(prompt='', negative_prompt='', seconds=5, project=['1', 0], video=['2', 0])), + '4': ('LoadAudio', dict(audio='lsdub_demo_landing_song.wav')), + '5': ('LoadAudio', dict(audio='lsdub_demo_landing_lead.wav')), + '6': ('LSDubSongSlot', dict(tags='', seconds=60, bpm=120, keyscale='C major', project=['1', 0], audio=['4', 0], vocal=['5', 0])), + '8': ('LSDubLyricsSlot', dict(text='Pleiadians bending space and time\nFour hundred light-years far away\nAlways so serious, never dance a day', + project=['1', 0])), + '9': ('LSDubAlign', dict(pronunciations='', stt_model='large-v3', transcript_json='lsdub_demo_landing_stt.json', aligner=aligner, + project=['1', 0], song=['6', 0], lyrics=['8', 0])), + '13': ('PreviewAny', dict(source=['9', 1])), + } + if not align_only: + w.update({ + '7': ('LSDubShotSplit', dict(project=['1', 0], source=['3', 0])), + '10': ('LSDubTriage', dict(samples=6, project=['1', 0], source=['3', 0], shots=['7', 0], song=['6', 0])), + '11': ('PreviewAny', dict(source=['10', 2])), + '14': ('LSDubBuildLipKit', dict(identity='red_nova', kit_dir='lsdub_demo_kits/red_nova.zip', shot_id=-1, + keep='person, same face shape, skin tone, hair, eyewear and makeup', seeds=3, project=['1', 0])), + '15': ('LSDubDub', dict(only_shots='', quality=18, on_fail='source', audio='source', vocalisations='skip', project=['1', 0], + source=['3', 0], shots=['10', 0], song=['6', 0], align=['9', 0], kit=['14', 0])), + '16': ('LSDubVerify', dict(project=['1', 0], source=['3', 0], render=['15', 0], shots=['10', 0], song=['6', 0], align=['9', 0])), + '17': ('PreviewAny', dict(source=['16', 1])), + '20': ('LSDubSaveCut', dict(watermark=True, quality=18, project=['1', 0], render=['15', 0])), + }) + if mouth: + w['21'] = ('LSDubTweaks', dict(lead=0.06, word_close=0.06, face_target=512.0, snap=True, auto=True, continuity=True, settings='', **MOUTH)) + w['15'][1]['tweaks'] = ['21', 0] + return {k: {'class_type': c, 'inputs': i} for k, (c, i) in w.items()} + + +def check_log(log, since, label): + with open(log, encoding='utf-8', errors='replace') as f: + f.seek(since) + new = f.read() + for rx, why in BAD_LOG: + m = rx.search(new) + if m: + ln = new[max(0, new.rfind('\n', 0, m.start()) + 1):new.find('\n', m.end())] + raise Fail(f'{label}: {why}: {ln.strip()[:500]}') + return new + + +def check_dub(comfy, name, outputs, label): + proj = os.path.join(comfy, 'output', 'lsdub', name) + renders = glob.glob(os.path.join(proj, 'shots', '*', '*', 'render.mp4')) + glob.glob(os.path.join(proj, 'shots', '*', '*', '*.mp4')) + errors = [] + for d in glob.glob(os.path.join(proj, 'shots', '*', '*', 'done.json')): + j = json.load(open(d)) + if j.get('error'): errors.append(f"{os.path.relpath(d, proj)}: {j['error']}") + if errors: raise Fail(f'{label}: dubbed shots raised: {errors}') + if not renders: raise Fail(f'{label}: no shot was dubbed (no render under {proj}/shots)') + cuts = [f for f in glob.glob(os.path.join(comfy, 'output', '**', '*.mp4'), recursive=True) if name in f and 'final' in f] + if not cuts: raise Fail(f'{label}: Save Cut wrote no file') + rep = texts(outputs, '17') + if 'shot' not in rep.lower(): raise Fail(f'{label}: Verify gave no report: {rep[:300]!r}') + say(f'{label}: {len(renders)} render file(s), cut {os.path.relpath(cuts[0], comfy)}; Verify: {rep.splitlines()[0][:120] if rep else ""}') + + +def main(): + ap = argparse.ArgumentParser(description=__doc__.split('\n')[0]) + ap.add_argument('--comfyui', required=True) + ap.add_argument('--pack', default=os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + ap.add_argument('--port', type=int, default=8199) + ap.add_argument('--demo-zip') + ap.add_argument('--hf-mirror') + ap.add_argument('--save-mirror') + ap.add_argument('--demo-fallback', help='a demo zip to use if fetch_demo.py fails (the step still fails)') + ap.add_argument('--demo-fallback-sha', default='', help="the fallback zip's sha256") + ap.add_argument('--steps', default='nodes,align,dub,mouth') + ap.add_argument('--timeout', type=int, default=3600, help='seconds per workflow') + a = ap.parse_args() + comfy, pack, steps = os.path.abspath(a.comfyui), os.path.abspath(a.pack), set(a.steps.split(',')) + rev, files = pinned(pack) + say(f'pack {pack}; recogniser {REPO}@{rev[:8]} ({len(files)} files)') + + dst = os.path.join(comfy, 'custom_nodes', 'comfyui-lsdub') + shutil.rmtree(dst, ignore_errors=True) + shutil.copytree(pack, dst, ignore=shutil.ignore_patterns('.git', '__pycache__', '.pytest_cache', '.ruff_cache', 'dist', '*.zip')) + failures = [] + fd = [sys.executable, os.path.join(dst, 'scripts', 'fetch_demo.py'), '--comfyui', comfy] + r = subprocess.run(fd + (['--zip', os.path.abspath(a.demo_zip)] if a.demo_zip else []), capture_output=True, text=True) + if r.returncode: + failures.append(f'demo: scripts/fetch_demo.py fails for a user: {(r.stderr or r.stdout).strip().splitlines()[-1][:400]}') + say(failures[-1]) + if not a.demo_fallback: raise Fail(failures[-1]) + say(f'demo: carrying on with the stand-in {a.demo_fallback} (sha256 {a.demo_fallback_sha[:12]}) so the other steps still report') + stand_in(fd, a.demo_fallback, a.demo_fallback_sha) + else: + say('demo: fetch_demo.py ok') + rec = os.path.join(comfy, 'models', RECOGNISER_DIR) + if os.path.exists(rec): raise Fail(f'{rec} is not empty: the align step needs an empty models folder') + + env = dict(os.environ, PYTHONUNBUFFERED='1') + if a.hf_mirror and all(os.path.isfile(os.path.join(a.hf_mirror, f)) for f in files): + env['HF_ENDPOINT'] = serve_mirror(os.path.abspath(a.hf_mirror), rev, files) + say(f'recogniser files from the cached mirror {a.hf_mirror} on {env["HF_ENDPOINT"]}') + else: + say('recogniser files from the Hugging Face Hub') + log = os.path.join(comfy, 'install-path-comfyui.log') + lf = open(log, 'w') + proc = subprocess.Popen([sys.executable, 'main.py', '--cpu', '--listen', '127.0.0.1', '--port', str(a.port), '--disable-auto-launch'], + cwd=comfy, env=env, stdout=lf, stderr=subprocess.STDOUT) + try: + t0 = time.time() + while True: + if proc.poll() is not None: raise Fail(f'ComfyUI exited ({proc.returncode}); see {log}') + try: + info = api(a.port, '/object_info', timeout=60) + break + except (OSError, urllib.error.URLError): + if time.time() - t0 > 600: raise Fail('ComfyUI did not start in 600 s') from None + time.sleep(2) + want, got = node_ids(pack), {k for k in info if k.startswith('LSDub')} + check_log(log, 0, 'startup') + if want - got: raise Fail(f'nodes not registered: {sorted(want - got)} (see {log})') + say(f'nodes: all {len(want)} LSDub nodes registered') + + def step(label, fn): + try: fn() + except Fail as e: + failures.append(str(e.code)) + say(str(e.code)) + + def align(): + pos = os.path.getsize(log) + out = run_prompt(a.port, workflow('ip-align', aligner='ctc', align_only=True), 'align (ctc, empty models folder)', a.timeout) + check_log(log, pos, 'align') + have = sorted(os.path.relpath(p, rec) for p in glob.glob(os.path.join(rec, '**', '*'), recursive=True) if os.path.isfile(p)) + snap = os.path.join(rec, 'models--' + REPO.replace('/', '--'), 'snapshots', rev) + if any(p.endswith('.safetensors') for p in have): raise Fail(f'align: an unpinned safetensors was downloaded: {have}') + for f, sha in files.items(): + p = os.path.join(snap, f) + if not os.path.isfile(p): raise Fail(f'align: pinned file {f} missing from {snap}; have {have}') + h = hashlib.sha256() + with open(p, 'rb') as fh: + for b in iter(lambda: fh.read(1 << 20), b''): h.update(b) + if h.hexdigest() != sha: raise Fail(f'align: {f} sha256 {h.hexdigest()} != pinned {sha}') + blobs = [p for p in have if '/blobs/' in p or p.startswith('models--') and '/blobs' in p] + if len(blobs) > len(files): raise Fail(f'align: more than the {len(files)} pinned files were downloaded: {blobs}') + rep = texts(out, '13') + if 'aligner ctc' not in rep: raise Fail(f'align: the lyric check does not say aligner ctc: {rep[:400]!r}') + say(f'align: four pinned files at {rev[:8]}, sha256 ok, no safetensors; {rep.splitlines()[2] if len(rep.splitlines()) > 2 else rep[:100]}') + if a.save_mirror and 'HF_ENDPOINT' not in env: + os.makedirs(a.save_mirror, exist_ok=True) + for f in files: shutil.copyfile(os.path.join(snap, f), os.path.join(a.save_mirror, f)) + say(f'align: saved the four files to {a.save_mirror} for the cache') + + def dub(name, label, kw): + pos = os.path.getsize(log) + out = run_prompt(a.port, workflow(name, **kw), label, a.timeout) + check_log(log, pos, label) + check_dub(comfy, name, out, label) + + if 'align' in steps: step('align', align) + if 'dub' in steps: step('dub', lambda: dub('ip-dub-defaults', 'dub at defaults', {})) + if 'mouth' in steps: + step('mouth', lambda: dub('ip-dub-mouth', 'dub with the mouth Combos (' + ', '.join(f'{k} {v}' for k, v in MOUTH.items()) + ')', + dict(aligner='ctc', mouth=True))) + if failures: raise Fail(f'{len(failures)} step(s) failed:\n ' + '\n '.join(failures)) + say('PASS') + finally: + proc.terminate() + try: proc.wait(30) + except subprocess.TimeoutExpired: proc.kill() + lf.close() + + +if __name__ == '__main__': + main() diff --git a/scripts/release.py b/scripts/release.py new file mode 100644 index 0000000..e79d209 --- /dev/null +++ b/scripts/release.py @@ -0,0 +1,261 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: Apache-2.0 +# Copyright 2026 Humanjava Enterprises. LSDub: https://github.com/HumanjavaEnterprises/comfyui-lsdub +"""release.py: the one release command. Maintainers only (it needs the private engine checkout and gh signed in). + + python scripts/release.py X.Y.Z [--dry-run] [--engine ../lsdub] [--site ../lsdub.web.microsite.src] + +In order, stopping at the first failure with a plain message (nothing remote changes before step 9): + 1. refuses unless the engine and pack trees are clean, on main and equal to origin/main, and engine CI is green on its HEAD; + 2. refuses unless CHANGELOG.md has a `## X.Y.Z` section that passes release_notes.py, the microsite's main has a + matching section in docs/changelog.html, and the demo zip fetch_demo.py points at is published with its checksum; + 3. bumps pyproject (version, Icon/Banner tag URLs) and CITATION.cff; regenerates the README node tables and the bug form; + 4. vendors the engine (a vendor record commit in the engine, pushed only in step 9); + 5. runs the pack checks CI runs (ruff registry rules, pytest, docs and models checks), including the private-methods scrub; + 6. commits "X.Y.Z: ..." on a release/X.Y.Z branch; + 7. pushes release/X.Y.Z and waits for CI and Install path to pass on that commit; + 8. (dry run stops before this: it works in a throwaway worktree and pushes nothing) + 9. fast-forwards main to it, tags vX.Y.Z, pushes main and the tag, pushes the engine record; + 10. watches the publish run, confirms the Comfy Registry lists X.Y.Z, and makes sure the site deploy ran. +""" +import argparse +import hashlib +import json +import os +import re +import shutil +import subprocess +import sys +import tempfile +import time +import urllib.request + +PACK = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +PACK_REPO = 'HumanjavaEnterprises/comfyui-lsdub' +ENGINE_REPO = 'HumanjavaEnterprises/lsdub' +SITE_REPO = 'HumanjavaEnterprises/lsdub.web.microsite.src' +REGISTRY = 'https://api.comfy.org/nodes/lsdub/versions' +EVIDENCE = ('Evidence: release-check source=lsdub/core/lyric_align.py read the vendored tree: vendor_engine scrub CHECK clean, ' + 'pack tests and install-path green on release/{v}') + + +class Stop(SystemExit): + def __init__(self, msg): super().__init__(f'\nrelease: STOPPED. {msg}\nNothing remote was changed.' if not REMOTE_TOUCHED[0] else + f'\nrelease: STOPPED. {msg}') + + +REMOTE_TOUCHED = [False] +STEP = [0] + + +def step(msg): + STEP[0] += 1 + print(f'\n[{STEP[0]}] {msg}', flush=True) + + +def sh(*cmd, cwd=PACK, check=True, quiet=False, env=None): + if not quiet: print(' $', ' '.join(cmd), flush=True) + r = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, env=env) + if check and r.returncode: + raise Stop(f'`{" ".join(cmd)}` failed (exit {r.returncode}) in {cwd}:\n{(r.stdout + r.stderr).strip()[-3000:]}') + return r.stdout.strip() + + +def git(repo, *a, **k): return sh('git', *a, cwd=repo, **k) + + +def gh_json(*a): return json.loads(sh('gh', *a, quiet=True) or 'null') + + +def clean_main(repo, name): + if git(repo, 'status', '--porcelain', quiet=True): raise Stop(f'the {name} tree at {repo} has uncommitted changes. Commit or stash them first.') + br = git(repo, 'rev-parse', '--abbrev-ref', 'HEAD', quiet=True) + if br != 'main': raise Stop(f'the {name} checkout is on {br!r}, not main.') + git(repo, 'fetch', '-q', 'origin', 'main', quiet=True) + if git(repo, 'rev-parse', 'HEAD', quiet=True) != git(repo, 'rev-parse', 'origin/main', quiet=True): + raise Stop(f'the {name} main is not origin/main (pull or push first).') + return git(repo, 'rev-parse', 'HEAD', quiet=True) + + +def runs_green(repo, sha, workflows, wait, timeout=4 * 3600): + """every named workflow has a successful run on sha (waits for them when wait=True).""" + t0 = time.time() + while True: + runs = gh_json('run', 'list', '-R', repo, '--commit', sha, '--json', 'workflowName,status,conclusion,url', '-L', '50') + state = {} + for w in workflows: + mine = [r for r in runs if r['workflowName'] == w] + if any(r['conclusion'] == 'success' for r in mine): state[w] = 'success' + elif any(r['status'] != 'completed' for r in mine): state[w] = 'running' + elif mine: state[w] = f"{mine[0]['conclusion']} ({mine[0]['url']})" + else: state[w] = 'no run yet' + bad = {w: s for w, s in state.items() if s not in ('success', 'running', 'no run yet')} + if bad: raise Stop(f'{repo} @ {sha[:9]}: ' + '; '.join(f'{w} {s}' for w, s in bad.items())) + if all(s == 'success' for s in state.values()): return + if not wait: raise Stop(f'{repo} @ {sha[:9]} is not green yet: {state}') + if time.time() - t0 > timeout: raise Stop(f'{repo} @ {sha[:9]}: gave up waiting: {state}') + print(f' waiting: {state}', flush=True) + time.sleep(60) + + +def read(p): return open(p, encoding='utf-8').read() + + +def write(p, s): open(p, 'w', encoding='utf-8').write(s) + + +def bump(tree, v): + pp = os.path.join(tree, 'pyproject.toml') + s = read(pp) + s = re.sub(r'^version = "[^"]+"', f'version = "{v}"', s, count=1, flags=re.M) + s = re.sub(r'(comfyui-lsdub/)v\d+\.\d+\.\d+(/assets/)', rf'\g<1>v{v}\g<2>', s) + write(pp, s) + cf = os.path.join(tree, 'CITATION.cff') + write(cf, re.sub(r'^version: "[^"]+"', f'version: "{v}"', read(cf), flags=re.M)) + + +def demo_published(tree): + src = read(os.path.join(tree, 'scripts', 'fetch_demo.py')) + ver = re.search(r"^VERSION = '([^']+)'", src, re.M).group(1) + tag = re.search(r"^TAG = (.+)$", src, re.M).group(1).strip() + tag = f'v{ver}' if tag == "f'v{VERSION}'" else tag.strip("'") + sha = re.search(r"^SHA256 = '([0-9a-f]{64})'", src, re.M).group(1) + url = f'https://github.com/{PACK_REPO}/releases/download/{tag}/lsdub-demo-{ver}.zip' + try: + with urllib.request.urlopen(url, timeout=120) as r: got = hashlib.sha256(r.read()).hexdigest() # noqa: S310 (fixed https URL) + except OSError as e: + got = f'unreachable ({e})' + if got != sha: + raise Stop(f'the demo zip fetch_demo.py points at is not published as pinned: {url} -> {got}, pinned {sha}.\n' + f'Attach it first (a new tag for a new zip, never a replaced asset):\n' + f' gh release create {tag} dist/lsdub-demo-{ver}.zip -R {PACK_REPO} --latest=false ' + f'--title "LSDub demo assets {ver}" --notes "Demo assets for the example workflows (scripts/fetch_demo.py)."') + return url + + +def main(): + ap = argparse.ArgumentParser(description='LSDub: the one release command', epilog=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument('version') + ap.add_argument('--dry-run', action='store_true', help='every check, in a throwaway worktree; pushes nothing') + ap.add_argument('--engine', default=os.path.join(os.path.dirname(PACK), 'lsdub')) + ap.add_argument('--site', default=os.path.join(os.path.dirname(PACK), 'lsdub.web.microsite.src')) + a = ap.parse_args() + v, dry = a.version, a.dry_run + eng, site = os.path.abspath(a.engine), os.path.abspath(a.site) + print(f'LSDub release {v}{" (DRY RUN: nothing is pushed)" if dry else ""}') + if not re.fullmatch(r'\d+\.\d+\.\d+', v): raise Stop(f'{v!r} is not X.Y.Z') + cur = re.search(r'^version = "([^"]+)"', read(os.path.join(PACK, 'pyproject.toml')), re.M).group(1) + if tuple(map(int, v.split('.'))) <= tuple(map(int, cur.split('.'))): raise Stop(f'{v} is not newer than the current {cur}') + for t in ('git', 'gh'): + if not shutil.which(t): raise Stop(f'{t} is not on PATH') + sh('gh', 'auth', 'status', quiet=True) + + step('engine and pack trees clean, on main, equal to origin/main; engine CI green on its HEAD') + eng_sha = clean_main(eng, 'engine') + pack_sha = clean_main(PACK, 'pack') + runs_green(ENGINE_REPO, eng_sha, ['ci'], wait=False) + if git(PACK, 'ls-remote', '--tags', 'origin', f'refs/tags/v{v}', quiet=True): raise Stop(f'tag v{v} already exists on origin') + if git(PACK, 'ls-remote', '--heads', 'origin', f'release/{v}', quiet=True): raise Stop(f'branch release/{v} already exists on origin') + print(f' engine {eng_sha[:9]} (ci green), pack {pack_sha[:9]}') + + step(f'CHANGELOG section {v} (release_notes.py), the microsite changelog section, the demo zip') + sys.path.insert(0, os.path.join(PACK, 'scripts')) + import release_notes as RN + try: RN.check(read(os.path.join(PACK, 'CHANGELOG.md')), v) + except ValueError as e: raise Stop(f'CHANGELOG.md: {e}. Write the {v} section first (CONTRIBUTING.md, Release notes).') from None + git(site, 'fetch', '-q', 'origin', 'main', quiet=True) + page = git(site, 'show', 'origin/main:docs/changelog.html', quiet=True) + if f'id="v{v}"' not in page: + raise Stop(f'the microsite main has no
in docs/changelog.html. Add it on the site\'s main first.') + print(f' demo zip ok: {demo_published(PACK)}') + + tree, made_worktree = PACK, False + if dry: + tree = tempfile.mkdtemp(prefix=f'lsdub-release-{v}-') + os.rmdir(tree) + git(PACK, 'worktree', 'add', '-q', '--detach', tree, 'HEAD') + made_worktree = True + records = os.path.join(eng, 'scripts', 'vendor_records.tsv') + records_before = read(records) + try: + step(f'bump to {v}: pyproject version + Icon/Banner URLs, CITATION.cff; regenerate README node tables and the bug form') + if not dry: git(PACK, 'checkout', '-q', '-b', f'release/{v}') + bump(tree, v) + sh(sys.executable, 'scripts/make_readme_nodes.py', cwd=tree) + + step('vendor the engine') + sh(sys.executable, os.path.join(eng, 'scripts', 'vendor_engine.py'), '--pack', tree, cwd=eng) + sh(sys.executable, os.path.join(eng, 'scripts', 'vendor_engine.py'), '--pack', tree, '--check', cwd=eng) + + step('pack checks (what CI runs, plus the private-methods scrub in tests/test_registry_rules.py)') + sh(sys.executable, '-m', 'ruff', 'check', '--select', 'S102,S307,E702,F', '--target-version', 'py310', '.', cwd=tree) + sh(sys.executable, '-m', 'pytest', '-q', 'tests', cwd=tree) + sh(sys.executable, 'scripts/make_readme_nodes.py', '--check', cwd=tree) + sh(sys.executable, 'scripts/check_models_md.py', cwd=tree) + sh(sys.executable, 'scripts/release_notes.py', cwd=tree, quiet=True) + + step(f'commit {v} on release/{v}') + title = RN.top_section(read(os.path.join(tree, 'CHANGELOG.md')))[1] + first = next((ln[2:].strip() for ln in title if ln.startswith('- ')), '') + git(tree, 'add', '-A') + if dry: + print(f' would commit "{v}: {first[:100]}" with: ' + git(tree, 'diff', '--cached', '--stat', quiet=True).replace('\n', '\n ')) + print(f' would commit the engine vendor record ({EVIDENCE.format(v=v)[:60]}...)') + print(f'\n[{STEP[0] + 1}..] would push release/{v}, wait for CI + Install path, fast-forward main, tag v{v}, push the tag and the ' + 'engine record, watch publish, confirm the Registry, check the site deploy.\nDRY RUN OK: every check passed.') + return + git(tree, 'commit', '-q', '-m', f'{v}: {first[:120]}') + rel_sha = git(tree, 'rev-parse', 'HEAD', quiet=True) + if read(records) != records_before: + git(eng, 'commit', '-q', '-m', f'{v} vendor record\n\n{EVIDENCE.format(v=v)}', '--', 'scripts/vendor_records.tsv') + + step(f'push release/{v} and wait for CI + Install path on {rel_sha[:9]}') + git(PACK, 'push', '-q', 'origin', f'release/{v}') + print(' (a branch only; main, tags and the Registry are untouched until both pass)') + time.sleep(20) + runs_green(PACK_REPO, rel_sha, ['CI', 'Install path'], wait=True) + + step(f'fast-forward main, tag v{v}, push; push the engine record') + REMOTE_TOUCHED[0] = True + git(PACK, 'checkout', '-q', 'main') + git(PACK, 'merge', '-q', '--ff-only', f'release/{v}') + git(PACK, 'push', '-q', 'origin', 'main') + git(PACK, 'tag', '-a', f'v{v}', '-m', f'LSDub {v}') + git(PACK, 'push', '-q', 'origin', f'v{v}') + tag_time = time.time() + git(eng, 'push', '-q', 'origin', 'main') + + step('watch publish (it re-runs Install path on the tag first)') + time.sleep(20) + runs_green(PACK_REPO, rel_sha, ['Publish to the Comfy Registry'], wait=True) + + step(f'the Comfy Registry lists {v}') + for _ in range(30): + with urllib.request.urlopen(REGISTRY, timeout=60) as r: vs = {x.get('version'): x.get('status') for x in json.loads(r.read())} # noqa: S310 + if v in vs: + print(f' {v}: {vs[v]}') + break + time.sleep(30) + else: + raise Stop(f'publish passed but {REGISTRY} does not list {v} after 15 minutes; check the publisher page.') + + step('site deploy (the publish workflow dispatches it; started here if it did not)') + time.sleep(30) + runs = gh_json('run', 'list', '-R', SITE_REPO, '--workflow', 'deploy.yml', '--json', 'databaseId,createdAt,event,url', '-L', '10') + fresh = [r for r in runs if time.mktime(time.strptime(r['createdAt'], '%Y-%m-%dT%H:%M:%SZ')) - time.timezone >= tag_time - 60] + if not fresh: + sh('gh', 'workflow', 'run', 'deploy.yml', '-R', SITE_REPO, '--ref', 'main') + time.sleep(15) + fresh = gh_json('run', 'list', '-R', SITE_REPO, '--workflow', 'deploy.yml', '--json', 'databaseId,createdAt,event,url', '-L', '1') + if fresh: + sh('gh', 'run', 'watch', str(fresh[0]['databaseId']), '-R', SITE_REPO, '--exit-status', check=False) + print(f' site deploy run: {fresh[0]["url"]} (a skipped deploy means CLOUDFLARE_API_TOKEN is not set there)') + print(f'\nreleased {v}.') + finally: + if made_worktree: + git(PACK, 'worktree', 'remove', '--force', tree, check=False, quiet=True) + if read(records) != records_before: write(records, records_before) # the dry run's vendor record, undone + + +if __name__ == '__main__': + main() diff --git a/tests/engine/test_watermark.py b/tests/engine/test_watermark.py index 92583fc..c744d24 100644 --- a/tests/engine/test_watermark.py +++ b/tests/engine/test_watermark.py @@ -72,7 +72,7 @@ def _top_atoms(path): def test_the_watermarked_file_is_faststart(clip, tmp_path): - """moov ahead of mdat (G10 lane 11: the saved cut, the site's redub.mp4, was the one file with its moov at the end).""" + """moov ahead of mdat (the saved cut, the site's redub.mp4, was the one file with its moov at the end).""" out = str(tmp_path / "wm.mp4") watermark.apply(clip, out) a = _top_atoms(out) diff --git a/tests/test_registry_rules.py b/tests/test_registry_rules.py index 4d2bee4..c7cbb42 100644 --- a/tests/test_registry_rules.py +++ b/tests/test_registry_rules.py @@ -22,7 +22,7 @@ def _files(exts=('.py', '.md', '.json', '.txt', '.toml', '.cff', '.yaml', '.yml' ds[:] = [x for x in ds if x not in SKIP_DIRS] for f in fs: if f in SKIP_DIRS: continue # a worktree's .git is a FILE - if os.path.splitext(f)[1] in exts: yield os.path.join(d, f) + if exts is None or os.path.splitext(f)[1] in exts: yield os.path.join(d, f) def test_no_machine_paths(): @@ -34,6 +34,30 @@ def test_no_machine_paths(): assert not hits, hits +# the pack ships product code and user docs; the methods stay private (the engine's ledger tools, review reports, +# research and plan docs). release.py runs this file, so a tree that carries any of them is never tagged. +METHODS = re.compile('|'.join(['G1' + r'\d\b', 'truth' + '-fix', 'lsdub-tryit' + '-assets', 'review/' + 'G1'])) +METHODS_PATHS = re.compile(r'(^|/)ledger(/|$)|^tests/ledger|^docs/(research|plans)/') + + +def _tracked(): + try: + out = subprocess.run(['git', '-C', ROOT, 'ls-files', '-z'], capture_output=True, check=True).stdout.decode() + return [f for f in out.split('\0') if f] + except (OSError, subprocess.CalledProcessError): + return [os.path.relpath(p, ROOT).replace(os.sep, '/') for p in _files(exts=None)] + + +def test_no_private_methods_in_shipped_tree(): + files = [f for f in _tracked() if os.path.isfile(os.path.join(ROOT, f))] + hits = [f for f in files if METHODS_PATHS.search(f)] + for f in files: + try: txt = open(os.path.join(ROOT, f), encoding='utf-8').read() + except (UnicodeDecodeError, OSError): continue + hits += [f'{f}:{i + 1}' for i, ln in enumerate(txt.splitlines()) if METHODS.search(ln)] + assert not hits, f'private methods in the shipped tree (keep them in the engine repo): {hits}' + + def _ruff(): exe = shutil.which('ruff') if exe: return [exe] From ec1c236b30ae00cc935ea8ee3dc4807f87a95509 Mon Sep 17 00:00:00 2001 From: vveerrgg Date: Mon, 9 Nov 2026 13:24:24 -0700 Subject: [PATCH 02/12] release.py: vendor --check runs after the engine record is committed (the engine is clean again); dry run says so --- scripts/install_path_check.py | 24 ++++++++++++++++++++++-- scripts/release.py | 3 ++- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/scripts/install_path_check.py b/scripts/install_path_check.py index 3056ec3..0891733 100644 --- a/scripts/install_path_check.py +++ b/scripts/install_path_check.py @@ -99,16 +99,36 @@ def _head(self, p): self.send_header('Content-Type', 'application/octet-stream') self.end_headers() + def _proxy(self, method): + """anything but the four recogniser files (DINOv2, ...) goes to the real Hub, redirects passed through as they come.""" + class NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, *a, **k): return None + req = urllib.request.Request('https://huggingface.co' + self.path, method=method, + headers={k: v for k, v in self.headers.items() if k.lower() not in ('host', 'connection')}) + try: r = urllib.request.build_opener(NoRedirect).open(req, timeout=300) # noqa: S310 (fixed https host) + except urllib.error.HTTPError as e: r = e + self.send_response(r.status if hasattr(r, 'status') else r.code) + for k, v in r.headers.items(): + if k.lower() not in ('transfer-encoding', 'connection'): self.send_header(k, v) + self.end_headers() + if method == 'GET': shutil.copyfileobj(r, self.wfile) + def do_HEAD(self): p = self._file() - if not p or not os.path.isfile(p): + if not p: + self._proxy('HEAD') + return + if not os.path.isfile(p): self.send_error(404) return self._head(p) def do_GET(self): p = self._file() - if not p or not os.path.isfile(p): + if not p: + self._proxy('GET') + return + if not os.path.isfile(p): self.send_error(404) return self._head(p) diff --git a/scripts/release.py b/scripts/release.py index e79d209..c7f54b6 100644 --- a/scripts/release.py +++ b/scripts/release.py @@ -185,7 +185,6 @@ def main(): step('vendor the engine') sh(sys.executable, os.path.join(eng, 'scripts', 'vendor_engine.py'), '--pack', tree, cwd=eng) - sh(sys.executable, os.path.join(eng, 'scripts', 'vendor_engine.py'), '--pack', tree, '--check', cwd=eng) step('pack checks (what CI runs, plus the private-methods scrub in tests/test_registry_rules.py)') sh(sys.executable, '-m', 'ruff', 'check', '--select', 'S102,S307,E702,F', '--target-version', 'py310', '.', cwd=tree) @@ -200,6 +199,7 @@ def main(): git(tree, 'add', '-A') if dry: print(f' would commit "{v}: {first[:100]}" with: ' + git(tree, 'diff', '--cached', '--stat', quiet=True).replace('\n', '\n ')) + print(' would then run vendor_engine.py --check on the committed record') print(f' would commit the engine vendor record ({EVIDENCE.format(v=v)[:60]}...)') print(f'\n[{STEP[0] + 1}..] would push release/{v}, wait for CI + Install path, fast-forward main, tag v{v}, push the tag and the ' 'engine record, watch publish, confirm the Registry, check the site deploy.\nDRY RUN OK: every check passed.') @@ -208,6 +208,7 @@ def main(): rel_sha = git(tree, 'rev-parse', 'HEAD', quiet=True) if read(records) != records_before: git(eng, 'commit', '-q', '-m', f'{v} vendor record\n\n{EVIDENCE.format(v=v)}', '--', 'scripts/vendor_records.tsv') + sh(sys.executable, os.path.join(eng, 'scripts', 'vendor_engine.py'), '--pack', tree, '--check', cwd=eng) # clean engine again step(f'push release/{v} and wait for CI + Install path on {rel_sha[:9]}') git(PACK, 'push', '-q', 'origin', f'release/{v}') From cadd25b9b6857eada98bb7af141c4bc62909a917 Mon Sep 17 00:00:00 2001 From: vveerrgg Date: Mon, 9 Nov 2026 15:47:11 -0700 Subject: [PATCH 03/12] Install path: the mouth Dub runs as a user's own face (a name the preset does not list), so the plain stack's drive stages run; that path is where 0.2.1's closures crash was; the hub mirror passes every other repo through to the Hub --- scripts/install_path_check.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/scripts/install_path_check.py b/scripts/install_path_check.py index 0891733..435400a 100644 --- a/scripts/install_path_check.py +++ b/scripts/install_path_check.py @@ -14,7 +14,7 @@ (exactly the four pinned files at the pinned revision, no safetensors), and phonemizer is never needed; 4. dub: the demo workflow (BYO Clip and Song) at its defaults: Dub, Verify, Save Cut; 5. mouth: the same Dub with Mouth Tweaks warp_events vocal, warp_hold nucleus-follow, warp_closures aligned-press, - warp_aperture p95 (the Dub's auto path). + warp_aperture p95, on the demo kit named as a user's own face (the Dub's auto path, the plain mouth stack). A dubbed shot that errors fails the test even though the Dub itself carries on with the source (on_fail=source). --hf-mirror DIR serves the four recogniser files from DIR on a local Hugging Face endpoint (CI's cache, so the files are @@ -37,6 +37,9 @@ REPO = 'facebook/wav2vec2-lv-60-espeak-cv-ft' RECOGNISER_DIR = os.path.join('lsdub', 'wav2vec2-espeak') +# the mouth Dub runs as a user's own face: a name the preset does not list, so the plain mouth stack and its drive stages +# (events, closures, hold) run, as they do for every face but the demo's (0.2.1's closures crash was on that path) +USER_FACE = 'my_singer' MOUTH = dict(warp_events='vocal', warp_hold='nucleus-follow', warp_closures='aligned-press', warp_aperture='p95') BAD_LOG = [(re.compile(r'phonemizer', re.I), 'phonemizer was needed'), (re.compile(r'phone network not loaded'), 'the phone recogniser did not load (vocal events fell back to flux)'), @@ -170,7 +173,7 @@ def texts(outputs, node): return '\n'.join(t if isinstance(t, list) else [str(t)]) -def workflow(name, aligner='sphinx', mouth=False, align_only=False): +def workflow(name, aligner='sphinx', mouth=False, align_only=False, identity='red_nova'): w = { '1': ('LSDubProject', dict(name=name, root='', mode='run', fps=25, song_offset=0, preset='landing', seed=0, force_shots='', backend='local')), '2': ('LoadVideo', dict(file='lsdub_demo_landing_clip.mp4')), @@ -189,7 +192,7 @@ def workflow(name, aligner='sphinx', mouth=False, align_only=False): '7': ('LSDubShotSplit', dict(project=['1', 0], source=['3', 0])), '10': ('LSDubTriage', dict(samples=6, project=['1', 0], source=['3', 0], shots=['7', 0], song=['6', 0])), '11': ('PreviewAny', dict(source=['10', 2])), - '14': ('LSDubBuildLipKit', dict(identity='red_nova', kit_dir='lsdub_demo_kits/red_nova.zip', shot_id=-1, + '14': ('LSDubBuildLipKit', dict(identity=identity, kit_dir='lsdub_demo_kits/red_nova.zip', shot_id=-1, keep='person, same face shape, skin tone, hair, eyewear and makeup', seeds=3, project=['1', 0])), '15': ('LSDubDub', dict(only_shots='', quality=18, on_fail='source', audio='source', vocalisations='skip', project=['1', 0], source=['3', 0], shots=['10', 0], song=['6', 0], align=['9', 0], kit=['14', 0])), @@ -330,7 +333,7 @@ def dub(name, label, kw): if 'dub' in steps: step('dub', lambda: dub('ip-dub-defaults', 'dub at defaults', {})) if 'mouth' in steps: step('mouth', lambda: dub('ip-dub-mouth', 'dub with the mouth Combos (' + ', '.join(f'{k} {v}' for k, v in MOUTH.items()) + ')', - dict(aligner='ctc', mouth=True))) + dict(aligner='ctc', mouth=True, identity=USER_FACE))) if failures: raise Fail(f'{len(failures)} step(s) failed:\n ' + '\n '.join(failures)) say('PASS') finally: From 6c1d2b3646b58cf0ca696557fdeec7678d3f7f94 Mon Sep 17 00:00:00 2001 From: vveerrgg Date: Mon, 9 Nov 2026 16:19:15 -0700 Subject: [PATCH 04/12] Install path: save the recogniser cache even when another step fails --- .github/workflows/install-path.yml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/workflows/install-path.yml b/.github/workflows/install-path.yml index 28c36cd..4d6f0fa 100644 --- a/.github/workflows/install-path.yml +++ b/.github/workflows/install-path.yml @@ -52,7 +52,8 @@ jobs: venv/bin/pip freeze | grep -iE '^(torch|transformers|huggingface.hub|mediapipe|numpy|phonemizer)=' || true if venv/bin/python -c "import phonemizer" 2>/dev/null; then echo "::error::phonemizer is installed; the test needs a venv without it"; exit 1; fi - name: Recogniser cache (fetched from the Hub once per revision, then served to the same client path) - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + id: hfcache + uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 with: path: hf-mirror key: lsdub-recogniser-${{ env.RECOGNISER_REVISION }} @@ -63,6 +64,12 @@ jobs: venv/bin/python pack/scripts/install_path_check.py --comfyui comfy --pack pack \ --hf-mirror hf-mirror --save-mirror hf-mirror \ --demo-fallback demo-fallback.zip --demo-fallback-sha "$DEMO_FALLBACK_SHA" + - name: Save the recogniser cache (also when another step failed: the files are checked by sha256 either way) + if: always() && steps.hfcache.outputs.cache-hit != 'true' && hashFiles('hf-mirror/pytorch_model.bin') != '' + uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + with: + path: hf-mirror + key: lsdub-recogniser-${{ env.RECOGNISER_REVISION }} - name: ComfyUI log if: always() run: tail -n 300 comfy/install-path-comfyui.log || true From c85e86f9dd777dc2b59798dc39069aab7d69d304 Mon Sep 17 00:00:00 2001 From: vveerrgg Date: Mon, 9 Nov 2026 16:43:39 -0700 Subject: [PATCH 05/12] Round 2: install-path.yml valid YAML (quoted step name); release.py names what it pushed when it stops, pushes main and the tag atomically, plain stops for a site main without the changelog page, Registry Pending vs Active; recovery in CONTRIBUTING; stability wording --- .github/workflows/install-path.yml | 2 +- CONTRIBUTING.md | 15 +++++++++++++-- README.md | 3 +-- scripts/release.py | 23 +++++++++++++++-------- 4 files changed, 30 insertions(+), 13 deletions(-) diff --git a/.github/workflows/install-path.yml b/.github/workflows/install-path.yml index 4d6f0fa..a851a41 100644 --- a/.github/workflows/install-path.yml +++ b/.github/workflows/install-path.yml @@ -64,7 +64,7 @@ jobs: venv/bin/python pack/scripts/install_path_check.py --comfyui comfy --pack pack \ --hf-mirror hf-mirror --save-mirror hf-mirror \ --demo-fallback demo-fallback.zip --demo-fallback-sha "$DEMO_FALLBACK_SHA" - - name: Save the recogniser cache (also when another step failed: the files are checked by sha256 either way) + - name: "Save the recogniser cache (also when another step failed; the files are checked by sha256 either way)" if: always() && steps.hfcache.outputs.cache-hit != 'true' && hashFiles('hf-mirror/pytorch_model.bin') != '' uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 with: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7f5b94e..3af30f5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -32,12 +32,12 @@ By contributing you agree your work is licensed under the Apache License 2.0, th ## Stability policy - Releases are batched; patch versions only for breakage. Features go into the next minor version. -- Defaults stay byte-identical across a minor series unless the changelog says otherwise. +- Defaults stay unchanged within a minor series unless the changelog says otherwise. - Every release passes the install-path test (`.github/workflows/install-path.yml`, `scripts/install_path_check.py`) on a clean ComfyUI before it is tagged. Run it yourself: `python scripts/install_path_check.py --comfyui PATH/TO/ComfyUI` with that ComfyUI's venv (its requirements plus `requirements.txt`, nothing else). - Models are pinned by revision and sha256. -- 0.2.0 to 0.2.2 landed in one day while the install-path test did not yet exist; it has run on every release since. +- 0.2.0 to 0.2.2 landed in one day while the install-path test did not yet exist; it runs on every release from 0.2.3 on. ## Releases (maintainers) One command, from a clean pack checkout on main with the engine and the microsite checked out beside it: @@ -52,6 +52,17 @@ engine; runs the pack checks; pushes `release/X.Y.Z` and waits for CI and Instal watches the publish (which runs Install path again first), confirms the Registry lists the version and checks the site deploy. Any failure before the tag stops with a plain message and leaves nothing changed remotely. +If release.py stops part way: +- Before "push release/X.Y.Z": nothing remote changed. Fix the cause; delete the local `release/X.Y.Z` branch + (`git checkout main && git branch -D release/X.Y.Z`) and drop a local engine record commit if one was made + (`git -C ../lsdub reset --hard origin/main`), then run it again. +- After the release branch is pushed but before the tag: only `release/X.Y.Z` is on origin. Fix on that branch and push + again (release.py waits on the new commit), or delete it (`git push origin --delete release/X.Y.Z`) and start over. +- After the tag: main and `vX.Y.Z` were pushed together (`--atomic`), so publish is running. Never re-tag or delete the tag; + watch the publish run, and if it failed, fix and release the next patch version. Push the engine record by hand if + release.py stopped before it did (`git -C ../lsdub push origin main`). +release.py names what it already pushed whenever it stops. + Repository secrets: - `REGISTRY_ACCESS_TOKEN` (here): the Comfy Registry publisher key, already set. - `MICROSITE_DISPATCH_TOKEN` (here): a fine-grained GitHub token, resource owner HumanjavaEnterprises, repository access only diff --git a/README.md b/README.md index f106f0c..39ccb03 100644 --- a/README.md +++ b/README.md @@ -206,8 +206,7 @@ Python SDK (`pip install comfy-sdk`). LSDub aims to be stable software that is easy to install and build on, not a stream of updates. - Releases are batched. A patch version (0.2.x) only fixes breakage; new features wait for the next minor version. -- Across a minor series the defaults stay byte-identical: the same inputs at the defaults give the same render, unless the - changelog says otherwise. +- Within a minor series the defaults stay unchanged unless the changelog says otherwise. - Every release passes the install-path test before it is tagged: a clean ComfyUI on CPU, only this pack's requirements, every node registered, the phone recogniser downloaded into an empty models folder, and a Dub at the defaults and with the mouth switches on the demo clip. diff --git a/scripts/release.py b/scripts/release.py index c7f54b6..cb86409 100644 --- a/scripts/release.py +++ b/scripts/release.py @@ -40,11 +40,14 @@ class Stop(SystemExit): - def __init__(self, msg): super().__init__(f'\nrelease: STOPPED. {msg}\nNothing remote was changed.' if not REMOTE_TOUCHED[0] else - f'\nrelease: STOPPED. {msg}') + def __init__(self, msg): + pushed = '\n'.join(f' - {p}' for p in PUSHED) + tail = ('\nNothing remote was changed.' if not PUSHED else + f'\nAlready pushed:\n{pushed}\nRecovery: CONTRIBUTING.md, Releases, "If release.py stops part way".') + super().__init__(f'\nrelease: STOPPED. {msg}{tail}') -REMOTE_TOUCHED = [False] +PUSHED = [] STEP = [0] @@ -164,7 +167,9 @@ def main(): try: RN.check(read(os.path.join(PACK, 'CHANGELOG.md')), v) except ValueError as e: raise Stop(f'CHANGELOG.md: {e}. Write the {v} section first (CONTRIBUTING.md, Release notes).') from None git(site, 'fetch', '-q', 'origin', 'main', quiet=True) - page = git(site, 'show', 'origin/main:docs/changelog.html', quiet=True) + r = subprocess.run(['git', 'show', 'origin/main:docs/changelog.html'], cwd=site, capture_output=True, text=True) + if r.returncode: raise Stop(f'the microsite main ({site}) has no docs/changelog.html. The changelog page has to be on the site\'s main first.') + page = r.stdout if f'id="v{v}"' not in page: raise Stop(f'the microsite main has no
in docs/changelog.html. Add it on the site\'s main first.') print(f' demo zip ok: {demo_published(PACK)}') @@ -212,19 +217,20 @@ def main(): step(f'push release/{v} and wait for CI + Install path on {rel_sha[:9]}') git(PACK, 'push', '-q', 'origin', f'release/{v}') + PUSHED.append(f'pack branch release/{v} ({rel_sha[:9]}); main, tags and the Registry untouched') print(' (a branch only; main, tags and the Registry are untouched until both pass)') time.sleep(20) runs_green(PACK_REPO, rel_sha, ['CI', 'Install path'], wait=True) step(f'fast-forward main, tag v{v}, push; push the engine record') - REMOTE_TOUCHED[0] = True git(PACK, 'checkout', '-q', 'main') git(PACK, 'merge', '-q', '--ff-only', f'release/{v}') - git(PACK, 'push', '-q', 'origin', 'main') git(PACK, 'tag', '-a', f'v{v}', '-m', f'LSDub {v}') - git(PACK, 'push', '-q', 'origin', f'v{v}') + git(PACK, 'push', '-q', '--atomic', 'origin', 'main', f'v{v}') # main and the tag land together or not at all + PUSHED.append(f'pack main -> {rel_sha[:9]} and tag v{v} (publish starts from the tag)') tag_time = time.time() git(eng, 'push', '-q', 'origin', 'main') + PUSHED.append('engine main: the vendor record commit') step('watch publish (it re-runs Install path on the tag first)') time.sleep(20) @@ -234,7 +240,8 @@ def main(): for _ in range(30): with urllib.request.urlopen(REGISTRY, timeout=60) as r: vs = {x.get('version'): x.get('status') for x in json.loads(r.read())} # noqa: S310 if v in vs: - print(f' {v}: {vs[v]}') + st = {'NodeVersionStatusActive': 'Active', 'NodeVersionStatusPending': 'Pending'}.get(vs[v], vs[v]) + print(f' {v}: {st}' + (' (listed; the Registry still has to review it before it is Active)' if st == 'Pending' else '')) break time.sleep(30) else: From 6e4a467c1184bd1d548d3ec2d357ef55fbcd57cf Mon Sep 17 00:00:00 2001 From: vveerrgg Date: Mon, 9 Nov 2026 18:57:58 -0700 Subject: [PATCH 06/12] 0.3.0: the new mouth is the Tweaks default (warp_events vocal, warp_hold nucleus-follow, warp_closures aligned-press, warp_aperture p95, new warp_realise events), classic on each and on Align's aligner (now ctc by default) is 0.2.x, 0.2.x saved values load as classic; Verify bands on the event reading; engine vendored; release.py deploys the site from this box with wrangler's login (no secrets), publish.yml's dispatch step removed --- .github/workflows/publish.yml | 17 --- CHANGELOG.md | 11 ++ CONTRIBUTING.md | 15 ++- README.md | 8 ++ .../1-LSDub-Prompt-Everything.json | 5 +- .../2-LSDub-BYO-Clip-and-Song.json | 5 +- .../4-LSDub-Make-Your-Character-Sing.json | 7 +- lsdub_engine/VERSION | 4 +- lsdub_engine/audits/qa.py | 102 ++++++++++++++++-- lsdub_engine/audits/scorecard.py | 5 + lsdub_engine/core/lyric_align.py | 5 +- lsdub_engine/core/mouth_paint.py | 24 +++-- lsdub_engine/core/sing.py | 55 ++++++---- nodes_audio.py | 28 +++-- nodes_mouth.py | 67 ++++++++---- nodes_qa.py | 4 +- scripts/make_workflows.py | 2 +- scripts/release.py | 48 ++++++--- tests/engine/test_default_flip_030.py | 86 +++++++++++++++ 19 files changed, 383 insertions(+), 115 deletions(-) create mode 100644 tests/engine/test_default_flip_030.py diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index c4329eb..435fd40 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -22,20 +22,3 @@ jobs: with: personal_access_token: ${{ secrets.REGISTRY_ACCESS_TOKEN }} # set this secret in the repo settings skip_checkout: 'true' - site: # after a good publish: ask the microsite to deploy its main branch (it serves /changelog) - needs: publish - runs-on: ubuntu-latest - steps: - - name: Dispatch lsdub-release to the microsite - env: - TOKEN: ${{ secrets.MICROSITE_DISPATCH_TOKEN }} - TAG: ${{ github.ref_name }} - run: | - if [ -z "$TOKEN" ]; then - echo "::notice title=Site deploy skipped::MICROSITE_DISPATCH_TOKEN is not set, so the microsite was not asked to deploy. Add it (CONTRIBUTING.md, Releases) or deploy the site by hand." - exit 0 - fi - curl -sSf -X POST -H "Authorization: Bearer $TOKEN" -H "Accept: application/vnd.github+json" \ - https://api.github.com/repos/HumanjavaEnterprises/lsdub.web.microsite.src/dispatches \ - -d "{\"event_type\":\"lsdub-release\",\"client_payload\":{\"tag\":\"$TAG\"}}" - echo "::notice title=Site deploy requested::lsdub-release $TAG sent to the microsite" diff --git a/CHANGELOG.md b/CHANGELOG.md index d9e78fc..cfd0e63 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## 0.3.0 (2026-11-09) + +- The new mouth is now the default; set the switches to classic for 0.2.x output (Mouth Tweaks' five mouth switches and Align's `aligner`). +- Verify's bands now read the mouth against the vocal: PASS needs clearly more sung syllables met than chance, the p/b/m shut and no extra shuts. +- On 190 test shots against 0.1.11: syllables met 0.54 → 0.72, p/b/m shut 0.27 → 0.55, vowel opening 0.89 → 0.97, stray shuts 2 → 0. +- Verify PASS on those shots: 0.1.11 8, the new mouth 59, the 1140 controls 0. The stricter target is shown beside it. +- New Mouth Tweaks switch `warp_realise`; Align now defaults to `ctc` (downloads 1.2 GB on first use). +- Workflows saved with 0.2.x values still load and keep the 0.2.x mouth. + +Full changelog: https://lsdub.sndbnch.app/changelog + ## 0.2.2 (2026-11-09) - Fixed: LSDub Dub renders again with the new mouth switches on (0.2.1 failed every shot there). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3af30f5..0056467 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -49,8 +49,11 @@ Before it: write the `## X.Y.Z` section in CHANGELOG.md (see Release notes) and microsite's `docs/changelog.html` on its main. The command refuses unless both trees are clean and on main and engine CI is green; bumps the version, the Icon/Banner URLs and CITATION.cff; regenerates the node tables and the bug form; vendors the engine; runs the pack checks; pushes `release/X.Y.Z` and waits for CI and Install path; then fast-forwards main, tags `vX.Y.Z`, -watches the publish (which runs Install path again first), confirms the Registry lists the version and checks the site -deploy. Any failure before the tag stops with a plain message and leaves nothing changed remotely. +watches the publish (which runs Install path again first), confirms the Registry lists the version, then deploys the site +from your box: a clean microsite checkout on main, equal to origin/main, through wrangler's own login (`npx wrangler login` +once, account fa095a45453409a42f1c739a174e609f; an exported `CLOUDFLARE_API_TOKEN` is ignored), and checks that +https://lsdub.sndbnch.app/changelog answers 200 with the new version. To deploy the site by hand: from the microsite's clean +main, `env -u CLOUDFLARE_API_TOKEN CLOUDFLARE_ACCOUNT_ID=fa095a45453409a42f1c739a174e609f npx wrangler deploy`. Any failure before the tag stops with a plain message and leaves nothing changed remotely. If release.py stops part way: - Before "push release/X.Y.Z": nothing remote changed. Fix the cause; delete the local `release/X.Y.Z` branch @@ -63,9 +66,5 @@ If release.py stops part way: release.py stopped before it did (`git -C ../lsdub push origin main`). release.py names what it already pushed whenever it stops. -Repository secrets: -- `REGISTRY_ACCESS_TOKEN` (here): the Comfy Registry publisher key, already set. -- `MICROSITE_DISPATCH_TOKEN` (here): a fine-grained GitHub token, resource owner HumanjavaEnterprises, repository access only - `lsdub.web.microsite.src`, permission Contents: Read and write (what `POST /repos/.../dispatches` needs). Without it the - publish job's site step prints a notice and skips, and `release.py` starts the deploy itself with your `gh` login. -- `CLOUDFLARE_API_TOKEN` (in the microsite repo): see its README, Deploys. +Repository secrets: only `REGISTRY_ACCESS_TOKEN` (here), the Comfy Registry publisher key, already set. The site needs no +secret: it deploys from the maintainer's box (above), the one deploy path. diff --git a/README.md b/README.md index 39ccb03..499936b 100644 --- a/README.md +++ b/README.md @@ -151,6 +151,10 @@ and `LSDub/qa`. - `coherence_charge` (`remade`): the continuity repair's budget counts only the frames it re-makes. `all` is 0.1.9's, which counts every flagged run. - Set all three to their second choice to dub as 0.1.9 did. + - The vocal-driven mouth (default since 0.3.0): `warp_events` (`vocal`), `warp_hold` (`nucleus-follow`), `warp_closures` + (`aligned-press`), `warp_aperture` (`p95`) and `warp_realise` (`events`): one opening per sung syllable, lips shut only on + the lyric's p/b/m, vowels as wide as the singer's own. Set all five to `classic`, and Align's `aligner` to `classic`, for + the 0.2.x mouth. - **LSDub Dub:** dubs each DUB and DUB-MEDIUM shot. - The shot is cropped to a 512 px face and warped at 2x the frame rate. The lower face is pasted back and a measured sync gate is applied. @@ -169,6 +173,9 @@ and `LSDub/qa`. **QA** - **LSDub Verify:** a likely-success score per dubbed shot (PASS, REVIEW or FAIL), compared with the source. A soft or held line is judged against the mouth travel the song asks for, and the report shows that reading beside the travel. + A shot can PASS when its mouth opens on clearly more of the sung syllables than chance would give, shuts on the sung + p/b/m and shuts nowhere else more often than the singer's own take (needs the recogniser Align `ctc` downloads; without + it, the 0.2.x band). - **LSDub Scorecard:** every shot of the cut scored 0 to 100, worst first. - **LSDub Sync QA:** the measured mouth against the vocal (correlation and lag), with a review card that scrolls the vocal waveform past a playhead. @@ -207,6 +214,7 @@ LSDub aims to be stable software that is easy to install and build on, not a str - Releases are batched. A patch version (0.2.x) only fixes breakage; new features wait for the next minor version. - Within a minor series the defaults stay unchanged unless the changelog says otherwise. +- 0.3.0 changed the defaults (the new mouth, Align `ctc`, Verify's bands), which is why it is a minor release. - Every release passes the install-path test before it is tagged: a clean ComfyUI on CPU, only this pack's requirements, every node registered, the phone recogniser downloaded into an empty models folder, and a Dub at the defaults and with the mouth switches on the demo clip. diff --git a/example_workflows/1-LSDub-Prompt-Everything.json b/example_workflows/1-LSDub-Prompt-Everything.json index 873cc86..186b5b9 100644 --- a/example_workflows/1-LSDub-Prompt-Everything.json +++ b/example_workflows/1-LSDub-Prompt-Everything.json @@ -1076,7 +1076,7 @@ ], "size": [ 340, - 398 + 424 ], "flags": {}, "order": 21, @@ -1108,7 +1108,8 @@ false, "asym", "aligner", - "remade" + "remade", + "events" ], "color": "#532", "title": "LSDub Mouth Tweaks" diff --git a/example_workflows/2-LSDub-BYO-Clip-and-Song.json b/example_workflows/2-LSDub-BYO-Clip-and-Song.json index dc9d78b..0898fe1 100644 --- a/example_workflows/2-LSDub-BYO-Clip-and-Song.json +++ b/example_workflows/2-LSDub-BYO-Clip-and-Song.json @@ -1084,7 +1084,7 @@ ], "size": [ 340, - 398 + 424 ], "flags": {}, "order": 22, @@ -1116,7 +1116,8 @@ false, "asym", "aligner", - "remade" + "remade", + "events" ], "color": "#532", "title": "LSDub Mouth Tweaks" diff --git a/example_workflows/4-LSDub-Make-Your-Character-Sing.json b/example_workflows/4-LSDub-Make-Your-Character-Sing.json index 61e8dca..10d853e 100644 --- a/example_workflows/4-LSDub-Make-Your-Character-Sing.json +++ b/example_workflows/4-LSDub-Make-Your-Character-Sing.json @@ -539,7 +539,7 @@ true, true, "", - "sphinx" + "classic" ], "color": "#523", "title": "LSDub Align" @@ -1055,7 +1055,7 @@ ], "size": [ 340, - 398 + 424 ], "flags": {}, "order": 20, @@ -1087,7 +1087,8 @@ false, "asym", "aligner", - "remade" + "remade", + "events" ], "color": "#532", "title": "LSDub Mouth Tweaks" diff --git a/lsdub_engine/VERSION b/lsdub_engine/VERSION index 291b2f7..e141800 100644 --- a/lsdub_engine/VERSION +++ b/lsdub_engine/VERSION @@ -1,4 +1,4 @@ pack_version: 0.2.2 uncommitted: 0 -tree_sha256: f36b7410d7dc3461fba57844603c81a5ba5ec37adb160148f9dc50595e4f19ba -tests_sha256: 59f5e441dbefa42caf1faf35824ac93cfec99b3e58b280a0e522d2f0f8759edb +tree_sha256: 83e26cd442610a9559b0ecc0cea44e04890516a738f067d900ed75a278ecc8cd +tests_sha256: 96f87779cd8eb927d41f259eec9d5c652eae3192c352f56bd78e2f4f425cbc24 diff --git a/lsdub_engine/audits/qa.py b/lsdub_engine/audits/qa.py index 6c4a410..73a0352 100644 --- a/lsdub_engine/audits/qa.py +++ b/lsdub_engine/audits/qa.py @@ -527,6 +527,13 @@ def asked_travel(s): EV_KEYS = ("onset_hit", "onset_n", "closure_hit", "closure_n", "spurious", "spurious_take", "open_ratio", "open_n") LAG_WINDOW_S = 0.2 # a mouth rise this near a sung onset, either side, is that onset's rise (the per-onset reading) +# Verify's sync band (0.3.0) reads the event judge, not the envelope. Each line comes from the 190-shot matrix's controls (the original take +# against the new song, the render against the vocal's events shifted +-200 ms: 1140 readings, none may PASS) and the take's own numbers: +EV_ODDS = 0.05 # onsets: the chance of meeting this many of them with the shot's own rises placed at random (rise density x 2 TOL) is under 1 in 20 +EV_MIN_ONSETS = 4 # fewer sung onsets than this are too few to read (the judge's thin line): REVIEW, not PASS +EV_CLOSURE = 0.5 # p/b/m shut when 2 or more: at least half (the take against the new song reaches 0.52 at its p90); one closure: shut +# stray shuts: no more than the singer's own take shows in the same sung time. The envelope reading stays only as the floor: under +# sync_truth.TRUTH_FAIL the mouth does not follow the song at all and the shot FAILS, whatever the events say. def vocal_events(vocal, align=None, phone_models=None): @@ -601,10 +608,48 @@ def event_reading(video, s, fps, offset, ev, read): p90 = round(float(np.percentile(lags, 90)), 1) if lags else None out = {f"ev_{k}": r.get(k) for k in EV_KEYS} out.update(ev_onset_lag_ms=med, ev_onset_lag_p90_ms=p90, ev_lag_n=len(lags), ev_pass=r.get("pass"), ev_read=read) + out["ev_chance"] = round(min(1.0, (r.get("rise_n") or 0) / (len(F) / fps) * 2 * EJ.TOL), 3) + out["ev_odds"] = onset_odds(out["ev_onset_hit"], out["ev_onset_n"], out["ev_chance"]) out["ev_why"] = event_words(r, med, p90) return out +def onset_odds(hit, n, chance): + """the chance of meeting at least hit x n of n sung onsets with each one met at random with probability `chance` (one-sided + binomial tail); None when unread.""" + if hit is None or not n or chance is None: + return None + from math import comb + + k, p = int(round(hit * n)), min(max(float(chance), 0.0), 1.0) + return round(sum(comb(n, i) * p**i * (1 - p) ** (n - i) for i in range(k, n + 1)), 5) + + +def event_band(r): + """Verify's sync band on the event reading -> (True / False, why) on a phones reading of a sung shot, (None, '') when the row has + no such reading (Verify then reads the envelope, sync_truth, as 0.2.x did). The lines: EV_* above.""" + if r.get("ev_read") != "phones" or r.get("ev_onset_n") is None: + return None, "" + n = r["ev_onset_n"] + if n < EV_MIN_ONSETS: + return False, f"too few sung onsets to read the sync ({n}, need {EV_MIN_ONSETS})" + odds = r.get("ev_odds") + if odds is None: + odds = onset_odds(r.get("ev_onset_hit"), n, r.get("ev_chance")) + if odds is None or odds > EV_ODDS: + return False, ( + f"mouth meets {r.get('ev_onset_hit') or 0:.2f} of {n} sung onsets, no better than chance " + f"({r.get('ev_chance') or 0:.2f}) would give often enough" + ) + cn, ch = r.get("ev_closure_n") or 0, r.get("ev_closure_hit") + if (cn >= 2 and (ch or 0) < EV_CLOSURE) or (cn == 1 and not ch): + return False, f"lips miss the sung p/b/m ({ch or 0:.2f} of {cn} shut)" + sp, st = r.get("ev_spurious"), r.get("ev_spurious_take") + if sp is not None and st is not None and sp > st: + return False, f"mouth shuts where the singer does not ({sp} vs {st} on the take)" + return True, "" + + def path_reading(s): """the two report-only columns: path, lag_frames.""" path, lag = delivered(s) @@ -758,7 +803,11 @@ def score_row(r, smed, SC): elif r["class"] == "COVERAGE": r["band"] = "REVIEW" else: - truth_cap(r) + ev_ok, ev_why = event_band(r) + if ev_ok is None: + truth_cap(r) + else: + sync_cap(r, ev_ok, ev_why) r["band"] = "PASS" if r["score"] >= PASS_BAND else "REVIEW" if r["score"] >= REVIEW_BAND else "FAIL" if (r.get("vocal_frac") or 0) >= VOCAL_NOTE and r["band"] in ("REVIEW", "FAIL"): r["reason"] = f"vocalisation spans cover {r['vocal_frac']:.0%} of the shot; " + r["reason"] @@ -785,6 +834,29 @@ def truth_cap(r): return r +def sync_cap(r, ev_ok, ev_why): + """the event reading decides the sync (0.3.0): a PERF shot whose mouth does not meet the vocal's events (event_band) cannot PASS + (score capped under PASS_BAND); one under the envelope floor (truth < TRUTH_FAIL: it follows the song no better than a mouth that + ignores it) FAILS (capped under REVIEW_BAND).""" + from . import sync_truth as ST + + r["ev_band"] = bool(ev_ok) + if r.get("score") is None: + return r + if r.get("truth") is not None and r["truth"] < ST.TRUTH_FAIL: + cap, why = REVIEW_BAND - 1, ST.why(r) + elif not ev_ok: + cap, why = PASS_BAND - 1, ev_why + else: + return r + if r["score"] > cap: + r["score"] = float(cap) + r["reason"] = why + else: + r["reason"] = f"{why}; {r['reason']}" + return r + + # ---------------------------------------------------------------------------------------------------- outputs def write_triage(meta, shots, out): order = sorted(shots, key=lambda s: (VERDICTS.index(s["verdict"]), -(s.get("px1080") or 0))) @@ -914,6 +986,10 @@ def write_verify(meta, rows, out): "ev_open_ratio", "ev_onset_lag_ms", "ev_onset_lag_p90_ms", + "ev_chance", + "ev_odds", + "ev_band", + "ev_pass", "ev_read", "ev_why", "vocal_lost", @@ -936,10 +1012,11 @@ def write_verify(meta, rows, out): "`sync corr` reads the mouth against the opening built from the alignment, the curve the dub itself was painted from: a high " "number says the render followed its plan, and cannot see a wrong alignment: reported, a diagnostic. `audio` reads it " "against the vocal itself with no alignment (its level and open voiced sound, levels and events, at lag 0), " - "on every frame of the shot, then the mouth's lag in ms (+ = the mouth trails): it decides the sync; with vocalisation " + "on every frame of the shot, then the mouth's lag in ms (+ = the mouth trails); with vocalisation " "spans handed to the take, the same reading on the other frames follows in brackets, reported only. Under " - f"{ST.TRUTH_MIN} or more than {ST.LATE_MS:.0f} ms late (or {ST.EARLY_MS:.0f} ms early) a shot cannot PASS; under " - f"{ST.TRUTH_FAIL} it FAILS.", + f"{ST.TRUTH_FAIL} it FAILS (the mouth does not follow the song at all). With a phones reading of the vocal, `events` decides " + f"the sync; without one (no local recogniser weights) `audio` does, as in 0.2.x: under {ST.TRUTH_MIN} or more than " + f"{ST.LATE_MS:.0f} ms late (or {ST.EARLY_MS:.0f} ms early) a shot cannot PASS.", "`shape` is reported and moves no band: height contrast (1 = the delivered heights follow the card's, 0 = every class the same " "height), then p / b / m phones with the lips really shut (gap under a share of the eye distance), then the width travel, then " "the darkest reading at the lips' corners on closures (no calibrated threshold: compare to the take's).", @@ -948,11 +1025,16 @@ def write_verify(meta, rows, out): "the shot, the worst word; a free walk, so a word it loses reads as off, and `lost` when a shot's words sit more than 0.6 s off, where the walk has run away on a long song: no reading), then the plan's phones read directly on the vocal's frames: " "hiss on hiss, closures on a dip or nasal, vowels on open sound, 0..1. Passed-over words (a lead sheet) are not read.", "`path` is reported and moves no band: the paint delivered, then the frames of it picked from before their own time, whose skin is a frame old.", - "`events` is reported and moves no band this release: the mouth read against the vocal's events one by one by the event " + "`events` reads the mouth against the vocal's events one by one by the event " f"judge (audits/event_judge.py, its own front end; this film: {meta.get('events_read') or 'unread'}): sung onsets met by a " - f"mouth rise within {EJ.TOL * 1000:.0f} ms, p/b/m closures met by shut lips, shuts where the singer has no closure (against " - "the take's own), the wide vowels' opening against the take's, then the per-onset lag: median and p90 of the nearest rise " - f"within {LAG_WINDOW_S * 1000:.0f} ms of each onset (+ = late). The whole-shot `audio` lag can read near 0 while the attacks are late.", + f"mouth rise within {EJ.TOL * 1000:.0f} ms (beside it the share chance would meet with the shot's own rises), p/b/m closures met " + "by shut lips, shuts where the singer has no closure (against the take's own), the wide vowels' opening against the take's, then " + f"the per-onset lag: median and p90 of the nearest rise within {LAG_WINDOW_S * 1000:.0f} ms of each onset (+ = late). " + f"On a phones reading it decides the sync: a shot can PASS when its mouth meets clearly more sung onsets than chance would " + f"(odds under 1 in {1 / EV_ODDS:.0f}, at least {EV_MIN_ONSETS} onsets), shuts on the sung p/b/m (at least half, or the one there " + "is) and shuts nowhere else more often than the singer's own take. `target` is the event judge's own target line (onsets " + f"{EJ.PASS['onset_hit']:.2f}, closures {EJ.PASS['closure_hit']:.2f}, vowels {EJ.PASS['open_ratio']:.2f} of the take's), shown " + "for the headroom and moving no band.", "`travel` is how far the mouth opens and closes over the shot (in face heights), then how much of the take's own movement the " "song asks for (read from the dub's warp log; a held line asks less than the take moves by itself). A mouth that barely moves " f"and opens less than {SC.TRAVEL_KEEP:.2f} as far as it was asked (as far as the take does when there is no reading), or that " @@ -991,7 +1073,7 @@ def _events_cell(r): """onsets hit, closures shut, spurious shuts (take's), vowel opening vs the take's, per-onset lag median / p90; then the words.""" if r.get("ev_onset_hit") is None: return "" - p = [f"onsets {r['ev_onset_hit']:.2f} of {r['ev_onset_n']}"] + p = [f"onsets {r['ev_onset_hit']:.2f} of {r['ev_onset_n']}" + ("" if r.get("ev_chance") is None else f" (chance {r['ev_chance']:.2f})")] if r.get("ev_closure_hit") is not None: p.append(f"closures {r['ev_closure_hit']:.2f} of {r['ev_closure_n']}") sp = r.get("ev_spurious") @@ -1000,6 +1082,8 @@ def _events_cell(r): p.append(f"vowels {r['ev_open_ratio']:.2f} of the take's") if r.get("ev_onset_lag_ms") is not None: p.append(f"lag {r['ev_onset_lag_ms']:+.0f} / p90 {r['ev_onset_lag_p90_ms']:+.0f} ms") + if r.get("ev_pass") is not None: + p.append("target met" if r["ev_pass"] else "target not yet met") return ", ".join(p) + (f": {r['ev_why']}" if r.get("ev_why") else "") diff --git a/lsdub_engine/audits/scorecard.py b/lsdub_engine/audits/scorecard.py index 009ae3f..6d51d45 100644 --- a/lsdub_engine/audits/scorecard.py +++ b/lsdub_engine/audits/scorecard.py @@ -207,6 +207,11 @@ def components(row, film_sharp_med, sharpness_mode="floor"): c["sync"] = clip01((max(row["corr"], row.get("corr_pm1") or row["corr"]) - 0.2) / 0.6) if _fin(row.get("truth")): # the vocal itself (sync_truth), when read, is the sync; the plan corr stays a diagnostic c["sync"] = clip01((row["truth"] - TRUTH_SYNC[0]) / (TRUTH_SYNC[1] - TRUTH_SYNC[0])) + if row.get("ev_read") == "phones": # 0.3.0: a phones event reading that meets the vocal's events is in sync (qa.event_band) + from . import qa + + if qa.event_band(row)[0]: + c["sync"] = 1.0 if row.get("open_in_silence") is not None: c["silence"] = 1 - clip01((row["open_in_silence"] - 0.15) / 0.4) sn = [ diff --git a/lsdub_engine/core/lyric_align.py b/lsdub_engine/core/lyric_align.py index a5c5f5c..a22c4ab 100644 --- a/lsdub_engine/core/lyric_align.py +++ b/lsdub_engine/core/lyric_align.py @@ -941,11 +941,12 @@ def main(argv=None): ap.add_argument("--stt-only", action="store_true") ap.add_argument( "--aligner", - choices=["sphinx", "mfa", "best", "ctc"], + choices=["sphinx", "mfa", "best", "ctc", "classic"], + type=lambda v: "sphinx" if v == "classic" else v, default="sphinx", help="mfa = Montreal Forced Aligner per phrase, sphinx/whisper chain for phrases MFA fails; best = per phrase, " "MFA vs the sphinx/whisper chain, whichever sits better on the vocal envelope (env_score); ctc = the lyric's phones forced " - "onto a CTC phone recogniser's frames (core/ctc_align.py), speech-to-text used only to pick the lyric window", + "onto a CTC phone recogniser's frames (core/ctc_align.py), speech-to-text used only to pick the lyric window; classic = sphinx (the 0.2.x chain)", ) ap.add_argument( "--phone-models", help="ctc: the recogniser's folder (default: LSDUB_PHONE_MODELS, LSDUB_HF_MODELS/wav2vec2-espeak, else the hub)" diff --git a/lsdub_engine/core/mouth_paint.py b/lsdub_engine/core/mouth_paint.py index f9226b2..ccdca33 100644 --- a/lsdub_engine/core/mouth_paint.py +++ b/lsdub_engine/core/mouth_paint.py @@ -210,6 +210,12 @@ def _not_negative(x): @functools.lru_cache(maxsize=1) +def classic(old): + """argparse type for a mouth drive stage flag: `classic` is the 0.2.x value (old), any other choice as typed. sing's SNAP turns + the stages on from 0.3.0; `classic` on each switch gives back the 0.2.x mouth exactly.""" + return lambda v: old if v == "classic" else v + + def _parser(): ap = argparse.ArgumentParser() ap.add_argument("--recipe", help="a named recipe (recipes.py): its flags go in at this point, later flags override them") @@ -439,16 +445,19 @@ def _parser(): ) ap.add_argument( "--warp-events", - choices=["off", "vocal"], + choices=["off", "vocal", "classic"], + type=classic("off"), default="off", help="warp (plain targets): the vocal's syllable events written onto the drive track (core/nucleus_events.py): nuclei off the " "phone network and the spectral flux, closures off the alignment's P/B/M, rests; read from --warp-audio. Annotation only: no " - "pixel moves until a stage reads it", + "pixel moves until a stage reads it. classic, here and on --warp-hold / --warp-closures / --warp-aperture / --warp-realise, is the " + "0.2.x value (off; plan for closures): sing turns the stages on, classic turns each back off", ) ap.add_argument("--warp-audio", help="warp (--warp-events): the lead stem the events are read from (song time; the track's t0 cuts it)") ap.add_argument( "--warp-hold", - choices=["off", "nucleus", "nucleus-follow"], + choices=["off", "nucleus", "nucleus-follow", "classic"], + type=classic("off"), default="off", help="warp (plain targets, needs --warp-events vocal): one held opening per sung syllable (core/nucleus_hold.py): the vowel's " "class times a coarse loudness, attack ahead of the onset, held to the next syllable, decayed only into a rest; replaces the " @@ -460,7 +469,8 @@ def _parser(): ) ap.add_argument( "--warp-realise", - choices=["off", "events"], + choices=["off", "events", "classic"], + type=classic("off"), default="off", help="warp (plain targets, needs --warp-events vocal): inside each sung syllable's onset window (the release and the attack) the " "plan is applied as planned: full confidence, no residual easing, no temporal smoothing there; everything outside the windows " @@ -468,7 +478,8 @@ def _parser(): ) ap.add_argument( "--warp-closures", - choices=["plan", "aligned", "aligned-press"], + choices=["plan", "aligned", "aligned-press", "classic"], + type=classic("plan"), default="plan", help="warp (plain targets, needs --warp-events vocal): where the lips shut (core/closure_plan.py). plan = the viseme track's PP " "frames as planned; aligned = PP only at the alignment's P / B / M read off the vocal, ahead of the burst, every stray PP lifted; " @@ -476,7 +487,8 @@ def _parser(): ) ap.add_argument( "--warp-aperture", - choices=["off", "map", "loop", "p95"], + choices=["off", "map", "loop", "p95", "classic"], + type=classic("off"), default="off", help="warp (plain targets): how far the mouth opens (core/aperture.py). map = the track's opening scaled per shot so its " "p95 is a full opening of the take's range; p95 = the same gain read on the wide-vowel nuclei only (needs --warp-events vocal); " diff --git a/lsdub_engine/core/sing.py b/lsdub_engine/core/sing.py index 191618d..204ac83 100644 --- a/lsdub_engine/core/sing.py +++ b/lsdub_engine/core/sing.py @@ -47,20 +47,32 @@ lead=0.06, word_close=0.06, attack_ms=15.0, - paint=["--warp-sigma", "0.8", "--warp-temporal", "0.6", "--warp-close-depth", "0.05", "--warp-follow", "0.15", "--warp-shape", "asym"], -) # --warp-follow: the plain target holds the opening near the plan, so a take's own mouth timing does not ride through. + paint=["--warp-sigma", "0.8", "--warp-temporal", "0.6", "--warp-close-depth", "0.05", "--warp-follow", "0.15", "--warp-shape", "asym", + "--warp-events", "vocal", "--warp-hold", "nucleus-follow", "--warp-closures", "aligned-press", "--warp-aperture", "p95", + "--warp-realise", "events"], +) # fmt: skip +# --warp-follow: the plain target holds the opening near the plan, so a take's own mouth timing does not ride through. # --warp-shape asym: closing edges 20 ms early, openings on time (core/drive_shape.py); a --warp-shape in the caller's flags wins +# the vocal-driven mouth (0.3.0): the syllable events off the vocal, one held opening per syllable that follows the voice's level, +# closures only on the lyric's p/b/m pressed to contact, vowels opened to the singer's own widest, and the plan realised inside each +# onset window. Plain targets only (coart has its own planner); a caller's value for any of them wins, and `classic` on each is 0.2.x. +STAGES = ("--warp-events", "--warp-hold", "--warp-closures", "--warp-aperture", "--warp-realise") +CLASSIC = {"--warp-events": "off", "--warp-hold": "off", "--warp-closures": "plan", "--warp-aperture": "off", "--warp-realise": "off"} +CALLER_WINS = ("--warp-shape", *STAGES) def snap_paint(paint_args, m): """SNAP's paint flags. A --warp-temporal in the caller's flags, in output frames, holds here at the internal rate (x m); it never goes under SNAP's own. --warp-temporal-width, in output frames too, the same, and --warp-temporal-spare eases over one output - frame. A --warp-shape in the caller's flags replaces SNAP's (--warp-shape off: the drive as it was before asym). Named by nobody: - SNAP's flags unchanged.""" + frame. A --warp-shape or a mouth stage flag (STAGES) in the caller's flags replaces SNAP's (--warp-shape off: the drive as it was + before asym; `classic` on a stage: the 0.2.x mouth). The stages ride only on the plain targets. Named by nobody: SNAP's flags + unchanged.""" p = list(SNAP["paint"]) - if any(str(x) == "--warp-shape" or str(x).startswith("--warp-shape=") for x in paint_args): - i = p.index("--warp-shape") - del p[i : i + 2] + plain = paint_api.target(paint_args) not in ("coart", "own") + for f in CALLER_WINS: + if has_flag(paint_args, f) or (f in STAGES and not plain): + i = p.index(f) + del p[i : i + 2] t = paint_api.temporal(paint_args) if t is not None: i = p.index("--warp-temporal") + 1 @@ -73,6 +85,14 @@ def snap_paint(paint_args, m): return p +def stage_value(paint_args, snap_flags, name): + """the value a mouth stage flag has on this paint: the caller's, else SNAP's (snap_flags, as snap_paint gave them); `classic` + read as the 0.2.x value. None: neither names it (the paint's own default).""" + v = flag_value(paint_args, name) + v = flag_value(snap_flags, name) if v is None else v + return CLASSIC.get(name) if v == "classic" else v + + # ---------------------------------------------------------------------------------------------------- pick def has_flag(flags, name): """name is among flags, as `--x v` or `--x=v`.""" @@ -102,12 +122,11 @@ def track_target(warp_json, n, m): def warp_audio(paint_args, snap_flags, vocal): - """the stem the event stage reads (--warp-audio), set when --warp-events is among the paint's flags (the caller's or SNAP's) and - the caller did not name one; () otherwise, so a paint without the stage gets the argv it always did.""" - flags = [str(x) for x in (*paint_args, *snap_flags)] - if "--warp-events" not in flags and not any(f.startswith("--warp-events=") for f in flags): + """the stem the event stage reads (--warp-audio), set when --warp-events is on (vocal) in the paint's flags (the caller's or SNAP's) + and the caller did not name one; () otherwise, so a paint without the stage gets the argv it always did.""" + if stage_value(paint_args, snap_flags, "--warp-events") in (None, "off"): return () - if "--warp-audio" in flags or any(f.startswith("--warp-audio=") for f in flags): + if has_flag(paint_args, "--warp-audio") or has_flag(snap_flags, "--warp-audio"): return () return ("--warp-audio", str(vocal)) @@ -322,13 +341,11 @@ def run( sd += paint_api.dyn_flags(paint_args) if word_close: vs += ["--word-close", str(word_close)] - hold = has_flag(paint_args, "--warp-hold") or has_flag(SNAP["paint"] if snap else (), "--warp-hold") + snap_flags = snap_paint(paint_args, m) if snap else () + hold = stage_value(paint_args, snap_flags, "--warp-hold") not in (None, "off") if hold: # the hold stage writes the opening: the track carries the class opening alone, no envelope under it vs += ["--open-curve", "off"] - if word_close and (flag_value(paint_args, "--warp-closures") or flag_value(SNAP["paint"] if snap else (), "--warp-closures")) in ( - "aligned", - "aligned-press", - ): + if word_close and stage_value(paint_args, snap_flags, "--warp-closures") in ("aligned", "aligned-press"): vs += ["--word-close-vocal"] # the between-word press only where the vocal drops steps.call("sung_dyn", sd, quiet=True) steps.call("visemes", vs, quiet=True) @@ -341,9 +358,9 @@ def run( user_overrides=paint_args, operational=[ *paint_api.coart_dyn(paint_args, dyn), - *(snap_paint(paint_args, m) if snap else ()), + *snap_flags, *(("--occluder-mask", occ) if mask else ()), - *warp_audio(paint_args, snap_paint(paint_args, m) if snap else (), drive), + *warp_audio(paint_args, snap_flags, drive), ], ) t_plan = plan_t0(t0, paint_args, fps * m) # the plan moved: the gate reads the same curve diff --git a/nodes_audio.py b/nodes_audio.py index 12088f6..ad0b5b0 100644 --- a/nodes_audio.py +++ b/nodes_audio.py @@ -164,10 +164,10 @@ def define_schema(cls): 'read by Verify.'), io.String.Input('lead_overrides', multiline=True, default='', optional=True, tooltip='With lead_sheet: your corrections, one per line: pass 10.04 11.93 or lead 10.04 10.49 (seconds).'), - io.Combo.Input('aligner', options=['sphinx', 'ctc'], default='sphinx', optional=True, - tooltip='How the lyric is timed on the vocal. sphinx = the 0.1.11 chain (speech aligner per phrase, ' - 'speech-to-text spread where it fails); ctc = the lyric\'s sounds forced onto a phone recogniser\'s ' - 'frames (first use downloads a 1.2 GB model); speech-to-text then only picks which lines are sung.'), + io.Combo.Input('aligner', options=['ctc', 'classic'], default='ctc', optional=True, + tooltip='How the lyric is timed on the vocal. ctc = the lyric\'s sounds forced onto a phone recogniser\'s ' + 'frames (first use downloads a 1.2 GB model); speech-to-text then only picks which lines are sung. ' + 'classic = the 0.2.x chain (speech aligner per phrase, speech-to-text spread where it fails).'), ], outputs=[T.LSDubAlign.Output(display_name='align', tooltip='Word timings + the song viseme track.'), io.String.Output(display_name='lyric_check', tooltip='Markdown: least certain words and the ' @@ -179,23 +179,35 @@ def _vocal(cls, song, vocal): return (song.lead or song.mix) if vocal is None else None @classmethod - def _key(cls, project, song, lyrics, vocal, pronunciations, stt_model, transcript_json, retime_to_vocal=False, lead_sheet=False, lead_overrides='', aligner='sphinx'): + def _key(cls, project, song, lyrics, vocal, pronunciations, stt_model, transcript_json, retime_to_vocal=False, lead_sheet=False, lead_overrides='', aligner='ctc'): transcript_json = _input_path(transcript_json) vh = media.audio_hash(vocal) if vocal is not None else (song.hashes.get('lead') or song.hash or file_hash(cls._vocal(song, None))) tj = file_hash(transcript_json) if transcript_json else '' + aligner = cls._aligner(aligner) return stage_key('LSDubAlign', {'vocal': vh, 'lyrics': lyrics.hash if lyrics else 'transcript', 'pron': text_hash(pronunciations.strip()), 'stt': stt_model, 'transcript': tj, 'v': 3, 'which': 'given' if vocal is not None else ('lead' if song.lead else 'mix'), **({'retime': 1} if retime_to_vocal else {}), - **({'lead_sheet': text_hash(lead_overrides.strip())} if lead_sheet else {})}, [project.cache_key()]) + **({'lead_sheet': text_hash(lead_overrides.strip())} if lead_sheet else {}), + **({'aligner': aligner} if aligner != 'sphinx' else {})}, [project.cache_key()]) + + @staticmethod + def _aligner(aligner): + """the engine's aligner for the Combo: classic (and sphinx, a 0.2.x workflow's saved value) is sphinx.""" + return 'sphinx' if aligner in ('classic', 'sphinx') else aligner + + @classmethod + def validate_inputs(cls, aligner='ctc'): + return True if aligner in ('ctc', 'classic', 'sphinx') else f'aligner must be ctc or classic, not {aligner!r}' @classmethod - def fingerprint_inputs(cls, project, song, lyrics=None, vocal=None, pronunciations='', stt_model='large-v3', transcript_json='', retime_to_vocal=False, lead_sheet=False, lead_overrides='', aligner='sphinx'): + def fingerprint_inputs(cls, project, song, lyrics=None, vocal=None, pronunciations='', stt_model='large-v3', transcript_json='', retime_to_vocal=False, lead_sheet=False, lead_overrides='', aligner='ctc'): return cls._key(project, song, lyrics, vocal, pronunciations, stt_model, transcript_json, retime_to_vocal, lead_sheet, lead_overrides, aligner) @classmethod - def execute(cls, project, song, lyrics=None, vocal=None, pronunciations='', stt_model='large-v3', transcript_json='', retime_to_vocal=False, lead_sheet=False, lead_overrides='', aligner='sphinx'): + def execute(cls, project, song, lyrics=None, vocal=None, pronunciations='', stt_model='large-v3', transcript_json='', retime_to_vocal=False, lead_sheet=False, lead_overrides='', aligner='ctc'): from .lsdub_engine.core import lead_sheet as LS, lyric_align as LA, viseme_track as VT, vocalisations as VO, vocal_retime as VR transcript_json = _input_path(transcript_json) + aligner = cls._aligner(aligner) st = project.stage('audio/align', cls._key(project, song, lyrics, vocal, pronunciations, stt_model, transcript_json, retime_to_vocal, lead_sheet, lead_overrides, aligner)) align, vis, vis_skip, md = st.path('align.json'), st.path('visemes.json'), st.path('visemes_skip.json'), st.path('lyric_check.md') if not st.is_done(): diff --git a/nodes_mouth.py b/nodes_mouth.py index 043ecef..9cf7336 100644 --- a/nodes_mouth.py +++ b/nodes_mouth.py @@ -27,14 +27,18 @@ DUB_VERDICTS = ('DUB', 'DUB-MEDIUM') WARP_SHAPES = ('asym', 'off') # the first is the engine's default; the last is 0.1.9's TRAVEL_GUARDS = ('aligner', 'take') -# the vocal-driven mouth stages (engine core/nucleus_events, nucleus_hold, closure_plan, aperture): the first value is the engine's default -# today (the 0.1.11 mouth); the others are the vocal-driven mouth, one choice away each. STAGE_FLAGS maps a Combo to its paint flag. -WARP_EVENTS = ('off', 'vocal') -WARP_HOLDS = ('off', 'nucleus-follow', 'nucleus') -WARP_CLOSURES = ('plan', 'aligned-press', 'aligned') -WARP_APERTURES = ('off', 'p95', 'map') +# the vocal-driven mouth stages (engine core/nucleus_events, nucleus_hold, closure_plan, aperture, the realise stage): since 0.3.0 the first +# value is the engine's default (sing.SNAP turns them on); `classic` is the 0.2.x mouth, one choice away on each. STAGE_FLAGS maps a Combo +# to its paint flag. LEGACY: the values a 0.2.x workflow saved for the old default, read as `classic`. +WARP_EVENTS = ('vocal', 'classic') +WARP_HOLDS = ('nucleus-follow', 'nucleus', 'classic') +WARP_CLOSURES = ('aligned-press', 'aligned', 'classic') +WARP_APERTURES = ('p95', 'map', 'classic') +WARP_REALISES = ('events', 'classic') STAGE_FLAGS = {'warp_events': ('--warp-events', WARP_EVENTS), 'warp_hold': ('--warp-hold', WARP_HOLDS), - 'warp_closures': ('--warp-closures', WARP_CLOSURES), 'warp_aperture': ('--warp-aperture', WARP_APERTURES)} + 'warp_closures': ('--warp-closures', WARP_CLOSURES), 'warp_aperture': ('--warp-aperture', WARP_APERTURES), + 'warp_realise': ('--warp-realise', WARP_REALISES)} +LEGACY = {'off': 'classic', 'plan': 'classic'} COHERENCE_CHARGES = ('remade', 'all') SWITCH_TIPS = dict( warp_shape='Mouth shape on the snap warp: asym closes the lips 20 ms early and opens them on time; off is the 0.1.9 shape.', @@ -42,16 +46,18 @@ 'that render; take judges it against the take alone (0.1.9).', coherence_charge='What the continuity repair\'s budget counts: remade only the frames it re-makes; all every flagged run (0.1.9), ' 'so less gets repaired.', - warp_events='Reads the sung syllables off the lead vocal and writes them on the mouth plan (needed by the three below). off = the ' - '0.1.11 plan. No pixel moves by itself.', + warp_events='Reads the sung syllables off the lead vocal and writes them on the mouth plan (needed by the four below). classic = the ' + '0.2.x plan.', warp_hold='One mouth opening per sung syllable, held to the next one, with a dip before each syllable so every onset is a visible rise: ' - 'nucleus-follow also lets the opening follow the voice\'s loudness inside the syllable; nucleus holds it flat; off = the ' - '0.1.11 envelope mouth. Needs warp_events vocal: without it the engine warns and it does nothing.', + 'nucleus-follow also lets the opening follow the voice\'s loudness inside the syllable; nucleus holds it flat; classic = the ' + '0.2.x envelope mouth. Needs warp_events vocal: without it the engine warns and it does nothing.', warp_closures='Where the lips shut: aligned-press only on the lyric\'s p/b/m, timed off the vocal, pressed to contact; aligned the same ' - 'without the press (a slit on an open take); plan = the 0.1.11 closures. Needs warp_events vocal: ' + 'without the press (a slit on an open take); classic = the 0.2.x closures. Needs warp_events vocal: ' 'without it the engine warns and it does nothing.', warp_aperture='How far the vowels open: p95 scales the plan so the loud wide vowels reach the singer\'s own widest opening; map the same ' - 'over every frame; off = as planned. p95 needs warp_events vocal: without it the engine warns and p95 is the same as map.', + 'over every frame; classic = as planned (0.2.x). p95 needs warp_events vocal: without it p95 is the same as map.', + warp_realise='events lets each sung syllable\'s attack through the smoothing, so short syllables still show; classic = the 0.2.x ' + 'smoothing everywhere. Needs warp_events vocal.', ) SING_EXTRA = ['--warp-track-mode', 'chain'] # the standard sing recipe's track mode @@ -93,8 +99,9 @@ def _shape(tw): def _stages(tw): - """the Tweaks' mouth-stage Combos as paint flags: nothing at a default (the engine's own), the flag at any other value; sing adds - --warp-audio for --warp-events itself. settings come after them.""" + """the Tweaks' mouth-stage Combos as paint flags: nothing at a default (sing's own), the flag at any other value (`classic` too: the + engine reads it as the 0.2.x value, and sing then drops its own); sing adds --warp-audio for --warp-events itself. settings come + after them.""" f = getattr(tw, 'flags', None) or {} out = [] for name, (flag, options) in STAGE_FLAGS.items(): @@ -243,21 +250,34 @@ def define_schema(cls): tooltip=SWITCH_TIPS['travel_guard']), io.Combo.Input('coherence_charge', options=['remade', 'all'], default='remade', optional=True, tooltip=SWITCH_TIPS['coherence_charge']), - io.Combo.Input('warp_events', options=['off', 'vocal'], default='off', optional=True, tooltip=SWITCH_TIPS['warp_events']), - io.Combo.Input('warp_hold', options=['off', 'nucleus-follow', 'nucleus'], default='off', optional=True, + io.Combo.Input('warp_events', options=['vocal', 'classic'], default='vocal', optional=True, tooltip=SWITCH_TIPS['warp_events']), + io.Combo.Input('warp_hold', options=['nucleus-follow', 'nucleus', 'classic'], default='nucleus-follow', optional=True, tooltip=SWITCH_TIPS['warp_hold']), - io.Combo.Input('warp_closures', options=['plan', 'aligned-press', 'aligned'], default='plan', optional=True, + io.Combo.Input('warp_closures', options=['aligned-press', 'aligned', 'classic'], default='aligned-press', optional=True, tooltip=SWITCH_TIPS['warp_closures']), - io.Combo.Input('warp_aperture', options=['off', 'p95', 'map'], default='off', optional=True, + io.Combo.Input('warp_aperture', options=['p95', 'map', 'classic'], default='p95', optional=True, tooltip=SWITCH_TIPS['warp_aperture']), + io.Combo.Input('warp_realise', options=['events', 'classic'], default='events', optional=True, + tooltip=SWITCH_TIPS['warp_realise']), ], outputs=[T.LSDubTweaks.Output(display_name='tweaks', tooltip='The mouth controls.')], ) + @classmethod + def validate_inputs(cls, warp_events='vocal', warp_hold='nucleus-follow', warp_closures='aligned-press', warp_aperture='p95', + warp_realise='events'): + """the mouth-stage Combos take their options and the values a 0.2.x workflow saved (off / plan, read as classic).""" + for name, v in (('warp_events', warp_events), ('warp_hold', warp_hold), ('warp_closures', warp_closures), + ('warp_aperture', warp_aperture), ('warp_realise', warp_realise)): + options = STAGE_FLAGS[name][1] + if v not in options and LEGACY.get(v) not in options: + return f'{name} must be one of {", ".join(options)}, not {v!r}' + return True + @classmethod def execute(cls, lead, word_close, face_target, snap, auto, continuity, settings, jaw_csv='', sync_advance_ms=0.0, sync_phrases=False, - warp_shape='asym', travel_guard='aligner', coherence_charge='remade', warp_events='off', warp_hold='off', - warp_closures='plan', warp_aperture='off'): + warp_shape='asym', travel_guard='aligner', coherence_charge='remade', warp_events='vocal', warp_hold='nucleus-follow', + warp_closures='aligned-press', warp_aperture='p95', warp_realise='events'): try: paint_api.split_settings(settings) except ValueError as e: @@ -273,7 +293,10 @@ def execute(cls, lead, word_close, face_target, snap, auto, continuity, settings for name, value, options in (('warp_shape', warp_shape, WARP_SHAPES), ('travel_guard', travel_guard, TRAVEL_GUARDS), ('coherence_charge', coherence_charge, COHERENCE_CHARGES), ('warp_events', warp_events, WARP_EVENTS), ('warp_hold', warp_hold, WARP_HOLDS), ('warp_closures', warp_closures, WARP_CLOSURES), - ('warp_aperture', warp_aperture, WARP_APERTURES)): + ('warp_aperture', warp_aperture, WARP_APERTURES), ('warp_realise', warp_realise, WARP_REALISES)): + if name in STAGE_FLAGS and value not in options and LEGACY.get(value) in options: + _log(f'Mouth Tweaks: {name} {value!r} is a 0.2.x value, read as classic (the 0.2.x mouth); pick {options[0]} for the 0.3.0 mouth') + value = 'classic' if value not in options: raise ValueError(f'LSDub Mouth Tweaks: {name} must be one of {", ".join(options)}, not {value!r}') if value != options[0]: # absent at the default, as above; a cached shot still re-dubs when the engine changes (its key flags[name] = value # carries the whole engine's version) diff --git a/nodes_qa.py b/nodes_qa.py index 2f05a45..b714194 100644 --- a/nodes_qa.py +++ b/nodes_qa.py @@ -46,7 +46,9 @@ def define_schema(cls): 'dub left as source reads N/A. Frames in vocalisation spans are left out of the sync score. The events column ' 'reads the mouth against the vocal syllable by syllable (onsets met, p/b/m shut, stray shuts, vowel ' 'opening, how late the mouth rises), with the phone recogniser when Align downloaded it, else the ' - 'vocal\'s onsets alone; reported only, it moves no band.', + 'vocal\'s onsets alone. With the recogniser it decides the sync: a shot can PASS when its mouth opens on ' + 'clearly more sung syllables than chance would give, shuts on the sung p/b/m and shuts nowhere else more often ' + 'than the take; without it the sync is banded as in 0.2.x. The stricter target is shown as its own reading.', inputs=[ T.LSDubProject.Input('project', tooltip='The LSDub project.'), T.LSDubSource.Input('source', tooltip='The video before the dub.'), diff --git a/scripts/make_workflows.py b/scripts/make_workflows.py index 6d0b589..94a0bfa 100644 --- a/scripts/make_workflows.py +++ b/scripts/make_workflows.py @@ -303,7 +303,7 @@ def make_character_sing(): for s_, o, i in (('project', 'project', 'project'), ('load_char', 0, 'character'), ('song', 'song', 'song')): wf.link(s_, o, 'video', i) wf.add('shots', 'LSDubShotSplit', (800, 0)).link('project', 'project', 'shots', 'project').link('video', 'source', 'shots', 'source') wf.add('lyrics', 'LSDubLyricsSlot', (1200, 800), text=LYRICS_ALR).link('project', 'project', 'lyrics', 'project') - wf.add('align', 'LSDubAlign', (1600, 800), transcript_json=DEMO + 'alr_stt.json', retime_to_vocal=True, lead_sheet=True, lead_overrides='', aligner='sphinx') + wf.add('align', 'LSDubAlign', (1600, 800), transcript_json=DEMO + 'alr_stt.json', retime_to_vocal=True, lead_sheet=True, lead_overrides='', aligner='classic') for s_, o, i in (('project', 'project', 'project'), ('song', 'song', 'song'), ('lyrics', 'lyrics', 'lyrics')): wf.link(s_, o, 'align', i) wf.add('triage', 'LSDubTriage', (1200, 0)) for s_, o, i in (('project', 'project', 'project'), ('video', 'source', 'source'), ('shots', 'shots', 'shots'), ('song', 'song', 'song')): diff --git a/scripts/release.py b/scripts/release.py index cb86409..e46c164 100644 --- a/scripts/release.py +++ b/scripts/release.py @@ -16,7 +16,9 @@ 7. pushes release/X.Y.Z and waits for CI and Install path to pass on that commit; 8. (dry run stops before this: it works in a throwaway worktree and pushes nothing) 9. fast-forwards main to it, tags vX.Y.Z, pushes main and the tag, pushes the engine record; - 10. watches the publish run, confirms the Comfy Registry lists X.Y.Z, and makes sure the site deploy ran. + 10. watches the publish run, confirms the Comfy Registry lists X.Y.Z, then deploys the site from this box: a clean checkout of + the microsite's main (equal to origin/main) through wrangler's own login (no API token, no GitHub secret), and checks + https://lsdub.sndbnch.app/changelog answers 200 with the new version on it. """ import argparse import hashlib @@ -228,7 +230,6 @@ def main(): git(PACK, 'tag', '-a', f'v{v}', '-m', f'LSDub {v}') git(PACK, 'push', '-q', '--atomic', 'origin', 'main', f'v{v}') # main and the tag land together or not at all PUSHED.append(f'pack main -> {rel_sha[:9]} and tag v{v} (publish starts from the tag)') - tag_time = time.time() git(eng, 'push', '-q', 'origin', 'main') PUSHED.append('engine main: the vendor record commit') @@ -247,17 +248,8 @@ def main(): else: raise Stop(f'publish passed but {REGISTRY} does not list {v} after 15 minutes; check the publisher page.') - step('site deploy (the publish workflow dispatches it; started here if it did not)') - time.sleep(30) - runs = gh_json('run', 'list', '-R', SITE_REPO, '--workflow', 'deploy.yml', '--json', 'databaseId,createdAt,event,url', '-L', '10') - fresh = [r for r in runs if time.mktime(time.strptime(r['createdAt'], '%Y-%m-%dT%H:%M:%SZ')) - time.timezone >= tag_time - 60] - if not fresh: - sh('gh', 'workflow', 'run', 'deploy.yml', '-R', SITE_REPO, '--ref', 'main') - time.sleep(15) - fresh = gh_json('run', 'list', '-R', SITE_REPO, '--workflow', 'deploy.yml', '--json', 'databaseId,createdAt,event,url', '-L', '1') - if fresh: - sh('gh', 'run', 'watch', str(fresh[0]['databaseId']), '-R', SITE_REPO, '--exit-status', check=False) - print(f' site deploy run: {fresh[0]["url"]} (a skipped deploy means CLOUDFLARE_API_TOKEN is not set there)') + step('site deploy from this box (wrangler login; clean microsite main), then /changelog shows the version') + deploy_site(site, v) print(f'\nreleased {v}.') finally: if made_worktree: @@ -265,5 +257,35 @@ def main(): if read(records) != records_before: write(records, records_before) # the dry run's vendor record, undone +CF_ACCOUNT = 'fa095a45453409a42f1c739a174e609f' +CHANGELOG_URL = 'https://lsdub.sndbnch.app/changelog' + + +def deploy_site(site, v): + """the microsite deploys from a clean checkout of its main, equal to origin/main, with wrangler's OAuth login on this box (an + API token in the environment is dropped so the login is what deploys); then /changelog must answer 200 and carry v.""" + git(site, 'fetch', '-q', 'origin', 'main', quiet=True) + if git(site, 'rev-parse', '--abbrev-ref', 'HEAD', quiet=True) != 'main': + raise Stop(f'the microsite checkout ({site}) is not on main. Switch it to main and deploy by hand: CONTRIBUTING.md, Releases.') + if git(site, 'status', '--porcelain', quiet=True): + raise Stop(f'the microsite checkout ({site}) has local changes. Deploy needs a clean main.') + if git(site, 'rev-parse', 'HEAD', quiet=True) != git(site, 'rev-parse', 'origin/main', quiet=True): + raise Stop(f'the microsite main ({site}) is not equal to origin/main. Pull it, then deploy by hand: CONTRIBUTING.md, Releases.') + env = {k: val for k, val in os.environ.items() if k != 'CLOUDFLARE_API_TOKEN'} + env['CLOUDFLARE_ACCOUNT_ID'] = CF_ACCOUNT + sh('npx', 'wrangler', 'deploy', cwd=site, env=env) + PUSHED.append('the microsite deployed from its main') + for _ in range(10): + try: + with urllib.request.urlopen(CHANGELOG_URL, timeout=30) as r: # noqa: S310 + if r.status == 200 and f'id="v{v}"' in r.read().decode('utf-8', 'replace'): + print(f' {CHANGELOG_URL}: 200, shows {v}') + return + except OSError: + pass + time.sleep(15) + raise Stop(f'the site deployed but {CHANGELOG_URL} does not answer 200 with {v} on it; check the deploy output above.') + + if __name__ == '__main__': main() diff --git a/tests/engine/test_default_flip_030.py b/tests/engine/test_default_flip_030.py new file mode 100644 index 0000000..6638b64 --- /dev/null +++ b/tests/engine/test_default_flip_030.py @@ -0,0 +1,86 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright 2026 Humanjava Enterprises. LSDub: https://github.com/HumanjavaEnterprises/comfyui-lsdub +"""0.3.0: the vocal-driven mouth is sing's default, `classic` gives the 0.2.x mouth back on each switch, and Verify's sync band reads +the event judge (qa.event_band) with the envelope reading kept only as the floor.""" + +import pytest + +from lsdub_engine.audits import qa, scorecard as SC +from lsdub_engine.core import mouth_paint as MP, sing as S + +NEW = ["--warp-events", "vocal", "--warp-hold", "nucleus-follow", "--warp-closures", "aligned-press", "--warp-aperture", "p95", + "--warp-realise", "events"] # fmt: skip +CLASSIC = [x for f in S.STAGES for x in (f, "classic")] + + +def test_snap_carries_the_chain(): + p = S.SNAP["paint"] + for f, v in zip(NEW[::2], NEW[1::2]): + assert p[p.index(f) + 1] == v + + +def test_a_callers_value_replaces_snaps_and_classic_reads_as_02x(): + snap = S.snap_paint(CLASSIC, 2) + assert not any(f in snap for f in S.STAGES) + for f in S.STAGES: + assert S.stage_value(CLASSIC, snap, f) == S.CLASSIC[f] + assert S.warp_audio(CLASSIC, snap, "v.wav") == () + one = S.snap_paint(["--warp-hold", "nucleus"], 2) + assert "--warp-hold" not in one and S.stage_value(["--warp-hold", "nucleus"], one, "--warp-hold") == "nucleus" + assert S.warp_audio([], S.snap_paint([], 2), "v.wav") == ("--warp-audio", "v.wav") + + +def test_coart_and_own_targets_get_no_stage_flags(): + for t in ("coart", "own"): + snap = S.snap_paint(["--warp-target", t], 2) + assert not any(f in snap for f in S.STAGES) and "--warp-shape" in snap + + +def test_paint_reads_classic_as_the_old_value(): + a = MP._parser().parse_args(["--video", "v", "--track", "t", "--out", "o", *CLASSIC]) + assert (a.warp_events, a.warp_hold, a.warp_closures, a.warp_aperture, a.warp_realise) == ("off", "off", "plan", "off", "off") + + +def _row(**ev): + base = dict( + shot=0, start=0.0, end=2.0, verdict="DUB", voiced=True, owner_px=500, corr=0.6, corr_pm1=0.6, open_in_silence=0.0, + mouth_visible=1.0, mouth_range=0.5, motion=1.0, pops=0, sharpness=100.0, truth=0.25, truth_lag_ms=0.0, truth_pass=False, + ) # fmt: skip + return dict(base, **ev) + + +GOOD = dict(ev_read="phones", ev_onset_hit=0.8, ev_onset_n=10, ev_chance=0.3, ev_closure_hit=1.0, ev_closure_n=2, ev_spurious=0, + ev_spurious_take=1) # fmt: skip + + +def test_the_odds_are_the_binomial_tail(): + assert qa.onset_odds(1.0, 5, 0.32) == pytest.approx(0.32**5, abs=1e-5) + assert qa.onset_odds(0.0, 5, 0.3) == pytest.approx(1.0) and qa.onset_odds(None, 5, 0.3) is None + + +@pytest.mark.parametrize( + "change, ok", + [ + ({}, True), + (dict(ev_onset_n=3, ev_onset_hit=1.0), False), # too few onsets to read + (dict(ev_onset_hit=0.4), False), # 4 of 10 at chance 0.3: odds 0.35 + (dict(ev_closure_hit=0.0, ev_closure_n=1), False), + (dict(ev_closure_hit=0.5, ev_closure_n=2), True), + (dict(ev_closure_hit=0.33, ev_closure_n=3), False), + (dict(ev_spurious=2, ev_spurious_take=1), False), + (dict(ev_read="flux only: no weights"), None), + ], +) +def test_event_band_lines(change, ok): + assert qa.event_band(_row(**{**GOOD, **change}))[0] is ok + + +def test_the_event_band_decides_and_the_envelope_is_the_floor(): + a = qa.score_row(_row(**GOOD), 100.0, SC) # old truth 0.25 would cap it under PASS in 0.2.x + assert a["band"] == "PASS" and a["ev_band"] is True + b = qa.score_row(_row(**{**GOOD, "ev_spurious": 3}), 100.0, SC) + assert b["band"] == "REVIEW" and "shuts where the singer does not" in b["reason"] + c = qa.score_row(_row(**{**GOOD, "truth": 0.05}), 100.0, SC) + assert c["band"] == "FAIL" and "does not follow the vocal" in c["reason"] + d = qa.score_row(_row(), 100.0, SC) # no phones reading: the 0.2.x envelope band + assert d["band"] == "REVIEW" and "ev_band" not in d From 38ff4663b4e79e1f3668e54d73b428b3183e38b7 Mon Sep 17 00:00:00 2001 From: vveerrgg Date: Mon, 9 Nov 2026 22:32:10 -0700 Subject: [PATCH 07/12] 0.3.0 round 2: the auto pick reads Verify's event band (engine re-vendored); the phone network's folder reaches the default mouth through LSDUB_PHONE_MODELS (the argv stays the argv); warp_realise joins the pending Tweaks widgets, so saved workflows carry no misplaced value; switch-test pins read the 0.3.0 SNAP with classic as 0.2.x --- .../1-LSDub-Prompt-Everything.json | 5 +- .../2-LSDub-BYO-Clip-and-Song.json | 5 +- .../4-LSDub-Make-Your-Character-Sing.json | 5 +- lsdub_engine/VERSION | 4 +- lsdub_engine/core/sing.py | 98 +++++++++++++++++-- nodes_mouth.py | 6 +- scripts/make_workflows.py | 2 +- tests/engine/test_default_flip_030.py | 31 ++++++ tests/test_engine_switches.py | 9 +- tests/test_example_workflows.py | 2 +- 10 files changed, 141 insertions(+), 26 deletions(-) diff --git a/example_workflows/1-LSDub-Prompt-Everything.json b/example_workflows/1-LSDub-Prompt-Everything.json index 186b5b9..873cc86 100644 --- a/example_workflows/1-LSDub-Prompt-Everything.json +++ b/example_workflows/1-LSDub-Prompt-Everything.json @@ -1076,7 +1076,7 @@ ], "size": [ 340, - 424 + 398 ], "flags": {}, "order": 21, @@ -1108,8 +1108,7 @@ false, "asym", "aligner", - "remade", - "events" + "remade" ], "color": "#532", "title": "LSDub Mouth Tweaks" diff --git a/example_workflows/2-LSDub-BYO-Clip-and-Song.json b/example_workflows/2-LSDub-BYO-Clip-and-Song.json index 0898fe1..dc9d78b 100644 --- a/example_workflows/2-LSDub-BYO-Clip-and-Song.json +++ b/example_workflows/2-LSDub-BYO-Clip-and-Song.json @@ -1084,7 +1084,7 @@ ], "size": [ 340, - 424 + 398 ], "flags": {}, "order": 22, @@ -1116,8 +1116,7 @@ false, "asym", "aligner", - "remade", - "events" + "remade" ], "color": "#532", "title": "LSDub Mouth Tweaks" diff --git a/example_workflows/4-LSDub-Make-Your-Character-Sing.json b/example_workflows/4-LSDub-Make-Your-Character-Sing.json index 10d853e..76e3d73 100644 --- a/example_workflows/4-LSDub-Make-Your-Character-Sing.json +++ b/example_workflows/4-LSDub-Make-Your-Character-Sing.json @@ -1055,7 +1055,7 @@ ], "size": [ 340, - 424 + 398 ], "flags": {}, "order": 20, @@ -1087,8 +1087,7 @@ false, "asym", "aligner", - "remade", - "events" + "remade" ], "color": "#532", "title": "LSDub Mouth Tweaks" diff --git a/lsdub_engine/VERSION b/lsdub_engine/VERSION index e141800..faa98a4 100644 --- a/lsdub_engine/VERSION +++ b/lsdub_engine/VERSION @@ -1,4 +1,4 @@ pack_version: 0.2.2 uncommitted: 0 -tree_sha256: 83e26cd442610a9559b0ecc0cea44e04890516a738f067d900ed75a278ecc8cd -tests_sha256: 96f87779cd8eb927d41f259eec9d5c652eae3192c352f56bd78e2f4f425cbc24 +tree_sha256: 14d993d1830df5db271949d022a1bb8c500e71e9aa94142528749c3e180fe1db +tests_sha256: fe4066164f9d36eb4f60702cafbe48ab076d8687b52b48e7ec9c9cb7d1bb93fa diff --git a/lsdub_engine/core/sing.py b/lsdub_engine/core/sing.py index 204ac83..f8abee1 100644 --- a/lsdub_engine/core/sing.py +++ b/lsdub_engine/core/sing.py @@ -42,6 +42,17 @@ "corr", "corr_all", "best", "lag_ms", "corr_pm1", "open_in_silence", "rise_ms", "closure_hit", "pass", "vocal_frac", "truth", "truth_lag_ms", "truth_best", "truth_level", "truth_events", "truth_pass", "truth_kept", "truth_kept_lag_ms", ) # fmt: skip +AUTO_EV_KEYS = ( + "ev_read", + "ev_band", + "ev_band_why", + "ev_onset_hit", + "ev_onset_n", + "ev_closure_hit", + "ev_closure_n", + "ev_spurious", + "ev_odds", +) AUTO_KEYS = ("truth", "truth_lag_ms", "corr", "best", "open_in_silence", "rise_ms", "closure_hit", "pass") SNAP = dict( lead=0.06, @@ -410,7 +421,23 @@ def run( if auto: info.update( auto_select( - take, align, vocal, t0, out, work, paint_args, dyn, R, target, fps, n, a2f, info, t_plan=t_plan, native_occ=native_occ + take, + align, + vocal, + t0, + out, + work, + paint_args, + dyn, + R, + target, + fps, + n, + a2f, + info, + t_plan=t_plan, + native_occ=native_occ, + events_on=stage_value(paint_args, snap_flags, "--warp-events") == "vocal", ) ) tm["native"] = round(time.perf_counter() - t_, 3) @@ -436,9 +463,58 @@ def run( return info +def event_read(video, take, vocal, align, t0, n, fps, paint_args, work=None): + """Verify's sync band (audits/qa.event_band) on one delivered candidate: the event judge's reading of its mouth against the vocal's + events in the shot's window [t0, t0 + n / fps), the take's own mouth for the stray-shut anchor. -> {ev_*: ..., ev_band, ev_read}; + ev_read is 'phones' only with the recogniser's weights on this box (never downloaded here); otherwise the row says why and has + no ev_band, and the pick falls back to the envelope gate.""" + from ..audits import event_judge as EJ, judge_phones as JP, judge_vocal as JV, qa + + models = flag_value(paint_args, "--warp-phone-models") + if JP.model_source(models) == JP.MODEL: + return {"ev_read": "flux only: no phone recogniser weights here"} + try: + phones = JP.Phones(models) + except Exception as e: # a broken folder or no torch + return {"ev_read": f"flux only: the phone recogniser did not load ({type(e).__name__})"} + x = JV.read_mono(vocal) if vocal and os.path.exists(vocal) else np.zeros(0) + a, b = int(round(max(0.0, t0) * JP.SR)), int(round((t0 + n / fps) * JP.SR)) + x = x[a:b] + if len(x) < JP.SR // 10: + return {"ev_read": "unread: no vocal in the shot"} + words = None + if align and os.path.exists(align): + with open(align, encoding="utf-8") as f: + W = json.load(f)["words"] + words = [w["w"] for w in W if t0 <= float(w.get("start", -1)) < t0 + n / fps and w.get("align") != "voiced-fill" and w["w"] != "da"] + ev = EJ.events(x, phones, words or None) + c = EJ.cut(ev, 0.0, n / fps) + op, _, _ = EJ.opening_of(video, work, n, fps) + take_op, _, _ = EJ.opening_of(take, None, n, fps) + r = EJ.score(op, fps, c["onsets"], c["closures"], c["sung"], c["wide"], take_op) + if r.get("onset_n") is None: + return {"ev_read": "unread: no mouth reading"} + row = {f"ev_{k}": r.get(k) for k in qa.EV_KEYS} + row.update(ev_read="phones", ev_pass=r.get("pass")) + row["ev_chance"] = round(min(1.0, (r.get("rise_n") or 0) / (n / fps) * 2 * EJ.TOL), 3) + row["ev_odds"] = qa.onset_odds(row["ev_onset_hit"], row["ev_onset_n"], row["ev_chance"]) + row["ev_band"], row["ev_band_why"] = qa.event_band(row) + return row + + def pick_paint(s, n): - """the rate that wins, by their gates: one that passes beats one that fails, else the better sync, within 0.02 the quieter silences. Unmeasured silence is the worst. - The sync is the audio sync (truth) when both gates read it, else the plan corr.""" + """the rate that wins, by their gates. 0.3.0: when both carry Verify's event band (a phones reading, s/n["ev_band"]), the one inside it + wins if the other is not, unless it is under the envelope floor (truth < TRUTH_FAIL); otherwise (both or neither inside, or no + phones reading) the 0.2.x rule: one that passes beats one that fails, else the better sync, within 0.02 the quieter silences. + Unmeasured silence is the worst. The sync is the audio sync (truth) when both gates read it, else the plan corr.""" + from ..audits import sync_truth as ST + + floor = lambda g: g.get("truth") is not None and g["truth"] < ST.TRUTH_FAIL # noqa: E731 + es, en = s.get("ev_band"), n.get("ev_band") + if es is not None and en is not None and bool(es) != bool(en): + win, lose = ("sing", "native") if es else ("native", "sing") + if not floor(s if es else n): + return win k = "truth" if s.get("truth") is not None and n.get("truth") is not None else "corr" key = lambda g: (g[k] if g[k] is not None else -9, -(g["open_in_silence"] if g.get("open_in_silence") is not None else 9)) if bool(s.get("pass")) != bool(n.get("pass")): @@ -447,7 +523,9 @@ def pick_paint(s, n): return "native" if (n_c[0] > s_c[0] + 0.02) or (abs(n_c[0] - s_c[0]) <= 0.02 and n_c[1] > s_c[1]) else "sing" -def auto_select(take, align, vocal, t0, out, work, paint_args, dyn, R, target, fps, n, a2f, sing_info, t_plan=None, native_occ=()): +def auto_select( + take, align, vocal, t0, out, work, paint_args, dyn, R, target, fps, n, a2f, sing_info, t_plan=None, native_occ=(), events_on=False +): """Per-shot auto-selection: also run the native warp (lsdub paint on the full take at the frame rate, the pre-sing recipe) and keep pick_paint's choice of the two. Both are kept: _sing.mp4 and _native.mp4; is the winner. native_occ: the native paint's occluder flags @@ -488,14 +566,20 @@ def auto_select(take, align, vocal, t0, out, work, paint_args, dyn, R, target, f target = target if t_plan is None else moved_target(dyn, n, t_plan, fps, nat_json) # its own paint, its own offsets g_n = gate(nat, R, target, vocal, align, t0, fps, work=os.path.join(work, "native_gate"), warp_json=nat_json) - win = pick_paint(sing_info, g_n) + if events_on: # the vocal-driven mouth: Verify's event band decides between the two (pick_paint) + ev_s = event_read(sng, take, vocal, align, t0, n, fps, paint_args, work) + ev_n = event_read(nat, take, vocal, align, t0, n, fps, paint_args, work=None) + else: # classic (0.2.x): the envelope gate alone, as 0.2.x picked + ev_s = ev_n = {"ev_read": "classic: the envelope gate picks"} + win = pick_paint({**sing_info, **ev_s}, {**g_n, **ev_n}) if win == "native": shutil.copyfile(nat, out) return { "auto": { "winner": win, - "sing": {k: sing_info.get(k) for k in AUTO_KEYS}, - "native": {k: g_n.get(k) for k in AUTO_KEYS}, + "sing": {**{k: sing_info.get(k) for k in AUTO_KEYS}, **{k: ev_s.get(k) for k in AUTO_EV_KEYS if k in ev_s}}, + "native": {**{k: g_n.get(k) for k in AUTO_KEYS}, **{k: ev_n.get(k) for k in AUTO_EV_KEYS if k in ev_n}}, + "by": "events" if ev_s.get("ev_band") is not None and ev_n.get("ev_band") is not None else f"envelope ({ev_s.get('ev_read')})", }, **( { diff --git a/nodes_mouth.py b/nodes_mouth.py index 9cf7336..ac82ede 100644 --- a/nodes_mouth.py +++ b/nodes_mouth.py @@ -107,9 +107,9 @@ def _stages(tw): for name, (flag, options) in STAGE_FLAGS.items(): v = f.get(name, options[0]) if v != options[0]: out += [flag, v] - if f.get('warp_events', WARP_EVENTS[0]) != WARP_EVENTS[0]: # the phone network's folder under models/lsdub, as Align's - from . import devices - out += ['--warp-phone-models', devices.models_dir('wav2vec2-espeak')] + if f.get('warp_events', WARP_EVENTS[0]) == 'vocal': # the phone network's folder under models/lsdub, as Align's: named through + from . import devices # LSDUB_PHONE_MODELS (paint, the event stage and the auto pick read it), so + os.environ.setdefault('LSDUB_PHONE_MODELS', devices.models_dir('wav2vec2-espeak')) # the default argv stays the argv return out diff --git a/scripts/make_workflows.py b/scripts/make_workflows.py index 94a0bfa..dcb2a3e 100644 --- a/scripts/make_workflows.py +++ b/scripts/make_workflows.py @@ -39,7 +39,7 @@ # trailing optionals and sockets added after the example workflows were saved: left out of a workflow unless it sets them, so the # JSONs stay what they were and the frontend fills the default. Mirrors the PENDING lists in tests/test_example_workflows.py. PENDING = {'LSDubProject': ('mouth_stack',), 'LSDubScorecard': ('sharpness_mode',), 'LSDubAlign': ('retime_to_vocal', 'lead_sheet', 'lead_overrides', 'aligner'), - 'LSDubTweaks': ('warp_events', 'warp_hold', 'warp_closures', 'warp_aperture')} + 'LSDubTweaks': ('warp_events', 'warp_hold', 'warp_closures', 'warp_aperture', 'warp_realise')} PENDING_SOCKETS = {'LSDubFixIt': ('scorecard', 'ruler')} diff --git a/tests/engine/test_default_flip_030.py b/tests/engine/test_default_flip_030.py index 6638b64..5dbd548 100644 --- a/tests/engine/test_default_flip_030.py +++ b/tests/engine/test_default_flip_030.py @@ -84,3 +84,34 @@ def test_the_event_band_decides_and_the_envelope_is_the_floor(): assert c["band"] == "FAIL" and "does not follow the vocal" in c["reason"] d = qa.score_row(_row(), 100.0, SC) # no phones reading: the 0.2.x envelope band assert d["band"] == "REVIEW" and "ev_band" not in d + + +def test_classic_on_every_switch_is_the_022_paint_exactly(): + """sing's paint flags with every stage on classic parse to the same paint namespace as 0.2.2's SNAP (the flags as 0.2.2 sent them).""" + snap_022 = [ + "--warp-sigma", + "0.8", + "--warp-temporal", + "0.6", + "--warp-close-depth", + "0.05", + "--warp-follow", + "0.15", + "--warp-shape", + "asym", + ] + base = ["--video", "v", "--track", "t", "--out", "o", "--lib", "K", "--warp-track-mode", "chain", "--vocalisations", "skip"] + new = vars(MP._parser().parse_args([*base, *CLASSIC, *S.snap_paint(CLASSIC, 2)])) + old = vars(MP._parser().parse_args([*base, *snap_022])) + assert new == old + assert S.warp_audio(CLASSIC, S.snap_paint(CLASSIC, 2), "v.wav") == () + + +def test_the_auto_pick_reads_the_event_band_with_the_envelope_as_floor(): + g = dict(truth=0.5, truth_lag_ms=0.0, corr=0.5, best=0.5, open_in_silence=0.1, pass_=True) + s = dict(g, **{"pass": False, "truth": 0.25, "ev_band": True}) # the new mouth: inside the event band, under the old gate + n = dict(g, **{"pass": True, "ev_band": False}) + assert S.pick_paint(s, n) == "sing" + assert S.pick_paint(dict(s, truth=0.1), n) == "native" # under the envelope floor: the event band cannot carry it + assert S.pick_paint(dict(s, ev_band=None), dict(n, ev_band=None)) == "native" # no phones reading: the 0.2.x rule + assert S.pick_paint(dict(s, ev_band=False), n) == "native" # neither inside: the 0.2.x rule diff --git a/tests/test_engine_switches.py b/tests/test_engine_switches.py index 419b036..0c5f71d 100644 --- a/tests/test_engine_switches.py +++ b/tests/test_engine_switches.py @@ -14,6 +14,9 @@ BASE = (0.06, 0.06, 512.0, True, True, True, '') OLD = ('off', 'take', 'all') # the 0.1.9 behaviour SNAP_019 = ['--warp-sigma', '0.8', '--warp-temporal', '0.6', '--warp-close-depth', '0.05', '--warp-follow', '0.15'] +STAGES_030 = ['--warp-events', 'vocal', '--warp-hold', 'nucleus-follow', '--warp-closures', 'aligned-press', '--warp-aperture', 'p95', + '--warp-realise', 'events'] # 0.3.0: sing's vocal-driven mouth +CLASSIC = ('classic',) * 5 # the five mouth-stage Combos at 0.2.x def _engine(pack, name): @@ -76,14 +79,14 @@ def test_the_defaults_reach_the_engine(pack, tmp_path, monkeypatch): c = _run_shot(pack, tmp_path, monkeypatch, _tweaks(pack, 'asym', 'aligner', 'remade')) assert c['run']['travel_ref'] == 'aligner' and c['repair']['charge'] == 'remade' assert '--warp-shape' not in c['paint'] # sing's own asym stands - assert SING.snap_paint(c['paint'], 2) == [*SNAP_019, '--warp-shape', 'asym'] + assert SING.snap_paint(c['paint'], 2) == [*SNAP_019, '--warp-shape', 'asym', *STAGES_030] assert _run_shot(pack, tmp_path, monkeypatch, pack.nodes_mouth.DEFAULT_TWEAKS) == c # no Tweaks node: the same def test_the_0_1_9_values_make_the_0_1_9_calls(pack, tmp_path, monkeypatch): SING, MP = _engine(pack, 'sing'), _engine(pack, 'mouth_paint') - new = _run_shot(pack, tmp_path, monkeypatch, _tweaks(pack, 'asym', 'aligner', 'remade')) - old = _run_shot(pack, tmp_path, monkeypatch, _tweaks(pack, *OLD)) + new = _run_shot(pack, tmp_path, monkeypatch, _tweaks(pack, 'asym', 'aligner', 'remade', *CLASSIC)) + old = _run_shot(pack, tmp_path, monkeypatch, _tweaks(pack, *OLD, *CLASSIC)) assert old['run'] == {**new['run'], 'travel_ref': 'take'} and old['repair'] == {**new['repair'], 'charge': 'all'} i = old['paint'].index('--warp-shape') assert old['paint'][:i] + old['paint'][i + 2:] == new['paint'] and old['paint'][i + 1] == 'off' diff --git a/tests/test_example_workflows.py b/tests/test_example_workflows.py index 502404c..3ee0caa 100644 --- a/tests/test_example_workflows.py +++ b/tests/test_example_workflows.py @@ -26,7 +26,7 @@ def schemas(pack): PENDING = {'LSDubProject': ('mouth_stack',), 'LSDubScorecard': ('sharpness_mode',), 'LSDubAlign': ('retime_to_vocal', 'lead_sheet', 'lead_overrides', 'aligner'), - 'LSDubTweaks': ('warp_events', 'warp_hold', 'warp_closures', 'warp_aperture')} # appended trailing optionals + 'LSDubTweaks': ('warp_events', 'warp_hold', 'warp_closures', 'warp_aperture', 'warp_realise')} # appended trailing optionals PENDING_SOCKETS = {'LSDubFixIt': ('scorecard', 'ruler')} # saved before these; regenerated with the rest From b31a7ffa1242ca33b1a528710ff7db9349326128 Mon Sep 17 00:00:00 2001 From: vveerrgg Date: Mon, 9 Nov 2026 22:59:32 -0700 Subject: [PATCH 08/12] engine re-vendored: the auto pick's event reading through audits/qa --- lsdub_engine/VERSION | 2 +- lsdub_engine/audits/qa.py | 39 ++++++++++++++++++++++++++++++++++++++ lsdub_engine/core/sing.py | 40 ++++----------------------------------- 3 files changed, 44 insertions(+), 37 deletions(-) diff --git a/lsdub_engine/VERSION b/lsdub_engine/VERSION index faa98a4..d96e827 100644 --- a/lsdub_engine/VERSION +++ b/lsdub_engine/VERSION @@ -1,4 +1,4 @@ pack_version: 0.2.2 uncommitted: 0 -tree_sha256: 14d993d1830df5db271949d022a1bb8c500e71e9aa94142528749c3e180fe1db +tree_sha256: cb50410b3a0e9a1b77d9d98ef82401f0daef6c92e2415d6d03dabbbbb5708eae tests_sha256: fe4066164f9d36eb4f60702cafbe48ab076d8687b52b48e7ec9c9cb7d1bb93fa diff --git a/lsdub_engine/audits/qa.py b/lsdub_engine/audits/qa.py index 73a0352..da77c1c 100644 --- a/lsdub_engine/audits/qa.py +++ b/lsdub_engine/audits/qa.py @@ -614,6 +614,45 @@ def event_reading(video, s, fps, offset, ev, read): return out +def candidate_events(video, take, vocal, align, t0, n, fps, models=None, work=None): + """Verify's sync band (audits/qa.event_band) on one delivered candidate: the event judge's reading of its mouth against the vocal's + events in the shot's window [t0, t0 + n / fps), the take's own mouth for the stray-shut anchor. -> {ev_*: ..., ev_band, ev_read}; + ev_read is 'phones' only with the recogniser's weights on this box (never downloaded here); otherwise the row says why and has + no ev_band, and the pick falls back to the envelope gate.""" + from . import event_judge as EJ, judge_phones as JP, judge_vocal as JV + + qa = sys.modules[__name__] + if JP.model_source(models) == JP.MODEL: + return {"ev_read": "flux only: no phone recogniser weights here"} + try: + phones = JP.Phones(models) + except Exception as e: # a broken folder or no torch + return {"ev_read": f"flux only: the phone recogniser did not load ({type(e).__name__})"} + x = JV.read_mono(vocal) if vocal and os.path.exists(vocal) else np.zeros(0) + a, b = int(round(max(0.0, t0) * JP.SR)), int(round((t0 + n / fps) * JP.SR)) + x = x[a:b] + if len(x) < JP.SR // 10: + return {"ev_read": "unread: no vocal in the shot"} + words = None + if align and os.path.exists(align): + with open(align, encoding="utf-8") as f: + W = json.load(f)["words"] + words = [w["w"] for w in W if t0 <= float(w.get("start", -1)) < t0 + n / fps and w.get("align") != "voiced-fill" and w["w"] != "da"] + ev = EJ.events(x, phones, words or None) + c = EJ.cut(ev, 0.0, n / fps) + op, _, _ = EJ.opening_of(video, work, n, fps) + take_op, _, _ = EJ.opening_of(take, None, n, fps) + r = EJ.score(op, fps, c["onsets"], c["closures"], c["sung"], c["wide"], take_op) + if r.get("onset_n") is None: + return {"ev_read": "unread: no mouth reading"} + row = {f"ev_{k}": r.get(k) for k in qa.EV_KEYS} + row.update(ev_read="phones", ev_pass=r.get("pass")) + row["ev_chance"] = round(min(1.0, (r.get("rise_n") or 0) / (n / fps) * 2 * EJ.TOL), 3) + row["ev_odds"] = qa.onset_odds(row["ev_onset_hit"], row["ev_onset_n"], row["ev_chance"]) + row["ev_band"], row["ev_band_why"] = qa.event_band(row) + return row + + def onset_odds(hit, n, chance): """the chance of meeting at least hit x n of n sung onsets with each one met at random with probability `chance` (one-sided binomial tail); None when unread.""" diff --git a/lsdub_engine/core/sing.py b/lsdub_engine/core/sing.py index f8abee1..39199f1 100644 --- a/lsdub_engine/core/sing.py +++ b/lsdub_engine/core/sing.py @@ -464,42 +464,10 @@ def run( def event_read(video, take, vocal, align, t0, n, fps, paint_args, work=None): - """Verify's sync band (audits/qa.event_band) on one delivered candidate: the event judge's reading of its mouth against the vocal's - events in the shot's window [t0, t0 + n / fps), the take's own mouth for the stray-shut anchor. -> {ev_*: ..., ev_band, ev_read}; - ev_read is 'phones' only with the recogniser's weights on this box (never downloaded here); otherwise the row says why and has - no ev_band, and the pick falls back to the envelope gate.""" - from ..audits import event_judge as EJ, judge_phones as JP, judge_vocal as JV, qa - - models = flag_value(paint_args, "--warp-phone-models") - if JP.model_source(models) == JP.MODEL: - return {"ev_read": "flux only: no phone recogniser weights here"} - try: - phones = JP.Phones(models) - except Exception as e: # a broken folder or no torch - return {"ev_read": f"flux only: the phone recogniser did not load ({type(e).__name__})"} - x = JV.read_mono(vocal) if vocal and os.path.exists(vocal) else np.zeros(0) - a, b = int(round(max(0.0, t0) * JP.SR)), int(round((t0 + n / fps) * JP.SR)) - x = x[a:b] - if len(x) < JP.SR // 10: - return {"ev_read": "unread: no vocal in the shot"} - words = None - if align and os.path.exists(align): - with open(align, encoding="utf-8") as f: - W = json.load(f)["words"] - words = [w["w"] for w in W if t0 <= float(w.get("start", -1)) < t0 + n / fps and w.get("align") != "voiced-fill" and w["w"] != "da"] - ev = EJ.events(x, phones, words or None) - c = EJ.cut(ev, 0.0, n / fps) - op, _, _ = EJ.opening_of(video, work, n, fps) - take_op, _, _ = EJ.opening_of(take, None, n, fps) - r = EJ.score(op, fps, c["onsets"], c["closures"], c["sung"], c["wide"], take_op) - if r.get("onset_n") is None: - return {"ev_read": "unread: no mouth reading"} - row = {f"ev_{k}": r.get(k) for k in qa.EV_KEYS} - row.update(ev_read="phones", ev_pass=r.get("pass")) - row["ev_chance"] = round(min(1.0, (r.get("rise_n") or 0) / (n / fps) * 2 * EJ.TOL), 3) - row["ev_odds"] = qa.onset_odds(row["ev_onset_hit"], row["ev_onset_n"], row["ev_chance"]) - row["ev_band"], row["ev_band_why"] = qa.event_band(row) - return row + """Verify's sync band on one delivered candidate (audits/qa.candidate_events: the judge read through its readings).""" + from ..audits import qa + + return qa.candidate_events(video, take, vocal, align, t0, n, fps, flag_value(paint_args, "--warp-phone-models"), work) def pick_paint(s, n): From 2490cb015d316e44f02b8590961877d6c3150f98 Mon Sep 17 00:00:00 2001 From: vveerrgg Date: Tue, 10 Nov 2026 00:23:22 -0700 Subject: [PATCH 09/12] engine re-vendored at ca2fa63: test_guard_budget's SNAP pin reads the 0.3.0 stages, 0.1.9 via classic Evidence: checker source=/home/admin_hj/lsdub-tryit-assets/out_8211/lsdub/tryit-v0111-jules-club-pop/shots/shot_001/f621d91cc78301ae/work/align_vocal.json read align_vocal.json (sphinx, 26 words); vendored engine ca2fa63 (tree cb50410b unchanged, tests hash moves: test_guard_budget reads the 0.3.0 SNAP with classic for 0.1.9) --- lsdub_engine/VERSION | 2 +- tests/engine/test_guard_budget.py | 12 +++++++++--- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/lsdub_engine/VERSION b/lsdub_engine/VERSION index d96e827..442dca4 100644 --- a/lsdub_engine/VERSION +++ b/lsdub_engine/VERSION @@ -1,4 +1,4 @@ pack_version: 0.2.2 uncommitted: 0 tree_sha256: cb50410b3a0e9a1b77d9d98ef82401f0daef6c92e2415d6d03dabbbbb5708eae -tests_sha256: fe4066164f9d36eb4f60702cafbe48ab076d8687b52b48e7ec9c9cb7d1bb93fa +tests_sha256: 416d471e1522792ccf54b61f5c49d5ab239cf3234eb16087ee8c82871847b3eb diff --git a/tests/engine/test_guard_budget.py b/tests/engine/test_guard_budget.py index ceaab4a..cd7ce2a 100644 --- a/tests/engine/test_guard_budget.py +++ b/tests/engine/test_guard_budget.py @@ -197,9 +197,15 @@ def test_the_019_values_are_one_flag_away(): """0.1.9: --travel-guard take, --coherence-charge all, and `-- --warp-shape off` after sing's flags, which leaves SNAP as it was.""" assert V019["travel_ref"] in S.TRAVEL_REFS and V019["charge"] in CONT.CHARGES snap_019 = ["--warp-sigma", "0.8", "--warp-temporal", "0.6", "--warp-close-depth", "0.05", "--warp-follow", "0.15"] - assert S.snap_paint(["--warp-shape", V019["warp_shape"]], 2) == snap_019 - assert S.snap_paint(["--warp-shape=closures"], 2) == snap_019 # the caller's own shape, in either spelling, wins - assert S.SNAP["paint"] == [*snap_019, "--warp-shape", "asym"] # SNAP itself is left alone + classic = [x for f in S.STAGES for x in (f, "classic")] # 0.3.0's stages, back to 0.2.x + assert S.snap_paint(["--warp-shape", V019["warp_shape"], *classic], 2) == snap_019 + assert S.snap_paint(["--warp-shape=closures", *classic], 2) == snap_019 # the caller's own shape, in either spelling, wins + assert S.SNAP["paint"] == [ + *snap_019, + "--warp-shape", + "asym", + *(x for f in S.STAGES for x in (f, S.SNAP["paint"][S.SNAP["paint"].index(f) + 1])), + ] def test_a_callers_shape_reaches_paint(monkeypatch): From baa3bef33ef801311c82adbc86923b59c1dca14d Mon Sep 17 00:00:00 2001 From: vveerrgg Date: Tue, 10 Nov 2026 08:29:10 -0700 Subject: [PATCH 10/12] engine re-vendored at 06a26d0: the new mouth runs on the demo's coart face (sing.routed), auto.by names the deciding rule; changelog bullet says the demo face too --- CHANGELOG.md | 2 +- lsdub_engine/VERSION | 4 +- lsdub_engine/core/sing.py | 51 +++++++++++++++++------ tests/engine/test_coart_route_030.py | 62 ++++++++++++++++++++++++++++ 4 files changed, 104 insertions(+), 15 deletions(-) create mode 100644 tests/engine/test_coart_route_030.py diff --git a/CHANGELOG.md b/CHANGELOG.md index cfd0e63..eff3b21 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## 0.3.0 (2026-11-09) -- The new mouth is now the default; set the switches to classic for 0.2.x output (Mouth Tweaks' five mouth switches and Align's `aligner`). +- The new mouth is now the default, on the shipped demo face too; set the switches to classic for 0.2.x output (Mouth Tweaks' five mouth switches and Align's `aligner`). - Verify's bands now read the mouth against the vocal: PASS needs clearly more sung syllables met than chance, the p/b/m shut and no extra shuts. - On 190 test shots against 0.1.11: syllables met 0.54 → 0.72, p/b/m shut 0.27 → 0.55, vowel opening 0.89 → 0.97, stray shuts 2 → 0. - Verify PASS on those shots: 0.1.11 8, the new mouth 59, the 1140 controls 0. The stricter target is shown beside it. diff --git a/lsdub_engine/VERSION b/lsdub_engine/VERSION index 442dca4..f7a4aa3 100644 --- a/lsdub_engine/VERSION +++ b/lsdub_engine/VERSION @@ -1,4 +1,4 @@ pack_version: 0.2.2 uncommitted: 0 -tree_sha256: cb50410b3a0e9a1b77d9d98ef82401f0daef6c92e2415d6d03dabbbbb5708eae -tests_sha256: 416d471e1522792ccf54b61f5c49d5ab239cf3234eb16087ee8c82871847b3eb +tree_sha256: 0f67dfad2f1c65a9468a6bfc9640aab8d278df5665748830cab8f6a40605d347 +tests_sha256: b8b217a526538bfc6dbf22ad65b8c06d8b687cc4b9a36db25b60eff70e6c30fb diff --git a/lsdub_engine/core/sing.py b/lsdub_engine/core/sing.py index 39199f1..6b4056b 100644 --- a/lsdub_engine/core/sing.py +++ b/lsdub_engine/core/sing.py @@ -96,6 +96,18 @@ def snap_paint(paint_args, m): return p +def routed(paint_args, snap=True): + """the paint flags sing paints with. The vocal-driven mouth (STAGES) rides the plain targets only, so on a coart face (a preset's + layer stack, e.g. landing's layers_v16_1) the stages would drop and the mouth stay 0.2.x. With SNAP on and --warp-events not + `classic`/`off` in the caller's flags, a coart target paints on the plain target instead (`--warp-target v9` appended: the last one + wins); the layer stack is unchanged and the coart-only flags fall inert. Classic (or snap off, or any other target): unchanged, so + the 0.2.x paint stays byte-identical. The native candidate of the auto pick keeps the caller's flags.""" + a = list(paint_args) + if not snap or flag_value(a, "--warp-events") in ("classic", "off") or paint_api.target(a) != "coart": + return a + return [*a, "--warp-target", "v9"] + + def stage_value(paint_args, snap_flags, name): """the value a mouth stage flag has on this paint: the caller's, else SNAP's (snap_flags, as snap_paint gave them); `classic` read as the 0.2.x value. None: neither names it (the paint's own default).""" @@ -293,6 +305,9 @@ def run( again = dict(paint_args=paint_args, char=char, db=db, box=box, face_target=face_target, fps_mult=fps_mult, snap=snap, a2f=a2f, lead=lead, word_close=word_close, auto=auto, preset=preset, lossless=lossless, pump=pump, drive_comp=drive_comp) # fmt: skip align0 = align + native_args, paint_args = list(paint_args), routed(paint_args, snap) + if paint_args != native_args: + print("sing: the vocal-driven mouth rides the plain target: --warp-target v9 on coart (classic keeps coart)", file=sys.stderr) if not a2f: wt = paint_api.target(paint_args) by = ( @@ -427,7 +442,7 @@ def run( t0, out, work, - paint_args, + native_args, dyn, R, target, @@ -471,24 +486,36 @@ def event_read(video, take, vocal, align, t0, n, fps, paint_args, work=None): def pick_paint(s, n): - """the rate that wins, by their gates. 0.3.0: when both carry Verify's event band (a phones reading, s/n["ev_band"]), the one inside it - wins if the other is not, unless it is under the envelope floor (truth < TRUTH_FAIL); otherwise (both or neither inside, or no - phones reading) the 0.2.x rule: one that passes beats one that fails, else the better sync, within 0.02 the quieter silences. - Unmeasured silence is the worst. The sync is the audio sync (truth) when both gates read it, else the plan corr.""" + """the rate that wins, by their gates (pick_rule's winner).""" + return pick_rule(s, n)[0] + + +def pick_rule(s, n): + """(winner, by) of the two gates. 0.3.0: when both carry Verify's event band (a phones reading, s/n["ev_band"]), the one inside it + wins if the other is not, unless it is under the envelope floor (truth < TRUTH_FAIL); by = "events". Otherwise (both or neither + inside, or no phones reading) the 0.2.x rule: one that passes beats one that fails, else the better sync, within 0.02 the quieter + silences; by = "envelope ()". Unmeasured silence is the worst. The sync is the audio sync + (truth) when both gates read it, else the plan corr.""" from ..audits import sync_truth as ST floor = lambda g: g.get("truth") is not None and g["truth"] < ST.TRUTH_FAIL # noqa: E731 es, en = s.get("ev_band"), n.get("ev_band") - if es is not None and en is not None and bool(es) != bool(en): - win, lose = ("sing", "native") if es else ("native", "sing") + if es is None or en is None: + why = s.get("ev_read") or n.get("ev_read") or "no event band on both" + elif bool(es) == bool(en): + why = "both inside the event band" if es else "neither inside the event band" + else: + win = "sing" if es else "native" if not floor(s if es else n): - return win + return win, "events" + why = f"the {win} inside the event band is under the envelope floor" k = "truth" if s.get("truth") is not None and n.get("truth") is not None else "corr" key = lambda g: (g[k] if g[k] is not None else -9, -(g["open_in_silence"] if g.get("open_in_silence") is not None else 9)) + by = f"envelope ({why})" if bool(s.get("pass")) != bool(n.get("pass")): - return "native" if n.get("pass") else "sing" + return ("native" if n.get("pass") else "sing"), by s_c, n_c = key(s), key(n) - return "native" if (n_c[0] > s_c[0] + 0.02) or (abs(n_c[0] - s_c[0]) <= 0.02 and n_c[1] > s_c[1]) else "sing" + return ("native" if (n_c[0] > s_c[0] + 0.02) or (abs(n_c[0] - s_c[0]) <= 0.02 and n_c[1] > s_c[1]) else "sing"), by def auto_select( @@ -539,7 +566,7 @@ def auto_select( ev_n = event_read(nat, take, vocal, align, t0, n, fps, paint_args, work=None) else: # classic (0.2.x): the envelope gate alone, as 0.2.x picked ev_s = ev_n = {"ev_read": "classic: the envelope gate picks"} - win = pick_paint({**sing_info, **ev_s}, {**g_n, **ev_n}) + win, by = pick_rule({**sing_info, **ev_s}, {**g_n, **ev_n}) if win == "native": shutil.copyfile(nat, out) return { @@ -547,7 +574,7 @@ def auto_select( "winner": win, "sing": {**{k: sing_info.get(k) for k in AUTO_KEYS}, **{k: ev_s.get(k) for k in AUTO_EV_KEYS if k in ev_s}}, "native": {**{k: g_n.get(k) for k in AUTO_KEYS}, **{k: ev_n.get(k) for k in AUTO_EV_KEYS if k in ev_n}}, - "by": "events" if ev_s.get("ev_band") is not None and ev_n.get("ev_band") is not None else f"envelope ({ev_s.get('ev_read')})", + "by": by, }, **( { diff --git a/tests/engine/test_coart_route_030.py b/tests/engine/test_coart_route_030.py new file mode 100644 index 0000000..3e767b6 --- /dev/null +++ b/tests/engine/test_coart_route_030.py @@ -0,0 +1,62 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright 2026 Humanjava Enterprises. LSDub: https://github.com/HumanjavaEnterprises/comfyui-lsdub +"""0.3.0: on a coart face (the landing preset's layer stack) the vocal-driven mouth rides the plain target, classic keeps coart, and the +auto pick's `by` names the rule that decided.""" + +from lsdub_engine.core import paint_api, recipes, sing as S + +CLASSIC = [x for f in S.STAGES for x in (f, "classic")] +LAYERS = paint_api.flags("layers_v16_1", lib="K", mode_mods=["--warp-track-mode", "chain"], user_overrides=["--vocalisations", "skip"]) + + +def test_the_landing_stack_is_coart(): + assert paint_api.target(LAYERS) == "coart" + assert recipes.get("layers_v16_1").expand(()).count("coart") >= 1 + + +def test_coart_at_the_defaults_routes_to_the_plain_target_and_the_stages_ride(): + a = S.routed(LAYERS) + assert a[: len(LAYERS)] == LAYERS and paint_api.target(a) == "v9" + snap = S.snap_paint(a, 2) + for f in S.STAGES: + assert f in snap + assert S.stage_value(a, snap, "--warp-events") == "vocal" + assert S.warp_audio(a, snap, "v.wav") == ("--warp-audio", "v.wav") + assert paint_api.coart_dyn(a, "d.csv") == () + + +def test_classic_snap_off_and_other_targets_are_unchanged(): + for args in ([*LAYERS, *CLASSIC], [*LAYERS, "--warp-events", "off"], [*LAYERS, "--warp-events=classic"], ["--warp-target", "own"], []): + assert S.routed(args) == list(args) + assert S.routed(LAYERS, snap=False) == list(LAYERS) + assert not any(f in S.snap_paint([*LAYERS, *CLASSIC], 2) for f in S.STAGES) + + +def test_routing_is_idempotent(): + assert S.routed(S.routed(LAYERS)) == S.routed(LAYERS) + + +def test_one_stage_on_classic_still_routes_while_events_are_vocal(): + a = S.routed([*LAYERS, "--warp-hold", "classic"]) + assert paint_api.target(a) == "v9" and S.stage_value(a, S.snap_paint(a, 2), "--warp-hold") == "off" + + +G = dict(truth=0.5, truth_lag_ms=0.0, corr=0.5, best=0.5, open_in_silence=0.1) + + +def test_by_says_events_only_when_the_event_band_decided(): + s = dict(G, **{"pass": False, "truth": 0.25, "ev_band": True}) + n = dict(G, **{"pass": True, "ev_band": False}) + assert S.pick_rule(s, n) == ("sing", "events") + + +def test_by_names_the_0_2_rule_when_it_decided(): + s = dict(G, **{"pass": False, "truth": 0.25, "ev_band": True}) + n = dict(G, **{"pass": True, "ev_band": False}) + assert S.pick_rule(dict(s, truth=0.1), n) == ("native", "envelope (the sing inside the event band is under the envelope floor)") + assert S.pick_rule(dict(s, ev_band=False), n) == ("native", "envelope (neither inside the event band)") + assert S.pick_rule(dict(s, ev_band=True), dict(n, ev_band=True)) == ("native", "envelope (both inside the event band)") + c = {"ev_read": "classic: the envelope gate picks"} + assert S.pick_rule(dict(s, ev_band=None, **c), dict(n, ev_band=None, **c)) == ("native", "envelope (classic: the envelope gate picks)") + for a, b in ((s, n), (dict(s, ev_band=None), n), (dict(s, truth=0.1), n)): + assert S.pick_rule(a, b)[0] == S.pick_paint(a, b) From 111de797c555fb4a37d9f89a6d7ddeb9bec9efb1 Mon Sep 17 00:00:00 2001 From: vveerrgg Date: Tue, 10 Nov 2026 09:46:52 -0700 Subject: [PATCH 11/12] engine re-vendored at 30eb66a: plan_t0 reads the advance only on a coart paint; layers_shape stays on coart (stages off) --- lsdub_engine/VERSION | 4 ++-- lsdub_engine/core/sing.py | 17 ++++++++++++++--- tests/engine/test_coart_route_030.py | 22 ++++++++++++++++++++++ 3 files changed, 38 insertions(+), 5 deletions(-) diff --git a/lsdub_engine/VERSION b/lsdub_engine/VERSION index f7a4aa3..cb4e3e9 100644 --- a/lsdub_engine/VERSION +++ b/lsdub_engine/VERSION @@ -1,4 +1,4 @@ pack_version: 0.2.2 uncommitted: 0 -tree_sha256: 0f67dfad2f1c65a9468a6bfc9640aab8d278df5665748830cab8f6a40605d347 -tests_sha256: b8b217a526538bfc6dbf22ad65b8c06d8b687cc4b9a36db25b60eff70e6c30fb +tree_sha256: 8041cc402dd6362c29c14cba0ebcaa6aaa81b8a8ee8e497cfd12cfedc76c5360 +tests_sha256: 5832ee77cd28f228bd887230ac01210533f95ab1225792cb255efa58bf6ac526 diff --git a/lsdub_engine/core/sing.py b/lsdub_engine/core/sing.py index 6b4056b..d3733bb 100644 --- a/lsdub_engine/core/sing.py +++ b/lsdub_engine/core/sing.py @@ -96,15 +96,23 @@ def snap_paint(paint_args, m): return p +# the coart planner's own shaping (recipe layers_v16_1_shape, the mouth_stack layers_shape): a face that asks for it keeps coart. On the +# demo face, routed against classic: sung onsets met 8/12 -> 7/12, truth 0.465 -> 0.449 (the stray shuts 2 -> 0), so it stays. +COART_SHAPED = ("--coart-phones", "--coart-function-gate", "--coart-closure-ramp", "--coart-gap-anchor") + + def routed(paint_args, snap=True): """the paint flags sing paints with. The vocal-driven mouth (STAGES) rides the plain targets only, so on a coart face (a preset's layer stack, e.g. landing's layers_v16_1) the stages would drop and the mouth stay 0.2.x. With SNAP on and --warp-events not `classic`/`off` in the caller's flags, a coart target paints on the plain target instead (`--warp-target v9` appended: the last one wins); the layer stack is unchanged and the coart-only flags fall inert. Classic (or snap off, or any other target): unchanged, so - the 0.2.x paint stays byte-identical. The native candidate of the auto pick keeps the caller's flags.""" + the 0.2.x paint stays byte-identical. A shaped coart plan (COART_SHAPED) stays on coart too, with the stages off. The native candidate + of the auto pick keeps the caller's flags.""" a = list(paint_args) if not snap or flag_value(a, "--warp-events") in ("classic", "off") or paint_api.target(a) != "coart": return a + if any(has_flag(a, f) for f in COART_SHAPED): # a shaped coart plan (layers_v16_1_shape) stays on coart: routed it did not hold up + return a return [*a, "--warp-target", "v9"] @@ -229,9 +237,12 @@ def compressed_vocal(vocal, work): def plan_t0(t0, paint_args, rate): - """where --coart-advance-ms puts the plan, on the track's whole frames as the paint moves it.""" + """where --coart-advance-ms puts the plan, on the track's whole frames as the paint moves it. Only the coart planner reads the + advance (mouth_warp): on any other target (a routed coart face, sing.routed) the plan is not moved and neither is the gate's curve.""" from .coart import whole_frames + if paint_api.target(paint_args) != "coart": + return t0 return t0 + whole_frames(paint_api.advance_s(paint_args), rate) @@ -450,7 +461,7 @@ def run( n, a2f, info, - t_plan=t_plan, + t_plan=plan_t0(t0, native_args, fps * m), # the native paint keeps the caller's target, so its own advance native_occ=native_occ, events_on=stage_value(paint_args, snap_flags, "--warp-events") == "vocal", ) diff --git a/tests/engine/test_coart_route_030.py b/tests/engine/test_coart_route_030.py index 3e767b6..f8c000f 100644 --- a/tests/engine/test_coart_route_030.py +++ b/tests/engine/test_coart_route_030.py @@ -60,3 +60,25 @@ def test_by_names_the_0_2_rule_when_it_decided(): assert S.pick_rule(dict(s, ev_band=None, **c), dict(n, ev_band=None, **c)) == ("native", "envelope (classic: the envelope gate picks)") for a, b in ((s, n), (dict(s, ev_band=None), n), (dict(s, truth=0.1), n)): assert S.pick_rule(a, b)[0] == S.pick_paint(a, b) + + +SHAPE = paint_api.flags("layers_v16_1_shape", lib="K", mode_mods=["--warp-track-mode", "chain"], user_overrides=["--vocalisations", "skip"]) + + +def test_the_gate_curve_moves_only_where_the_paint_does(): + """--coart-advance-ms moves the plan only on coart (mouth_warp); plan_t0 moves the gate's curve only there too.""" + assert paint_api.advance_s(SHAPE) > 0 and paint_api.target(SHAPE) == "coart" + assert S.plan_t0(1.0, SHAPE, 50.0) > 1.0 # coart (classic, and the native candidate): moved, as the paint moves it + assert S.plan_t0(1.0, [*SHAPE, "--warp-target", "v9"], 50.0) == 1.0 + assert S.plan_t0(1.0, S.routed(SHAPE), 50.0) == S.plan_t0(1.0, SHAPE, 50.0) # shaped coart is not routed: the advance stands + adv = [*LAYERS, "--coart-advance-ms", "40"] # landing with Tweaks sync_advance_ms 40 + assert S.plan_t0(1.0, adv, 50.0) == 1.0 + 0.04 + assert S.plan_t0(1.0, S.routed(adv), 50.0) == 1.0 + assert S.plan_t0(1.0, ["--coart-advance-ms", "40"], 50.0) == 1.0 # a plain face: the paint never read it + + +def test_a_shaped_coart_plan_stays_on_coart_with_no_stages(): + assert S.routed(SHAPE) == list(SHAPE) and paint_api.target(S.routed(SHAPE)) == "coart" + assert not any(f in S.snap_paint(SHAPE, 2) for f in S.STAGES) + for f in S.COART_SHAPED: + assert any(str(x).startswith(f) for x in SHAPE) From 6c34439c414d883b697f302115563771ded1338b Mon Sep 17 00:00:00 2001 From: vveerrgg Date: Tue, 10 Nov 2026 09:49:00 -0700 Subject: [PATCH 12/12] engine re-vendored at d51aaac: test_coart's whole-frames pin names --warp-target coart Evidence: checker source=/home/admin_hj/lsdub-tryit-assets/out_8211/lsdub/tryit-v0111-jules-club-pop/shots/shot_001/f621d91cc78301ae/work/align_vocal.json the sphinx-retimed word timings of a try-it shot, read before re-vendoring the test_coart pin --- lsdub_engine/VERSION | 2 +- tests/engine/test_coart.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lsdub_engine/VERSION b/lsdub_engine/VERSION index cb4e3e9..12d7e38 100644 --- a/lsdub_engine/VERSION +++ b/lsdub_engine/VERSION @@ -1,4 +1,4 @@ pack_version: 0.2.2 uncommitted: 0 tree_sha256: 8041cc402dd6362c29c14cba0ebcaa6aaa81b8a8ee8e497cfd12cfedc76c5360 -tests_sha256: 5832ee77cd28f228bd887230ac01210533f95ab1225792cb255efa58bf6ac526 +tests_sha256: acc5adad9b64dc605b06b8fc75ed5b9cda9fe516c72ac3b13c30e67a1384d395 diff --git a/tests/engine/test_coart.py b/tests/engine/test_coart.py index 872e3bb..8e4e3c9 100644 --- a/tests/engine/test_coart.py +++ b/tests/engine/test_coart.py @@ -472,7 +472,7 @@ def test_advance_rounds_half_to_even(): def test_sing_reads_the_plan_on_whole_frames(): from lsdub_engine.core import sing as S - assert S.plan_t0(1.0, ["--coart-advance-ms", "100"], 25) == 1.0 + 2 / 25 + assert S.plan_t0(1.0, ["--warp-target", "coart", "--coart-advance-ms", "100"], 25) == 1.0 + 2 / 25 def test_plan_clip_plans_on_whole_frames(monkeypatch, tmp_path):