forked from jsl/video_editing_poc
Add local CV worker readiness checks
This commit is contained in:
parent
80eca92d56
commit
adc979eea8
|
|
@ -35,4 +35,10 @@ build/
|
||||||
.vscode/
|
.vscode/
|
||||||
|
|
||||||
### Mac OS ###
|
### Mac OS ###
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
|
### Local CV runtime ###
|
||||||
|
.venv-local-cv/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
yolov*.pt
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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) + "...";
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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"))
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue