diff --git a/.gitignore b/.gitignore
index 2c5527a..30bc139 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,6 +5,16 @@ venv/
# Build output
dist/
+# Tool caches
+.cache/
+**/.cache/
+.bundle-cache/
+**/.bundle-cache/
+.vite/
+.turbo/
+.next/
+*.tsbuildinfo
+
# Data directory
data/
.podcli/
diff --git a/backend/cli.py b/backend/cli.py
index bc3710d..9e9b15b 100644
--- a/backend/cli.py
+++ b/backend/cli.py
@@ -2072,6 +2072,55 @@ def cmd_init_thumbnail(args):
)
+def cmd_thumbnail_config(args):
+ """Show, export, import, or reset the thumbnail template config.
+
+ Resolves the same config path the renderer uses, so the Web UI (which shells
+ out to this command) and generated thumbnails never disagree on it.
+ """
+ from services.thumbnail_html import _load_config
+
+ action = getattr(args, "tc_action", None) or "show"
+ target = paths["thumbnailConfig"]
+
+ if action == "show":
+ print(json.dumps(_load_config(), indent=2, ensure_ascii=False))
+ return
+
+ if action == "export":
+ dest = os.path.abspath(os.path.expanduser(args.path))
+ os.makedirs(os.path.dirname(dest) or ".", exist_ok=True)
+ # Copy the override verbatim for a clean round-trip; seed from defaults
+ # when there is none yet.
+ if os.path.exists(target):
+ shutil.copyfile(target, dest)
+ else:
+ with open(dest, "w", encoding="utf-8") as f:
+ json.dump(_load_config(), f, indent=2, ensure_ascii=False)
+ print(f"Exported thumbnail config to {dest}")
+ return
+
+ if action == "import":
+ src = os.path.abspath(os.path.expanduser(args.path))
+ with open(src, encoding="utf-8") as f:
+ data = json.load(f)
+ if not isinstance(data, dict):
+ raise ValueError("thumbnail config must be a JSON object")
+ os.makedirs(os.path.dirname(target) or ".", exist_ok=True)
+ with open(target, "w", encoding="utf-8") as f:
+ json.dump(data, f, indent=2, ensure_ascii=False)
+ print(f"Imported thumbnail config -> {target}")
+ return
+
+ if action == "reset":
+ if os.path.exists(target):
+ os.remove(target)
+ print("Reset thumbnail config — using the generic default template.")
+ return
+
+ raise ValueError(f"unknown thumbnail-config action: {action}")
+
+
def cmd_thumbnails(args):
"""Generate thumbnail variations for a title."""
from services.thumbnail_ai import generate_variations
@@ -3273,6 +3322,16 @@ def main():
thumb.add_argument("--line2", help="Explicit second thumbnail line")
thumb.add_argument("--json", action="store_true", help="Emit JSON {paths:[...]} to stdout")
+ # ── thumbnail-config ──
+ tcfg = sub.add_parser("thumbnail-config", help="Show, export, import, or reset the thumbnail template")
+ tcfg_sub = tcfg.add_subparsers(dest="tc_action")
+ tcfg_sub.add_parser("show", help="Print the effective thumbnail config (defaults + overrides) as JSON")
+ tcfg_exp = tcfg_sub.add_parser("export", help="Write the current thumbnail config to a file")
+ tcfg_exp.add_argument("path", help="Destination .json path")
+ tcfg_imp = tcfg_sub.add_parser("import", help="Replace the thumbnail config from a file")
+ tcfg_imp.add_argument("path", help="Source .json path")
+ tcfg_sub.add_parser("reset", help="Remove the override and revert to the generic default")
+
# ── 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)")
@@ -3412,6 +3471,8 @@ def main():
cmd_studio(args)
elif args.command == "thumbnails":
cmd_thumbnails(args)
+ elif args.command == "thumbnail-config":
+ cmd_thumbnail_config(args)
elif args.command == "swap-thumbnail":
cmd_swap_thumbnail(args)
elif args.command == "bake-thumbnail":
diff --git a/backend/services/clips_history.py b/backend/services/clips_history.py
index 98818e2..4a5f8d9 100644
--- a/backend/services/clips_history.py
+++ b/backend/services/clips_history.py
@@ -8,7 +8,8 @@
Entry shape (all fields beyond the core render record are optional):
id, source_video, start_second, end_second, caption_style, crop_strategy,
logo_path?, title, output_path, file_size_mb, duration, created_at,
- content_type?, transcript_slice?, youtube_video_id?, metrics?
+ content_type?, transcript_slice?, youtube_video_id?, metrics?,
+ generated_titles?, description?, tags?, hashtags?
metrics? = {views?, retention?, ctr?, impressions?, fetched_at?} (Phase 2)
diff --git a/cli/internal/update/update.go b/cli/internal/update/update.go
index 4a3fb7f..b190c81 100644
--- a/cli/internal/update/update.go
+++ b/cli/internal/update/update.go
@@ -1,10 +1,10 @@
-// Package update checks GitHub Releases for a newer podcli and (once releases
-// publish per-platform binaries) applies it. For now a manual update points the
-// user at their package manager, matching the npm/bun reinstall fallback.
+// Package update checks GitHub Releases for a newer podcli and applies the
+// release binary for this platform.
package update
import (
"encoding/json"
+ "errors"
"fmt"
"io"
"net/http"
@@ -22,6 +22,27 @@ import (
const repo = "nmbrthirteen/podcli"
+type updatePhase string
+
+const (
+ phaseDownload updatePhase = "download"
+ phaseVerify updatePhase = "verify"
+ phaseInstall updatePhase = "install"
+)
+
+type phaseError struct {
+ phase updatePhase
+ err error
+}
+
+func (e *phaseError) Error() string {
+ return e.err.Error()
+}
+
+func (e *phaseError) Unwrap() error {
+ return e.err
+}
+
func exeExt() string {
if runtime.GOOS == "windows" {
return ".exe"
@@ -29,8 +50,7 @@ func exeExt() string {
return ""
}
-// managedBin is the binary the npm shim and direct installs both exec, so
-// replacing it updates podcli regardless of how it was installed.
+// managedBin is the binary direct installs exec, so replacing it updates podcli.
func managedBin() string {
return filepath.Join(paths.BinDir(), "podcli"+exeExt())
}
@@ -139,35 +159,57 @@ func Run(current string) int {
}
fmt.Printf("Updating podcli %s → %s ...\n", current, tag)
if err := apply(tag); err != nil {
- fmt.Fprintf(os.Stderr, "podcli: self-update failed (%v).\n", err)
- fmt.Fprintln(os.Stderr, "Reinstall via your package manager: npm i -g podcli (or: bun add -g podcli)")
+ printSelfUpdateFailure(os.Stderr, err)
return 1
}
fmt.Printf("Updated to podcli %s.\n", tag)
return 0
}
+func printSelfUpdateFailure(w io.Writer, err error) {
+ fmt.Fprintf(w, "podcli: self-update failed (%v).\n", err)
+ fmt.Fprintln(w, "Your installed podcli was left unchanged.")
+
+ if phaseOf(err) == phaseDownload {
+ fmt.Fprintln(w, "Download failed. Check your network connection, then run `podcli update` again.")
+ return
+ }
+
+ fmt.Fprintln(w, "Run `podcli update` again. If it keeps failing, install the latest release binary manually.")
+}
+
+func phaseOf(err error) updatePhase {
+ var pe *phaseError
+ if errors.As(err, &pe) {
+ return pe.phase
+ }
+ return ""
+}
+
// apply downloads the release binary for this platform and swaps the managed
// binary atomically.
func apply(tag string) error {
dest := managedBin()
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
- return err
+ return &phaseError{phase: phaseInstall, err: err}
}
staged := dest + ".new"
if err := downloadFile(assetURL(tag), staged); err != nil {
- return err
+ return &phaseError{phase: phaseDownload, err: err}
}
if err := verifyStaged(tag, staged); err != nil {
os.Remove(staged)
- return err
+ return &phaseError{phase: phaseVerify, err: err}
}
if runtime.GOOS != "windows" {
if err := os.Chmod(staged, 0o755); err != nil {
- return err
+ return &phaseError{phase: phaseInstall, err: err}
}
}
- return swap(staged, dest)
+ if err := swap(staged, dest); err != nil {
+ return &phaseError{phase: phaseInstall, err: err}
+ }
+ return nil
}
// verifyStaged checks the downloaded binary against the release's checksums.txt.
diff --git a/cli/internal/update/update_test.go b/cli/internal/update/update_test.go
index 7be03c2..c6ec7c7 100644
--- a/cli/internal/update/update_test.go
+++ b/cli/internal/update/update_test.go
@@ -1,8 +1,11 @@
package update
import (
+ "bytes"
+ "errors"
"os"
"path/filepath"
+ "strings"
"testing"
)
@@ -43,3 +46,31 @@ func TestNewer(t *testing.T) {
}
}
}
+
+func TestSelfUpdateFailureOmitsPackageManagerFallback(t *testing.T) {
+ var out bytes.Buffer
+ printSelfUpdateFailure(&out, errors.New("boom"))
+
+ got := out.String()
+ for _, unwanted := range []string{"npm", "bun", "package manager"} {
+ if strings.Contains(got, unwanted) {
+ t.Fatalf("failure output contains %q:\n%s", unwanted, got)
+ }
+ }
+ if !strings.Contains(got, "Your installed podcli was left unchanged.") {
+ t.Fatalf("failure output should say the install is unchanged:\n%s", got)
+ }
+}
+
+func TestDownloadFailureSuggestsRetry(t *testing.T) {
+ var out bytes.Buffer
+ printSelfUpdateFailure(&out, &phaseError{phase: phaseDownload, err: errors.New("network down")})
+
+ got := out.String()
+ if !strings.Contains(got, "Download failed.") {
+ t.Fatalf("download failure output should identify the download failure:\n%s", got)
+ }
+ if strings.Contains(got, "latest release binary manually") {
+ t.Fatalf("download failure output should not suggest manual install first:\n%s", got)
+ }
+}
diff --git a/src/models/index.ts b/src/models/index.ts
index d92d9ca..ce95d90 100644
--- a/src/models/index.ts
+++ b/src/models/index.ts
@@ -238,6 +238,12 @@ export interface ClipHistoryEntry {
thumbnail_config?: ClipThumbnailConfig;
youtube_video_id?: string;
metrics?: ClipPerformanceMetrics;
+ // AI-generated publishing metadata (titles/description/tags/hashtags), persisted
+ // so it survives a page reload instead of vanishing after generation.
+ generated_titles?: string[];
+ description?: string;
+ tags?: string;
+ hashtags?: string;
}
// === Knowledge Base Models ===
diff --git a/src/server.ts b/src/server.ts
index b1e94f1..02a8bfe 100644
--- a/src/server.ts
+++ b/src/server.ts
@@ -1,5 +1,6 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
+import { readFileSync, writeFileSync } from "fs";
import {
transcribeToolDef,
@@ -1891,6 +1892,52 @@ export function createServer(): McpServer {
},
);
+ // =============================================
+ // Tool: manage_thumbnail_config
+ // =============================================
+ server.tool(
+ "manage_thumbnail_config",
+ "Show, export, import, or reset the thumbnail template (colors, fonts, frame, box, layout) podcli uses to generate thumbnails. 'show' returns the effective config; 'export' writes it to a file path; 'import' replaces it from a file path; 'reset' reverts to the generic default.",
+ {
+ action: z.enum(["show", "export", "import", "reset"]).describe("Config action"),
+ path: z.string().optional().describe("File path for export (destination) or import (source)"),
+ },
+ async ({ action, path: filePath }) => {
+ try {
+ if ((action === "export" || action === "import") && !filePath) {
+ return mcpError(`'path' is required for action '${action}'.`);
+ }
+ const base = "http://localhost:3847/api/thumbnail-config";
+ if (action === "show") {
+ const res = await fetch(base);
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
+ return { content: [{ type: "text" as const, text: JSON.stringify(await res.json(), null, 2) }] };
+ }
+ if (action === "export") {
+ const res = await fetch(base);
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
+ writeFileSync(filePath!, JSON.stringify(await res.json(), null, 2), "utf-8");
+ return { content: [{ type: "text" as const, text: `Exported thumbnail config to ${filePath}` }] };
+ }
+ if (action === "import") {
+ const parsed = JSON.parse(readFileSync(filePath!, "utf-8"));
+ const res = await fetch(base, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(parsed) });
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
+ return { content: [{ type: "text" as const, text: `Imported thumbnail config from ${filePath}` }] };
+ }
+ const res = await fetch(`${base}/reset`, { method: "POST", headers: { "Content-Type": "application/json" }, body: "{}" });
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
+ return { content: [{ type: "text" as const, text: "Reset thumbnail config to the generic default." }] };
+ } catch (err: unknown) {
+ const msg = err instanceof Error ? err.message : String(err);
+ if (msg.includes("ECONNREFUSED") || msg.includes("fetch failed")) {
+ return { content: [{ type: "text" as const, text: "Web UI is not running. Start with: npm run ui" }] };
+ }
+ return mcpError(msg);
+ }
+ },
+ );
+
// =============================================
// Tool: analyze_energy
// =============================================
diff --git a/src/ui/client/ClipDetail.tsx b/src/ui/client/ClipDetail.tsx
index 62ac309..18304f8 100644
--- a/src/ui/client/ClipDetail.tsx
+++ b/src/ui/client/ClipDetail.tsx
@@ -29,6 +29,10 @@ interface Clip {
content_type?: string;
transcript_slice?: string;
thumbnail_config?: ThumbnailConfig;
+ generated_titles?: string[];
+ description?: string;
+ tags?: string;
+ hashtags?: string;
}
const CAPTION_STYLES = ["branded", "hormozi", "karaoke", "subtle"];
@@ -150,6 +154,30 @@ export default function ClipDetail() {
} catch (e: any) { setMsg(`DaVinci export failed: ${e.message}`); } finally { setBusy(null); }
};
+ const generateContent = async () => {
+ setBusy("content"); setMsg(null);
+ try {
+ const body = {
+ clip: {
+ id: clip.id, title: clip.title,
+ start_second: clip.start_second, end_second: clip.end_second,
+ content_type: clip.content_type,
+ },
+ transcript_segments: clip.transcript_slice
+ ? [{ start: clip.start_second, text: clip.transcript_slice }]
+ : [],
+ };
+ const r = await api("/generate-content", { method: "POST", body: JSON.stringify(body) });
+ if (r.error) throw new Error(r.error);
+ if (!r.titles?.length && !r.description) throw new Error("AI CLI returned nothing — is claude/codex installed?");
+ load();
+ } catch (e: any) { setMsg(`Content generation failed: ${e.message}`); } finally { setBusy(null); }
+ };
+
+ const copy = (text: string) => {
+ navigator.clipboard?.writeText(text).then(() => setMsg("Copied to clipboard"), () => {});
+ };
+
const del = async () => {
if (!window.confirm(`Delete "${clip.title}"? This removes the rendered file too.`)) return;
setBusy("delete"); setMsg(null);
@@ -248,6 +276,60 @@ export default function ClipDetail() {
)}
+
+
+
+
+
+ {!clip.generated_titles?.length && !clip.description ? (
+
Generate titles, a description, tags, and hashtags for this clip.
+ ) : (
+
+ {clip.generated_titles?.length ? (
+
+
Title options · click to use
+
+ {clip.generated_titles.map((t, i) => (
+
+ ))}
+
+
+ ) : null}
+ {clip.description ? (
+
+
+ Description
+
+
+
{clip.description}
+
+ ) : null}
+ {clip.tags ? (
+
+
+ Tags
+
+
+
{clip.tags}
+
+ ) : null}
+ {clip.hashtags ? (
+
+
+ Hashtags
+
+
+
{clip.hashtags}
+
+ ) : null}
+
+ )}
+
+
{clip.transcript_slice && (
diff --git a/src/ui/client/EpisodeWorkspace.jsx b/src/ui/client/EpisodeWorkspace.jsx
index edbf601..ac856f8 100644
--- a/src/ui/client/EpisodeWorkspace.jsx
+++ b/src/ui/client/EpisodeWorkspace.jsx
@@ -1814,7 +1814,7 @@ const fmt = (s) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(
)}
{activePreset && (
-
+
Preset {'\u00B7'} {activePreset}
)}
diff --git a/src/ui/client/ThumbnailTemplate.tsx b/src/ui/client/ThumbnailTemplate.tsx
index b043a91..74fa22e 100644
--- a/src/ui/client/ThumbnailTemplate.tsx
+++ b/src/ui/client/ThumbnailTemplate.tsx
@@ -1,4 +1,4 @@
-import React, { useEffect, useState } from "react";
+import React, { useEffect, useRef, useState } from "react";
import { api, labelStyle } from "./lib";
type Cfg = Record
;
@@ -58,23 +58,54 @@ function padToPreview(pad: any): string {
export default function ThumbnailTemplate() {
const [cfg, setCfg] = useState(null);
- const [busy, setBusy] = useState(false);
+ const [busy, setBusy] = useState(null);
const [msg, setMsg] = useState(null);
+ const importRef = useRef(null);
- useEffect(() => {
- api("/thumbnail-config").then((d) => setCfg(d && typeof d === "object" ? d : {})).catch(() => setCfg({}));
- }, []);
+ const load = () => api("/thumbnail-config").then((d) => setCfg(d && typeof d === "object" ? d : {})).catch(() => setCfg({}));
+ useEffect(() => { load(); }, []);
if (!cfg) return ;
const set = (k: string, v: any) => setCfg({ ...cfg, [k]: v });
const save = async () => {
- setBusy(true); setMsg(null);
+ setBusy("save"); setMsg(null);
try {
const r = await api("/thumbnail-config", { method: "PUT", body: JSON.stringify(cfg) });
if (r.error) throw new Error(r.error);
setMsg("Saved — new thumbnails use this template");
- } catch (e: any) { setMsg(`Save failed: ${e.message}`); } finally { setBusy(false); }
+ } catch (e: any) { setMsg(`Save failed: ${e.message}`); } finally { setBusy(null); }
+ };
+
+ const exportCfg = () => {
+ const blob = new Blob([JSON.stringify(cfg, null, 2)], { type: "application/json" });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ a.href = url; a.download = "thumbnail-config.json"; a.click();
+ URL.revokeObjectURL(url);
+ };
+
+ const importCfg = async (f: File) => {
+ setBusy("import"); setMsg(null);
+ try {
+ const parsed = JSON.parse(await f.text());
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("not a config object");
+ const r = await api("/thumbnail-config", { method: "PUT", body: JSON.stringify(parsed) });
+ if (r.error) throw new Error(r.error);
+ await load();
+ setMsg("Imported — new thumbnails use this template");
+ } catch (e: any) { setMsg(`Import failed: ${e.message}`); } finally { setBusy(null); }
+ };
+
+ const reset = async () => {
+ if (!window.confirm("Reset to the generic default template? Your current settings will be removed.")) return;
+ setBusy("reset"); setMsg(null);
+ try {
+ const r = await api("/thumbnail-config/reset", { method: "POST", body: "{}" });
+ if (r.error) throw new Error(r.error);
+ await load();
+ setMsg("Reset to the generic default template");
+ } catch (e: any) { setMsg(`Reset failed: ${e.message}`); } finally { setBusy(null); }
};
const field = (f: Field) => {
@@ -135,8 +166,12 @@ export default function ThumbnailTemplate() {
-
-
+
+
+
+
+
+
e.target.files?.[0] && importCfg(e.target.files[0])} />
{msg &&
{msg}
}
diff --git a/src/ui/client/index.html b/src/ui/client/index.html
index 07b0455..2713439 100644
--- a/src/ui/client/index.html
+++ b/src/ui/client/index.html
@@ -6,14 +6,14 @@
Podcli — AI Podcast Clip Studio
-
+
diff --git a/src/ui/public/css/styles.css b/src/ui/public/css/styles.css
index 2fb3e66..d5a95c9 100644
--- a/src/ui/public/css/styles.css
+++ b/src/ui/public/css/styles.css
@@ -1,18 +1,24 @@
/* ─── podcli — Shared Styles ─── */
:root {
- --bg: #0b0b0d;
+ --bg: #08080a;
+ --bg2: #0e0e11;
--surface: #131316;
- --surface2: #1b1b20;
- --border: #252530;
- --border-hover: #35354a;
- --text: #e8e8e2;
- --text2: #77776f;
- --text3: #4e4e48;
- --accent: #d4874a;
- --accent-hover: #e09860;
- --accent-subtle: rgba(212, 135, 74, 0.10);
- --accent-glow: rgba(212, 135, 74, 0.25);
+ --surface2: #1a1a1f;
+ --surface3: #222228;
+ --border: #1b1b22;
+ --border-hover: #2b2833;
+ --border-h: #2b2833;
+ --text: #e8e6df;
+ --text2: #9a9388;
+ --text3: #8b8579;
+ --accent: #db8b48;
+ --accent-hover: #ec9d5b;
+ --accent-2: #f1b369;
+ --accent-edge: #9c5a28;
+ --accent-subtle: rgba(219, 139, 72, 0.12);
+ --accent-dim: rgba(219, 139, 72, 0.12);
+ --accent-glow: rgba(219, 139, 72, 0.32);
--green: #4ade80;
--green-subtle: rgba(74, 222, 128, 0.07);
--green-border: rgba(74, 222, 128, 0.18);
@@ -22,20 +28,49 @@
--blue: #60a5fa;
--blue-subtle: rgba(96, 165, 250, 0.08);
--blue-border: rgba(96, 165, 250, 0.18);
+ --yellow: #facc15;
+ --glass-bg: rgba(22, 20, 24, 0.55);
+ --glass-border: rgba(255, 255, 255, 0.055);
+ --glass-hi: rgba(255, 255, 255, 0.05);
--radius: 12px;
--radius-sm: 8px;
+ --radius-lg: 16px;
--ease: cubic-bezier(0.4, 0, 0.2, 1);
--ease-out: cubic-bezier(0.23, 1, 0.32, 1);
+ --ease-in-out: cubic-bezier(0.77, 0, 0.175, 1);
+ --font-sans: 'Instrument Sans', -apple-system, BlinkMacSystemFont, sans-serif;
+ --font-mono: 'JetBrains Mono', 'SF Mono', ui-monospace, monospace;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
- font-family: 'Plus Jakarta Sans', -apple-system, BlinkMacSystemFont, sans-serif;
+ font-family: var(--font-sans);
background: var(--bg); color: var(--text); min-height: 100vh;
-webkit-font-smoothing: antialiased;
+ text-rendering: optimizeLegibility;
}
+::selection { background: var(--accent); color: #000; }
+
+/* ─── Landing patterns: glass surfaces ─── */
+.glass {
+ background: var(--glass-bg);
+ backdrop-filter: blur(16px) saturate(1.3);
+ -webkit-backdrop-filter: blur(16px) saturate(1.3);
+ box-shadow: 0 0 0 1px var(--glass-border), 0 8px 40px rgba(0, 0, 0, 0.4),
+ inset 0 1px 0 var(--glass-hi);
+}
+.glass-hover { transition: transform 0.3s var(--ease-out), box-shadow 0.3s var(--ease-out); }
+@media (hover: hover) and (pointer: fine) {
+ .glass-hover:hover {
+ transform: translateY(-3px);
+ box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.08),
+ 0 18px 50px rgba(0, 0, 0, 0.5), inset 0 1px 0 var(--glass-hi);
+ }
+}
+.img-outline { box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.07); }
+
/* ─── Layout ─── */
.app { max-width: 1080px; margin: 0 auto; padding: 48px 32px 96px; }
.header { margin-bottom: 36px; }
@@ -374,7 +409,7 @@ textarea {
}
select {
appearance: none; cursor: pointer; padding-right: 34px;
- background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6' fill='none'%3E%3Cpath d='M1 1L5 5L9 1' stroke='%2377776f' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6' fill='none'%3E%3Cpath d='M1 1L5 5L9 1' stroke='%239a9388' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
background-repeat: no-repeat; background-position: right 14px center;
}
@@ -382,13 +417,18 @@ select {
.btn {
padding: 10px 18px; border-radius: var(--radius); font-family: inherit;
font-size: 13px; font-weight: 600; cursor: pointer; border: none;
- transition: transform 0.12s var(--ease), background 0.15s var(--ease), border-color 0.15s var(--ease), color 0.15s var(--ease), opacity 0.15s var(--ease);
+ transition: transform 0.16s var(--ease-out), background 0.15s var(--ease), box-shadow 0.16s var(--ease-out), border-color 0.15s var(--ease), color 0.15s var(--ease), opacity 0.15s var(--ease);
display: inline-flex;
align-items: center; justify-content: center; gap: 6px; text-decoration: none;
}
.btn:active:not(:disabled) { transform: scale(0.97); }
-.btn-primary { background: var(--accent); color: #fff; }
+.btn-primary {
+ background: var(--accent); color: #241803;
+ box-shadow: 0 4px 0 0 var(--accent-edge), 0 10px 20px -6px rgba(0, 0, 0, 0.5),
+ inset 0 1px 0 rgba(255, 255, 255, 0.18);
+}
.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); }
@@ -397,10 +437,28 @@ select {
.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;
- background: var(--accent); color: #fff; border-radius: var(--radius);
+ background: var(--accent); color: #241803; border-radius: var(--radius);
+ box-shadow: 0 5px 0 0 var(--accent-edge), 0 12px 22px -6px rgba(0, 0, 0, 0.55),
+ inset 0 1px 0 rgba(255, 255, 255, 0.18);
}
-.btn-go:hover:not(:disabled) { background: var(--accent-hover); box-shadow: 0 4px 24px var(--accent-glow); }
+.btn-go:hover:not(:disabled) { background: var(--accent-hover); }
+.btn-go:active:not(:disabled) { transform: translateY(5px); box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.18); }
.btn-go:disabled { opacity: 0.25; cursor: not-allowed; }
+.title-option {
+ text-align: left; width: 100%; padding: 9px 12px; cursor: pointer;
+ background: var(--surface); border: 1px solid var(--border);
+ border-radius: var(--radius-sm); color: var(--text); font-family: inherit;
+ font-size: 13px; line-height: 1.4;
+ 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); }
.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); }
@@ -512,8 +570,8 @@ select {
}
.clip-item:hover { border-color: var(--border-hover); }
.clip-item.dimmed { opacity: 0.35; }
-.clip-item.active-clip { border-color: rgba(212, 135, 74, 0.3); background: rgba(212, 135, 74, 0.03); }
-.clip-item.selected { border-color: var(--accent); background: rgba(212, 135, 74, 0.04); }
+.clip-item.active-clip { border-color: rgba(219, 139, 72, 0.3); background: rgba(219, 139, 72, 0.03); }
+.clip-item.selected { border-color: var(--accent); background: rgba(219, 139, 72, 0.04); }
.checkbox {
width: 20px; height: 20px; border-radius: 6px;
border: 1.5px solid var(--border); display: flex;
diff --git a/src/ui/web-server.ts b/src/ui/web-server.ts
index 51cc0a8..6d46ac6 100644
--- a/src/ui/web-server.ts
+++ b/src/ui/web-server.ts
@@ -24,6 +24,7 @@ import { mkdir, readdir, unlink } from "fs/promises";
import path from "path";
import { join, dirname, basename, extname, resolve } from "path";
import { execSync, spawn } from "child_process";
+import { tmpdir } from "os";
import { fileURLToPath } from "url";
import { v4 as uuidv4 } from "uuid";
@@ -40,6 +41,7 @@ import { sliceTranscript, sliceWords, findContentType } from "../utils/transcrip
import { errMsg } from "../utils/errors.js";
import type {
BatchClipsResult,
+ ClipHistoryEntry,
ClipResult,
ProgressEvent,
SuggestedClip,
@@ -1077,26 +1079,41 @@ app.get("/api/stream-source", (req, res) => {
streamVideo(req, res, filePath, mimeTypes[extname(filePath).toLowerCase()] || "video/mp4");
});
-app.get("/api/thumbnail-config", (_req, res) => {
- try {
- res.json(JSON.parse(readFileSync(paths.thumbnailConfig, "utf-8")));
- } catch {
- res.json({});
- }
+// Route through the CLI so the Studio and the renderer resolve the same config
+// path (a direct read here can miss it under the launcher's data dir).
+app.get("/api/thumbnail-config", async (_req, res) => {
+ const r = await runCli(["thumbnail-config", "show"]);
+ if (r.code !== 0) { res.json({}); return; }
+ try { res.json(JSON.parse(r.stdout)); } catch { res.json({}); }
});
-app.put("/api/thumbnail-config", (req, res) => {
+app.put("/api/thumbnail-config", async (req, res) => {
+ const tmp = join(tmpdir(), `podcli-tc-${uuidv4().slice(0, 8)}.json`);
try {
- let current: Record = {};
- try { current = JSON.parse(readFileSync(paths.thumbnailConfig, "utf-8")); } catch { /* new file */ }
- const merged = { ...current, ...(req.body || {}) };
- writeFileSync(paths.thumbnailConfig, JSON.stringify(merged, null, 2), "utf-8");
+ writeFileSync(tmp, JSON.stringify(req.body || {}), "utf-8");
+ const r = await runCli(["thumbnail-config", "import", tmp]);
+ if (r.code !== 0) throw new Error(stripAnsi(r.stderr || r.stdout) || "save failed");
res.json({ ok: true });
} catch (e: any) {
res.status(500).json({ error: e.message });
+ } finally {
+ try { await unlink(tmp); } catch { /* best effort */ }
}
});
+app.get("/api/thumbnail-config/export", async (_req, res) => {
+ const r = await runCli(["thumbnail-config", "show"]);
+ if (r.code !== 0) { res.status(500).json({ error: "export failed" }); return; }
+ res.setHeader("Content-Disposition", 'attachment; filename="thumbnail-config.json"');
+ res.type("application/json").send(r.stdout);
+});
+
+app.post("/api/thumbnail-config/reset", async (_req, res) => {
+ const r = await runCli(["thumbnail-config", "reset"]);
+ if (r.code !== 0) { res.status(500).json({ error: stripAnsi(r.stderr || r.stdout) || "reset failed" }); return; }
+ res.json({ ok: true });
+});
+
app.get("/api/image", (req, res) => {
const raw = req.query.path as string;
const mimes: Record = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp", ".gif": "image/gif" };
@@ -2244,7 +2261,20 @@ app.post("/api/generate-content", async (req, res) => {
}),
);
- res.json(result.data);
+ const data: any = result.data || {};
+ // Persist onto the clip's history entry so the generated metadata survives a
+ // reload — generation is expensive and was previously discarded after display.
+ const clipId = clip?.id ? await clipsHistory.resolveId(String(clip.id)) : null;
+ if (clipId) {
+ const patch: Partial = {};
+ if (Array.isArray(data.titles) && data.titles.length) patch.generated_titles = data.titles;
+ if (data.description) patch.description = data.description;
+ if (data.tags) patch.tags = data.tags;
+ if (data.hashtags) patch.hashtags = data.hashtags;
+ if (Object.keys(patch).length) await clipsHistory.update(clipId, patch);
+ }
+
+ res.json(data);
} catch (err: any) {
res.status(500).json({
error: `Content generation failed: ${err.message?.substring(0, 200)}`,