characters/voices: accept /v1/files paths and sha256 upload ids as media

Motus request 3. The character and voice extractors decoded media with their own
helpers, which took base64, data: URIs and http(s) URLs but not the
/v1/files/... paths compose accepts — so a client had to inline every source clip
as base64 (a third larger) even when the server already held the file it had
uploaded minutes earlier.

- codai/compose/media.py: one resolver for every accepted reference —
  blob_path() for a sha256: upload id, local_path() for that or a /v1/files path
  (traversal-checked), fetch_bytes() for the endpoints that want bytes; a file we
  hold is read from disk, never fetched over the loopback.
- characters._decode_source and voice_clone._decode_b64_or_url now delegate to
  it, which also gives POST /v1/characters and PATCH add_images the same
  references (they only base64-decoded before), and _decode_audio takes the
  stored extension from the CONTENT rather than the caller's claim (a
  data:audio/mpeg URI holding a wav was written as .mpeg).
- uploads.find_blob() reuses media.blob_path so there is one lookup, not two.
- A bare server path stays refused; a missing blob is a 400 naming the problem.
- docs/video-compose.md lists the endpoints that share the resolver;
  tests/test_compose.py +2 (every reference form, and a character saved from an
  upload id).
Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B7EnDW6RSd2nhEPZMzMkqi
parent f32a6c6c
...@@ -160,12 +160,8 @@ def _save_character(name: str, description: str, images: List[CharacterImage]) - ...@@ -160,12 +160,8 @@ def _save_character(name: str, description: str, images: List[CharacterImage]) -
img_files = [] img_files = []
for i, img in enumerate(images): for i, img in enumerate(images):
raw = img.data # Any reference the API accepts, not only base64 (see _decode_source).
if raw.startswith('data:'): img_bytes = _decode_source(img.data)
_, b64 = raw.split(',', 1)
else:
b64 = raw
img_bytes = base64.b64decode(b64)
# Detect PNG vs JPEG from magic bytes # Detect PNG vs JPEG from magic bytes
ext = '.png' if img_bytes[:4] == b'\x89PNG' else '.jpg' ext = '.png' if img_bytes[:4] == b'\x89PNG' else '.jpg'
fname = f"ref{i:02d}{ext}" fname = f"ref{i:02d}{ext}"
...@@ -228,15 +224,19 @@ def _list_characters() -> list: ...@@ -228,15 +224,19 @@ def _list_characters() -> list:
def _decode_source(data: str) -> bytes: def _decode_source(data: str) -> bytes:
"""Decode base64 or URL source into raw bytes.""" """Source media as raw bytes, in any form the API accepts.
if data.startswith('data:'):
_, b64 = data.split(',', 1) Everything goes through the one resolver (codai/compose/media.py), so these
return base64.b64decode(b64) endpoints take the same references as composition: a ``/v1/files/...`` path
if data.startswith('http://') or data.startswith('https://'): or an absolute URL of one (read from disk — a client that uploaded its footage
import urllib.request once should not have to re-send it as base64), a ``sha256:`` id from
with urllib.request.urlopen(data, timeout=60) as r: ``/v1/files/upload``, a ``data:`` URI, raw base64, or a remote URL. A bare
return r.read() path on this machine is still refused: a client cannot name server files."""
return base64.b64decode(data) from codai.compose import media as _media
try:
return _media.fetch_bytes(data)
except _media.MediaError as exc:
raise HTTPException(status_code=400, detail=str(exc))
def _detect_faces_cv2(img_bytes: bytes): def _detect_faces_cv2(img_bytes: bytes):
...@@ -487,12 +487,7 @@ async def patch_character(name: str, req: CharacterPatchRequest, _auth=Depends(_ ...@@ -487,12 +487,7 @@ async def patch_character(name: str, req: CharacterPatchRequest, _auth=Depends(_
if req.add_images: if req.add_images:
next_i = len(img_files) next_i = len(img_files)
for img in req.add_images: for img in req.add_images:
raw = img.data img_bytes = _decode_source(img.data) # same references as elsewhere
if raw.startswith('data:'):
_, b64 = raw.split(',', 1)
else:
b64 = raw
img_bytes = base64.b64decode(b64)
ext = '.png' if img_bytes[:4] == b'\x89PNG' else '.jpg' ext = '.png' if img_bytes[:4] == b'\x89PNG' else '.jpg'
fname = f"ref{next_i:02d}{ext}" fname = f"ref{next_i:02d}{ext}"
fpath = os.path.join(cdir, fname) fpath = os.path.join(cdir, fname)
......
...@@ -32,9 +32,10 @@ from codai.compose import media ...@@ -32,9 +32,10 @@ from codai.compose import media
router = APIRouter() router = APIRouter()
# The name a hash is stored under, e.g. up-3f9c…-<12 hex>.mp4. The prefix keeps # The name a hash is stored under, e.g. up-<sha256>.mp4. The prefix keeps uploads
# uploads recognisable in the archive listing and in /v1/files URLs. # recognisable in the archive listing and in /v1/files URLs; media.blob_path()
_PREFIX = "up-" # resolves it back.
_PREFIX = media.UPLOAD_PREFIX
_MAX_BYTES = 2 << 30 # 2 GiB: a minute of 4K is ~400 MB _MAX_BYTES = 2 << 30 # 2 GiB: a minute of 4K is ~400 MB
...@@ -53,25 +54,10 @@ def _stored_name(hexhash: str, ext: str) -> str: ...@@ -53,25 +54,10 @@ def _stored_name(hexhash: str, ext: str) -> str:
def find_blob(hexhash: str) -> Optional[str]: def find_blob(hexhash: str) -> Optional[str]:
"""The stored path for a hash, whatever extension it landed under.""" """The stored path for a hash, whatever extension it landed under. The lookup
h = (hexhash or "").strip().lower() itself lives in codai/compose/media.py so every endpoint that accepts a
if h.startswith("sha256:"): ``sha256:`` id resolves it the same way."""
h = h[7:] return media.blob_path(hexhash)
if not h or not all(c in "0123456789abcdef" for c in h) or len(h) != 64:
return None
try:
d = _files_dir()
except HTTPException:
return None
try:
for name in os.listdir(d):
if name.startswith(_PREFIX + h):
p = os.path.join(d, name)
if os.path.isfile(p):
return p
except OSError:
return None
return None
def store(data: bytes, filename: str = "", content_type: str = "") -> dict: def store(data: bytes, filename: str = "", content_type: str = "") -> dict:
......
...@@ -171,24 +171,32 @@ def _load_voice(name: str) -> Optional[dict]: ...@@ -171,24 +171,32 @@ def _load_voice(name: str) -> Optional[dict]:
return json.load(f) return json.load(f)
def _decode_audio(data: str) -> tuple[bytes, str]: def _decode_b64_or_url(data: str) -> bytes:
"""Decode base64 audio data, return (bytes, ext).""" """Source media as raw bytes, in any form the API accepts.
if data.startswith('data:'):
mime, b64 = data.split(',', 1) One resolver for every reference (codai/compose/media.py): a
ext = '.' + mime.split('/')[1].split(';')[0] ``/v1/files/...`` path or an absolute URL of one (read from disk, so a clip
return base64.b64decode(b64), ext uploaded once need not be re-sent as base64), a ``sha256:`` id from
return base64.b64decode(data), '.wav' ``/v1/files/upload``, a ``data:`` URI, raw base64, or a remote URL. A bare
path on this machine stays refused."""
from codai.compose import media as _media
try:
return _media.fetch_bytes(data)
except _media.MediaError as exc:
raise HTTPException(status_code=400, detail=str(exc))
def _decode_b64_or_url(data: str) -> bytes: def _decode_audio(data: str) -> tuple[bytes, str]:
if data.startswith('data:'): """The same, plus the extension to store the clip under — taken from the
_, b64 = data.split(',', 1) content rather than from what the caller claimed (a ``data:audio/mpeg`` URI
return base64.b64decode(b64) holding a wav used to be written as .mpeg)."""
if data.startswith('http://') or data.startswith('https://'): raw = _decode_b64_or_url(data)
import urllib.request from codai.compose import media as _media
with urllib.request.urlopen(data, timeout=60) as r: hint = ""
return r.read() if isinstance(data, str) and data.startswith("data:"):
return base64.b64decode(data) hint = data[5:].split(",", 1)[0].split(";")[0]
ext = _media.ext_for(_media.sniff_mime(raw, hint))
return raw, (ext if ext != ".bin" else ".wav")
#: F5-TTS prompts on the reference clip, and longer is NOT better: past roughly #: F5-TTS prompts on the reference clip, and longer is NOT better: past roughly
......
...@@ -179,6 +179,54 @@ def local_path_for_files_url(ref: str) -> Optional[str]: ...@@ -179,6 +179,54 @@ def local_path_for_files_url(ref: str) -> Optional[str]:
return cand if os.path.isfile(cand) else None return cand if os.path.isfile(cand) else None
_SHA256 = re.compile(r"^(?:sha256:)?([0-9a-f]{64})$", re.I)
#: Uploads are stored in the output directory under this prefix (codai/api/uploads.py).
UPLOAD_PREFIX = "up-"
def blob_path(ref: str) -> Optional[str]:
"""The stored path for a ``sha256:<hex>`` upload id, whatever extension it
landed under, or None when this install does not hold it."""
m = _SHA256.match((ref or "").strip())
if not m:
return None
h = m.group(1).lower()
base = files_dir()
if not base:
return None
try:
for name in os.listdir(base):
if name.startswith(UPLOAD_PREFIX + h):
p = os.path.join(base, name)
if os.path.isfile(p):
return p
except OSError:
return None
return None
def local_path(ref: str) -> Optional[str]:
"""The local file a reference names, when this install already holds it: a
``/v1/files/...`` path (or an absolute URL ending in one), or a ``sha256:``
id from ``/v1/files/upload``. None for anything that must be fetched."""
return local_path_for_files_url(ref) or blob_path(ref)
def fetch_bytes(ref: str, timeout: float = 120.0, max_bytes: int = 2 << 30) -> bytes:
"""The bytes behind ANY accepted media reference, for the endpoints that want
bytes rather than a path: a file this install holds (read from disk, never
over HTTP — a loopback fetch would need a token and would fail when the front
is bound elsewhere), a ``data:`` URI, base64, or a remote URL."""
local = local_path(ref)
if local:
with open(local, "rb") as f:
data = f.read(max_bytes + 1)
if len(data) > max_bytes:
raise MediaError(f"{os.path.basename(local)}: larger than {max_bytes} bytes")
return data
return fetch(ref, timeout=timeout, max_bytes=max_bytes)
def fetch(ref: str, timeout: float = 120.0, max_bytes: int = 2 << 30) -> bytes: def fetch(ref: str, timeout: float = 120.0, max_bytes: int = 2 << 30) -> bytes:
"""The bytes behind a media reference (URL / data URI / base64).""" """The bytes behind a media reference (URL / data URI / base64)."""
if not ref: if not ref:
...@@ -208,7 +256,7 @@ def resolve(ref: str, workdir: str, name_hint: str = "media", ...@@ -208,7 +256,7 @@ def resolve(ref: str, workdir: str, name_hint: str = "media",
"""A media reference → a local file path, downloading into ``workdir`` when """A media reference → a local file path, downloading into ``workdir`` when
the reference is remote or inline. Files this install already holds are used the reference is remote or inline. Files this install already holds are used
where they are.""" where they are."""
local = local_path_for_files_url(ref) local = local_path(ref)
if local: if local:
return local return local
data = fetch(ref, timeout=timeout) data = fetch(ref, timeout=timeout)
......
...@@ -182,6 +182,26 @@ The body may also be JSON (`{"file": "<base64 or data: URI>"}`) or the raw bytes ...@@ -182,6 +182,26 @@ The body may also be JSON (`{"file": "<base64 or data: URI>"}`) or the raw bytes
with a `Content-Type`. Video, image and audio only; the type is decided by the with a `Content-Type`. Video, image and audio only; the type is decided by the
content, not by the client's claim. content, not by the client's claim.
## The same references elsewhere
The extractors that build characters and voice profiles from a user's own media
resolve their media fields through the same resolver, so they accept everything
compose does — a `/v1/files/...` path, an absolute URL of one, a `sha256:` upload
id, a `data:` URI, raw base64, a remote URL:
* `POST /v1/characters` (each reference image), `PATCH /v1/characters/{name}`
(`add_images`)
* `POST /v1/characters/extract` (`images`, `videos`)
* `POST /v1/audio/voices`, `POST /v1/audio/voices/extract` (`audio`, `video`)
Upload the clip once and pass the path; a 100 MB source no longer has to travel
as base64 (a third larger) on every call. A file this install holds is read from
disk rather than fetched over HTTP — a loopback request would need a token and
would fail when the front is bound elsewhere. A bare path on the server is still
refused: a client cannot name files on this machine. Audio clips are now stored
under the extension their *content* implies rather than the one the caller
claimed.
## Notes for operators ## Notes for operators
* **ffmpeg is required** (the published images have it, with libass for the * **ffmpeg is required** (the published images have it, with libass for the
......
...@@ -665,3 +665,63 @@ def test_a_cloned_voice_profile_is_used_for_narration(tmp_path, files_dir, monke ...@@ -665,3 +665,63 @@ def test_a_cloned_voice_profile_is_used_for_narration(tmp_path, files_dir, monke
assert seen == [{"text": "in my own voice", "voice_name": "nextime", "speed": 0.9}] assert seen == [{"text": "in my own voice", "voice_name": "nextime", "speed": 0.9}]
# The scene is as long as the cloned clip (0.6s) plus the default padding. # The scene is as long as the cloned clip (0.6s) plus the default padding.
assert doc["result"]["duration"] == pytest.approx(0.75, abs=0.1) assert doc["result"]["duration"] == pytest.approx(0.75, abs=0.1)
# ================================== request 3: /v1/files in the extractors
def test_the_extractors_accept_files_paths_and_upload_ids(tmp_path, files_dir):
"""Characters and voice profiles are built from the user's own media. That
media is often already here (uploaded once), so these endpoints take the same
references composition does instead of forcing a base64 re-send."""
import base64
from codai.api.characters import _decode_source
from codai.api.voice_clone import _decode_audio, _decode_b64_or_url
from fastapi import HTTPException
png = Path(_png(tmp_path / "ref.png", 40, 40)).read_bytes()
wav = Path(_tone(tmp_path / "ref.wav", 0.3)).read_bytes()
# As /v1/files/upload would have stored them.
import hashlib
ph = hashlib.sha256(png).hexdigest()
ah = hashlib.sha256(wav).hexdigest()
(files_dir / f"up-{ph}.png").write_bytes(png)
(files_dir / f"up-{ah}.wav").write_bytes(wav)
for ref in (f"/v1/files/up-{ph}.png",
f"http://any-host.invalid/v1/files/up-{ph}.png",
f"sha256:{ph}", ph,
"data:image/png;base64," + base64.b64encode(png).decode(),
base64.b64encode(png).decode()):
assert _decode_source(ref) == png, ref
assert _decode_b64_or_url(f"sha256:{ah}") == wav
raw, ext = _decode_audio(f"/v1/files/up-{ah}.wav")
assert raw == wav and ext == ".wav"
# The extension comes from the content, not from the caller's claim.
_, ext = _decode_audio("data:audio/mpeg;base64," + base64.b64encode(wav).decode())
assert ext == ".wav"
# A path on the server is still not a reference a client may name.
for bad in ("/etc/passwd", "./secrets.json", "sha256:" + "f" * 64):
with pytest.raises(HTTPException) as e:
_decode_source(bad)
assert e.value.status_code == 400
def test_a_character_is_saved_from_an_uploaded_image(tmp_path, files_dir, monkeypatch):
import hashlib
from fastapi import FastAPI
from fastapi.testclient import TestClient
import codai.api.characters as ch
monkeypatch.setattr(ch, "_chars_dir", lambda: str(tmp_path / "chars"))
app = FastAPI()
app.include_router(ch.router)
client = TestClient(app)
png = Path(_png(tmp_path / "face.png", 64, 64)).read_bytes()
h = hashlib.sha256(png).hexdigest()
(files_dir / f"up-{h}.png").write_bytes(png)
r = client.post("/v1/characters", json={"name": "ada", "description": "d",
"images": [{"label": "front",
"data": f"sha256:{h}"}]})
assert r.status_code == 200, r.text
saved = tmp_path / "chars" / "ada" / "ref00.png"
assert saved.is_file() and saved.read_bytes() == png
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment