videopython 0.39.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.39.0 → videopython-0.41.0}/PKG-INFO +1 -1
- {videopython-0.39.0 → videopython-0.41.0}/pyproject.toml +1 -1
- {videopython-0.39.0 → videopython-0.41.0}/src/videopython/base/__init__.py +0 -9
- {videopython-0.39.0 → videopython-0.41.0}/src/videopython/base/exceptions.py +6 -13
- {videopython-0.39.0 → videopython-0.41.0}/src/videopython/base/fonts/__init__.py +32 -1
- {videopython-0.39.0 → videopython-0.41.0}/src/videopython/editing/__init__.py +8 -0
- videopython-0.41.0/src/videopython/editing/_ass.py +254 -0
- {videopython-0.39.0 → videopython-0.41.0}/src/videopython/editing/operation.py +29 -2
- videopython-0.41.0/src/videopython/editing/streaming.py +547 -0
- videopython-0.41.0/src/videopython/editing/transcription_overlay.py +397 -0
- {videopython-0.39.0 → videopython-0.41.0}/src/videopython/editing/video_edit.py +247 -75
- videopython-0.39.0/src/videopython/base/image_text.py +0 -1217
- videopython-0.39.0/src/videopython/editing/streaming.py +0 -295
- videopython-0.39.0/src/videopython/editing/transcription_overlay.py +0 -591
- {videopython-0.39.0 → videopython-0.41.0}/.gitignore +0 -0
- {videopython-0.39.0 → videopython-0.41.0}/LICENSE +0 -0
- {videopython-0.39.0 → videopython-0.41.0}/README.md +0 -0
- {videopython-0.39.0 → videopython-0.41.0}/src/videopython/__init__.py +0 -0
- {videopython-0.39.0 → videopython-0.41.0}/src/videopython/ai/__init__.py +0 -0
- {videopython-0.39.0 → videopython-0.41.0}/src/videopython/ai/_device.py +0 -0
- {videopython-0.39.0 → videopython-0.41.0}/src/videopython/ai/dubbing/__init__.py +0 -0
- {videopython-0.39.0 → videopython-0.41.0}/src/videopython/ai/dubbing/config.py +0 -0
- {videopython-0.39.0 → videopython-0.41.0}/src/videopython/ai/dubbing/dubber.py +0 -0
- {videopython-0.39.0 → videopython-0.41.0}/src/videopython/ai/dubbing/expressiveness.py +0 -0
- {videopython-0.39.0 → videopython-0.41.0}/src/videopython/ai/dubbing/loudness.py +0 -0
- {videopython-0.39.0 → videopython-0.41.0}/src/videopython/ai/dubbing/models.py +0 -0
- {videopython-0.39.0 → videopython-0.41.0}/src/videopython/ai/dubbing/pipeline.py +0 -0
- {videopython-0.39.0 → videopython-0.41.0}/src/videopython/ai/dubbing/quality.py +0 -0
- {videopython-0.39.0 → videopython-0.41.0}/src/videopython/ai/dubbing/remux.py +0 -0
- {videopython-0.39.0 → videopython-0.41.0}/src/videopython/ai/dubbing/timing.py +0 -0
- {videopython-0.39.0 → videopython-0.41.0}/src/videopython/ai/dubbing/voice_sample.py +0 -0
- {videopython-0.39.0 → videopython-0.41.0}/src/videopython/ai/effects.py +0 -0
- {videopython-0.39.0 → videopython-0.41.0}/src/videopython/ai/generation/__init__.py +0 -0
- {videopython-0.39.0 → videopython-0.41.0}/src/videopython/ai/generation/audio.py +0 -0
- {videopython-0.39.0 → videopython-0.41.0}/src/videopython/ai/generation/image.py +0 -0
- {videopython-0.39.0 → videopython-0.41.0}/src/videopython/ai/generation/qwen3.py +0 -0
- {videopython-0.39.0 → videopython-0.41.0}/src/videopython/ai/generation/translation.py +0 -0
- {videopython-0.39.0 → videopython-0.41.0}/src/videopython/ai/generation/video.py +0 -0
- {videopython-0.39.0 → videopython-0.41.0}/src/videopython/ai/transforms.py +0 -0
- {videopython-0.39.0 → videopython-0.41.0}/src/videopython/ai/understanding/__init__.py +0 -0
- {videopython-0.39.0 → videopython-0.41.0}/src/videopython/ai/understanding/audio.py +0 -0
- {videopython-0.39.0 → videopython-0.41.0}/src/videopython/ai/understanding/faces.py +0 -0
- {videopython-0.39.0 → videopython-0.41.0}/src/videopython/ai/understanding/image.py +0 -0
- {videopython-0.39.0 → videopython-0.41.0}/src/videopython/ai/understanding/objects.py +0 -0
- {videopython-0.39.0 → videopython-0.41.0}/src/videopython/ai/understanding/separation.py +0 -0
- {videopython-0.39.0 → videopython-0.41.0}/src/videopython/ai/understanding/temporal.py +0 -0
- {videopython-0.39.0 → videopython-0.41.0}/src/videopython/ai/video_analysis/__init__.py +0 -0
- {videopython-0.39.0 → videopython-0.41.0}/src/videopython/ai/video_analysis/analyzer.py +0 -0
- {videopython-0.39.0 → videopython-0.41.0}/src/videopython/ai/video_analysis/models.py +0 -0
- {videopython-0.39.0 → videopython-0.41.0}/src/videopython/ai/video_analysis/sampling.py +0 -0
- {videopython-0.39.0 → videopython-0.41.0}/src/videopython/ai/video_analysis/stages.py +0 -0
- {videopython-0.39.0 → videopython-0.41.0}/src/videopython/audio/__init__.py +0 -0
- {videopython-0.39.0 → videopython-0.41.0}/src/videopython/audio/analysis.py +0 -0
- {videopython-0.39.0 → videopython-0.41.0}/src/videopython/audio/audio.py +0 -0
- {videopython-0.39.0 → videopython-0.41.0}/src/videopython/base/_dimensions.py +0 -0
- {videopython-0.39.0 → videopython-0.41.0}/src/videopython/base/_ffmpeg.py +0 -0
- {videopython-0.39.0 → videopython-0.41.0}/src/videopython/base/_video_io.py +0 -0
- {videopython-0.39.0 → videopython-0.41.0}/src/videopython/base/description.py +0 -0
- {videopython-0.39.0 → videopython-0.41.0}/src/videopython/base/draw_detections.py +0 -0
- {videopython-0.39.0 → videopython-0.41.0}/src/videopython/base/fonts/Anton-OFL.txt +0 -0
- {videopython-0.39.0 → videopython-0.41.0}/src/videopython/base/fonts/Anton-Regular.ttf +0 -0
- {videopython-0.39.0 → videopython-0.41.0}/src/videopython/base/fonts/BebasNeue-OFL.txt +0 -0
- {videopython-0.39.0 → videopython-0.41.0}/src/videopython/base/fonts/BebasNeue-Regular.ttf +0 -0
- {videopython-0.39.0 → videopython-0.41.0}/src/videopython/base/fonts/DejaVuSans.ttf +0 -0
- {videopython-0.39.0 → videopython-0.41.0}/src/videopython/base/fonts/LICENSE_DEJAVU +0 -0
- {videopython-0.39.0 → videopython-0.41.0}/src/videopython/base/fonts/Lato-Bold.ttf +0 -0
- {videopython-0.39.0 → videopython-0.41.0}/src/videopython/base/fonts/Lato-OFL.txt +0 -0
- {videopython-0.39.0 → videopython-0.41.0}/src/videopython/base/fonts/Poppins-Bold.ttf +0 -0
- {videopython-0.39.0 → videopython-0.41.0}/src/videopython/base/fonts/Poppins-OFL.txt +0 -0
- {videopython-0.39.0 → videopython-0.41.0}/src/videopython/base/transcription.py +0 -0
- {videopython-0.39.0 → videopython-0.41.0}/src/videopython/base/video.py +0 -0
- {videopython-0.39.0 → videopython-0.41.0}/src/videopython/editing/_easing.py +0 -0
- {videopython-0.39.0 → videopython-0.41.0}/src/videopython/editing/effects.py +0 -0
- {videopython-0.39.0 → videopython-0.41.0}/src/videopython/editing/transforms.py +0 -0
- {videopython-0.39.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,8 +91,9 @@ 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"
|
|
95
|
+
# Streaming strictness (opt-in via strict_streaming).
|
|
96
|
+
STREAMING_FALLBACK = "streaming_fallback"
|
|
108
97
|
|
|
109
98
|
|
|
110
99
|
@dataclass
|
|
@@ -113,6 +102,9 @@ class PlanError:
|
|
|
113
102
|
|
|
114
103
|
``location`` is a path into the plan (e.g. ``'segments[1].operations[0]'``);
|
|
115
104
|
the remaining fields are populated when meaningful for the ``code``.
|
|
105
|
+
``detail`` carries a short human-readable cause when the code alone is not
|
|
106
|
+
actionable (e.g. *why* an op forces the eager fallback) -- prose meant for
|
|
107
|
+
LLM refine-loop feedback, not for branching.
|
|
116
108
|
"""
|
|
117
109
|
|
|
118
110
|
code: PlanErrorCode
|
|
@@ -122,6 +114,7 @@ class PlanError:
|
|
|
122
114
|
value: float | None = None
|
|
123
115
|
limit: float | None = None
|
|
124
116
|
predicted_duration: float | None = None
|
|
117
|
+
detail: str | None = None
|
|
125
118
|
|
|
126
119
|
|
|
127
120
|
@dataclass
|
|
@@ -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,
|
|
@@ -22,6 +23,7 @@ from .effects import (
|
|
|
22
23
|
Zoom,
|
|
23
24
|
)
|
|
24
25
|
from .operation import FilterCtx, OpCategory, Operation, TimeRange
|
|
26
|
+
from .streaming import OpStreamability, StreamabilityReport, StreamingClass
|
|
25
27
|
from .transcription_overlay import SubtitleRegion, SubtitleStyle, TranscriptionOverlay
|
|
26
28
|
from .transforms import (
|
|
27
29
|
Crop,
|
|
@@ -82,4 +84,10 @@ __all__ = [
|
|
|
82
84
|
# Plan runner
|
|
83
85
|
"VideoEdit",
|
|
84
86
|
"SegmentConfig",
|
|
87
|
+
# Subtitle placement
|
|
88
|
+
"AnchorPoint",
|
|
89
|
+
# Streamability report
|
|
90
|
+
"StreamabilityReport",
|
|
91
|
+
"OpStreamability",
|
|
92
|
+
"StreamingClass",
|
|
85
93
|
]
|
|
@@ -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
|
|