From e51953d44aa7e688b0a014837eb59c14f55a3977 Mon Sep 17 00:00:00 2001 From: Nika Siradze Date: Wed, 8 Jul 2026 11:28:00 +0400 Subject: [PATCH 1/3] Fix `podcli update` leaving the Python backend stale `podcli update` downloaded and swapped only the launcher binary. The embedded Python backend was extracted solely by `podcli setup`, and `ensureRuntime` only ran setup when `runtime/backend/cli.py` was absent. An updated launcher therefore kept driving whatever backend the release that first provisioned it had installed. The drift was invisible: main.go exports PODCLI_VERSION into the backend, and backend/version.py reads it before its own fallback, so a stale backend reported the launcher's version as its own. It also silently pinned dependencies. ensureBackendDeps reads requirements-runtime.txt from the backend tree and stamps on that file's hash, so a stale tree matched its old stamp and deps never reinstalled, even though the check ran on every invocation. - Stamp the extracted backend with the launcher version, reusing the marker name provision already writes for the studio and Remotion bundles. An unstamped tree is treated as stale, so existing installs self-heal on first run. - Re-extract on version mismatch from every path that executes Python: the runtime commands, the other engine verbs, and the mcp server (stderr-only, so the JSON-RPC channel on stdout stays clean). - Extract before ensureBackendDeps, so a refreshed tree yields a refreshed dep set. - Add `setup --refresh` to re-provision the version-bound artifacts without pulling a model, and run it from `update` against the newly installed binary, since the new backend is embedded in that binary rather than the running one. - Extract before any network provisioning in setup, so an offline or interrupted run still leaves a backend matching the launcher. - Report backend drift in `doctor`. Release 2.4.1. --- cli/internal/backend/embed.go | 35 +++++++++-- cli/internal/backend/embed_test.go | 68 +++++++++++++++++++++ cli/internal/update/update.go | 15 +++++ cli/main.go | 97 ++++++++++++++++++++++++------ package.json | 2 +- 5 files changed, 194 insertions(+), 23 deletions(-) create mode 100644 cli/internal/backend/embed_test.go diff --git a/cli/internal/backend/embed.go b/cli/internal/backend/embed.go index be0a363..c2af130 100644 --- a/cli/internal/backend/embed.go +++ b/cli/internal/backend/embed.go @@ -9,19 +9,42 @@ import ( "io/fs" "os" "path/filepath" + "strings" ) //go:generate sh sync.sh //go:embed all:files var files embed.FS -// Extract replaces dest with the embedded backend tree. dest is removed first so -// a stale dev symlink or an older extracted copy never shadows the shipped one. -func Extract(dest string) error { +// stampName matches the marker provision writes for the studio and Remotion +// bundles, so every version-bound artifact under runtime/ is checked the same way. +const stampName = ".podcli-version" + +// Version returns the launcher version that extracted dest, or "" if unstamped. +func Version(dest string) string { + b, err := os.ReadFile(filepath.Join(dest, stampName)) + if err != nil { + return "" + } + return strings.TrimSpace(string(b)) +} + +// IsCurrent reports whether dest holds this launcher's backend. An unstamped dest +// is stale by definition: it predates stamping, so it carries whatever release +// first provisioned it. +func IsCurrent(dest, version string) bool { + return Version(dest) == version +} + +// Extract replaces dest with the embedded backend tree. dest is removed first so a +// stale dev symlink or an older extracted copy never shadows the shipped one. The +// stamp is written last, so a crash mid-extract leaves dest unstamped and the next +// run re-extracts. +func Extract(dest, version string) error { if err := os.RemoveAll(dest); err != nil { return err } - return fs.WalkDir(files, "files", func(p string, d fs.DirEntry, err error) error { + err := fs.WalkDir(files, "files", func(p string, d fs.DirEntry, err error) error { if err != nil { return err } @@ -45,4 +68,8 @@ func Extract(dest string) error { } return os.WriteFile(target, data, 0o644) }) + if err != nil { + return err + } + return os.WriteFile(filepath.Join(dest, stampName), []byte(version), 0o644) } diff --git a/cli/internal/backend/embed_test.go b/cli/internal/backend/embed_test.go new file mode 100644 index 0000000..a1c0ca0 --- /dev/null +++ b/cli/internal/backend/embed_test.go @@ -0,0 +1,68 @@ +package backend + +import ( + "os" + "path/filepath" + "testing" +) + +func TestExtractStampsVersion(t *testing.T) { + dest := filepath.Join(t.TempDir(), "backend") + if err := Extract(dest, "2.4.1"); err != nil { + t.Fatalf("Extract: %v", err) + } + if _, err := os.Stat(filepath.Join(dest, "cli.py")); err != nil { + t.Fatalf("cli.py missing after extract: %v", err) + } + if got := Version(dest); got != "2.4.1" { + t.Fatalf("Version = %q, want 2.4.1", got) + } + if !IsCurrent(dest, "2.4.1") { + t.Fatal("IsCurrent false right after extracting that version") + } +} + +// A backend provisioned before stamping existed carries no marker. Treating it as +// current is the bug that let a v2.2.1 backend run under a v2.4.0 launcher. +func TestUnstampedBackendIsStale(t *testing.T) { + dest := t.TempDir() + if err := os.WriteFile(filepath.Join(dest, "cli.py"), []byte("old"), 0o644); err != nil { + t.Fatal(err) + } + if Version(dest) != "" { + t.Fatal("unstamped dir reported a version") + } + if IsCurrent(dest, "2.4.1") { + t.Fatal("unstamped backend reported current") + } +} + +func TestStaleStampIsNotCurrent(t *testing.T) { + dest := filepath.Join(t.TempDir(), "backend") + if err := Extract(dest, "2.2.1"); err != nil { + t.Fatalf("Extract: %v", err) + } + if IsCurrent(dest, "2.4.1") { + t.Fatal("2.2.1 backend reported current under a 2.4.1 launcher") + } +} + +func TestExtractReplacesStaleTree(t *testing.T) { + dest := filepath.Join(t.TempDir(), "backend") + if err := Extract(dest, "2.2.1"); err != nil { + t.Fatalf("Extract: %v", err) + } + stray := filepath.Join(dest, "stale_module.py") + if err := os.WriteFile(stray, []byte("removed"), 0o644); err != nil { + t.Fatal(err) + } + if err := Extract(dest, "2.4.1"); err != nil { + t.Fatalf("re-extract: %v", err) + } + if _, err := os.Stat(stray); !os.IsNotExist(err) { + t.Fatal("stale file survived re-extract") + } + if !IsCurrent(dest, "2.4.1") { + t.Fatal("re-extract did not restamp") + } +} diff --git a/cli/internal/update/update.go b/cli/internal/update/update.go index 07d0ec8..65a990d 100644 --- a/cli/internal/update/update.go +++ b/cli/internal/update/update.go @@ -9,6 +9,7 @@ import ( "io" "net/http" "os" + "os/exec" "path/filepath" "runtime" "strconv" @@ -162,10 +163,24 @@ func Run(current string) int { printSelfUpdateFailure(os.Stderr, err) return 1 } + if err := refreshRuntime(managedBin()); err != nil { + fmt.Fprintf(os.Stderr, "podcli: binary updated, but refreshing the runtime failed (%v).\n", err) + fmt.Fprintln(os.Stderr, "The next `podcli` run will retry, or run `podcli setup --refresh` now.") + return 1 + } fmt.Printf("Updated to podcli %s.\n", tag) return 0 } +// refreshRuntime re-provisions the version-bound artifacts under runtime/ (Python +// backend, its deps, studio and Remotion bundles). It shells out to the binary we +// just installed because the new backend is embedded in that binary, not this one. +func refreshRuntime(bin string) error { + cmd := exec.Command(bin, "setup", "--refresh") + cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr + return cmd.Run() +} + 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.") diff --git a/cli/main.go b/cli/main.go index 084e95f..61d1b8b 100644 --- a/cli/main.go +++ b/cli/main.go @@ -21,7 +21,7 @@ import ( ) // Version is set at build time via -ldflags "-X main.Version=...". -var Version = "2.2.1" +var Version = "2.4.1" func main() { os.Setenv("PODCLI_VERSION", Version) @@ -45,6 +45,12 @@ func main() { if len(args) >= 2 && args[1] == "install" { os.Exit(mcpInstall()) } + // Safe on this path despite stdout being the JSON-RPC channel: it only writes + // to stderr. Skipping it would strand MCP-only clients on a stale backend. + if err := refreshBackend(); err != nil { + fmt.Fprintln(os.Stderr, "podcli:", err) + os.Exit(1) + } code, err := engine.RunMCP() if err != nil { fmt.Fprintln(os.Stderr, "podcli:", err) @@ -99,15 +105,38 @@ func refreshStudioBundles() { } // ensureRuntime self-provisions on first run so `podcli` works without a separate -// `podcli setup`. Not called on the mcp path, whose stdout is the JSON-RPC channel. +// `podcli setup`. Not called on the mcp path, whose stdout is the JSON-RPC channel; +// that path calls refreshBackend directly, which only writes to stderr. func ensureRuntime() error { if _, ok := engine.BackendRoot(); !ok { fmt.Fprintln(os.Stderr, "First run - setting up podcli (one-time download)...") setup(nil) + } else if err := refreshBackend(); err != nil { + return err } return ensureBackendDeps() } +// refreshBackend re-extracts the embedded backend after a launcher upgrade, since +// `podcli update` replaces only the binary. Must run before ensureBackendDeps: the +// dep stamp hashes the backend's own requirements-runtime.txt, so a stale tree +// pins a stale dep set. +func refreshBackend() error { + managed := filepath.Join(paths.RuntimeDir(), "backend") + root, _ := engine.BackendRoot() + if root != managed { + return nil // repo checkout or PODCLI_BACKEND override: the user's tree, not ours + } + if backend.IsCurrent(managed, Version) { + return nil + } + fmt.Fprintf(os.Stderr, "Updating backend to podcli %s...\n", Version) + if err := backend.Extract(managed, Version); err != nil { + return fmt.Errorf("could not update the Python backend: %w\n run `podcli setup --refresh` to retry", err) + } + return nil +} + func ensureBackendDeps() error { if !engine.IsHermeticPython() { return nil @@ -133,6 +162,9 @@ func runEngine(args []string) int { fmt.Fprintln(os.Stderr, "podcli:", err) return 1 } + } else if err := refreshBackend(); err != nil { + fmt.Fprintln(os.Stderr, "podcli:", err) + return 1 } if wantsStudio(args) { refreshStudioBundles() @@ -221,6 +253,7 @@ func setup(args []string) int { size := "base" vad := false speakers := false + refresh := false for i := 0; i < len(args); i++ { switch args[i] { case "--model": @@ -232,35 +265,45 @@ func setup(args []string) int { vad = true case "--speakers": speakers = true + case "--refresh": + refresh = true } } fmt.Printf("Provisioning into %s\n", paths.Home()) - p, err := provision.EnsureModel(size) - if err != nil { - fmt.Fprintln(os.Stderr, "podcli: setup:", err) - return 1 + // Extracted from the binary, so it costs nothing and can't fail on a bad network. + // First, so an interrupted or offline setup still leaves a backend matching this + // launcher rather than the one a previous release installed. + backendDir := filepath.Join(paths.RuntimeDir(), "backend") + if err := backend.Extract(backendDir, Version); err != nil { + fmt.Fprintf(os.Stderr, " backend: skipped (%v) - falling back to repo/PODCLI_BACKEND\n", err) + backendDir, _ = engine.BackendRoot() + } else { + fmt.Printf(" backend: %s\n", backendDir) } - fmt.Printf(" model: %s\n", p) - if vad { - vp, err := provision.EnsureVADModel() + // --refresh re-provisions what a launcher upgrade invalidates. It must not pull a + // model: the user's chosen size isn't known here, and defaulting to base would + // download one they never asked for. + if !refresh { + p, err := provision.EnsureModel(size) if err != nil { fmt.Fprintln(os.Stderr, "podcli: setup:", err) return 1 } - fmt.Printf(" vad: %s\n", vp) + fmt.Printf(" model: %s\n", p) + if vad { + vp, err := provision.EnsureVADModel() + if err != nil { + fmt.Fprintln(os.Stderr, "podcli: setup:", err) + return 1 + } + fmt.Printf(" vad: %s\n", vp) + } } if fp, err := provision.EnsureFFmpeg(); err != nil { fmt.Fprintf(os.Stderr, " ffmpeg: skipped (%v) - backend will use PATH ffmpeg\n", err) } else { fmt.Printf(" ffmpeg: %s\n", fp) } - backendDir := filepath.Join(paths.RuntimeDir(), "backend") - if err := backend.Extract(backendDir); err != nil { - fmt.Fprintf(os.Stderr, " backend: skipped (%v) - falling back to repo/PODCLI_BACKEND\n", err) - backendDir, _ = engine.BackendRoot() - } else { - fmt.Printf(" backend: %s\n", backendDir) - } if backendDir != "" { reqs := filepath.Join(backendDir, "requirements-runtime.txt") if pb, err := provision.EnsurePython(reqs); err != nil { @@ -559,6 +602,23 @@ Options: --purge Kept for compatibility; uninstall already removes everything`) } +// backendStamp annotates an unmanaged or out-of-date backend, the drift the +// launcher version alone can't show: it exports PODCLI_VERSION into the backend, +// so a stale backend reports the launcher's version as its own. +func backendStamp(root string) string { + if root != filepath.Join(paths.RuntimeDir(), "backend") { + return " (unmanaged - repo or PODCLI_BACKEND)" + } + switch v := backend.Version(root); v { + case Version: + return "" + case "": + return " (STALE: unstamped - run `podcli setup --refresh`)" + default: + return fmt.Sprintf(" (STALE: %s - run `podcli setup --refresh`)", v) + } +} + func doctor() { fmt.Printf("podcli %s\n\n", Version) fmt.Println("Paths") @@ -571,7 +631,7 @@ func doctor() { } fmt.Println("\nEngine resolution") if root, ok := engine.BackendRoot(); ok { - fmt.Printf(" backend: %s\n", root) + fmt.Printf(" backend: %s%s\n", root, backendStamp(root)) } else { fmt.Printf(" backend: NOT FOUND (set PODCLI_BACKEND or run inside the repo)\n") } @@ -664,6 +724,7 @@ Launcher commands: uninstall Remove podcli app files (keeps user data unless --purge) setup [--model base] [--vad] [--speakers] Provision runtimes + models (--speakers adds pyannote+torch, ~2GB) + setup --refresh Re-provision runtimes for this launcher version, skipping models mcp Run the MCP server (stdio) for Claude/Codex mcp install Register the MCP server with Claude Code config set update.auto off Disable auto-update (also: PODCLI_NO_UPDATE=1) diff --git a/package.json b/package.json index d387a47..077cc29 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "podcli", - "version": "2.4.0", + "version": "2.4.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", From 35c33bfa3facfa77a237d35c03f3858dc271bf87 Mon Sep 17 00:00:00 2001 From: Nika Siradze Date: Wed, 8 Jul 2026 11:32:39 +0400 Subject: [PATCH 2/3] Derive the launcher version from package.json The version lived in three independent places: the git tag, package.json, and a hardcoded default in main.go. The default had rotted to 2.2.1 while the project shipped 2.4.0, so any build without the release workflow's -ldflags reported a version two minors stale. package.json is now the single source. `go generate` writes cli/VERSION from it and the launcher embeds that. A test fails when VERSION drifts from package.json, and the release workflow refuses to build a tag that disagrees with it. Drops -ldflags "-X main.Version": the linker applies -X only to a constant-initialized string and would silently ignore an embed-initialized one. --- .github/workflows/release.yml | 11 ++++++++-- RELEASE.md | 12 +++++++--- cli/VERSION | 1 + cli/gen-version.sh | 9 ++++++++ cli/main.go | 3 --- cli/version.go | 15 +++++++++++++ cli/version_test.go | 41 +++++++++++++++++++++++++++++++++++ 7 files changed, 84 insertions(+), 8 deletions(-) create mode 100644 cli/VERSION create mode 100755 cli/gen-version.sh create mode 100644 cli/version.go create mode 100644 cli/version_test.go diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c8bdf91..0cd6265 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -41,11 +41,18 @@ jobs: CGO_ENABLED: '0' run: | VERSION="${GITHUB_REF_NAME#v}" - go generate ./... # sync backend + PodStack commands for go:embed + go generate ./... # sync backend + PodStack commands for go:embed, write VERSION + # The launcher embeds VERSION (generated from package.json). Refuse to ship a + # tag that disagrees with it rather than publish a mislabelled binary. + EMBEDDED="$(cat VERSION)" + if [ "$VERSION" != "$EMBEDDED" ]; then + echo "tag v${VERSION} does not match package.json version ${EMBEDDED}" >&2 + exit 1 + fi EXT="" [ "${{ matrix.goos }}" = "windows" ] && EXT=".exe" OUT="podcli-${{ matrix.goos }}-${{ matrix.goarch }}${EXT}" - go build -trimpath -ldflags "-s -w -X main.Version=${VERSION}" -o "../${OUT}" . + go build -trimpath -ldflags "-s -w" -o "../${OUT}" . echo "ASSET=${OUT}" >> "$GITHUB_ENV" - uses: actions/upload-artifact@v4 with: diff --git a/RELEASE.md b/RELEASE.md index edfed04..90f559a 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -15,10 +15,16 @@ unscoped name `podcli` is blocked by npm as too similar to `pod-cli`.) ## Cutting a release -1. Pick the version `X.Y.Z` and tag with it. The installers resolve the latest - release automatically. +1. Bump the version in `package.json`. It is the single source of truth: the + launcher embeds `cli/VERSION`, which `go generate` writes from `package.json`, + and the Python backend reads it too. + ```bash + cd cli && go generate ./... && git add VERSION # refresh the embedded version + ``` + A tag that disagrees with `package.json` fails the release build, and + `go test ./...` in `cli/` fails if `VERSION` is stale. 2. Merge to `main` and make sure CI is green. -3. Tag and push: +3. Tag and push. The tag must match `package.json`: ```bash git tag vX.Y.Z git push origin vX.Y.Z diff --git a/cli/VERSION b/cli/VERSION new file mode 100644 index 0000000..005119b --- /dev/null +++ b/cli/VERSION @@ -0,0 +1 @@ +2.4.1 diff --git a/cli/gen-version.sh b/cli/gen-version.sh new file mode 100755 index 0000000..32740b6 --- /dev/null +++ b/cli/gen-version.sh @@ -0,0 +1,9 @@ +#!/bin/sh +# Write VERSION from package.json, the single source of truth for the version. +# Run via `go generate ./...` before building. VERSION is this script's only writer. +set -e +here="$(cd "$(dirname "$0")" && pwd)" +ver=$(sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$here/../package.json" | head -1) +[ -n "$ver" ] || { echo "gen-version: no \"version\" in package.json" >&2; exit 1; } +printf '%s\n' "$ver" > "$here/VERSION" +echo "version $ver -> $here/VERSION" diff --git a/cli/main.go b/cli/main.go index 61d1b8b..b6b7e6b 100644 --- a/cli/main.go +++ b/cli/main.go @@ -20,9 +20,6 @@ import ( "podcli/internal/update" ) -// Version is set at build time via -ldflags "-X main.Version=...". -var Version = "2.4.1" - func main() { os.Setenv("PODCLI_VERSION", Version) args := os.Args[1:] diff --git a/cli/version.go b/cli/version.go new file mode 100644 index 0000000..0a40550 --- /dev/null +++ b/cli/version.go @@ -0,0 +1,15 @@ +package main + +import ( + _ "embed" + "strings" +) + +//go:generate sh gen-version.sh +//go:embed VERSION +var versionFile string + +// Version is generated from package.json by gen-version.sh, so a release bumps one +// file. It is deliberately not wired to -ldflags "-X main.Version": the linker +// applies -X only to a constant-initialized string, and silently ignores it here. +var Version = strings.TrimSpace(versionFile) diff --git a/cli/version_test.go b/cli/version_test.go new file mode 100644 index 0000000..0de4c67 --- /dev/null +++ b/cli/version_test.go @@ -0,0 +1,41 @@ +package main + +import ( + "encoding/json" + "os" + "strings" + "testing" +) + +// The embedded VERSION is generated from package.json. If someone bumps one without +// running `go generate`, the launcher ships a version that disagrees with the repo, +// which is how a v2.2.1 default survived into the v2.4.0 release. +func TestVersionMatchesPackageJSON(t *testing.T) { + b, err := os.ReadFile("../package.json") + if err != nil { + t.Fatalf("read package.json: %v", err) + } + var pkg struct { + Version string `json:"version"` + } + if err := json.Unmarshal(b, &pkg); err != nil { + t.Fatalf("parse package.json: %v", err) + } + if pkg.Version == "" { + t.Fatal("package.json has no version") + } + if Version != pkg.Version { + t.Fatalf("embedded VERSION = %q, package.json = %q - run `go generate ./...` in cli/", Version, pkg.Version) + } +} + +// The stamp comparison and the update check are string equality, so a stray newline +// from VERSION would make every backend look stale and every release look newer. +func TestVersionIsTrimmed(t *testing.T) { + if Version == "" { + t.Fatal("Version is empty") + } + if strings.TrimSpace(Version) != Version { + t.Fatalf("Version %q has surrounding whitespace", Version) + } +} From 163cee21a8ac4a460f7593965a3c86cfd8818fc4 Mon Sep 17 00:00:00 2001 From: Nika Siradze Date: Wed, 8 Jul 2026 11:42:19 +0400 Subject: [PATCH 3/3] Stop truncating clip titles, and roll timestamps into hours Suggested clip titles were cut to 55 characters with a raw slice before being stored, so cards showed "...on an automot" and "...is evolving, bu". The cut was a display concern that had leaked into the data layer: the stored title, the render payload, and the clip history all carried the shortened text, and `.clip-title` already had `text-overflow: ellipsis` that never got a chance to run. Titles are now stored whole. Where a fixed width genuinely applies, truncate_title cuts on a word boundary instead: - claude_suggest keeps the full title (whitespace normalized). - The render payload no longer slices to 40 chars. clip_generator already slugifies and caps the filename on its own, so the slice only corrupted the title stored in clip history. - The CLI's heuristic fallback, which builds a title out of raw transcript text, uses truncate_title. So does terminal output, where the width is real. - .clip-title wraps instead of clipping to one line. Separately, fmt() divided seconds by 60 without rolling into hours, so a clip an hour into an episode read "78:31". HighlightsPage carried its own copy of the same bug; it now uses the shared helper. --- backend/cli.py | 5 ++-- backend/services/claude_suggest.py | 5 ++-- backend/utils/text.py | 30 ++++++++++++++++++++ src/ui/client/EpisodeWorkspace.jsx | 4 +-- src/ui/client/HighlightsPage.tsx | 11 ++------ src/ui/client/lib.test.ts | 29 ++++++++++++++++++++ src/ui/client/lib.ts | 12 ++++++-- src/ui/public/css/styles.css | 2 +- src/ui/web-server.ts | 2 +- tests/test_text_utils.py | 44 ++++++++++++++++++++++++++++++ 10 files changed, 125 insertions(+), 19 deletions(-) create mode 100644 backend/utils/text.py create mode 100644 src/ui/client/lib.test.ts create mode 100644 tests/test_text_utils.py diff --git a/backend/cli.py b/backend/cli.py index a7b27df..79dea8f 100644 --- a/backend/cli.py +++ b/backend/cli.py @@ -1999,9 +1999,8 @@ def _find_sentence_boundary_end(segs, idx, max_lookahead=3): break if not title: title = text[:60].strip() - if len(title) > 55: - # Cut at word boundary - title = title[:55].rsplit(" ", 1)[0] + "..." + from utils.text import truncate_title + title = truncate_title(title) if score >= 5: # Higher threshold = better clips clips.append({ diff --git a/backend/services/claude_suggest.py b/backend/services/claude_suggest.py index 96d8840..5c98890 100644 --- a/backend/services/claude_suggest.py +++ b/backend/services/claude_suggest.py @@ -19,6 +19,7 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from presets import MIN_CLIP_DURATION, MAX_CLIP_DURATION, TARGET_CLIP_DURATION_MIN, TARGET_CLIP_DURATION_MAX +from utils.text import clean_title def _cli_name_exts() -> list[str]: @@ -844,7 +845,7 @@ def find_moments_from_text( continue found.append({ - "title": c.get("title", "Untitled")[:55], + "title": clean_title(c.get("title", "Untitled")), "start_second": keep_segments[0]["start"] if keep_segments else start_sec, "end_second": keep_segments[-1]["end"] if keep_segments else end_sec, "segments": keep_segments, @@ -1043,7 +1044,7 @@ def _parse_seconds(val) -> float: continue normalized.append({ - "title": c.get("title", "Untitled")[:55], + "title": clean_title(c.get("title", "Untitled")), "start_second": keep_segments[0]["start"] if keep_segments else start_sec, "end_second": keep_segments[-1]["end"] if keep_segments else end_sec, "segments": keep_segments, diff --git a/backend/utils/text.py b/backend/utils/text.py new file mode 100644 index 0000000..07d2d35 --- /dev/null +++ b/backend/utils/text.py @@ -0,0 +1,30 @@ +"""Title helpers shared by the suggestion pipeline and the CLI.""" + +TITLE_MAX = 55 + +_TRAILING_PUNCT = " ,;:-–—" + + +def clean_title(text: str) -> str: + """Normalize whitespace, keeping the title intact. + + Stored titles are never shortened: the UI wraps them and the terminal + truncates them, so a cap here would lose text that both could have shown. + """ + return " ".join((text or "").split()) + + +def truncate_title(text: str, limit: int = TITLE_MAX) -> str: + """Trim to limit characters on a word boundary, for fixed-width output. + + A raw slice ends titles mid-word ("...on an automot"), which reads as a + rendering fault rather than an abbreviation. A single long word still gets a + hard cut, since there is no boundary to fall back to. + """ + collapsed = clean_title(text) + if len(collapsed) <= limit: + return collapsed + head = collapsed[:limit] + if " " in head: + head = head.rsplit(" ", 1)[0] + return head.rstrip(_TRAILING_PUNCT) + "…" diff --git a/src/ui/client/EpisodeWorkspace.jsx b/src/ui/client/EpisodeWorkspace.jsx index af50159..233b831 100644 --- a/src/ui/client/EpisodeWorkspace.jsx +++ b/src/ui/client/EpisodeWorkspace.jsx @@ -1027,7 +1027,7 @@ const isHttpUrl = (value) => /^https?:\/\//i.test(value.trim()); const data = await api('/batch-clips', { method: 'POST', body: JSON.stringify({ video_path: vp, - clips: sc.map(c => ({ start_second: c.start_second, end_second: c.end_second, title: c.title.slice(0, 40), caption_style: captionStyle, crop_strategy: cropStrategy, format })), + clips: sc.map(c => ({ start_second: c.start_second, end_second: c.end_second, title: c.title, caption_style: captionStyle, crop_strategy: cropStrategy, format })), transcript_words: transcript?.words || [], logo_path: logoPath || undefined, outro_path: outroPath || undefined, intro_path: introPath || undefined, clean_fillers: cleanFillers || undefined, }) }); @@ -1046,7 +1046,7 @@ const isHttpUrl = (value) => /^https?:\/\//i.test(value.trim()); const data = await api('/create-clip', { method: 'POST', body: JSON.stringify({ video_path: vp, start_second: c.start_second, end_second: c.end_second, - title: c.title.slice(0, 40), caption_style: captionStyle, crop_strategy: cropStrategy, format, + title: c.title, caption_style: captionStyle, crop_strategy: cropStrategy, format, transcript_words: transcript?.words || [], logo_path: logoPath || undefined, outro_path: outroPath || undefined, intro_path: introPath || undefined, clean_fillers: cleanFillers || undefined, }) }); diff --git a/src/ui/client/HighlightsPage.tsx b/src/ui/client/HighlightsPage.tsx index a19142c..28428cd 100644 --- a/src/ui/client/HighlightsPage.tsx +++ b/src/ui/client/HighlightsPage.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useRef, useState } from "react"; import { PageHeader } from "./Page"; -import { api, basename } from "./lib"; +import { api, basename, fmt } from "./lib"; import { useJob } from "./useJob"; import AssetPicker from "./AssetPicker"; import RecentSources from "./RecentSources"; @@ -51,11 +51,6 @@ const FORMAT_LABEL: Record = { square: "1:1", }; -const mmss = (s: number) => { - const t = Math.max(0, s); - return `${Math.floor(t / 60)}:${String(Math.floor(t % 60)).padStart(2, "0")}`; -}; - function Field({ label, children }: { label: string; children: React.ReactNode }) { return (
@@ -187,7 +182,7 @@ function MomentTrim({ />
- {saving ? "saving…" : `${mmss(start)}-${mmss(end)} · ${Math.round(end - start)}s`} + {saving ? "saving…" : `${fmt(start)}-${fmt(end)} · ${Math.round(end - start)}s`} @@ -597,7 +592,7 @@ export default function HighlightsPage() { }} > {idx + 1} - {mmss(m.start)}-{mmss(m.end)} + {fmt(m.start)}-{fmt(m.end)} {m.why} {(session.sources?.length ?? 1) > 1 && m.source && ( diff --git a/src/ui/client/lib.test.ts b/src/ui/client/lib.test.ts new file mode 100644 index 0000000..5100f0d --- /dev/null +++ b/src/ui/client/lib.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { fmt, fmtMs } from "./lib"; + +describe("fmt", () => { + it("formats sub-hour timestamps as m:ss", () => { + expect(fmt(0)).toBe("0:00"); + expect(fmt(9)).toBe("0:09"); + expect(fmt(767)).toBe("12:47"); + expect(fmt(3599)).toBe("59:59"); + }); + + // A podcast past the hour mark rendered as "78:31" instead of "1:18:31". + it("rolls into hours", () => { + expect(fmt(3600)).toBe("1:00:00"); + expect(fmt(4711)).toBe("1:18:31"); + expect(fmt(7325)).toBe("2:02:05"); + }); + + it("clamps negatives", () => { + expect(fmt(-5)).toBe("0:00"); + }); +}); + +describe("fmtMs", () => { + it("appends milliseconds", () => { + expect(fmtMs(12.5)).toBe("0:12.500"); + expect(fmtMs(4711.25)).toBe("1:18:31.250"); + }); +}); diff --git a/src/ui/client/lib.ts b/src/ui/client/lib.ts index 1796059..eee7bbc 100644 --- a/src/ui/client/lib.ts +++ b/src/ui/client/lib.ts @@ -1,7 +1,15 @@ import type { CSSProperties } from "react"; -export const fmt = (s: number) => - `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, "0")}`; +// Rolls into hours past 3600s: podcast timestamps ran past "78:31" without it. +export const fmt = (s: number) => { + const t = Math.max(0, s); + const hours = Math.floor(t / 3600); + const minutes = Math.floor((t % 3600) / 60); + const seconds = Math.floor(t % 60); + const mm = String(minutes).padStart(2, "0"); + const ss = String(seconds).padStart(2, "0"); + return hours > 0 ? `${hours}:${mm}:${ss}` : `${minutes}:${ss}`; +}; export const fmtMs = (s: number) => `${fmt(s)}.${String(Math.floor((s % 1) * 1000)).padStart(3, "0")}`; diff --git a/src/ui/public/css/styles.css b/src/ui/public/css/styles.css index 67fd9de..c1128fa 100644 --- a/src/ui/public/css/styles.css +++ b/src/ui/public/css/styles.css @@ -648,7 +648,7 @@ select { .checkbox:hover { border-color: var(--accent); } .checkbox.checked { background: var(--accent); border-color: var(--accent); } .clip-info { flex: 1; min-width: 0; } -.clip-title { font-size: 13px; font-weight: 600; line-height: 1.4; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.clip-title { font-size: 13px; font-weight: 600; line-height: 1.4; overflow-wrap: anywhere; } .clip-meta { font-size: 12px; color: var(--text2); margin-top: 2px; } .clip-meta .err { color: var(--red); } .clip-actions { display: flex; gap: 4px; flex-shrink: 0; } diff --git a/src/ui/web-server.ts b/src/ui/web-server.ts index e350add..ac85987 100644 --- a/src/ui/web-server.ts +++ b/src/ui/web-server.ts @@ -3289,7 +3289,7 @@ app.post("/api/mcp/export", async (req, res) => { const styledClips = clips.map((c: any) => ({ start_second: c.start_second, end_second: c.end_second, - title: (c.title || "clip").slice(0, 40), + title: c.title || "clip", caption_style: c.caption_style || captionStyle, crop_strategy: c.crop_strategy || cropStrategy, format: c.format || format, diff --git a/tests/test_text_utils.py b/tests/test_text_utils.py new file mode 100644 index 0000000..3a0075b --- /dev/null +++ b/tests/test_text_utils.py @@ -0,0 +1,44 @@ +import os +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "backend")) + +from utils.text import clean_title, truncate_title # noqa: E402 + + +def test_clean_title_keeps_full_text(): + long_title = "There are aerospace companies where for $300 you have the same part" + assert clean_title(long_title) == long_title + + +def test_clean_title_collapses_whitespace(): + assert clean_title(" So here I am,\n24 years old ") == "So here I am, 24 years old" + + +def test_clean_title_handles_empty(): + assert clean_title("") == "" + assert clean_title(None) == "" + + +def test_truncate_title_leaves_short_titles_alone(): + assert truncate_title("Short title") == "Short title" + + +def test_truncate_title_never_cuts_mid_word(): + # The reported bug: a raw [:55] slice produced "...you have t". + title = "There are aerospace companies where for $300 you have the same part" + out = truncate_title(title) + assert out == "There are aerospace companies where for $300 you have…" + assert len(out) <= 56 # 55 chars plus the ellipsis + assert not out.rstrip("…").endswith(" ") + + +def test_truncate_title_strips_trailing_punctuation(): + assert truncate_title("So here I am, 24 years old, and they're like: we are, flying") == ( + "So here I am, 24 years old, and they're like: we are…" + ) + + +def test_truncate_title_hard_cuts_a_single_long_word(): + out = truncate_title("x" * 80) + assert out == "x" * 55 + "…"