Add managed local CV worker startup

This commit is contained in:
JSLMPR 2026-07-11 16:50:54 +02:00
parent 9dc720b0cc
commit e2f49e2e5a
6 changed files with 440 additions and 3 deletions

View File

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

View File

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

View File

@ -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<String, String> 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;
}
}

View File

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

View File

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

View File

@ -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<ProcessBuilder> 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;
}
}
}