diff --git a/app/_launch_runtime.py b/app/_launch_runtime.py index 23f0650f..775d4813 100644 --- a/app/_launch_runtime.py +++ b/app/_launch_runtime.py @@ -126,7 +126,7 @@ 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 +from services.media_paths import MediaPathNotAllowed, resolve_permitted_media_path, resolve_voice_ref_paths from services.upload_stream import ( UploadTooLargeError, UploadTranscodeError, @@ -23812,7 +23812,11 @@ def _legacy_h3_progress( # Pop them out of raw_params so they don't leak into the # generation handler (other handlers don't understand them). pp_voice_clone_enabled = bool(raw_params.pop("voice_clone_enabled", False)) - pp_voice_clone_refs = raw_params.pop("voice_clone_refs", None) or [] + pp_voice_clone_refs = resolve_voice_ref_paths( + raw_params.pop("voice_clone_refs", None) or [], + uploads_root=os.path.join(os.getcwd(), "uploads"), + workspace_root=_workspace_dir(job.get("workspace")), + ) pp_voice_clone_mode = raw_params.pop("voice_clone_mode", "single") defer_output_publication = bool( diff --git a/app/services/media_paths.py b/app/services/media_paths.py index dc720873..1313c3a9 100644 --- a/app/services/media_paths.py +++ b/app/services/media_paths.py @@ -100,3 +100,34 @@ def resolve_permitted_media_path( return candidate raise FileNotFoundError("Permitted media file was not found") + +def resolve_voice_ref_paths( + refs: Iterable[str], + *, + uploads_root: str, + workspace_root: str, +) -> list[str]: + """Resolve SeedVC voice refs to real files under uploads or the workspace. + + Upload-audio stores files in uploads/audio/; the UI must send that + subfolder (or an absolute path). Bare filenames only match a workspace + file or a file sitting directly in uploads/. Unresolvable entries are + dropped so a stale ref cannot crash generation. + """ + resolved: list[str] = [] + for value in refs: + if not isinstance(value, str) or not value.strip() or "\x00" in value: + continue + try: + resolved.append( + resolve_permitted_media_path( + value, + uploads_root=uploads_root, + workspace_root=workspace_root, + kinds=("audio", "video"), + ) + ) + except (MediaPathNotAllowed, FileNotFoundError, ValueError): + continue + return resolved + diff --git a/tests/test_media_path_security.py b/tests/test_media_path_security.py index 6e73d047..ddb18a88 100644 --- a/tests/test_media_path_security.py +++ b/tests/test_media_path_security.py @@ -3,7 +3,11 @@ import pytest -from services.media_paths import MediaPathNotAllowed, resolve_permitted_media_path +from services.media_paths import ( + MediaPathNotAllowed, + resolve_permitted_media_path, + resolve_voice_ref_paths, +) ROOT = Path(__file__).parents[1] @@ -78,6 +82,48 @@ def test_permitted_media_path_rejects_a_symlink_escape(tmp_path): ) +def test_permitted_media_path_resolves_audio_upload_subdir(tmp_path): + uploads = tmp_path / "uploads" + workspace = tmp_path / "workspace" + audio = uploads / "audio" + audio.mkdir(parents=True) + workspace.mkdir() + sample = audio / "9f2.wav" + sample.write_bytes(b"RIFF") + + assert resolve_permitted_media_path( + "audio/9f2.wav", + uploads_root=str(uploads), + workspace_root=str(workspace), + kinds=("audio",), + ) == str(sample.resolve()) + with pytest.raises(FileNotFoundError): + resolve_permitted_media_path( + "9f2.wav", + uploads_root=str(uploads), + workspace_root=str(workspace), + kinds=("audio",), + ) + + +def test_resolve_voice_ref_paths_keeps_audio_subdir_and_workspace_names(tmp_path): + uploads = tmp_path / "uploads" + workspace = tmp_path / "workspace" + audio = uploads / "audio" + audio.mkdir(parents=True) + workspace.mkdir() + uploaded = audio / "9f2.wav" + clip = workspace / "hero.wav" + uploaded.write_bytes(b"RIFF") + clip.write_bytes(b"RIFF") + + assert resolve_voice_ref_paths( + ["audio/9f2.wav", "hero.wav", "missing.wav", "9f2.wav"], + uploads_root=str(uploads), + workspace_root=str(workspace), + ) == [str(uploaded.resolve()), str(clip.resolve())] + + def test_permitted_media_path_distinguishes_missing_from_forbidden(tmp_path): uploads = tmp_path / "uploads" workspace = tmp_path / "workspace" @@ -114,6 +160,21 @@ def test_audio_trim_and_analysis_endpoints_use_the_shared_resolver(): assert "_resolve_request_media_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( + node for node in tree.body + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) + and node.name == "_run_generation" + ) + calls = [ + node.func.id + for node in ast.walk(run) + if isinstance(node, ast.Call) and isinstance(node.func, ast.Name) + ] + assert "resolve_voice_ref_paths" in calls + + def test_adopting_audio_is_confined_to_audio_the_uploader_would_not_transcode(): """Adoption takes a file in place, so it may only accept what the rest of the pipeline can already open. The uploader transcodes mp3/m4a/aac to PCM diff --git a/ui/src/components/Sidebar/PostProcessing.tsx b/ui/src/components/Sidebar/PostProcessing.tsx index 70a56631..0f690c21 100644 --- a/ui/src/components/Sidebar/PostProcessing.tsx +++ b/ui/src/components/Sidebar/PostProcessing.tsx @@ -4,7 +4,7 @@ import { useStore } from '../../stores/useStore' import { useUiTranslation } from '../../i18n' import * as api from '../../api/client' import type { ApiOutput } from '../../api/outputs' -import { catalogItemToOutput } from '../../features/asset-picker' +import { catalogItemToOutput, voiceRefFromOutput } from '../../features/asset-picker' import { AssetInput } from '../../features/asset-picker/AssetInput.tsx' const baseOptions = [ @@ -221,7 +221,7 @@ export function PostProcessing() { accept="audio/*,video/*" optional constraints={{ kinds: ['audio', 'video'], maxCount: 1, optional: true }} - onChoose={item => setVoiceCloneRef(idx, item ? { filename: item.name, path: item.name } : null)} + onChoose={item => setVoiceCloneRef(idx, item ? voiceRefFromOutput(item, activeWorkspace) : null)} /> {ref?.path && (
diff --git a/ui/src/components/Sidebar/ToolsPanel.tsx b/ui/src/components/Sidebar/ToolsPanel.tsx index c8aa4f6b..ce0d46e0 100644 --- a/ui/src/components/Sidebar/ToolsPanel.tsx +++ b/ui/src/components/Sidebar/ToolsPanel.tsx @@ -5,7 +5,7 @@ import { useUiTranslation } from '../../i18n' import * as api from '../../api/client' import type { AssetCatalogItem } from '../../api/assets' import type { ApiOutput } from '../../api/outputs' -import { catalogItemToOutput, matchCatalogByOutput } from '../../features/asset-picker' +import { catalogItemToOutput, matchCatalogByOutput, voiceRefFromOutput } from '../../features/asset-picker' import { ToolsParamsPanel } from './ToolsParamsPanel' import { ToolsSourcePanel, type ToolSource } from './ToolsSourcePanel' @@ -110,7 +110,7 @@ export function ToolsPanel() { setRevoiceRef(index, null) return } - setRevoiceRef(index, { filename: item.name, path: item.name }) + setRevoiceRef(index, voiceRefFromOutput(item, activeWorkspace)) } const hasRefs = revoiceRefs.some(r => r && r.path) diff --git a/ui/src/features/asset-picker/adapters.ts b/ui/src/features/asset-picker/adapters.ts index 787e6c8d..5d38204a 100644 --- a/ui/src/features/asset-picker/adapters.ts +++ b/ui/src/features/asset-picker/adapters.ts @@ -1,4 +1,4 @@ -import type { ApiOutput } from '../../api/outputs' +import { getServerMediaReference, type ApiOutput } from '../../api/outputs' import type { AssetCatalogItem, AssetKind } from '../../api/assets' import { displayAssetTitle, knownCreatedAt } from './titles.ts' import type { AssetConstraints, AssetRef, Compatibility, LegacyOutputRef, PickerItem } from './types.ts' @@ -85,6 +85,11 @@ export function matchCatalogByOutput( }) } +export function voiceRefFromOutput(item: ApiOutput, workspaceId?: string): { filename: string; path: string } { + const ref = getServerMediaReference(item.url, item.name, workspaceId) + return { filename: item.name, path: ref?.audio_path || item.name } +} + export function outputToPickerItem(item: ApiOutput, workspaceId: string): PickerItem { const kind = OUTPUT_KIND[item.type] const createdAt = knownCreatedAt(item.created_at) diff --git a/ui/src/features/asset-picker/index.ts b/ui/src/features/asset-picker/index.ts index 7b4c6b41..c55f5470 100644 --- a/ui/src/features/asset-picker/index.ts +++ b/ui/src/features/asset-picker/index.ts @@ -6,6 +6,7 @@ export { checkCompatibility, matchCatalogByOutput, outputToPickerItem, + voiceRefFromOutput, resolveCatalogMatch, } from './adapters.ts' export { confirmPickerChoice, livePickerItem, matchOutputByPicker } from './confirmChoice.ts' diff --git a/ui/src/lib/labsImagePick.ts b/ui/src/lib/labsImagePick.ts index 522c5d3e..fe5d9604 100644 --- a/ui/src/lib/labsImagePick.ts +++ b/ui/src/lib/labsImagePick.ts @@ -30,11 +30,15 @@ export async function fileFromOutput(item: ApiOutput): Promise { return new File([blob], item.name, { type: blob.type || 'image/png' }) } -/** Series import copies from uploads/ only. Catalog picks are copied once into uploads. */ +/** + * Copy the pick into uploads/ and return the upload API payload. + * Callers (Series import, Character Creator describe, H3 isfile checks) + * only share that absolute filesystem path. A synthetic `uploads/${name}` + * is not enough: describe resolves relative names under uploads/, so + * `uploads/hero.png` becomes `uploads/uploads/hero.png` and the default + * empty-prompt Character Creator flow 400s. + */ export async function ensureUploadsPath(item: ApiOutput): Promise<{ path: string; name: string; url: string }> { - if (isUploadOutput(item)) { - return { path: `uploads/${item.name}`, name: item.name, url: item.url } - } const uploaded = await uploadImage(await fileFromOutput(item)) return { path: uploaded.path, name: uploaded.filename, url: uploaded.url } } diff --git a/ui/tests/assetPickerContract.test.ts b/ui/tests/assetPickerContract.test.ts index be4bda74..2b50debb 100644 --- a/ui/tests/assetPickerContract.test.ts +++ b/ui/tests/assetPickerContract.test.ts @@ -6,6 +6,7 @@ import { catalogItemToOutput, catalogItemToPickerItem, matchCatalogByOutput, + voiceRefFromOutput, checkCompatibility, confirmPickerChoice, createCatalogQuerySession, @@ -39,6 +40,41 @@ function catalogItem(overrides: Partial & Pick { + const uploaded = voiceRefFromOutput({ + name: '9f2.wav', + type: 'audio', + mode: null, + size: 12, + created_at: 1, + url: '/api/v1/uploads/audio/9f2.wav', + thumbnail_url: '', + }, 'default') + assert.deepEqual(uploaded, { filename: '9f2.wav', path: 'audio/9f2.wav' }) + + const library = voiceRefFromOutput({ + name: 'hero.wav', + type: 'audio', + mode: null, + size: 12, + created_at: 1, + url: '/api/v1/file/hero.wav?workspace=default', + thumbnail_url: '', + }, 'default') + assert.deepEqual(library, { filename: 'hero.wav', path: 'hero.wav' }) + + const videoUpload = voiceRefFromOutput({ + name: 'take.mp4', + type: 'video', + mode: null, + size: 12, + created_at: 1, + url: '/api/v1/uploads/take.mp4', + thumbnail_url: '', + }, 'default') + assert.deepEqual(videoUpload, { filename: 'take.mp4', path: 'take.mp4' }) +}) + test('catalog items map to outputs and match back by name and url', () => { const item = catalogItem({ id: 'asset-hero', filename: 'hero.png' }) const output = catalogItemToOutput(item, 'default') diff --git a/ui/tests/labsImagePick.test.mjs b/ui/tests/labsImagePick.test.mjs index 6d8135ed..a4a75dfc 100644 --- a/ui/tests/labsImagePick.test.mjs +++ b/ui/tests/labsImagePick.test.mjs @@ -12,8 +12,58 @@ const uploadItem = { thumbnail_url: '/api/v1/uploads/hero.png', } -test('upload catalog URLs stay in uploads/ without a second POST', async () => { +const catalogItem = { + name: 'shot.png', + type: 'image', + mode: 'image', + size: 20, + created_at: 2, + url: '/api/v1/file/shot.png?workspace=default', + thumbnail_url: '/api/v1/file/shot.png?workspace=default', +} + +function mockUploadFetch(expectedUrl) { + const original = globalThis.fetch + globalThis.fetch = (async (input, init) => { + const url = String(input) + if (url.includes(expectedUrl) && !init?.method) { + return new Response(new Uint8Array([137, 80, 78, 71]), { headers: { 'content-type': 'image/png' } }) + } + if (url.includes('/api/v1/upload') && init?.method === 'POST') { + return new Response(JSON.stringify({ + filename: 'copied.png', + path: '/abs/uploads/copied.png', + url: '/api/v1/uploads/copied.png', + }), { headers: { 'content-type': 'application/json' } }) + } + throw new Error(`unexpected fetch ${url}`) + }) + return () => { globalThis.fetch = original } +} + +test('upload catalog URLs still copy through /api/v1/upload so callers get an absolute path', async () => { assert.equal(isUploadOutput(uploadItem), true) - const ensured = await ensureUploadsPath(uploadItem) - assert.deepEqual(ensured, { path: 'uploads/hero.png', name: 'hero.png', url: '/api/v1/uploads/hero.png' }) + const restore = mockUploadFetch('/api/v1/uploads/hero.png') + try { + const ensured = await ensureUploadsPath(uploadItem) + assert.deepEqual(ensured, { + path: '/abs/uploads/copied.png', + name: 'copied.png', + url: '/api/v1/uploads/copied.png', + }) + } finally { + restore() + } +}) + +test('workspace catalog picks are fetched from their file URL and copied into uploads/', async () => { + assert.equal(isUploadOutput(catalogItem), false) + const restore = mockUploadFetch('/api/v1/file/shot.png?workspace=default') + try { + const ensured = await ensureUploadsPath(catalogItem) + assert.equal(ensured.path, '/abs/uploads/copied.png') + assert.equal(ensured.name, 'copied.png') + } finally { + restore() + } }) diff --git a/ui/tests/seriesLabsPicker.test.tsx b/ui/tests/seriesLabsPicker.test.tsx index a1517107..47edba0b 100644 --- a/ui/tests/seriesLabsPicker.test.tsx +++ b/ui/tests/seriesLabsPicker.test.tsx @@ -42,6 +42,16 @@ test('Series canon identity can be chosen from HocusPocus without approving the total: 1, }), { headers: { 'content-type': 'application/json' } }) } + if (url.includes('/api/v1/uploads/hero.png') && !init?.method) { + return new Response(new Uint8Array([137, 80, 78, 71]), { headers: { 'content-type': 'image/png' } }) + } + if (url.includes('/api/v1/upload') && init?.method === 'POST') { + return new Response(JSON.stringify({ + filename: 'copied.png', + path: '/abs/uploads/copied.png', + url: '/api/v1/uploads/copied.png', + }), { headers: { 'content-type': 'application/json' } }) + } if (url.includes('/assets/import') && init?.method === 'POST') { imported.push(JSON.parse(String(init.body || '{}'))) return new Response(JSON.stringify({ asset: { id: 'asset_1' }, series }), { headers: { 'content-type': 'application/json' } }) @@ -64,7 +74,7 @@ test('Series canon identity can be chosen from HocusPocus without approving the fireEvent.click(card) fireEvent.click(screen.getByRole('button', { name: 'Choose' })) await waitFor(() => assert.equal(imported.length, 1)) - assert.equal(imported[0].uploadPath, 'uploads/hero.png') + assert.equal(imported[0].uploadPath, '/abs/uploads/copied.png') assert.equal(imported[0].ownerType, 'character') assert.equal(imported[0].ownerId, character.id) assert.equal(imported[0].referenceRole, 'primary_portrait')