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]) -
img_files = []
for i, img in enumerate(images):
raw = img.data
if raw.startswith('data:'):
_, b64 = raw.split(',', 1)
else:
b64 = raw
img_bytes = base64.b64decode(b64)
# Any reference the API accepts, not only base64 (see _decode_source).
img_bytes = _decode_source(img.data)
# Detect PNG vs JPEG from magic bytes
ext = '.png' if img_bytes[:4] == b'\x89PNG' else '.jpg'
fname = f"ref{i:02d}{ext}"
......@@ -228,15 +224,19 @@ def _list_characters() -> list:
def _decode_source(data: str) -> bytes:
"""Decode base64 or URL source into raw bytes."""
if data.startswith('data:'):
_, b64 = data.split(',', 1)
return base64.b64decode(b64)
if data.startswith('http://') or data.startswith('https://'):
import urllib.request
with urllib.request.urlopen(data, timeout=60) as r:
return r.read()
return base64.b64decode(data)
"""Source media as raw bytes, in any form the API accepts.
Everything goes through the one resolver (codai/compose/media.py), so these
endpoints take the same references as composition: a ``/v1/files/...`` path
or an absolute URL of one (read from disk — a client that uploaded its footage
once should not have to re-send it as base64), a ``sha256:`` id from
``/v1/files/upload``, a ``data:`` URI, raw base64, or a remote URL. A bare
path on this machine is still refused: a client cannot name server files."""
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):
......@@ -487,12 +487,7 @@ async def patch_character(name: str, req: CharacterPatchRequest, _auth=Depends(_
if req.add_images:
next_i = len(img_files)
for img in req.add_images:
raw = img.data
if raw.startswith('data:'):
_, b64 = raw.split(',', 1)
else:
b64 = raw
img_bytes = base64.b64decode(b64)
img_bytes = _decode_source(img.data) # same references as elsewhere
ext = '.png' if img_bytes[:4] == b'\x89PNG' else '.jpg'
fname = f"ref{next_i:02d}{ext}"
fpath = os.path.join(cdir, fname)
......
......@@ -32,9 +32,10 @@ from codai.compose import media
router = APIRouter()
# The name a hash is stored under, e.g. up-3f9c…-<12 hex>.mp4. The prefix keeps
# uploads recognisable in the archive listing and in /v1/files URLs.
_PREFIX = "up-"
# The name a hash is stored under, e.g. up-<sha256>.mp4. The prefix keeps uploads
# recognisable in the archive listing and in /v1/files URLs; media.blob_path()
# resolves it back.
_PREFIX = media.UPLOAD_PREFIX
_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:
def find_blob(hexhash: str) -> Optional[str]:
"""The stored path for a hash, whatever extension it landed under."""
h = (hexhash or "").strip().lower()
if h.startswith("sha256:"):
h = h[7:]
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
"""The stored path for a hash, whatever extension it landed under. The lookup
itself lives in codai/compose/media.py so every endpoint that accepts a
``sha256:`` id resolves it the same way."""
return media.blob_path(hexhash)
def store(data: bytes, filename: str = "", content_type: str = "") -> dict:
......
......@@ -171,24 +171,32 @@ def _load_voice(name: str) -> Optional[dict]:
return json.load(f)
def _decode_audio(data: str) -> tuple[bytes, str]:
"""Decode base64 audio data, return (bytes, ext)."""
if data.startswith('data:'):
mime, b64 = data.split(',', 1)
ext = '.' + mime.split('/')[1].split(';')[0]
return base64.b64decode(b64), ext
return base64.b64decode(data), '.wav'
def _decode_b64_or_url(data: str) -> bytes:
"""Source media as raw bytes, in any form the API accepts.
One resolver for every reference (codai/compose/media.py): a
``/v1/files/...`` path or an absolute URL of one (read from disk, so a clip
uploaded once need not be re-sent as base64), a ``sha256:`` id from
``/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:
if data.startswith('data:'):
_, b64 = data.split(',', 1)
return base64.b64decode(b64)
if data.startswith('http://') or data.startswith('https://'):
import urllib.request
with urllib.request.urlopen(data, timeout=60) as r:
return r.read()
return base64.b64decode(data)
def _decode_audio(data: str) -> tuple[bytes, str]:
"""The same, plus the extension to store the clip under — taken from the
content rather than from what the caller claimed (a ``data:audio/mpeg`` URI
holding a wav used to be written as .mpeg)."""
raw = _decode_b64_or_url(data)
from codai.compose import media as _media
hint = ""
if isinstance(data, str) and data.startswith("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
......
......@@ -179,6 +179,54 @@ def local_path_for_files_url(ref: str) -> Optional[str]:
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:
"""The bytes behind a media reference (URL / data URI / base64)."""
if not ref:
......@@ -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
the reference is remote or inline. Files this install already holds are used
where they are."""
local = local_path_for_files_url(ref)
local = local_path(ref)
if local:
return local
data = fetch(ref, timeout=timeout)
......
......@@ -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
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
* **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
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.
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