diff --git a/backend/services/video_processor.py b/backend/services/video_processor.py index 5f40794c..06d0f9a4 100644 --- a/backend/services/video_processor.py +++ b/backend/services/video_processor.py @@ -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, @@ -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" diff --git a/cli/main.go b/cli/main.go index 9e4b2756..d1928b7f 100644 --- a/cli/main.go +++ b/cli/main.go @@ -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 @@ -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") diff --git a/cli/main_test.go b/cli/main_test.go index f549aca4..d4aaf3df 100644 --- a/cli/main_test.go +++ b/cli/main_test.go @@ -2,6 +2,7 @@ package main import ( "os" + "path/filepath" "testing" ) @@ -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) }) +} diff --git a/tests/test_video_processor.py b/tests/test_video_processor.py index 9563725e..47e2bbfd 100644 --- a/tests/test_video_processor.py +++ b/tests/test_video_processor.py @@ -1,5 +1,6 @@ import os import sys +import tempfile import unittest from unittest import mock @@ -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")],