v5: refactor into modular stream_tools desktop app
Same inaSpeechSegmenter engine split into reusable core/ (fftools, segmenter, exporters) + utils/ and a CustomTkinter multi-tab UI. Adds JSON and ffmpeg-script export. Extracted from the stream_tools app. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
188
core/exporters.py
Normal file
188
core/exporters.py
Normal file
@@ -0,0 +1,188 @@
|
||||
"""
|
||||
Export singing segments to various formats:
|
||||
- EDL (DaVinci Resolve timeline import)
|
||||
- CSV (human-readable)
|
||||
- DaVinci Resolve Markers CSV
|
||||
- JSON (programmatic)
|
||||
- ffmpeg concat script (auto-cut)
|
||||
"""
|
||||
|
||||
import csv
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from utils.formats import format_timecode, seconds_to_smpte
|
||||
|
||||
|
||||
def export_edl(segments: list, output_path: str, fps: float = 29.97,
|
||||
title: str = "Singing Segments", source_filename: str = None):
|
||||
"""Export EDL file for DaVinci Resolve."""
|
||||
if source_filename:
|
||||
clip_name = source_filename
|
||||
reel_name_safe = Path(source_filename).stem[:32].replace(" ", "_")
|
||||
else:
|
||||
clip_name = None
|
||||
reel_name_safe = "001"
|
||||
|
||||
rec_offset = 3600.0
|
||||
|
||||
with open(output_path, "w", encoding="utf-8") as f:
|
||||
f.write(f"TITLE: {title}\n")
|
||||
f.write("FCM: NON-DROP FRAME\n\n")
|
||||
|
||||
current_rec_pos = rec_offset
|
||||
for seg in segments:
|
||||
edit_num = f"{seg['index']:03d}"
|
||||
src_in = seconds_to_smpte(seg["start"], fps)
|
||||
src_out = seconds_to_smpte(seg["end"], fps)
|
||||
rec_in = seconds_to_smpte(current_rec_pos, fps)
|
||||
rec_out = seconds_to_smpte(current_rec_pos + seg["duration"], fps)
|
||||
|
||||
f.write(f"{edit_num} {reel_name_safe} V C {src_in} {src_out} {rec_in} {rec_out}\n")
|
||||
|
||||
if clip_name:
|
||||
f.write(f"* FROM CLIP NAME: {clip_name}\n")
|
||||
|
||||
f.write(f"* COMMENT: Song {seg['index']} - Duration {seg['duration']:.0f}s\n\n")
|
||||
current_rec_pos += seg["duration"]
|
||||
|
||||
|
||||
def export_csv(segments: list, output_path: str):
|
||||
"""Export singing segments as CSV."""
|
||||
with open(output_path, "w", newline="", encoding="utf-8") as f:
|
||||
writer = csv.writer(f)
|
||||
writer.writerow(["index", "start_sec", "end_sec", "duration_sec",
|
||||
"start_timecode", "end_timecode"])
|
||||
for seg in segments:
|
||||
writer.writerow([
|
||||
seg["index"],
|
||||
f"{seg['start']:.2f}",
|
||||
f"{seg['end']:.2f}",
|
||||
f"{seg['duration']:.1f}",
|
||||
format_timecode(seg["start"]),
|
||||
format_timecode(seg["end"]),
|
||||
])
|
||||
|
||||
|
||||
def export_markers_csv(segments: list, output_path: str, fps: float = 29.97):
|
||||
"""Export DaVinci Resolve Markers CSV."""
|
||||
with open(output_path, "w", newline="", encoding="utf-8") as f:
|
||||
writer = csv.writer(f)
|
||||
writer.writerow(["#", "Color", "Name", "Start TC", "End TC", "Duration TC", "Notes"])
|
||||
for seg in segments:
|
||||
writer.writerow([
|
||||
seg["index"],
|
||||
"Blue",
|
||||
f"Song {seg['index']}",
|
||||
seconds_to_smpte(seg["start"], fps),
|
||||
seconds_to_smpte(seg["end"], fps),
|
||||
seconds_to_smpte(seg["duration"], fps),
|
||||
f"Duration: {seg['duration']:.0f}s",
|
||||
])
|
||||
|
||||
|
||||
def export_json(segments: list, output_path: str):
|
||||
"""Export as JSON."""
|
||||
with open(output_path, "w", encoding="utf-8") as f:
|
||||
json.dump(segments, f, indent=2, ensure_ascii=False)
|
||||
|
||||
|
||||
def export_ffmpeg_script(segments: list, video_path: str, output_dir: str):
|
||||
"""
|
||||
Export a script that uses ffmpeg to cut and concatenate all
|
||||
singing segments into a single video file.
|
||||
"""
|
||||
video_name = Path(video_path).stem
|
||||
ext = Path(video_path).suffix
|
||||
out_video = str(Path(output_dir) / f"{video_name}_singing_only{ext}")
|
||||
|
||||
is_windows = os.name == "nt" or sys.platform == "win32"
|
||||
script_ext = ".bat" if is_windows else ".sh"
|
||||
script_path = str(Path(output_dir) / f"{video_name}_autocut{script_ext}")
|
||||
concat_list_path = str(Path(output_dir) / f"{video_name}_concat_list.txt")
|
||||
|
||||
lines = []
|
||||
if is_windows:
|
||||
lines.append("@echo off")
|
||||
lines.append("REM Auto-generated ffmpeg script to extract singing segments")
|
||||
lines.append(f'REM Source: {video_path}')
|
||||
lines.append("")
|
||||
else:
|
||||
lines.append("#!/bin/bash")
|
||||
lines.append("# Auto-generated ffmpeg script to extract singing segments")
|
||||
lines.append(f'# Source: {video_path}')
|
||||
lines.append("")
|
||||
|
||||
segment_files = []
|
||||
for seg in segments:
|
||||
seg_file = f"_seg_{seg['index']:03d}{ext}"
|
||||
seg_path = str(Path(output_dir) / seg_file)
|
||||
segment_files.append(seg_file)
|
||||
|
||||
cmd = (f'ffmpeg -y -ss {seg["start"]:.2f} -i "{video_path}" '
|
||||
f'-t {seg["duration"]:.2f} -c copy "{seg_path}"')
|
||||
lines.append(f"echo Extracting Song {seg['index']}...")
|
||||
lines.append(cmd)
|
||||
lines.append("")
|
||||
|
||||
lines.append("echo Creating concat list...")
|
||||
if is_windows:
|
||||
lines.append("(")
|
||||
for sf in segment_files:
|
||||
seg_path = str(Path(output_dir) / sf)
|
||||
lines.append(f" echo file '{seg_path}'")
|
||||
lines.append(f') > "{concat_list_path}"')
|
||||
else:
|
||||
for i, sf in enumerate(segment_files):
|
||||
seg_path = str(Path(output_dir) / sf)
|
||||
op = ">" if i == 0 else ">>"
|
||||
lines.append(f"echo \"file '{seg_path}'\" {op} \"{concat_list_path}\"")
|
||||
|
||||
lines.append("")
|
||||
lines.append("echo Concatenating all segments...")
|
||||
lines.append(f'ffmpeg -y -f concat -safe 0 -i "{concat_list_path}" -c copy "{out_video}"')
|
||||
lines.append("")
|
||||
lines.append("echo Cleaning up temp files...")
|
||||
|
||||
for sf in segment_files:
|
||||
seg_path = str(Path(output_dir) / sf)
|
||||
if is_windows:
|
||||
lines.append(f'del "{seg_path}"')
|
||||
else:
|
||||
lines.append(f'rm -f "{seg_path}"')
|
||||
if is_windows:
|
||||
lines.append(f'del "{concat_list_path}"')
|
||||
else:
|
||||
lines.append(f'rm -f "{concat_list_path}"')
|
||||
|
||||
lines.append("")
|
||||
lines.append(f'echo Done! Output: {out_video}')
|
||||
if is_windows:
|
||||
lines.append("pause")
|
||||
|
||||
with open(script_path, "w", encoding="utf-8") as f:
|
||||
f.write("\n".join(lines))
|
||||
|
||||
if not is_windows:
|
||||
os.chmod(script_path, 0o755)
|
||||
|
||||
return script_path
|
||||
|
||||
|
||||
def export_raw_segments(segments: list, output_path: str):
|
||||
"""Export ALL raw segments (speech/music/noise) as CSV for debugging."""
|
||||
with open(output_path, "w", newline="", encoding="utf-8") as f:
|
||||
writer = csv.writer(f)
|
||||
writer.writerow(["label", "start_sec", "end_sec", "duration_sec",
|
||||
"start_timecode", "end_timecode"])
|
||||
for label, start, end in segments:
|
||||
writer.writerow([
|
||||
label,
|
||||
f"{start:.2f}",
|
||||
f"{end:.2f}",
|
||||
f"{end - start:.1f}",
|
||||
format_timecode(start),
|
||||
format_timecode(end),
|
||||
])
|
||||
Reference in New Issue
Block a user