This commit is contained in:
zaiun xu
2026-04-13 17:13:02 +08:00
parent 6f006def64
commit c1aafc69bf
8 changed files with 580 additions and 66 deletions

View File

@@ -22,6 +22,36 @@ from app.state import HealthSnapshot, MeasureSnapshot
DEFAULT_CLIENT_ID = "default"
MAX_CLIENT_ID_LEN = 128
# 客户端切片索引起缓存:记录每个 client_id 上次返回的切片索引(用于对齐 water/video 端点)
_client_health_slice_index: dict[str, int] = {}
def _parse_slice_index_from_source_path(source_path: Optional[str]) -> int:
"""从 source_path 解析切片索引,格式为 video.mp4#slice{N}
Returns:
切片序号(>=0如果不是切片则返回 -1
"""
if not source_path:
return -1
if "#slice" not in source_path:
return -1
try:
idx_part = source_path.split("#slice")[-1]
return int(idx_part)
except (ValueError, IndexError):
return -1
def get_last_health_slice_index(client_id: str) -> int:
"""获取指定 client_id 上次返回的切片索引(用于 water/video 端点对齐)。
Returns:
切片序号(>=0如果没有记录则返回 -1
"""
cid = normalize_client_id(client_id)
return _client_health_slice_index.get(cid, -1)
def normalize_client_id(raw: Optional[str]) -> str:
"""供轮询接口使用;过长截断,空值回退为 DEFAULT_CLIENT_ID。"""
@@ -447,6 +477,8 @@ def pop_next_health(
client_id: str = DEFAULT_CLIENT_ID,
) -> Tuple[HealthSnapshot, bool, Optional[int]]:
"""取该客户端队首未投递且可交付的 health 快照并推进其游标;跳过不可交付行仅推进游标。"""
global _client_health_slice_index
cid = normalize_client_id(client_id)
init_db(settings)
conn = _connect(settings.sqlite_path)
@@ -458,7 +490,7 @@ def pop_next_health(
next_row = conn.execute(
"""
SELECT id, created_at, behavior_result, health_result,
raw_class_en, error
raw_class_en, error, source_path
FROM health_snapshots
WHERE id > ?
ORDER BY id ASC
@@ -476,6 +508,7 @@ def pop_next_health(
hlth = next_row["health_result"] or ""
raw_en = next_row["raw_class_en"] or ""
err: Optional[str] = next_row["error"]
source_path: Optional[str] = next_row["source_path"]
conn.execute(
"""
@@ -490,6 +523,11 @@ def pop_next_health(
continue
conn.commit()
# 解析并记录切片索引(用于与 water/video 端点对齐)
slice_idx = _parse_slice_index_from_source_path(source_path)
_client_health_slice_index[cid] = slice_idx
snap = HealthSnapshot(
behavior_result=beh,
health_result=hlth,

View File

@@ -85,7 +85,10 @@ async def get_health_result(
settings: Settings = Depends(get_settings),
client_id: str = Depends(_resolve_client_id),
):
"""行为 / 健康结果:每次 GET 投递该客户端下一条未消费的 FishAction 快照(按 client_id 独立游标)。"""
"""行为 / 健康结果:每次 GET 投递该客户端下一条未消费的 FishAction 快照(按 client_id 独立游标)。
每个视频切片被视为独立的视频,会分别投递。
"""
h, has_new, _ = pop_next_health(settings, client_id)
if not has_new:
return JSONResponse(
@@ -125,9 +128,19 @@ async def get_health_result(
@router.get("/water/video/")
async def get_water_video(settings: Settings = Depends(get_settings)):
"""水上视频FishAction 输入 mp4 经 H.264 转码后托管在 /media/,返回 `video_path` 绝对 URL。"""
video_path = await get_water_video_public_url(settings)
async def get_water_video(
settings: Settings = Depends(get_settings),
client_id: str = Depends(_resolve_client_id),
):
"""水上视频FishAction 输入 mp4 经 H.264 转码后托管在 /media/,返回 `video_path` 绝对 URL。
如果视频较长超过15秒会自动切分为多个10秒的片段。
每个切片被视为独立的视频每次调用返回一个切片的URL按顺序轮流返回
对齐机制:使用 client_id 参数(请求头 X-Fish-Client-Id 或查询参数 client_id
确保与 /health/result/ 端点对齐返回同一切片。
"""
video_path = await get_water_video_public_url(settings, client_id)
return JSONResponse(
content={
"code": 200,

View File

@@ -46,13 +46,18 @@ async def _action_job_serial(mp4_path: Path, settings: Settings) -> None:
async with app_state.action_lock:
app_state.action_status = "running"
try:
snap = await asyncio.to_thread(
# 返回 (第一个快照, 所有切片快照列表)
first_snap, all_snaps = await asyncio.to_thread(
action_svc.run_full_action, mp4_path, settings
)
if health_snapshot_deliverable(snap):
save_health_snapshot(
settings, snap, source_path=str(mp4_path.resolve())
)
# 将所有切片作为独立视频保存到数据库
for i, snap in enumerate(all_snaps):
if health_snapshot_deliverable(snap):
# 为每个切片生成独立的 source_path
slice_source = f"{mp4_path.resolve()}#slice{i}"
save_health_snapshot(settings, snap, source_path=slice_source)
app_state.action_status = "idle"
except Exception:
app_state.action_status = "error"

View File

@@ -6,13 +6,19 @@ import sys
import tempfile
from datetime import datetime, timezone
from pathlib import Path
from typing import List
from app.logging_config import format_json_pretty
from app.services.video_slice import get_video_duration, slice_video
from app.settings import Settings
from app.state import HealthSnapshot
from app.subprocess_run import run_subprocess_with_log
from loguru import logger
# 视频切片配置
DEFAULT_SLICE_DURATION = 10.0 # 每10秒切一个片段
DEFAULT_MIN_DURATION_FOR_SLICE = 15.0 # 视频超过15秒才切片
BEHAVIOR_EN_TO_ZH = {
"feeding": "吃饵",
"normal": "正常游行",
@@ -100,8 +106,95 @@ def run_action_subprocess(mp4_path: Path, settings: Settings) -> str:
Path(out_json).unlink(missing_ok=True)
def run_full_action(mp4_path: Path, settings: Settings) -> HealthSnapshot:
def run_full_action(mp4_path: Path, settings: Settings) -> tuple[HealthSnapshot, list[HealthSnapshot]]:
"""运行 FishAction 健康检测。如果视频较长,会自动切片后分别检测。
每个切片被视为独立的视频,返回所有切片的结果列表。
Args:
mp4_path: 输入视频路径
settings: 应用配置
Returns:
tuple[HealthSnapshot, list[HealthSnapshot]]: (第一个切片/完整视频的快照, 所有切片快照列表)
- 如果视频被切片:返回 (第一个切片, 所有切片列表)
- 如果视频未被切片:返回 (完整视频快照, [完整视频快照])
"""
logger.info("[FishAction] start mp4={}", mp4_path.resolve())
# 检查视频时长
duration = get_video_duration(mp4_path)
should_slice = duration > DEFAULT_MIN_DURATION_FOR_SLICE
if should_slice:
# 视频较长,需要切片处理
logger.info(
"[FishAction] video duration {}s > {}s, slicing into {}s segments",
duration,
DEFAULT_MIN_DURATION_FOR_SLICE,
DEFAULT_SLICE_DURATION,
)
slice_files, slice_dir = slice_video(mp4_path, DEFAULT_SLICE_DURATION)
if len(slice_files) > 1:
logger.info(
"[FishAction] processing {} slices for {}",
len(slice_files),
mp4_path.name,
)
# 处理每个切片
all_snaps: list[HealthSnapshot] = []
for i, slice_file in enumerate(slice_files):
start_time = i * DEFAULT_SLICE_DURATION
end_time = min(start_time + DEFAULT_SLICE_DURATION, duration)
try:
pred_en = run_action_subprocess(slice_file, settings)
zh = BEHAVIOR_EN_TO_ZH[pred_en]
health = behavior_to_health(pred_en)
snap = HealthSnapshot(
behavior_result=zh,
health_result=health,
updated_at=datetime.now(timezone.utc),
raw_class_en=pred_en,
)
logger.info(
"[FishAction] slice {} ({}s-{}s): pred={} behavior={} health={}",
i, start_time, end_time, pred_en, zh, health,
)
all_snaps.append(snap)
except Exception as e:
logger.error("[FishAction] failed to process slice {}: {}", i, e)
# 创建一个表示失败的快照
error_snap = HealthSnapshot(
behavior_result="处理失败",
health_result="未知",
updated_at=datetime.now(timezone.utc),
raw_class_en="error",
error=str(e),
)
all_snaps.append(error_snap)
logger.info(
"[FishAction] done mp4={} total_slices={}",
mp4_path.name,
len(slice_files),
)
# 返回第一个切片的结果和所有切片列表
first_snap = all_snaps[0] if all_snaps else HealthSnapshot(
behavior_result="",
health_result="",
updated_at=datetime.now(timezone.utc),
)
return first_snap, all_snaps
# 视频较短,直接处理(原有逻辑)
pred_en = run_action_subprocess(mp4_path, settings)
zh = BEHAVIOR_EN_TO_ZH[pred_en]
health = behavior_to_health(pred_en)
@@ -112,9 +205,10 @@ def run_full_action(mp4_path: Path, settings: Settings) -> HealthSnapshot:
zh,
health,
)
return HealthSnapshot(
snap = HealthSnapshot(
behavior_result=zh,
health_result=health,
updated_at=datetime.now(timezone.utc),
raw_class_en=pred_en,
)
return snap, [snap]

View File

@@ -56,20 +56,38 @@ async def _run_inference_and_state(
async with app_state.action_lock:
app_state.action_status = "running"
try:
snap = await asyncio.to_thread(action_svc.run_full_action, mp4, settings)
if health_snapshot_deliverable(snap):
save_health_snapshot(settings, snap, source_path=key)
else:
# 返回 (第一个快照, 所有切片快照列表)
first_snap, all_snaps = await asyncio.to_thread(
action_svc.run_full_action, mp4, settings
)
# 将所有切片作为独立视频保存到数据库
saved_count = 0
for i, snap in enumerate(all_snaps):
if health_snapshot_deliverable(snap):
# 为每个切片生成独立的 source_path
slice_key = f"{key}#slice{i}"
save_health_snapshot(settings, snap, source_path=slice_key)
saved_count += 1
if saved_count == 0:
logger.warning(
"[action-watch] no deliverable health snapshot for {}, skip SQLite",
mp4.name,
)
app_state.action_status = "idle"
processed.add(key)
if settings.action_watch_use_state_file:
add_watch_processed(settings, key, "action")
pred = (snap.raw_class_en or "").strip()
logger.info("[action-watch] done: {} -> {}", mp4.name, pred)
pred = (first_snap.raw_class_en or "").strip()
logger.info(
"[action-watch] done: {} -> {} (saved {} slices)",
mp4.name,
pred,
saved_count,
)
except Exception as e:
logger.exception("[action-watch] error on {}: {}", mp4, e)
app_state.action_status = "idle"

View File

@@ -11,6 +11,8 @@ from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
import numpy as np
from app.logging_config import format_json_pretty
from app.settings import Settings
from app.state import MeasureSnapshot
@@ -187,12 +189,15 @@ def _safe_media_prefix(stem: str) -> str:
def _result_from_weight_prediction(data: Dict[str, Any]) -> List[Dict[str, Any]]:
"""按 track_id 聚合:体重取 max(predicted_weight_g),体长取达到 max 的那条 PLY 的 length_input (mm)。"""
"""按 track_id 聚合:对每个 track_id 的点云,取 predicted_weight_g 最大的 top5 平均值作为体重,
体长取这 top5 点云的平均 length_input (mm)。只使用通过点云分类器的好点云used_for_prediction=True"""
items = data.get("per_cloud") or data.get("per_file") or []
if not isinstance(items, list):
return []
# tid -> (max_weight_g, length_mm at max weight)
best: Dict[int, Tuple[float, float]] = {}
# tid -> list of (weight_g, length_mm) for this track
track_predictions: Dict[int, List[Tuple[float, float]]] = {}
for it in items:
if not isinstance(it, dict):
continue
@@ -202,24 +207,55 @@ def _result_from_weight_prediction(data: Dict[str, Any]) -> List[Dict[str, Any]]
tid = _parse_tid_from_ply_name(Path(str(ply)).name)
if tid is None:
continue
# 只使用通过点云分类器的好点云
if not it.get("used_for_prediction", True):
continue
try:
wg = float(it.get("predicted_weight_g", float("nan")))
except (TypeError, ValueError):
continue
if not math.isfinite(wg):
continue
try:
ln = float(it.get("length_input", float("nan")))
except (TypeError, ValueError):
ln = float("nan")
if tid not in best or wg > best[tid][0]:
best[tid] = (wg, ln)
if tid not in track_predictions:
track_predictions[tid] = []
track_predictions[tid].append((wg, ln))
out: List[Dict[str, Any]] = []
for tid in sorted(best.keys()):
wg, ln = best[tid]
if not math.isfinite(ln):
TOP_K = 5
for tid in sorted(track_predictions.keys()):
predictions = track_predictions[tid]
if not predictions:
continue
out.append({"id": tid, "weight": wg, "length": ln})
# 按重量降序排序,取 top5
predictions_sorted = sorted(predictions, key=lambda x: x[0], reverse=True)
top5 = predictions_sorted[:min(TOP_K, len(predictions_sorted))]
# 计算 top5 平均重量和平均长度
avg_weight = float(np.mean([p[0] for p in top5]))
valid_lengths = [p[1] for p in top5 if math.isfinite(p[1])]
avg_length = float(np.mean(valid_lengths)) if valid_lengths else float("nan")
if not math.isfinite(avg_length):
continue
out.append({
"id": tid,
"type": "大黄鱼",
"weight": str(round(avg_weight)),
"length": str(round(avg_length)),
"date": datetime.now(timezone.utc).strftime("%Y-%m-%d"),
})
return out

View File

@@ -0,0 +1,148 @@
"""视频切片工具:将长视频按固定时长切分为多个片段。"""
from __future__ import annotations
import subprocess
import tempfile
from pathlib import Path
from typing import List, Tuple
from loguru import logger
def _get_ffmpeg_path() -> str:
"""获取可用的 ffmpeg 路径。"""
project_ffmpeg = Path("/home/ubuntu/projects/FishServer/tools/ffmpeg/bin/ffmpeg")
if project_ffmpeg.is_file():
return str(project_ffmpeg)
system_paths = ["/usr/bin/ffmpeg", "/usr/local/bin/ffmpeg"]
for path in system_paths:
if Path(path).is_file():
return path
return "ffmpeg"
def _get_ffprobe_path() -> str:
"""获取可用的 ffprobe 路径。"""
ffmpeg_path = Path(_get_ffmpeg_path())
ffprobe = ffmpeg_path.parent / "ffprobe"
if ffprobe.is_file():
return str(ffprobe)
return "ffprobe"
def get_video_duration(video_path: Path) -> float:
"""获取视频时长(秒)。"""
ffprobe_path = _get_ffprobe_path()
try:
result = subprocess.run(
[
ffprobe_path,
"-v", "error",
"-show_entries", "format=duration",
"-of", "default=noprint_wrappers=1:nokey=1",
str(video_path),
],
capture_output=True,
text=True,
timeout=30,
)
if result.returncode == 0:
duration = float(result.stdout.strip())
return duration
except Exception as e:
logger.warning("[video_slice] failed to get duration: {}", e)
return 0.0
def slice_video(
video_path: Path,
slice_duration: float = 10.0,
output_dir: Path | None = None,
) -> Tuple[List[Path], Path]:
"""将视频按固定时长切分为多个片段。
Args:
video_path: 源视频路径
slice_duration: 每个切片的时长默认10秒
output_dir: 输出目录,默认使用临时目录
Returns:
(切片文件列表, 输出目录路径)
"""
duration = get_video_duration(video_path)
if duration <= slice_duration:
# 视频太短,无需切片
return [video_path], video_path.parent
if output_dir is None:
output_dir = Path(tempfile.mkdtemp(prefix="video_slices_"))
output_dir.mkdir(parents=True, exist_ok=True)
ffmpeg_path = _get_ffmpeg_path()
base_name = video_path.stem
# 计算需要切多少片
num_slices = int(duration / slice_duration)
if duration % slice_duration > 1.0: # 剩余超过1秒才多切一片
num_slices += 1
logger.info(
"[video_slice] slicing {} ({}s) into {} slices of {}s each",
video_path.name,
duration,
num_slices,
slice_duration,
)
slice_files: List[Path] = []
for i in range(num_slices):
start_time = i * slice_duration
slice_file = output_dir / f"{base_name}_slice_{i:03d}.mp4"
# 使用 ffmpeg 切片,-c copy 快速复制,避免重新编码
cmd = [
ffmpeg_path,
"-y",
"-ss", str(start_time),
"-t", str(slice_duration),
"-i", str(video_path),
"-c", "copy",
"-avoid_negative_ts", "make_zero",
str(slice_file),
]
try:
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=60,
)
if result.returncode == 0 and slice_file.is_file():
slice_files.append(slice_file)
logger.debug(
"[video_slice] created slice {}: {} (start={}s)",
i,
slice_file.name,
start_time,
)
else:
logger.warning(
"[video_slice] failed to create slice {}: {}",
i,
result.stderr[-200:] if result.stderr else "unknown",
)
except Exception as e:
logger.warning("[video_slice] exception creating slice {}: {}", i, e)
if not slice_files:
# 切片失败,返回原视频
return [video_path], video_path.parent
logger.info(
"[video_slice] created {} slices for {}",
len(slice_files),
video_path.name,
)
return slice_files, output_dir

View File

@@ -1,19 +1,35 @@
"""水上视频:从 FishAction 输入目录或显式路径发布 H.264 MP4 到 MEDIA_ROOT。"""
"""水上视频:从 FishAction 输入目录或显式路径发布 H.264 MP4 到 MEDIA_ROOT。
支持长视频切片如果视频较长会切分为多个10秒片段并分别转码发布。
每个切片被视为独立的视频。
对齐机制:使用 client_id 区分不同客户端的轮询进度,确保 health/result 和
water/video 两个端点对齐返回同一切片。
"""
from __future__ import annotations
import asyncio
import shutil
from pathlib import Path
from typing import Dict, List
from loguru import logger
from app.services.action_watch import iter_mp4
from app.services.measure import transcode_src_to_h264_dst
from app.services.video_slice import get_video_duration, slice_video
from app.settings import Settings
_publish_lock = asyncio.Lock()
# 视频切片配置
SLICE_DURATION = 10.0 # 每个切片的时长(秒)
MIN_DURATION_FOR_SLICE = 15.0 # 超过此时长才切片
# 默认客户端ID与 db.py 保持一致)
DEFAULT_CLIENT_ID = "default"
def _public_media_url(settings: Settings, basename: str) -> str:
base = settings.public_base_url.rstrip("/")
@@ -27,6 +43,23 @@ def _safe_water_media_basename(raw: str) -> str:
return Path(n).name or "biomass_water_surface.mp4"
def _slice_media_basename(base_name: str, slice_index: int) -> str:
"""生成切片视频的媒体文件名。"""
base = Path(base_name).stem
return f"{base}_slice_{slice_index:03d}.mp4"
# 客户端独立的状态:每个 client_id 有自己的切片队列和索引
# _client_slice_queues[client_id] = [url0, url1, url2, ...]
# _client_slice_indices[client_id] = 当前应该返回的索引
_client_slice_queues: Dict[str, List[str]] = {}
_client_slice_indices: Dict[str, int] = {}
# 全局缓存的切片列表(用于检测源文件变化)
_global_slice_urls: List[str] = []
_last_source_mtime: float = 0.0
def resolve_water_video_source(settings: Settings) -> Path | None:
"""优先 BIOMASS_WATER_VIDEO_SOURCE否则取 ACTION_WATCH_DIR 中 mtime 最新的 .mp4。"""
cfg = settings.biomass_water_video_source
@@ -51,51 +84,180 @@ def resolve_water_video_source(settings: Settings) -> Path | None:
return None
async def get_water_video_public_url(settings: Settings) -> str:
"""转码并发布到 MEDIA_ROOT 后返回绝对 URL无可用源且无已发布文件时返回空串。"""
settings.media_root.mkdir(parents=True, exist_ok=True)
basename = _safe_water_media_basename(settings.biomass_water_video_media_name)
dst = settings.media_root / basename
async def _publish_video(
src: Path,
dst: Path,
settings: Settings,
) -> str:
"""发布视频到 MEDIA_ROOT。
src = resolve_water_video_source(settings)
if src is None:
Args:
src: 源视频路径
dst: 目标路径
settings: 应用配置
Returns:
发布的视频URL失败返回空串
"""
tmp = dst.with_name(dst.stem + "_tmp.mp4")
tmp.unlink(missing_ok=True)
try:
ok = await asyncio.to_thread(transcode_src_to_h264_dst, src, tmp)
if ok and tmp.is_file() and tmp.stat().st_size > 0:
tmp.replace(dst)
logger.info("[water-video] published H.264: {} -> {}", src.name, dst.name)
else:
tmp.unlink(missing_ok=True)
await asyncio.to_thread(shutil.copy2, src, dst)
logger.warning(
"[water-video] transcode failed, copied raw: {} -> {}",
src.name,
dst.name,
)
return _public_media_url(settings, dst.name)
except Exception:
logger.exception("[water-video] publish failed")
tmp.unlink(missing_ok=True)
if dst.is_file():
return _public_media_url(settings, dst.name)
return ""
async with _publish_lock:
need_publish = True
if dst.is_file():
try:
if dst.stat().st_mtime >= src.stat().st_mtime:
need_publish = False
except OSError:
pass
if not need_publish:
return _public_media_url(settings, dst.name)
tmp = dst.with_name(dst.stem + "_tmp.mp4")
tmp.unlink(missing_ok=True)
try:
ok = await asyncio.to_thread(transcode_src_to_h264_dst, src, tmp)
if ok and tmp.is_file() and tmp.stat().st_size > 0:
tmp.replace(dst)
logger.info("[water-video] published H.264: {} -> {}", src.name, dst.name)
else:
tmp.unlink(missing_ok=True)
await asyncio.to_thread(shutil.copy2, src, dst)
logger.warning(
"[water-video] transcode failed, copied raw: {} -> {}",
src.name,
dst.name,
)
except Exception:
logger.exception("[water-video] publish failed")
tmp.unlink(missing_ok=True)
async def _prepare_slices(settings: Settings) -> List[str]:
"""预处理:如果视频较长,切分为多个片段并发布到 MEDIA_ROOT。
返回切片URL列表供各客户端使用
"""
global _global_slice_urls, _last_source_mtime
base_basename = _safe_water_media_basename(settings.biomass_water_video_media_name)
src = resolve_water_video_source(settings)
if src is None:
return []
# 检查是否需要重新切片
try:
current_mtime = src.stat().st_mtime
except OSError:
current_mtime = 0.0
# 如果源文件未变化且已有缓存,直接返回缓存
if current_mtime == _last_source_mtime and _global_slice_urls:
return _global_slice_urls
# 检查视频时长
duration = get_video_duration(src)
should_slice = duration > MIN_DURATION_FOR_SLICE
new_urls: List[str] = []
if should_slice:
# 视频较长,切片处理
logger.info(
"[water-video] video duration {}s > {}s, slicing into {}s segments",
duration,
MIN_DURATION_FOR_SLICE,
SLICE_DURATION,
)
slice_files, slice_dir = slice_video(src, SLICE_DURATION)
if len(slice_files) > 1:
# 发布每个切片
for i, slice_file in enumerate(slice_files):
slice_basename = _slice_media_basename(base_basename, i)
dst = settings.media_root / slice_basename
# 检查是否需要重新发布
need_publish = True
if dst.is_file():
try:
if dst.stat().st_mtime >= slice_file.stat().st_mtime:
need_publish = False
except OSError:
pass
if need_publish:
url = await _publish_video(slice_file, dst, settings)
else:
url = _public_media_url(settings, dst.name)
if url:
new_urls.append(url)
logger.info(
"[water-video] prepared {} slices for {}",
len(new_urls),
src.name,
)
# 更新全局缓存
_global_slice_urls = new_urls
_last_source_mtime = current_mtime
return new_urls
# 视频较短,只保留完整视频
dst = settings.media_root / base_basename
url = await _publish_video(src, dst, settings)
if url:
new_urls = [url]
_global_slice_urls = new_urls
_last_source_mtime = current_mtime
return new_urls
return []
async def get_water_video_public_url(settings: Settings, client_id: str = DEFAULT_CLIENT_ID) -> str:
"""转码并发布到 MEDIA_ROOT 后返回绝对 URL无可用源且无已发布文件时返回空串。
如果视频较长被切片,会根据 health/result 端点的状态返回对应的切片URL。
对齐机制:查询 health 数据库记录的切片索引,确保与 health/result 端点对齐。
只有当 health/result 返回了第 N 个切片的行为结果后,本端点才会返回第 N 个切片的视频。
Args:
settings: 应用配置
client_id: 客户端标识,默认为 "default"
Returns:
视频URL失败返回空串
"""
from app.db import get_last_health_slice_index
settings.media_root.mkdir(parents=True, exist_ok=True)
async with _publish_lock:
# 确保切片已准备好
slice_urls = await _prepare_slices(settings)
if not slice_urls:
# 没有切片,尝试返回已发布的文件
basename = _safe_water_media_basename(settings.biomass_water_video_media_name)
dst = settings.media_root / basename
if dst.is_file():
return _public_media_url(settings, dst.name)
return ""
if dst.is_file():
return _public_media_url(settings, dst.name)
return ""
# 查询 health 端点上次返回的切片索引
target_slice_idx = get_last_health_slice_index(client_id)
if target_slice_idx >= 0 and target_slice_idx < len(slice_urls):
# 返回与 health 结果对齐的切片
logger.debug(
"[water-video] client_id={} aligned to health slice {}/{}",
client_id,
target_slice_idx,
len(slice_urls),
)
return slice_urls[target_slice_idx]
else:
# 没有对齐的 health 结果,返回空(等待 health/result 先被调用)
logger.debug(
"[water-video] client_id={} no health index yet, returning empty",
client_id,
)
return ""