Completes the pro-output story: an Export-format selector (WAV / MP3) on the Stories toolbar. WAV stays fully client-side; MP3 routes the client-stitched WAV through a new backend POST /stories/encode (ffmpeg via the Windows-reload- safe spawn_subprocess, #175), with a strict format whitelist (mp3/m4b/ogg) and bitrate validation so the uploaded format can't inject ffmpeg args. Both the audiobook and per-character stems honor the selected format; MP3 falls back to WAV with a toast if ffmpeg is unavailable. - backend/api/routers/stories.py + main.py registration; temp-file cleanup. - frontend/src/api/stories.ts encodeAudio() (apiFetch: same-origin + PIN). - tests: format-whitelist 400, ffmpeg-missing 501, real mp3 encode (skips if ffmpeg absent). Backend 26 incl. router smoke; frontend 152/152, typecheck/ build/CJK ✓. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
57 lines
1.6 KiB
Python
57 lines
1.6 KiB
Python
"""Stories audio-encode endpoint (/stories/encode)."""
|
|
from __future__ import annotations
|
|
|
|
import io
|
|
import struct
|
|
import wave
|
|
|
|
import pytest
|
|
from fastapi import FastAPI
|
|
from fastapi.testclient import TestClient
|
|
|
|
from api.routers import stories
|
|
from services.ffmpeg_utils import find_ffmpeg
|
|
|
|
|
|
def _app():
|
|
app = FastAPI()
|
|
app.include_router(stories.router)
|
|
return app
|
|
|
|
|
|
def _wav_bytes(sec=0.1, sr=8000):
|
|
buf = io.BytesIO()
|
|
with wave.open(buf, "wb") as w:
|
|
w.setnchannels(1)
|
|
w.setsampwidth(2)
|
|
w.setframerate(sr)
|
|
w.writeframes(b"".join(struct.pack("<h", 0) for _ in range(int(sr * sec))))
|
|
return buf.getvalue()
|
|
|
|
|
|
def test_rejects_unknown_format():
|
|
c = TestClient(_app())
|
|
r = c.post("/stories/encode", files={"file": ("s.wav", _wav_bytes(), "audio/wav")}, data={"format": "exe"})
|
|
assert r.status_code == 400
|
|
|
|
|
|
def test_501_when_ffmpeg_missing(monkeypatch):
|
|
monkeypatch.setattr(stories, "find_ffmpeg", lambda: None)
|
|
c = TestClient(_app())
|
|
r = c.post("/stories/encode", files={"file": ("s.wav", _wav_bytes(), "audio/wav")}, data={"format": "mp3"})
|
|
assert r.status_code == 501
|
|
|
|
|
|
def test_encodes_mp3_when_ffmpeg_present():
|
|
if not find_ffmpeg():
|
|
pytest.skip("ffmpeg not available in this environment")
|
|
c = TestClient(_app())
|
|
r = c.post(
|
|
"/stories/encode",
|
|
files={"file": ("s.wav", _wav_bytes(0.2), "audio/wav")},
|
|
data={"format": "mp3", "bitrate": "96k"},
|
|
)
|
|
assert r.status_code == 200
|
|
assert r.headers["content-type"] == "audio/mpeg"
|
|
assert len(r.content) > 0
|