From e2f49e2e5ac51f5c56faed7931f3368aae90a9fa Mon Sep 17 00:00:00 2001 From: JSLMPR Date: Sat, 11 Jul 2026 16:50:54 +0200 Subject: [PATCH] Add managed local CV worker startup --- docs/local-cv-visual-analysis-guide.md | 28 ++- .../config/VideoClippingProperties.java | 39 ++++ .../editing/LocalCvWorkerProcessManager.java | 182 ++++++++++++++++++ src/main/resources/application.yml | 4 + .../config/VideoClippingPropertiesTest.java | 12 ++ .../LocalCvWorkerProcessManagerTest.java | 178 +++++++++++++++++ 6 files changed, 440 insertions(+), 3 deletions(-) create mode 100644 src/main/java/org/example/videoclips/editing/LocalCvWorkerProcessManager.java create mode 100644 src/test/java/org/example/videoclips/editing/LocalCvWorkerProcessManagerTest.java diff --git a/docs/local-cv-visual-analysis-guide.md b/docs/local-cv-visual-analysis-guide.md index 6c16c25..92f7455 100644 --- a/docs/local-cv-visual-analysis-guide.md +++ b/docs/local-cv-visual-analysis-guide.md @@ -4,16 +4,17 @@ This service can use a local computer-vision worker for highlight visual analysi ## Default Behavior -By default the service uses the built-in heuristic provider: +The packaged `application.yml` defaults to `local-cv` with heuristic fallback enabled: ```yaml video-clipping: editing: visual-analysis: - provider: heuristic + provider: local-cv + fallback-to-heuristic: true ``` -This keeps the app runnable without Python, GPU drivers, model downloads, or a separate service. +This keeps the app runnable when the local worker is unavailable, but visual analysis is stronger when the worker is running. ## Enable A Local CV Worker @@ -27,6 +28,10 @@ video-clipping: endpoint: http://127.0.0.1:8091/v1/analyze-visuals timeout-ms: 30000 fallback-to-heuristic: true + local-cv-worker: + auto-start: false + script: ./tools/run_local_cv_worker.sh + startup-wait-ms: 0 ``` Equivalent environment variables: @@ -36,6 +41,9 @@ VIDEO_EDITING_VISUAL_ANALYSIS_PROVIDER=local-cv VIDEO_EDITING_VISUAL_ANALYSIS_ENDPOINT=http://127.0.0.1:8091/v1/analyze-visuals VIDEO_EDITING_VISUAL_ANALYSIS_TIMEOUT_MS=30000 VIDEO_EDITING_VISUAL_ANALYSIS_FALLBACK_TO_HEURISTIC=true +VIDEO_EDITING_LOCAL_CV_WORKER_AUTO_START=false +VIDEO_EDITING_LOCAL_CV_WORKER_SCRIPT=./tools/run_local_cv_worker.sh +VIDEO_EDITING_LOCAL_CV_WORKER_STARTUP_WAIT_MS=0 ``` This repository includes an optional starter worker: @@ -60,6 +68,20 @@ Health check: curl http://127.0.0.1:8091/health ``` +## Spring-Managed Worker Startup + +The Spring application can start the local CV worker for local runs: + +```bash +VIDEO_EDITING_VISUAL_ANALYSIS_PROVIDER=local-cv \ +VIDEO_EDITING_LOCAL_CV_WORKER_AUTO_START=true \ +mvn spring-boot:run +``` + +When `auto-start` is enabled, Spring starts `tools/run_local_cv_worker.sh`, derives `LOCAL_CV_HOST` and `LOCAL_CV_PORT` from the configured visual-analysis endpoint, streams worker output into application logs, and stops the worker process during application shutdown. + +Keep `auto-start` disabled in production unless the app host is intended to own the Python worker lifecycle. The first run can take time because the script creates `.venv-local-cv`, installs Python dependencies, and may download/cache the configured YOLO model. + ## Worker Request Contract The Spring service sends: diff --git a/src/main/java/org/example/videoclips/config/VideoClippingProperties.java b/src/main/java/org/example/videoclips/config/VideoClippingProperties.java index cbc472a..db1a717 100644 --- a/src/main/java/org/example/videoclips/config/VideoClippingProperties.java +++ b/src/main/java/org/example/videoclips/config/VideoClippingProperties.java @@ -858,6 +858,8 @@ public class VideoClippingProperties { private boolean fallbackToHeuristic = true; + private final LocalCvWorker localCvWorker = new LocalCvWorker(); + public String getProvider() { return provider; } @@ -889,6 +891,43 @@ public class VideoClippingProperties { public void setFallbackToHeuristic(boolean fallbackToHeuristic) { this.fallbackToHeuristic = fallbackToHeuristic; } + + public LocalCvWorker getLocalCvWorker() { + return localCvWorker; + } + + public static class LocalCvWorker { + private boolean autoStart = false; + + private String script = "./tools/run_local_cv_worker.sh"; + + @Min(0) + private long startupWaitMs = 0; + + public boolean isAutoStart() { + return autoStart; + } + + public void setAutoStart(boolean autoStart) { + this.autoStart = autoStart; + } + + public String getScript() { + return script; + } + + public void setScript(String script) { + this.script = script; + } + + public long getStartupWaitMs() { + return startupWaitMs; + } + + public void setStartupWaitMs(long startupWaitMs) { + this.startupWaitMs = startupWaitMs; + } + } } public static class LocalDirector { diff --git a/src/main/java/org/example/videoclips/editing/LocalCvWorkerProcessManager.java b/src/main/java/org/example/videoclips/editing/LocalCvWorkerProcessManager.java new file mode 100644 index 0000000..166feb7 --- /dev/null +++ b/src/main/java/org/example/videoclips/editing/LocalCvWorkerProcessManager.java @@ -0,0 +1,182 @@ +package org.example.videoclips.editing; + +import org.example.videoclips.config.VideoClippingProperties; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.SmartLifecycle; +import org.springframework.stereotype.Component; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +@Component +@ConditionalOnProperty(name = "video-clipping.editing.enabled", havingValue = "true", matchIfMissing = true) +public class LocalCvWorkerProcessManager implements SmartLifecycle { + + private static final Logger log = LoggerFactory.getLogger(LocalCvWorkerProcessManager.class); + + private final VideoClippingProperties.Editing.VisualAnalysis visualAnalysis; + private final ProcessLauncher processLauncher; + private Process process; + private Thread outputThread; + private boolean running; + + @Autowired + public LocalCvWorkerProcessManager(VideoClippingProperties properties) { + this(properties, ProcessBuilder::start); + } + + LocalCvWorkerProcessManager( + VideoClippingProperties properties, + ProcessLauncher processLauncher + ) { + this.visualAnalysis = properties.getEditing().getVisualAnalysis(); + this.processLauncher = processLauncher; + } + + @Override + public synchronized void start() { + if (running || !shouldStart()) { + return; + } + + VideoClippingProperties.Editing.VisualAnalysis.LocalCvWorker worker = visualAnalysis.getLocalCvWorker(); + Path script = Path.of(worker.getScript()).toAbsolutePath().normalize(); + if (!Files.isRegularFile(script)) { + throw new IllegalStateException("Local CV worker script does not exist: " + script); + } + + Endpoint endpoint = endpoint(); + ProcessBuilder processBuilder = new ProcessBuilder(script.toString()) + .redirectErrorStream(true); + Map environment = processBuilder.environment(); + environment.put("LOCAL_CV_HOST", endpoint.host()); + environment.put("LOCAL_CV_PORT", Integer.toString(endpoint.port())); + + try { + process = processLauncher.start(processBuilder); + running = true; + outputThread = outputReader(process.getInputStream(), script); + outputThread.start(); + log.info("event=local_cv_worker_started script={} pid={} host={} port={}", + script, process.pid(), endpoint.host(), endpoint.port()); + waitForStartupWindow(worker.getStartupWaitMs()); + } catch (IOException ex) { + running = false; + throw new IllegalStateException("Unable to start local CV worker script: " + script, ex); + } catch (RuntimeException ex) { + cleanupFailedStart(); + throw ex; + } + } + + @Override + public synchronized void stop() { + if (process == null) { + running = false; + return; + } + long pid = process.pid(); + if (process.isAlive()) { + process.destroy(); + try { + if (!process.waitFor(Duration.ofSeconds(5).toMillis(), TimeUnit.MILLISECONDS)) { + process.destroyForcibly(); + } + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + process.destroyForcibly(); + } + } + running = false; + log.info("event=local_cv_worker_stopped pid={}", pid); + } + + @Override + public boolean isRunning() { + return running && process != null && process.isAlive(); + } + + @Override + public boolean isAutoStartup() { + return true; + } + + @Override + public int getPhase() { + return Integer.MIN_VALUE + 100; + } + + private boolean shouldStart() { + return "local-cv".equalsIgnoreCase(visualAnalysis.getProvider()) + && visualAnalysis.getLocalCvWorker().isAutoStart(); + } + + private void waitForStartupWindow(long startupWaitMs) { + if (startupWaitMs <= 0) { + return; + } + try { + Thread.sleep(startupWaitMs); + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("Interrupted while waiting for local CV worker startup", ex); + } + if (process != null && !process.isAlive()) { + throw new IllegalStateException("Local CV worker exited during startup window"); + } + } + + private void cleanupFailedStart() { + running = false; + if (process != null && process.isAlive()) { + process.destroyForcibly(); + } + } + + private Endpoint endpoint() { + URI uri = URI.create(visualAnalysis.getEndpoint()); + String host = uri.getHost() == null || uri.getHost().isBlank() ? "127.0.0.1" : uri.getHost(); + int port = uri.getPort(); + if (port < 0) { + port = "https".equalsIgnoreCase(uri.getScheme()) ? 443 : 80; + } + return new Endpoint(host, port); + } + + private Thread outputReader(InputStream inputStream, Path script) { + Thread thread = new Thread(() -> { + try (BufferedReader reader = new BufferedReader( + new InputStreamReader(inputStream, StandardCharsets.UTF_8))) { + String line; + while ((line = reader.readLine()) != null) { + log.info("event=local_cv_worker_output script={} message={}", script, line); + } + } catch (IOException ex) { + log.debug("event=local_cv_worker_output_closed script={} error_type={}", + script, ex.getClass().getSimpleName()); + } + }, "local-cv-worker-output"); + thread.setDaemon(true); + return thread; + } + + record Endpoint(String host, int port) { + } + + @FunctionalInterface + interface ProcessLauncher { + Process start(ProcessBuilder processBuilder) throws IOException; + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 3b7175e..785a127 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -53,6 +53,10 @@ video-clipping: endpoint: ${VIDEO_EDITING_VISUAL_ANALYSIS_ENDPOINT:http://127.0.0.1:8091/v1/analyze-visuals} timeout-ms: ${VIDEO_EDITING_VISUAL_ANALYSIS_TIMEOUT_MS:30000} fallback-to-heuristic: ${VIDEO_EDITING_VISUAL_ANALYSIS_FALLBACK_TO_HEURISTIC:true} + local-cv-worker: + auto-start: ${VIDEO_EDITING_LOCAL_CV_WORKER_AUTO_START:false} + script: ${VIDEO_EDITING_LOCAL_CV_WORKER_SCRIPT:./tools/run_local_cv_worker.sh} + startup-wait-ms: ${VIDEO_EDITING_LOCAL_CV_WORKER_STARTUP_WAIT_MS:0} local-director: enabled: ${VIDEO_EDITING_LOCAL_DIRECTOR_ENABLED:true} source-directory: ${VIDEO_EDITING_LOCAL_DIRECTOR_SOURCE_DIRECTORY:./input/editing/source} diff --git a/src/test/java/org/example/videoclips/config/VideoClippingPropertiesTest.java b/src/test/java/org/example/videoclips/config/VideoClippingPropertiesTest.java index 815bc1f..4664f88 100644 --- a/src/test/java/org/example/videoclips/config/VideoClippingPropertiesTest.java +++ b/src/test/java/org/example/videoclips/config/VideoClippingPropertiesTest.java @@ -59,6 +59,10 @@ class VideoClippingPropertiesTest { .isEqualTo("http://127.0.0.1:8091/v1/analyze-visuals"); assertThat(properties.getEditing().getVisualAnalysis().getTimeoutMs()).isEqualTo(30000); assertThat(properties.getEditing().getVisualAnalysis().isFallbackToHeuristic()).isTrue(); + assertThat(properties.getEditing().getVisualAnalysis().getLocalCvWorker().isAutoStart()).isFalse(); + assertThat(properties.getEditing().getVisualAnalysis().getLocalCvWorker().getScript()) + .isEqualTo("./tools/run_local_cv_worker.sh"); + assertThat(properties.getEditing().getVisualAnalysis().getLocalCvWorker().getStartupWaitMs()).isZero(); assertThat(properties.getEditing().getLocalDirector().isEnabled()).isTrue(); assertThat(properties.getEditing().getLocalDirector().getSourceDirectory()) .isEqualTo("./input/editing/source"); @@ -101,6 +105,9 @@ class VideoClippingPropertiesTest { "video-clipping.editing.visual-analysis.endpoint=http://localhost:9000/analyze", "video-clipping.editing.visual-analysis.timeout-ms=12000", "video-clipping.editing.visual-analysis.fallback-to-heuristic=false", + "video-clipping.editing.visual-analysis.local-cv-worker.auto-start=true", + "video-clipping.editing.visual-analysis.local-cv-worker.script=/tmp/local-cv.sh", + "video-clipping.editing.visual-analysis.local-cv-worker.startup-wait-ms=250", "video-clipping.editing.local-director.enabled=false", "video-clipping.editing.local-director.source-directory=/tmp/source", "video-clipping.editing.local-director.poll-interval-ms=9000", @@ -140,6 +147,11 @@ class VideoClippingPropertiesTest { .isEqualTo("http://localhost:9000/analyze"); assertThat(properties.getEditing().getVisualAnalysis().getTimeoutMs()).isEqualTo(12000); assertThat(properties.getEditing().getVisualAnalysis().isFallbackToHeuristic()).isFalse(); + assertThat(properties.getEditing().getVisualAnalysis().getLocalCvWorker().isAutoStart()).isTrue(); + assertThat(properties.getEditing().getVisualAnalysis().getLocalCvWorker().getScript()) + .isEqualTo("/tmp/local-cv.sh"); + assertThat(properties.getEditing().getVisualAnalysis().getLocalCvWorker().getStartupWaitMs()) + .isEqualTo(250); assertThat(properties.getEditing().getLocalDirector().isEnabled()).isFalse(); assertThat(properties.getEditing().getLocalDirector().getSourceDirectory()).isEqualTo("/tmp/source"); assertThat(properties.getEditing().getLocalDirector().getPollIntervalMs()).isEqualTo(9000); diff --git a/src/test/java/org/example/videoclips/editing/LocalCvWorkerProcessManagerTest.java b/src/test/java/org/example/videoclips/editing/LocalCvWorkerProcessManagerTest.java new file mode 100644 index 0000000..1a12e66 --- /dev/null +++ b/src/test/java/org/example/videoclips/editing/LocalCvWorkerProcessManagerTest.java @@ -0,0 +1,178 @@ +package org.example.videoclips.editing; + +import org.example.videoclips.config.VideoClippingProperties; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class LocalCvWorkerProcessManagerTest { + + @TempDir + Path tempDir; + + @Test + void doesNotStartWhenProviderIsNotLocalCv() { + VideoClippingProperties properties = new VideoClippingProperties(); + properties.getEditing().getVisualAnalysis().setProvider("heuristic"); + properties.getEditing().getVisualAnalysis().getLocalCvWorker().setAutoStart(true); + + LocalCvWorkerProcessManager manager = new LocalCvWorkerProcessManager(properties, processBuilder -> { + throw new AssertionError("worker should not start"); + }); + + manager.start(); + + assertThat(manager.isRunning()).isFalse(); + } + + @Test + void doesNotStartWhenAutoStartIsDisabled() { + VideoClippingProperties properties = new VideoClippingProperties(); + properties.getEditing().getVisualAnalysis().setProvider("local-cv"); + properties.getEditing().getVisualAnalysis().getLocalCvWorker().setAutoStart(false); + + LocalCvWorkerProcessManager manager = new LocalCvWorkerProcessManager(properties, processBuilder -> { + throw new AssertionError("worker should not start"); + }); + + manager.start(); + + assertThat(manager.isRunning()).isFalse(); + } + + @Test + void startsWorkerScriptWithEndpointHostAndPort() throws Exception { + Path script = tempDir.resolve("run-worker.sh"); + Files.writeString(script, "#!/usr/bin/env bash\n"); + VideoClippingProperties properties = new VideoClippingProperties(); + properties.getEditing().getVisualAnalysis().setProvider("local-cv"); + properties.getEditing().getVisualAnalysis().setEndpoint("http://localhost:9005/v1/analyze-visuals"); + properties.getEditing().getVisualAnalysis().getLocalCvWorker().setAutoStart(true); + properties.getEditing().getVisualAnalysis().getLocalCvWorker().setScript(script.toString()); + AtomicReference captured = new AtomicReference<>(); + TestProcess process = new TestProcess(); + + LocalCvWorkerProcessManager manager = new LocalCvWorkerProcessManager(properties, processBuilder -> { + captured.set(processBuilder); + return process; + }); + + manager.start(); + + assertThat(manager.isRunning()).isTrue(); + assertThat(captured.get().command()).containsExactly(script.toAbsolutePath().normalize().toString()); + assertThat(captured.get().environment()).containsEntry("LOCAL_CV_HOST", "localhost"); + assertThat(captured.get().environment()).containsEntry("LOCAL_CV_PORT", "9005"); + + manager.stop(); + + assertThat(process.destroyed).isTrue(); + assertThat(manager.isRunning()).isFalse(); + } + + @Test + void rejectsMissingWorkerScriptWhenAutoStartIsEnabled() { + VideoClippingProperties properties = new VideoClippingProperties(); + properties.getEditing().getVisualAnalysis().setProvider("local-cv"); + properties.getEditing().getVisualAnalysis().getLocalCvWorker().setAutoStart(true); + properties.getEditing().getVisualAnalysis().getLocalCvWorker().setScript(tempDir.resolve("missing.sh").toString()); + + LocalCvWorkerProcessManager manager = new LocalCvWorkerProcessManager(properties, processBuilder -> { + throw new AssertionError("worker should not start"); + }); + + assertThatThrownBy(manager::start) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Local CV worker script does not exist"); + } + + @Test + void resetsRunningStateWhenWorkerExitsDuringStartupWindow() throws Exception { + Path script = tempDir.resolve("run-worker.sh"); + Files.writeString(script, "#!/usr/bin/env bash\n"); + VideoClippingProperties properties = new VideoClippingProperties(); + properties.getEditing().getVisualAnalysis().setProvider("local-cv"); + properties.getEditing().getVisualAnalysis().getLocalCvWorker().setAutoStart(true); + properties.getEditing().getVisualAnalysis().getLocalCvWorker().setScript(script.toString()); + properties.getEditing().getVisualAnalysis().getLocalCvWorker().setStartupWaitMs(1); + TestProcess process = new TestProcess(); + process.alive = false; + + LocalCvWorkerProcessManager manager = new LocalCvWorkerProcessManager(properties, processBuilder -> process); + + assertThatThrownBy(manager::start) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Local CV worker exited during startup window"); + assertThat(manager.isRunning()).isFalse(); + } + + private static class TestProcess extends Process { + private boolean alive = true; + private boolean destroyed; + + @Override + public OutputStream getOutputStream() { + return OutputStream.nullOutputStream(); + } + + @Override + public InputStream getInputStream() { + return new ByteArrayInputStream("worker ready\n".getBytes()); + } + + @Override + public InputStream getErrorStream() { + return InputStream.nullInputStream(); + } + + @Override + public int waitFor() { + alive = false; + return 0; + } + + @Override + public boolean waitFor(long timeout, TimeUnit unit) { + alive = false; + return true; + } + + @Override + public int exitValue() { + return alive ? 1 : 0; + } + + @Override + public void destroy() { + destroyed = true; + alive = false; + } + + @Override + public Process destroyForcibly() { + destroyed = true; + alive = false; + return this; + } + + @Override + public boolean isAlive() { + return alive; + } + + @Override + public long pid() { + return 1234; + } + } +}