Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,10 @@ You can also enable production methods directly in **Series Lab → Shots**. For

Location image prompts describe empty environments. Series Lab separates the physical setting and rendering style from character design and narrative occupants before generating. Use **Prepare environment prompt** in the location card to review the exact prompt first.

Character Creator identifies each Qwen3 preset by its original language/profile and timbre. The nine presets can speak several languages, but none is natively Spanish. Use **Generate voice sample** with a Spanish or English sentence to hear the selected voice before saving or preparing mouths. Changing the voice cancels only that audition and stops the previous sample.

Choose **Add your own voice: import or record** to use **Qwen3 Base** with a clean 3–30 second recording (up to 20 MB). Import an audio file or record with the microphone, name the voice, enter the exact recording transcript, and choose the language for new dialogue. Audition it, then **Save everything** on the character. Saved custom voices appear in the voice selector for other characters; new or regenerated native 2D/3D dialogue uses the stored recording and transcript. Existing takes remain available. Recording requires a browser with microphone support on HTTPS or localhost; importing also works over LAN HTTP. Audio samples are stored as persistent local uploads, and character metadata stores public references rather than machine-specific paths. No new model or recording is generated merely by selecting or saving a voice.

Each **Canon → Characters** card also shows voice, 2D lip-sync and 3D lip-sync readiness. **Configure in Character Creator** opens a dedicated view for that exact character, carries over its reference image, and links the saved configuration by ID. The Series card keeps its library selector. **Save everything and return to Series Lab** saves voice, mouth images and placement together, then returns to the source character. A failed save retains the draft and keeps the editor open; a fully saved session can yield to the next character even after switching tabs. A voice can be saved without a 3D model. Save the character before opening its 2D mouth workshop or 3D face calibration. The mouth workshop has a rectangle whose width and height can be adjusted independently, a visible mouth-pack catalog with previews, explicit AI generation buttons, and a one-click prerecorded English voice sample for previewing mouth movement without generating speech. **Apply placement to all mouths** copies the current position, scale and rotation to all nine mouth shapes. **Try with their voice** previews the full isolated recording with the same phonetic analyzer as native shots, including pauses and resting-mouth closure; the separate quick text preview is approximate. Eyes and blinking are optional: keep the original drawing unless you want to add overlays. Review the cleaned base and mouth variants, then save the speech character. Dialogue shots open Character Creator directly; the advanced voice table links to the character card. AI video with native audio continues to use its generator's voice; the reusable TTS preset is used in the speech editor.

### Finish without regenerating
Expand Down
1 change: 1 addition & 0 deletions app/_launch_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -1088,6 +1088,7 @@ def _resolve_request_media_path(
uploads_root=os.path.join(os.getcwd(), "uploads"),
workspace_root=_workspace_dir(workspace),
kinds=kinds,
workspace_name=workspace or _get_active_workspace(),
)
except MediaPathNotAllowed:
raise HTTPException(status_code=400, detail="Media path is not allowed") from None
Expand Down
52 changes: 51 additions & 1 deletion app/services/character_speech_definition.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,41 @@
import json
import math
import re
from urllib.parse import urlsplit, parse_qsl
from urllib.parse import urlsplit, parse_qsl, unquote

CHARACTER_VOICE_LANGUAGES = {"auto", "chinese", "english", "japanese", "korean", "german", "french", "russian", "portuguese", "spanish", "italian"}


def _voice_reference_query(kind, raw):
if kind == "uploads":
if raw is not None:
raise ValueError("Upload references cannot contain query parameters.")
return
query = parse_qsl(raw or "", keep_blank_values=True)
if (len(query) != 1 or query[0][0] != "workspace"
or not re.fullmatch(r"[A-Za-z0-9_. -]{1,120}", query[0][1]) or query[0][1] in {".", ".."}):
raise ValueError("The reference recording needs its source workspace.")


def normalize_character_voice_reference(value):
"""Persistent same-origin audio only, with an explicit source workspace."""
if not isinstance(value, str) or not value or len(value) > 1200 or re.search(r"\s|[\\#]", value):
raise ValueError("Import a local reference recording first.")
match = re.fullmatch(r"/api/v1/(uploads|file)/([^?]+)(?:\?(.+))?", value)
if not match:
raise ValueError("Use a persistent local reference recording.")
if re.search(r"%(?![0-9a-fA-F]{2})", value):
raise ValueError("Invalid reference recording URL.")
try:
path = unquote(match[2], errors="strict")
unquote(match[3] or "", errors="strict")
except UnicodeDecodeError as error:
raise ValueError("Invalid reference recording URL.") from error
if (re.search(r"[\x00-\x1f\x7f\\%?#]", path) or any(not part or part.startswith(".") for part in path.split("/"))
or not re.search(r"\.(wav|mp3|m4a|aac|flac|ogg|opus)$", path, re.I)):
raise ValueError("Choose a local audio recording.")
_voice_reference_query(match[1], match[3])
return value


def _asset_fields(value):
Expand Down Expand Up @@ -93,7 +127,23 @@ def normalize_speech3d(value):
return result


def _voice_text(value, limit):
return isinstance(value, str) and bool(value.strip()) and len(value) <= limit


def _normalize_reference_voice(value):
if (set(value) != {"provider", "model", "voiceId", "name", "referenceAudio", "transcript", "language"}
or value.get("provider") != "local" or value.get("voiceId") != "reference"
or not _voice_text(value.get("name"), 120) or not _voice_text(value.get("transcript"), 4000)
or not isinstance(value.get("language"), str) or value["language"] not in CHARACTER_VOICE_LANGUAGES):
raise ValueError("Add a named local reference recording, its transcript and a supported language.")
return {**value, "name": value["name"].strip(), "transcript": value["transcript"].strip(),
"referenceAudio": normalize_character_voice_reference(value["referenceAudio"])}


def normalize_character_voice(value):
if isinstance(value, dict) and value.get("model") == "qwen3_tts_base":
return _normalize_reference_voice(value)
if not isinstance(value, dict) or set(value) - {"provider", "model", "voiceId", "instructions"}:
raise ValueError("Store voice preferences only, never credentials.")
if value.get("provider") != "local" or value.get("model") != "qwen3_tts_customvoice":
Expand Down
38 changes: 36 additions & 2 deletions app/services/media_paths.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@
from __future__ import annotations

import os
import re
from collections.abc import Iterable
from urllib.parse import parse_qsl, unquote, urlsplit


class MediaPathNotAllowed(ValueError):
Expand Down Expand Up @@ -31,12 +33,40 @@ def _is_contained(path: str, root: str) -> bool:
return False


def _canonical_media_path(value: str, uploads_root: str, workspace_root: str, workspace_name: str | None):
"""Resolve the declared API root, without same-name fallback across roots."""
if any(ord(char) <= 32 or char in "\\#" for char in value) or re.search(r"%(?![0-9a-fA-F]{2})", value):
raise MediaPathNotAllowed("Invalid canonical media reference")
parsed = urlsplit(value)
upload_prefix, file_prefix = "/api/v1/uploads/", "/api/v1/file/"
if parsed.path.startswith(upload_prefix):
if parsed.query:
raise MediaPathNotAllowed("Upload references cannot contain query parameters")
root, relative = uploads_root, parsed.path[len(upload_prefix):]
elif parsed.path.startswith(file_prefix):
query = parse_qsl(parsed.query, keep_blank_values=True)
if len(query) != 1 or query[0] != ("workspace", workspace_name):
raise MediaPathNotAllowed("The reference workspace does not match its declared root")
root, relative = workspace_root, parsed.path[len(file_prefix):]
else:
raise MediaPathNotAllowed("Unsupported canonical media root")
try:
relative = unquote(relative, errors="strict")
except UnicodeDecodeError as error:
raise MediaPathNotAllowed("Invalid canonical media reference") from error
if (any(ord(char) < 32 or ord(char) == 127 or char == "\\" for char in relative)
or any(part in {"", ".", ".."} for part in relative.split("/"))):
raise MediaPathNotAllowed("Media path is not allowed")
return os.path.join(root, relative), root


def resolve_permitted_media_path(
value: str,
*,
uploads_root: str,
workspace_root: str,
kinds: Iterable[str] = ("audio", "video"),
workspace_name: str | None = None,
) -> str:
"""Resolve a media path contained in uploads or one workspace.

Expand All @@ -57,7 +87,12 @@ def resolve_permitted_media_path(
raise MediaPathNotAllowed("Media roots are not available")

raw = value.strip()
if os.path.isabs(raw) or os.path.splitdrive(raw)[0]:
if raw.startswith("/api/"):
canonical, declared_root = _canonical_media_path(raw, roots[0], roots[1], workspace_name)
# A canonical file may not follow a symlink into the other allowed root.
roots = (declared_root,)
raw_candidates = (canonical,)
elif os.path.isabs(raw) or os.path.splitdrive(raw)[0]:
raw_candidates = (raw,)
else:
raw_candidates = (raw, *(os.path.join(root, raw) for root in roots))
Expand Down Expand Up @@ -165,4 +200,3 @@ def resolve_story_cover_audio(
workspace_root=workspace_root,
kinds=("audio",),
)

1 change: 1 addition & 0 deletions scripts/ci_test_groups.json
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,7 @@
"tests/test_code_health.py",
"tests/test_core_runtime.py",
"tests/test_core_series_assembly.py",
"tests/test_custom_character_voice.py",
"tests/test_debug_trace.py",
"tests/test_development_branch_policy.py",
"tests/test_director_h3_identity_continuity.py",
Expand Down
6 changes: 6 additions & 0 deletions tests/fixtures/architecture_wire_inventory.json
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,12 @@
"classification": "symbol_importable",
"reason": "Extracts selected launch symbols with AST/exec; migrate to direct imports when that domain moves."
},
{
"file": "tests/test_custom_character_voice.py",
"target": "app/_launch_runtime.py",
"classification": "symbol_importable",
"reason": "Extracts selected launch symbols with AST/exec; migrate to direct imports when that domain moves."
},
{
"file": "tests/test_director_model_compat.py",
"target": "app/_launch_runtime.py",
Expand Down
160 changes: 160 additions & 0 deletions tests/test_custom_character_voice.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import ast
import asyncio
import os
from pathlib import Path
from urllib.parse import quote

import pytest
from fastapi import HTTPException

from app.services.character_kit_library import patch_character_kit, read_character_kit_library
from app.services.character_speech_definition import normalize_character_voice
from app.services.media_paths import MediaPathNotAllowed, resolve_permitted_media_path


def voice():
return {"provider": "local", "model": "qwen3_tts_base", "voiceId": "reference", "name": "Narradora",
"referenceAudio": "/api/v1/uploads/audio/recording.wav", "transcript": "Esta es mi voz.", "language": "spanish"}


def test_reference_voice_survives_library_reload_and_links_from_another_workspace(tmp_path):
custom = voice()
custom["referenceAudio"] = "/api/v1/file/assets/voice.wav?workspace=original"
kit = {"version": 1, "id": "narrator", "name": "Narrator", "style": "cutout", "poses": {},
"mouth": {}, "eyes": {}, "anchors": {}, "provenance": [], "voice": custom}
for workspace in ("original", "episode"):
patch_character_kit(str(tmp_path / workspace), "narrator", kit, base_revision=0)
assert read_character_kit_library(str(tmp_path / workspace))["kits"]["narrator"]["voice"] == custom
assert normalize_character_voice({**custom, "name": " Narradora ", "transcript": " Esta es mi voz. "}) == custom
preset = {"provider": "local", "model": "qwen3_tts_customvoice", "voiceId": "ryan", "instructions": "Warm"}
assert normalize_character_voice(preset) == preset


@pytest.mark.parametrize("patch", [
{"name": ""}, {"name": " "}, {"name": "a" * 121}, {"name": 12}, {"transcript": ""}, {"transcript": "\n"},
{"transcript": "a" * 4001}, {"transcript": None}, {"referenceAudio": ""}, {"language": "martian"},
{"language": []}, {"voiceId": "ryan"}, {"provider": "remote"}, {"apiKey": "not-a-secret"}, {"instructions": "unsupported"},
])
def test_incomplete_or_unsupported_reference_voice_is_rejected(patch):
with pytest.raises(ValueError):
normalize_character_voice({**voice(), **patch})


@pytest.mark.parametrize("key", list(voice()))
def test_every_reference_voice_field_is_required(key):
incomplete = voice()
del incomplete[key]
with pytest.raises(ValueError):
normalize_character_voice(incomplete)


@pytest.mark.parametrize("reference", [
"https://example.com/voice.wav", "//example.com/voice.wav", "blob:voice", "data:audio/wav;base64,AAAA",
"/home/private.wav", "/api/v1/uploads/../voice.wav", "/api/v1/uploads/%2e%2e/voice.wav",
"/api/v1/uploads/%252e%252e/voice.wav", "/api/v1/uploads/audio%5cvoice.wav", "/api/v1/uploads/.private/voice.wav",
"/api/v1/uploads/voice.wav?token=example", "/api/v1/uploads/voice.wav#fragment", "/api/v1/uploads/voice.wav?",
"/api/v1/uploads/voice.webm",
"/api/v1/uploads/voice%00.wav", "/api/v1/uploads/%ZZ.wav", "/api/v1/uploads/voice.json",
"/api/v1/uploads/voice wav.wav", "/api/v1/uploads//voice.wav", "/api/v1/file/voice.wav",
"/api/v1/file/voice.wav?workspace=", "/api/v1/file/voice.wav?workspace=..",
"/api/v1/file/voice.wav?workspace=one&workspace=two", "/api/v1/file/voice.wav?workspace=one&key=example",
"/api/v1/file/voice.wav?workspace=%ZZ", "/api/v1/file/voice.wav?workspace=%FF",
])
def test_remote_unsafe_or_ambiguous_reference_is_rejected(reference):
with pytest.raises(ValueError):
normalize_character_voice({**voice(), "referenceAudio": reference})


@pytest.fixture
def media_roots(tmp_path):
uploads, workspace = tmp_path / "uploads", tmp_path / "episode"
uploads.mkdir()
workspace.mkdir()
return uploads, workspace


def resolve(reference, roots, workspace_name="original"):
return resolve_permitted_media_path(reference, uploads_root=str(roots[0]), workspace_root=str(roots[1]),
workspace_name=workspace_name, kinds=("audio",))


def test_canonical_media_adoption_keeps_the_exact_root_even_with_colliding_names(media_roots):
uploads, workspace = media_roots
for root in media_roots:
(root / "same.wav").write_bytes(b"reference")
assert resolve("/api/v1/uploads/same.wav", media_roots) == str(uploads / "same.wav")
assert resolve("/api/v1/file/same.wav?workspace=original", media_roots) == str(workspace / "same.wav")
(workspace / "same.wav").unlink()
with pytest.raises(FileNotFoundError):
resolve("/api/v1/file/same.wav?workspace=original", media_roots)
(workspace / "nested").mkdir()
(workspace / "nested" / "mi voz.wav").write_bytes(b"reference")
assert resolve("/api/v1/file/nested%2Fmi%20voz.wav?workspace=original", media_roots) == str(workspace / "nested" / "mi voz.wav")


@pytest.mark.parametrize("reference", [
"/api/v1/file/same.wav?workspace=wrong", "/api/v1/file/same.wav",
"/api/v1/file/same.wav?workspace=original&workspace=wrong", "/api/v1/file/same.wav?workspace=original&token=example",
"/api/v1/uploads/same.wav?token=example", "/api/v1/uploads/../episode/same.wav",
"/api/v1/uploads/%2e%2e/episode/same.wav", "/api/v1/file/%2e%2e/uploads/same.wav?workspace=original",
"/api/v1/uploads/audio%5csame.wav", "/api/v1/uploads/same%00.wav", "/api/v1/uploads/%ZZ.wav",
"/api/v1/uploads/same.wav#ignored", "/api/v1/uploads//same.wav",
])
def test_canonical_adoption_rejects_wrong_workspaces_and_traversal(media_roots, reference):
for root in media_roots:
(root / "same.wav").write_bytes(b"reference")
with pytest.raises(MediaPathNotAllowed):
resolve(reference, media_roots)


def test_canonical_adoption_does_not_follow_a_symlink_into_another_allowed_root(media_roots):
uploads, workspace = media_roots
(uploads / "same.wav").write_bytes(b"reference")
try:
(workspace / "same.wav").symlink_to(uploads / "same.wav")
except (OSError, NotImplementedError):
pytest.skip("Symlinks unavailable")
with pytest.raises(MediaPathNotAllowed):
resolve("/api/v1/file/same.wav?workspace=original", media_roots)


def test_legacy_adoption_remains_available_for_existing_audio_studio_callers(media_roots):
uploads, _ = media_roots
(uploads / "same.wav").write_bytes(b"reference")
assert resolve("same.wav", media_roots) == str(uploads / "same.wav")
assert resolve(str(uploads / "same.wav"), media_roots) == str(uploads / "same.wav")


def test_audio_adopt_endpoint_uses_source_workspace_and_new_canonical_resolver(media_roots, monkeypatch):
"""Execute the actual lightweight route functions without importing GPU runtimes."""
uploads, workspace = media_roots
monkeypatch.chdir(uploads.parent)
for root in media_roots:
(root / "same.wav").write_bytes(b"reference")
launch = Path(__file__).resolve().parents[1] / "app" / "_launch_runtime.py"
parsed = ast.parse(launch.read_text(encoding="utf-8"))
selected = [node for node in parsed.body if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
and node.name in {"_resolve_request_media_path", "adopt_audio"}]
for node in selected:
node.decorator_list = []
seen_workspaces = []

def workspace_dir(name):
seen_workspaces.append(name)
return str(workspace)

namespace = {"os": os, "quote": quote, "Request": object, "HTTPException": HTTPException,
"resolve_permitted_media_path": resolve_permitted_media_path, "MediaPathNotAllowed": MediaPathNotAllowed,
"_workspace_dir": workspace_dir, "_get_active_workspace": lambda: "destination",
"_probe_audio_duration": lambda _: 5}
exec(compile(ast.Module(body=selected, type_ignores=[]), str(launch), "exec"), namespace)

class Request:
async def json(self):
return {"audio_path": "/api/v1/file/same.wav?workspace=original", "workspace": "original"}

result = asyncio.run(namespace["adopt_audio"](Request()))
assert seen_workspaces == ["original"]
assert result["path"] == str(workspace / "same.wav")
assert result["duration_seconds"] == 5
assert result["url"].endswith("?workspace=original")
Loading
Loading