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')