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.
Files changed (87) hide show
  1. ffman/__init__.py +27 -0
  2. ffman/__main__.py +27 -0
  3. ffman/cli.py +176 -0
  4. ffman/effects/__init__.py +120 -0
  5. ffman/effects/blur.py +36 -0
  6. ffman/effects/camcorder.py +171 -0
  7. ffman/effects/chromatic_aberration.py +50 -0
  8. ffman/effects/crt.py +145 -0
  9. ffman/effects/datamosh.py +73 -0
  10. ffman/effects/dither.py +45 -0
  11. ffman/effects/frame.py +48 -0
  12. ffman/effects/halation.py +33 -0
  13. ffman/effects/invert.py +14 -0
  14. ffman/effects/pixelate.py +28 -0
  15. ffman/effects/spec.py +69 -0
  16. ffman/effects/stages.py +123 -0
  17. ffman/effects/vhs.py +84 -0
  18. ffman/errors.py +48 -0
  19. ffman/fmt.py +38 -0
  20. ffman/fonts/IBMPlexMono-Bold.otf +0 -0
  21. ffman/fonts/IBMPlexSans-Bold.otf +0 -0
  22. ffman/fonts/IBMPlexSans-Regular.otf +0 -0
  23. ffman/fonts/OFL.txt +93 -0
  24. ffman/graph/__init__.py +170 -0
  25. ffman/graph/gif.py +34 -0
  26. ffman/graph/light.py +45 -0
  27. ffman/graph/resize.py +143 -0
  28. ffman/graph/sizes.py +35 -0
  29. ffman/jobs/__init__.py +1 -0
  30. ffman/jobs/convert/__init__.py +10 -0
  31. ffman/jobs/convert/attach.py +58 -0
  32. ffman/jobs/convert/burn.py +201 -0
  33. ffman/jobs/convert/carried.py +31 -0
  34. ffman/jobs/convert/covers.py +110 -0
  35. ffman/jobs/convert/cuesheet.py +46 -0
  36. ffman/jobs/convert/dispatch.py +29 -0
  37. ffman/jobs/convert/fonts.py +60 -0
  38. ffman/jobs/convert/metadata.py +25 -0
  39. ffman/jobs/convert/options.py +253 -0
  40. ffman/jobs/convert/output.py +177 -0
  41. ffman/jobs/convert/passes.py +97 -0
  42. ffman/jobs/convert/remux.py +106 -0
  43. ffman/jobs/convert/render.py +176 -0
  44. ffman/jobs/convert/resize.py +47 -0
  45. ffman/jobs/convert/subtitles.py +54 -0
  46. ffman/jobs/convert/tagging.py +197 -0
  47. ffman/jobs/convert/youtube.py +105 -0
  48. ffman/jobs/meta/__init__.py +1 -0
  49. ffman/jobs/meta/io.py +126 -0
  50. ffman/jobs/meta/options.py +72 -0
  51. ffman/jobs/meta/run.py +70 -0
  52. ffman/media/__init__.py +1 -0
  53. ffman/media/flac.py +76 -0
  54. ffman/media/paths.py +123 -0
  55. ffman/media/probe.py +300 -0
  56. ffman/media/run.py +241 -0
  57. ffman/normalize/ffmpeg-normalize/presets/youtube-aac-native.json +15 -0
  58. ffman/normalize/ffmpeg-normalize/presets/youtube-aac.json +15 -0
  59. ffman/options.py +523 -0
  60. ffman/plan/__init__.py +1 -0
  61. ffman/plan/encode.py +245 -0
  62. ffman/plan/flows.py +96 -0
  63. ffman/plan/geometry.py +110 -0
  64. ffman/plan/outputs.py +85 -0
  65. ffman/plan/request.py +59 -0
  66. ffman/plan/streams.py +135 -0
  67. ffman/plan/youtube.py +215 -0
  68. ffman/py.typed +0 -0
  69. ffman/subs/__init__.py +1 -0
  70. ffman/subs/ass.py +496 -0
  71. ffman/subs/breaks.py +578 -0
  72. ffman/subs/colorize.py +209 -0
  73. ffman/subs/ingest.py +36 -0
  74. ffman/subs/layout.py +97 -0
  75. ffman/subs/markup.py +16 -0
  76. ffman/subs/metrics.py +156 -0
  77. ffman/subs/normalize.py +160 -0
  78. ffman/subs/paint.py +203 -0
  79. ffman/subs/srt.py +29 -0
  80. ffman/values.py +69 -0
  81. ffman-0.1.0.dist-info/METADATA +173 -0
  82. ffman-0.1.0.dist-info/RECORD +87 -0
  83. ffman-0.1.0.dist-info/WHEEL +4 -0
  84. ffman-0.1.0.dist-info/entry_points.txt +2 -0
  85. ffman-0.1.0.dist-info/licenses/LICENSE-APACHE +202 -0
  86. ffman-0.1.0.dist-info/licenses/LICENSE-MIT +18 -0
  87. 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))