Add local CV worker readiness checks

This commit is contained in:
JSLMPR 2026-07-11 17:46:40 +02:00
parent 80eca92d56
commit adc979eea8
11 changed files with 261 additions and 31 deletions

6
.gitignore vendored
View File

@ -36,3 +36,9 @@ build/
### Mac OS ### ### Mac OS ###
.DS_Store .DS_Store
### Local CV runtime ###
.venv-local-cv/
__pycache__/
*.pyc
yolov*.pt

View File

@ -29,9 +29,11 @@ video-clipping:
timeout-ms: 30000 timeout-ms: 30000
fallback-to-heuristic: true fallback-to-heuristic: true
local-cv-worker: local-cv-worker:
auto-start: false auto-start: true
script: ./tools/run_local_cv_worker.sh 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: Equivalent environment variables:
@ -43,7 +45,9 @@ VIDEO_EDITING_VISUAL_ANALYSIS_TIMEOUT_MS=30000
VIDEO_EDITING_VISUAL_ANALYSIS_FALLBACK_TO_HEURISTIC=true VIDEO_EDITING_VISUAL_ANALYSIS_FALLBACK_TO_HEURISTIC=true
VIDEO_EDITING_LOCAL_CV_WORKER_AUTO_START=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_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: This repository includes an optional starter worker:
@ -68,6 +72,18 @@ Health check:
curl http://127.0.0.1:8091/health 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 ## Spring-Managed Worker Startup
The Spring application can start the local CV worker for local runs: 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 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 ## Worker Request Contract

View File

@ -904,6 +904,11 @@ public class VideoClippingProperties {
@Min(0) @Min(0)
private long startupWaitMs = 0; private long startupWaitMs = 0;
private String healthPath = "/health";
@Min(1)
private long healthCheckIntervalMs = 1000;
public boolean isAutoStart() { public boolean isAutoStart() {
return autoStart; return autoStart;
} }
@ -927,6 +932,22 @@ public class VideoClippingProperties {
public void setStartupWaitMs(long startupWaitMs) { public void setStartupWaitMs(long startupWaitMs) {
this.startupWaitMs = 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;
}
} }
} }

View File

@ -13,6 +13,9 @@ import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.io.InputStreamReader; import java.io.InputStreamReader;
import java.net.URI; 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.charset.StandardCharsets;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
@ -28,6 +31,7 @@ public class LocalCvWorkerProcessManager implements SmartLifecycle {
private final VideoClippingProperties.Editing.VisualAnalysis visualAnalysis; private final VideoClippingProperties.Editing.VisualAnalysis visualAnalysis;
private final ProcessLauncher processLauncher; private final ProcessLauncher processLauncher;
private final HealthChecker healthChecker;
private Process process; private Process process;
private Thread outputThread; private Thread outputThread;
private Thread watcherThread; private Thread watcherThread;
@ -36,15 +40,24 @@ public class LocalCvWorkerProcessManager implements SmartLifecycle {
@Autowired @Autowired
public LocalCvWorkerProcessManager(VideoClippingProperties properties) { public LocalCvWorkerProcessManager(VideoClippingProperties properties) {
this(properties, ProcessBuilder::start); this(properties, ProcessBuilder::start, new HttpHealthChecker());
} }
LocalCvWorkerProcessManager( LocalCvWorkerProcessManager(
VideoClippingProperties properties, VideoClippingProperties properties,
ProcessLauncher processLauncher ProcessLauncher processLauncher
) {
this(properties, processLauncher, new HttpHealthChecker());
}
LocalCvWorkerProcessManager(
VideoClippingProperties properties,
ProcessLauncher processLauncher,
HealthChecker healthChecker
) { ) {
this.visualAnalysis = properties.getEditing().getVisualAnalysis(); this.visualAnalysis = properties.getEditing().getVisualAnalysis();
this.processLauncher = processLauncher; this.processLauncher = processLauncher;
this.healthChecker = healthChecker;
} }
@Override @Override
@ -63,8 +76,11 @@ public class LocalCvWorkerProcessManager implements SmartLifecycle {
VideoClippingProperties.Editing.VisualAnalysis.LocalCvWorker worker = visualAnalysis.getLocalCvWorker(); VideoClippingProperties.Editing.VisualAnalysis.LocalCvWorker worker = visualAnalysis.getLocalCvWorker();
Path script = Path.of(worker.getScript()).toAbsolutePath().normalize(); Path script = Path.of(worker.getScript()).toAbsolutePath().normalize();
log.info("event=local_cv_worker_starting script={} endpoint={} startup_wait_ms={}", URI healthUri = healthUri(worker);
script, visualAnalysis.getEndpoint(), worker.getStartupWaitMs()); 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)) { if (!Files.isRegularFile(script)) {
throw new IllegalStateException("Local CV worker script does not exist: " + script); throw new IllegalStateException("Local CV worker script does not exist: " + script);
} }
@ -86,9 +102,9 @@ public class LocalCvWorkerProcessManager implements SmartLifecycle {
watcherThread.start(); watcherThread.start();
log.info("event=local_cv_worker_started script={} pid={} host={} port={} endpoint={}", log.info("event=local_cv_worker_started script={} pid={} host={} port={} endpoint={}",
script, process.pid(), endpoint.host(), endpoint.port(), visualAnalysis.getEndpoint()); script, process.pid(), endpoint.host(), endpoint.port(), visualAnalysis.getEndpoint());
waitForStartupWindow(worker.getStartupWaitMs()); waitUntilHealthy(worker, healthUri);
log.info("event=local_cv_worker_startup_window_completed script={} pid={} alive={}", log.info("event=local_cv_worker_ready script={} pid={} health_uri={} alive={}",
script, process.pid(), process.isAlive()); script, process.pid(), healthUri, process.isAlive());
} catch (IOException ex) { } catch (IOException ex) {
running = false; running = false;
log.error("event=local_cv_worker_start_failed script={} error_type={} message={}", log.error("event=local_cv_worker_start_failed script={} error_type={} message={}",
@ -147,20 +163,41 @@ public class LocalCvWorkerProcessManager implements SmartLifecycle {
&& visualAnalysis.getLocalCvWorker().isAutoStart(); && 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) { 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; return;
} }
long startedAt = System.nanoTime();
long deadline = System.currentTimeMillis() + startupWaitMs;
long intervalMs = Math.max(1, worker.getHealthCheckIntervalMs());
try { 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) { } catch (InterruptedException ex) {
Thread.currentThread().interrupt(); Thread.currentThread().interrupt();
throw new IllegalStateException("Interrupted while waiting for local CV worker startup", ex); throw new IllegalStateException("Interrupted while waiting for local CV worker health", ex);
}
if (process != null && !process.isAlive()) {
throw new IllegalStateException("Local CV worker exited during startup window");
} }
throw new IllegalStateException("Local CV worker did not become healthy within "
+ startupWaitMs + "ms at " + healthUri);
} }
private void cleanupFailedStart() { private void cleanupFailedStart() {
@ -181,6 +218,28 @@ public class LocalCvWorkerProcessManager implements SmartLifecycle {
return new Endpoint(host, port); 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) { private Thread outputReader(InputStream inputStream, Path script) {
Thread thread = new Thread(() -> { Thread thread = new Thread(() -> {
try (BufferedReader reader = new BufferedReader( try (BufferedReader reader = new BufferedReader(
@ -228,4 +287,40 @@ public class LocalCvWorkerProcessManager implements SmartLifecycle {
interface ProcessLauncher { interface ProcessLauncher {
Process start(ProcessBuilder processBuilder) throws IOException; 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<String> 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) + "...";
}
}
} }

View File

@ -5,7 +5,7 @@ video-clipping:
enabled: true enabled: true
local-director: local-director:
enabled: true enabled: true
auto-render-when-plan-appears: false auto-render-when-plan-appears: true
require-approval-before-render: true require-approval-before-render: true
approval-file-name: approved.flag approval-file-name: approved.flag

View File

@ -54,9 +54,11 @@ video-clipping:
timeout-ms: ${VIDEO_EDITING_VISUAL_ANALYSIS_TIMEOUT_MS:30000} timeout-ms: ${VIDEO_EDITING_VISUAL_ANALYSIS_TIMEOUT_MS:30000}
fallback-to-heuristic: ${VIDEO_EDITING_VISUAL_ANALYSIS_FALLBACK_TO_HEURISTIC:true} fallback-to-heuristic: ${VIDEO_EDITING_VISUAL_ANALYSIS_FALLBACK_TO_HEURISTIC:true}
local-cv-worker: 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} 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: local-director:
enabled: ${VIDEO_EDITING_LOCAL_DIRECTOR_ENABLED:true} enabled: ${VIDEO_EDITING_LOCAL_DIRECTOR_ENABLED:true}
source-directory: ${VIDEO_EDITING_LOCAL_DIRECTOR_SOURCE_DIRECTORY:./input/editing/source} source-directory: ${VIDEO_EDITING_LOCAL_DIRECTOR_SOURCE_DIRECTORY:./input/editing/source}

View File

@ -63,6 +63,10 @@ class VideoClippingPropertiesTest {
assertThat(properties.getEditing().getVisualAnalysis().getLocalCvWorker().getScript()) assertThat(properties.getEditing().getVisualAnalysis().getLocalCvWorker().getScript())
.isEqualTo("./tools/run_local_cv_worker.sh"); .isEqualTo("./tools/run_local_cv_worker.sh");
assertThat(properties.getEditing().getVisualAnalysis().getLocalCvWorker().getStartupWaitMs()).isZero(); 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().isEnabled()).isTrue();
assertThat(properties.getEditing().getLocalDirector().getSourceDirectory()) assertThat(properties.getEditing().getLocalDirector().getSourceDirectory())
.isEqualTo("./input/editing/source"); .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.auto-start=true",
"video-clipping.editing.visual-analysis.local-cv-worker.script=/tmp/local-cv.sh", "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.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.enabled=false",
"video-clipping.editing.local-director.source-directory=/tmp/source", "video-clipping.editing.local-director.source-directory=/tmp/source",
"video-clipping.editing.local-director.poll-interval-ms=9000", "video-clipping.editing.local-director.poll-interval-ms=9000",
@ -152,6 +158,10 @@ class VideoClippingPropertiesTest {
.isEqualTo("/tmp/local-cv.sh"); .isEqualTo("/tmp/local-cv.sh");
assertThat(properties.getEditing().getVisualAnalysis().getLocalCvWorker().getStartupWaitMs()) assertThat(properties.getEditing().getVisualAnalysis().getLocalCvWorker().getStartupWaitMs())
.isEqualTo(250); .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().isEnabled()).isFalse();
assertThat(properties.getEditing().getLocalDirector().getSourceDirectory()).isEqualTo("/tmp/source"); assertThat(properties.getEditing().getLocalDirector().getSourceDirectory()).isEqualTo("/tmp/source");
assertThat(properties.getEditing().getLocalDirector().getPollIntervalMs()).isEqualTo(9000); assertThat(properties.getEditing().getLocalDirector().getPollIntervalMs()).isEqualTo(9000);

View File

@ -11,7 +11,7 @@ import static org.assertj.core.api.Assertions.assertThat;
class CinematicEditingLocalProfileTest { class CinematicEditingLocalProfileTest {
@Test @Test
void enablesLocalDirectorAndDisablesEightSecondFolderScheduler() { void enablesLocalDirectorAutoRenderAndDisablesEightSecondFolderScheduler() {
YamlPropertiesFactoryBean factory = new YamlPropertiesFactoryBean(); YamlPropertiesFactoryBean factory = new YamlPropertiesFactoryBean();
factory.setResources(new ClassPathResource("application-cinematic-editing-local.yml")); factory.setResources(new ClassPathResource("application-cinematic-editing-local.yml"));
Properties properties = factory.getObject(); 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.enabled")).isEqualTo("true");
assertThat(properties.getProperty("video-clipping.editing.local-director.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")) 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")) assertThat(properties.getProperty("video-clipping.editing.local-director.require-approval-before-render"))
.isEqualTo("true"); .isEqualTo("true");
assertThat(properties.getProperty("video-clipping.editing.local-director.approval-file-name")) assertThat(properties.getProperty("video-clipping.editing.local-director.approval-file-name"))

View File

@ -7,9 +7,11 @@ import org.junit.jupiter.api.io.TempDir;
import java.io.ByteArrayInputStream; import java.io.ByteArrayInputStream;
import java.io.InputStream; import java.io.InputStream;
import java.io.OutputStream; import java.io.OutputStream;
import java.net.URI;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference; import java.util.concurrent.atomic.AtomicReference;
import static org.assertj.core.api.Assertions.assertThat; 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().setEndpoint("http://localhost:9005/v1/analyze-visuals");
properties.getEditing().getVisualAnalysis().getLocalCvWorker().setAutoStart(true); properties.getEditing().getVisualAnalysis().getLocalCvWorker().setAutoStart(true);
properties.getEditing().getVisualAnalysis().getLocalCvWorker().setScript(script.toString()); properties.getEditing().getVisualAnalysis().getLocalCvWorker().setScript(script.toString());
properties.getEditing().getVisualAnalysis().getLocalCvWorker().setStartupWaitMs(100);
AtomicReference<ProcessBuilder> captured = new AtomicReference<>(); AtomicReference<ProcessBuilder> captured = new AtomicReference<>();
AtomicReference<URI> healthUri = new AtomicReference<>();
TestProcess process = new TestProcess(); TestProcess process = new TestProcess();
LocalCvWorkerProcessManager manager = new LocalCvWorkerProcessManager(properties, processBuilder -> { LocalCvWorkerProcessManager manager = new LocalCvWorkerProcessManager(
captured.set(processBuilder); properties,
return process; processBuilder -> {
}); captured.set(processBuilder);
return process;
},
(uri, timeoutMs) -> {
healthUri.set(uri);
return new LocalCvWorkerProcessManager.HealthResult(true, 200, "ok");
});
manager.start(); manager.start();
@ -73,6 +83,7 @@ class LocalCvWorkerProcessManagerTest {
assertThat(captured.get().command()).containsExactly(script.toAbsolutePath().normalize().toString()); 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_HOST", "localhost");
assertThat(captured.get().environment()).containsEntry("LOCAL_CV_PORT", "9005"); assertThat(captured.get().environment()).containsEntry("LOCAL_CV_PORT", "9005");
assertThat(healthUri.get()).hasToString("http://localhost:9005/health");
manager.stop(); manager.stop();
@ -97,7 +108,7 @@ class LocalCvWorkerProcessManagerTest {
} }
@Test @Test
void resetsRunningStateWhenWorkerExitsDuringStartupWindow() throws Exception { void resetsRunningStateWhenWorkerExitsBeforeHealthCheckPasses() throws Exception {
Path script = tempDir.resolve("run-worker.sh"); Path script = tempDir.resolve("run-worker.sh");
Files.writeString(script, "#!/usr/bin/env bash\n"); Files.writeString(script, "#!/usr/bin/env bash\n");
VideoClippingProperties properties = new VideoClippingProperties(); VideoClippingProperties properties = new VideoClippingProperties();
@ -108,14 +119,69 @@ class LocalCvWorkerProcessManagerTest {
TestProcess process = new TestProcess(); TestProcess process = new TestProcess();
process.alive = false; 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) assertThatThrownBy(manager::start)
.isInstanceOf(IllegalStateException.class) .isInstanceOf(IllegalStateException.class)
.hasMessageContaining("Local CV worker exited during startup window"); .hasMessageContaining("Local CV worker exited before becoming healthy");
assertThat(manager.isRunning()).isFalse(); 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 static class TestProcess extends Process {
private boolean alive = true; private boolean alive = true;
private boolean destroyed; private boolean destroyed;

View File

@ -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

View File

@ -3,6 +3,7 @@ set -euo pipefail
HOST="${LOCAL_CV_HOST:-127.0.0.1}" HOST="${LOCAL_CV_HOST:-127.0.0.1}"
PORT="${LOCAL_CV_PORT:-8091}" PORT="${LOCAL_CV_PORT:-8091}"
MODEL="${LOCAL_CV_YOLO_MODEL:-yolov8n.pt}"
if [ ! -d ".venv-local-cv" ]; then if [ ! -d ".venv-local-cv" ]; then
python3 -m venv .venv-local-cv python3 -m venv .venv-local-cv
@ -12,4 +13,13 @@ fi
python -m pip install --upgrade pip python -m pip install --upgrade pip
python -m pip install -r tools/local_cv_requirements.txt 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" exec uvicorn tools.local_cv_worker:app --host "$HOST" --port "$PORT"