ffman 0.1.0__py3-none-any.whl
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.
- ffman/__init__.py +27 -0
- ffman/__main__.py +27 -0
- ffman/cli.py +176 -0
- ffman/effects/__init__.py +120 -0
- ffman/effects/blur.py +36 -0
- ffman/effects/camcorder.py +171 -0
- ffman/effects/chromatic_aberration.py +50 -0
- ffman/effects/crt.py +145 -0
- ffman/effects/datamosh.py +73 -0
- ffman/effects/dither.py +45 -0
- ffman/effects/frame.py +48 -0
- ffman/effects/halation.py +33 -0
- ffman/effects/invert.py +14 -0
- ffman/effects/pixelate.py +28 -0
- ffman/effects/spec.py +69 -0
- ffman/effects/stages.py +123 -0
- ffman/effects/vhs.py +84 -0
- ffman/errors.py +48 -0
- ffman/fmt.py +38 -0
- ffman/fonts/IBMPlexMono-Bold.otf +0 -0
- ffman/fonts/IBMPlexSans-Bold.otf +0 -0
- ffman/fonts/IBMPlexSans-Regular.otf +0 -0
- ffman/fonts/OFL.txt +93 -0
- ffman/graph/__init__.py +170 -0
- ffman/graph/gif.py +34 -0
- ffman/graph/light.py +45 -0
- ffman/graph/resize.py +143 -0
- ffman/graph/sizes.py +35 -0
- ffman/jobs/__init__.py +1 -0
- ffman/jobs/convert/__init__.py +10 -0
- ffman/jobs/convert/attach.py +58 -0
- ffman/jobs/convert/burn.py +201 -0
- ffman/jobs/convert/carried.py +31 -0
- ffman/jobs/convert/covers.py +110 -0
- ffman/jobs/convert/cuesheet.py +46 -0
- ffman/jobs/convert/dispatch.py +29 -0
- ffman/jobs/convert/fonts.py +60 -0
- ffman/jobs/convert/metadata.py +25 -0
- ffman/jobs/convert/options.py +253 -0
- ffman/jobs/convert/output.py +177 -0
- ffman/jobs/convert/passes.py +97 -0
- ffman/jobs/convert/remux.py +106 -0
- ffman/jobs/convert/render.py +176 -0
- ffman/jobs/convert/resize.py +47 -0
- ffman/jobs/convert/subtitles.py +54 -0
- ffman/jobs/convert/tagging.py +197 -0
- ffman/jobs/convert/youtube.py +105 -0
- ffman/jobs/meta/__init__.py +1 -0
- ffman/jobs/meta/io.py +126 -0
- ffman/jobs/meta/options.py +72 -0
- ffman/jobs/meta/run.py +70 -0
- ffman/media/__init__.py +1 -0
- ffman/media/flac.py +76 -0
- ffman/media/paths.py +123 -0
- ffman/media/probe.py +300 -0
- ffman/media/run.py +241 -0
- ffman/normalize/ffmpeg-normalize/presets/youtube-aac-native.json +15 -0
- ffman/normalize/ffmpeg-normalize/presets/youtube-aac.json +15 -0
- ffman/options.py +523 -0
- ffman/plan/__init__.py +1 -0
- ffman/plan/encode.py +245 -0
- ffman/plan/flows.py +96 -0
- ffman/plan/geometry.py +110 -0
- ffman/plan/outputs.py +85 -0
- ffman/plan/request.py +59 -0
- ffman/plan/streams.py +135 -0
- ffman/plan/youtube.py +215 -0
- ffman/py.typed +0 -0
- ffman/subs/__init__.py +1 -0
- ffman/subs/ass.py +496 -0
- ffman/subs/breaks.py +578 -0
- ffman/subs/colorize.py +209 -0
- ffman/subs/ingest.py +36 -0
- ffman/subs/layout.py +97 -0
- ffman/subs/markup.py +16 -0
- ffman/subs/metrics.py +156 -0
- ffman/subs/normalize.py +160 -0
- ffman/subs/paint.py +203 -0
- ffman/subs/srt.py +29 -0
- ffman/values.py +69 -0
- ffman-0.1.0.dist-info/METADATA +173 -0
- ffman-0.1.0.dist-info/RECORD +87 -0
- ffman-0.1.0.dist-info/WHEEL +4 -0
- ffman-0.1.0.dist-info/entry_points.txt +2 -0
- ffman-0.1.0.dist-info/licenses/LICENSE-APACHE +202 -0
- ffman-0.1.0.dist-info/licenses/LICENSE-MIT +18 -0
- ffman-0.1.0.dist-info/licenses/src/ffman/fonts/OFL.txt +93 -0
ffman/__init__.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""ffman: opinionated media conversion on ffmpeg."""
|
|
2
|
+
|
|
3
|
+
import tomllib
|
|
4
|
+
from importlib.metadata import PackageNotFoundError, metadata
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Final, cast
|
|
7
|
+
|
|
8
|
+
_ROOT: Final = Path(__file__).parents[2] # in a source tree: the project, pyproject.toml beside src
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def about(root: Path = _ROOT) -> tuple[str, str]:
|
|
12
|
+
"""The version and summary: the installed package's metadata, else pyproject.toml's.
|
|
13
|
+
|
|
14
|
+
Both come from pyproject.toml; installing writes them into the metadata. A
|
|
15
|
+
source tree run as is (the equivalence harness: PYTHONPATH=src) has no
|
|
16
|
+
metadata, and its pyproject.toml is ``root``, beside src.
|
|
17
|
+
"""
|
|
18
|
+
try:
|
|
19
|
+
found = metadata("ffman")
|
|
20
|
+
except PackageNotFoundError:
|
|
21
|
+
pyproject = tomllib.loads((root / "pyproject.toml").read_text())
|
|
22
|
+
project = cast("dict[str, str]", pyproject["project"])
|
|
23
|
+
return project["version"], project["description"]
|
|
24
|
+
return found["Version"], found["Summary"]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
__version__, __summary__ = about()
|
ffman/__main__.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""ffman's entry: the ``ffman`` command (pyproject's scripts) and ``python -m ffman``.
|
|
2
|
+
|
|
3
|
+
ffman runs its tools as POSIX does -- process groups, SIGHUP (``media/run.py``) -- so on
|
|
4
|
+
Windows its command line cannot even be imported: the platform is checked first, the command
|
|
5
|
+
line imported after.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import sys
|
|
9
|
+
from typing import Final
|
|
10
|
+
|
|
11
|
+
from ffman.errors import PROG
|
|
12
|
+
|
|
13
|
+
WINDOWS: Final = f"{PROG}: error: ffman runs on Linux and macOS; on Windows, run it in WSL\n"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def main() -> int:
|
|
17
|
+
"""The exit status: refused on Windows, else the command line's."""
|
|
18
|
+
if sys.platform == "win32":
|
|
19
|
+
_ = sys.stderr.write(WINDOWS)
|
|
20
|
+
return 1
|
|
21
|
+
from ffman.cli import main as command_line # noqa: PLC0415 -- after the check, as above
|
|
22
|
+
|
|
23
|
+
return command_line()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
if __name__ == "__main__":
|
|
27
|
+
sys.exit(main())
|
ffman/cli.py
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""The command line: the top level, and dispatch to a command."""
|
|
2
|
+
|
|
3
|
+
import contextlib
|
|
4
|
+
import os
|
|
5
|
+
import signal
|
|
6
|
+
import sys
|
|
7
|
+
from collections.abc import Callable, Sequence
|
|
8
|
+
from typing import Final
|
|
9
|
+
|
|
10
|
+
from ffman import __summary__, __version__
|
|
11
|
+
from ffman.effects import format_catalogue
|
|
12
|
+
from ffman.errors import PROG, REFUSALS, refuse
|
|
13
|
+
from ffman.jobs.convert.dispatch import convert
|
|
14
|
+
from ffman.jobs.convert.options import validate
|
|
15
|
+
from ffman.jobs.meta.options import validate as validate_meta
|
|
16
|
+
from ffman.jobs.meta.run import run as meta
|
|
17
|
+
from ffman.media.run import SIGNALS, Interrupted, Runner, install_signal_handlers
|
|
18
|
+
from ffman.options import CONVERT, META, format_help, parse
|
|
19
|
+
|
|
20
|
+
TOP_USAGE: Final = f"{PROG} COMMAND [options]"
|
|
21
|
+
TOP_HELP: Final = f"""Usage: {TOP_USAGE}
|
|
22
|
+
|
|
23
|
+
{__summary__}
|
|
24
|
+
|
|
25
|
+
Commands:
|
|
26
|
+
convert transform one media file: resize, effects, subtitles, encoding
|
|
27
|
+
effects the video effects, their values and what auto means
|
|
28
|
+
meta write or edit a metadata file: ffmetadata, Vorbis comments, a cue sheet
|
|
29
|
+
|
|
30
|
+
Options:
|
|
31
|
+
-h, --help show this help
|
|
32
|
+
--version show the version
|
|
33
|
+
|
|
34
|
+
See {PROG} COMMAND --help.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
# The bash commands, refused with their equivalent (D4; docs/ffman-spec.md section 7).
|
|
38
|
+
OLD_COMMANDS: Final = {
|
|
39
|
+
"resize": f"resize is now: {PROG} convert ... (the same options)",
|
|
40
|
+
"overlay": f"overlay is now: {PROG} convert -i F --burn-subs S ...",
|
|
41
|
+
"attach": f"attach is now: {PROG} convert -i F --add-subs S [--language L] (-o O | --in-place)",
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def run_convert(argv: list[str]) -> int:
|
|
46
|
+
"""``ffman convert``: parse and check its options."""
|
|
47
|
+
parsed = parse("convert", CONVERT, argv)
|
|
48
|
+
if parsed.help:
|
|
49
|
+
print(
|
|
50
|
+
format_help(
|
|
51
|
+
f"{PROG} convert -i INPUT [options]",
|
|
52
|
+
"Transform one media file: resize, effects, subtitles, encoding.",
|
|
53
|
+
CONVERT,
|
|
54
|
+
),
|
|
55
|
+
end="",
|
|
56
|
+
)
|
|
57
|
+
return 0
|
|
58
|
+
options = validate(parsed)
|
|
59
|
+
with Runner(dry_run=options.dry_run) as runner:
|
|
60
|
+
convert(options, runner)
|
|
61
|
+
return 0
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def run_meta(argv: list[str]) -> int:
|
|
65
|
+
"""``ffman meta``: a metadata file written or edited (spec 8)."""
|
|
66
|
+
parsed = parse("meta", META, argv)
|
|
67
|
+
if parsed.help:
|
|
68
|
+
usage = f"{PROG} meta [-i INPUT] (-o OUTPUT | -o - | --in-place) [--set K=V ...] [options]"
|
|
69
|
+
print(
|
|
70
|
+
format_help(
|
|
71
|
+
usage,
|
|
72
|
+
"Write or edit a metadata file: ffmetadata, Vorbis comments, a cue sheet.",
|
|
73
|
+
META,
|
|
74
|
+
),
|
|
75
|
+
end="",
|
|
76
|
+
)
|
|
77
|
+
return 0
|
|
78
|
+
options = validate_meta(parsed)
|
|
79
|
+
with Runner(dry_run=options.dry_run) as runner:
|
|
80
|
+
meta(options, runner)
|
|
81
|
+
return 0
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
EFFECTS_HELP: Final = f"""Usage: {PROG} effects [video] [NAME]
|
|
85
|
+
|
|
86
|
+
The effects --vfx takes, in the order they run, with what each value means
|
|
87
|
+
when left out. NAME shows one.
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def run_effects(argv: list[str]) -> int:
|
|
92
|
+
"""``ffman effects [video] [NAME]``: the catalogue."""
|
|
93
|
+
if any(arg in ("-h", "--help") for arg in argv):
|
|
94
|
+
print(EFFECTS_HELP, end="")
|
|
95
|
+
return 0
|
|
96
|
+
words = argv[1:] if argv[:1] == ["video"] else argv
|
|
97
|
+
if len(words) > 1 or (words and words[0].startswith("-")):
|
|
98
|
+
refuse(f"effects: unexpected: {' '.join(words)} (see {PROG} effects --help)")
|
|
99
|
+
print(format_catalogue(words[0] if words else None), end="")
|
|
100
|
+
return 0
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
COMMANDS: Final[dict[str, Callable[[list[str]], int]]] = {
|
|
104
|
+
"convert": run_convert,
|
|
105
|
+
"effects": run_effects,
|
|
106
|
+
"meta": run_meta,
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
111
|
+
"""Run ffman with ``argv`` (default: the process arguments); return the exit status."""
|
|
112
|
+
args = list(sys.argv[1:] if argv is None else argv)
|
|
113
|
+
previous = {sig: signal.getsignal(sig) for sig in SIGNALS}
|
|
114
|
+
install_signal_handlers()
|
|
115
|
+
try:
|
|
116
|
+
status = _dispatch(args)
|
|
117
|
+
# A pipe is block-buffered: flush here, where a reader gone is handled,
|
|
118
|
+
# not at exit, where Python reports it and exits 120 (the docs' recipe).
|
|
119
|
+
_ = sys.stdout.flush()
|
|
120
|
+
except Interrupted as stop: # cleaned up on the way out; silent, as bash was
|
|
121
|
+
return stop.status
|
|
122
|
+
except REFUSALS as error:
|
|
123
|
+
print(f"{PROG}: error: {error}", file=sys.stderr)
|
|
124
|
+
return 1
|
|
125
|
+
except BrokenPipeError: # the reader left (ffman ... | head): quiet, as bash died of SIGPIPE
|
|
126
|
+
_stdout_to_devnull()
|
|
127
|
+
return 128 + signal.SIGPIPE
|
|
128
|
+
except OSError as error: # the environment: a folder not writable, a disk full
|
|
129
|
+
print(f"{PROG}: error: {_describe(error)}", file=sys.stderr)
|
|
130
|
+
return 1
|
|
131
|
+
else:
|
|
132
|
+
return status
|
|
133
|
+
finally: # an in-process caller keeps its own handlers
|
|
134
|
+
for sig, handler in previous.items():
|
|
135
|
+
if handler is not None:
|
|
136
|
+
_ = signal.signal(sig, handler)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _stdout_to_devnull() -> None:
|
|
140
|
+
"""Point stdout at /dev/null, so the exit's own flush cannot fail again.
|
|
141
|
+
|
|
142
|
+
The Python documentation's recipe (signal module, "Note on SIGPIPE"); not
|
|
143
|
+
SIG_DFL, which would let a signal end ffman mid-job, its ffmpeg orphaned.
|
|
144
|
+
"""
|
|
145
|
+
# Best effort: a caller's stdout may have no descriptor (io.UnsupportedOperation).
|
|
146
|
+
with contextlib.suppress(OSError, ValueError):
|
|
147
|
+
devnull = os.open(os.devnull, os.O_WRONLY)
|
|
148
|
+
_ = os.dup2(devnull, sys.stdout.fileno())
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _describe(error: OSError) -> str:
|
|
152
|
+
"""``strerror: filename``: typeshed's ``filename`` is Any, narrowed to what it holds."""
|
|
153
|
+
filename: object = error.filename # pyright: ignore[reportAny] -- typeshed: Any
|
|
154
|
+
what = error.strerror or str(error)
|
|
155
|
+
if isinstance(filename, str | bytes):
|
|
156
|
+
return f"{what}: {os.fsdecode(filename)}"
|
|
157
|
+
return what
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _dispatch(args: list[str]) -> int:
|
|
161
|
+
if not args:
|
|
162
|
+
print(f"Usage: {TOP_USAGE}", file=sys.stderr)
|
|
163
|
+
return 1
|
|
164
|
+
word, rest = args[0], args[1:]
|
|
165
|
+
if word in ("-h", "--help"):
|
|
166
|
+
print(TOP_HELP, end="")
|
|
167
|
+
return 0
|
|
168
|
+
if word == "--version":
|
|
169
|
+
print(f"{PROG} {__version__}")
|
|
170
|
+
return 0
|
|
171
|
+
if word in OLD_COMMANDS:
|
|
172
|
+
refuse(OLD_COMMANDS[word])
|
|
173
|
+
command = COMMANDS.get(word)
|
|
174
|
+
if command is None:
|
|
175
|
+
refuse(f"unknown command: {word} (see {PROG} --help)")
|
|
176
|
+
return command(rest)
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""The effects registry: what each effect takes, the ``--vfx`` grammar, the catalogue.
|
|
2
|
+
|
|
3
|
+
A spec is ``NAME[:ARG[,ARG...]]``; an ARG is the main parameter's value (first
|
|
4
|
+
only) or ``KEY=VALUE``. Only the first ``:`` ends the name, only ``,`` separates
|
|
5
|
+
arguments, only the first ``=`` ends a key -- so ``time=23:59:58`` parses. No
|
|
6
|
+
value means auto. The order they run in is their stage's, not the listing's
|
|
7
|
+
(docs/ffman-python.md section 3.3). Ranges and messages are the bash ffman's
|
|
8
|
+
(``fx_validate``), the flag replaced by the spec.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from types import MappingProxyType
|
|
12
|
+
from typing import Final
|
|
13
|
+
|
|
14
|
+
from ffman.effects import (
|
|
15
|
+
blur,
|
|
16
|
+
camcorder,
|
|
17
|
+
chromatic_aberration,
|
|
18
|
+
crt,
|
|
19
|
+
datamosh,
|
|
20
|
+
dither,
|
|
21
|
+
halation,
|
|
22
|
+
invert,
|
|
23
|
+
pixelate,
|
|
24
|
+
vhs,
|
|
25
|
+
)
|
|
26
|
+
from ffman.effects.spec import Effect, Param, Request
|
|
27
|
+
from ffman.errors import refuse
|
|
28
|
+
|
|
29
|
+
# The registry, in the order the bash ffman listed and ran them
|
|
30
|
+
VIDEO: Final = (
|
|
31
|
+
blur.EFFECT,
|
|
32
|
+
pixelate.EFFECT,
|
|
33
|
+
invert.EFFECT,
|
|
34
|
+
chromatic_aberration.EFFECT,
|
|
35
|
+
halation.EFFECT,
|
|
36
|
+
datamosh.EFFECT,
|
|
37
|
+
camcorder.EFFECT,
|
|
38
|
+
vhs.EFFECT,
|
|
39
|
+
dither.EFFECT,
|
|
40
|
+
crt.EFFECT,
|
|
41
|
+
)
|
|
42
|
+
_BY_NAME: Final = {effect.name: effect for effect in VIDEO}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def parse_spec(spec: str) -> Request:
|
|
46
|
+
"""Parse one ``--vfx`` spec."""
|
|
47
|
+
name, colon, args = spec.partition(":")
|
|
48
|
+
effect = _BY_NAME.get(name)
|
|
49
|
+
if effect is None:
|
|
50
|
+
refuse(f"--vfx: unknown effect: {name} (see ffman effects)")
|
|
51
|
+
values: dict[str, str] = {}
|
|
52
|
+
if colon:
|
|
53
|
+
for position, arg in enumerate(args.split(",")):
|
|
54
|
+
key, equals, value = arg.partition("=")
|
|
55
|
+
if not equals:
|
|
56
|
+
key, value = _main_param(effect, spec, position), arg
|
|
57
|
+
_set(effect, values, key, value)
|
|
58
|
+
return Request(effect, MappingProxyType(values)) # read-only: validated once
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def parse_specs(specs: list[str]) -> tuple[Request, ...]:
|
|
62
|
+
"""Parse every ``--vfx``; an effect twice is refused."""
|
|
63
|
+
requests = tuple(parse_spec(spec) for spec in specs)
|
|
64
|
+
seen: set[str] = set()
|
|
65
|
+
for request in requests:
|
|
66
|
+
if request.effect.name in seen:
|
|
67
|
+
refuse(f"--vfx {request.effect.name} given twice")
|
|
68
|
+
seen.add(request.effect.name)
|
|
69
|
+
return requests
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _main_param(effect: Effect, spec: str, position: int) -> str:
|
|
73
|
+
if not effect.params:
|
|
74
|
+
refuse(f"--vfx {effect.name} takes no value: {spec}")
|
|
75
|
+
if not effect.positional:
|
|
76
|
+
keys = ", ".join(f"{p.name}=" for p in effect.params)
|
|
77
|
+
refuse(f"--vfx {effect.name} takes named values ({keys}): {spec}")
|
|
78
|
+
if position > 0:
|
|
79
|
+
refuse(f"--vfx {effect.name}: only the first value may go without a name: {spec}")
|
|
80
|
+
return effect.params[0].name
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _set(effect: Effect, values: dict[str, str], key: str, value: str) -> None:
|
|
84
|
+
param = next((p for p in effect.params if p.name == key), None)
|
|
85
|
+
if param is None:
|
|
86
|
+
known = ", ".join(p.name for p in effect.params) or "none"
|
|
87
|
+
refuse(f"--vfx {effect.name}: unknown parameter: {key} ({known})")
|
|
88
|
+
if key in values:
|
|
89
|
+
refuse(f"--vfx {effect.name}: {key} given twice")
|
|
90
|
+
if not ((param.auto and value == "auto") or param.check(value)):
|
|
91
|
+
refuse(f"--vfx {effect.name}: {param.must}: {value}")
|
|
92
|
+
values[key] = value
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def format_catalogue(name: str | None = None) -> str:
|
|
96
|
+
"""The catalogue: every video effect in stage order, or the one named."""
|
|
97
|
+
effects = sorted(VIDEO, key=lambda e: e.stage) if name is None else [_named(name)]
|
|
98
|
+
lines = (
|
|
99
|
+
[]
|
|
100
|
+
if name is not None
|
|
101
|
+
else ["Video effects, in the order they run (--vfx NAME[:VALUE]):", ""]
|
|
102
|
+
)
|
|
103
|
+
for effect in effects:
|
|
104
|
+
lines.append(f" {effect.name:<22}{effect.about}")
|
|
105
|
+
lines += [f" {'':<22} {_describe(param)}" for param in effect.params]
|
|
106
|
+
return "\n".join(lines) + "\n"
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _describe(param: Param) -> str:
|
|
110
|
+
"""``name: range (auto: meaning)``, the range read from the refusal's own text."""
|
|
111
|
+
allowed = param.must.removeprefix(f"{param.name} ").removeprefix("must be ")
|
|
112
|
+
unset = "auto" if param.auto else "left out"
|
|
113
|
+
return f"{param.name}: {allowed} ({unset}: {param.unset})"
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _named(name: str) -> Effect:
|
|
117
|
+
effect = _BY_NAME.get(name)
|
|
118
|
+
if effect is None:
|
|
119
|
+
refuse(f"unknown effect: {name} (see ffman effects)")
|
|
120
|
+
return effect
|
ffman/effects/blur.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""``--vfx blur``: the picture blurred in light, round on screen (decisions.md: Blurs review)."""
|
|
2
|
+
|
|
3
|
+
from fractions import Fraction
|
|
4
|
+
from typing import Final
|
|
5
|
+
|
|
6
|
+
from ffman.effects.frame import Frame
|
|
7
|
+
from ffman.effects.spec import Effect, Param, Request, Stage, asked, between
|
|
8
|
+
from ffman.fmt import calc, round_int
|
|
9
|
+
from ffman.graph import Filter, Labels, Open
|
|
10
|
+
from ffman.graph.light import DECODE, ENCODE, curves
|
|
11
|
+
|
|
12
|
+
EFFECT: Final = Effect(
|
|
13
|
+
"blur",
|
|
14
|
+
Stage.PICTURE,
|
|
15
|
+
"blur the picture, in light (a near-Gaussian)",
|
|
16
|
+
(
|
|
17
|
+
Param(
|
|
18
|
+
"sigma",
|
|
19
|
+
between(Fraction(0), Fraction(1024), integer=False, low_open=True),
|
|
20
|
+
"must be auto or a sigma above 0, up to 1024",
|
|
21
|
+
"1% of the displayed shorter side",
|
|
22
|
+
),
|
|
23
|
+
),
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def build(graph: Open, request: Request, frame: Frame, _: Labels) -> Open:
|
|
28
|
+
"""In light, 16-bit RGB; round on screen: sigma across divided by the SAR."""
|
|
29
|
+
sar = calc(float(frame.sar))
|
|
30
|
+
across = min(round_int(calc(frame.width * float(sar))), frame.height)
|
|
31
|
+
sigma = asked(request, "sigma") or calc(across / 100)
|
|
32
|
+
gblur = Filter(
|
|
33
|
+
"gblur", (("sigma", calc(float(sigma) / float(sar))), ("sigmaV", sigma), ("steps", "6"))
|
|
34
|
+
)
|
|
35
|
+
rgb16 = Filter("format", ("gbrp16le",))
|
|
36
|
+
return graph.then(rgb16, curves(DECODE, frame.video), gblur, curves(ENCODE, frame.video))
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""The camcorder's stamp: PLAY, SP, the counter and a running clock, as ASS (bash's cam_prepare).
|
|
2
|
+
|
|
3
|
+
The clock starts at --date and --time; each left out comes from the file's
|
|
4
|
+
creation time -- as the bash ffman read it: ``date -d`` gave it in *local* time,
|
|
5
|
+
and that wall-clock text was then read as UTC (``date -u -d``); so the stamp
|
|
6
|
+
shows the local time of the recording -- else from now. The text is gawk's
|
|
7
|
+
under LC_ALL=C: English names, whatever the locale.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import math
|
|
11
|
+
import re
|
|
12
|
+
import time
|
|
13
|
+
from datetime import UTC, datetime
|
|
14
|
+
from fractions import Fraction
|
|
15
|
+
from typing import Final
|
|
16
|
+
|
|
17
|
+
from ffman.effects.frame import Frame
|
|
18
|
+
from ffman.effects.spec import Effect, Param, Request, Stage
|
|
19
|
+
from ffman.errors import refuse
|
|
20
|
+
from ffman.fmt import calc, round_int
|
|
21
|
+
from ffman.graph import Filter, Labels, Open
|
|
22
|
+
|
|
23
|
+
_DATE: Final = re.compile(r"([0-9]{4})([-:])([0-9]{2})([-:])([0-9]{2})")
|
|
24
|
+
_TIME: Final = re.compile(r"([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]")
|
|
25
|
+
|
|
26
|
+
STAMP_FONT: Final = "IBM Plex Mono" # the Style line's, bold (-1), no override
|
|
27
|
+
STAMP_FILE: Final = "IBMPlexMono-Bold.otf" # its one weight, which ffman carries (6.7.3)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _real_date(text: str) -> bool:
|
|
31
|
+
"""A real date, one separator throughout; from 0001-01-01 (GNU date also takes 0000)."""
|
|
32
|
+
match = _DATE.fullmatch(text)
|
|
33
|
+
if match is None or match[2] != match[4]:
|
|
34
|
+
return False
|
|
35
|
+
try:
|
|
36
|
+
_ = datetime(int(match[1]), int(match[3]), int(match[5]), tzinfo=UTC) # real, or ValueError
|
|
37
|
+
except ValueError:
|
|
38
|
+
return False
|
|
39
|
+
return True
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _is_time(text: str) -> bool:
|
|
43
|
+
return _TIME.fullmatch(text) is not None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
EFFECT: Final = Effect(
|
|
47
|
+
"camcorder",
|
|
48
|
+
Stage.CAMCORDER,
|
|
49
|
+
"a camcorder's stamp: PLAY, the counter, SP, a running clock",
|
|
50
|
+
(
|
|
51
|
+
Param(
|
|
52
|
+
"date",
|
|
53
|
+
_real_date,
|
|
54
|
+
"date must be a real date, YYYY-MM-DD or YYYY:MM:DD",
|
|
55
|
+
"the file's creation date, else today",
|
|
56
|
+
auto=False,
|
|
57
|
+
),
|
|
58
|
+
Param(
|
|
59
|
+
"time",
|
|
60
|
+
_is_time,
|
|
61
|
+
"time must be HH:MM:SS (24-hour)",
|
|
62
|
+
"the file's creation time, else now",
|
|
63
|
+
auto=False,
|
|
64
|
+
),
|
|
65
|
+
),
|
|
66
|
+
positional=False,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def build(graph: Open, _request: Request, frame: Frame, _: Labels) -> Open:
|
|
71
|
+
"""The stamp's script, burned (bash's CAMASS), with its fonts folder."""
|
|
72
|
+
if frame.camcorder is None:
|
|
73
|
+
msg = "the camcorder's script must be written first"
|
|
74
|
+
raise ValueError(msg)
|
|
75
|
+
stamp = frame.camcorder
|
|
76
|
+
return graph.then(Filter("ass", (("filename", stamp.script), ("fontsdir", stamp.fonts))))
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
MARGIN: Final = 60 # px from the frame's edges, at 1080 lines
|
|
80
|
+
HEIGHT: Final = 1080 # the script's PlayResY: sizes are in its pixels
|
|
81
|
+
ASS_BREAK: Final = r"\N" # a hard line break in an ASS event's text
|
|
82
|
+
_MONTHS: Final = (
|
|
83
|
+
"JAN",
|
|
84
|
+
"FEB",
|
|
85
|
+
"MAR",
|
|
86
|
+
"APR",
|
|
87
|
+
"MAY",
|
|
88
|
+
"JUN",
|
|
89
|
+
"JUL",
|
|
90
|
+
"AUG",
|
|
91
|
+
"SEP",
|
|
92
|
+
"OCT",
|
|
93
|
+
"NOV",
|
|
94
|
+
"DEC",
|
|
95
|
+
)
|
|
96
|
+
_HEAD: Final = """[Script Info]
|
|
97
|
+
ScriptType: v4.00+
|
|
98
|
+
PlayResX: {width}
|
|
99
|
+
PlayResY: 1080
|
|
100
|
+
WrapStyle: 2
|
|
101
|
+
ScaledBorderAndShadow: yes
|
|
102
|
+
|
|
103
|
+
[V4+ Styles]
|
|
104
|
+
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
|
|
105
|
+
Style: Cam,{font},64,&H00FFFFFF,&H000000FF,&H00000000,&H80000000,-1,0,0,0,100,100,0,0,1,3,1.5,7,0,0,0,1
|
|
106
|
+
|
|
107
|
+
[Events]
|
|
108
|
+
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
|
|
109
|
+
""" # noqa: E501 -- the script's own lines
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def clock(
|
|
113
|
+
creation_time: str | None, date: str | None, time_of_day: str | None, now: datetime
|
|
114
|
+
) -> int:
|
|
115
|
+
"""The stamp's first second, as a UTC epoch (bash's ``e``)."""
|
|
116
|
+
start = _local_wall_clock(creation_time) or now.strftime("%Y-%m-%d %H:%M:%S")
|
|
117
|
+
day, _, hour = start.partition(" ")
|
|
118
|
+
wall = f"{(date or day).replace(':', '-')} {time_of_day or hour}"
|
|
119
|
+
try:
|
|
120
|
+
return int(datetime.strptime(wall, "%Y-%m-%d %H:%M:%S").replace(tzinfo=UTC).timestamp())
|
|
121
|
+
except ValueError:
|
|
122
|
+
refuse("the camcorder clock could not be set")
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _local_wall_clock(creation_time: str | None) -> str | None:
|
|
126
|
+
"""``date -d CT '+%Y-%m-%d %H:%M:%S'``: the creation time, in local time; None if unreadable."""
|
|
127
|
+
if not creation_time:
|
|
128
|
+
return None
|
|
129
|
+
try:
|
|
130
|
+
moment = datetime.fromisoformat(creation_time)
|
|
131
|
+
except ValueError:
|
|
132
|
+
return None
|
|
133
|
+
local = moment.astimezone() if moment.tzinfo else moment
|
|
134
|
+
return local.strftime("%Y-%m-%d %H:%M:%S")
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def width(frame_width: int, frame_height: int) -> int:
|
|
138
|
+
"""The script's PlayResX: the frame's shape at 1080 lines (bash's px)."""
|
|
139
|
+
return round_int(calc(HEIGHT * frame_width / frame_height))
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def script(epoch: int, duration: Fraction | None, play_width: int) -> str:
|
|
143
|
+
"""The ASS script: the fixed marks for the whole clip, a counter and clock a second."""
|
|
144
|
+
seconds = float(duration) if duration is not None else 1.0
|
|
145
|
+
n = max(math.ceil(seconds), 1)
|
|
146
|
+
m, right, bottom = MARGIN, play_width - MARGIN, HEIGHT - MARGIN
|
|
147
|
+
lines = [_HEAD.format(width=play_width, font=STAMP_FONT)]
|
|
148
|
+
lines.append(_event(0, n, rf"{{\an7\pos({m},{m})}}PLAY {{\p1}}m 0 10 l 36 30 0 50{{\p0}}"))
|
|
149
|
+
lines.append(_event(0, n, rf"{{\an1\pos({m},{bottom})}}SP"))
|
|
150
|
+
for k in range(n):
|
|
151
|
+
counter = f"{k // 3600}:{k % 3600 // 60:02d}:{k % 60:02d}"
|
|
152
|
+
lines.append(_event(k, k + 1, rf"{{\an9\pos({right},{m})}}{counter}"))
|
|
153
|
+
lines.append(_event(k, k + 1, rf"{{\an3\pos({right},{bottom})}}{_stamp(epoch + k)}"))
|
|
154
|
+
return "".join(lines)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _event(start: int, end: int, text: str) -> str:
|
|
158
|
+
return f"Dialogue: 0,{_time(start)},{_time(end)},Cam,,0,0,0,,{text}\n"
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _time(s: int) -> str:
|
|
162
|
+
return f"{s // 3600}:{s % 3600 // 60:02d}:{s % 60:02d}.00"
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _stamp(epoch: int) -> str:
|
|
166
|
+
r"""Gawk's strftime("%I:%M:%S %p", e, 1) \N toupper(strftime("%b. %d %Y", e, 1)), C locale."""
|
|
167
|
+
t = time.gmtime(epoch)
|
|
168
|
+
hour = t.tm_hour % 12 or 12
|
|
169
|
+
noon = "AM" if t.tm_hour < 12 else "PM" # noqa: PLR2004 -- noon
|
|
170
|
+
day = f"{_MONTHS[t.tm_mon - 1]}. {t.tm_mday:02d} {t.tm_year}"
|
|
171
|
+
return f"{hour:02d}:{t.tm_min:02d}:{t.tm_sec:02d} {noon}{ASS_BREAK}{day}"
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""``--vfx chromatic-aberration``: red and blue apart, growing to the edges.
|
|
2
|
+
|
|
3
|
+
decisions.md: Effects review.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from fractions import Fraction
|
|
7
|
+
from typing import Final
|
|
8
|
+
|
|
9
|
+
from ffman.effects.frame import Frame
|
|
10
|
+
from ffman.effects.spec import Effect, Param, Request, Stage, asked, between
|
|
11
|
+
from ffman.fmt import calc, round_int
|
|
12
|
+
from ffman.graph import Chain, Filter, Labels, Open
|
|
13
|
+
|
|
14
|
+
EFFECT: Final = Effect(
|
|
15
|
+
"chromatic-aberration",
|
|
16
|
+
Stage.LENS,
|
|
17
|
+
"lateral chromatic aberration: red and blue apart, growing to the edges",
|
|
18
|
+
(
|
|
19
|
+
Param(
|
|
20
|
+
"px",
|
|
21
|
+
between(Fraction(1), Fraction(255), integer=True),
|
|
22
|
+
"must be auto or a shift from 1 to 255 px",
|
|
23
|
+
"red and blue 1/135 of the shorter side apart at the edges",
|
|
24
|
+
),
|
|
25
|
+
),
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def build(graph: Open, request: Request, frame: Frame, labels: Labels) -> Open:
|
|
30
|
+
"""Green and blue scaled up from the centre, red kept: the shift grows to the edges."""
|
|
31
|
+
w, h = frame.width, frame.height
|
|
32
|
+
px = asked(request, "px") or str(max((frame.shorter + 67) // 135, 1))
|
|
33
|
+
k = float(calc(int(px) / w))
|
|
34
|
+
green, blue, red = (labels.new(n) for n in ("cg", "cb", "cr"))
|
|
35
|
+
green2, blue2, red2 = (labels.new(n) for n in ("cg2", "cb2", "cr2"))
|
|
36
|
+
split = graph.then(Filter("format", ("gbrp",)), Filter("extractplanes", ("g+b+r",))).end(
|
|
37
|
+
(green, blue, red)
|
|
38
|
+
)
|
|
39
|
+
shown = "1" if frame.squared else f"{frame.sar.numerator}/{frame.sar.denominator}"
|
|
40
|
+
sar = Filter("setsar", (shown,))
|
|
41
|
+
|
|
42
|
+
def grown(plane: str, by: float, out: str) -> Chain:
|
|
43
|
+
size = (str(round_int(calc(w * (1 + by * k)))), str(round_int(calc(h * (1 + by * k)))))
|
|
44
|
+
scaled = Filter("scale", (*size, ("flags", "bicubic")))
|
|
45
|
+
return Chain((scaled, Filter("crop", (str(w), str(h))), sar), (plane,), (out,))
|
|
46
|
+
|
|
47
|
+
planes = (grown(green, 1, green2), grown(blue, 2, blue2), Chain((sar,), (red,), (red2,)))
|
|
48
|
+
maps = tuple((f"map{i}{part}", str(v)) for i in range(3) for part, v in (("s", i), ("p", 0)))
|
|
49
|
+
merge = Filter("mergeplanes", (*maps, ("format", "gbrp")))
|
|
50
|
+
return Open((green2, blue2, red2), (merge,), (*split, *planes))
|