Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions backend/services/video_processor.py
Original file line number Diff line number Diff line change
Expand Up @@ -3260,7 +3260,7 @@ def concat_outro(
audio_fade_start = max(0.0, main_duration - audio_fade)
AFMT = "aformat=sample_fmts=fltp:sample_rates=44100:channel_layouts=stereo"
try:
return _run_ffmpeg_with_fallback(
joined = _run_ffmpeg_with_fallback(
cmd_parts_before_enc=[
"ffmpeg", "-y",
"-i", input_path,
Expand All @@ -3283,7 +3283,15 @@ def concat_outro(
label="outro_hardcut_soft_audio",
)
except Exception:
pass
joined = None
if joined:
# The scaled outro sits in the user's output folder. A failed
# delete must not send a finished join into the fallback below.
try:
os.remove(outro_scaled)
except OSError:
pass
return joined

# Fallback 2: pure hard cut concat
main_reenc = output_path + ".main_reenc.mp4"
Expand Down
29 changes: 27 additions & 2 deletions cli/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -114,9 +114,31 @@ 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;
// that path calls refreshBackend directly, which only writes to stderr.
// setupDoneStamp marks a setup that ran to the end, whatever it had to skip.
// Setup extracts the backend before any download, so a backend alone does not
// prove the runtime arrived.
func setupDoneStamp() string { return filepath.Join(paths.RuntimeDir(), ".setup-complete") }

// setupInterrupted reports a managed install whose first setup stopped after
// the backend landed but before the Python runtime did, e.g. a Ctrl-C during
// the model download. Without this check every later run trusted the backend
// and ran it on whatever python3 was on PATH.
func setupInterrupted() bool {
root, ok := engine.BackendRoot()
if !ok || root != filepath.Join(paths.RuntimeDir(), "backend") || os.Getenv("PODCLI_PYTHON") != "" {
return false
}
return !fileExists(setupDoneStamp()) && engine.Python() == "python3"
}

func ensureRuntime() error {
if _, ok := engine.BackendRoot(); !ok {
fmt.Fprintln(os.Stderr, "First run - setting up podcli (one-time download)...")
_, ok := engine.BackendRoot()
if interrupted := ok && setupInterrupted(); !ok || interrupted {
if interrupted {
fmt.Fprintln(os.Stderr, "Finishing an interrupted podcli setup...")
} else {
fmt.Fprintln(os.Stderr, "First run - setting up podcli (one-time download)...")
}
// setup reports on stdout, which belongs to the command being run here:
// `--json` callers parse it as exactly one JSON object.
stdout := os.Stdout
Expand Down Expand Up @@ -410,6 +432,9 @@ func setup(args []string) int {
fmt.Printf(" browser: ready\n")
}
}
if err := os.WriteFile(setupDoneStamp(), []byte(Version+"\n"), 0o644); err != nil {
fmt.Fprintf(os.Stderr, " setup: could not record completion (%v)\n", err)
}
if engine.MCPServer() != "" {
if mcpRegisteredToSelf() {
fmt.Printf(" mcp: already registered with Claude Code\n")
Expand Down
60 changes: 60 additions & 0 deletions cli/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package main

import (
"os"
"path/filepath"
"testing"
)

Expand Down Expand Up @@ -52,3 +53,62 @@ func TestTranscribeEngineOmnilingual(t *testing.T) {
t.Fatalf("engine = %q, want omnilingual", got)
}
}

func TestSetupInterruptedAfterBackendOnly(t *testing.T) {
home := t.TempDir()
t.Setenv("PODCLI_HOME", home)
t.Setenv("PODCLI_BACKEND", "")
t.Setenv("PODCLI_PYTHON", "")
chdirTemp(t)
backendDir := filepath.Join(home, "runtime", "backend")
if err := os.MkdirAll(backendDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(backendDir, "cli.py"), nil, 0o644); err != nil {
t.Fatal(err)
}

if !setupInterrupted() {
t.Fatal("a backend with no Python runtime and no completion stamp should count as interrupted")
}
if err := os.WriteFile(setupDoneStamp(), []byte("2.8.1\n"), 0o644); err != nil {
t.Fatal(err)
}
if setupInterrupted() {
t.Fatal("a completed setup that skipped Python must not rerun on every command")
}
}

func TestSetupNotInterruptedWithHermeticPython(t *testing.T) {
home := t.TempDir()
t.Setenv("PODCLI_HOME", home)
t.Setenv("PODCLI_BACKEND", "")
t.Setenv("PODCLI_PYTHON", "")
chdirTemp(t)
for _, f := range []string{filepath.Join("runtime", "backend", "cli.py"), filepath.Join("runtime", "python", "bin", "python3"), filepath.Join("runtime", "python", "python.exe")} {
p := filepath.Join(home, f)
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(p, nil, 0o755); err != nil {
t.Fatal(err)
}
}
if setupInterrupted() {
t.Fatal("an install from before the stamp existed, with its Python runtime, must not rerun setup")
}
}

// chdirTemp leaves any repo checkout, whose backend/ would otherwise win
// BackendRoot's working-directory search.
func chdirTemp(t *testing.T) {
t.Helper()
prev, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
if err := os.Chdir(t.TempDir()); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.Chdir(prev) })
}
37 changes: 37 additions & 0 deletions tests/test_video_processor.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import os
import sys
import tempfile
import unittest
from unittest import mock

Expand Down Expand Up @@ -51,6 +52,42 @@ def test_concat_outro_uses_soft_audio_fallback_before_hard_concat(self):
self.assertGreaterEqual(run_ffmpeg.call_count, 2)
self.assertEqual(run_ffmpeg.call_args_list[1].kwargs.get("label"), "outro_hardcut_soft_audio")

def test_soft_audio_fallback_removes_the_scaled_outro(self):
out_dir = tempfile.mkdtemp()
out = os.path.join(out_dir, "clip.mp4")
scaled = out + ".outro_scaled.mp4"
run_fail = mock.Mock(returncode=1, stdout="", stderr="xfade unavailable")

def fake_ffmpeg(**kwargs):
with open(kwargs["output_path"], "wb") as f:
f.write(b"x")
return kwargs["output_path"]

with mock.patch.object(vp, "get_dimensions", return_value=(1080, 1920)), \
mock.patch.object(vp, "_get_media_duration_seconds", side_effect=[20.0, 5.0]), \
mock.patch.object(vp, "_has_audio_stream", return_value=True), \
mock.patch.object(vp, "get_video_encode_flags", return_value=vp.CPU_FLAGS), \
mock.patch.object(vp, "_run_ffmpeg_with_fallback", side_effect=fake_ffmpeg), \
mock.patch.object(vp, "proc_run", return_value=run_fail):
self.assertEqual(vp.concat_outro("/tmp/in.mp4", "/tmp/outro.mp4", out), out)

self.assertTrue(os.path.exists(out))
self.assertFalse(os.path.exists(scaled))

def test_a_failed_outro_cleanup_keeps_the_finished_join(self):
run_fail = mock.Mock(returncode=1, stdout="", stderr="xfade unavailable")
with mock.patch.object(vp, "get_dimensions", return_value=(1080, 1920)), \
mock.patch.object(vp, "_get_media_duration_seconds", side_effect=[20.0, 5.0]), \
mock.patch.object(vp, "_has_audio_stream", return_value=True), \
mock.patch.object(vp, "get_video_encode_flags", return_value=vp.CPU_FLAGS), \
mock.patch.object(vp.os, "remove", side_effect=PermissionError("locked")), \
mock.patch.object(vp, "_run_ffmpeg_with_fallback", side_effect=["/tmp/scaled.mp4", "/tmp/out.mp4"]) as run_ffmpeg, \
mock.patch.object(vp, "proc_run", return_value=run_fail):
out = vp.concat_outro("/tmp/in.mp4", "/tmp/outro.mp4", "/tmp/out.mp4")

self.assertEqual(out, "/tmp/out.mp4")
self.assertEqual(run_ffmpeg.call_count, 2)

def test_resolve_speaker_sides_does_not_guess_from_transcript_order(self):
speaker_side = vp._resolve_speaker_sides(
segments=[(0.0, 2.0, "SPEAKER_00"), (2.0, 4.0, "SPEAKER_01")],
Expand Down
Loading