davinci-resolve-mcp 2.69.3 → 2.70.0
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.
- package/CHANGELOG.md +85 -0
- package/README.md +1 -1
- package/docs/SKILL.md +78 -1
- package/docs/guides/headless-edit-loop.md +238 -0
- package/docs/reference/api-limitations.md +65 -4
- package/docs/reference/headless-capability-matrix.md +56 -0
- package/docs/reference/headless-cli.md +751 -0
- package/docs/reference/pixel-equality.md +11 -0
- package/docs/reference/roundtrip-fidelity.md +17 -0
- package/docs/reference/roundtrip-rich-fidelity.md +18 -0
- package/install.py +1 -1
- package/package.json +1 -1
- package/scripts/bisect_headless_hang.py +125 -0
- package/scripts/contact_sheet.py +209 -0
- package/scripts/headless_differential.py +578 -0
- package/scripts/mode_matrix.py +345 -0
- package/scripts/mode_matrix_worker.py +232 -0
- package/scripts/pixel_equality.py +402 -0
- package/scripts/relink_moved_media.py +267 -0
- package/scripts/render_stress.py +542 -0
- package/scripts/resolve_headless.py +225 -0
- package/scripts/roundtrip_matrix.py +434 -0
- package/scripts/roundtrip_rich.py +431 -0
- package/src/granular/common.py +1 -1
- package/src/server.py +92 -67
- package/src/utils/api_truth.py +302 -4
- package/src/utils/headless_differential.py +363 -0
- package/src/utils/mode_matrix.py +338 -0
- package/src/utils/probe_catalogue.py +796 -0
- package/src/utils/project_cleanup.py +61 -9
- package/src/utils/resolve_runtime.py +233 -0
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# Pixel equality: GUI vs headless rendered frames
|
|
2
|
+
|
|
3
|
+
Frames are compared after decoding (ffmpeg `framemd5`), so container metadata cannot influence the result. `effective` is the within-mode control: whether the effect changed the picture at all relative to `plain`. An effect that is not effective makes its mode comparison meaningless, and is called out rather than counted as a pass.
|
|
4
|
+
|
|
5
|
+
| effect | effective (GUI) | effective (headless) | pixels identical across modes |
|
|
6
|
+
| --- | --- | --- | --- |
|
|
7
|
+
| plain | — | — | **identical** |
|
|
8
|
+
| transform | True | True | **identical** |
|
|
9
|
+
| cdl | True | True | **identical** |
|
|
10
|
+
| lut | True | True | **identical** |
|
|
11
|
+
| fusion_comp | True | True | **identical** |
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# Timeline round-trip fidelity: export, re-import, compare
|
|
2
|
+
|
|
3
|
+
`exact` = every item's record start, duration and **source in-point** survived, with all media linked. Anything else is unusable for an iterative edit loop.
|
|
4
|
+
|
|
5
|
+
| format | link strategy | GUI | headless |
|
|
6
|
+
| --- | --- | --- | --- |
|
|
7
|
+
| drt | reuse_pool | exact | exact |
|
|
8
|
+
| fcpxml_1_10 | reuse_pool | exact | exact |
|
|
9
|
+
| fcpxml_1_10 | import_source | exact | exact |
|
|
10
|
+
| fcp7xml | reuse_pool | exact | exact |
|
|
11
|
+
| fcp7xml | import_source | exact | exact |
|
|
12
|
+
| aaf | reuse_pool | exact | exact |
|
|
13
|
+
| aaf | import_source | exact | exact |
|
|
14
|
+
| otio | reuse_pool | exact-but-4-offline | exact-but-4-offline |
|
|
15
|
+
| otio | import_source | exact | exact |
|
|
16
|
+
| edl | reuse_pool | exact | exact |
|
|
17
|
+
| edl | import_source | exact | exact |
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# Complex-cut round trip: what each format keeps
|
|
2
|
+
|
|
3
|
+
Two video tracks, audio, a per-item transform, clip colours, flags, item and timeline markers. `kept` = identical to the reference; `n/a` = the API would not set that dimension, so no format can be blamed for losing it.
|
|
4
|
+
|
|
5
|
+
| format | mode | cut | tracks | transform | colour | flags | markers | timeline_markers | linkage |
|
|
6
|
+
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
|
|
7
|
+
| drt | gui | kept | kept | kept | kept | kept | kept | kept | kept |
|
|
8
|
+
| drt | headless | kept | kept | kept | kept | kept | kept | kept | kept |
|
|
9
|
+
| fcpxml_1_10 | gui | kept | lost:2v1a!=2v2a | kept | lost | kept | lost | lost | kept |
|
|
10
|
+
| fcpxml_1_10 | headless | kept | lost:2v1a!=2v2a | kept | lost | kept | lost | lost | kept |
|
|
11
|
+
| fcp7xml | gui | kept | kept | lost:FlipX | lost | kept | lost | lost | kept |
|
|
12
|
+
| fcp7xml | headless | kept | kept | lost:FlipX | lost | kept | lost | lost | kept |
|
|
13
|
+
| aaf | gui | kept | kept | lost:all | lost | kept | lost | kept | kept |
|
|
14
|
+
| aaf | headless | kept | kept | lost:all | lost | kept | lost | kept | kept |
|
|
15
|
+
| otio | gui | lost:source | kept | kept | lost | lost | kept | kept | offline:4 |
|
|
16
|
+
| otio | headless | lost:source | kept | kept | lost | lost | kept | kept | offline:4 |
|
|
17
|
+
| edl | gui | lost | lost:1v1a!=2v2a | lost:item-count | lost | lost | lost | lost | kept |
|
|
18
|
+
| edl | headless | lost | lost:1v1a!=2v2a | lost:item-count | lost | lost | lost | lost | kept |
|
package/install.py
CHANGED
|
@@ -36,7 +36,7 @@ from src.utils.update_check import (
|
|
|
36
36
|
|
|
37
37
|
# ─── Version ──────────────────────────────────────────────────────────────────
|
|
38
38
|
|
|
39
|
-
VERSION = "2.
|
|
39
|
+
VERSION = "2.70.0"
|
|
40
40
|
# Only hard floor: mcp[cli] requires Python 3.10+. There is no upper bound —
|
|
41
41
|
# Resolve's scripting bridge loads into newer interpreters on recent builds
|
|
42
42
|
# (Python 3.14 verified against Resolve Studio 20.3.2). Older Resolve builds
|
package/package.json
CHANGED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Walk the stress harness's setup sequence one call at a time, on a cold headless Resolve.
|
|
2
|
+
|
|
3
|
+
Two things are recorded around EVERY call: how long it took, and whether the
|
|
4
|
+
current database is still attached afterwards. The hang and the null database
|
|
5
|
+
have so far only been seen together, from two different clients, so neither is
|
|
6
|
+
known to cause the other. Checking the database between every step is what turns
|
|
7
|
+
that correlation into an ordering — if the database goes null one step *before*
|
|
8
|
+
the hang, it is a cause; if it only goes null once something is already blocked,
|
|
9
|
+
it is a symptom.
|
|
10
|
+
|
|
11
|
+
Each step has its own alarm, so a hang is reported with the culprit named instead
|
|
12
|
+
of parking the process forever.
|
|
13
|
+
"""
|
|
14
|
+
import json
|
|
15
|
+
import signal
|
|
16
|
+
import sys
|
|
17
|
+
import time
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
sys.path.append(
|
|
21
|
+
"/Library/Application Support/Blackmagic Design/DaVinci Resolve/Developer/Scripting/Modules"
|
|
22
|
+
)
|
|
23
|
+
import DaVinciResolveScript as dvr # noqa: E402
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Hang(Exception):
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _alarm(*_):
|
|
31
|
+
raise Hang()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
signal.signal(signal.SIGALRM, _alarm)
|
|
35
|
+
RESULTS = []
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def step(label, fn, secs=45, pm=None):
|
|
39
|
+
"""Run one call, timed, with the database checked immediately afterwards."""
|
|
40
|
+
signal.alarm(secs)
|
|
41
|
+
started = time.monotonic()
|
|
42
|
+
outcome, value = "ok", None
|
|
43
|
+
try:
|
|
44
|
+
value = fn()
|
|
45
|
+
except Hang:
|
|
46
|
+
outcome = "HUNG"
|
|
47
|
+
except Exception as exc:
|
|
48
|
+
outcome, value = "error", f"{type(exc).__name__}: {exc}"
|
|
49
|
+
finally:
|
|
50
|
+
signal.alarm(0)
|
|
51
|
+
elapsed = round(time.monotonic() - started, 1)
|
|
52
|
+
|
|
53
|
+
db = "unchecked"
|
|
54
|
+
if pm is not None and outcome != "HUNG":
|
|
55
|
+
signal.alarm(20)
|
|
56
|
+
try:
|
|
57
|
+
db = pm.GetCurrentDatabase()
|
|
58
|
+
except Hang:
|
|
59
|
+
db = "HUNG-checking-db"
|
|
60
|
+
except Exception as exc:
|
|
61
|
+
db = f"error: {type(exc).__name__}"
|
|
62
|
+
finally:
|
|
63
|
+
signal.alarm(0)
|
|
64
|
+
|
|
65
|
+
record = {"step": label, "outcome": outcome, "seconds": elapsed,
|
|
66
|
+
"value": repr(value)[:120], "database_after": db}
|
|
67
|
+
RESULTS.append(record)
|
|
68
|
+
print(json.dumps(record), flush=True)
|
|
69
|
+
if outcome == "HUNG":
|
|
70
|
+
print(f"\n>>> CULPRIT: {label} hung after {secs}s", flush=True)
|
|
71
|
+
dump()
|
|
72
|
+
raise SystemExit(9)
|
|
73
|
+
return value
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def dump():
|
|
77
|
+
Path(sys.argv[1]).write_text(json.dumps(RESULTS, indent=2, default=str))
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def main():
|
|
81
|
+
resolve = None
|
|
82
|
+
for _ in range(420):
|
|
83
|
+
resolve = dvr.scriptapp("Resolve")
|
|
84
|
+
if resolve is not None:
|
|
85
|
+
break
|
|
86
|
+
time.sleep(1)
|
|
87
|
+
if resolve is None:
|
|
88
|
+
raise SystemExit("no Resolve")
|
|
89
|
+
|
|
90
|
+
pm = step("GetProjectManager", resolve.GetProjectManager, pm=None)
|
|
91
|
+
step("GetCurrentDatabase (baseline)", pm.GetCurrentDatabase, pm=pm)
|
|
92
|
+
step("GetProjectListInCurrentFolder", lambda: len(pm.GetProjectListInCurrentFolder() or []), pm=pm)
|
|
93
|
+
incumbent = step("GetCurrentProject().GetName()",
|
|
94
|
+
lambda: (pm.GetCurrentProject().GetName() if pm.GetCurrentProject() else None), pm=pm)
|
|
95
|
+
# The first suspect: SaveProject on the never-saved default project. It is
|
|
96
|
+
# the first mutating call the harness makes, and it is documented to return
|
|
97
|
+
# False for exactly this project.
|
|
98
|
+
step("SaveProject (incumbent)", pm.SaveProject, pm=pm)
|
|
99
|
+
|
|
100
|
+
name = "COLD_HEADLESS_PROBE"
|
|
101
|
+
step("CreateProject", lambda: bool(pm.CreateProject(name)), pm=pm)
|
|
102
|
+
step("LoadProject", lambda: bool(pm.LoadProject(name)), pm=pm)
|
|
103
|
+
project = step("GetCurrentProject", pm.GetCurrentProject, pm=pm)
|
|
104
|
+
if project is None:
|
|
105
|
+
dump()
|
|
106
|
+
raise SystemExit("no project after LoadProject")
|
|
107
|
+
|
|
108
|
+
step("GetMediaStorage", resolve.GetMediaStorage, pm=pm)
|
|
109
|
+
step("project.GetMediaPool", project.GetMediaPool, pm=pm)
|
|
110
|
+
step("GetRenderFormats", lambda: len(project.GetRenderFormats() or {}), pm=pm)
|
|
111
|
+
step("CreateEmptyTimeline", lambda: bool(project.GetMediaPool().CreateEmptyTimeline("COLD_TL")), pm=pm)
|
|
112
|
+
|
|
113
|
+
# Teardown, itself instrumented — the harness's teardown is as likely a
|
|
114
|
+
# suspect as its setup, and it runs on every iteration boundary.
|
|
115
|
+
step("SaveProject (scratch)", pm.SaveProject, pm=pm)
|
|
116
|
+
step("CloseProject", lambda: bool(pm.CloseProject(pm.GetCurrentProject())), pm=pm)
|
|
117
|
+
if incumbent:
|
|
118
|
+
step("LoadProject (incumbent)", lambda: bool(pm.LoadProject(incumbent)), pm=pm)
|
|
119
|
+
step("DeleteProject", lambda: bool(pm.DeleteProject(name)), pm=pm)
|
|
120
|
+
dump()
|
|
121
|
+
print("\n>>> COMPLETED WITH NO HANG", flush=True)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
if __name__ == "__main__":
|
|
125
|
+
main()
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Labelled contact sheets from a media_analysis frames directory.
|
|
3
|
+
|
|
4
|
+
Solves a concrete cost problem in the host-chat vision loop. `media_analysis`
|
|
5
|
+
extracts sampled frames at FULL source resolution — a 35-minute 4K clip yields
|
|
6
|
+
80 frames at 2160x3840, ~280 KB each. Reading those one at a time to satisfy
|
|
7
|
+
`commit_vision` costs roughly 1,100 tokens per frame; a four-clip shoot runs to
|
|
8
|
+
250+ frames and several hundred thousand tokens.
|
|
9
|
+
|
|
10
|
+
Tiling them into labelled grids drops that by an order of magnitude while
|
|
11
|
+
keeping every frame visible. Each tile is burned with its frame index, source
|
|
12
|
+
timestamp and the sampler's `selection_reason`, so per-frame findings can still
|
|
13
|
+
be reported by index against the schema.
|
|
14
|
+
|
|
15
|
+
Source-safe: reads only the analysis scratch directory and the sidecar
|
|
16
|
+
`visual.json`; writes only into `out_dir`. Never touches source media.
|
|
17
|
+
|
|
18
|
+
Run: `python3 scripts/contact_sheet.py <clip_analysis_dir> <out_dir>`
|
|
19
|
+
where `<clip_analysis_dir>` is the directory holding `visual.json` and
|
|
20
|
+
`frames/` (printed as `artifacts.clip_dir` by `media_analysis`).
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import argparse
|
|
26
|
+
import json
|
|
27
|
+
import os
|
|
28
|
+
from typing import Any, Dict, List, Tuple
|
|
29
|
+
|
|
30
|
+
try:
|
|
31
|
+
from PIL import Image, ImageDraw, ImageFont
|
|
32
|
+
except ImportError: # pragma: no cover - guidance only, never auto-installs
|
|
33
|
+
raise SystemExit(
|
|
34
|
+
"Pillow is required: pip install Pillow "
|
|
35
|
+
"(source-safe; used only to tile analysis scratch frames)"
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
LABEL_HEIGHT = 26
|
|
39
|
+
FONT_CANDIDATES = (
|
|
40
|
+
"/System/Library/Fonts/Supplemental/Arial Bold.ttf",
|
|
41
|
+
"/System/Library/Fonts/Helvetica.ttc",
|
|
42
|
+
"/Library/Fonts/Arial.ttf",
|
|
43
|
+
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _load_font(size: int):
|
|
48
|
+
for path in FONT_CANDIDATES:
|
|
49
|
+
if os.path.exists(path):
|
|
50
|
+
try:
|
|
51
|
+
return ImageFont.truetype(path, size)
|
|
52
|
+
except OSError:
|
|
53
|
+
continue
|
|
54
|
+
return ImageFont.load_default()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _timestamp(seconds: Any) -> str:
|
|
58
|
+
if seconds is None:
|
|
59
|
+
return "?"
|
|
60
|
+
minutes, secs = divmod(float(seconds), 60)
|
|
61
|
+
return "%d:%05.2f" % (int(minutes), secs)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _read_json(path: str) -> Dict[str, Any]:
|
|
65
|
+
try:
|
|
66
|
+
with open(path, encoding="utf-8") as handle:
|
|
67
|
+
return json.load(handle)
|
|
68
|
+
except (OSError, ValueError):
|
|
69
|
+
return {}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def load_frames(clip_dir: str) -> Tuple[List[str], Dict[int, Dict[str, Any]]]:
|
|
73
|
+
"""Resolve frame paths + per-frame labels across all three artifact states.
|
|
74
|
+
|
|
75
|
+
Order matters. While vision is pending, visual.json's `frame_paths` IS the
|
|
76
|
+
payload `commit_vision` expects findings reported against, so its indices
|
|
77
|
+
must win — analysis.json holds a larger superset (vision sample plus
|
|
78
|
+
cut-boundary frames) whose numbering would not line up.
|
|
79
|
+
|
|
80
|
+
`commit_vision` then REPLACES visual.json with the committed analysis,
|
|
81
|
+
dropping `frame_paths`. After that analysis.json's `analysis_keyframes` is
|
|
82
|
+
the richest surviving source, and index alignment no longer matters because
|
|
83
|
+
there is no vision payload left to report against.
|
|
84
|
+
"""
|
|
85
|
+
visual = _read_json(os.path.join(clip_dir, "visual.json"))
|
|
86
|
+
paths = [p for p in visual.get("frame_paths", []) if os.path.exists(p)]
|
|
87
|
+
if paths:
|
|
88
|
+
meta = {
|
|
89
|
+
m["frame_index"]: m for m in visual.get("frame_metadata", [])
|
|
90
|
+
}
|
|
91
|
+
return paths, meta
|
|
92
|
+
|
|
93
|
+
keyframes = _read_json(os.path.join(clip_dir, "analysis.json")).get(
|
|
94
|
+
"analysis_keyframes"
|
|
95
|
+
)
|
|
96
|
+
if keyframes:
|
|
97
|
+
paths, meta = [], {}
|
|
98
|
+
for position, entry in enumerate(keyframes, start=1):
|
|
99
|
+
frame_path = entry.get("frame_path")
|
|
100
|
+
if not frame_path or not os.path.exists(frame_path):
|
|
101
|
+
continue
|
|
102
|
+
paths.append(frame_path)
|
|
103
|
+
meta[len(paths)] = {
|
|
104
|
+
"time_seconds": entry.get("time_seconds"),
|
|
105
|
+
"selection_reason": entry.get("selection_reason"),
|
|
106
|
+
"frame_index": entry.get("index", position),
|
|
107
|
+
}
|
|
108
|
+
if paths:
|
|
109
|
+
return paths, meta
|
|
110
|
+
|
|
111
|
+
frames_dir = os.path.join(clip_dir, "frames")
|
|
112
|
+
if os.path.isdir(frames_dir):
|
|
113
|
+
listed = sorted(
|
|
114
|
+
os.path.join(frames_dir, n)
|
|
115
|
+
for n in os.listdir(frames_dir)
|
|
116
|
+
if n.lower().endswith((".jpg", ".jpeg", ".png"))
|
|
117
|
+
)
|
|
118
|
+
return listed, {}
|
|
119
|
+
|
|
120
|
+
return [], {}
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def build_sheets(
|
|
124
|
+
clip_dir: str,
|
|
125
|
+
out_dir: str,
|
|
126
|
+
tile_width: int = 300,
|
|
127
|
+
columns: int = 6,
|
|
128
|
+
rows: int = 3,
|
|
129
|
+
) -> Tuple[List[Tuple[str, int, int]], int]:
|
|
130
|
+
"""Tile every sampled frame into labelled sheets. Returns (sheets, frame_count)."""
|
|
131
|
+
paths, meta = load_frames(clip_dir)
|
|
132
|
+
if not paths:
|
|
133
|
+
return [], 0
|
|
134
|
+
|
|
135
|
+
per_sheet = columns * rows
|
|
136
|
+
os.makedirs(out_dir, exist_ok=True)
|
|
137
|
+
font = _load_font(17)
|
|
138
|
+
|
|
139
|
+
# Derive tile height from a real frame — never assume 16:9. Vertical phone
|
|
140
|
+
# footage arrives here already rotated to portrait by ffmpeg.
|
|
141
|
+
with Image.open(paths[0]) as probe:
|
|
142
|
+
tile_height = max(1, round(tile_width * probe.height / probe.width))
|
|
143
|
+
|
|
144
|
+
sheets: List[Tuple[str, int, int]] = []
|
|
145
|
+
for offset in range(0, len(paths), per_sheet):
|
|
146
|
+
chunk = paths[offset:offset + per_sheet]
|
|
147
|
+
used_rows = (len(chunk) + columns - 1) // columns
|
|
148
|
+
sheet = Image.new(
|
|
149
|
+
"RGB",
|
|
150
|
+
(columns * tile_width, used_rows * (tile_height + LABEL_HEIGHT)),
|
|
151
|
+
(18, 18, 18),
|
|
152
|
+
)
|
|
153
|
+
draw = ImageDraw.Draw(sheet)
|
|
154
|
+
|
|
155
|
+
for position, frame_path in enumerate(chunk):
|
|
156
|
+
index = offset + position + 1
|
|
157
|
+
row, column = divmod(position, columns)
|
|
158
|
+
x = column * tile_width
|
|
159
|
+
y = row * (tile_height + LABEL_HEIGHT)
|
|
160
|
+
with Image.open(frame_path) as frame:
|
|
161
|
+
frame = frame.convert("RGB").resize(
|
|
162
|
+
(tile_width, tile_height), Image.LANCZOS
|
|
163
|
+
)
|
|
164
|
+
sheet.paste(frame, (x, y + LABEL_HEIGHT))
|
|
165
|
+
entry = meta.get(index, {})
|
|
166
|
+
reason = (entry.get("selection_reason") or "")[:14]
|
|
167
|
+
draw.text(
|
|
168
|
+
(x + 5, y + 4),
|
|
169
|
+
"#%d %s %s" % (index, _timestamp(entry.get("time_seconds")), reason),
|
|
170
|
+
fill=(255, 220, 90),
|
|
171
|
+
font=font,
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
out_path = os.path.join(out_dir, "sheet_%02d.jpg" % (offset // per_sheet + 1))
|
|
175
|
+
sheet.save(out_path, quality=82, optimize=True)
|
|
176
|
+
sheets.append((out_path, offset + 1, offset + len(chunk)))
|
|
177
|
+
|
|
178
|
+
return sheets, len(paths)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def main() -> int:
|
|
182
|
+
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
|
183
|
+
parser.add_argument("clip_dir", help="directory containing visual.json and frames/")
|
|
184
|
+
parser.add_argument("out_dir", help="where to write the contact sheets")
|
|
185
|
+
parser.add_argument("--tile-width", type=int, default=300)
|
|
186
|
+
parser.add_argument("--columns", type=int, default=6)
|
|
187
|
+
parser.add_argument("--rows", type=int, default=3)
|
|
188
|
+
args = parser.parse_args()
|
|
189
|
+
|
|
190
|
+
sheets, frame_count = build_sheets(
|
|
191
|
+
args.clip_dir, args.out_dir, args.tile_width, args.columns, args.rows
|
|
192
|
+
)
|
|
193
|
+
if not frame_count:
|
|
194
|
+
print(
|
|
195
|
+
"no sampled frames found in %s\n"
|
|
196
|
+
"expected analysis.json, visual.json, or a frames/ directory "
|
|
197
|
+
"(use the clip_dir printed as artifacts.clip_dir by media_analysis)"
|
|
198
|
+
% args.clip_dir
|
|
199
|
+
)
|
|
200
|
+
return 1
|
|
201
|
+
|
|
202
|
+
print("frames=%d sheets=%d" % (frame_count, len(sheets)))
|
|
203
|
+
for path, first, last in sheets:
|
|
204
|
+
print("%s frames %d-%d" % (path, first, last))
|
|
205
|
+
return 0
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
if __name__ == "__main__":
|
|
209
|
+
raise SystemExit(main())
|