videopython 0.40.0__tar.gz → 0.41.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.
- {videopython-0.40.0 → videopython-0.41.0}/PKG-INFO +1 -1
- {videopython-0.40.0 → videopython-0.41.0}/pyproject.toml +1 -1
- {videopython-0.40.0 → videopython-0.41.0}/src/videopython/base/__init__.py +0 -9
- {videopython-0.40.0 → videopython-0.41.0}/src/videopython/base/exceptions.py +0 -13
- {videopython-0.40.0 → videopython-0.41.0}/src/videopython/base/fonts/__init__.py +32 -1
- {videopython-0.40.0 → videopython-0.41.0}/src/videopython/editing/__init__.py +3 -0
- videopython-0.41.0/src/videopython/editing/_ass.py +254 -0
- {videopython-0.40.0 → videopython-0.41.0}/src/videopython/editing/operation.py +29 -2
- {videopython-0.40.0 → videopython-0.41.0}/src/videopython/editing/streaming.py +62 -4
- videopython-0.41.0/src/videopython/editing/transcription_overlay.py +397 -0
- {videopython-0.40.0 → videopython-0.41.0}/src/videopython/editing/video_edit.py +161 -100
- videopython-0.40.0/src/videopython/base/image_text.py +0 -1217
- videopython-0.40.0/src/videopython/editing/transcription_overlay.py +0 -591
- {videopython-0.40.0 → videopython-0.41.0}/.gitignore +0 -0
- {videopython-0.40.0 → videopython-0.41.0}/LICENSE +0 -0
- {videopython-0.40.0 → videopython-0.41.0}/README.md +0 -0
- {videopython-0.40.0 → videopython-0.41.0}/src/videopython/__init__.py +0 -0
- {videopython-0.40.0 → videopython-0.41.0}/src/videopython/ai/__init__.py +0 -0
- {videopython-0.40.0 → videopython-0.41.0}/src/videopython/ai/_device.py +0 -0
- {videopython-0.40.0 → videopython-0.41.0}/src/videopython/ai/dubbing/__init__.py +0 -0
- {videopython-0.40.0 → videopython-0.41.0}/src/videopython/ai/dubbing/config.py +0 -0
- {videopython-0.40.0 → videopython-0.41.0}/src/videopython/ai/dubbing/dubber.py +0 -0
- {videopython-0.40.0 → videopython-0.41.0}/src/videopython/ai/dubbing/expressiveness.py +0 -0
- {videopython-0.40.0 → videopython-0.41.0}/src/videopython/ai/dubbing/loudness.py +0 -0
- {videopython-0.40.0 → videopython-0.41.0}/src/videopython/ai/dubbing/models.py +0 -0
- {videopython-0.40.0 → videopython-0.41.0}/src/videopython/ai/dubbing/pipeline.py +0 -0
- {videopython-0.40.0 → videopython-0.41.0}/src/videopython/ai/dubbing/quality.py +0 -0
- {videopython-0.40.0 → videopython-0.41.0}/src/videopython/ai/dubbing/remux.py +0 -0
- {videopython-0.40.0 → videopython-0.41.0}/src/videopython/ai/dubbing/timing.py +0 -0
- {videopython-0.40.0 → videopython-0.41.0}/src/videopython/ai/dubbing/voice_sample.py +0 -0
- {videopython-0.40.0 → videopython-0.41.0}/src/videopython/ai/effects.py +0 -0
- {videopython-0.40.0 → videopython-0.41.0}/src/videopython/ai/generation/__init__.py +0 -0
- {videopython-0.40.0 → videopython-0.41.0}/src/videopython/ai/generation/audio.py +0 -0
- {videopython-0.40.0 → videopython-0.41.0}/src/videopython/ai/generation/image.py +0 -0
- {videopython-0.40.0 → videopython-0.41.0}/src/videopython/ai/generation/qwen3.py +0 -0
- {videopython-0.40.0 → videopython-0.41.0}/src/videopython/ai/generation/translation.py +0 -0
- {videopython-0.40.0 → videopython-0.41.0}/src/videopython/ai/generation/video.py +0 -0
- {videopython-0.40.0 → videopython-0.41.0}/src/videopython/ai/transforms.py +0 -0
- {videopython-0.40.0 → videopython-0.41.0}/src/videopython/ai/understanding/__init__.py +0 -0
- {videopython-0.40.0 → videopython-0.41.0}/src/videopython/ai/understanding/audio.py +0 -0
- {videopython-0.40.0 → videopython-0.41.0}/src/videopython/ai/understanding/faces.py +0 -0
- {videopython-0.40.0 → videopython-0.41.0}/src/videopython/ai/understanding/image.py +0 -0
- {videopython-0.40.0 → videopython-0.41.0}/src/videopython/ai/understanding/objects.py +0 -0
- {videopython-0.40.0 → videopython-0.41.0}/src/videopython/ai/understanding/separation.py +0 -0
- {videopython-0.40.0 → videopython-0.41.0}/src/videopython/ai/understanding/temporal.py +0 -0
- {videopython-0.40.0 → videopython-0.41.0}/src/videopython/ai/video_analysis/__init__.py +0 -0
- {videopython-0.40.0 → videopython-0.41.0}/src/videopython/ai/video_analysis/analyzer.py +0 -0
- {videopython-0.40.0 → videopython-0.41.0}/src/videopython/ai/video_analysis/models.py +0 -0
- {videopython-0.40.0 → videopython-0.41.0}/src/videopython/ai/video_analysis/sampling.py +0 -0
- {videopython-0.40.0 → videopython-0.41.0}/src/videopython/ai/video_analysis/stages.py +0 -0
- {videopython-0.40.0 → videopython-0.41.0}/src/videopython/audio/__init__.py +0 -0
- {videopython-0.40.0 → videopython-0.41.0}/src/videopython/audio/analysis.py +0 -0
- {videopython-0.40.0 → videopython-0.41.0}/src/videopython/audio/audio.py +0 -0
- {videopython-0.40.0 → videopython-0.41.0}/src/videopython/base/_dimensions.py +0 -0
- {videopython-0.40.0 → videopython-0.41.0}/src/videopython/base/_ffmpeg.py +0 -0
- {videopython-0.40.0 → videopython-0.41.0}/src/videopython/base/_video_io.py +0 -0
- {videopython-0.40.0 → videopython-0.41.0}/src/videopython/base/description.py +0 -0
- {videopython-0.40.0 → videopython-0.41.0}/src/videopython/base/draw_detections.py +0 -0
- {videopython-0.40.0 → videopython-0.41.0}/src/videopython/base/fonts/Anton-OFL.txt +0 -0
- {videopython-0.40.0 → videopython-0.41.0}/src/videopython/base/fonts/Anton-Regular.ttf +0 -0
- {videopython-0.40.0 → videopython-0.41.0}/src/videopython/base/fonts/BebasNeue-OFL.txt +0 -0
- {videopython-0.40.0 → videopython-0.41.0}/src/videopython/base/fonts/BebasNeue-Regular.ttf +0 -0
- {videopython-0.40.0 → videopython-0.41.0}/src/videopython/base/fonts/DejaVuSans.ttf +0 -0
- {videopython-0.40.0 → videopython-0.41.0}/src/videopython/base/fonts/LICENSE_DEJAVU +0 -0
- {videopython-0.40.0 → videopython-0.41.0}/src/videopython/base/fonts/Lato-Bold.ttf +0 -0
- {videopython-0.40.0 → videopython-0.41.0}/src/videopython/base/fonts/Lato-OFL.txt +0 -0
- {videopython-0.40.0 → videopython-0.41.0}/src/videopython/base/fonts/Poppins-Bold.ttf +0 -0
- {videopython-0.40.0 → videopython-0.41.0}/src/videopython/base/fonts/Poppins-OFL.txt +0 -0
- {videopython-0.40.0 → videopython-0.41.0}/src/videopython/base/transcription.py +0 -0
- {videopython-0.40.0 → videopython-0.41.0}/src/videopython/base/video.py +0 -0
- {videopython-0.40.0 → videopython-0.41.0}/src/videopython/editing/_easing.py +0 -0
- {videopython-0.40.0 → videopython-0.41.0}/src/videopython/editing/effects.py +0 -0
- {videopython-0.40.0 → videopython-0.41.0}/src/videopython/editing/transforms.py +0 -0
- {videopython-0.40.0 → videopython-0.41.0}/src/videopython/py.typed +0 -0
|
@@ -14,19 +14,16 @@ from .draw_detections import DetectionStyle, class_color, draw_detections
|
|
|
14
14
|
from .exceptions import (
|
|
15
15
|
AudioError,
|
|
16
16
|
AudioLoadError,
|
|
17
|
-
OutOfBoundsError,
|
|
18
17
|
PlanError,
|
|
19
18
|
PlanErrorCode,
|
|
20
19
|
PlanRepair,
|
|
21
20
|
PlanValidationError,
|
|
22
|
-
TextRenderError,
|
|
23
21
|
TransformError,
|
|
24
22
|
VideoError,
|
|
25
23
|
VideoLoadError,
|
|
26
24
|
VideoMetadataError,
|
|
27
25
|
VideoPythonError,
|
|
28
26
|
)
|
|
29
|
-
from .image_text import AnchorPoint, ImageText, TextAlign
|
|
30
27
|
from .transcription import Transcription, TranscriptionSegment, TranscriptionWord
|
|
31
28
|
from .video import FrameIterator, Video, VideoMetadata
|
|
32
29
|
|
|
@@ -43,17 +40,11 @@ __all__ = [
|
|
|
43
40
|
"AudioError",
|
|
44
41
|
"AudioLoadError",
|
|
45
42
|
"TransformError",
|
|
46
|
-
"TextRenderError",
|
|
47
|
-
"OutOfBoundsError",
|
|
48
43
|
# Structured plan validation / repair
|
|
49
44
|
"PlanError",
|
|
50
45
|
"PlanErrorCode",
|
|
51
46
|
"PlanValidationError",
|
|
52
47
|
"PlanRepair",
|
|
53
|
-
# Text rendering primitives
|
|
54
|
-
"ImageText",
|
|
55
|
-
"TextAlign",
|
|
56
|
-
"AnchorPoint",
|
|
57
48
|
# Detection overlay renderer (AI-free)
|
|
58
49
|
"draw_detections",
|
|
59
50
|
"DetectionStyle",
|
|
@@ -66,18 +66,6 @@ class TransformError(VideoPythonError):
|
|
|
66
66
|
pass
|
|
67
67
|
|
|
68
68
|
|
|
69
|
-
class TextRenderError(VideoPythonError):
|
|
70
|
-
"""Base exception for text rendering errors."""
|
|
71
|
-
|
|
72
|
-
pass
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
class OutOfBoundsError(TextRenderError):
|
|
76
|
-
"""Raised when text would be rendered outside image bounds."""
|
|
77
|
-
|
|
78
|
-
pass
|
|
79
|
-
|
|
80
|
-
|
|
81
69
|
class PlanErrorCode(str, Enum):
|
|
82
70
|
"""Machine-readable failure classes raised while validating a ``VideoEdit``.
|
|
83
71
|
|
|
@@ -103,7 +91,6 @@ class PlanErrorCode(str, Enum):
|
|
|
103
91
|
# Assembly / structural.
|
|
104
92
|
UNKNOWN_OP = "unknown_op"
|
|
105
93
|
CONCAT_MISMATCH = "concat_mismatch"
|
|
106
|
-
SUBTITLE_UNFITTABLE = "subtitle_unfittable"
|
|
107
94
|
POST_OP_REQUIRES_CONTEXT = "post_op_requires_context"
|
|
108
95
|
# Streaming strictness (opt-in via strict_streaming).
|
|
109
96
|
STREAMING_FALLBACK = "streaming_fallback"
|
|
@@ -12,10 +12,18 @@ unreadable font:
|
|
|
12
12
|
from __future__ import annotations
|
|
13
13
|
|
|
14
14
|
from importlib.resources import as_file, files
|
|
15
|
+
from pathlib import Path
|
|
15
16
|
|
|
16
17
|
from PIL import ImageFont
|
|
17
18
|
|
|
18
|
-
__all__ = [
|
|
19
|
+
__all__ = [
|
|
20
|
+
"BUNDLED_FONTS",
|
|
21
|
+
"BUNDLED_FONT_FAMILIES",
|
|
22
|
+
"DEFAULT_FONT_FILENAME",
|
|
23
|
+
"FONT_NAMES",
|
|
24
|
+
"bundled_fonts_dir",
|
|
25
|
+
"load_font",
|
|
26
|
+
]
|
|
19
27
|
|
|
20
28
|
DEFAULT_FONT_FILENAME = "DejaVuSans.ttf"
|
|
21
29
|
|
|
@@ -31,6 +39,29 @@ BUNDLED_FONTS: dict[str, str] = {
|
|
|
31
39
|
|
|
32
40
|
FONT_NAMES: list[str] = sorted(BUNDLED_FONTS)
|
|
33
41
|
|
|
42
|
+
# Registered NAME (or None for the default) -> (family name, is-bold-face),
|
|
43
|
+
# exactly as declared in each file's name table. Renderers that match fonts by
|
|
44
|
+
# family name -- libass via the ffmpeg ``subtitles=`` filter's ``fontsdir`` --
|
|
45
|
+
# need these, not the filenames. Verified against the bundled .ttf files
|
|
46
|
+
# (PIL ``ImageFont.getname()``); a test pins them.
|
|
47
|
+
BUNDLED_FONT_FAMILIES: dict[str | None, tuple[str, bool]] = {
|
|
48
|
+
None: ("DejaVu Sans", False),
|
|
49
|
+
"poppins-bold": ("Poppins", True),
|
|
50
|
+
"lato-bold": ("Lato", True),
|
|
51
|
+
"anton": ("Anton", False),
|
|
52
|
+
"bebas-neue": ("Bebas Neue", False),
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def bundled_fonts_dir() -> Path:
|
|
57
|
+
"""Filesystem directory holding the bundled .ttf files.
|
|
58
|
+
|
|
59
|
+
For ffmpeg/libass ``fontsdir`` wiring, which needs a real directory path.
|
|
60
|
+
Standard (non-zip) installs ship the package unpacked, so the resources
|
|
61
|
+
root is a plain directory.
|
|
62
|
+
"""
|
|
63
|
+
return Path(str(files(__package__)))
|
|
64
|
+
|
|
34
65
|
|
|
35
66
|
def _try_truetype(path: str, font_size: int) -> ImageFont.FreeTypeFont | None:
|
|
36
67
|
try:
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
from ._ass import AnchorPoint
|
|
1
2
|
from .effects import (
|
|
2
3
|
Blur,
|
|
3
4
|
ChromaticAberration,
|
|
@@ -83,6 +84,8 @@ __all__ = [
|
|
|
83
84
|
# Plan runner
|
|
84
85
|
"VideoEdit",
|
|
85
86
|
"SegmentConfig",
|
|
87
|
+
# Subtitle placement
|
|
88
|
+
"AnchorPoint",
|
|
86
89
|
# Streamability report
|
|
87
90
|
"StreamabilityReport",
|
|
88
91
|
"OpStreamability",
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
"""ASS (Advanced SubStation Alpha) generation for libass subtitle burn-in.
|
|
2
|
+
|
|
3
|
+
Compiles a segment-local, word-level :class:`Transcription` plus the resolved
|
|
4
|
+
``add_subtitles`` look into an ``.ass`` document that ffmpeg's ``subtitles=``
|
|
5
|
+
filter (libass) renders natively at decode time -- zero per-frame Python.
|
|
6
|
+
|
|
7
|
+
The word-level highlight (active word in the highlight color, enlarged by the
|
|
8
|
+
size multiplier) cannot be expressed with ASS karaoke ``\\k`` tags -- those are
|
|
9
|
+
two-state (upcoming vs spoken-and-active) while this look is three-state
|
|
10
|
+
(active word only). Instead each cue is emitted as a sequence of word-state
|
|
11
|
+
``Dialogue`` events tiling the cue's time range: one event per active-word
|
|
12
|
+
interval with inline ``\\1c``/``\\fscx``/``\\fscy`` overrides on that word, and
|
|
13
|
+
plain events for the gaps between words. Event boundaries are rounded to ASS's
|
|
14
|
+
centisecond precision *before* deriving the intervals, so adjacent events tile
|
|
15
|
+
exactly (no double-draw overlap, no blank gaps).
|
|
16
|
+
|
|
17
|
+
Placement uses ``{\\an..\\pos(x,y)}`` on every event -- the exact box-anchor
|
|
18
|
+
semantics of the Python renderer (a box anchored at a normalized position) --
|
|
19
|
+
rather than margin arithmetic; ``MarginL/R`` are still set so libass wraps at
|
|
20
|
+
the same ``box_width`` the Python renderer uses.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from dataclasses import dataclass
|
|
26
|
+
from enum import Enum
|
|
27
|
+
|
|
28
|
+
from videopython.base.transcription import Transcription
|
|
29
|
+
|
|
30
|
+
__all__ = ["AnchorPoint", "AssLook", "build_ass", "escape_filter_value"]
|
|
31
|
+
|
|
32
|
+
RGBColor = tuple[int, int, int]
|
|
33
|
+
RGBAColor = tuple[int, int, int, int]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class AnchorPoint(str, Enum):
|
|
37
|
+
"""Which point of the subtitle box sits at the configured position."""
|
|
38
|
+
|
|
39
|
+
TOP_LEFT = "top-left"
|
|
40
|
+
TOP_CENTER = "top-center"
|
|
41
|
+
TOP_RIGHT = "top-right"
|
|
42
|
+
CENTER_LEFT = "center-left"
|
|
43
|
+
CENTER = "center"
|
|
44
|
+
CENTER_RIGHT = "center-right"
|
|
45
|
+
BOTTOM_LEFT = "bottom-left"
|
|
46
|
+
BOTTOM_CENTER = "bottom-center"
|
|
47
|
+
BOTTOM_RIGHT = "bottom-right"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# AnchorPoint -> ASS \an numpad code (7-8-9 top, 4-5-6 middle, 1-2-3 bottom).
|
|
51
|
+
_ANCHOR_TO_AN: dict[AnchorPoint, int] = {
|
|
52
|
+
AnchorPoint.TOP_LEFT: 7,
|
|
53
|
+
AnchorPoint.TOP_CENTER: 8,
|
|
54
|
+
AnchorPoint.TOP_RIGHT: 9,
|
|
55
|
+
AnchorPoint.CENTER_LEFT: 4,
|
|
56
|
+
AnchorPoint.CENTER: 5,
|
|
57
|
+
AnchorPoint.CENTER_RIGHT: 6,
|
|
58
|
+
AnchorPoint.BOTTOM_LEFT: 1,
|
|
59
|
+
AnchorPoint.BOTTOM_CENTER: 2,
|
|
60
|
+
AnchorPoint.BOTTOM_RIGHT: 3,
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(frozen=True)
|
|
65
|
+
class AssLook:
|
|
66
|
+
"""The resolved ``add_subtitles`` look, expressed in ASS terms.
|
|
67
|
+
|
|
68
|
+
``font_family`` is the family name inside the font file (libass matches by
|
|
69
|
+
name table, not filename); ``bold`` selects the bold face. ``background``
|
|
70
|
+
of ``None`` means no box (BorderStyle 1); otherwise the RGBA box uses
|
|
71
|
+
libass's BorderStyle 4 (whole-event background, burn-in only) with
|
|
72
|
+
``background_padding`` as the box padding.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
font_family: str
|
|
76
|
+
bold: bool
|
|
77
|
+
font_px: int
|
|
78
|
+
text_color: RGBColor
|
|
79
|
+
highlight_color: RGBColor
|
|
80
|
+
outline_px: int
|
|
81
|
+
background: RGBAColor | None
|
|
82
|
+
background_padding: int
|
|
83
|
+
highlight_size_multiplier: float
|
|
84
|
+
position: tuple[float, float]
|
|
85
|
+
anchor: AnchorPoint
|
|
86
|
+
box_width: float
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _ass_time(seconds: float) -> str:
|
|
90
|
+
"""``H:MM:SS.cc`` -- ASS timestamps are centisecond-precision only."""
|
|
91
|
+
cs = max(0, round(seconds * 100))
|
|
92
|
+
hours, rem = divmod(cs, 360_000)
|
|
93
|
+
minutes, rem = divmod(rem, 6_000)
|
|
94
|
+
secs, centis = divmod(rem, 100)
|
|
95
|
+
return f"{hours}:{minutes:02d}:{secs:02d}.{centis:02d}"
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _ass_color(rgb: RGBColor, opacity: int = 255) -> str:
|
|
99
|
+
"""RGB + opacity (0-255, 255=opaque) -> ``&HAABBGGRR`` (ASS alpha: 00=opaque)."""
|
|
100
|
+
r, g, b = rgb
|
|
101
|
+
alpha = 255 - max(0, min(255, opacity))
|
|
102
|
+
return f"&H{alpha:02X}{b:02X}{g:02X}{r:02X}"
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _ass_color_tag(rgb: RGBColor) -> str:
|
|
106
|
+
"""RGB -> the inline override form ``&HBBGGRR&`` (no alpha, trailing ``&``)."""
|
|
107
|
+
r, g, b = rgb
|
|
108
|
+
return f"&H{b:02X}{g:02X}{r:02X}&"
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _escape_text(text: str) -> str:
|
|
112
|
+
"""Neutralize ASS markup in transcribed text.
|
|
113
|
+
|
|
114
|
+
``{``/``}`` delimit override blocks and ``\\`` introduces escapes/breaks;
|
|
115
|
+
ASS has no escape sequence for them, so they are substituted.
|
|
116
|
+
"""
|
|
117
|
+
return text.replace("\\", "/").replace("{", "(").replace("}", ")").replace("\n", " ")
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def escape_filter_value(value: str) -> str:
|
|
121
|
+
"""Quote a string for use as an ffmpeg filter option value.
|
|
122
|
+
|
|
123
|
+
Escapes the option-value level metacharacters (``\\``, ``'``, ``:``) and
|
|
124
|
+
wraps the result in single quotes so the filtergraph-level separators
|
|
125
|
+
(``,``, ``;``, ``[``, ``]``) pass through untouched.
|
|
126
|
+
"""
|
|
127
|
+
escaped = value.replace("\\", "\\\\").replace("'", "\\'").replace(":", "\\:")
|
|
128
|
+
return f"'{escaped}'"
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _word_state_intervals(
|
|
132
|
+
start: float, end: float, word_spans: list[tuple[float, float]]
|
|
133
|
+
) -> list[tuple[int, int, int | None]]:
|
|
134
|
+
"""Tile ``[start, end)`` into centisecond intervals keyed by active word.
|
|
135
|
+
|
|
136
|
+
Returns ``(start_cs, end_cs, highlight_index)`` tuples; ``None`` means no
|
|
137
|
+
word is active (the gap before/between/after words). Boundaries are
|
|
138
|
+
rounded to centiseconds first and durations derived from the rounded
|
|
139
|
+
boundaries, so rounding error cannot accumulate into overlaps or gaps.
|
|
140
|
+
Zero-length intervals are dropped.
|
|
141
|
+
"""
|
|
142
|
+
cue_start = round(start * 100)
|
|
143
|
+
cue_end = round(end * 100)
|
|
144
|
+
intervals: list[tuple[int, int, int | None]] = []
|
|
145
|
+
cursor = cue_start
|
|
146
|
+
for i, (w_start, w_end) in enumerate(word_spans):
|
|
147
|
+
if cursor >= cue_end:
|
|
148
|
+
break
|
|
149
|
+
ws = min(cue_end, max(cursor, round(w_start * 100)))
|
|
150
|
+
we = min(cue_end, round(w_end * 100))
|
|
151
|
+
if ws > cursor:
|
|
152
|
+
intervals.append((cursor, ws, None))
|
|
153
|
+
cursor = ws
|
|
154
|
+
if we > cursor:
|
|
155
|
+
intervals.append((cursor, we, i))
|
|
156
|
+
cursor = we
|
|
157
|
+
if cue_end > cursor:
|
|
158
|
+
intervals.append((cursor, cue_end, None))
|
|
159
|
+
return intervals
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def build_ass(
|
|
163
|
+
transcription: Transcription,
|
|
164
|
+
*,
|
|
165
|
+
width: int,
|
|
166
|
+
height: int,
|
|
167
|
+
look: AssLook,
|
|
168
|
+
window: tuple[float | None, float | None] | None = None,
|
|
169
|
+
) -> str:
|
|
170
|
+
"""Compile a segment-local transcription into a complete ASS document.
|
|
171
|
+
|
|
172
|
+
``transcription`` must already be on the timeline of the frames the
|
|
173
|
+
``subtitles=`` filter will see (the plan builder re-bases context onto the
|
|
174
|
+
cut segment, and the ``-ss``-before-``-i`` decode resets PTS to zero, so
|
|
175
|
+
segment-local is exactly right). ``window`` clips events to the effect's
|
|
176
|
+
active range -- the ``subtitles`` filter has no timeline (``enable=``)
|
|
177
|
+
support, so windowing happens here, at compile time.
|
|
178
|
+
|
|
179
|
+
``PlayResX/Y`` are set to the frame size entering the filter, so one
|
|
180
|
+
script pixel is one video pixel and ``font_px`` carries over 1:1.
|
|
181
|
+
"""
|
|
182
|
+
win_start = window[0] if window is not None else None
|
|
183
|
+
win_stop = window[1] if window is not None else None
|
|
184
|
+
|
|
185
|
+
primary = _ass_color(look.text_color)
|
|
186
|
+
outline_colour = _ass_color((0, 0, 0))
|
|
187
|
+
if look.background is not None:
|
|
188
|
+
border_style = 4 # libass extension: whole-event background box (burn-in only)
|
|
189
|
+
back_colour = _ass_color(look.background[:3], look.background[3])
|
|
190
|
+
shadow = look.background_padding # BorderStyle 4 reads box padding from Shadow
|
|
191
|
+
else:
|
|
192
|
+
border_style = 1
|
|
193
|
+
back_colour = _ass_color((0, 0, 0))
|
|
194
|
+
shadow = 0
|
|
195
|
+
|
|
196
|
+
an = _ANCHOR_TO_AN[look.anchor]
|
|
197
|
+
pos_x = look.position[0] * width
|
|
198
|
+
pos_y = look.position[1] * height
|
|
199
|
+
# Margins do not place positioned events, but they still define the wrap
|
|
200
|
+
# width (PlayResX - MarginL - MarginR), which is how box_width carries over.
|
|
201
|
+
margin_h = max(0, round((1.0 - look.box_width) / 2.0 * width))
|
|
202
|
+
|
|
203
|
+
lines = [
|
|
204
|
+
"[Script Info]",
|
|
205
|
+
"ScriptType: v4.00+",
|
|
206
|
+
f"PlayResX: {width}",
|
|
207
|
+
f"PlayResY: {height}",
|
|
208
|
+
"ScaledBorderAndShadow: yes",
|
|
209
|
+
"WrapStyle: 0",
|
|
210
|
+
"",
|
|
211
|
+
"[V4+ Styles]",
|
|
212
|
+
(
|
|
213
|
+
"Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, "
|
|
214
|
+
"Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, "
|
|
215
|
+
"Shadow, Alignment, MarginL, MarginR, MarginV, Encoding"
|
|
216
|
+
),
|
|
217
|
+
(
|
|
218
|
+
f"Style: Default,{look.font_family},{look.font_px},{primary},{primary},{outline_colour},"
|
|
219
|
+
f"{back_colour},{-1 if look.bold else 0},0,0,0,100,100,0,0,{border_style},{look.outline_px},"
|
|
220
|
+
f"{shadow},{an},{margin_h},{margin_h},0,1"
|
|
221
|
+
),
|
|
222
|
+
"",
|
|
223
|
+
"[Events]",
|
|
224
|
+
"Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text",
|
|
225
|
+
]
|
|
226
|
+
|
|
227
|
+
scale = round(look.highlight_size_multiplier * 100)
|
|
228
|
+
highlight = _ass_color_tag(look.highlight_color)
|
|
229
|
+
placement = f"\\an{an}\\pos({pos_x:.1f},{pos_y:.1f})"
|
|
230
|
+
|
|
231
|
+
for segment in transcription.segments:
|
|
232
|
+
words = [_escape_text(w.word) for w in segment.words]
|
|
233
|
+
if not any(w.strip() for w in words):
|
|
234
|
+
continue
|
|
235
|
+
start, end = segment.start, segment.end
|
|
236
|
+
if win_start is not None:
|
|
237
|
+
start = max(start, win_start)
|
|
238
|
+
if win_stop is not None:
|
|
239
|
+
end = min(end, win_stop)
|
|
240
|
+
if end <= start:
|
|
241
|
+
continue
|
|
242
|
+
spans = [(w.start, w.end) for w in segment.words]
|
|
243
|
+
for start_cs, end_cs, hl in _word_state_intervals(start, end, spans):
|
|
244
|
+
if hl is None:
|
|
245
|
+
text = " ".join(words)
|
|
246
|
+
else:
|
|
247
|
+
marked = f"{{\\1c{highlight}\\fscx{scale}\\fscy{scale}}}{words[hl]}{{\\r}}"
|
|
248
|
+
text = " ".join([*words[:hl], marked, *words[hl + 1 :]])
|
|
249
|
+
lines.append(
|
|
250
|
+
f"Dialogue: 0,{_ass_time(start_cs / 100)},{_ass_time(end_cs / 100)},Default,,0,0,0,,"
|
|
251
|
+
f"{{{placement}}}{text}"
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
return "\n".join(lines) + "\n"
|
|
@@ -31,8 +31,9 @@ Subclass contract::
|
|
|
31
31
|
from __future__ import annotations
|
|
32
32
|
|
|
33
33
|
import copy
|
|
34
|
-
from dataclasses import dataclass
|
|
34
|
+
from dataclasses import dataclass, field
|
|
35
35
|
from enum import Enum
|
|
36
|
+
from pathlib import Path
|
|
36
37
|
from typing import TYPE_CHECKING, Annotated, Any, ClassVar, Literal, NamedTuple, Union, get_args, get_origin
|
|
37
38
|
|
|
38
39
|
import numpy as np
|
|
@@ -99,11 +100,23 @@ class BoundedTimeField(NamedTuple):
|
|
|
99
100
|
|
|
100
101
|
@dataclass(frozen=True)
|
|
101
102
|
class FilterCtx:
|
|
102
|
-
"""Current pipeline state (post-prior-ops) when compiling to ffmpeg.
|
|
103
|
+
"""Current pipeline state (post-prior-ops) when compiling to ffmpeg.
|
|
104
|
+
|
|
105
|
+
``context`` carries the resolved, segment-local runtime context (the same
|
|
106
|
+
re-based values ``streaming_init`` receives) so a context-consuming op can
|
|
107
|
+
compile itself into the filter chain (e.g. ``add_subtitles`` consuming the
|
|
108
|
+
transcription to write an ``.ass`` file). Empty when no context applies.
|
|
109
|
+
|
|
110
|
+
``owned_files`` collects temp files a compilation creates (the ``.ass``
|
|
111
|
+
file a ``subtitles=`` entry references); the plan runner deletes them once
|
|
112
|
+
streaming finishes or the plan is abandoned.
|
|
113
|
+
"""
|
|
103
114
|
|
|
104
115
|
width: int
|
|
105
116
|
height: int
|
|
106
117
|
fps: float
|
|
118
|
+
context: dict[str, Any] = field(default_factory=dict)
|
|
119
|
+
owned_files: list[Path] = field(default_factory=list)
|
|
107
120
|
|
|
108
121
|
|
|
109
122
|
LLM_HIDDEN_KEY = "llm_hidden"
|
|
@@ -391,6 +404,20 @@ class Effect(Operation):
|
|
|
391
404
|
description="Time window for the effect in seconds. Omit to apply across the full duration.",
|
|
392
405
|
)
|
|
393
406
|
|
|
407
|
+
@property
|
|
408
|
+
def compiles_to_filter(self) -> bool:
|
|
409
|
+
"""Whether this effect joins the decode filter chain instead of scheduling per-frame Python.
|
|
410
|
+
|
|
411
|
+
When True, the streaming plan builder calls :meth:`to_ffmpeg_filter`
|
|
412
|
+
(with the segment's resolved context on the :class:`FilterCtx`) and, if
|
|
413
|
+
it compiles, appends the result to the vf chain at this op's plan
|
|
414
|
+
position -- the Filter class of the streaming contract. Instance-level
|
|
415
|
+
rather than a ClassVar because it may depend on field values (e.g.
|
|
416
|
+
``add_subtitles``'s ``renderer``). False by default: effects normally
|
|
417
|
+
stream via ``streaming_init``/``process_frame``.
|
|
418
|
+
"""
|
|
419
|
+
return False
|
|
420
|
+
|
|
394
421
|
def apply(self, video: Video, **context: Any) -> Video:
|
|
395
422
|
from videopython.base.video import Video as _Video
|
|
396
423
|
|
|
@@ -122,12 +122,22 @@ def analyze_streamability(
|
|
|
122
122
|
entries: list[OpStreamability] = []
|
|
123
123
|
for i, ops in enumerate(segment_operations):
|
|
124
124
|
seen_effect = False
|
|
125
|
+
seen_encode_filter = False
|
|
125
126
|
for j, op in enumerate(ops):
|
|
126
127
|
location = f"segments[{i}].operations[{j}]"
|
|
127
128
|
if isinstance(op, Effect):
|
|
128
|
-
if op.streamable:
|
|
129
|
-
|
|
130
|
-
|
|
129
|
+
if op.streamable and op.compiles_to_filter:
|
|
130
|
+
# Filter-class effect (add_subtitles): joins the decode
|
|
131
|
+
# filter chain at this plan position -- or the encode
|
|
132
|
+
# chain when frame effects precede it -- so it streams in
|
|
133
|
+
# plan order either way and does not block later
|
|
134
|
+
# transforms the way a scheduled frame effect does. The
|
|
135
|
+
# builder mirrors this exactly.
|
|
136
|
+
entries.append(OpStreamability(location, op.op, StreamingClass.FILTER))
|
|
137
|
+
if seen_effect:
|
|
138
|
+
seen_encode_filter = True
|
|
139
|
+
continue
|
|
140
|
+
if not op.streamable:
|
|
131
141
|
entries.append(
|
|
132
142
|
OpStreamability(
|
|
133
143
|
location,
|
|
@@ -136,6 +146,21 @@ def analyze_streamability(
|
|
|
136
146
|
reason="effect has no streaming implementation (streamable=False)",
|
|
137
147
|
)
|
|
138
148
|
)
|
|
149
|
+
elif seen_encode_filter:
|
|
150
|
+
entries.append(
|
|
151
|
+
OpStreamability(
|
|
152
|
+
location,
|
|
153
|
+
op.op,
|
|
154
|
+
StreamingClass.EAGER,
|
|
155
|
+
reason=(
|
|
156
|
+
"frame effect follows burned-in subtitles (an encode-stage filter) in "
|
|
157
|
+
"plan order; the filter runs after every frame effect, so plan order "
|
|
158
|
+
"cannot be preserved -- move the effect before the subtitles to stream"
|
|
159
|
+
),
|
|
160
|
+
)
|
|
161
|
+
)
|
|
162
|
+
else:
|
|
163
|
+
entries.append(OpStreamability(location, op.op, StreamingClass.FRAME_EFFECT))
|
|
139
164
|
seen_effect = True
|
|
140
165
|
elif op.requires:
|
|
141
166
|
entries.append(
|
|
@@ -198,6 +223,18 @@ def analyze_streamability(
|
|
|
198
223
|
),
|
|
199
224
|
)
|
|
200
225
|
)
|
|
226
|
+
elif isinstance(op, Effect) and op.streamable and op.compiles_to_filter:
|
|
227
|
+
entries.append(
|
|
228
|
+
OpStreamability(
|
|
229
|
+
location,
|
|
230
|
+
op.op,
|
|
231
|
+
StreamingClass.EAGER,
|
|
232
|
+
reason=(
|
|
233
|
+
"filter-class post-operations cannot fold into the segment schedule -- "
|
|
234
|
+
"move the op into the segment to stream"
|
|
235
|
+
),
|
|
236
|
+
)
|
|
237
|
+
)
|
|
201
238
|
elif isinstance(op, Effect) and op.streamable:
|
|
202
239
|
entries.append(OpStreamability(location, op.op, StreamingClass.FRAME_EFFECT))
|
|
203
240
|
elif isinstance(op, Effect):
|
|
@@ -240,7 +277,17 @@ class EffectScheduleEntry:
|
|
|
240
277
|
|
|
241
278
|
@dataclass
|
|
242
279
|
class StreamingSegmentPlan:
|
|
243
|
-
"""Describes how to stream-process one video segment.
|
|
280
|
+
"""Describes how to stream-process one video segment.
|
|
281
|
+
|
|
282
|
+
``vf_filters`` run at decode time, before every scheduled effect;
|
|
283
|
+
``post_vf_filters`` run at encode time (``FrameEncoder``'s ``-vf``),
|
|
284
|
+
after every scheduled effect -- where a filter-class op that follows
|
|
285
|
+
frame effects in plan order lands (e.g. ``[fade, add_subtitles]``).
|
|
286
|
+
|
|
287
|
+
``owned_temp_files`` are compile-time artifacts referenced by the filter
|
|
288
|
+
chains (e.g. the ``.ass`` file behind a ``subtitles=`` entry); the runner
|
|
289
|
+
deletes them once streaming finishes or the plan is abandoned.
|
|
290
|
+
"""
|
|
244
291
|
|
|
245
292
|
source_path: Path
|
|
246
293
|
start_second: float
|
|
@@ -250,6 +297,8 @@ class StreamingSegmentPlan:
|
|
|
250
297
|
output_height: int
|
|
251
298
|
vf_filters: list[str] = field(default_factory=list)
|
|
252
299
|
effect_schedule: list[EffectScheduleEntry] = field(default_factory=list)
|
|
300
|
+
post_vf_filters: list[str] = field(default_factory=list)
|
|
301
|
+
owned_temp_files: list[Path] = field(default_factory=list)
|
|
253
302
|
|
|
254
303
|
|
|
255
304
|
class FrameEncoder:
|
|
@@ -268,6 +317,7 @@ class FrameEncoder:
|
|
|
268
317
|
format: str = "mp4",
|
|
269
318
|
preset: str = "medium",
|
|
270
319
|
crf: int = 23,
|
|
320
|
+
vf_filters: list[str] | None = None,
|
|
271
321
|
):
|
|
272
322
|
self._output_path = output_path
|
|
273
323
|
self._width = width
|
|
@@ -277,6 +327,7 @@ class FrameEncoder:
|
|
|
277
327
|
self._format = format
|
|
278
328
|
self._preset = preset
|
|
279
329
|
self._crf = crf
|
|
330
|
+
self._vf_filters = vf_filters or []
|
|
280
331
|
self._stack: ExitStack | None = None
|
|
281
332
|
self._process: subprocess.Popen[bytes] | None = None
|
|
282
333
|
|
|
@@ -302,6 +353,12 @@ class FrameEncoder:
|
|
|
302
353
|
if self._audio_path is not None:
|
|
303
354
|
cmd.extend(["-i", str(self._audio_path)])
|
|
304
355
|
|
|
356
|
+
if self._vf_filters:
|
|
357
|
+
# Encode-stage filters: run after every per-frame effect (frames
|
|
358
|
+
# arrive via stdin already processed). The rawvideo pipe's PTS
|
|
359
|
+
# start at zero, so time-based filters (subtitles=) see the same
|
|
360
|
+
# segment-local timeline the decode side uses.
|
|
361
|
+
cmd.extend(["-vf", ",".join(self._vf_filters)])
|
|
305
362
|
cmd.extend(
|
|
306
363
|
[
|
|
307
364
|
"-c:v",
|
|
@@ -424,6 +481,7 @@ def stream_segment(
|
|
|
424
481
|
format=format,
|
|
425
482
|
preset=preset,
|
|
426
483
|
crf=crf,
|
|
484
|
+
vf_filters=plan.post_vf_filters,
|
|
427
485
|
) as encoder:
|
|
428
486
|
logger.info("Streaming frames...")
|
|
429
487
|
frame_count = 0
|