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..a851a41 --- /dev/null +++ b/.github/workflows/install-path.yml @@ -0,0 +1,80 @@ +# 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) + id: hfcache + uses: actions/cache/restore@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: "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 + - 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..435fd40 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: diff --git a/CHANGELOG.md b/CHANGELOG.md index d9e78fc..eff3b21 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, 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. +- 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 db1f292..0056467 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -29,3 +29,42 @@ 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 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 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: + + 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, 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 + (`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: 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 1e2d24f..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. @@ -202,12 +209,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. +- 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. +- 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/example_workflows/4-LSDub-Make-Your-Character-Sing.json b/example_workflows/4-LSDub-Make-Your-Character-Sing.json index 61e8dca..76e3d73 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" diff --git a/lsdub_engine/VERSION b/lsdub_engine/VERSION index 2e2a75f..12d7e38 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: 8041cc402dd6362c29c14cba0ebcaa6aaa81b8a8ee8e497cfd12cfedc76c5360 +tests_sha256: acc5adad9b64dc605b06b8fc75ed5b9cda9fe516c72ac3b13c30e67a1384d395 diff --git a/lsdub_engine/audits/qa.py b/lsdub_engine/audits/qa.py index 860675c..da77c1c 100644 --- a/lsdub_engine/audits/qa.py +++ b/lsdub_engine/audits/qa.py @@ -526,7 +526,14 @@ 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) +# 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,87 @@ 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 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.""" + 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 +842,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 +873,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 +1025,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 +1051,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 +1064,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 +1112,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 +1121,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..d3733bb 100644 --- a/lsdub_engine/core/sing.py +++ b/lsdub_engine/core/sing.py @@ -42,25 +42,48 @@ "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, 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 +96,34 @@ 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. 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"] + + +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 +153,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)) @@ -187,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) @@ -263,6 +316,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 = ( @@ -322,13 +378,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 +395,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 @@ -393,7 +447,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, + native_args, + dyn, + R, + target, + fps, + n, + a2f, + info, + 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", ) ) tm["native"] = round(time.perf_counter() - t_, 3) @@ -419,18 +489,49 @@ def run( return info +def event_read(video, take, vocal, align, t0, n, fps, paint_args, work=None): + """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): - """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 (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 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, "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(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 @@ -471,14 +572,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, by = pick_rule({**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": by, }, **( { 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..ac82ede 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,16 +99,17 @@ 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(): 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 @@ -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/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..435400a --- /dev/null +++ b/scripts/install_path_check.py @@ -0,0 +1,347 @@ +#!/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, 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 +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') +# 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)'), + (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 _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: + 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: + self._proxy('GET') + return + if 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, 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')), + '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=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])), + '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, identity=USER_FACE))) + 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/make_workflows.py b/scripts/make_workflows.py index 6d0b589..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')} @@ -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 new file mode 100644 index 0000000..e46c164 --- /dev/null +++ b/scripts/release.py @@ -0,0 +1,291 @@ +#!/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, 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 +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): + 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}') + + +PUSHED = [] +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) + 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)}') + + 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) + + 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(' 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.') + 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') + 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}') + 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') + git(PACK, 'checkout', '-q', 'main') + git(PACK, 'merge', '-q', '--ff-only', f'release/{v}') + 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)') + 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) + 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: + 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: + raise Stop(f'publish passed but {REGISTRY} does not list {v} after 15 minutes; check the publisher page.') + + 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: + 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 + + +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_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): diff --git a/tests/engine/test_coart_route_030.py b/tests/engine/test_coart_route_030.py new file mode 100644 index 0000000..f8c000f --- /dev/null +++ b/tests/engine/test_coart_route_030.py @@ -0,0 +1,84 @@ +# 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) + + +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) diff --git a/tests/engine/test_default_flip_030.py b/tests/engine/test_default_flip_030.py new file mode 100644 index 0000000..5dbd548 --- /dev/null +++ b/tests/engine/test_default_flip_030.py @@ -0,0 +1,117 @@ +# 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 + + +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/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): 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_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 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]