mirror of
https://github.com/FireRedTeam/FireRed-OpenStoryline.git
synced 2026-08-30 16:57:16 +08:00
temp save
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
---
|
||||
name: speech_rough_cut_skill
|
||||
description: 【SKILL】根据输入视频的音频信息进行粗剪,口播粗剪。
|
||||
---
|
||||
|
||||
# 角色定义 (Role)
|
||||
你是一个专业的“口播粗剪专家”。你具备深厚的影视视听语言知识,能够根据视频的音频信息(如ASR结果)进行合理的剪辑,提取出有价值的片段,去除冗余内容。
|
||||
|
||||
# 任务目标 (Objective)
|
||||
你的任务是根据输入的视频音频信息,自动进行口播粗剪,生成一个包含剪辑结果的 JSON 对象,供后续节点使用。需要一次调用以下几个工具,首先正常读取视频素材,执行split_shots节点但是使用“skip”参数跳过,再使用asr节点完成文字的识别和文字时间戳打标,再用speech_rough_cut节点实现视频粗剪切分。
|
||||
|
||||
注意,之后直接推荐花字生成字幕,生成时间线,渲染,分组和文案生成都使用skip参数跳过。
|
||||
+1
-1
@@ -44,7 +44,7 @@ available_node_pkgs = [
|
||||
"open_storyline.nodes.core_nodes"
|
||||
]
|
||||
available_nodes = [
|
||||
"LoadMediaNode", "SearchMediaNode", "SplitShotsNode", "LocalASRNode",
|
||||
"LoadMediaNode", "SearchMediaNode", "SplitShotsNode", "LocalASRNode", "SpeechRoughCutNode",
|
||||
"UnderstandClipsNode", "FilterClipsNode", "GroupClipsNode", "GenerateScriptNode", "ScriptTemplateRecomendation",
|
||||
"GenerateVoiceoverNode", "SelectBGMNode", "RecommendTransitionNode", "RecommendTextNode",
|
||||
"PlanTimelineProNode", "RenderVideoNode"
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
以下是asr识别结果,包括句子开始和结束的时间戳以及每个字的时间戳:
|
||||
{{asr_sentence_info}}
|
||||
@@ -93,6 +93,7 @@ class LocalASRNode(BaseNode):
|
||||
if kind != "video":
|
||||
asr_infos.append({
|
||||
"clip_id": clip["clip_id"],
|
||||
"path": video_path,
|
||||
"kind": kind,
|
||||
"asr_res": {},
|
||||
})
|
||||
@@ -105,6 +106,7 @@ class LocalASRNode(BaseNode):
|
||||
if audio_wav is None:
|
||||
asr_infos.append({
|
||||
"clip_id": clip["clip_id"],
|
||||
"path": video_path,
|
||||
"kind": kind,
|
||||
"asr_res": {},
|
||||
})
|
||||
@@ -120,6 +122,7 @@ class LocalASRNode(BaseNode):
|
||||
)
|
||||
asr_infos.append({
|
||||
"clip_id": clip["clip_id"],
|
||||
"path": video_path,
|
||||
"kind": kind,
|
||||
"asr_res": res[0] if res else {},
|
||||
})
|
||||
|
||||
@@ -5,7 +5,15 @@ import tempfile
|
||||
|
||||
from open_storyline.nodes.core_nodes.base_node import BaseNode, NodeMeta
|
||||
from open_storyline.nodes.node_state import NodeState
|
||||
from open_storyline.nodes.node_schema import LocalASRInput
|
||||
from open_storyline.nodes.node_schema import SpeechRoughCutInput
|
||||
from open_storyline.utils.prompts import get_prompt
|
||||
from open_storyline.utils.parse_json import parse_json_list
|
||||
from open_storyline.utils.ffmpeg_utils import (
|
||||
resolve_ffmpeg_executable,
|
||||
read_video_frames_as_rgb24,
|
||||
segment_video_stream_copy_with_ffmpeg,
|
||||
VideoSegment,
|
||||
)
|
||||
from open_storyline.utils.register import NODE_REGISTRY
|
||||
|
||||
@NODE_REGISTRY.register()
|
||||
@@ -21,23 +29,11 @@ class SpeechRoughCutNode(BaseNode):
|
||||
next_available_node=[],
|
||||
)
|
||||
|
||||
input_schema = LocalASRInput
|
||||
input_schema = SpeechRoughCutInput
|
||||
|
||||
def _load_asr_model(self):
|
||||
|
||||
if hasattr(self, "asr_model"):
|
||||
return self.asr_model
|
||||
else:
|
||||
from funasr import AutoModel
|
||||
|
||||
self.asr_model = AutoModel(
|
||||
model="paraformer-zh",
|
||||
vad_model="fsmn-vad",
|
||||
punc_model="ct-punc",
|
||||
vad_kwargs={"max_single_segment_time": 30000},
|
||||
)
|
||||
return self.asr_model
|
||||
|
||||
def __init__(self, server_cfg):
|
||||
super().__init__(server_cfg)
|
||||
self.ffmpeg_executable = resolve_ffmpeg_executable()
|
||||
|
||||
async def default_process(
|
||||
self,
|
||||
@@ -49,25 +45,101 @@ class SpeechRoughCutNode(BaseNode):
|
||||
async def process(self, node_state: NodeState, inputs: Dict[str, Any]) -> Any:
|
||||
|
||||
asr_infos = inputs["asr"].get('asr_infos', [])
|
||||
|
||||
|
||||
def _combine_tool_outputs(self, node_state, outputs):
|
||||
|
||||
asr_infos = outputs.get("asr_infos", [])
|
||||
regularized_asr_infos = []
|
||||
video_path = inputs["asr"].get('video_path')
|
||||
gap_threshold = inputs.get('gap_threshold', 400)
|
||||
output_directory = self._prepare_output_directory(node_state, inputs)
|
||||
llm = node_state.llm
|
||||
rough_cut_jsons = []
|
||||
|
||||
system_prompt = get_prompt("speech_rough_cut.system", lang=node_state.lang)
|
||||
|
||||
for asr_info in asr_infos:
|
||||
clip_id = asr_info["clip_id"]
|
||||
kind = asr_info["kind"]
|
||||
asr_res = asr_info.get("asr_res", {})
|
||||
|
||||
regularized_asr_infos.append({
|
||||
"clip_id": clip_id,
|
||||
"kind": kind,
|
||||
"asr_text": asr_res.get("text", "") if asr_res else "",
|
||||
"asr_timestamps": asr_res.get("timestamps", []) if asr_res else [],
|
||||
"asr_sentence_info": asr_res.get("sentence_info", []) if asr_res else [],
|
||||
user_prompt = get_prompt(
|
||||
"speech_rough_cut.user",
|
||||
lang=node_state.lang,
|
||||
asr_sentence_info=asr_info.get("asr_sentence_info", {})
|
||||
)
|
||||
|
||||
try:
|
||||
raw = await llm.complete(
|
||||
system_prompt=system_prompt,
|
||||
user_prompt=user_prompt,
|
||||
media=None,
|
||||
temperature=0.1,
|
||||
top_p=0.9,
|
||||
max_tokens=8092,
|
||||
model_preferences=None,
|
||||
)
|
||||
except Exception as e:
|
||||
last_error = e
|
||||
|
||||
try:
|
||||
rough_cut_json = parse_json_list(raw)
|
||||
segments = self.group_sentences(rough_cut_json, gap_threshold=gap_threshold)
|
||||
ranges = self.segments_to_ranges(segments)
|
||||
cuts = self.ranges_to_cut_points(ranges)
|
||||
segments = segment_video_stream_copy_with_ffmpeg(
|
||||
input_video=video_path,
|
||||
ffmpeg_executable=self.ffmpeg_executable,
|
||||
split_points_seconds=cuts,
|
||||
output_directory=output_directory,
|
||||
filename_prefix=f"clip_{asr_info['clip_id']}",
|
||||
start_index=len(rough_cut_jsons),
|
||||
)
|
||||
rough_cut_jsons.append(rough_cut_json)
|
||||
except Exception as e:
|
||||
last_error = e
|
||||
|
||||
breakpoint()
|
||||
|
||||
return {"rough_cut": rough_cut_jsons}
|
||||
|
||||
|
||||
def group_sentences(self, items, gap_threshold: int=400):
|
||||
segments = []
|
||||
current = [items[0]]
|
||||
|
||||
for i in range(len(items) - 1):
|
||||
cur = items[i]
|
||||
next = items[i+1]
|
||||
|
||||
gap = next["start"] - cur["end"]
|
||||
|
||||
if gap > gap_threshold:
|
||||
segments.append(current)
|
||||
current = [next]
|
||||
else:
|
||||
current.append(next)
|
||||
|
||||
if current:
|
||||
segments.append(current)
|
||||
|
||||
return segments
|
||||
|
||||
def segments_to_ranges(self, segments):
|
||||
ranges = []
|
||||
|
||||
for seg in segments:
|
||||
ranges.append({
|
||||
"start": seg[0]["start"],
|
||||
"end": seg[-1]["end"]
|
||||
})
|
||||
return {
|
||||
"asr_infos": regularized_asr_infos,
|
||||
}
|
||||
|
||||
return ranges
|
||||
|
||||
def ranges_to_cut_points(self,ranges):
|
||||
cuts = []
|
||||
|
||||
for i in range(len(ranges) - 1):
|
||||
cuts.append(ranges[i]["end"])
|
||||
cuts.append(ranges[i+1]["start"])
|
||||
|
||||
return cuts
|
||||
|
||||
def _prepare_output_directory(self, node_state: NodeState, inputs: Dict[str, Any]) -> Path:
|
||||
artifact_id = node_state.artifact_id
|
||||
session_id = node_state.session_id
|
||||
output_directory = self.server_cache_dir / session_id / artifact_id
|
||||
output_directory.mkdir(parents=True, exist_ok=True)
|
||||
return output_directory
|
||||
|
||||
@@ -17,6 +17,12 @@ from open_storyline.nodes.core_nodes.base_node import BaseNode, NodeMeta
|
||||
from open_storyline.nodes.node_schema import SplitShotsInput
|
||||
from open_storyline.nodes.node_state import NodeState
|
||||
from open_storyline.nodes.node_summary import NodeSummary
|
||||
from open_storyline.utils.ffmpeg_utils import (
|
||||
resolve_ffmpeg_executable,
|
||||
read_video_frames_as_rgb24,
|
||||
segment_video_stream_copy_with_ffmpeg,
|
||||
VideoSegment,
|
||||
)
|
||||
from open_storyline.utils.register import NODE_REGISTRY
|
||||
|
||||
MODEL_CACHE_MAXSIZE = 4
|
||||
@@ -36,22 +42,8 @@ DEFAULT_MAX_SHOT_DURATION_MILLISECONDS = 30000
|
||||
CLIP_ID_NUMBER_WIDTH = 4
|
||||
MILLISECONDS_PER_SECOND = 1000.0
|
||||
|
||||
FFMPEG_LOGLEVEL = "error"
|
||||
FFMPEG_PIXEL_FORMAT_RGB24 = "rgb24"
|
||||
FFMPEG_SCALE_FLAGS = "fast_bilinear"
|
||||
FFMPEG_STDOUT_PIPE = "pipe:1"
|
||||
|
||||
FFMPEG_ENVIRONMENT_VARIABLE_KEYS = ("IMAGEIO_FFMPEG_EXE", "FFMPEG_BINARY")
|
||||
SAFE_MAP_ARGS = ["-map", "0:v:0", "-map", "0:a?", "-dn", "-sn"]
|
||||
|
||||
COPY_VIDEO_WHEN_NO_SPLIT = False
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class VideoSegment:
|
||||
path: Path
|
||||
start_seconds: float
|
||||
end_seconds: float # ffmpeg segment csv might use -1 for "until end" in our wrapper
|
||||
|
||||
# =========================
|
||||
# Model / ffmpeg helpers
|
||||
# =========================
|
||||
@@ -72,97 +64,6 @@ def load_transnetv2_model_cached(weight_path: str, device: str = "auto"):
|
||||
return model
|
||||
|
||||
|
||||
def resolve_ffmpeg_executable() -> str:
|
||||
"""
|
||||
Resolve ffmpeg executable path:
|
||||
1) env var IMAGEIO_FFMPEG_EXE / FFMPEG_BINARY
|
||||
2) system PATH
|
||||
3) imageio-ffmpeg
|
||||
"""
|
||||
# 1) Environment variables
|
||||
for key in FFMPEG_ENVIRONMENT_VARIABLE_KEYS:
|
||||
configured_value = os.getenv(key)
|
||||
if not configured_value:
|
||||
continue
|
||||
|
||||
configured_path = Path(configured_value).expanduser()
|
||||
if configured_path.exists():
|
||||
return str(configured_path)
|
||||
|
||||
# env var may also be just "ffmpeg" or a command name
|
||||
resolved_from_path = shutil.which(configured_value)
|
||||
if resolved_from_path:
|
||||
return resolved_from_path
|
||||
|
||||
# 2) System PATH
|
||||
ffmpeg_in_path = shutil.which("ffmpeg")
|
||||
if ffmpeg_in_path:
|
||||
return ffmpeg_in_path
|
||||
|
||||
# 3) imageio-ffmpeg
|
||||
try:
|
||||
import imageio_ffmpeg
|
||||
ffmpeg_from_imageio = imageio_ffmpeg.get_ffmpeg_exe()
|
||||
if ffmpeg_from_imageio:
|
||||
return ffmpeg_from_imageio
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
raise RuntimeError("ffmpeg not found (checked env vars, PATH, and imageio-ffmpeg).")
|
||||
|
||||
|
||||
def read_video_frames_as_rgb24(
|
||||
input_video: Path,
|
||||
ffmpeg_executable: str,
|
||||
*,
|
||||
frames_per_second: int = DEFAULT_SCENE_DETECTION_FRAMES_PER_SECOND,
|
||||
target_width: int = TRANSNETV2_INPUT_WIDTH,
|
||||
target_height: int = TRANSNETV2_INPUT_HEIGHT,
|
||||
) -> np.ndarray:
|
||||
"""
|
||||
Use ffmpeg to decode frames at fixed FPS and fixed size, output as raw RGB24 bytes.
|
||||
Returns: np.ndarray with shape [frame_count, target_height, target_width, 3], dtype=uint8
|
||||
"""
|
||||
input_video = Path(input_video)
|
||||
|
||||
video_filter = (
|
||||
f"fps={frames_per_second},"
|
||||
f"scale={target_width}:{target_height}:flags={FFMPEG_SCALE_FLAGS}"
|
||||
)
|
||||
|
||||
command = [
|
||||
ffmpeg_executable, "-hide_banner", "-loglevel", FFMPEG_LOGLEVEL, "-nostdin",
|
||||
"-i", str(input_video),
|
||||
"-an",
|
||||
"-vf", video_filter,
|
||||
"-pix_fmt", FFMPEG_PIXEL_FORMAT_RGB24,
|
||||
"-f", "rawvideo",
|
||||
FFMPEG_STDOUT_PIPE,
|
||||
]
|
||||
|
||||
process = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
assert process.stdout is not None and process.stderr is not None
|
||||
|
||||
stdout_bytes, stderr_bytes = process.communicate()
|
||||
if process.returncode != 0:
|
||||
raise RuntimeError(
|
||||
f"ffmpeg frame extraction failed: {input_video}\n"
|
||||
f"{stderr_bytes.decode('utf-8', errors='replace')}"
|
||||
)
|
||||
|
||||
bytes_per_frame = target_width * target_height * TRANSNETV2_INPUT_CHANNELS
|
||||
frame_count = len(stdout_bytes) // bytes_per_frame
|
||||
|
||||
if frame_count <= 0:
|
||||
return np.empty((0, target_height, target_width, TRANSNETV2_INPUT_CHANNELS), dtype=np.uint8)
|
||||
|
||||
stdout_bytes = stdout_bytes[: frame_count * bytes_per_frame]
|
||||
frames = np.frombuffer(stdout_bytes, dtype=np.uint8).reshape(
|
||||
(frame_count, target_height, target_width, TRANSNETV2_INPUT_CHANNELS)
|
||||
)
|
||||
return frames
|
||||
|
||||
|
||||
def detect_scenes_with_transnetv2_without_proxy(
|
||||
model: Any,
|
||||
input_video: Path,
|
||||
@@ -332,86 +233,6 @@ def enforce_shot_duration_constraints_on_split_points_seconds(
|
||||
# milliseconds -> seconds
|
||||
return [cut_ms / MILLISECONDS_PER_SECOND for cut_ms in cut_points_ms]
|
||||
|
||||
def segment_video_stream_copy_with_ffmpeg(
|
||||
input_video: Path,
|
||||
ffmpeg_executable: str,
|
||||
*,
|
||||
split_points_seconds: List[float],
|
||||
output_directory: Path,
|
||||
filename_prefix: str,
|
||||
start_index: int = 0,
|
||||
) -> List[VideoSegment]:
|
||||
"""
|
||||
Fast segmentation: stream copy (-c copy) + segment muxer.
|
||||
Returns segments with start/end in seconds from ffmpeg segment list csv.
|
||||
"""
|
||||
output_directory.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# No split points -> single output copy
|
||||
if not split_points_seconds:
|
||||
output_path = output_directory / f"{filename_prefix}_{start_index:0{CLIP_ID_NUMBER_WIDTH}d}.mp4"
|
||||
command = [
|
||||
ffmpeg_executable, "-hide_banner", "-loglevel", FFMPEG_LOGLEVEL, "-nostdin",
|
||||
"-y",
|
||||
"-i", str(input_video),
|
||||
*SAFE_MAP_ARGS,
|
||||
"-c", "copy",
|
||||
"-movflags", "+faststart",
|
||||
str(output_path),
|
||||
]
|
||||
completed = subprocess.run(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
if completed.returncode != 0:
|
||||
raise RuntimeError(
|
||||
f"ffmpeg stream copy failed: {input_video}\n"
|
||||
f"{completed.stderr.decode('utf-8', errors='replace')}"
|
||||
)
|
||||
return [VideoSegment(path=output_path, start_seconds=0.0, end_seconds=-1.0)]
|
||||
|
||||
split_points_argument = ",".join(f"{t:.3f}" for t in split_points_seconds)
|
||||
|
||||
segment_list_csv_path = output_directory / f"{filename_prefix}_{start_index:0{CLIP_ID_NUMBER_WIDTH}d}.csv"
|
||||
output_pattern = output_directory / f"{filename_prefix}_%0{CLIP_ID_NUMBER_WIDTH}d.mp4"
|
||||
|
||||
command = [
|
||||
ffmpeg_executable, "-hide_banner", "-loglevel", FFMPEG_LOGLEVEL, "-nostdin",
|
||||
"-y",
|
||||
"-i", str(input_video),
|
||||
*SAFE_MAP_ARGS,
|
||||
"-c", "copy",
|
||||
"-f", "segment",
|
||||
"-segment_start_number", str(start_index),
|
||||
"-segment_list", str(segment_list_csv_path),
|
||||
"-segment_list_type", "csv",
|
||||
"-segment_times", split_points_argument,
|
||||
"-reset_timestamps", "1",
|
||||
"-segment_format_options", "movflags=+faststart",
|
||||
str(output_pattern),
|
||||
]
|
||||
|
||||
completed = subprocess.run(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
if completed.returncode != 0:
|
||||
raise RuntimeError(
|
||||
f"ffmpeg segment failed: {input_video}\n"
|
||||
f"{completed.stderr.decode('utf-8', errors='replace')}"
|
||||
)
|
||||
|
||||
segments: List[VideoSegment] = []
|
||||
with segment_list_csv_path.open("r", encoding="utf-8", newline="") as file_handle:
|
||||
csv_reader = csv.reader(file_handle)
|
||||
for row in csv_reader:
|
||||
if not row or len(row) < 3:
|
||||
continue
|
||||
filename, start_time, end_time = row[0], row[1], row[2]
|
||||
segments.append(
|
||||
VideoSegment(
|
||||
path=output_directory / filename,
|
||||
start_seconds=float(start_time),
|
||||
end_seconds=float(end_time),
|
||||
)
|
||||
)
|
||||
|
||||
return segments
|
||||
|
||||
|
||||
# =========================
|
||||
# Node implementation
|
||||
|
||||
@@ -221,6 +221,13 @@ class LocalASRInput(BaseInput):
|
||||
description="auto: Perform ASR on the clips generated by split_shots node; skip: Skip ASR; default: Use default ASR method (same as skip)"
|
||||
)
|
||||
|
||||
class SpeechRoughCutInput(BaseInput):
|
||||
mode: Literal["auto", "skip", "default"] = Field(
|
||||
default="auto",
|
||||
description="auto: Perform rough cut on speech clips based on ASR results; skip: Skip rough cut; default: Use default rough cut method (same as skip)"
|
||||
)
|
||||
gap_threshold: Annotated[int, Field(default=400, description="Gap threshold for grouping sentences into subtitle units (milliseconds)")]
|
||||
|
||||
class UnderstandClipsInput(BaseModel):
|
||||
mode: Literal["auto", "skip", "default"] = Field(
|
||||
default="auto",
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
import numpy as np
|
||||
|
||||
|
||||
TRANSNETV2_INPUT_CHANNELS = 3
|
||||
|
||||
FFMPEG_LOGLEVEL = "error"
|
||||
FFMPEG_PIXEL_FORMAT_RGB24 = "rgb24"
|
||||
FFMPEG_SCALE_FLAGS = "fast_bilinear"
|
||||
FFMPEG_STDOUT_PIPE = "pipe:1"
|
||||
|
||||
FFMPEG_ENVIRONMENT_VARIABLE_KEYS = ("IMAGEIO_FFMPEG_EXE", "FFMPEG_BINARY")
|
||||
|
||||
SAFE_MAP_ARGS = ["-map", "0:v:0", "-map", "0:a?", "-dn", "-sn"]
|
||||
|
||||
CLIP_ID_NUMBER_WIDTH = 4
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class VideoSegment:
|
||||
path: Path
|
||||
start_seconds: float
|
||||
end_seconds: float
|
||||
|
||||
|
||||
def resolve_ffmpeg_executable() -> str:
|
||||
"""
|
||||
Resolve ffmpeg executable path:
|
||||
1) env var IMAGEIO_FFMPEG_EXE / FFMPEG_BINARY
|
||||
2) system PATH
|
||||
3) imageio-ffmpeg
|
||||
"""
|
||||
|
||||
for key in FFMPEG_ENVIRONMENT_VARIABLE_KEYS:
|
||||
configured_value = os.getenv(key)
|
||||
if not configured_value:
|
||||
continue
|
||||
|
||||
configured_path = Path(configured_value).expanduser()
|
||||
if configured_path.exists():
|
||||
return str(configured_path)
|
||||
|
||||
resolved_from_path = shutil.which(configured_value)
|
||||
if resolved_from_path:
|
||||
return resolved_from_path
|
||||
|
||||
ffmpeg_in_path = shutil.which("ffmpeg")
|
||||
if ffmpeg_in_path:
|
||||
return ffmpeg_in_path
|
||||
|
||||
try:
|
||||
import imageio_ffmpeg
|
||||
|
||||
ffmpeg_from_imageio = imageio_ffmpeg.get_ffmpeg_exe()
|
||||
if ffmpeg_from_imageio:
|
||||
return ffmpeg_from_imageio
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
raise RuntimeError("ffmpeg not found (checked env vars, PATH, and imageio-ffmpeg).")
|
||||
|
||||
|
||||
def read_video_frames_as_rgb24(
|
||||
input_video: Path,
|
||||
ffmpeg_executable: str,
|
||||
*,
|
||||
frames_per_second: int,
|
||||
target_width: int,
|
||||
target_height: int,
|
||||
) -> np.ndarray:
|
||||
"""
|
||||
Use ffmpeg to decode frames at fixed FPS and fixed size, output as raw RGB24 bytes.
|
||||
"""
|
||||
|
||||
video_filter = (
|
||||
f"fps={frames_per_second},"
|
||||
f"scale={target_width}:{target_height}:flags={FFMPEG_SCALE_FLAGS}"
|
||||
)
|
||||
|
||||
command = [
|
||||
ffmpeg_executable,
|
||||
"-hide_banner",
|
||||
"-loglevel",
|
||||
FFMPEG_LOGLEVEL,
|
||||
"-nostdin",
|
||||
"-i",
|
||||
str(input_video),
|
||||
"-an",
|
||||
"-vf",
|
||||
video_filter,
|
||||
"-pix_fmt",
|
||||
FFMPEG_PIXEL_FORMAT_RGB24,
|
||||
"-f",
|
||||
"rawvideo",
|
||||
FFMPEG_STDOUT_PIPE,
|
||||
]
|
||||
|
||||
process = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
|
||||
stdout_bytes, stderr_bytes = process.communicate()
|
||||
|
||||
if process.returncode != 0:
|
||||
raise RuntimeError(
|
||||
f"ffmpeg frame extraction failed: {input_video}\n"
|
||||
f"{stderr_bytes.decode('utf-8', errors='replace')}"
|
||||
)
|
||||
|
||||
bytes_per_frame = target_width * target_height * TRANSNETV2_INPUT_CHANNELS
|
||||
|
||||
frame_count = len(stdout_bytes) // bytes_per_frame
|
||||
|
||||
if frame_count <= 0:
|
||||
return np.empty((0, target_height, target_width, 3), dtype=np.uint8)
|
||||
|
||||
stdout_bytes = stdout_bytes[: frame_count * bytes_per_frame]
|
||||
|
||||
frames = np.frombuffer(stdout_bytes, dtype=np.uint8).reshape(
|
||||
(frame_count, target_height, target_width, 3)
|
||||
)
|
||||
|
||||
return frames
|
||||
|
||||
|
||||
def segment_video_stream_copy_with_ffmpeg(
|
||||
input_video: Path,
|
||||
ffmpeg_executable: str,
|
||||
*,
|
||||
split_points_seconds: List[float],
|
||||
output_directory: Path,
|
||||
filename_prefix: str,
|
||||
start_index: int = 0,
|
||||
) -> List[VideoSegment]:
|
||||
|
||||
output_directory.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if not split_points_seconds:
|
||||
|
||||
output_path = output_directory / f"{filename_prefix}_{start_index:04d}.mp4"
|
||||
|
||||
command = [
|
||||
ffmpeg_executable,
|
||||
"-hide_banner",
|
||||
"-loglevel",
|
||||
FFMPEG_LOGLEVEL,
|
||||
"-nostdin",
|
||||
"-y",
|
||||
"-i",
|
||||
str(input_video),
|
||||
*SAFE_MAP_ARGS,
|
||||
"-c",
|
||||
"copy",
|
||||
"-movflags",
|
||||
"+faststart",
|
||||
str(output_path),
|
||||
]
|
||||
|
||||
completed = subprocess.run(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
|
||||
if completed.returncode != 0:
|
||||
raise RuntimeError(
|
||||
f"ffmpeg stream copy failed: {input_video}\n"
|
||||
f"{completed.stderr.decode('utf-8', errors='replace')}"
|
||||
)
|
||||
|
||||
return [VideoSegment(path=output_path, start_seconds=0.0, end_seconds=-1.0)]
|
||||
|
||||
split_points_argument = ",".join(f"{t:.3f}" for t in split_points_seconds)
|
||||
|
||||
segment_list_csv_path = output_directory / f"{filename_prefix}_{start_index:04d}.csv"
|
||||
|
||||
output_pattern = output_directory / f"{filename_prefix}_%04d.mp4"
|
||||
|
||||
command = [
|
||||
ffmpeg_executable,
|
||||
"-hide_banner",
|
||||
"-loglevel",
|
||||
FFMPEG_LOGLEVEL,
|
||||
"-nostdin",
|
||||
"-y",
|
||||
"-i",
|
||||
str(input_video),
|
||||
*SAFE_MAP_ARGS,
|
||||
"-c",
|
||||
"copy",
|
||||
"-f",
|
||||
"segment",
|
||||
"-segment_start_number",
|
||||
str(start_index),
|
||||
"-segment_list",
|
||||
str(segment_list_csv_path),
|
||||
"-segment_list_type",
|
||||
"csv",
|
||||
"-segment_times",
|
||||
split_points_argument,
|
||||
"-reset_timestamps",
|
||||
"1",
|
||||
"-segment_format_options",
|
||||
"movflags=+faststart",
|
||||
str(output_pattern),
|
||||
]
|
||||
|
||||
completed = subprocess.run(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
|
||||
if completed.returncode != 0:
|
||||
raise RuntimeError(
|
||||
f"ffmpeg segment failed: {input_video}\n"
|
||||
f"{completed.stderr.decode('utf-8', errors='replace')}"
|
||||
)
|
||||
|
||||
segments: List[VideoSegment] = []
|
||||
|
||||
with segment_list_csv_path.open("r", encoding="utf-8") as f:
|
||||
|
||||
reader = csv.reader(f)
|
||||
|
||||
for row in reader:
|
||||
if not row or len(row) < 3:
|
||||
continue
|
||||
|
||||
segments.append(
|
||||
VideoSegment(
|
||||
path=output_directory / row[0],
|
||||
start_seconds=float(row[1]),
|
||||
end_seconds=float(row[2]),
|
||||
)
|
||||
)
|
||||
|
||||
return segments
|
||||
@@ -1,6 +1,6 @@
|
||||
import json
|
||||
import re
|
||||
from typing import Any, Dict, Optional, Iterable
|
||||
from typing import Any, Dict, Optional, Iterable, List
|
||||
|
||||
def try_parse_tool_call(text:str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
@@ -142,6 +142,47 @@ def _iter_object_candidates(text: str) -> Iterable[str]:
|
||||
if cand:
|
||||
yield cand
|
||||
|
||||
def parse_json_list(text: str) -> List[Any]:
|
||||
"""
|
||||
Parse a JSON array (list) from arbitrary text.
|
||||
|
||||
Supports:
|
||||
1) Markdown fenced JSON code blocks: ```json ... ```
|
||||
2) JSON surrounded by extra text
|
||||
3) Removing trailing commas before '}' or ']'
|
||||
Args:
|
||||
text: Input string to parse
|
||||
Returns:
|
||||
Parsed list
|
||||
Raises:
|
||||
ValueError: Cannot find a valid JSON array to parse
|
||||
TypeError: Input text is not a string
|
||||
"""
|
||||
if not isinstance(text, str):
|
||||
raise TypeError(f"text must be str, got {type(text).__name__}")
|
||||
|
||||
# Try fenced block first, then try the entire text
|
||||
search_spaces = list(_iter_fenced_json_blocks(text))
|
||||
search_spaces.append(text)
|
||||
|
||||
last_err: Optional[Exception] = None
|
||||
|
||||
for space in search_spaces:
|
||||
stripped = space.lstrip().lstrip("\ufeff") # 顺便去 BOM
|
||||
if not stripped.startswith("["):
|
||||
continue
|
||||
|
||||
cleaned = _strip_trailing_commas(stripped).strip()
|
||||
try:
|
||||
obj = json.loads(cleaned)
|
||||
if isinstance(obj, list):
|
||||
return obj
|
||||
except Exception as e:
|
||||
last_err = e
|
||||
continue
|
||||
|
||||
raise ValueError("No valid JSON array (list) found in input") from last_err
|
||||
|
||||
|
||||
def parse_json_dict(text: str) -> Dict[str, Any]:
|
||||
"""
|
||||
|
||||
Reference in New Issue
Block a user