From 12a18ebbc7b7018d73ab5437671630c5b91b6ba6 Mon Sep 17 00:00:00 2001 From: Teethat Kamsai <79137211+teethatkamsai@users.noreply.github.com> Date: Mon, 6 Jul 2026 12:07:50 +0700 Subject: [PATCH 1/4] Add AssemblyAI studio flow and align versions --- .github/workflows/release.yml | 1 + backend/cli.py | 21 +- backend/clip_studio.py | 11 +- backend/main.py | 42 +++- backend/services/content_generator.py | 8 +- backend/services/env_settings.py | 8 + backend/services/transcript_packer.py | 8 +- backend/services/transcription.py | 268 ++++++++++++++++++++- backend/utils/proc.py | 8 +- backend/version.py | 22 ++ cli/main.go | 3 +- cli/main_test.go | 20 +- install.ps1 | 36 +-- package-lock.json | 4 +- package.json | 2 +- remotion/src/components/SubtleCaptions.tsx | 2 +- src/models/index.ts | 14 +- src/server.ts | 13 +- src/services/clips-history.ts | 4 +- src/services/transcript-cache.ts | 23 +- src/ui/client/ConfigPage.tsx | 2 +- src/ui/client/ContentStudio.tsx | 12 +- src/ui/client/EpisodeWorkspace.jsx | 100 ++++++-- src/ui/client/Layout.tsx | 10 +- src/ui/client/StudioHome.tsx | 6 +- src/ui/public/css/styles.css | 1 - src/ui/web-server.ts | 79 +++++- src/version.ts | 17 ++ tests/test_transcript_cache_engine.py | 12 + tests/test_transcription_engine.py | 8 + 30 files changed, 657 insertions(+), 108 deletions(-) create mode 100644 backend/version.py create mode 100644 src/version.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fdcad35..914970a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -99,6 +99,7 @@ jobs: node-version: '20' - name: Build studio bundle run: | + npm version --no-git-tag-version --allow-same-version "${GITHUB_REF_NAME#v}" npm ci sh scripts/build-studio.sh dist/studio tar -czf studio-bundle.tar.gz -C dist/studio . diff --git a/backend/cli.py b/backend/cli.py index f5c3f78..fe57516 100644 --- a/backend/cli.py +++ b/backend/cli.py @@ -18,6 +18,7 @@ import sys import textwrap import time +from version import VERSION # Windows stdout/stderr default to cp1252, which can't encode chars like '→'; output is UTF-8. for _stream in (sys.stdout, sys.stderr): @@ -360,6 +361,11 @@ def cmd_studio(args): cmd += ["--paragraph", args.paragraph] if args.language: cmd += ["--language", args.language] + if getattr(args, "engine", None): + cmd += ["--engine", args.engine] + env = os.environ.copy() + if getattr(args, "assemblyai_api_key", None): + env["ASSEMBLYAI_API_KEY"] = args.assemblyai_api_key cmd += [ "--caption-style", args.caption_style, "--crop", args.crop, @@ -386,7 +392,7 @@ def cmd_studio(args): cmd += ["--no-outro"] import subprocess - rc = subprocess.run(cmd).returncode + rc = subprocess.run(cmd, env=env).returncode sys.exit(rc) @@ -437,6 +443,8 @@ def cmd_process(args): # CLI overrides if getattr(args, "engine", None): os.environ["PODCLI_ENGINE"] = args.engine + if getattr(args, "assemblyai_api_key", None): + os.environ["ASSEMBLYAI_API_KEY"] = args.assemblyai_api_key if args.caption_style: config["caption_style"] = args.caption_style if args.crop: @@ -585,7 +593,8 @@ def cmd_process(args): result = cached print(f" {len(segments)} segments, {len(words)} words") else: - print(" [1/4] Transcribing with Whisper...") + engine_label = "AssemblyAI" if os.environ.get("PODCLI_ENGINE", "").strip().lower() in ("assemblyai", "assembly-ai", "aai") else "Whisper" + print(f" [1/4] Transcribing with {engine_label}...") _ensure_ssl_certs() import warnings warnings.filterwarnings("ignore", message="FP16 is not supported on CPU") @@ -615,6 +624,7 @@ def _transcribe_progress(pct, msg): result = transcribe_file( file_path=video_path, model_size=config.get("whisper_model", "base"), + engine=os.environ.get("PODCLI_ENGINE") or None, enable_diarization=not config.get("no_speakers", False), progress_callback=_transcribe_progress, ) @@ -3095,8 +3105,6 @@ def cmd_info(args): print() -VERSION = "1.0.0" - BANNER = """ \033[38;2;212;135;74m ┌─────────────────────────────────────┐ │ │ @@ -3309,7 +3317,8 @@ def main(): proc.add_argument("-n", "--top", type=int, help="Number of top clips to export (default: 5)") proc.add_argument("-o", "--output", help="Output directory (default: ./clips)") proc.add_argument("-p", "--preset", help="Load a saved preset") - proc.add_argument("--engine", choices=["whisper-py", "whispercpp"], help="Transcription engine (default: whisper-py; whispercpp is the native, PyTorch-free path)") + proc.add_argument("--engine", choices=["whisper-py", "whispercpp", "assemblyai"], help="Transcription engine (default: whisper-py; whispercpp is local; assemblyai uses ASSEMBLYAI_API_KEY)") + proc.add_argument("--assemblyai-api-key", help="AssemblyAI API key for --engine assemblyai") proc.add_argument("--fast", action="store_true", help="Draft mode: tiny Whisper, heuristic selection, center crop, low quality") proc.add_argument("--thumbnails", dest="thumbnails", action="store_true", default=None, help="Force thumbnail generation on") proc.add_argument("--no-thumbnails", dest="thumbnails", action="store_false", help="Skip thumbnail generation") @@ -3335,6 +3344,8 @@ def main(): studio.add_argument("--end", type=float, help="Fragment end (seconds)") studio.add_argument("--paragraph", help="Find the fragment by matching this text in the transcript") studio.add_argument("--language", help="Transcription language (e.g. es). Auto-detect if omitted.") + studio.add_argument("--engine", choices=["whisper-py", "whispercpp", "assemblyai"], help="Transcription engine") + studio.add_argument("--assemblyai-api-key", help="AssemblyAI API key for --engine assemblyai") studio.add_argument("--caption-style", choices=["hormozi", "karaoke", "subtle", "branded"], default="hormozi") studio.add_argument("--crop", choices=["center", "face", "speaker", "speaker-hardcut"], default="face") studio.add_argument("-o", "--output", help="Final output path") diff --git a/backend/clip_studio.py b/backend/clip_studio.py index bbb9d87..731de78 100644 --- a/backend/clip_studio.py +++ b/backend/clip_studio.py @@ -82,12 +82,14 @@ def _probe_duration(path: str) -> float: return 0.0 -def _transcribe(video: str, language: str | None): +def _transcribe(video: str, language: str | None, engine: str | None): """Transcribe (cached) and return the word list.""" from services.transcription import transcribe_file - print(" [transcribe] running Whisper...", flush=True) + selected = (engine or os.environ.get("PODCLI_ENGINE", "")).strip().lower() + label = "AssemblyAI" if selected in ("assemblyai", "assembly-ai", "aai") else "Whisper" + print(f" [transcribe] running {label}...", flush=True) res = transcribe_file( - file_path=video, model_size="base", language=language, + file_path=video, model_size="base", engine=engine, language=language, enable_diarization=False, progress_callback=lambda p, m: print(f" {p}% {m}", flush=True), ) @@ -199,6 +201,7 @@ def main(): ap.add_argument("--end", type=float, help="Fragment end (seconds)") ap.add_argument("--paragraph", help="Find fragment by matching this text in the transcript") ap.add_argument("--language", default=None, help="Transcription language (e.g. es). Auto-detect if omitted.") + ap.add_argument("--engine", choices=["whisper-py", "whispercpp", "assemblyai"], default=None, help="Transcription engine") ap.add_argument("--caption-style", default="hormozi", choices=["hormozi", "karaoke", "subtle", "branded"]) ap.add_argument("--crop", default="face", choices=["center", "face", "speaker", "speaker-hardcut"]) ap.add_argument("--output", default=None, help="Final output path") @@ -244,7 +247,7 @@ def main(): os.makedirs(out_dir, exist_ok=True) # Need a transcript if cutting by paragraph or if rendering captions. - words = _transcribe(video, args.language) + words = _transcribe(video, args.language, args.engine) if args.paragraph: start, end = _find_paragraph(words, args.paragraph) diff --git a/backend/main.py b/backend/main.py index 468669a..ade0681 100644 --- a/backend/main.py +++ b/backend/main.py @@ -25,6 +25,8 @@ except ImportError: pass import traceback +from version import VERSION + def emit_progress(task_id: str, stage: str, percent: int, message: str, **extra): """Write a progress event to stderr (picked up by TypeScript executor).""" @@ -53,25 +55,43 @@ def emit_result(task_id: str, status: str, data=None, error=None): def handle_ping(task_id: str, params: dict): """Simple health check.""" - emit_result(task_id, "success", data={"message": "pong", "version": "1.0.0"}) + emit_result(task_id, "success", data={"message": "pong", "version": VERSION}) def handle_transcribe(task_id: str, params: dict): """Transcribe a podcast video/audio file with speaker detection.""" from services.transcription import transcribe_file from services.corrections import apply_corrections - from services.transcript_packer import compute_cache_hash, write_packed + from services.transcript_packer import compute_cache_hash, engine_cache_suffix, write_packed emit_progress(task_id, "transcribing", 0, "Starting transcription...") file_path = params["file_path"] - result = transcribe_file( - file_path=file_path, - model_size=params.get("model_size", "base"), - language=params.get("language"), - enable_diarization=params.get("enable_diarization", True), - num_speakers=params.get("num_speakers"), - progress_callback=lambda pct, msg: emit_progress(task_id, "transcribing", pct, msg), - ) + engine = params.get("engine") + previous_engine = os.environ.get("PODCLI_ENGINE") + previous_assemblyai_key = os.environ.get("ASSEMBLYAI_API_KEY") + if engine: + os.environ["PODCLI_ENGINE"] = engine + if params.get("assemblyai_api_key"): + os.environ["ASSEMBLYAI_API_KEY"] = params["assemblyai_api_key"] + try: + result = transcribe_file( + file_path=file_path, + model_size=params.get("model_size", "base"), + engine=engine, + language=params.get("language"), + enable_diarization=params.get("enable_diarization", True), + num_speakers=params.get("num_speakers"), + progress_callback=lambda pct, msg: emit_progress(task_id, "transcribing", pct, msg), + ) + finally: + if previous_engine is None: + os.environ.pop("PODCLI_ENGINE", None) + else: + os.environ["PODCLI_ENGINE"] = previous_engine + if previous_assemblyai_key is None: + os.environ.pop("ASSEMBLYAI_API_KEY", None) + else: + os.environ["ASSEMBLYAI_API_KEY"] = previous_assemblyai_key # Apply word corrections (Whisper misheard proper nouns) apply_corrections(result.get("words", []), result.get("segments", [])) @@ -80,7 +100,7 @@ def handle_transcribe(task_id: str, params: dict): try: from services.audio_analyzer import extract_audio_energy - cache_hash = compute_cache_hash(file_path) + cache_hash = compute_cache_hash(file_path) + engine_cache_suffix(result.get("engine") or engine) energy_data = None try: energy_data = extract_audio_energy(file_path) diff --git a/backend/services/content_generator.py b/backend/services/content_generator.py index 881b959..b6a76f6 100644 --- a/backend/services/content_generator.py +++ b/backend/services/content_generator.py @@ -7,6 +7,7 @@ import json import os import subprocess +import sys import tempfile import threading from typing import Optional, Callable @@ -234,7 +235,8 @@ def generate_custom_content( project_dir=project_dir, timeout=120, ) - except Exception: + except Exception as exc: + print(f"Warning: {label} content generation failed: {exc}", file=sys.stderr) continue if cr.returncode != 0 or not cr.stdout.strip(): continue @@ -245,8 +247,8 @@ def generate_custom_content( finally: try: os.unlink(prompt_file) - except Exception: - pass + except Exception as exc: + print(f"Warning: could not remove prompt file {prompt_file}: {exc}", file=sys.stderr) def generate_clip_content( diff --git a/backend/services/env_settings.py b/backend/services/env_settings.py index d35be73..fe4d6ca 100644 --- a/backend/services/env_settings.py +++ b/backend/services/env_settings.py @@ -18,6 +18,14 @@ "url": "https://huggingface.co/settings/tokens", "secret": True, }, + { + "key": "ASSEMBLYAI_API_KEY", + "label": "AssemblyAI API key", + "help": "Enables AssemblyAI transcription in the Studio and CLI.", + "url": "https://www.assemblyai.com/dashboard/activation", + "secret": True, + "placeholder": "aai_...", + }, { "key": "PODCLI_CLAUDE_PATH", "label": "Claude Code CLI path", diff --git a/backend/services/transcript_packer.py b/backend/services/transcript_packer.py index f311496..85c91b1 100644 --- a/backend/services/transcript_packer.py +++ b/backend/services/transcript_packer.py @@ -66,9 +66,15 @@ def _engine_cache_suffix() -> str: """Namespace the cache by engine so a whisper.cpp run doesn't reuse a whisper-py transcript (their timings/word splits differ). whisper-py keeps the bare filename, which the TS transcript cache also writes.""" - engine = os.environ.get("PODCLI_ENGINE", "whisper-py").strip().lower() + return engine_cache_suffix(os.environ.get("PODCLI_ENGINE", "whisper-py")) + + +def engine_cache_suffix(engine: str | None) -> str: + engine = (engine or "whisper-py").strip().lower() if engine in ("whispercpp", "whisper-cpp", "whisper.cpp", "cpp"): return "-whispercpp" + if engine in ("assemblyai", "assembly-ai", "aai"): + return "-assemblyai" return "" diff --git a/backend/services/transcription.py b/backend/services/transcription.py index 41cb9d0..cd962ac 100644 --- a/backend/services/transcription.py +++ b/backend/services/transcription.py @@ -7,11 +7,16 @@ 3. Merging speaker labels onto each word and segment """ +import json +import http.client import os import shutil -import subprocess import sys import tempfile +import time +import urllib.error +import urllib.parse +import urllib.request from typing import Optional, Callable @@ -79,6 +84,248 @@ def _transcribe_with_whispercpp(file_path, model_size, language, progress_callba return result +def _assemblyai_base_url() -> str: + region = os.environ.get("ASSEMBLYAI_REGION", "").strip().lower() + if region == "eu": + return "https://api.eu.assemblyai.com/v2" + return "https://api.assemblyai.com/v2" + + +def _assemblyai_json_request(method: str, url: str, api_key: str, payload: Optional[dict], timeout: int) -> dict: + body = None if payload is None else json.dumps(payload).encode("utf-8") + headers = {"Authorization": api_key} + if body is not None: + headers["Content-Type"] = "application/json" + last_error = None + for attempt in range(1, 4): + try: + req = urllib.request.Request(url, data=body, headers=headers, method=method) + with urllib.request.urlopen(req, timeout=timeout) as res: + return json.loads(res.read().decode("utf-8")) + except urllib.error.HTTPError as e: + detail = e.read().decode("utf-8", errors="replace") + last_error = RuntimeError( + f"AssemblyAI request failed: method={method} url={url} status={e.code} body={detail}" + ) + if e.code not in (408, 409, 425, 429) and e.code < 500: + raise last_error from e + print( + f"Warning: AssemblyAI request retry {attempt}/3 failed: method={method} url={url} status={e.code} body={detail}", + file=sys.stderr, + ) + if attempt < 3: + time.sleep(attempt) + except (urllib.error.URLError, TimeoutError) as e: + last_error = e + print( + f"Warning: AssemblyAI request retry {attempt}/3 failed: method={method} url={url} error={e}", + file=sys.stderr, + ) + if attempt < 3: + time.sleep(attempt) + raise RuntimeError( + f"AssemblyAI request failed after retries: method={method} url={url} error={last_error}" + ) from last_error + + +def _assemblyai_upload(file_path: str, api_key: str, base_url: str) -> str: + url = f"{base_url}/upload" + parsed = urllib.parse.urlparse(url) + if parsed.scheme != "https" or not parsed.netloc: + raise RuntimeError(f"AssemblyAI upload URL is invalid: url={url}") + + last_error = None + for attempt in range(1, 4): + conn = None + try: + size = os.path.getsize(file_path) + conn = http.client.HTTPSConnection(parsed.netloc, timeout=3600) + conn.putrequest("POST", parsed.path) + conn.putheader("Authorization", api_key) + conn.putheader("Content-Type", "application/octet-stream") + conn.putheader("Content-Length", str(size)) + conn.endheaders() + with open(file_path, "rb") as f: + while True: + chunk = f.read(1024 * 1024) + if not chunk: + break + conn.send(chunk) + + res = conn.getresponse() + detail = res.read().decode("utf-8", errors="replace") + if res.status < 200 or res.status >= 300: + last_error = RuntimeError( + f"AssemblyAI upload failed: url={url} file_path={file_path} status={res.status} body={detail}" + ) + if res.status not in (408, 409, 425, 429) and res.status < 500: + raise last_error + print( + f"Warning: AssemblyAI upload retry {attempt}/3 failed: file_path={file_path} status={res.status} body={detail}", + file=sys.stderr, + ) + if attempt < 3: + time.sleep(attempt) + continue + + data = json.loads(detail) + upload_url = data.get("upload_url") + if not upload_url: + raise RuntimeError(f"AssemblyAI upload response missing upload_url: body={data}") + return upload_url + except (OSError, TimeoutError, http.client.HTTPException) as e: + last_error = e + print( + f"Warning: AssemblyAI upload retry {attempt}/3 failed: file_path={file_path} error={e}", + file=sys.stderr, + ) + if attempt < 3: + time.sleep(attempt) + finally: + if conn: + conn.close() + raise RuntimeError( + f"AssemblyAI upload failed after retries: url={url} file_path={file_path} error={last_error}" + ) from last_error + + +def _assemblyai_speaker(raw_speaker: Optional[str]) -> Optional[str]: + if raw_speaker is None: + return None + label = str(raw_speaker).strip() + if not label: + return None + if len(label) == 1 and label.isalpha(): + return f"SPEAKER_{ord(label.upper()) - ord('A'):02d}" + return label + + +def _assemblyai_words(data: dict) -> list[dict]: + return [ + { + "word": str(w.get("text", "")).strip(), + "start": round(float(w.get("start", 0)) / 1000.0, 3), + "end": round(float(w.get("end", 0)) / 1000.0, 3), + "confidence": round(float(w.get("confidence", 0)), 3), + "speaker": _assemblyai_speaker(w.get("speaker")), + } + for w in data.get("words", []) + if str(w.get("text", "")).strip() + ] + + +def _assemblyai_result(data: dict) -> dict: + words = _assemblyai_words(data) + utterances = data.get("utterances") or [] + if utterances: + segments = [ + { + "id": i, + "start": round(float(u.get("start", 0)) / 1000.0, 3), + "end": round(float(u.get("end", 0)) / 1000.0, 3), + "text": str(u.get("text", "")).strip(), + "speaker": _assemblyai_speaker(u.get("speaker")), + } + for i, u in enumerate(utterances) + if str(u.get("text", "")).strip() + ] + else: + segments = [{ + "id": 0, + "start": words[0]["start"] if words else 0.0, + "end": words[-1]["end"] if words else 0.0, + "text": str(data.get("text") or "").strip(), + "speaker": None, + }] + + speaker_segments = [ + { + "speaker": segment["speaker"], + "start": segment["start"], + "end": segment["end"], + } + for segment in segments + if segment["speaker"] + ] + speakers = sorted({s["speaker"] for s in speaker_segments}) + speaker_map = { + speaker: { + "label": speaker, + "total_time": round( + sum(s["end"] - s["start"] for s in speaker_segments if s["speaker"] == speaker), + 2, + ), + "segments": sum(1 for s in speaker_segments if s["speaker"] == speaker), + } + for speaker in speakers + } + return { + "transcript": str(data.get("text") or "").strip(), + "segments": segments, + "words": words, + "duration": round(float(data.get("audio_duration") or (words[-1]["end"] if words else 0.0)), 3), + "language": str(data.get("language_code") or "en"), + "speakers": { + "num_speakers": len(speakers), + "speakers": speaker_map, + }, + "speaker_segments": speaker_segments, + "engine": "assemblyai", + } + + +def _transcribe_with_assemblyai(file_path, language, enable_diarization, num_speakers, progress_callback): + api_key = os.environ.get("ASSEMBLYAI_API_KEY", "").strip() + if not api_key: + raise RuntimeError("ASSEMBLYAI_API_KEY is required when PODCLI_ENGINE=assemblyai") + + base_url = _assemblyai_base_url() + if progress_callback: + progress_callback(10, "Uploading media to AssemblyAI...") + upload_url = _assemblyai_upload(file_path, api_key, base_url) + + payload = { + "audio_url": upload_url, + "punctuate": True, + "format_text": True, + "speaker_labels": bool(enable_diarization), + } + if language: + payload["language_code"] = language + else: + payload["language_detection"] = True + if num_speakers: + payload["speakers_expected"] = num_speakers + + if progress_callback: + progress_callback(20, "Starting AssemblyAI transcript...") + started = _assemblyai_json_request("POST", f"{base_url}/transcript", api_key, payload, 60) + transcript_id = started.get("id") + if not transcript_id: + raise RuntimeError(f"AssemblyAI transcript response missing id: body={started}") + + url = f"{base_url}/transcript/{transcript_id}" + for _ in range(720): + data = _assemblyai_json_request("GET", url, api_key, None, 60) + status = data.get("status") + if status == "completed": + if progress_callback: + progress_callback(50, "AssemblyAI transcription complete") + return _assemblyai_result(data) + if status == "error": + raise RuntimeError( + f"AssemblyAI transcript failed: transcript_id={transcript_id} error={data.get('error')}" + ) + if status not in ("queued", "processing"): + raise RuntimeError( + f"AssemblyAI transcript returned unknown status: transcript_id={transcript_id} status={status} body={data}" + ) + if progress_callback: + progress_callback(30, f"AssemblyAI transcript {status}...") + time.sleep(5) + raise TimeoutError(f"AssemblyAI transcript timed out: transcript_id={transcript_id}") + + def _attach_speakers_and_faces( file_path, base, @@ -93,8 +340,8 @@ def _attach_speakers_and_faces( words = base.get("words") or [] duration = base.get("duration") or (segments[-1]["end"] if segments else 0.0) - speaker_segments = [] - speaker_summary = {"num_speakers": 0, "speakers": {}} + speaker_segments = base.get("speaker_segments") or [] + speaker_summary = base.get("speakers") or {"num_speakers": 0, "speakers": {}} diarization_warning = None if enable_diarization: @@ -158,7 +405,8 @@ def _attach_speakers_and_faces( if progress_callback: progress_callback(90, diarization_warning) else: - diarization_warning = "Speaker detection disabled" + if not speaker_segments: + diarization_warning = "Speaker detection disabled" face_map = None try: @@ -192,6 +440,7 @@ def _attach_speakers_and_faces( def transcribe_file( file_path: str, model_size: str = "base", + engine: Optional[str] = None, language: Optional[str] = None, enable_diarization: bool = True, num_speakers: Optional[int] = None, @@ -214,9 +463,16 @@ def transcribe_file( if not os.path.exists(file_path): raise FileNotFoundError(f"File not found: {file_path}") - requested = os.environ.get("PODCLI_ENGINE", "").strip().lower() + requested = (engine if engine is not None else os.environ.get("PODCLI_ENGINE", "")).strip().lower() engine = requested or "whisper-py" use_cpp = engine in ("whispercpp", "whisper-cpp", "whisper.cpp", "cpp") + use_assemblyai = engine in ("assemblyai", "assembly-ai", "aai") + + if use_assemblyai: + base = _transcribe_with_assemblyai( + file_path, language, enable_diarization, num_speakers, progress_callback + ) + return _attach_speakers_and_faces(file_path, base, False, num_speakers, progress_callback) # Native installs ship whisper.cpp, not openai-whisper. Fall back to it # automatically — whether whisper is missing OR a broken install fails to @@ -239,6 +495,7 @@ def transcribe_file( if use_cpp: base = _transcribe_with_whispercpp(file_path, model_size, language, progress_callback) + base["engine"] = "whispercpp" # whisper.cpp is the no-torch path: importing torch for diarization can # hard-crash native runtimes. Skip diarization, keep face analysis (OpenCV). return _attach_speakers_and_faces( @@ -326,6 +583,7 @@ def transcribe_file( "words": words, "duration": duration, "language": detected_lang, + "engine": "whisper-py", } return _attach_speakers_and_faces( file_path, base, enable_diarization, num_speakers, progress_callback diff --git a/backend/utils/proc.py b/backend/utils/proc.py index b09f300..ee0ef0f 100644 --- a/backend/utils/proc.py +++ b/backend/utils/proc.py @@ -87,7 +87,13 @@ def run( (result.stderr or "")[-400:].strip(), ) raise ProcError(cmd, result.returncode, result.stderr or "", duration) - log.debug("proc.nonzero tool=%s rc=%d duration=%.2fs", tool, result.returncode, duration) + log.debug( + "proc.nonzero tool=%s rc=%d duration=%.2fs stderr=%s", + tool, + result.returncode, + duration, + (result.stderr or "")[-400:].strip(), + ) else: log.debug("proc.ok tool=%s duration=%.2fs", tool, duration) return result diff --git a/backend/version.py b/backend/version.py new file mode 100644 index 0000000..3d101b4 --- /dev/null +++ b/backend/version.py @@ -0,0 +1,22 @@ +import json +import os +from pathlib import Path + + +def podcli_version() -> str: + env_version = os.environ.get("PODCLI_VERSION", "").strip() + if env_version: + return env_version + + package_json = Path(__file__).resolve().parent.parent / "package.json" + try: + version = json.loads(package_json.read_text(encoding="utf-8")).get("version") + if isinstance(version, str) and version.strip(): + return version + except (OSError, json.JSONDecodeError): + pass + + return "0.0.0-dev" + + +VERSION = podcli_version() diff --git a/cli/main.go b/cli/main.go index 50b6d7f..6e41bb8 100644 --- a/cli/main.go +++ b/cli/main.go @@ -20,9 +20,10 @@ import ( ) // Version is set at build time via -ldflags "-X main.Version=...". -var Version = "2.0.0-dev" +var Version = "2.2.1" func main() { + os.Setenv("PODCLI_VERSION", Version) args := os.Args[1:] if len(args) == 0 { os.Exit(runEngine(args)) // backend's branded interactive menu diff --git a/cli/main_test.go b/cli/main_test.go index d3a1b74..c98265b 100644 --- a/cli/main_test.go +++ b/cli/main_test.go @@ -1,6 +1,9 @@ package main -import "testing" +import ( + "os" + "testing" +) func TestTranscribeModel(t *testing.T) { if got := transcribeModel([]string{"process", "episode.mp4"}); got != "base" { @@ -10,3 +13,18 @@ func TestTranscribeModel(t *testing.T) { t.Fatalf("fast model = %q, want tiny.en", got) } } + +func TestTranscribeEngineAssemblyAI(t *testing.T) { + old, ok := os.LookupEnv("PODCLI_ENGINE") + t.Cleanup(func() { + if ok { + os.Setenv("PODCLI_ENGINE", old) + } else { + os.Unsetenv("PODCLI_ENGINE") + } + }) + os.Unsetenv("PODCLI_ENGINE") + if got := transcribeEngine([]string{"process", "episode.mp4", "--engine", "assemblyai"}); got != "assemblyai" { + t.Fatalf("engine = %q, want assemblyai", got) + } +} diff --git a/install.ps1 b/install.ps1 index a3a042e..c4ddffa 100644 --- a/install.ps1 +++ b/install.ps1 @@ -32,13 +32,25 @@ function Test-PathEntryEquals { } } +function Send-EnvironmentPathChange { + if (-not ('Podcli.NativeMethods' -as [type])) { + Add-Type -Namespace Podcli -Name NativeMethods -MemberDefinition @' +[DllImport("user32.dll", SetLastError=true, CharSet=CharSet.Auto)] +public static extern IntPtr SendMessageTimeout(IntPtr hWnd, uint Msg, UIntPtr wParam, string lParam, uint fuFlags, uint uTimeout, out UIntPtr lpdwResult); +'@ + } + + $result = [UIntPtr]::Zero + $status = [Podcli.NativeMethods]::SendMessageTimeout([IntPtr]0xffff, 0x1a, [UIntPtr]::Zero, 'Environment', 0x0002, 5000, [ref]$result) + if ($status -eq [IntPtr]::Zero) { + $errorCode = [Runtime.InteropServices.Marshal]::GetLastWin32Error() + Write-Warning "could not broadcast PATH update (SendMessageTimeout error $errorCode); restart your terminal" + } +} + if ($Uninstall) { Write-Host "Uninstalling podcli..." - if ($Purge) { - $targets = @($homeDir) - } else { - $targets = @($binDir, (Join-Path $homeDir 'runtime'), (Join-Path $homeDir 'models'), (Join-Path $homeDir 'tools')) - } + $targets = @($homeDir) foreach ($p in $targets) { if (Test-Path $p) { try { @@ -55,15 +67,11 @@ if ($Uninstall) { $kept = @($parts | Where-Object { -not (Test-PathEntryEquals $_ $binDir) }) if ($kept.Count -ne $parts.Count) { $pathEntry.Key.SetValue('Path', ($kept -join ';'), $pathEntry.Kind) + Send-EnvironmentPathChange Write-Host " removed from user PATH (restart your terminal)" } } - if ($Purge) { - Write-Host " removed managed data." - } else { - Write-Host " kept user data (config, knowledge, presets, assets, history, cache)." - Write-Host " To remove everything: rerun with -Uninstall -Purge" - } + Write-Host " removed managed data." exit 0 } @@ -83,10 +91,7 @@ $dest = Join-Path $binDir 'podcli.exe' Invoke-WebRequest "$base/$asset" -OutFile $dest -UseBasicParsing try { - $sums = (Invoke-WebRequest "$base/checksums.txt" -UseBasicParsing).Content - if ($sums -is [byte[]]) { - $sums = [System.Text.Encoding]::UTF8.GetString($sums) - } + $sums = Invoke-RestMethod "$base/checksums.txt" -Headers @{ 'Accept' = 'text/plain'; 'User-Agent' = 'podcli-install' } $want = $sums -split "`n" | Where-Object { $_ -match ([regex]::Escape($asset) + '\s*$') } | ForEach-Object { ($_ -split '\s+')[0] } | Select-Object -First 1 @@ -106,6 +111,7 @@ if ($pathEntry) { $parts = @($pathEntry.Value -split ';' | Where-Object { $_ }) if (-not ($parts | Where-Object { Test-PathEntryEquals $_ $binDir })) { $pathEntry.Key.SetValue('Path', ($binDir + ';' + $pathEntry.Value), $pathEntry.Kind) + Send-EnvironmentPathChange Write-Host " added to PATH (restart your terminal)" } } diff --git a/package-lock.json b/package-lock.json index b68bfc8..6cb3f10 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "podcli", - "version": "2.0.0", + "version": "2.2.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "podcli", - "version": "2.0.0", + "version": "2.2.1", "license": "AGPL-3.0-only", "dependencies": { "@fontsource/dm-sans": "^5.2.8", diff --git a/package.json b/package.json index bf7c8f9..202d662 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "podcli", - "version": "2.0.0", + "version": "2.2.1", "description": "AI-powered podcast clip generator for TikTok/YouTube Shorts. Transcribe, find viral moments, export vertical clips with burned captions.", "type": "module", "license": "AGPL-3.0-only", diff --git a/remotion/src/components/SubtleCaptions.tsx b/remotion/src/components/SubtleCaptions.tsx index ee35521..5c538a6 100644 --- a/remotion/src/components/SubtleCaptions.tsx +++ b/remotion/src/components/SubtleCaptions.tsx @@ -103,7 +103,7 @@ export const SubtleCaptions: React.FC = ({ words, style }) => { { export function createServer(): McpServer { const server = new McpServer({ name: "podcli", - version: "1.0.0", + version: podcliVersion(), }); // ============================================= @@ -662,7 +663,7 @@ export function createServer(): McpServer { end_second: params.end_second as number, caption_style: (params.caption_style || "hormozi") as string, crop_strategy: (params.crop_strategy || "speaker") as string, - format: (params.format || "vertical") as string, + format: (params.format || "vertical") as Format, logo_path: params.logo_path as string | undefined, title: (params.title || "clip") as string, output_path: parsed.output_path, @@ -1135,6 +1136,10 @@ export function createServer(): McpServer { .string() .optional() .describe("Crop strategy (for check)"), + format: z + .enum(["vertical", "horizontal", "square"]) + .optional() + .describe("Output format (for check)"), limit: z.number().optional().default(20).describe("Max results for list"), }, async ({ @@ -1145,6 +1150,7 @@ export function createServer(): McpServer { end_second, caption_style, crop_strategy, + format, limit, }) => { try { @@ -1195,6 +1201,7 @@ export function createServer(): McpServer { end_second, caption_style || "hormozi", crop_strategy || "speaker", + format || "vertical", ); if (dup) { return { diff --git a/src/services/clips-history.ts b/src/services/clips-history.ts index 5624b56..d110706 100644 --- a/src/services/clips-history.ts +++ b/src/services/clips-history.ts @@ -4,7 +4,7 @@ import { basename, join } from "path"; import { v4 as uuidv4 } from "uuid"; import { paths } from "../config/paths.js"; import { sliceTranscript } from "../utils/transcript.js"; -import type { BatchClipsResult, ClipHistoryEntry, WordTimestamp } from "../models/index.js"; +import type { BatchClipsResult, ClipHistoryEntry, Format, WordTimestamp } from "../models/index.js"; type BatchResultRow = BatchClipsResult["results"][number]; @@ -13,7 +13,7 @@ interface BatchRecordContext { transcriptWords?: WordTimestamp[] | null; defaultCaptionStyle?: string; defaultCropStrategy?: string; - defaultFormat?: string; + defaultFormat?: Format; contentTypeFor?: (start: number, end: number) => string | undefined; } diff --git a/src/services/transcript-cache.ts b/src/services/transcript-cache.ts index e8deb0c..9f65f6e 100644 --- a/src/services/transcript-cache.ts +++ b/src/services/transcript-cache.ts @@ -64,10 +64,21 @@ export class TranscriptCache { }); } - async get(filePath: string): Promise { + private engineSuffix(engine?: string): string { + const value = (engine ?? "").trim().toLowerCase(); + if (["whispercpp", "whisper-cpp", "whisper.cpp", "cpp"].includes(value)) { + return "-whispercpp"; + } + if (["assemblyai", "assembly-ai", "aai"].includes(value)) { + return "-assemblyai"; + } + return ""; + } + + async get(filePath: string, engine?: string): Promise { try { const hash = await this.getFileHash(filePath); - const cachePath = join(this.cacheDir, `${hash}.json`); + const cachePath = join(this.cacheDir, `${hash}${this.engineSuffix(engine)}.json`); if (!existsSync(cachePath)) return null; @@ -78,10 +89,10 @@ export class TranscriptCache { } } - async set(filePath: string, transcript: TranscriptResult): Promise { + async set(filePath: string, transcript: TranscriptResult, engine?: string): Promise { await this.ensureDir(); const hash = await this.getFileHash(filePath); - const cachePath = join(this.cacheDir, `${hash}.json`); + const cachePath = join(this.cacheDir, `${hash}${this.engineSuffix(engine)}.json`); await writeFile(cachePath, JSON.stringify(transcript), "utf-8"); } @@ -90,9 +101,9 @@ export class TranscriptCache { * written by backend/services/transcript_packer.py as a side-effect of * transcription. Returns null if not yet generated. */ - async getPackedMarkdown(filePath: string): Promise { + async getPackedMarkdown(filePath: string, engine?: string): Promise { try { - const hash = await this.getFileHash(filePath); + const hash = `${await this.getFileHash(filePath)}${this.engineSuffix(engine)}`; return await this.readPackedByHash(hash); } catch { return null; diff --git a/src/ui/client/ConfigPage.tsx b/src/ui/client/ConfigPage.tsx index 43d4260..0ea10b5 100644 --- a/src/ui/client/ConfigPage.tsx +++ b/src/ui/client/ConfigPage.tsx @@ -246,7 +246,7 @@ export default function ConfigPage() {
setSecretInputs((p) => ({ ...p, [s.key]: e.target.value }))} style={{ flex: 1 }} diff --git a/src/ui/client/ContentStudio.tsx b/src/ui/client/ContentStudio.tsx index 07933dd..0edf566 100644 --- a/src/ui/client/ContentStudio.tsx +++ b/src/ui/client/ContentStudio.tsx @@ -30,6 +30,7 @@ export default function ContentStudio() { const [regenField, setRegenField] = useState(null); const [regenNote, setRegenNote] = useState(""); const [regenBusy, setRegenBusy] = useState(false); + const isBusy = busy || customBusy || regenBusy; useEffect(() => { try { @@ -62,6 +63,7 @@ export default function ContentStudio() { }; const generate = async () => { + if (isBusy) return; if (!transcript.trim()) { setMsg("Paste a transcript first"); return; @@ -111,7 +113,7 @@ export default function ContentStudio() { }; const askCustom = async () => { - if (!customReq.trim() || customBusy) return; + if (!customReq.trim() || isBusy) return; setCustomBusy(true); setCustomOut(null); setMsg(null); @@ -132,7 +134,7 @@ export default function ContentStudio() { }; const regenerate = async (field: string) => { - if (regenBusy) return; + if (isBusy) return; setRegenBusy(true); setMsg(null); try { @@ -216,7 +218,7 @@ export default function ContentStudio() {
{sessionText && ( - )} @@ -233,7 +235,7 @@ export default function ContentStudio() { -
@@ -260,7 +262,7 @@ export default function ContentStudio() { className="btn btn-primary btn-sm" style={{ marginTop: 10 }} onClick={askCustom} - disabled={customBusy || !customReq.trim()} + disabled={isBusy || !customReq.trim()} > {customBusy ? <>
Generating… : "Generate"} diff --git a/src/ui/client/EpisodeWorkspace.jsx b/src/ui/client/EpisodeWorkspace.jsx index f2609e4..0fdce53 100644 --- a/src/ui/client/EpisodeWorkspace.jsx +++ b/src/ui/client/EpisodeWorkspace.jsx @@ -507,6 +507,8 @@ const isHttpUrl = (value) => /^https?:\/\//i.test(value.trim()); const [transcriptMode, setTranscriptMode] = useState('whisper'); const [transcriptText, setTranscriptText] = useState(''); const [timeAdjust, setTimeAdjust] = useState(-1); + const [transcriptionEngine, setTranscriptionEngine] = useState('whisper'); + const [assemblyAiKey, setAssemblyAiKey] = useState(''); const [whisperModel, setWhisperModel] = useState('base'); const [captionStyle, setCaptionStyle] = useState('branded'); const [cropStrategy, setCropStrategy] = useState('face'); @@ -517,6 +519,7 @@ const isHttpUrl = (value) => /^https?:\/\//i.test(value.trim()); const [outroPath, setOutroPath] = useState(''); const initializedRef = useRef(false); const outroRef = useRef(); + const videoFileRef = useRef(); const [outroUploading, setOutroUploading] = useState(false); const [transcriptDragOver, setTranscriptDragOver] = useState(false); const [transcriptFileName, setTranscriptFileName] = useState(''); @@ -722,7 +725,19 @@ const isHttpUrl = (value) => /^https?:\/\//i.test(value.trim()); const fileData = await api('/select-file', { method: 'POST', body: JSON.stringify({ file_path: vp }) }); if (fileData.error) { setTranscribing(false); return; } setFile(fileData); - const data = await api('/transcribe', { method: 'POST', body: JSON.stringify({ file_path: vp, model_size: whisperModel, enable_diarization: speakerStatus?.configured || false }) }); + const engine = transcriptionEngine === 'assemblyai' ? 'assemblyai' : undefined; + const data = await api('/transcribe', { method: 'POST', body: JSON.stringify({ + file_path: vp, + model_size: whisperModel, + engine, + assemblyai_api_key: transcriptionEngine === 'assemblyai' ? assemblyAiKey.trim() : undefined, + enable_diarization: transcriptionEngine === 'assemblyai' || (speakerStatus?.configured || false), + }) }); + if (data.error) { + setError(data.error); + setTranscribing(false); + return; + } if (data.cached && data.data) { // Instant cache hit setTranscript(data.data); @@ -750,15 +765,15 @@ const isHttpUrl = (value) => /^https?:\/\//i.test(value.trim()); } }, [transcribeStream?.status]); - // Auto-trigger transcribe when video path is set and mode is whisper + // Auto-trigger transcribe when video path is set and auto transcript mode is active const autoTranscribeRef = useRef(''); useEffect(() => { const vp = videoPath.trim(); - if (transcriptMode === 'whisper' && vp && !isHttpUrl(vp) && !transcript && !transcribing && vp !== autoTranscribeRef.current) { + if (transcriptMode === 'whisper' && transcriptionEngine === 'whisper' && vp && !isHttpUrl(vp) && !transcript && !transcribing && vp !== autoTranscribeRef.current) { autoTranscribeRef.current = vp; autoTranscribe(vp); } - }, [videoPath, transcriptMode, transcript, transcribing]); + }, [videoPath, transcriptMode, transcriptionEngine, transcript, transcribing]); // Fetch clip history on mount and after exports const fetchHistory = () => { fetch('/api/history?limit=50').then(r => r.json()).then(d => { if (Array.isArray(d)) setClipHistory(d); }).catch(() => { }); }; @@ -826,10 +841,14 @@ const isHttpUrl = (value) => /^https?:\/\//i.test(value.trim()); else if (d.suggestions && !d.deselectedIndices) setDeselected(new Set()); if (d.phase) setPhase(d.phase); if (sseEvent.type === 'state' && Array.isArray(d.results)) setResults(d.results); - if (d.activeExportJobId) setBatchJobId(d.activeExportJobId); + if (d.activeExportJobId !== undefined) setBatchJobId(d.activeExportJobId); if (d.videoPath !== undefined) setVideoPath(d.videoPath); - if (d.transcript) { setTranscript(d.transcript); if (d.videoPath) autoTranscribeRef.current = d.videoPath; } - if (d.rawTranscriptText && !transcript) setTranscriptText(d.rawTranscriptText); + if (d.transcript !== undefined) { + setTranscript(d.transcript); + if (d.videoPath) autoTranscribeRef.current = d.videoPath; + if (d.transcript === null) autoTranscribeRef.current = ''; + } + if (d.rawTranscriptText !== undefined && (d.transcript === null || !transcript)) setTranscriptText(d.rawTranscriptText); if (d.settings) { if (d.settings.captionStyle) setCaptionStyle(d.settings.captionStyle); if (d.settings.cropStrategy) setCropStrategy(d.settings.cropStrategy); @@ -909,16 +928,27 @@ const isHttpUrl = (value) => /^https?:\/\//i.test(value.trim()); setActiveClipIdx(null); }; - // Native file browse - const doBrowse = useCallback(async () => { - setBrowsing(true); + const setUploadedVideo = useCallback(async (file) => { + if (!file) return; + setBrowsing(true); setError(null); try { - const d = await api('/browse-file'); + const d = await uploadFile(file, () => { }); + if (d.error) setError(d.error); if (d.file_path) setVideoPath(d.file_path); - } catch (e) { setError('Browse failed: ' + e.message); } + } catch (e) { setError('Upload failed: ' + e.message); } finally { setBrowsing(false); } }, []); + const doBrowse = useCallback(() => { + videoFileRef.current?.click(); + }, []); + + const handleVideoFileSelect = (e) => { + const f = e.target.files?.[0]; + e.target.value = ''; + setUploadedVideo(f); + }; + const downloadVideo = async () => { const url = videoPath.trim(); if (!url || downloadingVideo) return; @@ -952,6 +982,8 @@ const isHttpUrl = (value) => /^https?:\/\//i.test(value.trim()); setDeselected(new Set()); setResults([]); setEnergyData({}); + setPreviewSrc(null); + setActiveClipIdx(null); autoTranscribeRef.current = ''; setDownloadingVideo(false); setDownloadJobId(null); @@ -1177,7 +1209,11 @@ const isHttpUrl = (value) => /^https?:\/\//i.test(value.trim()); return 'pending'; }; - const handleVideoDrop = (e) => { e.preventDefault(); }; + const handleVideoDrop = (e) => { + e.preventDefault(); + const f = e.dataTransfer?.files?.[0]; + setUploadedVideo(f); + }; const handleTranscriptDrop = (e) => { e.preventDefault(); setTranscriptDragOver(false); const files = e.dataTransfer?.files; if (!files?.length) return; const f = files[0]; setTranscriptFileName(f.name); const reader = new FileReader(); reader.onload = (ev) => setTranscriptText(ev.target.result); reader.readAsText(f); }; const handleTranscriptFileSelect = (e) => { const f = e.target.files?.[0]; if (!f) return; setTranscriptFileName(f.name); const reader = new FileReader(); reader.onload = (ev) => setTranscriptText(ev.target.result); reader.readAsText(f); }; const preventDef = (e) => e.preventDefault(); @@ -1246,6 +1282,7 @@ const isHttpUrl = (value) => /^https?:\/\//i.test(value.trim()); {!videoPath && (
+ {browsing ? (
@@ -1287,7 +1324,7 @@ const isHttpUrl = (value) => /^https?:\/\//i.test(value.trim());
Transcript
setTranscriptMode('import')}>Paste transcript
-
setTranscriptMode('whisper')}>Auto (Whisper)
+
setTranscriptMode('whisper')}>Auto
{transcriptMode === 'import' && (
@@ -1320,14 +1357,33 @@ const isHttpUrl = (value) => /^https?:\/\//i.test(value.trim()); )} {transcriptMode === 'whisper' && (
-
- - -
+
+
+ + +
+ {transcriptionEngine === 'whisper' && ( +
+ + +
+ )} +
+ {transcriptionEngine === 'assemblyai' && ( +
+ + setAssemblyAiKey(e.target.value)} + placeholder="aai_..." disabled={isProcessing || transcribing} + style={{ fontSize: 12, padding: '9px 13px' }} /> +
+ )} {/* Transcription progress */} {transcribing && !cachedTranscript && ( diff --git a/src/ui/client/Layout.tsx b/src/ui/client/Layout.tsx index be9c231..6d898b6 100644 --- a/src/ui/client/Layout.tsx +++ b/src/ui/client/Layout.tsx @@ -1,4 +1,4 @@ -import React from "react"; +import React, { useEffect } from "react"; import { NavLink, Link, Outlet } from "react-router-dom"; import { LayoutGrid, @@ -30,6 +30,14 @@ function Icon({ name }: { name: string }) { } export default function Layout() { + useEffect(() => { + fetch("/api/session-cache/clear", { method: "POST" }).then((res) => { + if (!res.ok) console.warn("Failed to clear session cache", res.status); + }).catch((err: unknown) => { + console.warn("Failed to clear session cache", err); + }); + }, []); + return (