diff --git a/app/_launch_runtime.py b/app/_launch_runtime.py index 7e4d0b0df..f50ed4435 100644 --- a/app/_launch_runtime.py +++ b/app/_launch_runtime.py @@ -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, @@ -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 @@ -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", @@ -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( diff --git a/app/services/media_paths.py b/app/services/media_paths.py index 1313c3a99..277fb8a3a 100644 --- a/app/services/media_paths.py +++ b/app/services/media_paths.py @@ -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",), + ) + diff --git a/tests/test_media_path_security.py b/tests/test_media_path_security.py index ddb18a886..640341e8e 100644 --- a/tests/test_media_path_security.py +++ b/tests/test_media_path_security.py @@ -6,6 +6,7 @@ from services.media_paths import ( MediaPathNotAllowed, resolve_permitted_media_path, + resolve_story_cover_audio, resolve_voice_ref_paths, ) @@ -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" @@ -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( diff --git a/ui/src/features/stories/storyAudioPick.ts b/ui/src/features/stories/storyAudioPick.ts index ab5d6eaa7..582bb4a40 100644 --- a/ui/src/features/stories/storyAudioPick.ts +++ b/ui/src/features/stories/storyAudioPick.ts @@ -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 || '') } diff --git a/ui/tests/storyAudioPick.test.mjs b/ui/tests/storyAudioPick.test.mjs index 473292de8..5c9fc111d 100644 --- a/ui/tests/storyAudioPick.test.mjs +++ b/ui/tests/storyAudioPick.test.mjs @@ -6,6 +6,7 @@ import { coverPatchFromOutput, cueCandidateFromOutput, isCustomMp3Output, + isUploadedAudioOutput, } from '../src/features/stories/storyAudioPick.ts' const live = { projectId: 'story-1', cueId: 'cue-1' } @@ -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') })