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..c96cca1 100644 --- a/src/ui/public/css/styles.css +++ b/src/ui/public/css/styles.css @@ -430,10 +430,22 @@ select { .btn-primary:hover:not(:disabled) { background: var(--accent-hover); } .btn-primary:active:not(:disabled) { transform: translateY(4px); box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.18); } .btn-primary:disabled { opacity: 0.25; cursor: not-allowed; } -.btn-ghost { background: transparent; color: var(--text2); border: 1px solid var(--border); } -.btn-ghost:hover { border-color: var(--border-hover); color: var(--text); } -.btn-danger { background: transparent; color: var(--text3); border: 1px solid var(--border); } -.btn-danger:hover:not(:disabled) { border-color: var(--red-border); color: var(--red); background: var(--red-subtle); } +.btn-ghost { + background: var(--surface3); color: var(--text); + box-shadow: 0 4px 0 0 var(--bg), 0 9px 16px -7px rgba(0, 0, 0, 0.7), + 0 0 0 1px rgba(255, 255, 255, 0.07), inset 0 1px 0 rgba(255, 255, 255, 0.10); +} +.btn-ghost:hover:not(:disabled) { background: #2c2c34; } +.btn-ghost:active:not(:disabled) { transform: translateY(4px); box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.09), inset 0 1px 0 rgba(255, 255, 255, 0.10); } +.btn-ghost:disabled { opacity: 0.4; cursor: not-allowed; } +.btn-danger { + background: var(--surface3); color: var(--text2); + box-shadow: 0 4px 0 0 var(--bg), 0 9px 16px -7px rgba(0, 0, 0, 0.7), + 0 0 0 1px rgba(255, 255, 255, 0.07), inset 0 1px 0 rgba(255, 255, 255, 0.10); +} +.btn-danger:hover:not(:disabled) { color: var(--red); box-shadow: 0 4px 0 0 var(--bg), 0 9px 16px -7px rgba(0, 0, 0, 0.7), 0 0 0 1px var(--red-border), inset 0 1px 0 rgba(255, 255, 255, 0.10); } +.btn-danger:active:not(:disabled) { transform: translateY(4px); box-shadow: 0 0 0 1px var(--red-border), inset 0 1px 0 rgba(255, 255, 255, 0.10); } +.btn-danger:disabled { opacity: 0.4; cursor: not-allowed; } .btn-sm { padding: 6px 12px; font-size: 12px; border-radius: var(--radius-sm); } .btn-go { width: 100%; padding: 14px; font-size: 15px; justify-content: center; @@ -452,13 +464,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); } -.copy-btn { - padding: 3px 9px; cursor: pointer; background: transparent; - border: 1px solid var(--border); border-radius: var(--radius-sm); - color: var(--text2); font-family: inherit; font-size: 11px; font-weight: 600; - transition: border-color 0.15s var(--ease), color 0.15s var(--ease); -} -.copy-btn:hover { border-color: var(--border-hover); color: var(--text); } +.title-option.selected { border-color: var(--accent); background: var(--accent-subtle); } .pill { display: inline-block; padding: 3px 10px; border-radius: 20px; font-size: 11px; font-weight: 600; } .pill-blue { background: var(--accent-subtle); color: var(--accent); } diff --git a/src/ui/web-server.ts b/src/ui/web-server.ts index 33ba40d..3101f61 100644 --- a/src/ui/web-server.ts +++ b/src/ui/web-server.ts @@ -1443,6 +1443,63 @@ 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) { 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", 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)); + 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 (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 }; + 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 }); +}); + // --- Secrets/settings stored in the global .env (e.g. HF_TOKEN) --- app.get("/api/settings", async (_req, res) => {