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
38 changes: 28 additions & 10 deletions app/_launch_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,12 @@
from routers.lan_auth import create_lan_auth_router
from services.durable_generation_queue import DurableGenerationQueue
from services.lan_auth import LanAuthMiddleware, describe_lan_auth_startup
from services.media_paths import MediaPathNotAllowed, resolve_permitted_media_path, resolve_voice_ref_paths
from services.media_paths import (
MediaPathNotAllowed,
resolve_permitted_media_path,
resolve_story_cover_audio,
resolve_voice_ref_paths,
)
from services.upload_stream import (
UploadTooLargeError,
UploadTranscodeError,
Expand Down Expand Up @@ -1089,6 +1094,19 @@ def _resolve_request_media_path(
raise HTTPException(status_code=404, detail="Media file not found") from None


def _story_cover_reference_path(filename: str, workspace: str) -> str | None:
"""Resolve a cover reference from uploads/audio or the active workspace."""
try:
return resolve_story_cover_audio(
filename,
uploads_audio_root=os.path.join(os.getcwd(), "uploads", "audio"),
uploads_root=os.path.join(os.getcwd(), "uploads"),
workspace_root=_workspace_dir(workspace),
)
except (MediaPathNotAllowed, FileNotFoundError, ValueError):
return None


def _workspace_file_count(path: str) -> int:
"""Non-hidden files directly inside a workspace folder (for delete
confirms). scandir answers is_file() from the enumeration data on
Expand Down Expand Up @@ -31861,12 +31879,11 @@ def start_story_music_candidates_job(body: dict):
raise HTTPException(status_code=400, detail="Lyrics are required for a vocal song")
reference_audio_path = None
if model in minimax_music_service.COVER_MODELS:
reference_name = os.path.basename(
str(body.get("reference_audio_filename") or "").strip()
reference_audio_path = _story_cover_reference_path(
str(body.get("reference_audio_filename") or ""),
workspace,
)
upload_root = os.path.realpath(os.path.join(os.getcwd(), "uploads", "audio"))
reference_audio_path = _safe_join(upload_root, reference_name) if reference_name else None
if not reference_audio_path or not os.path.isfile(reference_audio_path):
if not reference_audio_path:
raise HTTPException(
status_code=400,
detail="Upload a valid reference song before generating a cover",
Expand Down Expand Up @@ -32016,10 +32033,11 @@ async def generate_story_music_candidates(body: dict):
model = str(body.get("model") or "music-3.0").strip()
reference_audio_path = None
if model in {"music-cover", "music-cover-free"}:
reference_name = os.path.basename(str(body.get("reference_audio_filename") or "").strip())
upload_root = os.path.realpath(os.path.join(os.getcwd(), "uploads", "audio"))
reference_audio_path = _safe_join(upload_root, reference_name) if reference_name else None
if not reference_audio_path or not os.path.isfile(reference_audio_path):
reference_audio_path = _story_cover_reference_path(
str(body.get("reference_audio_filename") or ""),
workspace,
)
if not reference_audio_path:
raise HTTPException(status_code=400, detail="Upload a valid reference song before generating a cover")
try:
candidates = await asyncio.to_thread(
Expand Down
35 changes: 35 additions & 0 deletions app/services/media_paths.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,3 +131,38 @@ def resolve_voice_ref_paths(
continue
return resolved


def resolve_story_cover_audio(
filename: str,
*,
uploads_audio_root: str,
uploads_root: str,
workspace_root: str,
) -> str:
"""Resolve a Story cover reference from uploads/audio or the workspace.

Cover jobs used to look only in uploads/audio/. The shared picker can also
bind a catalog track that already lives in the workspace, so fall back to
the confined resolver when that basename is not an upload.
"""
name = os.path.basename(str(filename or "").strip())
if not name or "\x00" in name:
raise FileNotFoundError("Permitted media file was not found")

audio_root = os.path.realpath(os.path.abspath(uploads_audio_root))
uploaded = os.path.realpath(os.path.abspath(os.path.join(audio_root, name)))
if (
_is_contained(uploaded, audio_root)
and uploaded != audio_root
and os.path.splitext(uploaded)[1].lower() in _KIND_EXTENSIONS["audio"]
and os.path.isfile(uploaded)
):
return uploaded

return resolve_permitted_media_path(
name,
uploads_root=uploads_root,
workspace_root=workspace_root,
kinds=("audio",),
)

52 changes: 52 additions & 0 deletions tests/test_media_path_security.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from services.media_paths import (
MediaPathNotAllowed,
resolve_permitted_media_path,
resolve_story_cover_audio,
resolve_voice_ref_paths,
)

Expand Down Expand Up @@ -106,6 +107,38 @@ def test_permitted_media_path_resolves_audio_upload_subdir(tmp_path):
)


def test_story_cover_prefers_audio_upload_then_workspace_basename(tmp_path):
uploads = tmp_path / "uploads"
workspace = tmp_path / "workspace"
audio = uploads / "audio"
audio.mkdir(parents=True)
workspace.mkdir()
uploaded = audio / "ref.wav"
catalog = workspace / "theme.mp3"
uploaded.write_bytes(b"RIFF")
catalog.write_bytes(b"ID3")

assert resolve_story_cover_audio(
"ref.wav",
uploads_audio_root=str(audio),
uploads_root=str(uploads),
workspace_root=str(workspace),
) == str(uploaded.resolve())
assert resolve_story_cover_audio(
"theme.mp3",
uploads_audio_root=str(audio),
uploads_root=str(uploads),
workspace_root=str(workspace),
) == str(catalog.resolve())
with pytest.raises(FileNotFoundError):
resolve_story_cover_audio(
"missing.mp3",
uploads_audio_root=str(audio),
uploads_root=str(uploads),
workspace_root=str(workspace),
)


def test_resolve_voice_ref_paths_keeps_audio_subdir_and_workspace_names(tmp_path):
uploads = tmp_path / "uploads"
workspace = tmp_path / "workspace"
Expand Down Expand Up @@ -160,6 +193,25 @@ def test_audio_trim_and_analysis_endpoints_use_the_shared_resolver():
assert "_resolve_request_media_path" in calls, name


def test_story_cover_jobs_use_the_shared_cover_resolver():
tree = ast.parse(LAUNCH.read_text(encoding="utf-8"), filename=str(LAUNCH))
functions = {
node.name: node
for node in tree.body
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
}
for name in (
"start_story_music_candidates_job",
"generate_story_music_candidates",
):
calls = [
node.func.id
for node in ast.walk(functions[name])
if isinstance(node, ast.Call) and isinstance(node.func, ast.Name)
]
assert "_story_cover_reference_path" in calls, name


def test_generation_resolves_voice_clone_refs_before_seedvc():
tree = ast.parse(LAUNCH.read_text(encoding="utf-8"), filename=str(LAUNCH))
run = next(
Expand Down
11 changes: 11 additions & 0 deletions ui/src/features/stories/storyAudioPick.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,18 @@ export function isAudioOutput(item: ApiOutput | null): item is ApiOutput {
return /\.(mp3|wav|flac|ogg|m4a|aac)$/i.test(item.name || item.path || '')
}

export function isUploadedAudioOutput(item: ApiOutput): boolean {
const url = typeof item.url === 'string' ? item.url : ''
const path = typeof item.path === 'string' ? item.path.replace(/\\/g, '/') : ''
return /\/api\/v1\/uploads\/audio\//i.test(url) || /(^|\/)uploads\/audio\//i.test(path)
}

export function isCustomMp3Output(item: ApiOutput): boolean {
// Device picks go through /upload-audio, which transcodes mp3/m4a/aac to wav
// and returns that artifact. Rejecting the wav would make "Import custom MP3"
// from disk fail after a successful upload. Catalog rows still need an mp3
// name or URL so a library wav/flac cannot be labeled custom MP3.
if (isUploadedAudioOutput(item)) return true
const filename = audioBindingFilename(item)
return /\.mp3$/i.test(filename) || /\.mp3(\?|$)/i.test(item.url || '')
}
Expand Down
15 changes: 15 additions & 0 deletions ui/tests/storyAudioPick.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
coverPatchFromOutput,
cueCandidateFromOutput,
isCustomMp3Output,
isUploadedAudioOutput,
} from '../src/features/stories/storyAudioPick.ts'

const live = { projectId: 'story-1', cueId: 'cue-1' }
Expand Down Expand Up @@ -58,6 +59,20 @@ test('stale project or cue is ignored; wav is rejected as custom mp3', () => {
assert.equal(applied.action, 'apply')
})

test('device mp3 import still applies after upload-audio transcodes to wav', () => {
const uploaded = {
...audio,
name: 'a1b2c3d4.wav',
path: '/app/uploads/audio/a1b2c3d4.wav',
url: '/api/v1/uploads/audio/a1b2c3d4.wav',
}
assert.equal(isUploadedAudioOutput(uploaded), true)
assert.equal(isCustomMp3Output(uploaded), true)
const applied = commitStoryAudioChoice(live, live, uploaded, true)
assert.equal(applied.action, 'apply')
assert.equal(isCustomMp3Output({ ...audio, name: 'take.wav', path: 'outputs/take.wav', url: '/api/v1/file/take.wav?workspace=film' }), false)
})

test('audioBindingFilename prefers the path basename', () => {
assert.equal(audioBindingFilename({ ...audio, path: 'workspace/nested/hook.mp3', name: 'Hook.mp3' }), 'hook.mp3')
})