Initial video AI analysis project

This commit is contained in:
yangyl
2026-06-17 11:33:54 +08:00
commit ef0047af6d
35 changed files with 8613 additions and 0 deletions

719
docs/project.md Normal file
View File

@@ -0,0 +1,719 @@
# Project Documentation
## Goal
本项目是在 `/Users/yoilun/AI-train/video-ai-analysis-poc` 中实现视频离线批处理分析 PoC。`v1.0` 已支持本地视频文件夹;`v1.1` 新增海康云存储录像下载作为视频来源下载完成后复用现有抽帧、clip、VLM 推理和聚合流程。
必须支持:
- 选择一个本地视频文件夹。
- 直接调用海康云存储录像下载 API 获取录像下载地址并下载视频。
- AccessToken 通过 config 或环境变量配置,不写入测试夹具和文档样例。
- 设备序列号和通道可配置,并支持多设备。
- 分析时间段包含年月日,支持 `YYYY-MM-DD HH:MM:SS` 配置。
- 海康 API 单次最多下载 1 小时,超过 1 小时的时间段必须拆成多个不超过 3600 秒的请求;默认示例使用 600 秒分片,真实 smoke 中比 3600 秒更稳定。
- 自动发现文件夹内所有常见视频文件。
- 对每个视频按 1 FPS 抽帧,按 10-20 秒 clip 组织输入。
- 使用已有 4B VLM 模型能力,兼容 `memai-zhengxin-v3-20260413` 的 OpenAI-compatible vLLM 接口。
- prompt 通过 config 调整。
- 输出结构化 JSON/JSONL。
- 输出中必须包含监控画面的时间轴包括视频、clip、frame 和事件的时间定位。
## v1.1 Hik Cloud Storage Source
海康文档 `录像下载流程_1.pdf` 的“2、获取录像下载地址”定义
```text
POST https://api2.hik-cloud.com/v1/carrier/cstorage/open/play/download
Authorization: bearer <AccessToken>
Content-Type: application/json
```
请求 body
```json
{
"deviceSerial": "EXAMPLE_DEVICE_SERIAL",
"channelNo": 1,
"timeBegin": 1764856787,
"timeEnd": 1764856978
}
```
成功返回 `data.url``actualBeginTime``actualEndTime`。错误码 `80430002` 包含起止时间大于 3600 秒的参数错误,错误码 `80438027` 表示起始时间内没有录像。
配置示例:
```yaml
source:
mode: hik_cloud # local | hik_cloud
hik_cloud:
api_base_url: https://api2.hik-cloud.com
download_path: /v1/carrier/cstorage/open/play/download
access_token: null
access_token_env: HIK_CLOUD_ACCESS_TOKEN
chunk_seconds: 600
timeout_seconds: 60
download_timeout_seconds: 600
devices:
- device_serial: EXAMPLE_DEVICE_SERIAL
channel_no: 1
name: store-front
time_ranges:
- begin: "2026-02-03 09:00:00"
end: "2026-02-03 11:30:00"
```
云下载输出:
- `hik_cloud_download_manifest.jsonl`:每个设备/通道/时间分片的请求、实际时间、状态和错误。`--dry-run` 云模式只请求下载地址并写入 `address_ok` / failure 状态,不下载 mp4不 probe。
- `downloads/hik_cloud/<device_serial>/ch<channel_no>/*.mp4`:下载后供现有分析链路消费的视频文件。
- `video_manifest.jsonl`:保留现有契约,并附加云来源元数据。
运行本地文件夹模式:
```bash
python3 -B -m video_ai_analysis_poc.cli \
--config config/local_batch.yaml \
--input-dir /path/to/local/videos \
--output-dir ./outputs/local-batch
```
运行海康云存储模式时,复制配置文件并设置 `source.mode: hik_cloud`AccessToken 优先通过环境变量提供:
```bash
export HIK_CLOUD_ACCESS_TOKEN='<redacted>'
python3 -B -m video_ai_analysis_poc.cli \
--config /path/to/hik-cloud.yaml \
--output-dir ./outputs/hik-cloud
```
`--dry-run` 会请求海康下载地址并写 `hik_cloud_download_manifest.jsonl`但不会下载视频文件、probe、抽帧、推理或聚合。`--until clips` 会在下载、探测、抽帧和 clip manifest 后停止;`--until inference` 会继续运行模型推理并写入 `clip_results.jsonl`
真实远端 smoke 观察到同一 1 小时时间段直接按 3600 秒下载时,云端返回的 MP4 缺少 `moov` atom`ffprobe` 无法解析;改用 600 秒分片后 6 个分片均可探测并进入抽帧。抽帧阶段会根据云下载记录的 `actual_begin/actual_end``requested_begin/requested_end` 给 FFmpeg 加输出帧数上限,避免海康 MP4 异常时间戳导致 `fps=1` 复制出过量帧。
海康云存储安全规则:
- 不提交真实 AccessToken。
- 优先使用 `hik_cloud.access_token_env: HIK_CLOUD_ACCESS_TOKEN`
- 不记录 Authorization header。
- 不持久化签名下载 URL query例如 `sign``sig``token``access_token`
- `access_token.md` 是敏感验证文件,只能用于远端真实 smoke不复制进文档、测试或输出样例。
## Directory Boundaries
```text
/Users/yoilun/AI-train/video-ai-analysis-poc
本次 PoC 项目目录,后续代码、配置、计划、文档都放这里。
/Users/yoilun/AI-train/zhengxin-vlm-0413
外部模型和参考实现目录,不是本次项目目录。
```
硬性边界:
- 不在 `zhengxin-vlm-0413` 中创建本项目文件。
- 不修改 `zhengxin-vlm-0413/models/**`
- 不修改 `zhengxin-vlm-0413/service/config.yaml``service/config.yaml-bk``docker/.env`
- 不把参考项目真实 RTSP、Webhook、token、Cookie、密码写入本项目示例配置、测试夹具、文档或输出样例。
- 输出目录只能是用户显式传入目录,或本项目内 `outputs/`
- 不覆盖用户原始视频文件。
## Inference Architecture Decision
本 PoC 明确选择:
```text
OpenAI-compatible vLLM API
```
不在 PoC 第一版中直接加载 PyTorch + Transformers + PEFT。原因
- 用户说明测试环境已有模型。
- 参考项目已经使用 vLLM OpenAI-compatible API。
- 本地视频批处理的主要目标是打通工程链路,而不是重新实现模型服务。
配置字段固定为:
```yaml
vlm:
api_base_url: http://localhost:8679
chat_completions_path: /v1/chat/completions
```
代码拼接规则:
```text
chat_url = api_base_url.rstrip("/") + chat_completions_path
```
不要在配置中同时传完整 endpoint 和 base URL避免出现 `/v1/chat/completions/v1/chat/completions` 之类的双拼路径。
## Target File Structure
```text
video-ai-analysis-poc/
agent.md
task_plan.md
findings.md
progress.md
memories.md
video_ai_analysis_system_plan.md
config/
local_batch.yaml
video_ai_analysis_poc/
__init__.py
cli.py
config.py
paths.py
discovery.py
probe.py
ffmpeg_sampler.py
frames.py
clips.py
vlm_client.py
result_parser.py
aggregator.py
manifest.py
logging_utils.py
schemas/
clip_result.schema.json
video_result.schema.json
folder_summary.schema.json
tests/
test_config.py
test_discovery.py
test_probe.py
test_clips.py
test_result_parser.py
test_aggregator.py
outputs/
.gitkeep
```
## Module Boundaries
### `config.py`
- 加载 `config/local_batch.yaml`
- 合并 CLI 参数覆盖项。
- 校验必填字段、数值范围、路径安全。
- 不访问视频、不调用 FFmpeg、不调用模型。
### `paths.py`
- 生成稳定 `video_id``clip_id`
- 生成输出目录结构。
- 防止输出目录指向参考模型目录或覆盖输入视频目录。
### `discovery.py`
- 只负责按 `input.dir``recursive``extensions` 发现视频。
- 输出 `video_manifest.jsonl`
- 不做 ffprobe不做抽帧不调用模型。
### `probe.py`
- 包装 `ffprobe`
- 输出 `duration_seconds``codec_name``width``height``fps``format_name``start_time`
- 损坏或不支持视频标记 `probe_failed`,记录 `last_error`,不阻塞其他视频。
### `ffmpeg_sampler.py`
- 使用 FFmpeg + NVDEC 做 1 FPS 抽帧。
- 根据 codec 选择 `h264_cuvid` / `hevc_cuvid`
- 默认 `allow_cpu_fallback: false`
- 输出 JPEG 和 `frame_manifest.jsonl`
- 保存 FFmpeg stderr 摘要,作为实际使用 GPU 解码的证据。
### `frames.py`
- 计算 frame 的相对秒数和 timecode。
- 维护 frame 文件路径、offset、timecode。
- 优先使用可获得的 `pts_time`,否则使用抽帧序号按 FPS 推导相对时间。
### `clips.py`
- 读取 `frame_manifest.jsonl`
-`clip.length_seconds``clip.stride_seconds` 构建 clip。
- 从 1 FPS 帧中均匀采样 `frames_per_clip`
- 输出 `clip_manifest.jsonl`,必须包含参与推理的实际帧时间。
### `vlm_client.py`
- 调用 OpenAI-compatible `/v1/chat/completions`
- 多帧使用 `image_url`,默认 `data:image/jpeg;base64`
- prompt 来自 config不硬编码。
- 不解析业务事件,只返回 raw response、latency 和 HTTP 状态。
- 阶段 4 实现使用 Python 标准库 `urllib`,并暴露可注入 HTTP 函数以便测试 mock默认 URL 拼接为 `vlm.api_base_url.rstrip("/") + vlm.chat_completions_path`
### `result_parser.py`
- 从 raw response 中提取严格 JSON。
- 校验 `schema_version``events``screen_time`、事件枚举等字段。
- 解析失败触发一次严格 prompt 重试。
- 仍失败写 `parse_failed`,保留 `raw_response`
- 阶段 4 实现支持 raw JSON、markdown/prose 中嵌入 JSON输出 clip 级 `monitoring_timeline``events``raw_response``processing``error` 字段。
### `aggregator.py`
- 消费 `video_manifest.jsonl``clip_manifest.jsonl``clip_results.jsonl`
- 聚合为 `videos/<video_id>/video_result.json` 和输出根目录下的 `folder_summary.json`
-`merge_gap_seconds` 合并同视频、同类型、相邻时间范围接近的事件。
- 保留事件相对时间轴、screen_time、clip evidence 和 frame evidence。
- 统计 `parse_failed` / `inference_failed` clip 数量。
### `manifest.py`
- 负责 JSONL 读写和状态字段。
- 支持断点续跑。
- 每条记录包含 `status``retry_count``last_error`
## Config Schema
`config/local_batch.yaml` 建议字段:
```yaml
input:
dir: /path/to/videos
recursive: true
extensions: [".mp4", ".mov", ".mkv", ".avi", ".flv", ".ts", ".m4v"]
source:
mode: local
output:
dir: ./outputs/local-batch
overwrite: false
resume: true
keep_frames: true
hik_cloud:
api_base_url: https://api2.hik-cloud.com
download_path: /v1/carrier/cstorage/open/play/download
access_token: null
access_token_env: HIK_CLOUD_ACCESS_TOKEN
chunk_seconds: 600
timeout_seconds: 60
download_timeout_seconds: 600
devices:
- device_serial: EXAMPLE_DEVICE_SERIAL
channel_no: 1
name: example-device
time_ranges:
- begin: "2026-02-03 09:00:00"
end: "2026-02-03 10:00:00"
ffprobe:
timeout_seconds: 30
ffmpeg:
prefer_nvdec: true
allow_cpu_fallback: false
hwaccel: cuda
codec_decoders:
h264: h264_cuvid
hevc: hevc_cuvid
frame_fps: 1
frame_width: 640
jpeg_quality: 4
timeout_seconds_per_video: 3600
clip:
length_seconds: 10
stride_seconds: 10
frames_per_clip: 8
min_frames_per_clip: 4
vlm:
api_base_url: http://localhost:8679
chat_completions_path: /v1/chat/completions
model: memai-zhengxin-v3-20260413
timeout_seconds: 120
max_tokens: 512
temperature: 0
batch_size: 1
image_transport: data_uri
retries: 1
prompt:
system: "You are a store video analysis assistant. Return strict JSON only."
user: "Analyze this clip. Return events and screen_time. If no event, return events: []."
schema:
version: local-batch-v1
event_types:
- customer_enter
- customer_leave
- queue_detected
- staff_absent
- staff_present
- area_crowded
- abnormal_behavior
- unknown
require_strict_json: true
parse_retry: 1
merge_gap_seconds: 30
runtime:
timezone: Asia/Shanghai
log_level: INFO
```
## File Contracts
### `video_manifest.jsonl`
One line per discovered video:
```json
{
"video_id": "stable_hash_or_slug",
"source_path": "/path/to/video.mp4",
"status": "pending",
"probe": null,
"retry_count": 0,
"last_error": null
}
```
### `frame_manifest.jsonl`
One line per sampled frame:
```json
{
"video_id": "stable_hash_or_slug",
"frame_id": "stable_hash_or_slug_f000120",
"frame_path": "frames/stable_hash_or_slug/000120.jpg",
"offset_seconds": 120.0,
"timecode": "00:02:00",
"pts_time": 120.0,
"status": "sampled"
}
```
### `clip_manifest.jsonl`
One line per clip:
```json
{
"video_id": "stable_hash_or_slug",
"clip_id": "stable_hash_or_slug_c000012",
"clip_start_seconds": 120.0,
"clip_end_seconds": 130.0,
"clip_start_timecode": "00:02:00",
"clip_end_timecode": "00:02:10",
"frame_times": [
{
"frame_path": "frames/stable_hash_or_slug/000120.jpg",
"offset_seconds": 120.0,
"timecode": "00:02:00"
}
],
"status": "pending",
"retry_count": 0,
"last_error": null
}
```
### `clip_results.jsonl`
One line per inferred clip:
```json
{
"schema_version": "local-batch-v1",
"video_id": "stable_hash_or_slug",
"video_path": "/path/to/video.mp4",
"clip_id": "stable_hash_or_slug_c000012",
"status": "ok",
"monitoring_timeline": {
"timezone": "Asia/Shanghai",
"video_start_time": null,
"clip_start_seconds": 120.0,
"clip_end_seconds": 130.0,
"clip_start_timecode": "00:02:00",
"clip_end_timecode": "00:02:10",
"frame_times": [
{
"frame_path": "frames/stable_hash_or_slug/000120.jpg",
"offset_seconds": 120.0,
"timecode": "00:02:00"
}
],
"screen_time": "2026-06-14 12:31:20"
},
"events": [
{
"event_type": "queue_detected",
"start_time": null,
"end_time": null,
"start_offset_seconds": 120.0,
"end_offset_seconds": 130.0,
"confidence": 0.86,
"severity": "medium",
"attributes": {},
"evidence": {
"clip_id": "stable_hash_or_slug_c000012",
"frame_paths": ["frames/stable_hash_or_slug/000120.jpg"]
}
}
],
"raw_response": null,
"processing": {
"started_at": "2026-06-15T10:00:00+08:00",
"finished_at": "2026-06-15T10:00:02+08:00",
"latency_ms": 1800
},
"error": null
}
```
### `video_result.json`
Written to:
```text
videos/<video_id>/video_result.json
```
Required top-level fields:
```text
schema_version
video_id
video_path
probe
monitoring_timeline.video_start_time
monitoring_timeline.video_duration_seconds
clip_count
failed_clip_count
event_counts
events
outputs.clip_results_jsonl
processing
```
### `folder_summary.json`
Required top-level fields:
```text
schema_version
input_dir
video_count
processed_video_count
failed_video_count
event_counts
videos
processing
```
## Timeline Rules
时间轴必须区分三类时间:
- 视频相对时间:`offset_seconds``timecode`
- 画面 OCR 时间:`screen_time` 或模型输出里的 `画面时间`
- 处理时间:`processing.started_at``processing.finished_at`
本地视频没有可靠业务开始时间时:
- `video_start_time` 必须为 `null`
- 不允许伪造绝对时间。
- 事件必须保留 `start_offset_seconds``end_offset_seconds`
参与推理的实际帧时间必须写入 `frame_times`。不能只写 clip 起止时间。
## Reference Code Usage
可以参考:
- `zhengxin-vlm-0413/shared/vlm_client.py` 的 OpenAI-compatible payload 结构。
- `zhengxin-vlm-0413/shared/frame_utils.py` 的 base64 data URI 处理方式。
- `zhengxin-vlm-0413/service/config.yaml` 的 prompt 配置风格。
不能直接复用为核心实现:
- `frame_utils.extract_frames_from_video`,因为它是整段均匀抽 8 帧,不满足 1 FPS、clip manifest、时间轴要求。
- `vlm_client.extract_action`,因为它只解析 `Action`,不能覆盖本项目完整事件和时间轴 schema。
- `rtsp_service.py` 主循环,因为它服务实时 RTSP不适合离线文件夹批处理。
## Validation Matrix
### Phase 1 Architecture Validation
阶段 1 complete 条件:
- `docs/project.md` 固化模块边界、文件输出契约、config schema、时间轴 schema、安全边界和验证矩阵。
- 推理接口选择已明确为 OpenAI-compatible vLLM。
- API URL 字段语义已固定为 `api_base_url` + `chat_completions_path`
- 已声明参考 `frame_utils.py` / `vlm_client.py` 哪些可借鉴、哪些不能直接复用。
- 已列出阶段 2-6 的 smoke test 输入、命令、期望输出字段和失败判定标准。
- 子 agent 审查结论记录到 `progress.md`
### Phase 2 Validation
目标本地视频发现、ffprobe、manifest、CLI 骨架。
命令:
```bash
python3 -m py_compile video_ai_analysis_poc/*.py
python3 -m video_ai_analysis_poc.cli --config config/local_batch.yaml --input-dir /path/to/videos --output-dir ./outputs/local-batch --dry-run
```
期望:
- 生成 `video_manifest.jsonl`
- 损坏/不支持视频被标记失败,不阻塞其他视频。
- 不读取或写入参考模型目录。
### Phase 3 Validation
目标FFmpeg/NVDEC 1 FPS 抽帧和 clip 构建。
命令:
```bash
ffmpeg -hwaccels
ffmpeg -decoders | grep cuvid
python3 -m video_ai_analysis_poc.cli --config config/local_batch.yaml --input-dir /path/to/short-videos --output-dir ./outputs/local-batch --until clips
```
期望:
- 对一个样例视频实际运行带 `-hwaccel cuda``h264_cuvid``hevc_cuvid` 的抽帧命令。
- 保存 FFmpeg stderr 或日志中的解码器证据。
- 生成 `frame_manifest.jsonl``clip_manifest.jsonl`
- `clip_manifest.jsonl` 包含 `frame_times`
### Phase 4 Validation
目标vLLM OpenAI-compatible API、prompt 配置、JSON 解析重试。
命令:
```bash
curl http://localhost:8679/v1/models
python3 -m video_ai_analysis_poc.cli --config config/local_batch.yaml --input-dir /path/to/short-videos --output-dir ./outputs/local-batch --until inference --limit-clips 3
```
期望:
- prompt 从 config 读取。
- 请求 URL 使用 `api_base_url + chat_completions_path`
- 生成 `clip_results.jsonl`
- 每条结果包含 `monitoring_timeline.frame_times``screen_time` 字段。
### Phase 5 Validation
目标clip/video/folder 聚合和 schema 校验。
命令:
```bash
python3 -m video_ai_analysis_poc.cli --config config/local_batch.yaml --input-dir /path/to/short-videos --output-dir ./outputs/local-batch
python3 -m json.tool ./outputs/local-batch/folder_summary.json >/dev/null
```
期望:
- 默认 CLI 运行不传 `--dry-run``--until` 时,会执行到 inference 并继续 aggregation。
- `--until clips``--until inference` 仍停在各自阶段,不写聚合输出。
- 生成 `videos/<video_id>/video_result.json`
- 生成 `folder_summary.json`
- 事件聚合保留相对时间轴。
- JSON 可被标准工具解析。
### Phase 6 Validation
目标:测试环境 smoke test 与文档更新。
远端环境:
```text
ssh xiaozheng@192.168.5.100
/home/xiaozheng/video-ai-analysis-poc
```
模型服务:
```bash
ssh xiaozheng@192.168.5.100 'curl http://localhost:8679/v1/models'
```
当前服务状态:
- 容器:`zhengxin-vllm`
- 镜像:`vllm/vllm-openai:v0.14.1`
- 端口:`8679`
- 模型:`memai-zhengxin-v3-20260413`
- 模型目录挂载:`/home/xiaozheng/zhengxin-vlm-0413/models:/models:ro`
远端能力验证命令:
```bash
ssh xiaozheng@192.168.5.100 'nvidia-smi --query-gpu=name,memory.total,driver_version --format=csv,noheader'
ssh xiaozheng@192.168.5.100 'ffmpeg -hwaccels'
ssh xiaozheng@192.168.5.100 'ffmpeg -decoders'
```
已验证:
- GPU: `NVIDIA GeForce RTX 3080`, `20480 MiB`, driver `595.71.05`
- FFmpeg 6.1.1 支持 `cuda` hwaccel。
- FFmpeg decoders 包含 `h264_cuvid``hevc_cuvid`
- `/v1/models` 返回模型 id `memai-zhengxin-v3-20260413`
- `/v1/chat/completions` 安全 quoted health check 返回 `OK`
远端 smoke 输入:
```text
/tmp/video-ai-analysis-poc-smoke.h1cZUR/input/sample_h264.mp4
```
远端 smoke 输出:
```text
/tmp/video-ai-analysis-poc-smoke.h1cZUR/output
```
远端批处理命令:
```bash
ssh xiaozheng@192.168.5.100 'PYTHONPATH=/home/xiaozheng/video-ai-analysis-poc python3 -B -m unittest discover -s /home/xiaozheng/video-ai-analysis-poc/tests -v'
ssh xiaozheng@192.168.5.100 'python3 -B -m compileall -q /home/xiaozheng/video-ai-analysis-poc/video_ai_analysis_poc'
ssh xiaozheng@192.168.5.100 'PYTHONPATH=/home/xiaozheng/video-ai-analysis-poc python3 -B -m video_ai_analysis_poc.cli --config /home/xiaozheng/video-ai-analysis-poc/config/local_batch.yaml --input-dir /tmp/video-ai-analysis-poc-smoke.h1cZUR/input --output-dir /tmp/video-ai-analysis-poc-smoke.h1cZUR/output --until clips'
ssh xiaozheng@192.168.5.100 'PYTHONPATH=/home/xiaozheng/video-ai-analysis-poc python3 -B -m video_ai_analysis_poc.cli --config /home/xiaozheng/video-ai-analysis-poc/config/local_batch.yaml --input-dir /tmp/video-ai-analysis-poc-smoke.h1cZUR/input --output-dir /tmp/video-ai-analysis-poc-smoke.h1cZUR/output --until inference --limit-clips 1'
ssh xiaozheng@192.168.5.100 'PYTHONPATH=/home/xiaozheng/video-ai-analysis-poc python3 -B -m video_ai_analysis_poc.cli --config /home/xiaozheng/video-ai-analysis-poc/config/local_batch.yaml --input-dir /tmp/video-ai-analysis-poc-smoke.h1cZUR/input --output-dir /tmp/video-ai-analysis-poc-smoke.h1cZUR/output'
```
已验证输出:
- `video_manifest.jsonl`: 1 条视频记录。
- `frame_manifest.jsonl`: 12 条 sampled frame 记录。
- `clip_manifest.jsonl`: 1 条 clip 记录。
- frame manifest 中持久化 `hwaccel: cuda``decoder: h264_cuvid``ffmpeg_command` 和 FFmpeg stderr 摘要。
- `clip_results.jsonl`: 1 条记录,`status: ok`,包含 `monitoring_timeline.frame_times`
- `videos/<video_id>/video_result.json`: JSON 可解析,`failed_clip_count: 0`
- `folder_summary.json`: JSON 可解析,`video_count: 1``processed_video_count: 1`
- 本地视频没有可靠业务开始时间时,`monitoring_timeline.video_start_time` 输出 `null`ffprobe 的 `start_time: 0.0` 只保留在 `probe`
远端验证约束:
- 只写入明确输出目录。
- 不覆盖远端已有模型、配置和视频。
- 不复制真实凭据到日志或文档。
## Known Risks
- HEVC decoder 可用性已验证,但实际 smoke 只覆盖 H.264 样例视频。
- 24 小时真实门店视频吞吐量尚未压测。
- 海康云眸云录像/RTSP 接入仍在当前本地文件夹 PoC 范围之外。
- 本地视频可能没有画面内时间戳,必须同时保留相对时间。
- 模型事件质量尚未用真实门店素材验收;合成测试图没有业务事件,输出空事件是合理结果。
- 远端 vLLM 容器当前为手工启动,不是生产级 systemd/compose 托管。