Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions backend/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)")
Expand Down Expand Up @@ -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":
Expand Down
130 changes: 78 additions & 52 deletions src/ui/client/ClipDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,9 @@ export default function ClipDetail() {
const [captionStyle, setCaptionStyle] = useState("");
const [line1, setLine1] = useState("");
const [line2, setLine2] = useState("");
const [thumbImage, setThumbImage] = useState<string | null>(null);
const [thumbTimestamp, setThumbTimestamp] = useState<number | null>(null);
const [textOpts, setTextOpts] = useState<[string, string][]>([]);
const [frameOpts, setFrameOpts] = useState<any[]>([]);
const [selFrame, setSelFrame] = useState<{ path: string; info?: any } | null>(null);
const [busy, setBusy] = useState<string | null>(null);
const [msg, setMsg] = useState<string | null>(null);
const [bust, setBust] = useState(1);
Expand All @@ -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));
Expand All @@ -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) });

Expand All @@ -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<any>("/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 () => {
Expand Down Expand Up @@ -232,17 +231,27 @@ export default function ClipDetail() {
</div>

<div className="section">
<label style={labelStyle}>Thumbnail</label>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 10 }}>
<label style={{ ...labelStyle, marginBottom: 0 }}>Thumbnail</label>
<button className="btn btn-ghost btn-sm" onClick={loadOptions} disabled={busy !== null}>
{busy === "options" ? <><div className="spinner sm" /> Finding options…</> : (textOpts.length || frameOpts.length ? "Refresh options" : "Get options")}
</button>
</div>

<div className="thumb-edit">
<div className="thumb-edit-preview">
<div className="thumb-stage">
{tc.preview_path ? (
{busy === "render" ? (
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", height: "100%", gap: 8, color: "var(--text2)", fontSize: 11 }}>
<div className="spinner sm" /> Rendering…
</div>
) : tc.preview_path ? (
<img key={`gen-${bust}`} src={img(tc.preview_path, bust)} alt="thumbnail" />
) : thumbImage ? (
<img src={img(thumbImage, bust)} alt="thumbnail source" />
) : selFrame ? (
<img src={img(selFrame.path, bust)} alt="selected frame" />
) : (
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", height: "100%", color: "var(--text3)", fontSize: 11, textAlign: "center", padding: 12 }}>
Generate to preview
Get options, pick a frame, then generate
</div>
)}
</div>
Expand All @@ -251,27 +260,41 @@ export default function ClipDetail() {
<input type="text" value={line1} onChange={(e) => setLine1(e.target.value)} placeholder="Line 1" style={{ width: "100%", fontSize: 14, padding: "9px 12px" }} />
<input type="text" value={line2} onChange={(e) => setLine2(e.target.value)} placeholder="Line 2 (highlighted)" style={{ width: "100%", fontSize: 14, padding: "9px 12px", marginTop: 8 }} />
<div className="set-actions" style={{ marginTop: 10 }}>
<button className="btn btn-ghost btn-sm" onClick={useCurrentFrame} disabled={busy !== null}>Use current frame</button>
<button className="btn btn-ghost btn-sm" onClick={() => fileRef.current?.click()} disabled={busy !== null}>
{busy === "upload" ? <div className="spinner sm" /> : "Upload image"}
<button className="btn btn-primary btn-sm" onClick={renderThumb} disabled={busy !== null || !selFrame}>
{busy === "render" ? <div className="spinner sm" /> : (tc.preview_path ? "Regenerate" : "Generate")}
</button>
<input ref={fileRef} type="file" accept=".png,.jpg,.jpeg,.webp" style={{ display: "none" }} onChange={(e) => e.target.files?.[0] && uploadImage(e.target.files[0])} />
</div>
<div style={{ fontSize: 11, color: "var(--text3)", marginTop: 8 }}>Source · {source}</div>
<div style={{ marginTop: 12 }}>
<button className="btn btn-primary btn-sm" onClick={generate} disabled={busy !== null}>
{busy === "thumb" ? <div className="spinner sm" /> : (tc.variations?.length ? "Regenerate" : "Generate thumbnail")}
<button className="btn btn-ghost btn-sm" onClick={() => fileRef.current?.click()} disabled={busy !== null}>
{busy === "upload" ? <div className="spinner sm" /> : "Upload frame"}
</button>
<input ref={fileRef} type="file" accept=".png,.jpg,.jpeg,.webp" style={{ display: "none" }} onChange={(e) => e.target.files?.[0] && uploadFrame(e.target.files[0])} />
</div>
<div style={{ fontSize: 11, color: "var(--text3)", marginTop: 8 }}>Leave Line 1 &amp; 2 empty to auto-write the text.</div>
</div>
</div>
{(tc.variations?.length ?? 0) > 0 && (
<div className="thumb-variations" style={{ marginTop: 14 }}>
{tc.variations!.map((v) => (
<button key={v} className={`thumb-variation ${tc.preview_path === v ? "selected" : ""}`} onClick={() => pickVariation(v)} disabled={busy !== null}>
<img src={img(v, bust)} alt="" />
</button>
))}

{textOpts.length > 0 && (
<div style={{ marginTop: 16 }}>
<div style={{ fontSize: 11, color: "var(--text3)", marginBottom: 6 }}>Text options · click to use</div>
<div style={{ display: "flex", flexDirection: "column", gap: 5 }}>
{textOpts.map(([l1, l2], i) => (
<button key={i} className={`title-option ${line1 === l1 && line2 === l2 ? "selected" : ""}`} onClick={() => { setLine1(l1); setLine2(l2); }}>
<strong>{l1}</strong>{l2 ? ` · ${l2}` : ""}
</button>
))}
</div>
</div>
)}

{frameOpts.length > 0 && (
<div style={{ marginTop: 16 }}>
<div style={{ fontSize: 11, color: "var(--text3)", marginBottom: 6 }}>Frame options · click to select</div>
<div className="thumb-variations">
{frameOpts.map((f, i) => (
<button key={i} className={`thumb-variation ${selFrame?.path === f.path ? "selected" : ""}`} onClick={() => setSelFrame({ path: f.path, info: f })} disabled={busy !== null}>
<img src={img(f.path, bust)} alt="" />
</button>
))}
</div>
</div>
)}
</div>
Expand All @@ -291,11 +314,14 @@ export default function ClipDetail() {
<div>
<div style={{ fontSize: 11, color: "var(--text3)", marginBottom: 6 }}>Title options · click to use</div>
<div style={{ display: "flex", flexDirection: "column", gap: 5 }}>
{clip.generated_titles.map((t, i) => (
<button key={i} className="title-option" onClick={() => { setTitle(t.replace(/^\d+\.\s*/, "")); }}>
{t}
</button>
))}
{clip.generated_titles.map((t, i) => {
const clean = t.replace(/^\d+\.\s*/, "");
return (
<button key={i} className={`title-option ${title === clean ? "selected" : ""}`} onClick={() => { setTitle(clean); setMsg("Title set — click Save to apply"); }}>
{t}
</button>
);
})}
</div>
</div>
) : null}
Expand Down
28 changes: 17 additions & 11 deletions src/ui/public/css/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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); }

Expand Down
Loading
Loading