diff --git a/.gitignore b/.gitignore index 5ff6309..db91d43 100644 --- a/.gitignore +++ b/.gitignore @@ -35,4 +35,10 @@ build/ .vscode/ ### Mac OS ### -.DS_Store \ No newline at end of file +.DS_Store + +### Local CV runtime ### +.venv-local-cv/ +__pycache__/ +*.pyc +yolov*.pt diff --git a/docs/local-cv-visual-analysis-guide.md b/docs/local-cv-visual-analysis-guide.md index cbc3dfd..7990160 100644 --- a/docs/local-cv-visual-analysis-guide.md +++ b/docs/local-cv-visual-analysis-guide.md @@ -29,9 +29,11 @@ video-clipping: timeout-ms: 30000 fallback-to-heuristic: true local-cv-worker: - auto-start: false + auto-start: true script: ./tools/run_local_cv_worker.sh - startup-wait-ms: 0 + startup-wait-ms: 120000 + health-path: /health + health-check-interval-ms: 1000 ``` Equivalent environment variables: @@ -43,7 +45,9 @@ VIDEO_EDITING_VISUAL_ANALYSIS_TIMEOUT_MS=30000 VIDEO_EDITING_VISUAL_ANALYSIS_FALLBACK_TO_HEURISTIC=true VIDEO_EDITING_LOCAL_CV_WORKER_AUTO_START=true VIDEO_EDITING_LOCAL_CV_WORKER_SCRIPT=./tools/run_local_cv_worker.sh -VIDEO_EDITING_LOCAL_CV_WORKER_STARTUP_WAIT_MS=0 +VIDEO_EDITING_LOCAL_CV_WORKER_STARTUP_WAIT_MS=120000 +VIDEO_EDITING_LOCAL_CV_WORKER_HEALTH_PATH=/health +VIDEO_EDITING_LOCAL_CV_WORKER_HEALTH_CHECK_INTERVAL_MS=1000 ``` This repository includes an optional starter worker: @@ -68,6 +72,18 @@ Health check: curl http://127.0.0.1:8091/health ``` +## Preload Dependencies And Model + +Do this once before starting the Spring service if you do not want dependency installation or YOLO model download to happen during application startup: + +```bash +LOCAL_CV_PRELOAD_ONLY=true tools/run_local_cv_worker.sh +``` + +This creates `.venv-local-cv`, installs `tools/local_cv_requirements.txt`, and downloads/caches the configured YOLO model. After that, Spring-managed startup should be much faster. + +Do not commit `.venv-local-cv`, Python wheels, Torch binaries, or YOLO weights into source control. They are large, platform-specific runtime artifacts. For production, prefer a prebuilt container image or a VM/bootstrap step that runs the preload command during deployment. + ## Spring-Managed Worker Startup The Spring application can start the local CV worker for local runs: @@ -78,9 +94,9 @@ 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. +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, polls the configured health endpoint until it returns HTTP 2xx, 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. +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. Use the preload command or a prebuilt image to avoid that startup cost. ## Worker Request Contract diff --git a/src/main/java/org/example/videoclips/config/VideoClippingProperties.java b/src/main/java/org/example/videoclips/config/VideoClippingProperties.java index db1a717..849a6bf 100644 --- a/src/main/java/org/example/videoclips/config/VideoClippingProperties.java +++ b/src/main/java/org/example/videoclips/config/VideoClippingProperties.java @@ -904,6 +904,11 @@ public class VideoClippingProperties { @Min(0) private long startupWaitMs = 0; + private String healthPath = "/health"; + + @Min(1) + private long healthCheckIntervalMs = 1000; + public boolean isAutoStart() { return autoStart; } @@ -927,6 +932,22 @@ public class VideoClippingProperties { public void setStartupWaitMs(long startupWaitMs) { this.startupWaitMs = startupWaitMs; } + + public String getHealthPath() { + return healthPath; + } + + public void setHealthPath(String healthPath) { + this.healthPath = healthPath; + } + + public long getHealthCheckIntervalMs() { + return healthCheckIntervalMs; + } + + public void setHealthCheckIntervalMs(long healthCheckIntervalMs) { + this.healthCheckIntervalMs = healthCheckIntervalMs; + } } } diff --git a/src/main/java/org/example/videoclips/editing/LocalCvWorkerProcessManager.java b/src/main/java/org/example/videoclips/editing/LocalCvWorkerProcessManager.java index e92d45f..adae475 100644 --- a/src/main/java/org/example/videoclips/editing/LocalCvWorkerProcessManager.java +++ b/src/main/java/org/example/videoclips/editing/LocalCvWorkerProcessManager.java @@ -13,6 +13,9 @@ import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; @@ -28,6 +31,7 @@ public class LocalCvWorkerProcessManager implements SmartLifecycle { private final VideoClippingProperties.Editing.VisualAnalysis visualAnalysis; private final ProcessLauncher processLauncher; + private final HealthChecker healthChecker; private Process process; private Thread outputThread; private Thread watcherThread; @@ -36,15 +40,24 @@ public class LocalCvWorkerProcessManager implements SmartLifecycle { @Autowired public LocalCvWorkerProcessManager(VideoClippingProperties properties) { - this(properties, ProcessBuilder::start); + this(properties, ProcessBuilder::start, new HttpHealthChecker()); } LocalCvWorkerProcessManager( VideoClippingProperties properties, ProcessLauncher processLauncher + ) { + this(properties, processLauncher, new HttpHealthChecker()); + } + + LocalCvWorkerProcessManager( + VideoClippingProperties properties, + ProcessLauncher processLauncher, + HealthChecker healthChecker ) { this.visualAnalysis = properties.getEditing().getVisualAnalysis(); this.processLauncher = processLauncher; + this.healthChecker = healthChecker; } @Override @@ -63,8 +76,11 @@ public class LocalCvWorkerProcessManager implements SmartLifecycle { VideoClippingProperties.Editing.VisualAnalysis.LocalCvWorker worker = visualAnalysis.getLocalCvWorker(); Path script = Path.of(worker.getScript()).toAbsolutePath().normalize(); - log.info("event=local_cv_worker_starting script={} endpoint={} startup_wait_ms={}", - script, visualAnalysis.getEndpoint(), worker.getStartupWaitMs()); + URI healthUri = healthUri(worker); + log.info("event=local_cv_worker_starting script={} endpoint={} health_uri={} startup_wait_ms={} " + + "health_check_interval_ms={}", + script, visualAnalysis.getEndpoint(), healthUri, worker.getStartupWaitMs(), + worker.getHealthCheckIntervalMs()); if (!Files.isRegularFile(script)) { throw new IllegalStateException("Local CV worker script does not exist: " + script); } @@ -86,9 +102,9 @@ public class LocalCvWorkerProcessManager implements SmartLifecycle { watcherThread.start(); log.info("event=local_cv_worker_started script={} pid={} host={} port={} endpoint={}", script, process.pid(), endpoint.host(), endpoint.port(), visualAnalysis.getEndpoint()); - waitForStartupWindow(worker.getStartupWaitMs()); - log.info("event=local_cv_worker_startup_window_completed script={} pid={} alive={}", - script, process.pid(), process.isAlive()); + waitUntilHealthy(worker, healthUri); + log.info("event=local_cv_worker_ready script={} pid={} health_uri={} alive={}", + script, process.pid(), healthUri, process.isAlive()); } catch (IOException ex) { running = false; log.error("event=local_cv_worker_start_failed script={} error_type={} message={}", @@ -147,20 +163,41 @@ public class LocalCvWorkerProcessManager implements SmartLifecycle { && visualAnalysis.getLocalCvWorker().isAutoStart(); } - private void waitForStartupWindow(long startupWaitMs) { + private void waitUntilHealthy( + VideoClippingProperties.Editing.VisualAnalysis.LocalCvWorker worker, + URI healthUri + ) { + long startupWaitMs = worker.getStartupWaitMs(); if (startupWaitMs <= 0) { - log.info("event=local_cv_worker_startup_wait_skipped reason=disabled"); + log.info("event=local_cv_worker_health_wait_skipped reason=disabled health_uri={}", healthUri); return; } + long startedAt = System.nanoTime(); + long deadline = System.currentTimeMillis() + startupWaitMs; + long intervalMs = Math.max(1, worker.getHealthCheckIntervalMs()); try { - Thread.sleep(startupWaitMs); + while (System.currentTimeMillis() <= deadline) { + if (process != null && !process.isAlive()) { + throw new IllegalStateException("Local CV worker exited before becoming healthy"); + } + HealthResult result = healthChecker.check(healthUri, Math.min(intervalMs, 5000)); + log.info("event=local_cv_worker_health_check health_uri={} healthy={} status_code={} " + + "elapsed_ms={} message={}", + healthUri, result.healthy(), result.statusCode(), elapsedMillis(startedAt), + result.message()); + if (result.healthy()) { + log.info("event=local_cv_worker_health_ready health_uri={} elapsed_ms={}", + healthUri, elapsedMillis(startedAt)); + return; + } + Thread.sleep(intervalMs); + } } 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"); + throw new IllegalStateException("Interrupted while waiting for local CV worker health", ex); } + throw new IllegalStateException("Local CV worker did not become healthy within " + + startupWaitMs + "ms at " + healthUri); } private void cleanupFailedStart() { @@ -181,6 +218,28 @@ public class LocalCvWorkerProcessManager implements SmartLifecycle { return new Endpoint(host, port); } + private URI healthUri(VideoClippingProperties.Editing.VisualAnalysis.LocalCvWorker worker) { + URI endpointUri = URI.create(visualAnalysis.getEndpoint()); + String scheme = endpointUri.getScheme() == null || endpointUri.getScheme().isBlank() + ? "http" + : endpointUri.getScheme(); + String host = endpointUri.getHost() == null || endpointUri.getHost().isBlank() + ? "127.0.0.1" + : endpointUri.getHost(); + int port = endpointUri.getPort(); + String healthPath = worker.getHealthPath() == null || worker.getHealthPath().isBlank() + ? "/health" + : worker.getHealthPath(); + if (!healthPath.startsWith("/")) { + healthPath = "/" + healthPath; + } + return URI.create("%s://%s%s%s".formatted(scheme, host, port < 0 ? "" : ":" + port, healthPath)); + } + + private long elapsedMillis(long startedAt) { + return (System.nanoTime() - startedAt) / 1_000_000; + } + private Thread outputReader(InputStream inputStream, Path script) { Thread thread = new Thread(() -> { try (BufferedReader reader = new BufferedReader( @@ -228,4 +287,40 @@ public class LocalCvWorkerProcessManager implements SmartLifecycle { interface ProcessLauncher { Process start(ProcessBuilder processBuilder) throws IOException; } + + record HealthResult(boolean healthy, int statusCode, String message) { + } + + @FunctionalInterface + interface HealthChecker { + HealthResult check(URI healthUri, long timeoutMs) throws InterruptedException; + } + + private static class HttpHealthChecker implements HealthChecker { + private final HttpClient httpClient = HttpClient.newHttpClient(); + + @Override + public HealthResult check(URI healthUri, long timeoutMs) throws InterruptedException { + try { + HttpRequest request = HttpRequest.newBuilder() + .uri(healthUri) + .timeout(Duration.ofMillis(timeoutMs)) + .GET() + .build(); + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + return new HealthResult(response.statusCode() >= 200 && response.statusCode() < 300, + response.statusCode(), preview(response.body())); + } catch (IOException ex) { + return new HealthResult(false, 0, ex.getClass().getSimpleName() + ": " + ex.getMessage()); + } + } + + private String preview(String body) { + if (body == null || body.isBlank()) { + return ""; + } + String compact = body.replaceAll("\\s+", " ").trim(); + return compact.length() <= 160 ? compact : compact.substring(0, 160) + "..."; + } + } } diff --git a/src/main/resources/application-cinematic-editing-local.yml b/src/main/resources/application-cinematic-editing-local.yml index 8d85f96..4bca522 100644 --- a/src/main/resources/application-cinematic-editing-local.yml +++ b/src/main/resources/application-cinematic-editing-local.yml @@ -5,7 +5,7 @@ video-clipping: enabled: true local-director: enabled: true - auto-render-when-plan-appears: false + auto-render-when-plan-appears: true require-approval-before-render: true approval-file-name: approved.flag diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 785a127..41c84ad 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -54,9 +54,11 @@ video-clipping: 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} + auto-start: ${VIDEO_EDITING_LOCAL_CV_WORKER_AUTO_START:true} 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} + startup-wait-ms: ${VIDEO_EDITING_LOCAL_CV_WORKER_STARTUP_WAIT_MS:120000} + health-path: ${VIDEO_EDITING_LOCAL_CV_WORKER_HEALTH_PATH:/health} + health-check-interval-ms: ${VIDEO_EDITING_LOCAL_CV_WORKER_HEALTH_CHECK_INTERVAL_MS:1000} 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 4664f88..fd5313f 100644 --- a/src/test/java/org/example/videoclips/config/VideoClippingPropertiesTest.java +++ b/src/test/java/org/example/videoclips/config/VideoClippingPropertiesTest.java @@ -63,6 +63,10 @@ class VideoClippingPropertiesTest { assertThat(properties.getEditing().getVisualAnalysis().getLocalCvWorker().getScript()) .isEqualTo("./tools/run_local_cv_worker.sh"); assertThat(properties.getEditing().getVisualAnalysis().getLocalCvWorker().getStartupWaitMs()).isZero(); + assertThat(properties.getEditing().getVisualAnalysis().getLocalCvWorker().getHealthPath()) + .isEqualTo("/health"); + assertThat(properties.getEditing().getVisualAnalysis().getLocalCvWorker().getHealthCheckIntervalMs()) + .isEqualTo(1000); assertThat(properties.getEditing().getLocalDirector().isEnabled()).isTrue(); assertThat(properties.getEditing().getLocalDirector().getSourceDirectory()) .isEqualTo("./input/editing/source"); @@ -108,6 +112,8 @@ class VideoClippingPropertiesTest { "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.visual-analysis.local-cv-worker.health-path=/ready", + "video-clipping.editing.visual-analysis.local-cv-worker.health-check-interval-ms=25", "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", @@ -152,6 +158,10 @@ class VideoClippingPropertiesTest { .isEqualTo("/tmp/local-cv.sh"); assertThat(properties.getEditing().getVisualAnalysis().getLocalCvWorker().getStartupWaitMs()) .isEqualTo(250); + assertThat(properties.getEditing().getVisualAnalysis().getLocalCvWorker().getHealthPath()) + .isEqualTo("/ready"); + assertThat(properties.getEditing().getVisualAnalysis().getLocalCvWorker().getHealthCheckIntervalMs()) + .isEqualTo(25); 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/CinematicEditingLocalProfileTest.java b/src/test/java/org/example/videoclips/editing/CinematicEditingLocalProfileTest.java index 958307d..c869523 100644 --- a/src/test/java/org/example/videoclips/editing/CinematicEditingLocalProfileTest.java +++ b/src/test/java/org/example/videoclips/editing/CinematicEditingLocalProfileTest.java @@ -11,7 +11,7 @@ import static org.assertj.core.api.Assertions.assertThat; class CinematicEditingLocalProfileTest { @Test - void enablesLocalDirectorAndDisablesEightSecondFolderScheduler() { + void enablesLocalDirectorAutoRenderAndDisablesEightSecondFolderScheduler() { YamlPropertiesFactoryBean factory = new YamlPropertiesFactoryBean(); factory.setResources(new ClassPathResource("application-cinematic-editing-local.yml")); Properties properties = factory.getObject(); @@ -21,7 +21,7 @@ class CinematicEditingLocalProfileTest { assertThat(properties.getProperty("video-clipping.editing.enabled")).isEqualTo("true"); assertThat(properties.getProperty("video-clipping.editing.local-director.enabled")).isEqualTo("true"); assertThat(properties.getProperty("video-clipping.editing.local-director.auto-render-when-plan-appears")) - .isEqualTo("false"); + .isEqualTo("true"); assertThat(properties.getProperty("video-clipping.editing.local-director.require-approval-before-render")) .isEqualTo("true"); assertThat(properties.getProperty("video-clipping.editing.local-director.approval-file-name")) diff --git a/src/test/java/org/example/videoclips/editing/LocalCvWorkerProcessManagerTest.java b/src/test/java/org/example/videoclips/editing/LocalCvWorkerProcessManagerTest.java index 3e7fd15..3321663 100644 --- a/src/test/java/org/example/videoclips/editing/LocalCvWorkerProcessManagerTest.java +++ b/src/test/java/org/example/videoclips/editing/LocalCvWorkerProcessManagerTest.java @@ -7,9 +7,11 @@ import org.junit.jupiter.api.io.TempDir; import java.io.ByteArrayInputStream; import java.io.InputStream; import java.io.OutputStream; +import java.net.URI; import java.nio.file.Files; import java.nio.file.Path; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; import static org.assertj.core.api.Assertions.assertThat; @@ -59,13 +61,21 @@ class LocalCvWorkerProcessManagerTest { properties.getEditing().getVisualAnalysis().setEndpoint("http://localhost:9005/v1/analyze-visuals"); properties.getEditing().getVisualAnalysis().getLocalCvWorker().setAutoStart(true); properties.getEditing().getVisualAnalysis().getLocalCvWorker().setScript(script.toString()); + properties.getEditing().getVisualAnalysis().getLocalCvWorker().setStartupWaitMs(100); AtomicReference captured = new AtomicReference<>(); + AtomicReference healthUri = new AtomicReference<>(); TestProcess process = new TestProcess(); - LocalCvWorkerProcessManager manager = new LocalCvWorkerProcessManager(properties, processBuilder -> { - captured.set(processBuilder); - return process; - }); + LocalCvWorkerProcessManager manager = new LocalCvWorkerProcessManager( + properties, + processBuilder -> { + captured.set(processBuilder); + return process; + }, + (uri, timeoutMs) -> { + healthUri.set(uri); + return new LocalCvWorkerProcessManager.HealthResult(true, 200, "ok"); + }); manager.start(); @@ -73,6 +83,7 @@ class LocalCvWorkerProcessManagerTest { 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"); + assertThat(healthUri.get()).hasToString("http://localhost:9005/health"); manager.stop(); @@ -97,7 +108,7 @@ class LocalCvWorkerProcessManagerTest { } @Test - void resetsRunningStateWhenWorkerExitsDuringStartupWindow() throws Exception { + void resetsRunningStateWhenWorkerExitsBeforeHealthCheckPasses() throws Exception { Path script = tempDir.resolve("run-worker.sh"); Files.writeString(script, "#!/usr/bin/env bash\n"); VideoClippingProperties properties = new VideoClippingProperties(); @@ -108,14 +119,69 @@ class LocalCvWorkerProcessManagerTest { TestProcess process = new TestProcess(); process.alive = false; - LocalCvWorkerProcessManager manager = new LocalCvWorkerProcessManager(properties, processBuilder -> process); + LocalCvWorkerProcessManager manager = new LocalCvWorkerProcessManager( + properties, + processBuilder -> process, + (uri, timeoutMs) -> new LocalCvWorkerProcessManager.HealthResult(false, 0, "connection refused")); assertThatThrownBy(manager::start) .isInstanceOf(IllegalStateException.class) - .hasMessageContaining("Local CV worker exited during startup window"); + .hasMessageContaining("Local CV worker exited before becoming healthy"); assertThat(manager.isRunning()).isFalse(); } + @Test + void failsWhenWorkerDoesNotBecomeHealthyBeforeTimeout() 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); + properties.getEditing().getVisualAnalysis().getLocalCvWorker().setHealthCheckIntervalMs(1); + TestProcess process = new TestProcess(); + + LocalCvWorkerProcessManager manager = new LocalCvWorkerProcessManager( + properties, + processBuilder -> process, + (uri, timeoutMs) -> new LocalCvWorkerProcessManager.HealthResult(false, 503, "starting")); + + assertThatThrownBy(manager::start) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Local CV worker did not become healthy"); + assertThat(process.destroyed).isTrue(); + assertThat(manager.isRunning()).isFalse(); + } + + @Test + void retriesHealthChecksUntilWorkerIsReady() 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(100); + properties.getEditing().getVisualAnalysis().getLocalCvWorker().setHealthCheckIntervalMs(1); + AtomicInteger attempts = new AtomicInteger(); + TestProcess process = new TestProcess(); + + LocalCvWorkerProcessManager manager = new LocalCvWorkerProcessManager( + properties, + processBuilder -> process, + (uri, timeoutMs) -> attempts.incrementAndGet() < 2 + ? new LocalCvWorkerProcessManager.HealthResult(false, 0, "connection refused") + : new LocalCvWorkerProcessManager.HealthResult(true, 200, "ok")); + + manager.start(); + + assertThat(attempts.get()).isEqualTo(2); + assertThat(manager.isRunning()).isTrue(); + + manager.stop(); + } + private static class TestProcess extends Process { private boolean alive = true; private boolean destroyed; diff --git a/src/test/resources/application.properties b/src/test/resources/application.properties new file mode 100644 index 0000000..71f756d --- /dev/null +++ b/src/test/resources/application.properties @@ -0,0 +1,4 @@ +video-clipping.folder-scheduler.enabled=false +video-clipping.editing.local-director.enabled=false +video-clipping.editing.highlight-scheduler.enabled=false +video-clipping.editing.visual-analysis.local-cv-worker.auto-start=false diff --git a/tools/run_local_cv_worker.sh b/tools/run_local_cv_worker.sh index 689fde0..2cd4fd8 100755 --- a/tools/run_local_cv_worker.sh +++ b/tools/run_local_cv_worker.sh @@ -3,6 +3,7 @@ set -euo pipefail HOST="${LOCAL_CV_HOST:-127.0.0.1}" PORT="${LOCAL_CV_PORT:-8091}" +MODEL="${LOCAL_CV_YOLO_MODEL:-yolov8n.pt}" if [ ! -d ".venv-local-cv" ]; then python3 -m venv .venv-local-cv @@ -12,4 +13,13 @@ fi python -m pip install --upgrade pip python -m pip install -r tools/local_cv_requirements.txt +if [ "${LOCAL_CV_DISABLE_YOLO:-false}" != "true" ]; then + LOCAL_CV_YOLO_MODEL="$MODEL" python -c "import os; from ultralytics import YOLO; YOLO(os.environ['LOCAL_CV_YOLO_MODEL'])" +fi + +if [ "${LOCAL_CV_PRELOAD_ONLY:-false}" = "true" ]; then + echo "Local CV dependencies and model are ready." + exit 0 +fi + exec uvicorn tools.local_cv_worker:app --host "$HOST" --port "$PORT"