Skip to content

fix: repair local zero-key rendering (Remotion asset staging, resolveAsset, Piper status)#238

Open
0xDevNinja wants to merge 8 commits into
calesthio:mainfrom
0xDevNinja:fix/issue-237-local-rendering
Open

fix: repair local zero-key rendering (Remotion asset staging, resolveAsset, Piper status)#238
0xDevNinja wants to merge 8 commits into
calesthio:mainfrom
0xDevNinja:fix/issue-237-local-rendering

Conversation

@0xDevNinja

Copy link
Copy Markdown
Contributor

Summary

Fixes the three bugs from #237 that block fully local, zero-API-key rendering (Piper narration + Remotion component scenes). Verified end to end by rendering a real Explainer composition with a Piper-generated narration track at an absolute path — it now produces a 28s 1080p MP4 with audio, no manual workaround.

Closes #237

Changes

1. tools/video/video_compose.py — stage local assets into public/ (the real fix).
Remotion's renderer only loads http(s) URLs or files served from the composer's public/ dir via staticFilefile:// URIs are rejected at render time (Can only download URLs starting with http:// or https://). _remotion_render previously rewrote cuts[].source to file:// and never touched audio paths at all. Now every local asset (cuts[].source and audio.narration/music.src) is copied into remotion-composer/public/_om_assets/<hash> and the prop rewritten to the staticFile-relative path. public/* is already gitignored.

2. remotion-composer/src/Explainer.tsx — fix resolveAsset (defense in depth).
It stripped file:/// including the leading slash, so file:///abs became the relative abs (mis-served from public/) and a bare /abs became file:////abs (four slashes). Now strips only the file:// scheme: unix /absfile:///abs, windows C:/absfile:///C:/abs, relative → staticFile().

3. tools/audio/piper_tts.py — honest status.
get_status() returned AVAILABLE whenever the module imported, but Piper ships no voice model, so execute() failed with Unable to find voice ... and preflight wrongly reported TTS ready. Now reports DEGRADED when installed but no voice model is discoverable (cwd, PIPER_VOICE_DIR, ~/.local/share/piper, ~/.piper/models), gives an actionable download hint on execute, and fixes install_instructions to the current python -m piper.download_voices command.

Testing

Clean venv (requirements-dev.txt + numpy):

  • New tests/tools/test_piper_tts_status.py → 4 passed (unavailable / degraded / available / actionable execute error).
  • Live: video_compose remotion_render with audio.narration.src at an absolute path → renders 28s 1080p MP4 with audio; asset auto-staged to public/_om_assets/. (Before: 404 / file:// rejected.)
  • Full pytest tests/tools tests/contracts → only the 2 pre-existing unrelated failures (test_runtime_presentation_contract[character-animation], test_phase3_contracts::...test_registry_catalog_views).

Checklist

  • The change is focused on a single logical concern. (one root issue: local-asset rendering)
  • I ran the relevant tests locally where applicable.
  • I updated docs/instructions where behavior changed (piper_tts install_instructions).
  • No unrelated files (build artifacts, local config) are included in the diff.

@calesthio calesthio left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for digging into this and for the clear repro in #237. The direction is right, and the focused Piper status tests are useful. I’m requesting changes because I found two remaining local-rendering blockers in the PR branch.

  1. CinematicRenderer audio is not staged. _remotion_render() now rewrites audio.narration.src and audio.music.src, but cinematic-trailer / documentary-montage route to CinematicRenderer, whose audio props are top-level soundtrack.src and music.src. I verified those absolute paths remain unchanged, so local cinematic/documentary Remotion renders can still hit the same local-audio failure described in #237.

  2. Staged asset filenames only hash the source path, and existing staged files are not refreshed. If a generated narration/image/video is rewritten at the same path, the next render silently uses the old copy in public/_om_assets. I reproduced this with two _remotion_render() calls against the same source path: after changing the source bytes, the staged file still contained the first render’s bytes.

One smaller follow-up: Piper status currently reports AVAILABLE if any .onnx voice exists, even when the default/requested en_US-lessac-medium model is absent. That can still make preflight look ready while the default execute path fails later.

Verification I ran:

  • python -m pytest tests/tools/test_piper_tts_status.py -q -> 4 passed
  • python -m py_compile tools\video\video_compose.py tools\audio\piper_tts.py -> passed
  • Direct _remotion_render() probes for the cinematic prop gap and stale staged-asset behavior

@0xDevNinja

Copy link
Copy Markdown
Contributor Author

Thanks — all three are fair and the direct _remotion_render() probes made them easy to reproduce. Pushed fixes in two commits.

1. CinematicRenderer audio (and scenes) not staged. You're right, the staging pass only knew the Explainer shape. Broadened it to cover the Cinematic shape too: scenes[].src and top-level soundtrack.src / music.src are now staged alongside cuts[].source and audio.narration/music.src. (I added scenes[].src as well — CinematicRenderer resolves those via resolveAsset at CinematicRenderer.tsx:90, so local absolute scene paths would have hit the same wall as the audio.)

2. Stale staged copies. Fixed. The staged filename still keys on the source path, but the copy now refreshes when the source size/mtime_ns differs from the staged file. Since copy2 preserves mtime, an unchanged source still skips the copy (no needless re-copy of large clips), while a regenerated asset at the same path is refreshed. Reproduced your two-render scenario in a test.

3. Piper AVAILABLE with default absent. Fixed. get_status() now reports AVAILABLE only when the default en_US-lessac-medium voice is actually present, DEGRADED otherwise (even if some other .onnx exists). execute() gates on the voice the call will use (inputs['model'] or the default) rather than the coarse status, so an explicitly-provided installed voice still runs, and a missing-model error names the requested model.

New/added tests:

  • tests/tools/test_remotion_staging.py — cinematic scene + soundtrack/music staging, and stale-copy refresh.
  • tests/tools/test_piper_tts_status.py — DEGRADED when only a non-default voice is present; missing-requested-model error names the model.

Verification:

  • pytest tests/tools/test_remotion_staging.py tests/tools/test_piper_tts_status.py -q -> 8 passed
  • Full suite: 342 passed, 8 skipped; the only 2 failures are the pre-existing character-animation runtime-contract and test_registry_catalog_views ones unrelated to this branch.

Ready for another look when you have a moment.

@calesthio

Copy link
Copy Markdown
Owner

Thanks for the follow-up and for addressing the three review points. The implementation changes look aligned with the prior feedback.

One thing to fix before this can be cleared: please pull/rebase onto the latest main and rerun the tests. GitHub's merge-ref CI is currently failing in the newly added tests/tools/test_remotion_staging.py tests. It looks like the branch tests passed on your head, but the merged code path uses the existing --props=<path> Remotion argument form, while the new test stub only parses --props as a separate argument.

Once the branch is updated and the merge-ref CI is green, I can take another look.

@0xDevNinja

Copy link
Copy Markdown
Contributor Author

Thanks — fixed and rebased onto latest main. The failure was in the test stub, not the render code: it parsed only the separate --props <path> form, while the composer invokes Remotion with the --props=<path> equals form, so the merged code path tripped it. The stub now handles both forms.

Rebased the branch onto current main and reran locally:

  • pytest tests/tools/test_remotion_staging.py tests/tools/test_piper_tts_status.py -q -> 8 passed
  • full suite -> 443 passed, 8 skipped

Should be green on merge-ref now — ready for another look.

@0xDevNinja 0xDevNinja force-pushed the fix/issue-237-local-rendering branch from 23029c7 to 6edd315 Compare July 2, 2026 06:33

@calesthio calesthio left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for rebasing and fixing the merge-ref test stub. The Remotion staging coverage now looks good: the focused staging/Piper tests pass locally and CI is green.

I found one remaining blocker in the Piper path. execute() now checks _find_voice(model) against the current directory / PIPER_VOICE_DIR / home search dirs, but _generate() still invokes Piper with the original model string rather than the resolved .onnx path:

"--model", inputs.get("model", self.default_voice)

So if the default voice is installed in PIPER_VOICE_DIR or ~/.piper/models, preflight reports AVAILABLE and execute() passes the gate, but the actual subprocess is still called as piper --model en_US-lessac-medium from the repo cwd instead of piper --model <resolved search-dir path>/en_US-lessac-medium.onnx. That reintroduces the same shape of issue this PR is fixing: status says the local TTS path is ready, but the default execution can fail at render time depending on where the voice model is installed.

I verified this with a subprocess-capture probe: _find_voice('en_US-lessac-medium') returned the model under a patched search dir, but the captured command was still:

['piper', '--model', 'en_US-lessac-medium', ...]

Please plumb the resolved path into _generate() (and cover it with a test that installs the fake voice outside cwd and asserts the --model argument is that resolved .onnx path).

Checks run:

  • python -m pytest tests/tools/test_remotion_staging.py tests/tools/test_piper_tts_status.py -q -> 8 passed
  • python -m py_compile tools/video/video_compose.py tools/audio/piper_tts.py -> passed
  • targeted Piper subprocess-capture probe for search-dir voice resolution -> command still used bare model name

@0xDevNinja

Copy link
Copy Markdown
Contributor Author

Good catch — you're right, get_status()/execute() resolved the voice via _find_voice() but _generate() still passed the bare model name to piper, so a voice in PIPER_VOICE_DIR/~/.piper cleared the gate yet piper (which only searches cwd for a bare name) would fail to load it from a different cwd — the exact false-ready shape this PR is meant to remove.

Fixed: execute() now passes the resolved .onnx path from _find_voice(model) into _generate(), which uses it for --model (and reports it in the result). A voice given as an explicit path resolves to itself, so that case is unchanged.

Added a regression test that installs the fake voice in a search dir outside cwd and asserts the captured piper --model argument is the resolved .onnx path (fails before this change, passes after).

Checks: pytest tests/tools/test_piper_tts_status.py -q -> 7 passed.

@calesthio calesthio left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I re-reviewed the latest commits after the prior feedback. The previously requested Piper/Remotion fixes look addressed in the branch: focused local checks pass, including the test that asserts Piper receives the resolved .onnx path for a voice found outside cwd.

I am not clearing this yet because the PR is currently CONFLICTING against main. Please rebase/update the branch and rerun CI so the review applies to the actual merge result.

Checks I ran on the PR head:

  • python -m pytest tests/tools/test_remotion_staging.py tests/tools/test_piper_tts_status.py -q -> 9 passed
  • python -m pytest tests/contracts/test_phase0_contracts.py -q -> 35 passed
  • python -m py_compile tools/video/video_compose.py tools/audio/piper_tts.py -> passed
  • git diff --check -> passed

@0xDevNinja 0xDevNinja force-pushed the fix/issue-237-local-rendering branch from c6012c5 to 34863c8 Compare July 6, 2026 07:14
@0xDevNinja

Copy link
Copy Markdown
Contributor Author

Rebased onto current main — the conflict was isolated to piper_tts.get_status (main still had the bare which(piper) check). Resolved so the branch keeps the DEGRADED-without-default-voice status and the resolved-.onnx-path plumbing into _generate().

Re-ran locally after the rebase:

  • pytest tests/tools/test_remotion_staging.py tests/tools/test_piper_tts_status.py -q -> 9 passed
  • pytest tests/contracts/test_phase0_contracts.py -q -> 35 passed
  • py_compile tools/video/video_compose.py tools/audio/piper_tts.py -> OK
  • git diff --check -> clean

The three prior review points (cinematic audio staging, stale staged-asset refresh, Piper resolved-path) are unchanged from the commits you last cleared. Ready for a re-review on the merge result.

@calesthio calesthio left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the continued follow-up on the local rendering fixes. The Remotion asset staging and resolved Piper voice-path fixes look valuable and the focused new tests pass, but I’m requesting changes because the current branch still fails the merge-ref CI and I can reproduce the failing contract locally.\n\nFailing check:\n- tests/contracts/test_phase3_contracts.py::TestPiperTTS::test_status_requires_piper_executable_even_if_python_package_imports\n\nLocal validation on the PR head:\n- python -m pytest tests/tools/test_remotion_staging.py tests/tools/test_piper_tts_status.py tests/contracts/test_phase3_contracts.py::TestPiperTTS::test_status_requires_piper_executable_even_if_python_package_imports -q -> 9 passed, 1 failed\n\nThe regression is that _piper_installed() returns true when the Python piper package imports even if shutil.which('piper') is absent. This tool shells out to the piper executable, so importing the package is not enough for execution readiness. In that state get_status() should remain UNAVAILABLE, not DEGRADED; otherwise preflight still implies the local TTS path is installed when the binary command cannot run.\n\nPlease make executable availability the install gate again, then keep the voice-model check as the AVAILABLE vs DEGRADED distinction once the binary exists. After that, the existing staging/Piper tests plus this contract should be green and the PR should be in good shape.

resolveAsset stripped 'file:///' including the leading slash, so 'file:///abs'
became the relative 'abs' (then mis-served from public/) and a bare '/abs'
became 'file:////abs' (four slashes). Strip only the 'file://' scheme and build
the URI without doubling the slash: unix '/abs' -> 'file:///abs', windows
'C:/abs' -> 'file:///C:/abs', relative -> staticFile(). Refs calesthio#237
Remotion's renderer only loads http(s) URLs or files served from the composer's
public/ directory (via staticFile) — file:// URIs are rejected at render time.
The remotion_render path passed local cut sources through as file:// and never
converted audio paths at all, so local images, video, narration, and music
failed to load (e.g. 'Can only download URLs starting with http:// or https://').

Stage every local asset (cuts[].source and audio.narration/music.src) into
remotion-composer/public/_om_assets/ under a content-hashed name and rewrite the
prop to the staticFile-relative path. public/* is already gitignored. Refs calesthio#237
get_status() returned AVAILABLE whenever the piper module imported, but Piper
ships no voice model, so execute() then failed ('Unable to find voice ...') and
preflight wrongly reported TTS ready. Now report DEGRADED when installed but no
voice model is discoverable (cwd, PIPER_VOICE_DIR, ~/.local/share/piper,
~/.piper/models), give an actionable download hint on execute, and fix
install_instructions to the current 'python -m piper.download_voices' command.
Tests cover unavailable/degraded/available and the execute hint. Closes calesthio#237
…opies

The Remotion asset-staging pass only handled the Explainer prop shape
(cuts[].source, audio.narration/music.src). Cinematic compositions
(cinematic-trailer, documentary-montage) route to CinematicRenderer, whose
assets live at scenes[].src and top-level soundtrack.src / music.src — those
local absolute paths were left unstaged and still hit the file:// render
rejection described in calesthio#237.

Also key the staged copy on source size+mtime, not path alone: a regenerated
asset written back to the same path previously left the earlier render's bytes
in public/_om_assets and was served stale. copy2 preserves mtime, so an
unchanged source still skips the copy while a changed one refreshes.

Adds tests covering cinematic scene/audio staging and stale-copy refresh.

Refs calesthio#237
get_status() reported AVAILABLE when any .onnx voice existed, so preflight
looked ready even when the default en_US-lessac-medium was absent and the
default execute path would fail. Report AVAILABLE only when the default voice
is present, DEGRADED otherwise.

execute() now gates on the voice the call will actually use (inputs['model']
or the default) rather than the coarse status, so a caller may still run an
explicitly-provided installed voice, and a missing-model error names the
requested model instead of always the default.

Refs calesthio#237
The staging test stub parsed only the separate `--props <path>` form, but the
composer invokes Remotion with the `--props=<path>` equals form, so the merged
code path failed the test in merge-ref CI. Handle both forms.

Refs calesthio#237
… name

get_status()/execute() resolve the voice via _find_voice() across cwd,
PIPER_VOICE_DIR, and ~/.piper, but _generate() still invoked piper with the
bare model string. Piper only searches the current directory for a bare name,
so a voice installed in PIPER_VOICE_DIR/~/.piper passed the availability gate
yet failed to load at run time from a different cwd — the same false-ready
shape this PR fixes for status. Plumb the resolved .onnx path into _generate
and report it in the result.

Adds a test that installs the voice outside cwd and asserts the piper --model
argument is the resolved .onnx path.

Refs calesthio#237
…package

get_status()/execute() checked `_piper_installed()`, which returned True when
the Python `piper` package merely imports even if the `piper` binary is absent
from PATH. This tool shells out to the `piper` executable, so a package-only
install cannot actually run — preflight must report UNAVAILABLE, not DEGRADED,
in that state. Gate solely on `shutil.which("piper")`; the voice-model check
still distinguishes AVAILABLE vs DEGRADED once the binary exists.

Fixes the phase3 contract
test_status_requires_piper_executable_even_if_python_package_imports.
@0xDevNinja 0xDevNinja force-pushed the fix/issue-237-local-rendering branch from 34863c8 to 57a2777 Compare July 7, 2026 07:45
@0xDevNinja

Copy link
Copy Markdown
Contributor Author

Good catch — fixed. _piper_installed() now gates solely on shutil.which("piper"); the Python-package import fallback is gone. So with the package importable but the binary absent from PATH, get_status() returns UNAVAILABLE (not DEGRADED), since this tool shells out to the piper executable and a package-only install cannot run it. The voice-model check still draws the AVAILABLE vs DEGRADED line once the binary exists.

Also rebased onto current main.

Local run on the new head:

  • pytest tests/contracts/test_phase3_contracts.py::TestPiperTTS -q -> passed (incl. test_status_requires_piper_executable_even_if_python_package_imports)
  • pytest tests/tools/test_remotion_staging.py tests/tools/test_piper_tts_status.py -q -> passed
  • full tests/contracts/test_phase3_contracts.py + test_phase0_contracts.py -> 99 passed
  • git diff --check -> clean

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Local zero-key rendering broken: resolveAsset path mangling, unconverted audio paths, and false-available Piper TTS

2 participants