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
Loading…
; 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)}`,