From dc5f4742e9dcedc40f837839e3d234c5329e0b0b Mon Sep 17 00:00:00 2001 From: Nika Siradze Date: Fri, 19 Jun 2026 15:55:35 +0400 Subject: [PATCH 1/3] Fix bundled Studio config paths --- src/config/paths.ts | 22 +++++++++++++++++++--- src/handlers/integrations.routes.ts | 13 ++++++++++--- src/ui/client/ConfigPage.tsx | 21 ++++++++------------- src/ui/client/McpSetupPage.tsx | 20 ++++++++------------ src/ui/public/css/styles.css | 6 ++++-- 5 files changed, 49 insertions(+), 33 deletions(-) diff --git a/src/config/paths.ts b/src/config/paths.ts index c9477d7..fc2eff0 100644 --- a/src/config/paths.ts +++ b/src/config/paths.ts @@ -30,9 +30,25 @@ const home = resolveHome(); // Hermetic installs run from a bundled runtime where the backend lives outside // projectRoot; the launcher points PODCLI_BACKEND at it. -const backendDir = process.env.PODCLI_BACKEND - ? resolve(process.env.PODCLI_BACKEND) - : join(projectRoot, "backend"); +function resolveBackendDir(): string { + if (process.env.PODCLI_BACKEND) { + return resolve(process.env.PODCLI_BACKEND); + } + + const sourceBackend = join(projectRoot, "backend"); + if (existsSync(join(sourceBackend, "cli.py"))) { + return sourceBackend; + } + + const runtimeBackend = join(projectRoot, "runtime", "backend"); + if (existsSync(join(runtimeBackend, "cli.py"))) { + return runtimeBackend; + } + + return sourceBackend; +} + +const backendDir = resolveBackendDir(); function detectPython(): string { if (process.env.PYTHON_PATH) return process.env.PYTHON_PATH; diff --git a/src/handlers/integrations.routes.ts b/src/handlers/integrations.routes.ts index 5e1dc39..44949b8 100644 --- a/src/handlers/integrations.routes.ts +++ b/src/handlers/integrations.routes.ts @@ -126,11 +126,18 @@ export function registerConfigIntegrationRoutes( }); app.get("/api/integration-info", (_req, res) => { - const distPath = join(projectRoot, "dist", "index.js"); + const packagedMcpPath = join(projectRoot, "runtime", "studio", "mcp-server.mjs"); + const sourceMcpPath = join(projectRoot, "dist", "index.js"); + const mcpPath = process.env.PODCLI_STUDIO + ? join(process.env.PODCLI_STUDIO, "mcp-server.mjs") + : existsSync(packagedMcpPath) + ? packagedMcpPath + : sourceMcpPath; res.json({ - dist_path: distPath, + mcp_path: mcpPath, + dist_path: mcpPath, project_root: projectRoot, - server_ok: existsSync(distPath), + server_ok: existsSync(mcpPath), }); }); } diff --git a/src/ui/client/ConfigPage.tsx b/src/ui/client/ConfigPage.tsx index bac332e..714335a 100644 --- a/src/ui/client/ConfigPage.tsx +++ b/src/ui/client/ConfigPage.tsx @@ -82,9 +82,7 @@ export default function ConfigPage() { const rows: Array<[string, string]> = status ? [ ["Config home", status.home || ""], - ["Data / cache", status.cache || ""], - ["Profile marker", status.profile_marker || ""], - ["Migration", status.migration?.already_migrated ? "Up to date" : "Ran on load"], + ["Cache", status.cache || ""], ] : []; @@ -117,13 +115,13 @@ export default function ConfigPage() {
setSecretInputs((p) => ({ ...p, [s.key]: e.target.value }))} style={{ fontSize: 13, flex: 1 }} @@ -137,10 +135,7 @@ export default function ConfigPage() { {savingKey === s.key ? "Saving…" : "Save"}
-
- {s.help}{" "} - Get token → -
+ Get token ))} @@ -149,12 +144,12 @@ export default function ConfigPage() {
Actions
- - + +
- +
diff --git a/src/ui/client/McpSetupPage.tsx b/src/ui/client/McpSetupPage.tsx index 635e8f3..deef907 100644 --- a/src/ui/client/McpSetupPage.tsx +++ b/src/ui/client/McpSetupPage.tsx @@ -10,9 +10,9 @@ const STATUS_STYLE: Record = { }; export default function McpSetupPage() { - const [distPath, setDistPath] = useState(null); + const [mcpPath, setMcpPath] = useState(null); const [statusKind, setStatusKind] = useState("warn"); - const [statusText, setStatusText] = useState("Checking connection…"); + const [statusText, setStatusText] = useState("Checking…"); const desktopRef = useRef(null); const codeRef = useRef(null); const [copied, setCopied] = useState>({}); @@ -20,13 +20,13 @@ export default function McpSetupPage() { useEffect(() => { api("/integration-info") .then((data) => { - setDistPath(data.dist_path); + setMcpPath(data.mcp_path || data.dist_path); if (data.server_ok) { setStatusKind("ok"); - setStatusText(`Server running · ${data.tools_count} tools available`); + setStatusText("Ready"); } else { setStatusKind("err"); - setStatusText("Server not built — run: npm run build"); + setStatusText("Not built"); } }) .catch(() => { @@ -42,12 +42,12 @@ export default function McpSetupPage() { }); } - const dist = distPath ?? "/dist/index.js"; + const serverPath = mcpPath ?? "/mcp-server.mjs"; const desktopJson = `{ "mcpServers": { "podcli": { "command": "node", - "args": ["${dist}"] + "args": ["${serverPath}"] } } }`; @@ -60,10 +60,6 @@ export default function McpSetupPage() {
Claude Desktop
-
- Add to ~/Library/Application Support/Claude/claude_desktop_config.json (macOS) or - %APPDATA%\Claude\claude_desktop_config.json (Windows), then restart Claude. -
claude_desktop_config.json @@ -84,7 +80,7 @@ export default function McpSetupPage() { {copied.code ? "Copied" : "Copy"}
-
{`claude mcp add podcli node ${dist}`}
+
podcli mcp install
diff --git a/src/ui/public/css/styles.css b/src/ui/public/css/styles.css index d5a95c9..22ec263 100644 --- a/src/ui/public/css/styles.css +++ b/src/ui/public/css/styles.css @@ -394,13 +394,13 @@ h1 { font-size: 28px; font-weight: 700; letter-spacing: -0.5px; line-height: 1.1 .section-label { font-size: 11px; font-weight: 700; letter-spacing: 0.8px; color: var(--text2); margin-bottom: 10px; text-transform: uppercase; } /* ─── Form Elements ─── */ -input[type="text"], input[type="number"], select, textarea { +input[type="text"], input[type="number"], input[type="password"], select, textarea { width: 100%; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 11px 14px; color: var(--text); font-family: inherit; font-size: 14px; outline: none; transition: border-color 0.2s var(--ease), box-shadow 0.2s var(--ease); } -input[type="text"]:focus, input[type="number"]:focus, select:focus, textarea:focus { +input[type="text"]:focus, input[type="number"]:focus, input[type="password"]:focus, select:focus, textarea:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-subtle); } textarea { @@ -1078,6 +1078,8 @@ input[type="range"]::-webkit-slider-thumb { .set-note.ok { background: var(--green-subtle); color: var(--green); } .set-note.err { background: var(--red-subtle); color: var(--red); } .set-file { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; } +.set-link { display: inline-block; margin-top: 6px; font-size: 12px; color: var(--accent); text-decoration: none; } +.set-link:hover { color: var(--accent-hover); } .int-row { display: flex; align-items: center; justify-content: space-between; gap: 14px; padding: 14px 0; border-bottom: 1px solid var(--border); } .int-row:last-child { border-bottom: none; } .int-row .name { font-size: 14px; font-weight: 600; } From d5abd58a011b96728b120bd64a86a5e77dc9cba0 Mon Sep 17 00:00:00 2001 From: Nika Siradze Date: Fri, 19 Jun 2026 18:07:19 +0400 Subject: [PATCH 2/3] Two-step thumbnail picker: choose text + frame, then render Replaces the one-shot "generate 3 variations" thumbnail UI with a pick-then-render flow on the clip detail page: - "Get options" fetches headline text candidates (one AI call) and face frame candidates (no AI) for the clip. - Pick a text option (fills Line 1/2) and a frame, or upload your own frame; leaving the lines empty lets the AI write them. - "Generate" renders one final thumbnail from the chosen frame + text and bakes it into the clip; a rendering indicator shows progress. - "Refresh options" re-rolls both lists for iteration. Backend: new `thumbnail-options` and `thumbnail-render` CLI commands wrap generate_headline_variations / extract_candidate_frames / generate_thumbnail_with_template; new GET/POST /api/clips/:id/thumbnail/{options,render} endpoints. Also fix title-option selection feedback: clicking a generated title now shows a selected state and a "Save to apply" hint (it set the title silently before, off-screen, so it looked broken). --- backend/cli.py | 63 +++++++++++++++++ src/ui/client/ClipDetail.tsx | 130 +++++++++++++++++++++-------------- src/ui/public/css/styles.css | 1 + src/ui/web-server.ts | 50 ++++++++++++++ 4 files changed, 192 insertions(+), 52 deletions(-) diff --git a/backend/cli.py b/backend/cli.py index 9e9b15b..bb93103 100644 --- a/backend/cli.py +++ b/backend/cli.py @@ -2121,6 +2121,45 @@ def cmd_thumbnail_config(args): raise ValueError(f"unknown thumbnail-config action: {action}") +def cmd_thumbnail_options(args): + """Emit candidate headline text pairs and face frames for the thumbnail picker.""" + from services.thumbnail_ai import generate_headline_variations, extract_candidate_frames + + os.makedirs(args.output, exist_ok=True) + texts = generate_headline_variations(args.title, args.texts) or [] + frames = [] + if args.video: + frames = extract_candidate_frames( + args.video, args.output, count=args.frames, + start_second=args.start, end_second=args.end, + ) or [] + print(json.dumps({"texts": [list(t) for t in texts], "frames": frames})) + + +def cmd_thumbnail_render(args): + """Render one final thumbnail from a chosen frame + headline. + + Empty line1/line2 let the AI write the text; a chosen frame is used as-is. + """ + from services.thumbnail_ai import generate_thumbnail_with_template + from services.asset_store import resolve_logo + + frame_info = json.loads(args.frame_info) if args.frame_info else None + out = generate_thumbnail_with_template( + title=args.title, + frame_path=args.frame, + output_path=args.output, + logo_path=resolve_logo(args.logo) if args.logo else None, + frame_info=frame_info, + line1_override=args.line1 or None, + line2_override=args.line2 or None, + ) + if not out: + print("thumbnail render failed", file=sys.stderr) + sys.exit(1) + print(json.dumps({"path": out})) + + def cmd_thumbnails(args): """Generate thumbnail variations for a title.""" from services.thumbnail_ai import generate_variations @@ -3332,6 +3371,26 @@ def main(): tcfg_imp.add_argument("path", help="Source .json path") tcfg_sub.add_parser("reset", help="Remove the override and revert to the generic default") + # ── thumbnail-options (candidate text + frames for the picker) ── + topt = sub.add_parser("thumbnail-options", help="Emit candidate headline texts and face frames as JSON") + topt.add_argument("title", help="Clip/episode title to base headlines on") + topt.add_argument("-o", "--output", required=True, help="Directory to write candidate frames into") + topt.add_argument("--video", help="Source video to extract face frames from") + topt.add_argument("--start", type=float, help="Frame window start (seconds)") + topt.add_argument("--end", type=float, help="Frame window end (seconds)") + topt.add_argument("--texts", type=int, default=6, help="Number of headline options") + topt.add_argument("--frames", type=int, default=6, help="Number of frame options") + + # ── thumbnail-render (one final thumbnail from a chosen frame + headline) ── + trnd = sub.add_parser("thumbnail-render", help="Render one thumbnail PNG from a chosen frame + headline") + trnd.add_argument("title", help="Clip/episode title") + trnd.add_argument("--frame", required=True, help="Background frame image path") + trnd.add_argument("-o", "--output", required=True, help="Destination PNG path") + trnd.add_argument("--line1", help="Headline line 1 (empty = AI writes it)") + trnd.add_argument("--line2", help="Headline line 2 (empty = AI writes it)") + trnd.add_argument("--frame-info", dest="frame_info", help="JSON face metadata for the frame") + trnd.add_argument("--logo", help="Logo (asset name or path)") + # ── swap-thumbnail ── st = sub.add_parser("swap-thumbnail", help="Regenerate thumbnail on an existing clip") st.add_argument("clip", help="Path to rendered clip (.mp4)") @@ -3473,6 +3532,10 @@ def main(): cmd_thumbnails(args) elif args.command == "thumbnail-config": cmd_thumbnail_config(args) + elif args.command == "thumbnail-options": + cmd_thumbnail_options(args) + elif args.command == "thumbnail-render": + cmd_thumbnail_render(args) elif args.command == "swap-thumbnail": cmd_swap_thumbnail(args) elif args.command == "bake-thumbnail": diff --git a/src/ui/client/ClipDetail.tsx b/src/ui/client/ClipDetail.tsx index 18304f8..b5d97c8 100644 --- a/src/ui/client/ClipDetail.tsx +++ b/src/ui/client/ClipDetail.tsx @@ -50,8 +50,9 @@ export default function ClipDetail() { const [captionStyle, setCaptionStyle] = useState(""); const [line1, setLine1] = useState(""); const [line2, setLine2] = useState(""); - const [thumbImage, setThumbImage] = useState(null); - const [thumbTimestamp, setThumbTimestamp] = useState(null); + const [textOpts, setTextOpts] = useState<[string, string][]>([]); + const [frameOpts, setFrameOpts] = useState([]); + const [selFrame, setSelFrame] = useState<{ path: string; info?: any } | null>(null); const [busy, setBusy] = useState(null); const [msg, setMsg] = useState(null); const [bust, setBust] = useState(1); @@ -69,8 +70,6 @@ export default function ClipDetail() { const tc = found.thumbnail_config || {}; setLine1(tc.line1 ?? ""); setLine2(tc.line2 ?? ""); - setThumbImage(tc.image_path ?? null); - setThumbTimestamp(tc.image_path ? null : tc.timestamp ?? null); } }) .finally(() => setLoading(false)); @@ -88,7 +87,6 @@ export default function ClipDetail() { const tc = clip.thumbnail_config || {}; const dirty = title !== clip.title || captionStyle !== clip.caption_style; const previewUrl = `/api/clips/${clip.id}/preview?t=${bust}`; - const source = thumbImage ? `Image · ${basename(thumbImage)}` : thumbTimestamp != null ? `Frame @ ${fmt(thumbTimestamp)}` : "Auto"; const patch = (body: any) => api(`/clips/${clip.id}`, { method: "PATCH", body: JSON.stringify(body) }); @@ -101,39 +99,40 @@ export default function ClipDetail() { } catch (e: any) { setMsg(`Save failed: ${e.message}`); } finally { setBusy(null); } }; - const useCurrentFrame = () => { setThumbTimestamp(clip.start_second + playerTime.current); setThumbImage(null); setMsg(null); }; + const loadOptions = async () => { + setBusy("options"); setMsg(null); + try { + const r = await api(`/clips/${clip.id}/thumbnail/options?texts=6&frames=6`); + if (r.error) throw new Error(r.error); + setTextOpts(r.texts || []); + setFrameOpts(r.frames || []); + if ((r.frames || []).length) setSelFrame({ path: r.frames[0].path, info: r.frames[0] }); + if (!(r.texts || []).length && !(r.frames || []).length) setMsg("No options — is the AI CLI installed and the source video available?"); + } catch (e: any) { setMsg(`Options failed: ${e.message}`); } finally { setBusy(null); } + }; - const uploadImage = async (f: File) => { + const uploadFrame = async (f: File) => { setBusy("upload"); setMsg(null); try { const fd = new FormData(); fd.append("file", f); const r = await upload("/upload", fd); if (!r.file_path) throw new Error("upload failed"); - setThumbImage(r.file_path); setThumbTimestamp(null); + setSelFrame({ path: r.file_path }); } catch (e: any) { setMsg(`Upload failed: ${e.message}`); } finally { setBusy(null); } }; - const generate = async () => { - setBusy("thumb"); setMsg(null); - try { - const cfg: ThumbnailConfig = { line1: line1 || undefined, line2: line2 || undefined }; - if (thumbImage) cfg.image_path = thumbImage; - else if (thumbTimestamp != null) cfg.timestamp = thumbTimestamp; - const p = await patch({ thumbnail_config: cfg }); - if (p.error) throw new Error(p.error); - const r = await api(`/clips/${clip.id}/thumbnail`, { method: "POST", body: "{}" }); - if (r.error) throw new Error(r.error); - setBust(Date.now()); load(); - } catch (e: any) { setMsg(`Thumbnail failed: ${e.message}`); } finally { setBusy(null); } - }; - - const pickVariation = async (p: string) => { - setBusy("pick"); + const renderThumb = async () => { + if (!selFrame) { setMsg("Select or upload a frame first"); return; } + setBusy("render"); setMsg(null); try { - const r = await api(`/clips/${clip.id}/thumbnail/select`, { method: "POST", body: JSON.stringify({ path: p }) }); + const r = await api(`/clips/${clip.id}/thumbnail/render`, { + method: "POST", + body: JSON.stringify({ line1: line1 || undefined, line2: line2 || undefined, frame_path: selFrame.path, frame_info: selFrame.info }), + }); if (r.error) throw new Error(r.error); setBust(Date.now()); load(); - } catch (e: any) { setMsg(`Pick failed: ${e.message}`); } finally { setBusy(null); } + setMsg("Thumbnail generated"); + } catch (e: any) { setMsg(`Generate failed: ${e.message}`); } finally { setBusy(null); } }; const reopen = async () => { @@ -232,17 +231,27 @@ export default function ClipDetail() {
- +
+ + +
+
- {tc.preview_path ? ( + {busy === "render" ? ( +
+
Rendering… +
+ ) : tc.preview_path ? ( thumbnail - ) : thumbImage ? ( - thumbnail source + ) : selFrame ? ( + selected frame ) : (
- Generate to preview + Get options, pick a frame, then generate
)}
@@ -251,27 +260,41 @@ export default function ClipDetail() { setLine1(e.target.value)} placeholder="Line 1" style={{ width: "100%", fontSize: 14, padding: "9px 12px" }} /> setLine2(e.target.value)} placeholder="Line 2 (highlighted)" style={{ width: "100%", fontSize: 14, padding: "9px 12px", marginTop: 8 }} />
- - - e.target.files?.[0] && uploadImage(e.target.files[0])} /> -
-
Source · {source}
-
- + e.target.files?.[0] && uploadFrame(e.target.files[0])} />
+
Leave Line 1 & 2 empty to auto-write the text.
- {(tc.variations?.length ?? 0) > 0 && ( -
- {tc.variations!.map((v) => ( - - ))} + + {textOpts.length > 0 && ( +
+
Text options · click to use
+
+ {textOpts.map(([l1, l2], i) => ( + + ))} +
+
+ )} + + {frameOpts.length > 0 && ( +
+
Frame options · click to select
+
+ {frameOpts.map((f, i) => ( + + ))} +
)}
@@ -291,11 +314,14 @@ export default function ClipDetail() {
Title options · click to use
- {clip.generated_titles.map((t, i) => ( - - ))} + {clip.generated_titles.map((t, i) => { + const clean = t.replace(/^\d+\.\s*/, ""); + return ( + + ); + })}
) : null} diff --git a/src/ui/public/css/styles.css b/src/ui/public/css/styles.css index 22ec263..7004a03 100644 --- a/src/ui/public/css/styles.css +++ b/src/ui/public/css/styles.css @@ -452,6 +452,7 @@ select { transition: border-color 0.15s var(--ease), background 0.15s var(--ease); } .title-option:hover { border-color: var(--accent); background: var(--accent-subtle); } +.title-option.selected { border-color: var(--accent); background: var(--accent-subtle); } .copy-btn { padding: 3px 9px; cursor: pointer; background: transparent; border: 1px solid var(--border); border-radius: var(--radius-sm); diff --git a/src/ui/web-server.ts b/src/ui/web-server.ts index 33ba40d..4c3623e 100644 --- a/src/ui/web-server.ts +++ b/src/ui/web-server.ts @@ -1443,6 +1443,56 @@ app.post("/api/clips/:id/thumbnail/select", async (req, res) => { res.json({ ok: true, preview_path: pick }); }); +// Candidate headline texts + face frames for the two-step thumbnail picker. +app.get("/api/clips/:id/thumbnail/options", async (req, res) => { + const clip = await clipsHistory.findById(req.params.id); + if (!clip) { res.status(404).json({ error: "clip not found" }); return; } + const tc = clip.thumbnail_config || {}; + const clamp = (v: any, d: number) => Math.min(Math.max(parseInt(String(v)) || d, 1), 8); + const outDir = join(paths.output, "thumbnails", String(clip.id), "frames"); + const r = await runCli([ + "thumbnail-options", tc.text || clip.title, + "--output", outDir, + "--video", clip.source_video, + "--start", String(clip.start_second), + "--end", String(clip.end_second), + "--texts", String(clamp(req.query.texts, 6)), + "--frames", String(clamp(req.query.frames, 6)), + ]); + if (r.code !== 0) { res.status(400).json({ error: stripAnsi(r.stderr || r.stdout) || "options failed" }); return; } + const jsonLine = r.stdout.trim().split("\n").reverse().find((l) => l.trim().startsWith("{")); + try { res.json(JSON.parse(jsonLine || "{}")); } catch { res.status(500).json({ error: "bad options output" }); } +}); + +// Render one final thumbnail from a chosen frame + headline (empty lines = AI writes the text). +app.post("/api/clips/:id/thumbnail/render", async (req, res) => { + const clip = await clipsHistory.findById(req.params.id); + if (!clip) { res.status(404).json({ error: "clip not found" }); return; } + const tc = clip.thumbnail_config || {}; + const { line1, line2, frame_path, frame_info } = req.body || {}; + if (!frame_path || !existsSync(String(frame_path))) { res.status(400).json({ error: "select a frame first" }); return; } + const outDir = join(paths.output, "thumbnails", String(clip.id)); + await mkdir(outDir, { recursive: true }); + const out = join(outDir, `thumb_${uuidv4().slice(0, 8)}.png`); + const args = ["thumbnail-render", tc.text || clip.title, "--frame", String(frame_path), "--output", out]; + if (line1) args.push("--line1", String(line1)); + if (line2) args.push("--line2", String(line2)); + if (frame_info) args.push("--frame-info", JSON.stringify(frame_info)); + const r = await runCli(args); + if (r.code !== 0) { res.status(400).json({ error: stripAnsi(r.stderr || r.stdout) || "render failed" }); return; } + const jsonLine = r.stdout.trim().split("\n").reverse().find((l) => l.trim().startsWith("{")); + let outPath = ""; + try { outPath = JSON.parse(jsonLine || "{}").path || ""; } catch { /* no path */ } + if (!outPath || !existsSync(outPath)) { res.status(500).json({ error: "no thumbnail produced" }); return; } + if (existsSync(clip.output_path)) { + const bake = await bakeThumbnailCard(clip.output_path, outPath, tc.card_seconds || 0); + if (!bake.ok) { res.status(500).json({ error: `rendered but bake into clip failed: ${bake.error}` }); return; } + } + const merged = { ...tc, line1: line1 || undefined, line2: line2 || undefined, preview_path: outPath, card_seconds: 1.5 }; + await runCli(["clips", "edit", String(clip.id), "--thumbnail-config", JSON.stringify(merged)]); + res.json({ ok: true, preview_path: outPath }); +}); + // --- Secrets/settings stored in the global .env (e.g. HF_TOKEN) --- app.get("/api/settings", async (_req, res) => { From a4906fd7925c8ba911635961d153e8a1ab039db2 Mon Sep 17 00:00:00 2001 From: Nika Siradze Date: Fri, 19 Jun 2026 18:27:23 +0400 Subject: [PATCH 3/3] Address CodeRabbit review: validate frame_path, check clips-edit exit, guard output_path - thumbnail/render: resolve frame_path and require it under the clip's thumbnail dir or the upload dir before passing to the renderer. - Fail the request if the clips-edit metadata write returns non-zero. - Guard clip.output_path before existsSync. (McpSetup Windows-path JSON was already resolved by taking JSON.stringify in the merge.) --- src/ui/web-server.ts | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/ui/web-server.ts b/src/ui/web-server.ts index 4c3623e..3101f61 100644 --- a/src/ui/web-server.ts +++ b/src/ui/web-server.ts @@ -1470,11 +1470,17 @@ app.post("/api/clips/:id/thumbnail/render", async (req, res) => { if (!clip) { res.status(404).json({ error: "clip not found" }); return; } const tc = clip.thumbnail_config || {}; const { line1, line2, frame_path, frame_info } = req.body || {}; - if (!frame_path || !existsSync(String(frame_path))) { res.status(400).json({ error: "select a frame first" }); return; } + if (!frame_path) { res.status(400).json({ error: "select a frame first" }); return; } + // Only allow frames podcli itself produced (candidate frames) or the user uploaded — + // never an arbitrary server path passed through to the renderer. + const resolvedFrame = resolve(String(frame_path)); + const frameRoots = [resolve(join(paths.output, "thumbnails", String(clip.id))), resolve(uploadDir)]; + const inAllowedRoot = frameRoots.some((root) => resolvedFrame === root || resolvedFrame.startsWith(root + path.sep)); + if (!inAllowedRoot || !existsSync(resolvedFrame)) { res.status(400).json({ error: "invalid frame" }); return; } const outDir = join(paths.output, "thumbnails", String(clip.id)); await mkdir(outDir, { recursive: true }); const out = join(outDir, `thumb_${uuidv4().slice(0, 8)}.png`); - const args = ["thumbnail-render", tc.text || clip.title, "--frame", String(frame_path), "--output", out]; + const args = ["thumbnail-render", tc.text || clip.title, "--frame", resolvedFrame, "--output", out]; if (line1) args.push("--line1", String(line1)); if (line2) args.push("--line2", String(line2)); if (frame_info) args.push("--frame-info", JSON.stringify(frame_info)); @@ -1484,12 +1490,13 @@ app.post("/api/clips/:id/thumbnail/render", async (req, res) => { let outPath = ""; try { outPath = JSON.parse(jsonLine || "{}").path || ""; } catch { /* no path */ } if (!outPath || !existsSync(outPath)) { res.status(500).json({ error: "no thumbnail produced" }); return; } - if (existsSync(clip.output_path)) { + if (clip.output_path && existsSync(clip.output_path)) { const bake = await bakeThumbnailCard(clip.output_path, outPath, tc.card_seconds || 0); if (!bake.ok) { res.status(500).json({ error: `rendered but bake into clip failed: ${bake.error}` }); return; } } const merged = { ...tc, line1: line1 || undefined, line2: line2 || undefined, preview_path: outPath, card_seconds: 1.5 }; - await runCli(["clips", "edit", String(clip.id), "--thumbnail-config", JSON.stringify(merged)]); + const edit = await runCli(["clips", "edit", String(clip.id), "--thumbnail-config", JSON.stringify(merged)]); + if (edit.code !== 0) { res.status(500).json({ error: stripAnsi(edit.stderr || edit.stdout) || "thumbnail metadata update failed" }); return; } res.json({ ok: true, preview_path: outPath }); });