temp save

This commit is contained in:
minipuding
2026-03-16 14:50:13 +08:00
parent dc7d3fba72
commit d7633848d4
9 changed files with 417 additions and 222 deletions
@@ -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
View File
@@ -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
+7
View File
@@ -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",
+237
View File
@@ -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
+42 -1
View File
@@ -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]:
"""