polysync 0.1.0__tar.gz → 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (23) hide show
  1. {polysync-0.1.0/src/polysync.egg-info → polysync-0.2.0}/PKG-INFO +1 -1
  2. {polysync-0.1.0 → polysync-0.2.0}/pyproject.toml +1 -1
  3. {polysync-0.1.0 → polysync-0.2.0}/src/polysync/__init__.py +1 -1
  4. polysync-0.2.0/src/polysync/edit/grade.py +110 -0
  5. {polysync-0.1.0 → polysync-0.2.0}/src/polysync/edit/render_cuts.py +23 -10
  6. {polysync-0.1.0 → polysync-0.2.0}/src/polysync/edit/render_pip.py +20 -8
  7. {polysync-0.1.0 → polysync-0.2.0/src/polysync.egg-info}/PKG-INFO +1 -1
  8. {polysync-0.1.0 → polysync-0.2.0}/src/polysync.egg-info/SOURCES.txt +1 -0
  9. {polysync-0.1.0 → polysync-0.2.0}/LICENSE +0 -0
  10. {polysync-0.1.0 → polysync-0.2.0}/README.md +0 -0
  11. {polysync-0.1.0 → polysync-0.2.0}/setup.cfg +0 -0
  12. {polysync-0.1.0 → polysync-0.2.0}/src/polysync/audio.py +0 -0
  13. {polysync-0.1.0 → polysync-0.2.0}/src/polysync/cli.py +0 -0
  14. {polysync-0.1.0 → polysync-0.2.0}/src/polysync/edit/__init__.py +0 -0
  15. {polysync-0.1.0 → polysync-0.2.0}/src/polysync/edit/autoedit.py +0 -0
  16. {polysync-0.1.0 → polysync-0.2.0}/src/polysync/sidecar.py +0 -0
  17. {polysync-0.1.0 → polysync-0.2.0}/src/polysync/sync.py +0 -0
  18. {polysync-0.1.0 → polysync-0.2.0}/src/polysync/verify.py +0 -0
  19. {polysync-0.1.0 → polysync-0.2.0}/src/polysync.egg-info/dependency_links.txt +0 -0
  20. {polysync-0.1.0 → polysync-0.2.0}/src/polysync.egg-info/entry_points.txt +0 -0
  21. {polysync-0.1.0 → polysync-0.2.0}/src/polysync.egg-info/requires.txt +0 -0
  22. {polysync-0.1.0 → polysync-0.2.0}/src/polysync.egg-info/top_level.txt +0 -0
  23. {polysync-0.1.0 → polysync-0.2.0}/tests/test_sync_synthetic.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: polysync
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Multicam audio sync and director-style auto-edit — align N angles of one event by audio cross-correlation, then cut/PiP them into one MP4. Reversible sidecars, never re-encodes the originals.
5
5
  Author: 王建硕 (Jian Shuo Wang)
6
6
  License: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "polysync"
7
- version = "0.1.0"
7
+ version = "0.2.0"
8
8
  description = "Multicam audio sync and director-style auto-edit — align N angles of one event by audio cross-correlation, then cut/PiP them into one MP4. Reversible sidecars, never re-encodes the originals."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -11,7 +11,7 @@ Public API:
11
11
  from .sync import compute_sync, SyncResult, SyncError
12
12
  from .sidecar import read_sidecar, write_sidecar, sidecar_path, SCHEMA_VERSION
13
13
 
14
- __version__ = "0.1.0"
14
+ __version__ = "0.2.0"
15
15
  __all__ = [
16
16
  "compute_sync", "SyncResult", "SyncError",
17
17
  "read_sidecar", "write_sidecar", "sidecar_path", "SCHEMA_VERSION",
@@ -0,0 +1,110 @@
1
+ """Color grading + orientation helpers for the renderers.
2
+
3
+ Raw camera footage almost never renders correctly straight off the card. Two
4
+ things bite every time and are handled here:
5
+
6
+ 1. **Log color.** Sony cameras (FX3/FX6) shoot S-Log3 / S-Gamut3.Cine by default
7
+ — flat, grey, low-contrast. It MUST be converted to Rec.709 with a LUT or it
8
+ looks broken. Check the `.XML` sidecar's `CaptureGammaEquation` (`s-log3-cine`)
9
+ or run `ffprobe ... color_transfer`. `--log slog3` generates and applies the
10
+ conversion LUT for you.
11
+ 2. **Orientation.** Phones / vertically-mounted cameras record rotated. Some
12
+ (FX3) write a rotation flag and ffmpeg auto-rotates; others (FX6 turned on its
13
+ side) write NO flag and come out lying down. `--rotate cam:deg` fixes those.
14
+
15
+ Performance note baked into `segment_filter`: the LUT is applied AFTER the
16
+ downscale, not before. A 3D LUT on 4K (8 MP) is ~4x slower than on 1080p — and
17
+ the result is visually identical. Always scale, then grade.
18
+ """
19
+ import os
20
+ import tempfile
21
+
22
+ import numpy as np
23
+
24
+
25
+ def make_slog3_709_lut(path, size=33):
26
+ """Write a Sony S-Log3 / S-Gamut3.Cine -> Rec.709 3D LUT (.cube) to `path`."""
27
+ def slog3_to_lin(n): # n in [0,1] == 10-bit code value / 1023
28
+ cv = n * 1023.0
29
+ return np.where(
30
+ cv >= 171.2102946929,
31
+ (10 ** ((cv - 420.0) / 261.5)) * 0.19 - 0.01,
32
+ (cv - 95.0) * 0.01125000 / (171.2102946929 - 95.0),
33
+ )
34
+ # S-Gamut3.Cine -> Rec.709 (linear) matrix, D65
35
+ M = np.array([[1.6269, -0.3576, -0.2693],
36
+ [-0.0928, 1.3478, -0.2550],
37
+ [0.0387, -0.1622, 1.1235]])
38
+ def oetf709(L):
39
+ L = np.clip(L, 0, 1)
40
+ return np.where(L < 0.018, 4.5 * L, 1.099 * np.power(L, 0.45) - 0.099)
41
+ lines = ["TITLE \"SLog3 SGamut3Cine to Rec709\"", f"LUT_3D_SIZE {size}",
42
+ "DOMAIN_MIN 0 0 0", "DOMAIN_MAX 1 1 1"]
43
+ for b in range(size):
44
+ for g in range(size):
45
+ for r in range(size):
46
+ lin = slog3_to_lin(np.array([r, g, b]) / (size - 1))
47
+ out = oetf709(M @ lin)
48
+ lines.append("%.6f %.6f %.6f" % (out[0], out[1], out[2]))
49
+ with open(path, "w") as f:
50
+ f.write("\n".join(lines) + "\n")
51
+ return path
52
+
53
+
54
+ # Built-in log profiles -> on-the-fly LUT generators. Cached in tempdir so
55
+ # repeated render calls in one session don't regenerate.
56
+ _BUILTIN = {"slog3": make_slog3_709_lut}
57
+
58
+
59
+ def resolve_lut(lut=None, log=None):
60
+ """Return a .cube path: explicit `lut` file wins; else generate from `log`."""
61
+ if lut:
62
+ return lut
63
+ if not log:
64
+ return None
65
+ key = log.lower()
66
+ if key not in _BUILTIN:
67
+ raise SystemExit("unknown --log %r (known: %s)" % (log, ", ".join(_BUILTIN)))
68
+ cache = os.path.join(tempfile.gettempdir(), "polysync_%s_709.cube" % key)
69
+ if not os.path.exists(cache):
70
+ _BUILTIN[key](cache)
71
+ return cache
72
+
73
+
74
+ def parse_rotate(values):
75
+ """Parse repeatable `--rotate cam:deg` into {cam_index: degrees}. Degrees in
76
+ {90, 180, 270, -90}. 90 = clockwise."""
77
+ out = {}
78
+ for v in (values or []):
79
+ cam, _, deg = v.partition(":")
80
+ out[int(cam)] = int(deg)
81
+ return out
82
+
83
+
84
+ def _transpose_chain(deg):
85
+ """ffmpeg filter fragment to rotate `deg` clockwise (90/180/270/-90)."""
86
+ deg = deg % 360
87
+ if deg == 90:
88
+ return "transpose=1,"
89
+ if deg == 270:
90
+ return "transpose=2,"
91
+ if deg == 180:
92
+ return "transpose=1,transpose=1,"
93
+ return ""
94
+
95
+
96
+ def segment_filter(cam, start, end, idx, W, H, fps, rotate_deg=0, lut=None,
97
+ pip=False):
98
+ """Build one segment's video filter chain. Order: trim -> rotate -> scale ->
99
+ crop/pad -> LUT (after downscale, for speed) -> sar -> fps. With `pip=True`
100
+ the frame fills (crop) instead of pad — used for main/inset tiles."""
101
+ rot = _transpose_chain(rotate_deg)
102
+ if pip:
103
+ fit = ("scale=%d:%d:force_original_aspect_ratio=increase,crop=%d:%d"
104
+ % (W, H, W, H))
105
+ else:
106
+ fit = ("scale=%d:%d:force_original_aspect_ratio=decrease,"
107
+ "pad=%d:%d:(ow-iw)/2:(oh-ih)/2" % (W, H, W, H))
108
+ grade = ("lut3d=%s," % lut) if lut else ""
109
+ return ("[%d:v]trim=start=%s:end=%s,setpts=PTS-STARTPTS,%s%s,%ssetsar=1,"
110
+ "fps=%d[v%d]" % (cam, start, end, rot, fit, grade, fps, idx))
@@ -2,21 +2,30 @@
2
2
 
3
3
  Applies each input's `delta` via `ffmpeg -itsoffset` so EDL times (reference
4
4
  timeline) work directly inside the filter graph — originals are read untouched.
5
+
6
+ Raw footage usually needs `--log slog3` (Sony S-Log3 -> Rec.709 grade) and, for
7
+ vertically-shot cameras with no rotation flag, `--rotate cam:90`. For vertical
8
+ delivery (小红书 / Reels / Shorts) pass `--width 1080 --height 1920 --fill`.
5
9
  """
6
10
  import argparse
7
11
  import json
8
12
  import subprocess
9
13
  from pathlib import Path
10
14
 
15
+ from .grade import resolve_lut, parse_rotate, segment_filter
16
+
11
17
 
12
18
  def render_cuts(edl_path, out, encoder="hevc_videotoolbox", bitrate="12M",
13
- width=1920, height=1080, fps=30, run=True):
19
+ width=1920, height=1080, fps=30, lut=None, log=None,
20
+ rotate=None, fill=False, run=True):
14
21
  plan = json.loads(Path(edl_path).read_text())
15
22
  inputs = plan["inputs"]
16
23
  deltas = plan.get("deltas", [0.0] * len(inputs))
17
24
  edl = plan["edl"]
18
25
  audio_src = plan["audio_source"]
19
26
  W, H = width, height
27
+ lut_path = resolve_lut(lut, log)
28
+ rot = parse_rotate(rotate)
20
29
 
21
30
  cmd = ["ffmpeg", "-nostdin", "-y"]
22
31
  for src, dlt in zip(inputs, deltas):
@@ -24,14 +33,11 @@ def render_cuts(edl_path, out, encoder="hevc_videotoolbox", bitrate="12M",
24
33
  cmd.extend(["-itsoffset", "%.6f" % dlt])
25
34
  cmd.extend(["-i", src])
26
35
 
27
- filters = []
28
- for i, row in enumerate(edl):
29
- filters.append(
30
- "[%d:v]trim=start=%s:end=%s,setpts=PTS-STARTPTS,"
31
- "scale=%d:%d:force_original_aspect_ratio=decrease,"
32
- "pad=%d:%d:(ow-iw)/2:(oh-ih)/2,setsar=1,fps=%d[v%d]"
33
- % (row["cam"], row["start"], row["end"], W, H, W, H, fps, i)
34
- )
36
+ filters = [
37
+ segment_filter(row["cam"], row["start"], row["end"], i, W, H, fps,
38
+ rotate_deg=rot.get(row["cam"], 0), lut=lut_path, pip=fill)
39
+ for i, row in enumerate(edl)
40
+ ]
35
41
  concat = "".join("[v%d]" % i for i in range(len(edl)))
36
42
  filters.append("%sconcat=n=%d:v=1:a=0[vout]" % (concat, len(edl)))
37
43
  fc = ";".join(filters)
@@ -63,9 +69,16 @@ def main(argv=None):
63
69
  ap.add_argument("--width", type=int, default=1920)
64
70
  ap.add_argument("--height", type=int, default=1080)
65
71
  ap.add_argument("--fps", type=int, default=30)
72
+ ap.add_argument("--lut", help="3D LUT (.cube) applied after downscale")
73
+ ap.add_argument("--log", help="built-in log->Rec.709 grade (e.g. slog3)")
74
+ ap.add_argument("--rotate", action="append",
75
+ help="per-cam rotation CAM:DEG (90=CW), repeatable")
76
+ ap.add_argument("--fill", action="store_true",
77
+ help="crop to fill instead of letterbox-pad (use for vertical)")
66
78
  args = ap.parse_args(argv)
67
79
  render_cuts(args.edl, args.out, encoder=args.encoder, bitrate=args.bitrate,
68
- width=args.width, height=args.height, fps=args.fps)
80
+ width=args.width, height=args.height, fps=args.fps,
81
+ lut=args.lut, log=args.log, rotate=args.rotate, fill=args.fill)
69
82
 
70
83
 
71
84
  if __name__ == "__main__":
@@ -11,6 +11,8 @@ import json
11
11
  import subprocess
12
12
  from pathlib import Path
13
13
 
14
+ from .grade import resolve_lut, parse_rotate, _transpose_chain
15
+
14
16
  POSITIONS = {
15
17
  "bottom-right": ("W-w-{m}", "H-h-{m}"),
16
18
  "top-right": ("W-w-{m}", "{m}"),
@@ -41,7 +43,7 @@ def pick_pip(row, K, coverage, mode="next"):
41
43
  def render_pip(edl_path, out, encoder="hevc_videotoolbox", bitrate="12M",
42
44
  width=1920, height=1080, fps=30, pip="bottom-right",
43
45
  pip_width=480, pip_margin=24, border_px=4, pip_pick="next",
44
- run=True):
46
+ lut=None, log=None, rotate=None, run=True):
45
47
  plan = json.loads(Path(edl_path).read_text())
46
48
  inputs = plan["inputs"]
47
49
  deltas = plan.get("deltas", [0.0] * len(inputs))
@@ -49,6 +51,9 @@ def render_pip(edl_path, out, encoder="hevc_videotoolbox", bitrate="12M",
49
51
  audio_src = plan["audio_source"]
50
52
  K = len(inputs)
51
53
  coverage = plan.get("coverage", [[0.0, plan["duration_sec"]]] * K)
54
+ lut_path = resolve_lut(lut, log)
55
+ rot = parse_rotate(rotate)
56
+ grade = ("lut3d=%s," % lut_path) if lut_path else ""
52
57
 
53
58
  W, H = width, height
54
59
  pw = pip_width
@@ -70,10 +75,11 @@ def render_pip(edl_path, out, encoder="hevc_videotoolbox", bitrate="12M",
70
75
  s, e = row["start"], row["end"]
71
76
  main_label = "m%d" % i if K > 1 else "v%d" % i
72
77
  filters.append(
73
- "[%d:v]trim=start=%s:end=%s,setpts=PTS-STARTPTS,"
78
+ "[%d:v]trim=start=%s:end=%s,setpts=PTS-STARTPTS,%s"
74
79
  "scale=%d:%d:force_original_aspect_ratio=decrease,"
75
- "pad=%d:%d:(ow-iw)/2:(oh-ih)/2,setsar=1,fps=%d[%s]"
76
- % (cam, s, e, W, H, W, H, fps, main_label)
80
+ "pad=%d:%d:(ow-iw)/2:(oh-ih)/2,%ssetsar=1,fps=%d[%s]"
81
+ % (cam, s, e, _transpose_chain(rot.get(cam, 0)),
82
+ W, H, W, H, grade, fps, main_label)
77
83
  )
78
84
  if K == 1:
79
85
  continue
@@ -82,10 +88,11 @@ def render_pip(edl_path, out, encoder="hevc_videotoolbox", bitrate="12M",
82
88
  filters.append("[m%d]copy[v%d]" % (i, i))
83
89
  continue
84
90
  chain = (
85
- "[%d:v]trim=start=%s:end=%s,setpts=PTS-STARTPTS,"
91
+ "[%d:v]trim=start=%s:end=%s,setpts=PTS-STARTPTS,%s"
86
92
  "scale=%d:%d:force_original_aspect_ratio=decrease,"
87
- "pad=%d:%d:(ow-iw)/2:(oh-ih)/2,"
88
- % (pip_cam, s, e, pw, ph, pw, ph)
93
+ "pad=%d:%d:(ow-iw)/2:(oh-ih)/2,%s"
94
+ % (pip_cam, s, e, _transpose_chain(rot.get(pip_cam, 0)),
95
+ pw, ph, pw, ph, grade)
89
96
  )
90
97
  if bw > 0:
91
98
  chain += "pad=%d:%d:%d:%d:white," % (pw + 2 * bw, ph + 2 * bw, bw, bw)
@@ -130,11 +137,16 @@ def main(argv=None):
130
137
  ap.add_argument("--pip-margin", type=int, default=24)
131
138
  ap.add_argument("--border-px", type=int, default=4)
132
139
  ap.add_argument("--pip-pick", choices=["next", "second-best"], default="next")
140
+ ap.add_argument("--lut", help="3D LUT (.cube) applied after downscale")
141
+ ap.add_argument("--log", help="built-in log->Rec.709 grade (e.g. slog3)")
142
+ ap.add_argument("--rotate", action="append",
143
+ help="per-cam rotation CAM:DEG (90=CW), repeatable")
133
144
  args = ap.parse_args(argv)
134
145
  render_pip(args.edl, args.out, encoder=args.encoder, bitrate=args.bitrate,
135
146
  width=args.width, height=args.height, fps=args.fps, pip=args.pip,
136
147
  pip_width=args.pip_width, pip_margin=args.pip_margin,
137
- border_px=args.border_px, pip_pick=args.pip_pick)
148
+ border_px=args.border_px, pip_pick=args.pip_pick,
149
+ lut=args.lut, log=args.log, rotate=args.rotate)
138
150
 
139
151
 
140
152
  if __name__ == "__main__":
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: polysync
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Multicam audio sync and director-style auto-edit — align N angles of one event by audio cross-correlation, then cut/PiP them into one MP4. Reversible sidecars, never re-encodes the originals.
5
5
  Author: 王建硕 (Jian Shuo Wang)
6
6
  License: MIT
@@ -15,6 +15,7 @@ src/polysync.egg-info/requires.txt
15
15
  src/polysync.egg-info/top_level.txt
16
16
  src/polysync/edit/__init__.py
17
17
  src/polysync/edit/autoedit.py
18
+ src/polysync/edit/grade.py
18
19
  src/polysync/edit/render_cuts.py
19
20
  src/polysync/edit/render_pip.py
20
21
  tests/test_sync_synthetic.py
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes