volume panel; copilot instructions

This commit is contained in:
2026-04-15 16:10:35 -06:00
parent 0df967507f
commit 4f90750497
11 changed files with 688 additions and 106 deletions

View File

@ -21,6 +21,10 @@ class SegmentModel(BaseModel):
end: float
class GainRangeModel(SegmentModel):
gain_db: float
class ExportWordModel(BaseModel):
word: str
start: float
@ -33,6 +37,8 @@ class ExportRequest(BaseModel):
output_path: str
keep_segments: List[SegmentModel]
mute_ranges: Optional[List[SegmentModel]] = None
gain_ranges: Optional[List[GainRangeModel]] = None
global_gain_db: float = 0.0
mode: str = "fast"
resolution: str = "1080p"
format: str = "mp4"
@ -42,6 +48,42 @@ class ExportRequest(BaseModel):
deleted_indices: Optional[List[int]] = None
def _map_ranges_to_output_timeline(
ranges: List[dict],
keep_segments: List[dict],
) -> List[dict]:
"""Map source-time ranges to output timeline after cuts are applied."""
if not ranges or not keep_segments:
return []
mapped: List[dict] = []
output_cursor = 0.0
for keep in keep_segments:
keep_start = float(keep["start"])
keep_end = float(keep["end"])
keep_len = max(0.0, keep_end - keep_start)
if keep_len <= 0:
continue
for src_range in ranges:
overlap_start = max(keep_start, float(src_range["start"]))
overlap_end = min(keep_end, float(src_range["end"]))
if overlap_end <= overlap_start:
continue
mapped_range = {
"start": output_cursor + (overlap_start - keep_start),
"end": output_cursor + (overlap_end - keep_start),
}
if "gain_db" in src_range:
mapped_range["gain_db"] = float(src_range["gain_db"])
mapped.append(mapped_range)
output_cursor += keep_len
return mapped
def _mux_audio(video_path: str, audio_path: str, output_path: str) -> str:
"""Replace video's audio track with cleaned audio using FFmpeg."""
import subprocess
@ -66,15 +108,19 @@ async def export_video(req: ExportRequest):
try:
segments = [{"start": s.start, "end": s.end} for s in req.keep_segments]
mute_segments = [{"start": s.start, "end": s.end} for s in req.mute_ranges] if req.mute_ranges else None
gain_segments = [{"start": s.start, "end": s.end, "gain_db": s.gain_db} for s in req.gain_ranges] if req.gain_ranges else None
if not segments and not mute_segments:
raise HTTPException(status_code=400, detail="No segments to export")
use_stream_copy = req.mode == "fast" and len(segments) == 1 and not mute_segments
mapped_gain_segments = _map_ranges_to_output_timeline(gain_segments or [], segments)
has_gain = abs(float(req.global_gain_db)) > 1e-6 or bool(gain_segments)
use_stream_copy = req.mode == "fast" and len(segments) == 1 and not mute_segments and not has_gain
needs_reencode_for_subs = req.captions == "burn-in"
# Burn-in captions or mute ranges require re-encode
if needs_reencode_for_subs or mute_segments:
# Burn-in captions or audio filters require re-encode
if needs_reencode_for_subs or mute_segments or has_gain:
use_stream_copy = False
words_dicts = [w.model_dump() for w in req.words] if req.words else []
@ -101,6 +147,8 @@ async def export_video(req: ExportRequest):
resolution=req.resolution,
format_hint=req.format,
mute_ranges=mute_segments,
gain_ranges=mapped_gain_segments,
global_gain_db=req.global_gain_db,
)
else:
output = export_reencode(
@ -110,6 +158,8 @@ async def export_video(req: ExportRequest):
resolution=req.resolution,
format_hint=req.format,
mute_ranges=mute_segments,
gain_ranges=mapped_gain_segments,
global_gain_db=req.global_gain_db,
)
finally:
if ass_path and os.path.exists(ass_path):

View File

@ -112,6 +112,8 @@ def export_reencode(
resolution: str = "1080p",
format_hint: str = "mp4",
mute_ranges: List[dict] = None,
gain_ranges: List[dict] = None,
global_gain_db: float = 0.0,
) -> str:
"""
Export video with full re-encode. Slower but supports resolution changes,
@ -128,21 +130,29 @@ def export_reencode(
"4k": "scale=-2:2160",
}
# Handle muting case - keep full video but silence audio ranges
if mute_ranges and len(mute_ranges) > 0:
# Build volume filter for muting
volume_filters = []
for i, mute_range in enumerate(mute_ranges):
def build_audio_filter() -> str:
filters = []
if abs(float(global_gain_db)) > 1e-6:
filters.append(f"volume={float(global_gain_db)}dB")
for gain_range in gain_ranges or []:
start = gain_range['start']
end = gain_range['end']
gain_db = gain_range.get('gain_db', 0.0)
filters.append(f"volume={float(gain_db)}dB:enable='between(t,{start},{end})'")
for mute_range in mute_ranges or []:
start = mute_range['start']
end = mute_range['end']
# Use volume=0 to mute, enable to specify time range
volume_filters.append(f"volume=0:enable='between(t,{start},{end})'")
filters.append(f"volume=0:enable='between(t,{start},{end})'")
# Combine all volume filters
if volume_filters:
audio_filter = ",".join(volume_filters)
else:
audio_filter = "anull" # No muting needed
return ",".join(filters) if filters else "anull"
has_audio_filters = bool(mute_ranges) or bool(gain_ranges) or abs(float(global_gain_db)) > 1e-6
# Handle filtered full-timeline audio case (mute/gain/global gain)
if has_audio_filters:
audio_filter = build_audio_filter()
# Video filter - just scaling if needed
scale = scale_map.get(resolution, "")
@ -170,7 +180,14 @@ def export_reencode(
output_path,
]
logger.info(f"Re-encoding with {len(mute_ranges)} mute ranges -> {output_path} ({resolution})")
logger.info(
"Re-encoding with audio filters (mute=%s gain=%s global=%s) -> %s (%s)",
len(mute_ranges or []),
len(gain_ranges or []),
global_gain_db,
output_path,
resolution,
)
else:
# Original cutting logic
if not keep_segments:
@ -228,6 +245,8 @@ def export_reencode_with_subs(
resolution: str = "1080p",
format_hint: str = "mp4",
mute_ranges: List[dict] = None,
gain_ranges: List[dict] = None,
global_gain_db: float = 0.0,
) -> str:
"""
Export video with re-encode and burn-in subtitles (ASS format).
@ -245,19 +264,29 @@ def export_reencode_with_subs(
"4k": "scale=-2:2160",
}
# Handle muting case - keep full video but silence audio ranges
if mute_ranges and len(mute_ranges) > 0:
# Build volume filter for muting
volume_filters = []
for i, mute_range in enumerate(mute_ranges):
def build_audio_filter() -> str:
filters = []
if abs(float(global_gain_db)) > 1e-6:
filters.append(f"volume={float(global_gain_db)}dB")
for gain_range in gain_ranges or []:
start = gain_range['start']
end = gain_range['end']
gain_db = gain_range.get('gain_db', 0.0)
filters.append(f"volume={float(gain_db)}dB:enable='between(t,{start},{end})'")
for mute_range in mute_ranges or []:
start = mute_range['start']
end = mute_range['end']
volume_filters.append(f"volume=0:enable='between(t,{start},{end})'")
filters.append(f"volume=0:enable='between(t,{start},{end})'")
if volume_filters:
audio_filter = ",".join(volume_filters)
else:
audio_filter = "anull"
return ",".join(filters) if filters else "anull"
has_audio_filters = bool(mute_ranges) or bool(gain_ranges) or abs(float(global_gain_db)) > 1e-6
# Handle filtered full-timeline audio case (mute/gain/global gain)
if has_audio_filters:
audio_filter = build_audio_filter()
# Video filter with subtitles
escaped_sub = subtitle_path.replace("\\", "/").replace(":", "\\:")
@ -284,7 +313,14 @@ def export_reencode_with_subs(
output_path,
]
logger.info(f"Re-encoding with subtitles and {len(mute_ranges)} mute ranges -> {output_path} ({resolution})")
logger.info(
"Re-encoding with subtitles and audio filters (mute=%s gain=%s global=%s) -> %s (%s)",
len(mute_ranges or []),
len(gain_ranges or []),
global_gain_db,
output_path,
resolution,
)
else:
# Original cutting logic with subtitles
if not keep_segments: