diff --git a/README.md b/README.md index 0afe02f..2d2e71c 100644 --- a/README.md +++ b/README.md @@ -163,6 +163,8 @@ Both halves share the same **knowledge base** (`.podcli/knowledge/`) — your sh ## Quick Start +**macOS / Linux** + ```bash git clone https://github.com/nmbrthirteen/podcli.git cd podcli @@ -170,6 +172,15 @@ chmod +x setup.sh podcli ./setup.sh ``` +**Windows (PowerShell)** + +```powershell +git clone https://github.com/nmbrthirteen/podcli.git +cd podcli +# If scripts are blocked: Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass +.\setup.ps1 +``` + This will: 1. Check system dependencies (Node, Python, FFmpeg) @@ -188,6 +199,8 @@ This will: ./setup.sh --mcp # print MCP config for Claude ``` +On Windows, use `.\setup.ps1` with `-Install`, `-Ui`, or `-Mcp`, and run the CLI via `podcli.cmd` (e.g. `podcli process video.mp4 --top 5`). + --- ## Usage diff --git a/backend/cli.py b/backend/cli.py index be1304f..aa25ffe 100644 --- a/backend/cli.py +++ b/backend/cli.py @@ -25,7 +25,7 @@ except ImportError: pass if os.path.exists(_env_file): - with open(_env_file) as _f: + with open(_env_file, encoding="utf-8") as _f: for _line in _f: _line = _line.strip() if _line and not _line.startswith("#") and "=" in _line: @@ -40,6 +40,23 @@ os.environ.setdefault("PODCLI_TRANSITION_AUTOFIX_PASSES", "2") sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + + +def _reveal_in_os(path: str) -> None: + """Open a file or folder in the OS file manager / default app. Best-effort.""" + try: + if sys.platform == "darwin": + import subprocess + subprocess.Popen(["open", path]) + elif sys.platform == "win32": + os.startfile(path) # type: ignore[attr-defined] + else: + import subprocess + subprocess.Popen(["xdg-open", path]) + except Exception as exc: + print(f" ⚠ Could not open {path}: {exc}", file=sys.stderr) + + from config.paths import paths from presets import MIN_CLIP_DURATION, MAX_CLIP_DURATION, TARGET_CLIP_DURATION_MIN, TARGET_CLIP_DURATION_MAX @@ -441,7 +458,7 @@ def cmd_process(args): if args.transcript: print(" [1/4] Loading transcript...") - with open(args.transcript, "r") as f: + with open(args.transcript, "r", encoding="utf-8") as f: raw_text = f.read() # Detect format @@ -633,7 +650,7 @@ def _transcribe_progress(pct, msg): if _thumb_enabled and os.path.exists(_tc_path): try: - with open(_tc_path) as _tcf: + with open(_tc_path, encoding="utf-8") as _tcf: _thumb_cfg = json.load(_tcf) _thumb_enabled = _thumb_cfg.get("enabled", True) _thumb_intro_duration = float( @@ -746,7 +763,7 @@ def _transcribe_progress(pct, msg): if content_result and content_result.get("raw_text"): # Save per-clip content to file _content_path = result["output_path"].replace(".mp4", "_content.md") - with open(_content_path, "w") as _cf: + with open(_content_path, "w", encoding="utf-8") as _cf: _cf.write(f"# {clip.get('title', 'Clip')}\n\n{content_result['raw_text']}") # Pretty-print in terminal @@ -778,8 +795,7 @@ def _transcribe_progress(pct, msg): # ── Per-clip review: open video, ask for feedback ── if ok and not _skip_review and result.get("output_path") and os.path.exists(result["output_path"]): - import subprocess as _review_sp - _review_sp.Popen(["open", result["output_path"]] if sys.platform == "darwin" else ["xdg-open", result["output_path"]]) + _reveal_in_os(result["output_path"]) while True: import questionary as _rq @@ -910,7 +926,7 @@ def _transcribe_progress(pct, msg): results[-1] = result print(f" ✓ Re-rendered: {result['file_size_mb']}MB") # Open new version - _review_sp.Popen(["open", result["output_path"]] if sys.platform == "darwin" else ["xdg-open", result["output_path"]]) + _reveal_in_os(result["output_path"]) except Exception as _re: print(f" ✗ {_re}") break @@ -1354,10 +1370,9 @@ def _post_render_loop( def _open_clip(r): """Open a rendered clip for preview.""" - import subprocess as _sp out = r["result"].get("output_path", "") if out and os.path.exists(out): - _sp.Popen(["open", out] if sys.platform == "darwin" else ["xdg-open", out]) + _reveal_in_os(out) def _rerender_clip(r): """Re-render a clip with current config.""" @@ -1471,8 +1486,7 @@ def _rerender_clip(r): ).ask() if action is None or action == "done": - import subprocess as _sp - _sp.run(["open", output_dir] if sys.platform == "darwin" else ["xdg-open", output_dir]) + _reveal_in_os(output_dir) break if action == "rerender": @@ -2438,7 +2452,7 @@ def cmd_knowledge(args): # Read first non-empty, non-header line as preview preview = "" try: - with open(fpath) as f: + with open(fpath, encoding="utf-8") as f: for line in f: line = line.strip() if line and not line.startswith("#") and not line.startswith("---"): @@ -2463,7 +2477,7 @@ def cmd_knowledge(args): if not os.path.exists(fpath): print(f" {red}✗{reset} Not found: {name}", file=sys.stderr) return - with open(fpath) as f: + with open(fpath, encoding="utf-8") as f: print(f.read()) elif action == "edit": @@ -2477,7 +2491,7 @@ def cmd_knowledge(args): os.makedirs(kb_dir, exist_ok=True) fpath = os.path.join(kb_dir, name) if content: - with open(fpath, "w") as f: + with open(fpath, "w", encoding="utf-8") as f: f.write(content) print(f" {green}✓{reset} Written: {name}") else: @@ -2667,7 +2681,7 @@ def cmd_clips(args): existing = {} try: if os.path.exists(ui_state_path): - with open(ui_state_path) as f: + with open(ui_state_path, encoding="utf-8") as f: existing = json.load(f) or {} except Exception: existing = {} @@ -2697,7 +2711,7 @@ def cmd_clips(args): "phase": "reviewing", } os.makedirs(os.path.dirname(ui_state_path), exist_ok=True) - with open(ui_state_path, "w") as f: + with open(ui_state_path, "w", encoding="utf-8") as f: json.dump(state, f, indent=2, ensure_ascii=False) print(f"\n {green}✓{reset} Reopened {accent}{str(clip.get('id'))[:8]}{reset} {bold}{clip.get('title')}{reset}") @@ -2890,7 +2904,7 @@ def cmd_cache(args): # Try to read the cached file to show what video it's for try: - with open(fpath) as f: + with open(fpath, encoding="utf-8") as f: data = json.load(f) n_words = len(data.get("words", [])) n_segs = len(data.get("segments", [])) @@ -2929,7 +2943,7 @@ def cmd_info(args): env_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", ".env") if os.path.exists(env_path): try: - with open(env_path) as f: + with open(env_path, encoding="utf-8") as f: for line in f: if line.strip().startswith("HF_TOKEN=") and line.strip().split("=", 1)[1].strip(): hf_token = line.strip().split("=", 1)[1].strip() @@ -2995,7 +3009,7 @@ def print_banner(): if not hf_token: env_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", ".env") if os.path.exists(env_path): - with open(env_path) as f: + with open(env_path, encoding="utf-8") as f: for line in f: if line.strip().startswith("HF_TOKEN=") and line.strip().split("=", 1)[1].strip(): hf_token = line.strip().split("=", 1)[1].strip() @@ -3454,14 +3468,16 @@ def interactive_menu(): spa = os.path.join(repo, "dist", "ui", "public", "index.html") port = os.environ.get("PORT", "3847") ok = True + # npm is npm.cmd on Windows; subprocess can't run a batch file without a shell. + _npm_shell = sys.platform == "win32" if not os.path.exists(spa): print(f"\n {gray}Building the studio (first run)…{reset}\n") - ok = sp.run(["npm", "run", "build"], cwd=repo).returncode == 0 + ok = sp.run(["npm", "run", "build"], cwd=repo, shell=_npm_shell).returncode == 0 if not ok: print(f"\n {yellow}Build failed — run 'npm install' then try again.{reset}\n") if ok: print(f"\n {gray}Studio:{reset} {accent}http://localhost:{port}{reset} {dim}(Ctrl+C to stop){reset}\n") - sp.run(["npm", "run", "ui:prod"], cwd=repo) + sp.run(["npm", "run", "ui:prod"], cwd=repo, shell=_npm_shell) elif choice == "assets": _interactive_assets() elif choice == "presets": diff --git a/backend/presets.py b/backend/presets.py index f186084..ff74696 100644 --- a/backend/presets.py +++ b/backend/presets.py @@ -63,7 +63,7 @@ def list_presets() -> list[dict]: name = f[:-5] path = os.path.join(PRESETS_DIR, f) try: - with open(path, "r") as fh: + with open(path, "r", encoding="utf-8") as fh: data = json.load(fh) presets.append({"name": name, **data}) except (json.JSONDecodeError, IOError): @@ -79,7 +79,7 @@ def get_preset(name: str) -> dict: return {**DEFAULT_PRESET} raise FileNotFoundError(f"Preset not found: {name}") - with open(path, "r") as f: + with open(path, "r", encoding="utf-8") as f: saved = json.load(f) # Merge with defaults so new keys are always present @@ -99,7 +99,7 @@ def save_preset(name: str, config: dict) -> str: continue # don't store the name inside the file to_save[key] = val - with open(path, "w") as f: + with open(path, "w", encoding="utf-8") as f: json.dump(to_save, f, indent=2) return path diff --git a/backend/services/asset_store.py b/backend/services/asset_store.py index b0c0672..61b2ef9 100644 --- a/backend/services/asset_store.py +++ b/backend/services/asset_store.py @@ -30,7 +30,7 @@ def _load() -> list[dict]: if not os.path.exists(path): return [] try: - with open(path) as f: + with open(path, encoding="utf-8") as f: data = json.load(f) return data.get("assets", []) if isinstance(data, dict) else data except (json.JSONDecodeError, IOError): @@ -39,7 +39,7 @@ def _load() -> list[dict]: def _save(assets: list[dict]): path = _registry_path() - with open(path, "w") as f: + with open(path, "w", encoding="utf-8") as f: json.dump({"assets": assets}, f, indent=2) diff --git a/backend/services/claude_suggest.py b/backend/services/claude_suggest.py index fb452d1..8e9fb12 100644 --- a/backend/services/claude_suggest.py +++ b/backend/services/claude_suggest.py @@ -108,7 +108,7 @@ def _run_ai_command( timeout=timeout, ) if os.path.exists(output_file): - with open(output_file) as f: + with open(output_file, encoding="utf-8") as f: result = subprocess.CompletedProcess( args=result.args, returncode=result.returncode, @@ -136,7 +136,7 @@ def _load_existing_shorts(episodes_path: str) -> list[str]: if not os.path.exists(episodes_path): return [] try: - with open(episodes_path) as f: + with open(episodes_path, encoding="utf-8") as f: content = f.read() # Parse lines that look like shorts entries: "1. [title] — [category]" shorts = [] @@ -187,7 +187,7 @@ def _build_prompt( fpath = os.path.join(kb_dir, fname) if os.path.exists(fpath): try: - with open(fpath) as f: + with open(fpath, encoding="utf-8") as f: content = f.read().strip() # Skip template-only files (uncustomized placeholders) if content.count("[Your Show Name]") > 2 and len(content) < 500: diff --git a/backend/services/clip_generator.py b/backend/services/clip_generator.py index ec6220e..9608bf4 100644 --- a/backend/services/clip_generator.py +++ b/backend/services/clip_generator.py @@ -504,7 +504,7 @@ def _render_with_remotion( "words": adjusted_words, "faceY": face_y_norm, # normalized face center Y (0-1), null if unknown } - with open(words_file, "w") as f: + with open(words_file, "w", encoding="utf-8") as f: json.dump(payload, f) cmd = [ diff --git a/backend/services/clips_history.py b/backend/services/clips_history.py index 171aa2d..98818e2 100644 --- a/backend/services/clips_history.py +++ b/backend/services/clips_history.py @@ -31,7 +31,7 @@ def load_clips_history() -> list[dict]: if not os.path.exists(_CLIPS_HISTORY_PATH): return [] try: - with open(_CLIPS_HISTORY_PATH) as f: + with open(_CLIPS_HISTORY_PATH, encoding="utf-8") as f: data = json.load(f) return data if isinstance(data, list) else [] except Exception: @@ -46,7 +46,7 @@ def save_clips_history(entries: list[dict]) -> str: """ os.makedirs(os.path.dirname(_CLIPS_HISTORY_PATH), exist_ok=True) tmp = f"{_CLIPS_HISTORY_PATH}.{os.getpid()}.tmp" - with open(tmp, "w") as f: + with open(tmp, "w", encoding="utf-8") as f: json.dump(entries, f, indent=2, ensure_ascii=False) os.replace(tmp, _CLIPS_HISTORY_PATH) return _CLIPS_HISTORY_PATH diff --git a/backend/services/content_generator.py b/backend/services/content_generator.py index de4d46c..e1d3ba5 100644 --- a/backend/services/content_generator.py +++ b/backend/services/content_generator.py @@ -28,7 +28,7 @@ def _load_kb_context() -> str: fpath = os.path.join(kb_dir, fname) if os.path.exists(fpath): try: - with open(fpath) as kf: + with open(fpath, encoding="utf-8") as kf: content = kf.read().strip() # Skip uncustomized templates if content.count("[Your Show Name]") > 2 and len(content) < 500: diff --git a/backend/services/corrections.py b/backend/services/corrections.py index 41c47a5..959c2fc 100644 --- a/backend/services/corrections.py +++ b/backend/services/corrections.py @@ -28,7 +28,7 @@ def _load_corrections() -> dict[str, str]: if not os.path.exists(_CORRECTIONS_PATH): return {} try: - with open(_CORRECTIONS_PATH) as f: + with open(_CORRECTIONS_PATH, encoding="utf-8") as f: data = json.load(f) if isinstance(data, dict): return data @@ -45,7 +45,7 @@ def get_corrections() -> dict[str, str]: def save_corrections(corrections: dict[str, str]) -> str: """Save corrections dict to .podcli/corrections.json. Returns the file path.""" os.makedirs(os.path.dirname(_CORRECTIONS_PATH), exist_ok=True) - with open(_CORRECTIONS_PATH, "w") as f: + with open(_CORRECTIONS_PATH, "w", encoding="utf-8") as f: json.dump(corrections, f, indent=2, ensure_ascii=False) return _CORRECTIONS_PATH diff --git a/backend/services/encoder.py b/backend/services/encoder.py index 87bcaaf..346fcb5 100644 --- a/backend/services/encoder.py +++ b/backend/services/encoder.py @@ -178,7 +178,7 @@ def get_encoder_info() -> dict: fp = _ffmpeg_fingerprint() try: - with open(cache_path) as f: + with open(cache_path, encoding="utf-8") as f: cached = json.load(f) if cached.get("fingerprint") == fp and cached.get("system") == platform.system(): return cached["info"] @@ -194,7 +194,7 @@ def get_encoder_info() -> dict: try: os.makedirs(os.path.dirname(cache_path), exist_ok=True) - with open(cache_path, "w") as f: + with open(cache_path, "w", encoding="utf-8") as f: json.dump({"fingerprint": fp, "system": platform.system(), "info": info}, f) except OSError: pass diff --git a/backend/services/integrations/youtube/client.py b/backend/services/integrations/youtube/client.py index 2181ea1..bf86873 100644 --- a/backend/services/integrations/youtube/client.py +++ b/backend/services/integrations/youtube/client.py @@ -26,7 +26,7 @@ def _yt_config() -> dict[str, Any]: """The 'youtube' block of integrations.json (client_id/client_secret/...).""" try: - with open(paths["integrations"]) as f: + with open(paths["integrations"], encoding="utf-8") as f: return (json.load(f).get("youtube") or {}) except Exception: return {} @@ -35,7 +35,7 @@ def _yt_config() -> dict[str, Any]: def is_authorized() -> bool: """True only if a cached token actually carries a refresh token (not just present).""" try: - with open(_TOKEN_PATH) as f: + with open(_TOKEN_PATH, encoding="utf-8") as f: return bool((json.load(f) or {}).get("refresh_token")) except (OSError, ValueError): return False diff --git a/backend/services/integrations/youtube/learnings.py b/backend/services/integrations/youtube/learnings.py index 0058a7c..829c603 100644 --- a/backend/services/integrations/youtube/learnings.py +++ b/backend/services/integrations/youtube/learnings.py @@ -23,7 +23,7 @@ def _existing_ai_block(path: str) -> str: if not os.path.exists(path): return "" - txt = open(path).read() + txt = open(path, encoding="utf-8").read() i, j = txt.find(AI_START), txt.find(AI_END) return txt[i:j + len(AI_END)] if (i != -1 and j != -1) else "" @@ -105,7 +105,7 @@ def write_learnings(min_clips: int = 3, ai_block: Optional[str] = None) -> Optio content = "\n".join(L) if block: content += "\n\n" + block + "\n" - with open(path, "w") as f: + with open(path, "w", encoding="utf-8") as f: f.write(content) return path @@ -148,7 +148,7 @@ def write_semantic_learnings(top_n: int = 4, min_total: int = 6) -> Optional[str ) os.makedirs(paths["working"], exist_ok=True) prompt_file = os.path.join(paths["working"], "_perf_analysis_prompt.txt") - with open(prompt_file, "w") as f: + with open(prompt_file, "w", encoding="utf-8") as f: f.write(prompt) try: res = _run_ai_command(cli_path, engine, prompt, prompt_file, paths["project_root"], timeout=180) diff --git a/backend/services/thumbnail_ai.py b/backend/services/thumbnail_ai.py index 9258a15..8eeef18 100644 --- a/backend/services/thumbnail_ai.py +++ b/backend/services/thumbnail_ai.py @@ -36,7 +36,7 @@ def _load_brand_config() -> dict: config_path = paths["thumbnailConfig"] if os.path.exists(config_path): try: - with open(config_path) as f: + with open(config_path, encoding="utf-8") as f: defaults.update(json.load(f)) except Exception: pass diff --git a/backend/services/thumbnail_generator.py b/backend/services/thumbnail_generator.py index 3a794e3..d468da6 100644 --- a/backend/services/thumbnail_generator.py +++ b/backend/services/thumbnail_generator.py @@ -98,7 +98,7 @@ def _load_config() -> dict: config_path = paths["thumbnailConfig"] if os.path.exists(config_path): try: - with open(config_path) as f: + with open(config_path, encoding="utf-8") as f: user = json.load(f) config.update(user) except (json.JSONDecodeError, IOError): @@ -116,21 +116,25 @@ def _get_font(size: int, bold: bool = True, italic: bool = False) -> ImageFont.F ttc_options = [ ("/System/Library/Fonts/HelveticaNeue.ttc", 3), # Helvetica Neue Bold Italic ("/System/Library/Fonts/Helvetica.ttc", 3), + ("C:/Windows/Fonts/arialbi.ttf", 0), ] elif bold: ttc_options = [ ("/System/Library/Fonts/HelveticaNeue.ttc", 1), # Helvetica Neue Bold ("/System/Library/Fonts/Helvetica.ttc", 1), + ("C:/Windows/Fonts/arialbd.ttf", 0), ] elif italic: ttc_options = [ ("/System/Library/Fonts/HelveticaNeue.ttc", 2), # Helvetica Neue Italic ("/System/Library/Fonts/Helvetica.ttc", 2), + ("C:/Windows/Fonts/ariali.ttf", 0), ] else: ttc_options = [ ("/System/Library/Fonts/HelveticaNeue.ttc", 0), # Helvetica Neue Regular ("/System/Library/Fonts/Helvetica.ttc", 0), + ("C:/Windows/Fonts/arial.ttf", 0), ] for path, idx in ttc_options: @@ -146,6 +150,7 @@ def _get_font(size: int, bold: bool = True, italic: bool = False) -> ImageFont.F fallbacks = [ "/System/Library/Fonts/Supplemental/Arial Bold.ttf", "/Library/Fonts/Arial Bold.ttf", + "C:/Windows/Fonts/arialbd.ttf", "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", "/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf", ] @@ -153,6 +158,7 @@ def _get_font(size: int, bold: bool = True, italic: bool = False) -> ImageFont.F fallbacks = [ "/System/Library/Fonts/Supplemental/Arial.ttf", "/Library/Fonts/Arial.ttf", + "C:/Windows/Fonts/arial.ttf", "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", ] diff --git a/backend/services/thumbnail_html.py b/backend/services/thumbnail_html.py index 3929f97..3aaaa52 100644 --- a/backend/services/thumbnail_html.py +++ b/backend/services/thumbnail_html.py @@ -116,7 +116,7 @@ def _load_config() -> dict: config_path = paths["thumbnailConfig"] if os.path.exists(config_path): try: - with open(config_path) as f: + with open(config_path, encoding="utf-8") as f: user = json.load(f) defaults.update(user) except Exception: @@ -127,7 +127,9 @@ def _load_config() -> dict: def _playwright_cli_candidates() -> list[list[str]]: """Return Playwright CLI commands in preference order.""" repo_root = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..") - local_bin = os.path.join(repo_root, "node_modules", ".bin", "playwright") + # On Windows the runnable shim is playwright.cmd; the extensionless file is a POSIX script. + local_name = "playwright.cmd" if sys.platform == "win32" else "playwright" + local_bin = os.path.join(repo_root, "node_modules", ".bin", local_name) candidates = [] if os.path.exists(local_bin): @@ -663,6 +665,8 @@ def generate_thumbnail( capture_output=True, text=True, timeout=timeout_s, + # .cmd/npx shims need cmd.exe on Windows; shell=True with a list breaks on POSIX. + shell=sys.platform == "win32", ) except subprocess.TimeoutExpired: errors.append(f"{cmd_label} timed out after {timeout_s} seconds") diff --git a/backend/services/transcript_packer.py b/backend/services/transcript_packer.py index ee4f513..2d7beec 100644 --- a/backend/services/transcript_packer.py +++ b/backend/services/transcript_packer.py @@ -364,7 +364,7 @@ def write_packed( md = pack_transcript(transcript, label, energy_data=energy_data) os.makedirs(_packed_dir(), exist_ok=True) out_path = packed_path_for(cache_hash) - with open(out_path, "w") as f: + with open(out_path, "w", encoding="utf-8") as f: f.write(md) return out_path, md diff --git a/backend/services/video_cut.py b/backend/services/video_cut.py index 55aea80..c064941 100644 --- a/backend/services/video_cut.py +++ b/backend/services/video_cut.py @@ -72,7 +72,7 @@ def cut_multi_segment( cut_segment(input_path, part_path, seg["start"], seg["end"]) part_paths.append(part_path) - with open(concat_file, "w") as f: + with open(concat_file, "w", encoding="utf-8") as f: for p in part_paths: f.write(f"file '{os.path.abspath(p)}'\n") diff --git a/backend/services/video_processor.py b/backend/services/video_processor.py index 7d828da..d953be9 100644 --- a/backend/services/video_processor.py +++ b/backend/services/video_processor.py @@ -1788,7 +1788,7 @@ def _is_segment_split(seg_start_t): # Concat all parts concat_file = os.path.join(work_dir, "_speaker_concat.txt") - with open(concat_file, "w") as f: + with open(concat_file, "w", encoding="utf-8") as f: for p in part_paths: f.write(f"file '{os.path.abspath(p)}'\n") @@ -2633,7 +2633,7 @@ def concat_outro( ) try: - with open(concat_list, "w") as f: + with open(concat_list, "w", encoding="utf-8") as f: f.write(f"file '{main_reenc}'\n") f.write(f"file '{outro_scaled}'\n") diff --git a/podcli.cmd b/podcli.cmd new file mode 100644 index 0000000..ef9d022 --- /dev/null +++ b/podcli.cmd @@ -0,0 +1,41 @@ +@echo off +setlocal enabledelayedexpansion +rem podcli - CLI wrapper (Windows) +rem Usage: podcli process video.mp4 --top 5 --transcript file.txt + +set "SCRIPT_DIR=%~dp0" + +rem Use venv python if available, else fall back to PYTHON_PATH in .env, else `python`. +set "PYTHON=python" +if exist "%SCRIPT_DIR%venv\Scripts\python.exe" ( + set "PYTHON=%SCRIPT_DIR%venv\Scripts\python.exe" +) else if exist "%SCRIPT_DIR%.env" ( + for /f "usebackq tokens=1,* delims==" %%A in ("%SCRIPT_DIR%.env") do ( + if /I "%%A"=="PYTHON_PATH" if not "%%B"=="" set "PYTHON=%%B" + ) +) + +rem Strip a leading / or -- from the first arg so /auto, --auto, auto all match. +set "CMD_CLEAN=%~1" +if defined CMD_CLEAN ( + if "!CMD_CLEAN:~0,2!"=="--" set "CMD_CLEAN=!CMD_CLEAN:~2!" + if "!CMD_CLEAN:~0,1!"=="/" set "CMD_CLEAN=!CMD_CLEAN:~1!" +) + +rem PodStack slash commands run inside Claude Code / Codex, not the terminal. +for %%P in (auto prep-episode process-transcript generate-titles generate-descriptions plan-thumbnails review-content publish-checklist retro-episode plan-episode produce-shorts) do ( + if /I "!CMD_CLEAN!"=="%%P" ( + echo. + echo /%%P is a PodStack command - it runs inside Claude Code or Codex, not the terminal. + echo. + echo How to use: + echo 1. Open Claude Code: claude + echo Or open Codex: codex --cd "%SCRIPT_DIR%" + echo 2. Type: /%%P + echo. + exit /b 0 + ) +) + +"%PYTHON%" -W ignore::UserWarning "%SCRIPT_DIR%backend\cli.py" %* +exit /b %ERRORLEVEL% diff --git a/setup.ps1 b/setup.ps1 new file mode 100644 index 0000000..7665e58 --- /dev/null +++ b/setup.ps1 @@ -0,0 +1,218 @@ +#Requires -Version 5.1 +<# + podcli - Install & Launch (Windows) + + Usage (from a PowerShell prompt in the repo root): + .\setup.ps1 # Install everything + launch UI + .\setup.ps1 -Install # Install only (no launch) + .\setup.ps1 -Ui # Launch UI only (skip install) + .\setup.ps1 -Mcp # Show MCP config (for Claude Desktop/Code) + + If scripts are blocked, run once: + Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass +#> +[CmdletBinding()] +param( + [switch]$Install, + [switch]$Ui, + [switch]$Mcp +) + +$ErrorActionPreference = "Stop" +$ScriptDir = $PSScriptRoot +Set-Location $ScriptDir + +$VenvPython = Join-Path $ScriptDir "venv\Scripts\python.exe" + +function Write-Banner { + Write-Host "" + Write-Host " +======================================+" + Write-Host " | podcli (Windows) |" + Write-Host " +======================================+" + Write-Host "" +} + +function Resolve-HostPython { + foreach ($candidate in @("python", "py")) { + $cmd = Get-Command $candidate -ErrorAction SilentlyContinue + if ($cmd) { return $candidate } + } + return $null +} + +function Invoke-Install { + Write-Host "--- [1/6] Checking system dependencies ---" + $missing = $false + + if (Get-Command ffmpeg -ErrorAction SilentlyContinue) { + Write-Host " ok FFmpeg" + } else { + Write-Host " X FFmpeg not found -> winco: choco install ffmpeg (or scoop install ffmpeg)" + $missing = $true + } + + $hostPy = Resolve-HostPython + if ($hostPy) { + $pyVer = (& $hostPy --version) 2>&1 + Write-Host " ok $pyVer" + } else { + Write-Host " X Python 3 not found -> https://www.python.org/downloads/ (check 'Add to PATH')" + $missing = $true + } + + if (Get-Command node -ErrorAction SilentlyContinue) { + Write-Host " ok Node $(node --version)" + } else { + Write-Host " X Node.js not found -> https://nodejs.org/" + $missing = $true + } + + if ($missing) { + Write-Host "" + Write-Host " Please install the missing dependencies above and re-run." + exit 1 + } + + Write-Host "" + Write-Host "--- [2/6] Creating directories ---" + $clipperHome = if ($env:PODCLI_HOME) { $env:PODCLI_HOME } else { Join-Path $ScriptDir ".podcli" } + $dataDir = if ($env:PODCLI_DATA) { $env:PODCLI_DATA } else { Join-Path $ScriptDir "data" } + foreach ($d in @("assets", "history", "knowledge")) { + New-Item -ItemType Directory -Force -Path (Join-Path $clipperHome $d) | Out-Null + } + foreach ($d in @("cache\transcripts", "working", "working\uploads", "output", "logs")) { + New-Item -ItemType Directory -Force -Path (Join-Path $dataDir $d) | Out-Null + } + Write-Host " ok $clipperHome (internal)" + Write-Host " ok $dataDir (output & cache)" + + $modelDir = Join-Path $ScriptDir "backend\models" + New-Item -ItemType Directory -Force -Path $modelDir | Out-Null + $caffeModel = Join-Path $modelDir "res10_300x300_ssd_iter_140000.caffemodel" + if (-not (Test-Path $caffeModel)) { + Write-Host " .. Downloading face detection model..." + $oldProgress = $ProgressPreference + $ProgressPreference = "SilentlyContinue" + Invoke-WebRequest -Uri "https://raw.githubusercontent.com/opencv/opencv/master/samples/dnn/face_detector/deploy.prototxt" ` + -OutFile (Join-Path $modelDir "deploy.prototxt") + Invoke-WebRequest -Uri "https://raw.githubusercontent.com/opencv/opencv_3rdparty/dnn_samples_face_detector_20170830/res10_300x300_ssd_iter_140000.caffemodel" ` + -OutFile $caffeModel + $ProgressPreference = $oldProgress + Write-Host " ok Face detection model ready" + } else { + Write-Host " ok Face detection model exists" + } + + Write-Host "" + Write-Host "--- [3/6] Python virtual environment ---" + if (-not (Test-Path $VenvPython)) { + & $hostPy -m venv venv + Write-Host " ok Created venv" + } else { + Write-Host " ok venv exists" + } + + Write-Host "" + Write-Host "--- [4/6] Installing Python packages ---" + & $VenvPython -m pip install --quiet --upgrade pip + & $VenvPython -m pip install --quiet -r backend\requirements.txt + Write-Host " ok Python packages ready" + + Write-Host "" + Write-Host "--- [5/6] Installing Node packages ---" + npm install --silent + Write-Host " ok Node packages ready" + + Write-Host "" + Write-Host "--- [6/6] Building TypeScript ---" + npx tsc + Write-Host " ok Build complete" + + if (-not (Test-Path ".env")) { + Copy-Item ".env.example" ".env" + } + $envLines = Get-Content ".env" + if ($envLines -match "^PYTHON_PATH=") { + $envLines = $envLines -replace "^PYTHON_PATH=.*", "PYTHON_PATH=$VenvPython" + } else { + $envLines += "PYTHON_PATH=$VenvPython" + } + Set-Content ".env" $envLines + Write-Host " ok .env configured (python: $VenvPython)" + + New-Item -ItemType Directory -Force -Path "dist\ui\public" | Out-Null + Copy-Item -Recurse -Force "src\ui\public\*" "dist\ui\public\" + + Write-Host "" + Write-Host " Installation complete!" + Write-Host "" +} + +function Invoke-LaunchUi { + if (Test-Path $VenvPython) { + $env:PYTHON_PATH = $VenvPython + Write-Host " Using Python: $VenvPython" + } + + if (Test-Path ".env") { + foreach ($line in Get-Content ".env") { + if ($line -match "^\s*#" -or $line -notmatch "=") { continue } + $parts = $line -split "=", 2 + $key = $parts[0].Trim() + $value = $parts[1].Trim() + if (-not $key -or $key -eq "PYTHON_PATH") { continue } + [Environment]::SetEnvironmentVariable($key, $value) + } + } + + if (-not (Test-Path "dist\ui\public\index.html")) { + Write-Host " Building the studio UI..." + npm run build + if ($LASTEXITCODE -ne 0) { + Write-Host " Build failed - run .\setup.ps1 -Install first." + exit 1 + } + } + + $port = if ($env:PORT) { $env:PORT } else { "3847" } + Write-Host " Studio: http://localhost:$port" + Write-Host "" + node dist\ui\web-server.js +} + +function Show-Mcp { + $distIndex = Join-Path $ScriptDir "dist\index.js" + Write-Host " -- Claude Desktop config --" + Write-Host " Add to %APPDATA%\Claude\claude_desktop_config.json:" + Write-Host "" + Write-Host " {" + Write-Host " `"mcpServers`": {" + Write-Host " `"podcli`": {" + Write-Host " `"command`": `"node`"," + Write-Host " `"args`": [`"$($distIndex -replace '\\','\\')`"]," + Write-Host " `"env`": {" + Write-Host " `"PYTHON_PATH`": `"$($VenvPython -replace '\\','\\')`"" + Write-Host " }" + Write-Host " }" + Write-Host " }" + Write-Host " }" + Write-Host "" + Write-Host " -- Claude Code --" + Write-Host " claude mcp add podcli -- node `"$distIndex`"" + Write-Host "" +} + +Write-Banner + +if ($Mcp) { + Show-Mcp +} elseif ($Ui) { + Invoke-LaunchUi +} elseif ($Install) { + Invoke-Install +} else { + Invoke-Install + Write-Host "----------------------------------------" + Write-Host "" + Invoke-LaunchUi +} diff --git a/src/ui/client/lib.ts b/src/ui/client/lib.ts index 2c0ab08..5859277 100644 --- a/src/ui/client/lib.ts +++ b/src/ui/client/lib.ts @@ -72,4 +72,4 @@ export function timeAgo(iso: string): string { return new Date(iso).toLocaleDateString(); } -export const basename = (p: string) => (p || "").split("/").pop() || ""; +export const basename = (p: string) => (p || "").split(/[/\\]/).pop() || ""; diff --git a/src/ui/web-server.ts b/src/ui/web-server.ts index d55bfbc..b2a0bde 100644 --- a/src/ui/web-server.ts +++ b/src/ui/web-server.ts @@ -388,6 +388,19 @@ app.get("/api/browse-file", (_req, res) => { encoding: "utf-8", timeout: 120_000, }).trim(); + } else if (process.platform === "win32") { + // EncodedCommand (UTF-16LE base64) sidesteps cmd→PowerShell quoting; -STA is required by WinForms dialogs. + const ps = [ + "Add-Type -AssemblyName System.Windows.Forms;", + "$f = New-Object System.Windows.Forms.OpenFileDialog;", + "$f.Filter = 'Media files|*.mp4;*.mov;*.mkv;*.webm;*.mp3;*.wav;*.m4a';", + "if ($f.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) { Write-Output $f.FileName }", + ].join(" "); + const encoded = Buffer.from(ps, "utf16le").toString("base64"); + filePath = execSync(`powershell -NoProfile -STA -EncodedCommand ${encoded}`, { + encoding: "utf-8", + timeout: 120_000, + }).trim(); } else { // Linux fallback filePath = execSync( @@ -1820,7 +1833,7 @@ function buildPromptForAction( const parts: string[] = []; const settingsSummary = [ - uiState.videoPath ? `Video: ${uiState.videoPath.split("/").pop()}` : null, + uiState.videoPath ? `Video: ${uiState.videoPath.split(/[/\\]/).pop()}` : null, `Style: ${uiState.settings.captionStyle}`, `Crop: ${uiState.settings.cropStrategy}`, uiState.settings.logoPath ? `Logo: set` : null,