Skip to content

Commit 3dbf0f4

Browse files
committed
feat(clipper): add --fast-trim/-ft option to generate outputs quickly without re-encoding
1 parent 63ddd76 commit 3dbf0f4

File tree

6 files changed

+161
-9
lines changed

6 files changed

+161
-9
lines changed

src/clipper/argparser.py

+7
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,13 @@ def getArgParser(clipper_paths: ClipperPaths) -> argparse.ArgumentParser:
7676
],
7777
),
7878
)
79+
parser.add_argument(
80+
"--fast-trim",
81+
"-ft",
82+
action="store_true",
83+
dest="fastTrim",
84+
help="Enable fast trim mode. Generates output clips very quickly by skipping re-encoding. The output will use the same video and audio codec as the input. Will output video clips with imprecise time trim and will disable most features including crop and speed.",
85+
)
7986
parser.add_argument(
8087
"--overlay",
8188
"-ov",

src/clipper/clip_maker.py

+115-6
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,15 @@ def getMarkerPairSettings( # noqa: PLR0912
5656
mps["titlePrefix"] = cleanFileName(mps["titlePrefix"])
5757
titlePrefix = f'{mps["titlePrefix"] + "-" if "titlePrefix" in mps else ""}'
5858
mp["fileNameStem"] = f'{titlePrefix}{mps["titleSuffix"]}-{markerPairIndex + 1}'
59-
mp["fileNameSuffix"] = "mp4" if mps["videoCodec"] == "h264" else "webm"
59+
60+
if mps["fastTrim"]:
61+
if mps["inputVideo"]:
62+
mp["fileNameSuffix"] = Path(mps["inputVideo"]).suffix.removeprefix(".")
63+
else:
64+
mp["fileNameSuffix"] = mps["ext"]
65+
else:
66+
mp["fileNameSuffix"] = "mp4" if mps["videoCodec"] == "h264" else "webm"
67+
6068
mp["fileName"] = f'{mp["fileNameStem"]}.{mp["fileNameSuffix"]}'
6169
mp["filePath"] = f'{cp.clipsPath}/{mp["fileName"]}'
6270
mp["exists"] = checkClipExists(
@@ -237,6 +245,63 @@ def findVideoPart(mp: DictStrAny, mps: DictStrAny) -> Optional[DictStrAny]:
237245
return None
238246

239247

248+
FFMPEG_NETWORK_INPUT_FLAGS = (
249+
r"-reconnect 1 -reconnect_at_eof 1 -reconnect_streamed 1 -reconnect_delay_max 5"
250+
)
251+
252+
253+
def fastTrimClip(
254+
cs: ClipperState,
255+
markerPairIndex: int,
256+
mp: DictStrAny,
257+
mps: DictStrAny,
258+
) -> Optional[Dict[str, Any]]:
259+
settings = cs.settings
260+
cp = cs.clipper_paths
261+
inputs = ""
262+
263+
if mp["isVariableSpeed"] or mps["loop"] != "none":
264+
mps["audio"] = False
265+
266+
if mps["audio"]:
267+
aStart = mp["start"] + mps["audioDelay"]
268+
aEnd = mp["end"] + mps["audioDelay"]
269+
# aDuration = aEnd - aStart
270+
# ffplay previewing does not support multiple inputs
271+
# if an input video is provided or previewing is on, there is only one input
272+
if not mps["inputVideo"] and not settings["preview"]:
273+
inputs += FFMPEG_NETWORK_INPUT_FLAGS
274+
inputs += f' -ss {aStart} -to {aEnd} -i "{mps["audioDownloadURL"]}" '
275+
# when streaming the required chunks from the internet the video and audio inputs are separate
276+
else:
277+
mps["audio"] = False
278+
logger.warning(
279+
"Audio disabled when previewing without an input video over non-dash protocol.",
280+
)
281+
282+
if not mps["inputVideo"]:
283+
inputs += FFMPEG_NETWORK_INPUT_FLAGS
284+
285+
videoStart = mp["start"]
286+
videoEnd = mp["end"]
287+
if mps["inputVideo"]:
288+
inputs += f' -ss {videoStart} -to {videoEnd} -i "{mps["inputVideo"]}" '
289+
elif mps["videoType"] != "multi_video":
290+
inputs += f' -ss {videoStart} -to {videoEnd} -i "{mps["videoDownloadURL"]}" '
291+
elif "videoPart" in mp:
292+
videoPart = mp["videoPart"]
293+
inputs += f' -ss {videoStart} -to {videoEnd} -i "{videoPart["url"]}" '
294+
else:
295+
logger.error(
296+
f'Failed to generate: "{mp["fileName"]}". The marker pair defines a clip that spans multiple video parts which is not currently supported.',
297+
)
298+
return None
299+
300+
ffmpegCommand = getFfmpegCommandFastTrim(cp, inputs, mp, mps)
301+
302+
return runffmpegCommand(settings, [ffmpegCommand], markerPairIndex, mp)
303+
304+
240305
def makeClip(cs: ClipperState, markerPairIndex: int) -> Optional[Dict[str, Any]]: # noqa: PLR0912
241306
settings = cs.settings
242307
cp = cs.clipper_paths
@@ -246,22 +311,27 @@ def makeClip(cs: ClipperState, markerPairIndex: int) -> Optional[Dict[str, Any]]
246311
if mp["exists"] and not mps["overwrite"]:
247312
return {**(settings["markerPairs"][markerPairIndex]), **mp}
248313

314+
if mps["fastTrim"]:
315+
logger.notice(
316+
f"Fast-trim enabled for marker pair {markerPairIndex}. Features that require re-encoding (including crop and speed) will be disabled.",
317+
)
318+
return fastTrimClip(cs, markerPairIndex, mp, mps)
319+
249320
inputs = ""
250321
audio_filter = ""
251322
video_filter = ""
252323

253324
if mp["isVariableSpeed"] or mps["loop"] != "none":
254325
mps["audio"] = False
255326

256-
inputFlags = r"-reconnect 1 -reconnect_at_eof 1 -reconnect_streamed 1 -reconnect_delay_max 5"
257327
if mps["audio"]:
258328
aStart = mp["start"] + mps["audioDelay"]
259329
aEnd = mp["end"] + mps["audioDelay"]
260330
aDuration = aEnd - aStart
261331
# ffplay previewing does not support multiple inputs
262332
# if an input video is provided or previewing is on, there is only one input
263333
if not mps["inputVideo"] and not settings["preview"]:
264-
inputs += inputFlags
334+
inputs += FFMPEG_NETWORK_INPUT_FLAGS
265335
inputs += f' -ss {aStart} -to {aEnd} -i "{mps["audioDownloadURL"]}" '
266336

267337
# preview mode does not start each clip at time 0 unlike encoding mode
@@ -283,7 +353,7 @@ def makeClip(cs: ClipperState, markerPairIndex: int) -> Optional[Dict[str, Any]]
283353
audio_filter += f',{mps["extraAudioFilters"]}'
284354

285355
if not mps["inputVideo"]:
286-
inputs += inputFlags
356+
inputs += FFMPEG_NETWORK_INPUT_FLAGS
287357

288358
if mps["inputVideo"]:
289359
inputs += f' -ss {mp["start"]} -i "{mps["inputVideo"]}" '
@@ -309,7 +379,16 @@ def makeClip(cs: ClipperState, markerPairIndex: int) -> Optional[Dict[str, Any]]
309379
+ f'({mps["targetSize"]} MB / ~{round(mp["outputDuration"],3)} s).',
310380
)
311381

312-
ffmpegCommand = getFfmpegCommand(audio_filter, cbr, cp, inputs, mp, mps, qmax, qmin)
382+
ffmpegCommand = getFfmpegCommandWithoutVideoFilter(
383+
audio_filter,
384+
cbr,
385+
cp,
386+
inputs,
387+
mp,
388+
mps,
389+
qmax,
390+
qmin,
391+
)
313392

314393
if not mps["preview"]:
315394
video_filter += f'trim=0:{mp["duration"]}'
@@ -574,7 +653,7 @@ def makeClip(cs: ClipperState, markerPairIndex: int) -> Optional[Dict[str, Any]]
574653
return runffmpegCommand(settings, ffmpegCommands, markerPairIndex, mp)
575654

576655

577-
def getFfmpegCommand(
656+
def getFfmpegCommandWithoutVideoFilter(
578657
audio_filter: str,
579658
cbr: Optional[int],
580659
cp: ClipperPaths,
@@ -616,6 +695,36 @@ def getFfmpegCommand(
616695
)
617696

618697

698+
def getFfmpegCommandFastTrim(
699+
cp: ClipperPaths,
700+
inputs: str,
701+
mp: DictStrAny,
702+
mps: DictStrAny,
703+
) -> str:
704+
overwriteArg = " -y " if mps["overwrite"] else " -n "
705+
706+
return " ".join(
707+
(
708+
cp.ffmpegPath,
709+
overwriteArg,
710+
f"-hide_banner",
711+
getFfmpegHeaders(mps["platform"]),
712+
inputs,
713+
f"-benchmark",
714+
# f'-loglevel 56',
715+
f"-c copy",
716+
(
717+
f'-metadata title="{mps["videoTitle"]}"'
718+
if not mps["removeMetadata"]
719+
else "-map_metadata -1"
720+
),
721+
f"" if mps["audio"] else "-an",
722+
f'{mps["extraFfmpegArgs"]}',
723+
f'{mp["filePath"]}' " ",
724+
),
725+
)
726+
727+
619728
def getFfmpegVideoCodec(
620729
videoCodec: str,
621730
cbr: Optional[int],

src/clipper/ffprobe.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ def ffprobeVideoProperties(cs: ClipperState, videoURL: str) -> Optional[DictStrA
5252
else:
5353
logger.warning(f"Ignoring estimated bit rate from ffprobe as it is 0.")
5454

55-
logger.important(f"ffprobeData={ffprobeData}")
55+
logger.debug(f"ffprobeData={ffprobeOutput}")
5656
color_transfer = ffprobeData.get("color_transfer")
5757
settings["inputIsHDR"] = settings.get("inputIsHDR") or color_transfer in (
5858
"smpte2084",

src/clipper/tests/test_e2e.py

+27
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,33 @@ def test_make_clip(
5050
print(out, err)
5151

5252

53+
@pytest.mark.slow
54+
def test_make_clip_fast_trim(
55+
monkeypatch: pytest.MonkeyPatch,
56+
capsys: pytest.CaptureFixture,
57+
) -> None:
58+
with monkeypatch.context() as m:
59+
m.setattr(
60+
sys,
61+
"argv",
62+
[
63+
"yt_clipper.py",
64+
"--markers-json",
65+
f'{this_dir / "testdata" / "test-with-dynamic.json"}',
66+
"--overwrite",
67+
"--fast-trim",
68+
],
69+
)
70+
main()
71+
out, err = capsys.readouterr()
72+
# assert no warnings from a yt-dlp extractor
73+
assert "WARNING: [" not in out
74+
# assert no errors
75+
assert "error" not in out
76+
assert "ERROR" not in out
77+
print(out, err)
78+
79+
5380
@pytest.mark.slow
5481
def test_make_clip_with_local_input_video(
5582
monkeypatch: pytest.MonkeyPatch,

src/clipper/ytc_logger.py

+10-1
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,15 @@
66

77
from clipper.clipper_types import ClipperState
88

9+
# CRITICAL = 50
10+
# FATAL = CRITICAL
11+
# ERROR = 40
12+
# WARNING = 30
13+
# WARN = WARNING
14+
# INFO = 20
15+
# DEBUG = 10
16+
# NOTSET = 0
17+
918

1019
class YTCLogger(verboselogs.VerboseLogger):
1120
def important(self, msg: str, *args, **kwargs) -> None: # noqa: ANN002, ANN003
@@ -42,7 +51,7 @@ def setUpLogger(cs: ClipperState) -> None:
4251
coloredlogs.DEFAULT_LEVEL_STYLES["REPORT"] = {"color": "cyan"}
4352

4453
datefmt = "%y-%m-%d %H:%M:%S"
45-
coloredlogs.install(level=verboselogs.VERBOSE, datefmt=datefmt)
54+
coloredlogs.install(level=logging.DEBUG, datefmt=datefmt)
4655

4756
coloredFormatter = coloredlogs.ColoredFormatter(datefmt=datefmt)
4857

src/clipper/ytc_settings.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -269,7 +269,7 @@ def _getVideoInfo(cs: ClipperState) -> Tuple[Dict[str, Any], Dict[str, Any]]:
269269
audioInfo = videoInfo
270270
settings["mergedStreams"] = True
271271

272-
# logger.important(f"videoInfo={json.dumps(videoInfo)}")
272+
logger.debug(f"videoInfo={json.dumps(videoInfo)}")
273273

274274
dynamic_range: str = videoInfo.get("dynamic_range", "")
275275
settings["inputIsHDR"] = settings.get("inputIsHDR") or dynamic_range.lower().startswith("hdr")

0 commit comments

Comments
 (0)