diff --git a/.storyline/skills/speech_rough_cut_skill/SKILL.md b/.storyline/skills/speech_rough_cut_skill/SKILL.md new file mode 100644 index 0000000..7882e6c --- /dev/null +++ b/.storyline/skills/speech_rough_cut_skill/SKILL.md @@ -0,0 +1,12 @@ +--- +name: speech_rough_cut_skill +description: 【SKILL】根据输入视频的音频信息进行粗剪,口播粗剪。 +--- + +# 角色定义 (Role) +你是一个专业的“口播粗剪专家”。你具备深厚的影视视听语言知识,能够根据视频的音频信息(如ASR结果)进行合理的剪辑,提取出有价值的片段,去除冗余内容。 + +# 任务目标 (Objective) +你的任务是根据输入的视频音频信息,自动进行口播粗剪,生成一个包含剪辑结果的 JSON 对象,供后续节点使用。需要一次调用以下几个工具,首先正常读取视频素材,执行split_shots节点但是使用“skip”参数跳过,再使用asr节点完成文字的识别和文字时间戳打标,再用speech_rough_cut节点实现视频粗剪切分。 + +注意,之后直接推荐花字生成字幕,生成时间线,渲染,分组和文案生成都使用skip参数跳过。 \ No newline at end of file diff --git a/config.toml b/config.toml index d43dd6a..29f9875 100644 --- a/config.toml +++ b/config.toml @@ -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" diff --git a/prompts/tasks/speech_rough_cut/zh/user.md b/prompts/tasks/speech_rough_cut/zh/user.md index e69de29..37f5e0a 100644 --- a/prompts/tasks/speech_rough_cut/zh/user.md +++ b/prompts/tasks/speech_rough_cut/zh/user.md @@ -0,0 +1,2 @@ +以下是asr识别结果,包括句子开始和结束的时间戳以及每个字的时间戳: +{{asr_sentence_info}} \ No newline at end of file diff --git a/src/open_storyline/nodes/core_nodes/asr_node.py b/src/open_storyline/nodes/core_nodes/asr_node.py index f91ef7f..e9103cf 100644 --- a/src/open_storyline/nodes/core_nodes/asr_node.py +++ b/src/open_storyline/nodes/core_nodes/asr_node.py @@ -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 {}, }) diff --git a/src/open_storyline/nodes/core_nodes/speech_rough_cut.py b/src/open_storyline/nodes/core_nodes/speech_rough_cut.py index a27e8eb..9df9b74 100644 --- a/src/open_storyline/nodes/core_nodes/speech_rough_cut.py +++ b/src/open_storyline/nodes/core_nodes/speech_rough_cut.py @@ -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 diff --git a/src/open_storyline/nodes/core_nodes/split_shots.py b/src/open_storyline/nodes/core_nodes/split_shots.py index 4e47ec9..e7984fb 100644 --- a/src/open_storyline/nodes/core_nodes/split_shots.py +++ b/src/open_storyline/nodes/core_nodes/split_shots.py @@ -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 diff --git a/src/open_storyline/nodes/node_schema.py b/src/open_storyline/nodes/node_schema.py index 119e3c0..c24985f 100644 --- a/src/open_storyline/nodes/node_schema.py +++ b/src/open_storyline/nodes/node_schema.py @@ -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", diff --git a/src/open_storyline/utils/ffmpeg_utils.py b/src/open_storyline/utils/ffmpeg_utils.py new file mode 100644 index 0000000..b15393b --- /dev/null +++ b/src/open_storyline/utils/ffmpeg_utils.py @@ -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 \ No newline at end of file diff --git a/src/open_storyline/utils/parse_json.py b/src/open_storyline/utils/parse_json.py index ef4043a..f127a4e 100644 --- a/src/open_storyline/utils/parse_json.py +++ b/src/open_storyline/utils/parse_json.py @@ -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]: """