videopython 0.46.0__tar.gz → 0.47.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. {videopython-0.46.0 → videopython-0.47.0}/PKG-INFO +1 -1
  2. {videopython-0.46.0 → videopython-0.47.0}/pyproject.toml +1 -1
  3. {videopython-0.46.0 → videopython-0.47.0}/src/videopython/ai/effects.py +1 -2
  4. {videopython-0.46.0 → videopython-0.47.0}/src/videopython/ai/transforms.py +0 -1
  5. {videopython-0.46.0 → videopython-0.47.0}/src/videopython/editing/effects.py +0 -372
  6. {videopython-0.46.0 → videopython-0.47.0}/src/videopython/editing/operation.py +30 -20
  7. {videopython-0.46.0 → videopython-0.47.0}/src/videopython/editing/streaming.py +18 -26
  8. {videopython-0.46.0 → videopython-0.47.0}/src/videopython/editing/transcription_overlay.py +0 -1
  9. {videopython-0.46.0 → videopython-0.47.0}/src/videopython/editing/transforms.py +0 -6
  10. {videopython-0.46.0 → videopython-0.47.0}/src/videopython/editing/video_edit.py +12 -24
  11. {videopython-0.46.0 → videopython-0.47.0}/.gitignore +0 -0
  12. {videopython-0.46.0 → videopython-0.47.0}/LICENSE +0 -0
  13. {videopython-0.46.0 → videopython-0.47.0}/README.md +0 -0
  14. {videopython-0.46.0 → videopython-0.47.0}/src/videopython/__init__.py +0 -0
  15. {videopython-0.46.0 → videopython-0.47.0}/src/videopython/ai/__init__.py +0 -0
  16. {videopython-0.46.0 → videopython-0.47.0}/src/videopython/ai/_device.py +0 -0
  17. {videopython-0.46.0 → videopython-0.47.0}/src/videopython/ai/_optional.py +0 -0
  18. {videopython-0.46.0 → videopython-0.47.0}/src/videopython/ai/_predictor.py +0 -0
  19. {videopython-0.46.0 → videopython-0.47.0}/src/videopython/ai/_revisions.py +0 -0
  20. {videopython-0.46.0 → videopython-0.47.0}/src/videopython/ai/dubbing/__init__.py +0 -0
  21. {videopython-0.46.0 → videopython-0.47.0}/src/videopython/ai/dubbing/config.py +0 -0
  22. {videopython-0.46.0 → videopython-0.47.0}/src/videopython/ai/dubbing/dubber.py +0 -0
  23. {videopython-0.46.0 → videopython-0.47.0}/src/videopython/ai/dubbing/expressiveness.py +0 -0
  24. {videopython-0.46.0 → videopython-0.47.0}/src/videopython/ai/dubbing/loudness.py +0 -0
  25. {videopython-0.46.0 → videopython-0.47.0}/src/videopython/ai/dubbing/models.py +0 -0
  26. {videopython-0.46.0 → videopython-0.47.0}/src/videopython/ai/dubbing/pipeline.py +0 -0
  27. {videopython-0.46.0 → videopython-0.47.0}/src/videopython/ai/dubbing/quality.py +0 -0
  28. {videopython-0.46.0 → videopython-0.47.0}/src/videopython/ai/dubbing/remux.py +0 -0
  29. {videopython-0.46.0 → videopython-0.47.0}/src/videopython/ai/dubbing/timing.py +0 -0
  30. {videopython-0.46.0 → videopython-0.47.0}/src/videopython/ai/dubbing/voice_sample.py +0 -0
  31. {videopython-0.46.0 → videopython-0.47.0}/src/videopython/ai/generation/__init__.py +0 -0
  32. {videopython-0.46.0 → videopython-0.47.0}/src/videopython/ai/generation/_tts_backend.py +0 -0
  33. {videopython-0.46.0 → videopython-0.47.0}/src/videopython/ai/generation/audio.py +0 -0
  34. {videopython-0.46.0 → videopython-0.47.0}/src/videopython/ai/generation/image.py +0 -0
  35. {videopython-0.46.0 → videopython-0.47.0}/src/videopython/ai/generation/qwen3.py +0 -0
  36. {videopython-0.46.0 → videopython-0.47.0}/src/videopython/ai/generation/translation.py +0 -0
  37. {videopython-0.46.0 → videopython-0.47.0}/src/videopython/ai/generation/video.py +0 -0
  38. {videopython-0.46.0 → videopython-0.47.0}/src/videopython/ai/understanding/__init__.py +0 -0
  39. {videopython-0.46.0 → videopython-0.47.0}/src/videopython/ai/understanding/audio.py +0 -0
  40. {videopython-0.46.0 → videopython-0.47.0}/src/videopython/ai/understanding/faces.py +0 -0
  41. {videopython-0.46.0 → videopython-0.47.0}/src/videopython/ai/understanding/image.py +0 -0
  42. {videopython-0.46.0 → videopython-0.47.0}/src/videopython/ai/understanding/objects.py +0 -0
  43. {videopython-0.46.0 → videopython-0.47.0}/src/videopython/ai/understanding/separation.py +0 -0
  44. {videopython-0.46.0 → videopython-0.47.0}/src/videopython/ai/understanding/temporal.py +0 -0
  45. {videopython-0.46.0 → videopython-0.47.0}/src/videopython/ai/video_analysis/__init__.py +0 -0
  46. {videopython-0.46.0 → videopython-0.47.0}/src/videopython/ai/video_analysis/analyzer.py +0 -0
  47. {videopython-0.46.0 → videopython-0.47.0}/src/videopython/ai/video_analysis/models.py +0 -0
  48. {videopython-0.46.0 → videopython-0.47.0}/src/videopython/ai/video_analysis/sampling.py +0 -0
  49. {videopython-0.46.0 → videopython-0.47.0}/src/videopython/ai/video_analysis/stages.py +0 -0
  50. {videopython-0.46.0 → videopython-0.47.0}/src/videopython/audio/__init__.py +0 -0
  51. {videopython-0.46.0 → videopython-0.47.0}/src/videopython/audio/analysis.py +0 -0
  52. {videopython-0.46.0 → videopython-0.47.0}/src/videopython/audio/audio.py +0 -0
  53. {videopython-0.46.0 → videopython-0.47.0}/src/videopython/base/__init__.py +0 -0
  54. {videopython-0.46.0 → videopython-0.47.0}/src/videopython/base/_dimensions.py +0 -0
  55. {videopython-0.46.0 → videopython-0.47.0}/src/videopython/base/_ffmpeg.py +0 -0
  56. {videopython-0.46.0 → videopython-0.47.0}/src/videopython/base/_video_io.py +0 -0
  57. {videopython-0.46.0 → videopython-0.47.0}/src/videopython/base/description.py +0 -0
  58. {videopython-0.46.0 → videopython-0.47.0}/src/videopython/base/draw_detections.py +0 -0
  59. {videopython-0.46.0 → videopython-0.47.0}/src/videopython/base/exceptions.py +0 -0
  60. {videopython-0.46.0 → videopython-0.47.0}/src/videopython/base/fonts/Anton-OFL.txt +0 -0
  61. {videopython-0.46.0 → videopython-0.47.0}/src/videopython/base/fonts/Anton-Regular.ttf +0 -0
  62. {videopython-0.46.0 → videopython-0.47.0}/src/videopython/base/fonts/BebasNeue-OFL.txt +0 -0
  63. {videopython-0.46.0 → videopython-0.47.0}/src/videopython/base/fonts/BebasNeue-Regular.ttf +0 -0
  64. {videopython-0.46.0 → videopython-0.47.0}/src/videopython/base/fonts/DejaVuSans.ttf +0 -0
  65. {videopython-0.46.0 → videopython-0.47.0}/src/videopython/base/fonts/LICENSE_DEJAVU +0 -0
  66. {videopython-0.46.0 → videopython-0.47.0}/src/videopython/base/fonts/Lato-Bold.ttf +0 -0
  67. {videopython-0.46.0 → videopython-0.47.0}/src/videopython/base/fonts/Lato-OFL.txt +0 -0
  68. {videopython-0.46.0 → videopython-0.47.0}/src/videopython/base/fonts/Poppins-Bold.ttf +0 -0
  69. {videopython-0.46.0 → videopython-0.47.0}/src/videopython/base/fonts/Poppins-OFL.txt +0 -0
  70. {videopython-0.46.0 → videopython-0.47.0}/src/videopython/base/fonts/__init__.py +0 -0
  71. {videopython-0.46.0 → videopython-0.47.0}/src/videopython/base/transcription.py +0 -0
  72. {videopython-0.46.0 → videopython-0.47.0}/src/videopython/base/video.py +0 -0
  73. {videopython-0.46.0 → videopython-0.47.0}/src/videopython/editing/__init__.py +0 -0
  74. {videopython-0.46.0 → videopython-0.47.0}/src/videopython/editing/_ass.py +0 -0
  75. {videopython-0.46.0 → videopython-0.47.0}/src/videopython/editing/_easing.py +0 -0
  76. {videopython-0.46.0 → videopython-0.47.0}/src/videopython/editing/audio_ops.py +0 -0
  77. {videopython-0.46.0 → videopython-0.47.0}/src/videopython/py.typed +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: videopython
3
- Version: 0.46.0
3
+ Version: 0.47.0
4
4
  Summary: Minimal video generation and processing library.
5
5
  Project-URL: Homepage, https://videopython.com
6
6
  Project-URL: Repository, https://github.com/bartwojtowicz/videopython/
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "videopython"
3
- version = "0.46.0"
3
+ version = "0.47.0"
4
4
  description = "Minimal video generation and processing library."
5
5
  authors = [
6
6
  { name = "Bartosz Wójtowicz", email = "bartoszwojtowicz@outlook.com" },
@@ -9,7 +9,7 @@ AI-free renderer in :mod:`videopython.base.draw_detections`.
9
9
 
10
10
  from __future__ import annotations
11
11
 
12
- from typing import Any, ClassVar, Literal
12
+ from typing import Any, Literal
13
13
 
14
14
  import numpy as np
15
15
  from pydantic import Field, PrivateAttr
@@ -39,7 +39,6 @@ class ObjectDetectionOverlay(Effect):
39
39
  """
40
40
 
41
41
  op: Literal["object_detection_overlay"] = "object_detection_overlay"
42
- streamable: ClassVar[bool] = True
43
42
 
44
43
  confidence_threshold: float = Field(0.5, ge=0, le=1, description="Minimum detection confidence to draw a box, 0-1.")
45
44
  class_filter: list[str] | None = Field(
@@ -44,7 +44,6 @@ class FaceTrackingCrop(Operation):
44
44
 
45
45
  op: Literal["face_crop"] = "face_crop"
46
46
  category: ClassVar[OpCategory] = OpCategory.TRANSFORM
47
- streamable: ClassVar[bool] = True
48
47
  compiles_from_source: ClassVar[bool] = True
49
48
 
50
49
  target_aspect: tuple[int, int] = Field((9, 16), description="Output aspect ratio as (width, height).")
@@ -15,7 +15,6 @@ override :meth:`Effect.predict_metadata`. Anchored RGBA overlays
15
15
  from __future__ import annotations
16
16
 
17
17
  import logging
18
- import math
19
18
  import tempfile
20
19
  from io import BytesIO
21
20
  from pathlib import Path
@@ -73,7 +72,6 @@ class FullImageOverlay(Effect):
73
72
  """
74
73
 
75
74
  op: Literal["full_image_overlay"] = "full_image_overlay"
76
- streamable: ClassVar[bool] = True
77
75
  llm_exposed: ClassVar[bool] = False
78
76
 
79
77
  source: Path = Field(
@@ -143,7 +141,6 @@ class Blur(Effect):
143
141
  """Applies Gaussian blur that can stay constant or ramp up/down over the clip."""
144
142
 
145
143
  op: Literal["blur_effect"] = "blur_effect"
146
- streamable: ClassVar[bool] = True
147
144
 
148
145
  mode: Literal["constant", "ascending", "descending"] = Field(
149
146
  description=(
@@ -192,7 +189,6 @@ class Zoom(Effect):
192
189
  """Progressively zooms into or out of the frame center over the clip duration."""
193
190
 
194
191
  op: Literal["zoom_effect"] = "zoom_effect"
195
- streamable: ClassVar[bool] = True
196
192
 
197
193
  zoom_factor: float = Field(
198
194
  gt=1,
@@ -228,47 +224,11 @@ class Zoom(Effect):
228
224
  cropped = frame[round(y) : round(y + h), round(x) : round(x + w)]
229
225
  return cv2.resize(cropped, (width, height))
230
226
 
231
- @property
232
- def compiles_to_filter(self) -> bool:
233
- # Only an unwindowed, whole-clip zoom is a single comma-join-safe -vf
234
- # entry. A windowed zoom (frames outside the window pass through) cannot
235
- # be one zoompan, so it stays frame-only.
236
- return self.window is None
237
-
238
- def to_ffmpeg_filter(self, ctx: FilterCtx) -> str | None:
239
- """Whole-clip center zoom via ffmpeg ``zoompan``.
240
-
241
- Matches the numpy twin: the crop SIZE ramps linearly between the full
242
- frame and ``width // zoom_factor`` and the frame is rescaled to the
243
- original size, so the per-frame zoom is ``iw/crop_w``. The trailing
244
- ``setpts=N/(fps*TB)`` is LOAD-BEARING: zoompan reinterprets PTS at its
245
- own framerate, and without re-deriving PTS from the output frame number
246
- the decode-stage ``fps=`` resampler multiplies the frame count. yuv-safe.
247
- """
248
- if ctx.frame_count <= 0:
249
- return None
250
- n = ctx.frame_count
251
- w, h = ctx.width, ctx.height
252
- small_w = w // self.zoom_factor
253
- if self.mode == "out":
254
- cw0, cw1 = float(small_w), float(w)
255
- else: # in
256
- cw0, cw1 = float(w), float(small_w)
257
- denom = max(n - 1, 1)
258
- z_expr = f"iw/({cw0:.6f}+({cw1 - cw0:.6f})*on/{denom})"
259
- return (
260
- f"zoompan=z='{z_expr}'"
261
- f":x='iw/2-iw/zoom/2':y='ih/2-ih/zoom/2'"
262
- f":d=1:s={w}x{h}:fps={ctx.fps},setpts=N/({ctx.fps}*TB)"
263
- )
264
-
265
227
 
266
228
  class ColorGrading(Effect):
267
229
  """Adjusts color properties: brightness, contrast, saturation, and temperature."""
268
230
 
269
231
  op: Literal["color_adjust"] = "color_adjust"
270
- streamable: ClassVar[bool] = True
271
- filter_needs_rgb24: ClassVar[bool] = True
272
232
 
273
233
  brightness: float = Field(
274
234
  0.0,
@@ -316,80 +276,11 @@ class ColorGrading(Effect):
316
276
  def process_frame(self, frame: np.ndarray, frame_index: int) -> np.ndarray:
317
277
  return self._grade_frame(frame)
318
278
 
319
- @property
320
- def _is_noop(self) -> bool:
321
- return self.brightness == 0.0 and self.contrast == 1.0 and self.saturation == 1.0 and self.temperature == 0.0
322
-
323
- def _channel_expr(self, src: str, temp_offset: float) -> str:
324
- """Per-channel ``geq`` expression mirroring :meth:`_grade_frame`.
325
-
326
- ``src`` is the geq accessor for the channel being computed
327
- (``r(X,Y)``/``g(X,Y)``/``b(X,Y)``); ``temp_offset`` is the additive
328
- temperature shift for this channel (``+t`` on red, ``0`` on green, ``-t``
329
- on blue). The pipeline runs on normalised 0..1 values: brightness add,
330
- contrast about 0.5 (unclipped, matching numpy), a cv2-equivalent HSV
331
- "toward-max" saturation scale, the temperature shift, then a final clamp.
332
- """
333
- norm = {"r": "(r(X,Y)/255)", "g": "(g(X,Y)/255)", "b": "(b(X,Y)/255)"}
334
-
335
- def bc(v: str) -> str:
336
- return f"((({v}+{self.brightness:g})-0.5)*{self.contrast:g}+0.5)"
337
-
338
- graded = bc(f"({src}/255)")
339
- if self.saturation != 1.0:
340
- # cv2 RGB->HSV scales saturation toward V=max(R,G,B), clipping S at 1:
341
- # each channel C maps to V-(V-C)*ratio with ratio=min(sat, V/chroma).
342
- # The HSV roundtrip clips inputs to 0..1 first.
343
- rc = f"clip({bc(norm['r'])},0,1)"
344
- gc = f"clip({bc(norm['g'])},0,1)"
345
- bcl = f"clip({bc(norm['b'])},0,1)"
346
- src_c = f"clip({graded},0,1)"
347
- v = f"max(max({rc},{gc}),{bcl})"
348
- mn = f"min(min({rc},{gc}),{bcl})"
349
- chroma = f"({v}-{mn})"
350
- ratio = f"if(gt({chroma},0),min({self.saturation:g},{v}/{chroma}),1)"
351
- graded = f"({v}-({v}-{src_c})*({ratio}))"
352
- if temp_offset != 0.0:
353
- graded = f"({graded}+({temp_offset:g}))"
354
- return f"clip(({graded})*255,0,255)"
355
-
356
- def to_ffmpeg_filter(self, ctx: FilterCtx) -> str | None:
357
- """Compile the colour grade to a single ``geq`` ``-vf`` entry.
358
-
359
- Mirrors :meth:`_grade_frame` per channel (brightness, contrast,
360
- cv2-equivalent HSV saturation, temperature) on normalised RGB, faithful
361
- to the numpy path to within uint8 rounding (geq rounds; numpy truncates).
362
- All commas live inside single-quoted per-channel expressions, so the
363
- entry is comma-join safe. A no-op grade returns ``None``. ``self.window``
364
- appends a timeline ``enable='between(t,start,stop)'``.
365
- """
366
- if self._is_noop:
367
- return None
368
- temp_shift = self.temperature * 0.1
369
- r = self._channel_expr("r(X,Y)", temp_shift)
370
- g = self._channel_expr("g(X,Y)", 0.0)
371
- b = self._channel_expr("b(X,Y)", -temp_shift)
372
- expr = f"geq=r='{r}':g='{g}':b='{b}'"
373
- if self.window is not None:
374
- stop = ctx.frame_count / ctx.fps if ctx.fps > 0 and ctx.frame_count > 0 else None
375
- start_s = 0.0 if self.window.start is None else float(self.window.start)
376
- stop_s = stop if self.window.stop is None else float(self.window.stop)
377
- if stop_s is not None:
378
- expr += f":enable='between(t,{start_s:g},{stop_s:g})'"
379
- return expr
380
-
381
- @property
382
- def compiles_to_filter(self) -> bool:
383
- """A no-op grade emits no filter; any active field compiles to one ``geq`` entry."""
384
- return not self._is_noop
385
-
386
279
 
387
280
  class Vignette(Effect):
388
281
  """Darkens the edges of the frame, drawing attention to the center."""
389
282
 
390
283
  op: Literal["vignette"] = "vignette"
391
- streamable: ClassVar[bool] = True
392
- filter_needs_rgb24: ClassVar[bool] = True
393
284
 
394
285
  strength: float = Field(
395
286
  0.5,
@@ -427,38 +318,6 @@ class Vignette(Effect):
427
318
  assert self._stream_mask_3d is not None
428
319
  return (frame.astype(np.float32) * self._stream_mask_3d).astype(np.uint8)
429
320
 
430
- @property
431
- def compiles_to_filter(self) -> bool:
432
- return True
433
-
434
- def to_ffmpeg_filter(self, ctx: FilterCtx) -> str | None:
435
- """Compile the radial darkening to a per-channel ``geq`` expression.
436
-
437
- Reproduces :meth:`_create_mask` exactly in ffmpeg's expression language:
438
- ``geq`` exposes per-pixel ``X``/``Y`` and the plane size ``W``/``H``, so
439
- the normalized coordinates ``Xn = 2*X/(W-1) - 1`` / ``Yn = 2*Y/(H-1) - 1``
440
- mirror ``np.linspace(-1, 1, ...)``, and the mask is
441
- ``1 - clip(hypot(Xn, Yn)/radius - 0.5, 0, 1) * 2*strength`` applied to every
442
- channel. ``clip`` is spelled with ``min``/``max`` and explicit products so
443
- the expression carries no commas of its own -- only the quoted ``r(X,Y)``
444
- lookups and the ``enable`` clause do, and those quotes survive the runner's
445
- comma-join of the ``-vf`` chain. ``geq`` has native timeline support, so a
446
- windowed vignette gates on ``enable`` and passes other frames through.
447
- """
448
- xn = "((2*X/(W-1))-1)"
449
- yn = "((2*Y/(H-1))-1)"
450
- falloff = 2.0 * self.strength
451
- mask = f"(1-min(max(sqrt({xn}*{xn}+{yn}*{yn})/{self.radius:.6f}-0.5,0),1)*{falloff:.6f})"
452
- r = f"r='r(X,Y)*{mask}'"
453
- g = f"g='g(X,Y)*{mask}'"
454
- b = f"b='b(X,Y)*{mask}'"
455
- filt = f"geq={r}:{g}:{b}"
456
- if self.window is not None:
457
- start_s = 0.0 if self.window.start is None else float(self.window.start)
458
- stop_s = ctx.frame_count / ctx.fps if self.window.stop is None else float(self.window.stop)
459
- filt += f":enable='between(t,{start_s:.6f},{stop_s:.6f})'"
460
- return filt
461
-
462
321
 
463
322
  class KenBurns(Effect):
464
323
  """Cinematic pan-and-zoom that smoothly animates between two crop regions.
@@ -469,7 +328,6 @@ class KenBurns(Effect):
469
328
  """
470
329
 
471
330
  op: Literal["ken_burns"] = "ken_burns"
472
- streamable: ClassVar[bool] = True
473
331
 
474
332
  start_region: BoundingBox = Field(
475
333
  description="Starting crop region as a BoundingBox with normalized 0-1 coordinates."
@@ -549,7 +407,6 @@ class Fade(Effect):
549
407
  """Fades video and audio to or from black."""
550
408
 
551
409
  op: Literal["fade"] = "fade"
552
- streamable: ClassVar[bool] = True
553
410
  audio_coupled: ClassVar[bool] = True
554
411
 
555
412
  mode: Literal["in", "out", "in_out"] = Field(
@@ -651,7 +508,6 @@ class VolumeAdjust(Effect):
651
508
  """Changes audio volume within a time range without affecting video frames."""
652
509
 
653
510
  op: Literal["volume_adjust"] = "volume_adjust"
654
- streamable: ClassVar[bool] = True
655
511
  audio_coupled: ClassVar[bool] = True
656
512
 
657
513
  volume: float = Field(
@@ -828,7 +684,6 @@ class TextOverlay(_AnchoredOverlay):
828
684
  """Draws text on video frames, with auto word-wrap and optional background box."""
829
685
 
830
686
  op: Literal["text_overlay"] = "text_overlay"
831
- streamable: ClassVar[bool] = True
832
687
 
833
688
  text: str = Field(min_length=1, description=r"The string to display. Use \n for line breaks.")
834
689
  position: tuple[float, float] = Field(
@@ -1036,7 +891,6 @@ class ImageOverlay(_AnchoredOverlay):
1036
891
  """
1037
892
 
1038
893
  op: Literal["image_overlay"] = "image_overlay"
1039
- streamable: ClassVar[bool] = True
1040
894
  llm_exposed: ClassVar[bool] = False
1041
895
 
1042
896
  source: Path = Field(
@@ -1158,7 +1012,6 @@ class Shake(Effect):
1158
1012
  """
1159
1013
 
1160
1014
  op: Literal["shake"] = "shake"
1161
- streamable: ClassVar[bool] = True
1162
1015
 
1163
1016
  intensity_px: float = Field(
1164
1017
  gt=0,
@@ -1224,7 +1077,6 @@ class PunchIn(Effect):
1224
1077
  """
1225
1078
 
1226
1079
  op: Literal["punch_in"] = "punch_in"
1227
- streamable: ClassVar[bool] = True
1228
1080
 
1229
1081
  zoom_factor: float = Field(
1230
1082
  gt=1.0,
@@ -1287,7 +1139,6 @@ class Flash(Effect):
1287
1139
  """
1288
1140
 
1289
1141
  op: Literal["flash"] = "flash"
1290
- streamable: ClassVar[bool] = True
1291
1142
 
1292
1143
  color: tuple[int, int, int] = Field(
1293
1144
  (255, 255, 255),
@@ -1351,8 +1202,6 @@ class ChromaticAberration(Effect):
1351
1202
  """
1352
1203
 
1353
1204
  op: Literal["chromatic_aberration"] = "chromatic_aberration"
1354
- streamable: ClassVar[bool] = True
1355
- filter_needs_rgb24: ClassVar[bool] = True
1356
1205
 
1357
1206
  shift_px: int = Field(
1358
1207
  gt=0,
@@ -1410,52 +1259,6 @@ class ChromaticAberration(Effect):
1410
1259
  def process_frame(self, frame: np.ndarray, frame_index: int) -> np.ndarray:
1411
1260
  return self._aberrate(frame)
1412
1261
 
1413
- def _enable_suffix(self, ctx: FilterCtx) -> str:
1414
- """``:enable='between(t,START,STOP)'`` for a windowed effect, else ``""``.
1415
-
1416
- START defaults to 0.0 and STOP to the segment duration
1417
- (``ctx.frame_count / ctx.fps``); an unset window yields no suffix. The
1418
- clause is single-quoted, so its inner comma survives the runner's
1419
- ``",".join`` into the ``-vf`` chain.
1420
- """
1421
- if self.window is None:
1422
- return ""
1423
- total_seconds = ctx.frame_count / ctx.fps if ctx.fps > 0 and ctx.frame_count > 0 else 0.0
1424
- start_s = 0.0 if self.window.start is None else float(self.window.start)
1425
- stop_s = total_seconds if self.window.stop is None else float(self.window.stop)
1426
- return f":enable='between(t,{start_s:.6f},{stop_s:.6f})'"
1427
-
1428
- def to_ffmpeg_filter(self, ctx: FilterCtx) -> str | None:
1429
- """Compile the channel split to a single native ``-vf`` entry.
1430
-
1431
- ``horizontal``/``vertical`` become ``rgbashift`` (R by ``+shift_px``,
1432
- B by ``-shift_px`` along the axis, ``edge=smear`` == BORDER_REPLICATE).
1433
- ``radial`` becomes one ``geq`` sampling R/B from a scale about the frame
1434
- center (matches bilinear ``cv2.remap`` to ~4/255). Green is untouched.
1435
- Every inner comma sits inside single-quoted ``geq`` expressions or the
1436
- ``enable`` clause, so the entry is comma-join-safe.
1437
- """
1438
- enable = self._enable_suffix(ctx)
1439
- if self.mode == "horizontal":
1440
- return f"rgbashift=rh={self.shift_px}:bh={-self.shift_px}:edge=smear{enable}"
1441
- if self.mode == "vertical":
1442
- return f"rgbashift=rv={self.shift_px}:bv={-self.shift_px}:edge=smear{enable}"
1443
- # radial: reproduce the cv2.remap scale about the center via geq.
1444
- cx, cy = ctx.width / 2.0, ctx.height / 2.0
1445
- max_d = float(max(ctx.width, ctx.height))
1446
- scale_r = 1.0 - self.shift_px / max_d
1447
- scale_b = 1.0 + self.shift_px / max_d
1448
- rx = f"((X-{cx:.6f})*{scale_r:.9f}+{cx:.6f})"
1449
- ry = f"((Y-{cy:.6f})*{scale_r:.9f}+{cy:.6f})"
1450
- bx = f"((X-{cx:.6f})*{scale_b:.9f}+{cx:.6f})"
1451
- by = f"((Y-{cy:.6f})*{scale_b:.9f}+{cy:.6f})"
1452
- return f"geq=r='r({rx}\\,{ry})':b='b({bx}\\,{by})'{enable}"
1453
-
1454
- @property
1455
- def compiles_to_filter(self) -> bool:
1456
- """Every mode compiles to a single, comma-join-safe ``-vf`` entry."""
1457
- return True
1458
-
1459
1262
 
1460
1263
  class Glitch(Effect):
1461
1264
  """Random horizontal slice displacement + channel offsets for a digital-corruption look.
@@ -1466,7 +1269,6 @@ class Glitch(Effect):
1466
1269
  """
1467
1270
 
1468
1271
  op: Literal["glitch"] = "glitch"
1469
- streamable: ClassVar[bool] = True
1470
1272
 
1471
1273
  intensity: float = Field(
1472
1274
  0.5,
@@ -1528,7 +1330,6 @@ class FilmGrain(Effect):
1528
1330
  """
1529
1331
 
1530
1332
  op: Literal["film_grain"] = "film_grain"
1531
- streamable: ClassVar[bool] = True
1532
1333
 
1533
1334
  intensity: float = Field(
1534
1335
  0.1,
@@ -1558,31 +1359,6 @@ class FilmGrain(Effect):
1558
1359
  def process_frame(self, frame: np.ndarray, frame_index: int) -> np.ndarray:
1559
1360
  return self._grain_frame(frame, frame_index)
1560
1361
 
1561
- @property
1562
- def compiles_to_filter(self) -> bool:
1563
- # Only unwindowed monochrome (luma-only) grain compiles to a single
1564
- # comma-join-safe `noise` entry. The per-channel variant and any windowed
1565
- # grain stay on the numpy frame path.
1566
- return self.monochrome and self.window is None
1567
-
1568
- def to_ffmpeg_filter(self, ctx: FilterCtx) -> str | None:
1569
- """Compile luma-only grain to ffmpeg's temporal ``noise`` filter.
1570
-
1571
- A statistical (NOT pixel-identical) twin of :meth:`process_frame`:
1572
- ffmpeg's RNG differs from numpy's, so the grain is a different but
1573
- distributionally-matched draw -- equivalent in look for a random grain.
1574
- ``c0_flags=t`` adds fresh temporal noise to component 0 (Y in the
1575
- pipeline's yuv420p) each frame; ``c0_strength`` is ffmpeg's 0-100 scale,
1576
- which the numpy std (``intensity*255``) maps onto by the empirically-fit
1577
- ``strength = intensity * 255 / 0.57`` (clamped to [1, 100]) so the
1578
- injected per-pixel std matches in the real yuv pipeline. yuv-safe.
1579
- """
1580
- if not self.monochrome:
1581
- return None
1582
- strength = int(round(self.intensity * 255.0 / 0.57))
1583
- strength = max(1, min(100, strength))
1584
- return f"noise=c0_seed={self.seed}:c0_strength={strength}:c0_flags=t"
1585
-
1586
1362
 
1587
1363
  class Sharpen(Effect):
1588
1364
  """Unsharp-mask sharpening: blur the frame and subtract from itself with weight.
@@ -1592,7 +1368,6 @@ class Sharpen(Effect):
1592
1368
  """
1593
1369
 
1594
1370
  op: Literal["sharpen"] = "sharpen"
1595
- streamable: ClassVar[bool] = True
1596
1371
 
1597
1372
  amount: float = Field(
1598
1373
  1.0,
@@ -1625,29 +1400,6 @@ class Sharpen(Effect):
1625
1400
  def process_frame(self, frame: np.ndarray, frame_index: int) -> np.ndarray:
1626
1401
  return self._sharpen_frame(frame)
1627
1402
 
1628
- @property
1629
- def compiles_to_filter(self) -> bool:
1630
- # The whole-frame, windowless case maps cleanly to a single comma-join
1631
- # safe `unsharp` -vf entry. A `window` would need an enable= gate that
1632
- # `unsharp` does not support, so windowed sharpening stays frame-only;
1633
- # `unsharp` also caps the combined matrix, so kernel_size > 13 stays too.
1634
- return self.window is None and self.kernel_size <= 13
1635
-
1636
- def to_ffmpeg_filter(self, ctx: FilterCtx) -> str | None:
1637
- """Native unsharp-mask sharpening via ffmpeg's ``unsharp`` filter.
1638
-
1639
- The numpy twin blends ``out = in + amount*(in - gaussian_blur(in))``.
1640
- ``unsharp=lx:ly:la`` is the same unsharp-mask form with the same
1641
- ``amount`` (``la``) weighting; its kernel is a box rather than a Gaussian,
1642
- so the result is a faithful visual twin (re-baseline-to-ffmpeg), not
1643
- bit-identical. ``amount == 0`` is a no-op (``None`` -> the runner keeps
1644
- the frame). ``unsharp`` is yuv-safe, so no ``format=rgb24`` is needed.
1645
- """
1646
- if self.amount == 0:
1647
- return None
1648
- k = self.kernel_size
1649
- return f"unsharp={k}:{k}:{self.amount:.6f}:{k}:{k}:{self.amount:.6f}"
1650
-
1651
1403
 
1652
1404
  class Pixelate(Effect):
1653
1405
  """Mosaic blocks: downscale + nearest-neighbour upscale, optionally limited to a region.
@@ -1657,7 +1409,6 @@ class Pixelate(Effect):
1657
1409
  """
1658
1410
 
1659
1411
  op: Literal["pixelate"] = "pixelate"
1660
- streamable: ClassVar[bool] = True
1661
1412
 
1662
1413
  block_size: int = Field(
1663
1414
  gt=1,
@@ -1697,22 +1448,6 @@ class Pixelate(Effect):
1697
1448
  return self._pixelate_frame(frame, self._stream_region_px)
1698
1449
 
1699
1450
 
1700
- def _mirror_geq(coord: str, src_x: str, src_y: str) -> str:
1701
- """One comma-join-safe ``geq`` entry that reflects a half-frame.
1702
-
1703
- ``coord`` is the per-pixel predicate (e.g. ``gte(X,W/2)``); where it holds
1704
- the pixel samples its mirror ``(src_x, src_y)``, elsewhere it keeps
1705
- ``(X, Y)``. Per-channel (r/g/b) so geq runs in RGB. The function-call commas
1706
- live inside single-quoted plane expressions, so the whole entry survives
1707
- ``",".join`` into the ``-vf`` chain.
1708
- """
1709
-
1710
- def plane(channel: str) -> str:
1711
- return f"if({coord},{channel}({src_x},{src_y}),{channel}(X,Y))"
1712
-
1713
- return f"geq=r='{plane('r')}':g='{plane('g')}':b='{plane('b')}'"
1714
-
1715
-
1716
1451
  class MirrorFlip(Effect):
1717
1452
  """Flip frames or reflect one half onto the other.
1718
1453
 
@@ -1722,8 +1457,6 @@ class MirrorFlip(Effect):
1722
1457
  """
1723
1458
 
1724
1459
  op: Literal["mirror_flip"] = "mirror_flip"
1725
- streamable: ClassVar[bool] = True
1726
- filter_needs_rgb24: ClassVar[bool] = True
1727
1460
 
1728
1461
  mode: Literal[
1729
1462
  "horizontal",
@@ -1767,54 +1500,6 @@ class MirrorFlip(Effect):
1767
1500
  def process_frame(self, frame: np.ndarray, frame_index: int) -> np.ndarray:
1768
1501
  return self._flip_frame(frame)
1769
1502
 
1770
- def _enable_suffix(self, ctx: FilterCtx) -> str:
1771
- """``enable='between(t,START,STOP)'`` for the window, or '' when full-clip.
1772
-
1773
- START defaults to 0.0 and STOP to the segment duration
1774
- (``ctx.frame_count / ctx.fps``). Returns the bare ``key=value`` (no
1775
- leading separator); the caller joins it with the right separator.
1776
- """
1777
- win = self.window
1778
- if win is None:
1779
- return ""
1780
- stop = ctx.frame_count / ctx.fps if win.stop is None else float(win.stop)
1781
- start = 0.0 if win.start is None else float(win.start)
1782
- return f"enable='between(t,{start:.6f},{stop:.6f})'"
1783
-
1784
- def to_ffmpeg_filter(self, ctx: FilterCtx) -> str | None:
1785
- """Compile to one comma-join-safe ``-vf`` entry mirroring :meth:`process_frame`.
1786
-
1787
- ``horizontal`` / ``vertical`` map to native ``hflip`` / ``vflip``
1788
- (bit-exact). The ``mirror_*`` modes reflect one half onto the other via a
1789
- single per-channel ``geq`` expression: each output pixel samples its
1790
- mirror (``W-1-X`` / ``H-1-Y``) on the reflected half and itself on the
1791
- kept half, matching the numpy ``w//2`` / ``h//2`` split for both even and
1792
- odd dimensions. A window appends an ``enable=`` option (``=`` for the
1793
- option-less flips, ``:`` after the ``geq`` options).
1794
- """
1795
- if self.mode == "horizontal":
1796
- base, sep = "hflip", "="
1797
- elif self.mode == "vertical":
1798
- base, sep = "vflip", "="
1799
- else:
1800
- sep = ":"
1801
- if self.mode == "mirror_left":
1802
- coord, src_x, src_y = "gte(X,W/2)", "W-1-X", "Y"
1803
- elif self.mode == "mirror_right":
1804
- coord, src_x, src_y = "lt(X,W/2)", "W-1-X", "Y"
1805
- elif self.mode == "mirror_top":
1806
- coord, src_x, src_y = "gte(Y,H/2)", "X", "H-1-Y"
1807
- else: # mirror_bottom
1808
- coord, src_x, src_y = "lt(Y,H/2)", "X", "H-1-Y"
1809
- base = _mirror_geq(coord, src_x, src_y)
1810
-
1811
- enable = self._enable_suffix(ctx)
1812
- return f"{base}{sep}{enable}" if enable else base
1813
-
1814
- @property
1815
- def compiles_to_filter(self) -> bool:
1816
- return True
1817
-
1818
1503
 
1819
1504
  class Kaleidoscope(Effect):
1820
1505
  """N-way radial mirror around the frame center.
@@ -1825,8 +1510,6 @@ class Kaleidoscope(Effect):
1825
1510
  """
1826
1511
 
1827
1512
  op: Literal["kaleidoscope"] = "kaleidoscope"
1828
- streamable: ClassVar[bool] = True
1829
- filter_needs_rgb24: ClassVar[bool] = True
1830
1513
 
1831
1514
  segments: int = Field(
1832
1515
  6,
@@ -1875,58 +1558,3 @@ class Kaleidoscope(Effect):
1875
1558
 
1876
1559
  def process_frame(self, frame: np.ndarray, frame_index: int) -> np.ndarray:
1877
1560
  return self._kaleidoscope_frame(frame)
1878
-
1879
- def to_ffmpeg_filter(self, ctx: FilterCtx) -> str | None:
1880
- """Compile the radial mirror to a single per-pixel ``geq`` remap.
1881
-
1882
- Mirrors :meth:`_build_maps`/:meth:`process_frame`. For each output pixel
1883
- ``geq`` recovers polar coordinates about ``((W-1)/2, (H-1)/2)``, folds
1884
- the angle into one wedge (``2*pi/segments``) the same way the numpy path
1885
- does (``mod`` then reflect across the half-wedge), and samples with
1886
- ``interpolation=bilinear``. Out-of-frame sample coordinates are reflected
1887
- back in-bounds (registers 8/9) to reproduce ``cv2.BORDER_REFLECT``. One
1888
- ``-vf`` node, comma-join-safe.
1889
- """
1890
- if ctx.width <= 0 or ctx.height <= 0:
1891
- return None
1892
- width, height = ctx.width, ctx.height
1893
- cx = (width - 1) / 2.0
1894
- cy = (height - 1) / 2.0
1895
- wedge = 2.0 * math.pi / self.segments
1896
- half = wedge * 0.5
1897
- pw = 2 * width
1898
- ph = 2 * height
1899
- ao = self.angle_offset
1900
-
1901
- common = (
1902
- f"st(1,X-{cx:.8f});"
1903
- f"st(2,Y-{cy:.8f});"
1904
- f"st(3,hypot(ld(1),ld(2)));"
1905
- f"st(4,atan2(ld(2),ld(1))-{ao:.8f});"
1906
- f"st(5,mod(ld(4),{wedge:.10f}));"
1907
- f"st(5,if(lt(ld(5),0),ld(5)+{wedge:.10f},ld(5)));"
1908
- f"st(6,if(gt(ld(5),{half:.10f}),{wedge:.10f}-ld(5),ld(5)));"
1909
- f"st(7,ld(6)+{ao:.8f});"
1910
- f"st(8,mod({cx:.8f}+ld(3)*cos(ld(7)),{pw}));"
1911
- f"st(8,if(lt(ld(8),0),ld(8)+{pw},ld(8)));"
1912
- f"st(8,if(gte(ld(8),{width}),{pw - 1}-ld(8),ld(8)));"
1913
- f"st(9,mod({cy:.8f}+ld(3)*sin(ld(7)),{ph}));"
1914
- f"st(9,if(lt(ld(9),0),ld(9)+{ph},ld(9)));"
1915
- f"st(9,if(gte(ld(9),{height}),{ph - 1}-ld(9),ld(9)));"
1916
- )
1917
- expr = (
1918
- f"geq=interpolation=bilinear:"
1919
- f"r='{common}r(ld(8),ld(9))':"
1920
- f"g='{common}g(ld(8),ld(9))':"
1921
- f"b='{common}b(ld(8),ld(9))'"
1922
- )
1923
- if self.window is not None:
1924
- stop = ctx.frame_count / ctx.fps if ctx.fps > 0 and ctx.frame_count > 0 else 0.0
1925
- start_s = self.window.start if self.window.start is not None else 0.0
1926
- stop_s = self.window.stop if self.window.stop is not None else stop
1927
- expr += f":enable='between(t,{start_s:.6f},{stop_s:.6f})'"
1928
- return expr
1929
-
1930
- @property
1931
- def compiles_to_filter(self) -> bool:
1932
- return True
@@ -18,7 +18,6 @@ Subclass contract::
18
18
 
19
19
  op: Literal["resize"] = "resize"
20
20
  category: ClassVar[OpCategory] = OpCategory.TRANSFORM
21
- streamable: ClassVar[bool] = True
22
21
 
23
22
  width: int | None = Field(None, gt=0)
24
23
  height: int | None = Field(None, gt=0)
@@ -234,8 +233,8 @@ class Operation(BaseModel):
234
233
 
235
234
  Concrete subclasses MUST declare an ``op`` field with a single-value
236
235
  ``Literal[str]`` annotation; that value is the discriminator on the JSON
237
- wire and the registry key. Subclasses may override the ``category``,
238
- ``streamable``, and ``requires`` ClassVars.
236
+ wire and the registry key. Subclasses may override the ``category`` and
237
+ ``requires`` ClassVars.
239
238
 
240
239
  ``predict_metadata`` defaults to identity; ``to_ffmpeg_filter`` defaults to
241
240
  ``None`` (no filter compilation).
@@ -246,7 +245,6 @@ class Operation(BaseModel):
246
245
  op: str
247
246
 
248
247
  category: ClassVar[OpCategory] = OpCategory.SPECIAL
249
- streamable: ClassVar[bool] = False
250
248
  requires: ClassVar[tuple[str, ...]] = ()
251
249
  compiles_from_source: ClassVar[bool] = False
252
250
  """Whether the op's filter compile decodes the source itself (face_crop's
@@ -269,12 +267,11 @@ class Operation(BaseModel):
269
267
  """Whether this op is engine-internal and must NOT be a chain op.
270
268
 
271
269
  ``CutSeconds``/``CutFrames`` trim a segment, but trimming is the segment's own
272
- ``start``/``end`` mechanism -- the engine constructs them directly. They are
273
- non-streamable (no ffmpeg filter, no ``process_frame``), so to honor
274
- "every registered op is streamable" they set this flag and are kept OUT of
275
- the registry: they cannot appear in a plan's ``operations`` list or the LLM
270
+ ``start``/``end`` mechanism -- the engine constructs them directly. They have
271
+ no ffmpeg filter and no ``process_frame``, so this flag keeps them OUT of the
272
+ registry: they cannot appear in a plan's ``operations`` list or the LLM
276
273
  schema, while direct construction (``CutSeconds(start=..., end=...)``) still
277
- works. Default False (a normal, streamable chain op)."""
274
+ works. Default False (a normal chain op)."""
278
275
  time_fields: ClassVar[tuple[BoundedTimeField, ...]] = ()
279
276
  """Time-valued (seconds) fields :meth:`VideoEdit.repair` may clamp into range.
280
277
 
@@ -449,6 +446,19 @@ class Operation(BaseModel):
449
446
  """
450
447
  return None
451
448
 
449
+ def streams(self) -> bool:
450
+ """Whether this op streams in O(1) memory at its plan position.
451
+
452
+ Structural replacement for the former ``streamable`` ClassVar: a transform
453
+ streams iff it overrides :meth:`to_ffmpeg_filter`. :class:`Effect` widens
454
+ this (a frame effect streams via ``process_frame``; a filter effect via
455
+ ``compiles_to_filter``). The one case structure cannot express -- the
456
+ override exists but the filter compiles to ``None`` at this position -- is
457
+ caught at runtime by the ``STREAMING_UNSUPPORTED`` raise in
458
+ ``VideoEdit._compile_streaming_plans``.
459
+ """
460
+ return type(self).to_ffmpeg_filter is not Operation.to_ffmpeg_filter
461
+
452
462
 
453
463
  class Effect(Operation):
454
464
  """Operation that preserves shape and frame count, driven by per-frame streaming.
@@ -470,17 +480,6 @@ class Effect(Operation):
470
480
  category: ClassVar[OpCategory] = OpCategory.EFFECT
471
481
  audio_coupled: ClassVar[bool] = False
472
482
  """Whether the effect mutates audio alongside pixels (``afade``/``volume``)."""
473
- filter_needs_rgb24: ClassVar[bool] = False
474
- """Whether :meth:`to_ffmpeg_filter` reads RGB pixel values and so needs the
475
- decode chain converted to ``rgb24`` first.
476
-
477
- ``geq``'s ``r``/``g``/``b`` accessors and ``rgbashift`` read RGB channels;
478
- on the native yuv decode stream they read garbage. When a decode-stage
479
- effect sets this, the plan builder prepends ``format=rgb24`` to the segment's
480
- decode filter chain (encode-stage effects already receive rgb24 from the
481
- frame pipe). Default False -- geometry/overlay filters (``hflip``,
482
- ``subtitles=``) work in any pixel format. Mirrors the rgb24 frames the numpy
483
- ``process_frame`` twin operated on."""
484
483
 
485
484
  window: TimeRange | None = Field(
486
485
  None,
@@ -527,3 +526,14 @@ class Effect(Operation):
527
526
  ``frame_index`` is 0-based within this effect's active window.
528
527
  """
529
528
  raise NotImplementedError(f"{type(self).__name__} does not support streaming")
529
+
530
+ def streams(self) -> bool:
531
+ """An effect streams via per-frame Python (``process_frame``) or a filter.
532
+
533
+ Frame effects override :meth:`process_frame`; filter effects
534
+ (``add_subtitles``, ``vignette``, ...) instead set
535
+ :attr:`compiles_to_filter` and implement :meth:`to_ffmpeg_filter`.
536
+ ``add_subtitles`` streams *only* via the filter path (it does not override
537
+ ``process_frame``), so ``compiles_to_filter`` is consulted per-instance.
538
+ """
539
+ return type(self).process_frame is not Effect.process_frame or self.compiles_to_filter
@@ -110,11 +110,12 @@ def _classify_op_list(ops: Sequence[Operation], location_prefix: str) -> list[Op
110
110
  ``location_prefix`` is the plan path entries are stamped under
111
111
  (``segments[i].operations`` or ``post_operations``).
112
112
 
113
- ``streamable`` is the authoritative declaration (a flag-False transform
114
- never streams, even with a working ``to_ffmpeg_filter``); the one divergence
115
- the flag cannot express -- flag True but the filter compiles to ``None`` --
116
- is caught at runtime by the ``STREAMING_UNSUPPORTED`` raise in
117
- ``_compile_streaming_plans``. Purely structural: no disk access, no context.
113
+ A transform streams iff it overrides ``to_ffmpeg_filter`` and an effect iff it
114
+ overrides ``process_frame`` or sets ``compiles_to_filter`` (``op.streams()``);
115
+ the one divergence structure cannot express -- the override exists but the
116
+ filter compiles to ``None`` at this position -- is caught at runtime by the
117
+ ``STREAMING_UNSUPPORTED`` raise in ``_compile_streaming_plans``. Purely
118
+ structural: no disk access, no context.
118
119
  """
119
120
  entries: list[OpStreamability] = []
120
121
  seen_effect = False
@@ -123,7 +124,7 @@ def _classify_op_list(ops: Sequence[Operation], location_prefix: str) -> list[Op
123
124
  for j, op in enumerate(ops):
124
125
  location = f"{location_prefix}[{j}]"
125
126
  if isinstance(op, Effect):
126
- if op.streamable and op.requires and seen_duration_change:
127
+ if op.requires and seen_duration_change:
127
128
  # The builder rejects context-consuming ops behind a
128
129
  # duration-changing transform: segment-local context is
129
130
  # not re-mapped through the time warp yet.
@@ -140,7 +141,7 @@ def _classify_op_list(ops: Sequence[Operation], location_prefix: str) -> list[Op
140
141
  )
141
142
  )
142
143
  continue
143
- if op.streamable and op.compiles_to_filter:
144
+ if op.compiles_to_filter:
144
145
  # Filter-class effect (add_subtitles, vignette, ...): joins the
145
146
  # decode filter chain at this plan position -- or the encode
146
147
  # chain when frame effects precede it -- so it streams in plan
@@ -150,13 +151,16 @@ def _classify_op_list(ops: Sequence[Operation], location_prefix: str) -> list[Op
150
151
  if seen_effect:
151
152
  seen_encode_stage = True
152
153
  continue
153
- if not op.streamable:
154
+ if not op.streams():
155
+ # Defensive: a custom Effect that overrides neither process_frame
156
+ # nor to_ffmpeg_filter (no built-in op does). compiles_to_filter
157
+ # was handled above, so this is "no process_frame".
154
158
  entries.append(
155
159
  OpStreamability(
156
160
  location,
157
161
  op.op,
158
162
  StreamingClass.UNSTREAMABLE,
159
- reason="effect has no streaming implementation (streamable=False)",
163
+ reason="effect has no streaming implementation (no process_frame or to_ffmpeg_filter)",
160
164
  )
161
165
  )
162
166
  elif seen_encode_stage:
@@ -189,7 +193,7 @@ def _classify_op_list(ops: Sequence[Operation], location_prefix: str) -> list[Op
189
193
  ),
190
194
  )
191
195
  )
192
- elif op.requires and not op.streamable:
196
+ elif op.requires and not op.streams():
193
197
  entries.append(
194
198
  OpStreamability(
195
199
  location,
@@ -198,7 +202,7 @@ def _classify_op_list(ops: Sequence[Operation], location_prefix: str) -> list[Op
198
202
  reason=(f"transform requires runtime context {sorted(op.requires)} and has no streaming strategy"),
199
203
  )
200
204
  )
201
- elif op.streamable and op.compiles_from_source and (seen_effect or seen_encode_stage):
205
+ elif op.streams() and op.compiles_from_source and (seen_effect or seen_encode_stage):
202
206
  entries.append(
203
207
  OpStreamability(
204
208
  location,
@@ -210,7 +214,7 @@ def _classify_op_list(ops: Sequence[Operation], location_prefix: str) -> list[Op
210
214
  ),
211
215
  )
212
216
  )
213
- elif op.streamable:
217
+ elif op.streams():
214
218
  # Transforms compile to filters at any plan position: the decode
215
219
  # chain normally, the encode chain (after every process_frame) when
216
220
  # frame effects precede them.
@@ -269,22 +273,12 @@ class EffectScheduleEntry:
269
273
  re-based onto the segment-local timeline by the plan builder), forwarded
270
274
  as keyword arguments to ``streaming_init``. Empty for context-free
271
275
  effects.
272
-
273
- For a post-operation folded across a multi-segment plan, the effect's
274
- window lives on the assembled (concatenated) timeline: ``index_offset``
275
- is the number of window frames consumed by previous segments (so
276
- ``process_frame`` indices continue across the concat boundary) and
277
- ``total_effect_frames`` is the global window length ``streaming_init``
278
- must size envelopes to. Defaults describe the ordinary segment-local
279
- case.
280
276
  """
281
277
 
282
278
  effect: Effect
283
279
  start_frame: int
284
280
  end_frame: int # exclusive
285
281
  context: dict[str, Any] = field(default_factory=dict)
286
- index_offset: int = 0
287
- total_effect_frames: int | None = None
288
282
 
289
283
 
290
284
  @dataclass
@@ -751,9 +745,7 @@ def _stream_segment_framewise(
751
745
  # so a context-requiring effect with missing context fails before any
752
746
  # frame work on this segment.
753
747
  for entry in plan.effect_schedule:
754
- n_effect_frames = (
755
- entry.total_effect_frames if entry.total_effect_frames is not None else entry.end_frame - entry.start_frame
756
- )
748
+ n_effect_frames = entry.end_frame - entry.start_frame
757
749
  entry.effect.streaming_init(
758
750
  n_effect_frames, plan.output_fps, plan.output_width, plan.output_height, **entry.context
759
751
  )
@@ -794,7 +786,7 @@ def _stream_segment_framewise(
794
786
  # sized to the estimate stay in bounds.
795
787
  open_ended = entry.end_frame >= total_frames
796
788
  if entry.start_frame <= frame_count and (frame_count < entry.end_frame or open_ended):
797
- local_index = entry.index_offset + min(frame_count, entry.end_frame - 1) - entry.start_frame
789
+ local_index = min(frame_count, entry.end_frame - 1) - entry.start_frame
798
790
  frame = entry.effect.process_frame(frame, local_index)
799
791
 
800
792
  encoder.write_frame(frame)
@@ -107,7 +107,6 @@ class TranscriptionOverlay(Effect):
107
107
  """
108
108
 
109
109
  op: Literal["add_subtitles"] = "add_subtitles"
110
- streamable: ClassVar[bool] = True
111
110
  requires: ClassVar[tuple[str, ...]] = ("transcription",)
112
111
 
113
112
  # ---- primary, resolution-independent surface ----
@@ -146,7 +146,6 @@ class Resize(Operation):
146
146
 
147
147
  op: Literal["resize"] = "resize"
148
148
  category: ClassVar[OpCategory] = OpCategory.TRANSFORM
149
- streamable: ClassVar[bool] = True
150
149
 
151
150
  width: int | None = Field(None, gt=0, description="Target width in pixels, or None to maintain aspect ratio.")
152
151
  height: int | None = Field(None, gt=0, description="Target height in pixels, or None to maintain aspect ratio.")
@@ -187,7 +186,6 @@ class ResampleFPS(Operation):
187
186
 
188
187
  op: Literal["resample_fps"] = "resample_fps"
189
188
  category: ClassVar[OpCategory] = OpCategory.TRANSFORM
190
- streamable: ClassVar[bool] = True
191
189
 
192
190
  fps: float = Field(gt=0, description="Target frames per second.")
193
191
 
@@ -212,7 +210,6 @@ class Crop(Operation):
212
210
 
213
211
  op: Literal["crop"] = "crop"
214
212
  category: ClassVar[OpCategory] = OpCategory.TRANSFORM
215
- streamable: ClassVar[bool] = True
216
213
 
217
214
  width: int | float = Field(description="Crop width in pixels (int) or fraction in (0, 1] of source width.")
218
215
  height: int | float = Field(description="Crop height in pixels (int) or fraction in (0, 1] of source height.")
@@ -280,7 +277,6 @@ class SpeedChange(Operation):
280
277
 
281
278
  op: Literal["speed_change"] = "speed_change"
282
279
  category: ClassVar[OpCategory] = OpCategory.TRANSFORM
283
- streamable: ClassVar[bool] = True
284
280
  changes_duration: ClassVar[bool] = True
285
281
 
286
282
  speed: float = Field(gt=0, description="Playback speed multiplier. 2.0 = twice as fast, 0.5 = half speed.")
@@ -385,7 +381,6 @@ class FreezeFrame(Operation):
385
381
 
386
382
  op: Literal["freeze_frame"] = "freeze_frame"
387
383
  category: ClassVar[OpCategory] = OpCategory.TRANSFORM
388
- streamable: ClassVar[bool] = True
389
384
  changes_duration: ClassVar[bool] = True
390
385
  # `timestamp` indexes a frame, so it must be strictly < the clip duration;
391
386
  # repair clamps an out-of-range value to the last frame.
@@ -510,7 +505,6 @@ class SilenceRemoval(Operation):
510
505
 
511
506
  op: Literal["silence_removal"] = "silence_removal"
512
507
  category: ClassVar[OpCategory] = OpCategory.TRANSFORM
513
- streamable: ClassVar[bool] = True
514
508
  changes_duration: ClassVar[bool] = True
515
509
  requires: ClassVar[tuple[str, ...]] = ("transcription",)
516
510
 
@@ -1954,9 +1954,9 @@ class VideoEdit(BaseModel):
1954
1954
 
1955
1955
  The single admission point for :meth:`run_to_file`. Unstreamable shapes raise
1956
1956
  :class:`PlanValidationError` with the streamability report's
1957
- structured errors; a builder/report drift (e.g. a third-party
1958
- transform whose ``streamable`` flag does not match its
1959
- ``to_ffmpeg_filter``) raises with a generic ``STREAMING_UNSUPPORTED``.
1957
+ structured errors; a builder/report drift (e.g. an op whose
1958
+ ``to_ffmpeg_filter`` returns ``None`` at its plan position despite
1959
+ overriding it) raises with a generic ``STREAMING_UNSUPPORTED``.
1960
1960
  On raise, any compile-time temp files already created are deleted;
1961
1961
  on success the caller owns ``plan.owned_temp_files``.
1962
1962
  """
@@ -2073,7 +2073,6 @@ class VideoEdit(BaseModel):
2073
2073
  post_af_filters: list[str] = []
2074
2074
  audio_idx = 0
2075
2075
  duration_changed = False
2076
- decode_needs_rgb = False
2077
2076
 
2078
2077
  def compile_audio_twin(op: Operation, ctx: FilterCtx, encode_stage: bool) -> None:
2079
2078
  """Append the op's audio-domain filter at the same stage the video
@@ -2100,7 +2099,7 @@ class VideoEdit(BaseModel):
2100
2099
  try:
2101
2100
  for op in segment.operations:
2102
2101
  if isinstance(op, Effect):
2103
- if not op.streamable:
2102
+ if not op.streams():
2104
2103
  abandon()
2105
2104
  return None
2106
2105
  if op.requires and duration_changed:
@@ -2129,7 +2128,6 @@ class VideoEdit(BaseModel):
2129
2128
  post_vf_filters.append(filter_expr)
2130
2129
  else:
2131
2130
  vf_filters.append(filter_expr)
2132
- decode_needs_rgb = decode_needs_rgb or op.filter_needs_rgb24
2133
2131
  # Audio twin at the same stage (add_subtitles has
2134
2132
  # none today; kept coupled for extensibility).
2135
2133
  compile_audio_twin(op, ctx, encode_stage_effect)
@@ -2155,7 +2153,7 @@ class VideoEdit(BaseModel):
2155
2153
  effect_ctx = make_ctx()
2156
2154
  compile_audio_twin(op, effect_ctx, encode_stage=False)
2157
2155
  continue
2158
- if op.requires and (duration_changed or not op.streamable):
2156
+ if op.requires and (duration_changed or not op.streams()):
2159
2157
  # A context-requiring transform can stream only when its
2160
2158
  # filter compile can consume the context (silence_removal
2161
2159
  # does, via FilterCtx.context) -- but not after a
@@ -2165,15 +2163,13 @@ class VideoEdit(BaseModel):
2165
2163
  # streamability report.
2166
2164
  abandon()
2167
2165
  return None
2168
- # Non-effect transform: compile to ffmpeg filter if streamable.
2169
- # The `streamable` flag is the authoritative declaration -- checked
2170
- # first so the streamability report (which classifies by the flag)
2171
- # can never disagree with the builder in this direction: a
2172
- # flag-False transform is classified UNSTREAMABLE even if it has
2173
- # a working to_ffmpeg_filter. The remaining drift class (flag
2174
- # True, filter compiles to None) is caught by the
2175
- # STREAMING_UNSUPPORTED raise in _compile_streaming_plans.
2176
- if not op.streamable:
2166
+ # Non-effect transform: streams iff it compiles a filter. Checked
2167
+ # structurally (op.streams() == overrides to_ffmpeg_filter) so the
2168
+ # streamability report and the builder cannot disagree; the
2169
+ # remaining drift class (overrides to_ffmpeg_filter but it compiles
2170
+ # to None here) is caught by the STREAMING_UNSUPPORTED raise in
2171
+ # _compile_streaming_plans.
2172
+ if not op.streams():
2177
2173
  abandon()
2178
2174
  return None
2179
2175
  encode_stage = bool(effect_schedule or post_vf_filters)
@@ -2213,14 +2209,6 @@ class VideoEdit(BaseModel):
2213
2209
  abandon()
2214
2210
  raise
2215
2211
 
2216
- # A decode-stage filter-effect that reads RGB (geq r/g/b, rgbashift)
2217
- # must see rgb24, not the native yuv decode stream; prepend one
2218
- # conversion ahead of the whole decode chain (encode-stage effects
2219
- # already receive rgb24 from the frame pipe). Idempotent: only when an
2220
- # rgb-reading effect landed at decode and no conversion leads already.
2221
- if decode_needs_rgb and not (vf_filters and vf_filters[0] == "format=rgb24"):
2222
- vf_filters.insert(0, "format=rgb24")
2223
-
2224
2212
  pipe = pipe_meta or running
2225
2213
  # Final output duration after every transform (decode + encode stage),
2226
2214
  # used to pin the audio graph length to the video timeline. `running`
File without changes
File without changes
File without changes