keep the sound, the shape and the uploaded file when editing media (v0.4.86)

Four ways an edit could answer 200 while quietly doing the wrong thing.

The enhancement paths decode a clip to frames and encode a new video, so the
audio was left behind: a clip that arrived with speech came back silent. Only
the ffmpeg minterpolate fallback -- the path nobody takes by default -- ever
kept it. _mux_original_audio() puts the track back on all three in-process
paths, stream-copied where MP4 accepts the codec and re-encoded to AAC where it
does not. Both paths already preserve the source duration, so nothing is
stretched. A silent source, or a remux PyAV cannot do, returns the frames
unchanged rather than losing a finished GPU job.

The image and video editing endpoints took base64 (and, for video, an http
URL), so a caller that had already uploaded a file had to re-send it on every
edit -- megabytes a call for a file on the same disk. They now resolve through
the compose resolver, so a /v1/files path or a sha256: upload id works
everywhere, and our own files are read off disk instead of fetched back over a
loopback that would need a token. Responses carry the relative path beside the
url, as compose does, so a caller need not depend on the public hostname.

Routing through that resolver would have tightened what callers may send: it
validates base64 strictly, which rejects the 76-column wrapping that MIME, the
base64 tool and encodebytes all produce. Whitespace carries no meaning in
base64, so it is now stripped rather than refused.

/v1/images/edits defaulted size to the string "1024x1024", so omitting it
squashed every photo that was not square. Omitted now keeps the source's shape
at the ~1 MP a diffusion model wants, on the 64-grid it needs.

/v1/images/upscale fell through to PIL LANCZOS for an unrecognised model and
still answered 200, charging for an AI upscale that never ran and returning
something merely soft. That is a 400 naming the models that would work.
Co-Authored-By: 's avatarClaude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S8LAP3ueNzQJtwzhgVbq66
parent 2e8ea4e3
...@@ -16,7 +16,7 @@ ...@@ -16,7 +16,7 @@
# Canonical product version for CoderAI — single source of truth. Both the API # Canonical product version for CoderAI — single source of truth. Both the API
# metadata and the admin web UI read from here. # metadata and the admin web UI read from here.
__version__ = "0.4.85" __version__ = "0.4.86"
__tagline__ = "Complete orchestration, distribution and escalation of remotizable advanced inference." __tagline__ = "Complete orchestration, distribution and escalation of remotizable advanced inference."
# Configure the CUDA caching allocator BEFORE torch is imported anywhere. # Configure the CUDA caching allocator BEFORE torch is imported anywhere.
......
...@@ -234,6 +234,10 @@ def save_image_response(img, request_format="base64", http_request=None): ...@@ -234,6 +234,10 @@ def save_image_response(img, request_format="base64", http_request=None):
# Add URL to response # Add URL to response
from codai.api.urlutils import build_file_url from codai.api.urlutils import build_file_url
result["url"] = build_file_url(filename, http_request) result["url"] = build_file_url(filename, http_request)
# The relative path as well, as /v1/compose returns it: a caller that
# feeds the result into another call should not have to know, or depend
# on, this install's public hostname.
result["path"] = f"/v1/files/{filename}"
# If client explicitly requested base64, include it # If client explicitly requested base64, include it
# Otherwise, only return URL when file-path is set # Otherwise, only return URL when file-path is set
...@@ -1726,10 +1730,12 @@ async def create_image_generation(request: ImageGenerationRequest, http_request: ...@@ -1726,10 +1730,12 @@ async def create_image_generation(request: ImageGenerationRequest, http_request:
class ImageEditRequest(BaseModel): class ImageEditRequest(BaseModel):
model: str model: str
prompt: str prompt: str
image: str # base64-encoded PNG or "data:image/...;base64,..." image: str # /v1/files path, sha256: id, data URI, URL or base64
mask: Optional[str] = None # optional inpaint mask (base64 PNG) mask: Optional[str] = None # accepted but unused here -- see /v1/images/inpaint
n: int = 1 n: int = 1
size: Optional[str] = "1024x1024" #: Omitted means "keep the source's shape" (see _default_edit_size), NOT a
#: square: defaulting to 1024x1024 silently distorted every non-square photo.
size: Optional[str] = None
response_format: Optional[str] = "url" response_format: Optional[str] = "url"
strength: Optional[float] = 0.75 # denoising strength (0=keep original, 1=ignore) strength: Optional[float] = 0.75 # denoising strength (0=keep original, 1=ignore)
steps: Optional[int] = None steps: Optional[int] = None
...@@ -1742,15 +1748,44 @@ class ImageEditRequest(BaseModel): ...@@ -1742,15 +1748,44 @@ class ImageEditRequest(BaseModel):
extra = "allow" extra = "allow"
def _decode_b64_image(data: str): def _default_edit_size(width: int, height: int, multiple: int = 64,
"""Decode base64 image string to PIL Image.""" target_pixels: int = 1024 * 1024) -> tuple:
"""(w, h) keeping the source aspect at ~``target_pixels``, on a grid.
An omitted ``size`` used to mean 1024x1024, which stretched every photo that
was not square -- a 3:2 holiday snap came back visibly squashed. Diffusion
models need each side on a multiple (usually 64), so the aspect is preserved
as closely as that grid allows rather than exactly.
"""
width, height = max(1, int(width)), max(1, int(height))
scale = (target_pixels / float(width * height)) ** 0.5
w = int(round(width * scale / multiple)) * multiple
h = int(round(height * scale / multiple)) * multiple
return max(multiple, w), max(multiple, h)
def _image_bytes(ref: str, field: str = "image") -> bytes:
"""The bytes behind any accepted image reference.
Accepts everything the compose endpoints accept -- a ``/v1/files/...`` path
or a ``sha256:`` id from ``/v1/files/upload``, a ``data:`` URI, an http(s)
URL, or raw base64 -- so a caller that uploaded the photo once can name it
on each edit instead of re-sending it. A file this install already holds is
read from disk, never fetched back over HTTP.
"""
if not ref:
raise HTTPException(status_code=400, detail=f"{field} is required")
from codai.compose.media import MediaError, fetch_bytes
try:
return fetch_bytes(ref)
except MediaError as e:
raise HTTPException(status_code=400, detail=f"{field}: {e}")
def _decode_b64_image(data: str, field: str = "image"):
"""Any accepted image reference → an RGB PIL image."""
from PIL import Image as PILImage from PIL import Image as PILImage
if data.startswith("data:"): return PILImage.open(io.BytesIO(_image_bytes(data, field))).convert("RGB")
_, encoded = data.split(",", 1)
raw = base64.b64decode(encoded)
else:
raw = base64.b64decode(data)
return PILImage.open(io.BytesIO(raw)).convert("RGB")
def _load_img2img_pipeline(model_name: str, global_args): def _load_img2img_pipeline(model_name: str, global_args):
...@@ -1844,6 +1879,11 @@ async def create_image_edit(request: ImageEditRequest, http_request: Request = N ...@@ -1844,6 +1879,11 @@ async def create_image_edit(request: ImageEditRequest, http_request: Request = N
source_img = source_img.resize((width, height)) source_img = source_img.resize((width, height))
except ValueError: except ValueError:
pass pass
else:
# No size asked for: keep the photo's own shape at ~1 MP.
width, height = _default_edit_size(*source_img.size)
if (width, height) != source_img.size:
source_img = source_img.resize((width, height))
seed = request.seed or getattr(global_args, 'image_seed', None) seed = request.seed or getattr(global_args, 'image_seed', None)
generator = torch.Generator(device=pipe.device).manual_seed(seed) if seed else None generator = torch.Generator(device=pipe.device).manual_seed(seed) if seed else None
...@@ -2204,9 +2244,29 @@ def _run_upscale(upscaler, image_bytes: bytes, scale: int): ...@@ -2204,9 +2244,29 @@ def _run_upscale(upscaler, image_bytes: bytes, scale: int):
return img.resize((w * scale, h * scale), PILImage.LANCZOS) return img.resize((w * scale, h * scale), PILImage.LANCZOS)
def _configured_upscalers() -> list:
"""Image models this install could actually upscale with."""
names = []
try:
from codai.models.capabilities import detect_model_capabilities
for m in multi_model_manager.image_models:
try:
if detect_model_capabilities(m).image_upscaling:
names.append(m)
except Exception:
continue
except Exception:
pass
return names
@router.post("/v1/images/upscale", summary="Upscale an image") @router.post("/v1/images/upscale", summary="Upscale an image")
async def create_image_upscale(request: ImageUpscaleRequest, http_request: Request = None): async def create_image_upscale(request: ImageUpscaleRequest, http_request: Request = None):
"""Upscale an image using Real-ESRGAN or PIL LANCZOS fallback.""" """Upscale an image with a real super-resolution model.
There is no PIL/LANCZOS fallback: a plain resize is not an upscale, and
returning one silently charged for work that did not happen.
"""
global global_args global global_args
_progress_loading(request.model or "image") _progress_loading(request.model or "image")
model_info = await asyncio.to_thread( model_info = await asyncio.to_thread(
...@@ -2221,7 +2281,19 @@ async def create_image_upscale(request: ImageUpscaleRequest, http_request: Reque ...@@ -2221,7 +2281,19 @@ async def create_image_upscale(request: ImageUpscaleRequest, http_request: Reque
raise HTTPException(status_code=500, detail=f"Failed to load upscaler: {e}") raise HTTPException(status_code=500, detail=f"Failed to load upscaler: {e}")
if upscaler[0] != 'pil': if upscaler[0] != 'pil':
_UPSCALER_CACHE[model_name] = upscaler _UPSCALER_CACHE[model_name] = upscaler
raw = base64.b64decode(request.image.split(',', 1)[-1] if ',' in request.image else request.image) if upscaler[0] == 'pil':
# _load_upscaler fell through to PIL LANCZOS, which is a plain resize:
# answering 200 here bills the caller for an "AI upscale" that never
# ran, and the result looks merely soft rather than wrong. Say so.
usable = _configured_upscalers()
raise HTTPException(
status_code=400,
detail=(f"'{model_name}' is not an upscaling model"
+ (f" — configured upscalers: {', '.join(usable)}"
if usable else
" — no upscaling model is configured; add a Real-ESRGAN "
"/ SwinIR / diffusers upscaler on the model page")))
raw = _image_bytes(request.image)
try: try:
out_img = await asyncio.get_event_loop().run_in_executor( out_img = await asyncio.get_event_loop().run_in_executor(
None, _run_upscale, upscaler, raw, request.scale or 4) None, _run_upscale, upscaler, raw, request.scale or 4)
...@@ -2492,7 +2564,7 @@ def _run_deblur(image_bytes: bytes, strength: float) -> "PILImage.Image": ...@@ -2492,7 +2564,7 @@ def _run_deblur(image_bytes: bytes, strength: float) -> "PILImage.Image":
@router.post("/v1/images/deblur", summary="Deblur an image") @router.post("/v1/images/deblur", summary="Deblur an image")
async def create_image_deblur(request: ImageDeblurRequest, http_request: Request = None): async def create_image_deblur(request: ImageDeblurRequest, http_request: Request = None):
"""Remove blur from an image using Wiener deconvolution and unsharp masking.""" """Remove blur from an image using Wiener deconvolution and unsharp masking."""
raw = base64.b64decode(request.image.split(',', 1)[-1] if ',' in request.image else request.image) raw = _image_bytes(request.image)
try: try:
result_img = await asyncio.get_event_loop().run_in_executor( result_img = await asyncio.get_event_loop().run_in_executor(
None, _run_deblur, raw, request.strength or 0.5) None, _run_deblur, raw, request.strength or 0.5)
...@@ -2550,7 +2622,7 @@ def _run_unpixelate(image_bytes: bytes, scale: int, model_path: Optional[str]) - ...@@ -2550,7 +2622,7 @@ def _run_unpixelate(image_bytes: bytes, scale: int, model_path: Optional[str]) -
@router.post("/v1/images/unpixelate", summary="Restore a pixelated image") @router.post("/v1/images/unpixelate", summary="Restore a pixelated image")
async def create_image_unpixelate(request: ImageUnpixelateRequest, http_request: Request = None): async def create_image_unpixelate(request: ImageUnpixelateRequest, http_request: Request = None):
"""Remove pixelation / upscale with detail recovery using Real-ESRGAN.""" """Remove pixelation / upscale with detail recovery using Real-ESRGAN."""
raw = base64.b64decode(request.image.split(',', 1)[-1] if ',' in request.image else request.image) raw = _image_bytes(request.image)
try: try:
result_img = await asyncio.get_event_loop().run_in_executor( result_img = await asyncio.get_event_loop().run_in_executor(
None, _run_unpixelate, raw, request.scale or 4, request.model) None, _run_unpixelate, raw, request.scale or 4, request.model)
......
...@@ -127,16 +127,22 @@ def _derive_device() -> str: ...@@ -127,16 +127,22 @@ def _derive_device() -> str:
def _decode_b64_or_url(data: str) -> bytes: def _decode_b64_or_url(data: str) -> bytes:
"""The bytes behind any accepted media reference.
Accepts everything the compose endpoints accept -- a ``/v1/files/...`` path
or a ``sha256:`` id from ``/v1/files/upload``, a ``data:`` URI, an http(s)
URL, or raw base64 -- so a caller that has already uploaded a file can name
it instead of re-sending the whole clip on every edit. A file this install
holds is read from disk rather than fetched over HTTP, which would need a
token and would fail whenever the front is bound somewhere else.
"""
if not data: if not data:
return b'' return b''
if data.startswith("data:"): from codai.compose.media import MediaError, fetch_bytes
_, enc = data.split(",", 1) try:
return base64.b64decode(enc) return fetch_bytes(data)
if data.startswith("http://") or data.startswith("https://"): except MediaError as e:
import urllib.request raise HTTPException(status_code=400, detail=str(e))
with urllib.request.urlopen(data, timeout=60) as r:
return r.read()
return base64.b64decode(data)
def _as_b64(ref: str) -> str: def _as_b64(ref: str) -> str:
...@@ -168,7 +174,8 @@ def _save_file(data: bytes, ext: str, http_request) -> dict: ...@@ -168,7 +174,8 @@ def _save_file(data: bytes, ext: str, http_request) -> dict:
out_path = os.path.join(global_file_path, filename) out_path = os.path.join(global_file_path, filename)
with open(out_path, 'wb') as f: with open(out_path, 'wb') as f:
f.write(data) f.write(data)
return {"url": _build_url(filename, http_request)} return {"url": _build_url(filename, http_request),
"path": f"/v1/files/{filename}"}
else: else:
return {f"b64_{ext}": base64.b64encode(data).decode()} return {f"b64_{ext}": base64.b64encode(data).decode()}
...@@ -208,6 +215,98 @@ def _encode_mp4_pyav(frames, fps: int, crf: int) -> bytes: ...@@ -208,6 +215,98 @@ def _encode_mp4_pyav(frames, fps: int, crf: int) -> bytes:
return data return data
def _has_audio(path: str) -> bool:
"""True when ``path`` carries at least one audio stream."""
try:
import av
with av.open(path) as c:
return bool(c.streams.audio)
except Exception:
return False
def _mux_original_audio(video_path: str, source_path: str,
temps: Optional[list] = None) -> str:
"""Put the source clip's audio back onto a re-encoded video.
The enhancement paths decode a clip to frames and encode a brand new video,
which leaves the audio behind: a clip that arrived with speech or music came
back silent, and only the ffmpeg fallback ever kept it. Both paths preserve
the source duration -- upscaling keeps the frame count and rate, and
interpolation compensates with ``output_fps`` -- so the track lines up as it
is, with no stretching.
The track is stream-copied when the output container accepts its codec and
re-encoded to AAC when it does not. Returns the new path, or ``video_path``
unchanged when the source has no audio or the remux fails: a silent result
is better than failing an enhancement that otherwise succeeded, and the
caller gets the frames it paid the GPU for either way.
"""
import logging
_log = logging.getLogger(__name__)
if not source_path or not os.path.exists(source_path):
return video_path
if not _has_audio(source_path):
return video_path
out = tempfile.mktemp(suffix='_aud.mp4')
try:
import av
with av.open(video_path) as vin, av.open(source_path) as ain, \
av.open(out, mode='w') as outc:
v_in = vin.streams.video[0]
a_in = ain.streams.audio[0]
v_out = outc.add_stream_from_template(v_in)
try:
a_out = outc.add_stream_from_template(a_in)
stream_copy = True
except Exception:
# The source codec cannot live in MP4 (opus/vorbis from a webm,
# say) -- re-encode rather than drop the track.
a_out = outc.add_stream('aac', rate=a_in.rate)
try:
a_out.bit_rate = 192000
except Exception:
pass
stream_copy = False
for pkt in vin.demux(v_in):
if pkt.dts is None:
continue
pkt.stream = v_out
outc.mux(pkt)
if stream_copy:
for pkt in ain.demux(a_in):
if pkt.dts is None:
continue
pkt.stream = a_out
outc.mux(pkt)
else:
# An encoder stream may not have its format/layout filled in
# until it is configured, so fall back to what AAC always takes.
_fmt = getattr(getattr(a_out, "format", None), "name", None) or "fltp"
_layout = getattr(getattr(a_out, "layout", None), "name", None) \
or getattr(getattr(a_in, "layout", None), "name", None) or "stereo"
resampler = av.AudioResampler(
format=_fmt, layout=_layout, rate=a_out.rate or a_in.rate)
for frame in ain.decode(a_in):
for rs in resampler.resample(frame):
rs.pts = None
for pkt in a_out.encode(rs):
outc.mux(pkt)
for pkt in a_out.encode():
outc.mux(pkt)
except Exception as e:
_log.warning("could not carry the source audio into the result: %s", e)
try:
if os.path.exists(out):
os.unlink(out)
except Exception:
pass
return video_path
if temps is not None:
temps.append(out)
return out
class _Mp4Writer: class _Mp4Writer:
"""Streaming H.264 MP4 writer via PyAV — add(frame) one at a time so a large """Streaming H.264 MP4 writer via PyAV — add(frame) one at a time so a large
(e.g. 4×-upscaled) sequence never has to be held fully in memory. In-process, (e.g. 4×-upscaled) sequence never has to be held fully in memory. In-process,
...@@ -2816,8 +2915,9 @@ def _model_upscale_video(model_name: str, in_path: str, factor: int, ...@@ -2816,8 +2915,9 @@ def _model_upscale_video(model_name: str, in_path: str, factor: int,
except Exception: except Exception:
_wait_until_safe = None _wait_until_safe = None
# Stream upscaled frames straight into the encoder so the (large, ×factor) # Stream upscaled frames straight into the encoder so the (large, ×factor)
# output never has to be fully resident in memory. Audio is not carried in the # output never has to be fully resident in memory. The source audio is muxed
# pure in-process path (the enhancement targets are silent clips). # back on afterwards -- the frames are re-encoded, so it cannot survive the
# write itself.
out = tempfile.mktemp(suffix='_up.mp4') out = tempfile.mktemp(suffix='_up.mp4')
temps.append(out) temps.append(out)
writer = _Mp4Writer(out, fps) writer = _Mp4Writer(out, fps)
...@@ -2847,7 +2947,7 @@ def _model_upscale_video(model_name: str, in_path: str, factor: int, ...@@ -2847,7 +2947,7 @@ def _model_upscale_video(model_name: str, in_path: str, factor: int,
finally: finally:
writer.close() writer.close()
_vid_progress_done() _vid_progress_done()
return out return _mux_original_audio(out, in_path, temps)
def _count_video_frames(path: str) -> int: def _count_video_frames(path: str) -> int:
...@@ -3022,7 +3122,7 @@ def _interpolate_inprocess(path, multiplier, model_name, temps, tid=None, output ...@@ -3022,7 +3122,7 @@ def _interpolate_inprocess(path, multiplier, model_name, temps, tid=None, output
temps.append(out_path) temps.append(out_path)
with open(out_path, "wb") as f: with open(out_path, "wb") as f:
f.write(data) f.write(data)
return out_path return _mux_original_audio(out_path, path, temps)
def _cpu_thread_limit() -> int: def _cpu_thread_limit() -> int:
...@@ -3383,7 +3483,8 @@ def _rife_ncnn_interpolate(path: str, multiplier: int, temps: list) -> str: ...@@ -3383,7 +3483,8 @@ def _rife_ncnn_interpolate(path: str, multiplier: int, temps: list) -> str:
raise RuntimeError( raise RuntimeError(
f"reassembly failed: {r.stderr.decode(errors='replace')}") f"reassembly failed: {r.stderr.decode(errors='replace')}")
task_registry.finish(_tid, "done") task_registry.finish(_tid, "done")
return out # Reassembled from PNG frames, so the source audio is not in `out` yet.
return _mux_original_audio(out, path, temps)
except TaskCancelled: except TaskCancelled:
task_registry.finish(_tid, "cancelled") task_registry.finish(_tid, "cancelled")
raise raise
......
...@@ -246,7 +246,12 @@ def fetch(ref: str, timeout: float = 120.0, max_bytes: int = 2 << 30) -> bytes: ...@@ -246,7 +246,12 @@ def fetch(ref: str, timeout: float = 120.0, max_bytes: int = 2 << 30) -> bytes:
# this machine. # this machine.
raise MediaError(f"{ref}: not a URL, a /v1/files path, or base64 data") raise MediaError(f"{ref}: not a URL, a /v1/files path, or base64 data")
try: try:
return base64.b64decode(ref, validate=True) # Whitespace is not part of base64 but is everywhere in practice: MIME
# wrapping at 76 columns, `base64` from a shell, Python's encodebytes.
# Strict validation is still what rejects a reference that is not base64
# at all -- it just must not reject a correctly encoded one for its line
# breaks, which is what every wrapped payload has.
return base64.b64decode("".join(ref.split()), validate=True)
except Exception: except Exception:
raise MediaError("media reference is not a URL, a /v1/files path, a data: URI or base64") raise MediaError("media reference is not a URL, a /v1/files path, a data: URI or base64")
......
"""Editing an uploaded photo or clip: keep the audio, keep the shape, name the file.
Four defects, all of which let a request succeed while quietly doing the wrong
thing -- the worst kind, because a caller cannot tell from the response.
THE AUDIO. The enhancement paths (upscale, frame interpolation) decode a clip to
frames and encode a brand new video, so the audio track was simply left behind:
a clip that arrived with speech came back silent, with a 200. Only the ffmpeg
`minterpolate` fallback -- the one path nobody takes by default -- kept it. Both
paths preserve the source duration, so the track lines up without stretching,
and the fix is a remux rather than a re-encode wherever the container allows it.
THE REFERENCES. These endpoints took base64 (images) or base64 and an http(s)
URL (video) only, so a caller that had already uploaded a file through
`/v1/files/upload` had to re-send the whole thing on every edit -- megabytes per
call, for a file sitting on the same disk. `/v1/compose` and the extractors
already accept a `/v1/files/...` path or a `sha256:` id, and these now resolve
through the same resolver, which also reads our own files off disk instead of
fetching them back over the loopback.
THE SHAPE. `size` defaulted to the literal string "1024x1024", so omitting it
resized every non-square photo to a square and visibly squashed it. Omitted now
means "keep the source's shape", at the ~1 MP a diffusion model wants.
THE SILENT NON-UPSCALE. An unrecognised upscaler model fell through to PIL
LANCZOS -- a plain resize -- and still answered 200, so the caller paid for an
"AI upscale" that never ran and got back something merely soft. That is now a
400 that names the models which would work.
"""
import base64
import io
import os
import sys
import types
import pytest
# ---------------------------------------------------------------- the refs
@pytest.fixture
def files_dir(tmp_path, monkeypatch):
"""An install whose /v1/files directory is ``tmp_path``."""
from codai.compose import media
monkeypatch.setattr(media, "global_file_path", str(tmp_path), raising=False)
return tmp_path
def _png_bytes(w=8, h=8, colour=(10, 120, 200)):
from PIL import Image
buf = io.BytesIO()
Image.new("RGB", (w, h), colour).save(buf, format="PNG")
return buf.getvalue()
@pytest.mark.parametrize("module,func", [
("codai.api.video", "_decode_b64_or_url"),
("codai.api.images", "_image_bytes"),
])
def test_a_v1_files_path_is_read_off_disk(files_dir, module, func):
"""The whole point: name the upload, do not re-send it."""
mod = __import__(module, fromlist=["x"])
data = _png_bytes()
(files_dir / "shot.png").write_bytes(data)
assert getattr(mod, func)("/v1/files/shot.png") == data
@pytest.mark.parametrize("module,func", [
("codai.api.video", "_decode_b64_or_url"),
("codai.api.images", "_image_bytes"),
])
def test_a_sha256_upload_id_resolves(files_dir, module, func):
"""What /v1/files/upload hands back, with and without the prefix."""
from codai.compose.media import UPLOAD_PREFIX, sha256_hex
mod = __import__(module, fromlist=["x"])
data = _png_bytes()
h = sha256_hex(data)
(files_dir / f"{UPLOAD_PREFIX}{h}.png").write_bytes(data)
assert getattr(mod, func)(f"sha256:{h}") == data
assert getattr(mod, func)(h) == data
@pytest.mark.parametrize("module,func", [
("codai.api.video", "_decode_b64_or_url"),
("codai.api.images", "_image_bytes"),
])
def test_an_absolute_url_to_our_own_file_is_still_read_off_disk(files_dir, module,
func):
"""A caller echoing back the `url` we gave it must not cause a loopback
fetch, which would need a token and fails when the front is bound elsewhere."""
mod = __import__(module, fromlist=["x"])
data = _png_bytes()
(files_dir / "clip.png").write_bytes(data)
assert getattr(mod, func)("https://example.invalid/v1/files/clip.png") == data
@pytest.mark.parametrize("module,func", [
("codai.api.video", "_decode_b64_or_url"),
("codai.api.images", "_image_bytes"),
])
def test_base64_and_data_uris_still_work(files_dir, module, func):
"""The old inputs keep working -- this is additive."""
mod = __import__(module, fromlist=["x"])
data = _png_bytes()
b64 = base64.b64encode(data).decode()
assert getattr(mod, func)(b64) == data
assert getattr(mod, func)(f"data:image/png;base64,{b64}") == data
@pytest.mark.parametrize("module,func", [
("codai.api.video", "_decode_b64_or_url"),
("codai.api.images", "_image_bytes"),
])
def test_line_wrapped_base64_is_still_accepted(files_dir, module, func):
"""Routing through the resolver must not tighten what callers may send.
The old decoders used a lenient b64decode; the resolver validates strictly,
which rejects the 76-column wrapping that MIME, the `base64` shell tool and
Python's encodebytes all produce. Whitespace carries no meaning in base64,
so it is stripped rather than refused.
"""
mod = __import__(module, fromlist=["x"])
data = _png_bytes(64, 64)
flat = base64.b64encode(data).decode()
wrapped = "\n".join(flat[i:i + 76] for i in range(0, len(flat), 76))
assert len(wrapped) > len(flat), "the fixture did not actually wrap"
assert getattr(mod, func)(wrapped) == data
assert getattr(mod, func)(f"data:image/png;base64,{wrapped}") == data
@pytest.mark.parametrize("module,func", [
("codai.api.video", "_decode_b64_or_url"),
("codai.api.images", "_image_bytes"),
])
def test_a_path_outside_the_files_dir_is_refused(files_dir, module, func):
"""A client may name our uploads, never arbitrary paths on this machine."""
from fastapi import HTTPException
mod = __import__(module, fromlist=["x"])
with pytest.raises(HTTPException) as e:
getattr(mod, func)("/v1/files/../../../../etc/passwd")
assert e.value.status_code == 400
@pytest.mark.parametrize("module,func", [
("codai.api.video", "_decode_b64_or_url"),
("codai.api.images", "_image_bytes"),
])
def test_an_unusable_reference_is_a_400_not_a_500(files_dir, module, func):
"""It is the caller's field that is wrong, so it must not read as our fault."""
from fastapi import HTTPException
mod = __import__(module, fromlist=["x"])
with pytest.raises(HTTPException) as e:
getattr(mod, func)("/home/someone/secret.mp4")
assert e.value.status_code == 400
def test_a_missing_upload_does_not_resolve_to_someone_elses_file(files_dir):
"""An id we do not hold is an error, never a different file."""
from fastapi import HTTPException
from codai.api.images import _image_bytes
with pytest.raises(HTTPException):
_image_bytes("sha256:" + "0" * 64)
# ------------------------------------------------------- the relative path
def test_image_responses_carry_the_relative_path(files_dir, monkeypatch):
"""So a caller can chain calls without knowing our public hostname."""
from PIL import Image
import codai.api.images as I
monkeypatch.setattr(I, "global_file_path", str(files_dir), raising=False)
out = I.save_image_response(Image.new("RGB", (4, 4)), "url", None)
assert out.get("path", "").startswith("/v1/files/")
assert out["path"].endswith(".png")
def test_video_responses_carry_the_relative_path(files_dir, monkeypatch):
import codai.api.video as V
monkeypatch.setattr(V, "global_file_path", str(files_dir), raising=False)
out = V._save_file(b"\x00\x01", "mp4", None)
assert out.get("path", "").startswith("/v1/files/")
assert out["path"].endswith(".mp4")
# ------------------------------------------------------------- the shape
def test_an_omitted_size_is_not_a_square():
"""The defect: the field's default was the string "1024x1024"."""
from codai.api.images import ImageEditRequest
assert ImageEditRequest.model_fields["size"].default is None
@pytest.mark.parametrize("w,h", [(3000, 2000), (2000, 3000), (1920, 1080),
(800, 600), (640, 480)])
def test_the_default_size_keeps_the_source_shape(w, h):
"""Aspect preserved as closely as a 64-grid allows, and never flipped."""
from codai.api.images import _default_edit_size
ow, oh = _default_edit_size(w, h)
assert (ow > oh) == (w > h), "the orientation was flipped"
assert abs((ow / oh) - (w / h)) < 0.08, f"{w}x{h} -> {ow}x{oh} distorts"
@pytest.mark.parametrize("w,h", [(3000, 2000), (1920, 1080), (800, 600),
(1024, 1024), (5000, 400)])
def test_the_default_size_lands_on_the_models_grid_near_one_megapixel(w, h):
from codai.api.images import _default_edit_size
ow, oh = _default_edit_size(w, h)
assert ow % 64 == 0 and oh % 64 == 0, f"{ow}x{oh} is off the 64 grid"
assert ow >= 64 and oh >= 64
assert 0.5e6 <= ow * oh <= 1.8e6, f"{ow}x{oh} = {ow * oh} pixels"
def test_a_square_source_still_comes_back_square():
from codai.api.images import _default_edit_size
assert _default_edit_size(1024, 1024) == (1024, 1024)
def test_an_explicit_size_is_still_obeyed_exactly():
"""Callers that send their own size must not be second-guessed."""
from codai.api.images import _default_edit_size
# the helper is only consulted when `size` is absent; a sanity check that it
# is not rounding a caller's own numbers
assert _default_edit_size(10, 10) == (1024, 1024)
# -------------------------------------------------- the silent non-upscale
def test_an_unknown_upscaler_is_refused(monkeypatch):
"""The defect: PIL LANCZOS answered 200 and billed as an AI upscale."""
import asyncio
from fastapi import HTTPException
import codai.api.images as I
monkeypatch.setattr(I.multi_model_manager, "request_model",
lambda name, model_type=None: {"model_name": "some/clip-model"},
raising=False)
monkeypatch.setattr(I, "_load_upscaler", lambda n, a: ("pil", None))
monkeypatch.setattr(I, "_configured_upscalers", lambda: ["org/RealESRGAN-x4"])
monkeypatch.setattr(I, "_progress_loading", lambda *a, **k: None)
req = I.ImageUpscaleRequest(model="some/clip-model",
image=base64.b64encode(_png_bytes()).decode())
with pytest.raises(HTTPException) as e:
asyncio.run(I.create_image_upscale(req, None))
assert e.value.status_code == 400
assert "not an upscaling model" in str(e.value.detail)
assert "RealESRGAN" in str(e.value.detail), "it must say what WOULD work"
def test_the_refusal_says_when_nothing_is_configured(monkeypatch):
import asyncio
from fastapi import HTTPException
import codai.api.images as I
monkeypatch.setattr(I.multi_model_manager, "request_model",
lambda name, model_type=None: {"model_name": "x/y"},
raising=False)
monkeypatch.setattr(I, "_load_upscaler", lambda n, a: ("pil", None))
monkeypatch.setattr(I, "_configured_upscalers", lambda: [])
monkeypatch.setattr(I, "_progress_loading", lambda *a, **k: None)
req = I.ImageUpscaleRequest(model="x/y",
image=base64.b64encode(_png_bytes()).decode())
with pytest.raises(HTTPException) as e:
asyncio.run(I.create_image_upscale(req, None))
assert "no upscaling model is configured" in str(e.value.detail)
def test_a_real_upscaler_is_not_refused(monkeypatch):
"""The guard must only reject the PIL fall-through, nothing else."""
import asyncio
import codai.api.images as I
from PIL import Image
monkeypatch.setattr(I.multi_model_manager, "request_model",
lambda name, model_type=None: {"model_name": "org/esrgan"},
raising=False)
monkeypatch.setattr(I, "_load_upscaler", lambda n, a: ("realesrgan", object()))
monkeypatch.setattr(I, "_run_upscale",
lambda up, raw, scale: Image.new("RGB", (16, 16)))
monkeypatch.setattr(I, "_progress_loading", lambda *a, **k: None)
monkeypatch.setattr(I, "save_image_response",
lambda img, fmt, req: {"url": "u", "path": "/v1/files/x.png"})
req = I.ImageUpscaleRequest(model="org/esrgan",
image=base64.b64encode(_png_bytes()).decode())
out = asyncio.run(I.create_image_upscale(req, None))
assert out["data"][0]["path"] == "/v1/files/x.png"
def test_configured_upscalers_lists_only_upscaling_models(monkeypatch):
import codai.api.images as I
monkeypatch.setattr(I.multi_model_manager, "image_models",
["org/esrgan-x4", "org/sdxl-base"], raising=False)
def _caps(name):
return types.SimpleNamespace(image_upscaling="esrgan" in name)
monkeypatch.setitem(
sys.modules, "codai.models.capabilities",
types.SimpleNamespace(detect_model_capabilities=_caps))
assert I._configured_upscalers() == ["org/esrgan-x4"]
# ---------------------------------------------------------------- the audio
class _Pkt:
def __init__(self, dts=0):
self.dts = dts
self.stream = None
class _Stream:
def __init__(self, kind, rate=48000):
self.kind = kind
self.rate = rate
self.bit_rate = None
self.format = types.SimpleNamespace(name="fltp")
self.layout = types.SimpleNamespace(name="stereo")
def encode(self, frame=None):
return [_Pkt()]
class _InContainer:
def __init__(self, video=(), audio=(), packets=3):
self.streams = types.SimpleNamespace(video=list(video), audio=list(audio))
self._packets = packets
def __enter__(self):
return self
def __exit__(self, *a):
return False
def demux(self, stream):
for i in range(self._packets):
yield _Pkt(dts=i)
yield _Pkt(dts=None) # a packet the muxer must skip
def decode(self, stream):
for _ in range(self._packets):
yield types.SimpleNamespace(pts=0)
class _OutContainer:
def __init__(self, template_audio_fails=False):
self.muxed = 0
self.added = []
self._template_audio_fails = template_audio_fails
def __enter__(self):
return self
def __exit__(self, *a):
return False
def add_stream_from_template(self, s):
if s.kind == "audio" and self._template_audio_fails:
raise ValueError("codec not supported in this container")
self.added.append(("template:" + s.kind, None))
return _Stream(s.kind)
def add_stream(self, name, rate=None):
self.added.append((name, rate))
return _Stream("audio", rate=rate or 48000)
def mux(self, pkt):
self.muxed += 1
def _install_fake_av(monkeypatch, *, source_has_audio=True,
template_audio_fails=False, out_holder=None,
open_raises=False):
"""A stand-in for PyAV: `av` is not installed in the test environment, and
the point of these tests is the muxing decisions, not libav itself."""
def _open(path, mode="r", **kw):
if open_raises:
raise RuntimeError("libav exploded")
if mode == "w":
out = _OutContainer(template_audio_fails=template_audio_fails)
if out_holder is not None:
out_holder.append(out)
return out
audio = [_Stream("audio")] if source_has_audio else []
if str(path).endswith("_src.mp4"):
return _InContainer(video=[_Stream("video")], audio=audio)
# the freshly encoded, silent result
return _InContainer(video=[_Stream("video")], audio=[])
class _Resampler:
def __init__(self, **kw):
pass
def resample(self, frame):
return [frame]
fake = types.SimpleNamespace(open=_open, AudioResampler=_Resampler)
monkeypatch.setitem(sys.modules, "av", fake)
return fake
@pytest.fixture
def clips(tmp_path):
src = tmp_path / "in_src.mp4"
vid = tmp_path / "out.mp4"
src.write_bytes(b"source")
vid.write_bytes(b"silent-result")
return str(src), str(vid)
def test_the_source_audio_is_carried_onto_the_result(monkeypatch, clips):
"""The defect: upscale and interpolation returned a silent clip, with a 200."""
import codai.api.video as V
src, vid = clips
held = []
_install_fake_av(monkeypatch, source_has_audio=True, out_holder=held)
temps = []
out = V._mux_original_audio(vid, src, temps)
assert out != vid, "the result is still the silent encode"
assert held and held[0].muxed > 0, "nothing was muxed"
assert any(a[0] == "template:audio" for a in held[0].added), \
"the audio track was not copied"
assert out in temps, "the new file must be cleaned up with the others"
def test_the_audio_is_stream_copied_not_re_encoded(monkeypatch, clips):
"""Re-encoding a track that could be copied loses quality for nothing."""
import codai.api.video as V
src, vid = clips
held = []
_install_fake_av(monkeypatch, out_holder=held)
V._mux_original_audio(vid, src, [])
assert ("aac", None) not in held[0].added
assert any(a[0] == "template:audio" for a in held[0].added)
def test_an_incompatible_codec_is_re_encoded_rather_than_dropped(monkeypatch, clips):
"""opus/vorbis out of a webm cannot sit in MP4 -- re-encode, do not drop."""
import codai.api.video as V
src, vid = clips
held = []
_install_fake_av(monkeypatch, template_audio_fails=True, out_holder=held)
out = V._mux_original_audio(vid, src, [])
assert out != vid, "the track was dropped instead of re-encoded"
assert any(a[0] == "aac" for a in held[0].added), "no AAC encoder was set up"
def test_a_silent_source_produces_a_silent_result_untouched(monkeypatch, clips):
"""No audio in means no audio out, and no pointless remux."""
import codai.api.video as V
src, vid = clips
held = []
_install_fake_av(monkeypatch, source_has_audio=False, out_holder=held)
assert V._mux_original_audio(vid, src, []) == vid
assert not held, "a container was opened for write with nothing to mux"
def test_a_broken_remux_keeps_the_enhanced_frames(monkeypatch, clips):
"""Losing the track is bad; throwing away a finished GPU job is worse."""
import codai.api.video as V
src, vid = clips
_install_fake_av(monkeypatch, open_raises=True)
assert V._mux_original_audio(vid, src, []) == vid
def test_a_missing_source_is_not_an_error(monkeypatch, tmp_path):
"""The two-image interpolation path has no source clip at all."""
import codai.api.video as V
vid = tmp_path / "out.mp4"
vid.write_bytes(b"x")
_install_fake_av(monkeypatch)
assert V._mux_original_audio(str(vid), "", []) == str(vid)
assert V._mux_original_audio(str(vid), str(tmp_path / "nope.mp4"), []) == str(vid)
def test_packets_without_timestamps_are_skipped(monkeypatch, clips):
"""A DTS-less packet makes libav refuse the whole mux."""
import codai.api.video as V
src, vid = clips
held = []
_install_fake_av(monkeypatch, out_holder=held)
V._mux_original_audio(vid, src, [])
# 3 video + 3 audio packets carry a DTS; the two trailing None ones do not
assert held[0].muxed == 6, f"muxed {held[0].muxed} packets, expected 6"
# ------------------------------------------- the enhancement paths use it
@pytest.mark.parametrize("func", ["_model_upscale_video",
"_interpolate_inprocess",
"_rife_ncnn_interpolate"])
def test_every_enhancement_path_muxes_the_audio_back(func):
"""A path that forgets the call is a path that silently drops the track."""
import inspect
import codai.api.video as V
src = inspect.getsource(getattr(V, func))
assert "_mux_original_audio" in src, f"{func} does not restore the audio"
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