@arbidocs/blocks 0.4.106 → 0.4.108
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/bin/arbidocs-render-take.mjs +27 -0
- package/dist/NativeDocxEditor-UAZUKZ77.js +10 -0
- package/dist/NativeDocxEditor-UAZUKZ77.js.map +1 -0
- package/dist/NativeDocxEditor-YX7B7OFH.cjs +31 -0
- package/dist/NativeDocxEditor-YX7B7OFH.cjs.map +1 -0
- package/dist/{chunk-W2RTZOK3.js → chunk-A2MN2ZLX.js} +1750 -1779
- package/dist/chunk-A2MN2ZLX.js.map +1 -0
- package/dist/{chunk-3R6XBYL7.cjs → chunk-DTBSGG36.cjs} +7 -2
- package/dist/chunk-DTBSGG36.cjs.map +1 -0
- package/dist/{chunk-4GFB5U3K.js → chunk-EGDWNEAG.js} +4 -3
- package/dist/chunk-EGDWNEAG.js.map +1 -0
- package/dist/chunk-GYAUJ4KT.js +71 -0
- package/dist/chunk-GYAUJ4KT.js.map +1 -0
- package/dist/{chunk-PIT2YZCK.js → chunk-JQVPGMLU.js} +3 -3
- package/dist/{chunk-PIT2YZCK.js.map → chunk-JQVPGMLU.js.map} +1 -1
- package/dist/{chunk-LGX6UFYG.js → chunk-P2KPDPFF.js} +8 -3
- package/dist/chunk-P2KPDPFF.js.map +1 -0
- package/dist/{chunk-ZWWN4CUL.cjs → chunk-QT6EGMAJ.cjs} +1748 -1778
- package/dist/chunk-QT6EGMAJ.cjs.map +1 -0
- package/dist/chunk-S24XEVPS.cjs +73 -0
- package/dist/chunk-S24XEVPS.cjs.map +1 -0
- package/dist/{chunk-TP3FAP5J.cjs → chunk-TTXK62YI.cjs} +5 -5
- package/dist/{chunk-TP3FAP5J.cjs.map → chunk-TTXK62YI.cjs.map} +1 -1
- package/dist/{chunk-7LLLQUM7.cjs → chunk-ZYSY4L5L.cjs} +6 -5
- package/dist/chunk-ZYSY4L5L.cjs.map +1 -0
- package/dist/collaboration/index.cjs +15 -15
- package/dist/collaboration/index.js +1 -1
- package/dist/collaboration/mail/MailMessageView.d.ts +3 -1
- package/dist/connect/docx.cjs +7 -6
- package/dist/connect/docx.cjs.map +1 -1
- package/dist/connect/docx.js +5 -4
- package/dist/connect/docx.js.map +1 -1
- package/dist/connect/index.cjs +368 -52
- package/dist/connect/index.cjs.map +1 -1
- package/dist/connect/index.js +356 -41
- package/dist/connect/index.js.map +1 -1
- package/dist/connection/mail/ConnectedMailComposer.d.ts +12 -2
- package/dist/connection/mail/ConnectedMailMessage.d.ts +19 -0
- package/dist/connection/mail/ConnectedMailbox.d.ts +7 -3
- package/dist/connection/mail/MailComposerForm.d.ts +12 -2
- package/dist/connection/mail/index.d.ts +2 -1
- package/dist/connection/mail/types.d.ts +15 -0
- package/dist/connection/mail/useWordBody.d.ts +48 -0
- package/dist/docx/NativeDocxEditor.d.ts +9 -1
- package/dist/docx/index.cjs +51 -50
- package/dist/docx/index.cjs.map +1 -1
- package/dist/docx/index.js +4 -3
- package/dist/docx/index.js.map +1 -1
- package/dist/docx/template/HouseTemplate.d.ts +5 -0
- package/dist/index.cjs +15 -15
- package/dist/index.js +1 -1
- package/dist/record/index.cjs +307 -66
- package/dist/record/index.cjs.map +1 -1
- package/dist/record/index.d.ts +145 -26
- package/dist/record/index.js +301 -67
- package/dist/record/index.js.map +1 -1
- package/node_modules/@arbidocs/coedit/package.json +1 -1
- package/package.json +8 -5
- package/record/render-overlays.mjs +152 -0
- package/record/render-take.py +985 -0
- package/src/docx/styles.css +22 -0
- package/dist/chunk-3R6XBYL7.cjs.map +0 -1
- package/dist/chunk-4GFB5U3K.js.map +0 -1
- package/dist/chunk-7LLLQUM7.cjs.map +0 -1
- package/dist/chunk-LGX6UFYG.js.map +0 -1
- package/dist/chunk-W2RTZOK3.js.map +0 -1
- package/dist/chunk-ZWWN4CUL.cjs.map +0 -1
|
@@ -0,0 +1,985 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Render a recorded take into the demo video: cut the long waits, zoom and pan onto the action,
|
|
3
|
+
picture-in-picture for long typing, and the brand overlays (title and chapter cards, lower-thirds,
|
|
4
|
+
captions, callouts). Everything else stays at real speed.
|
|
5
|
+
|
|
6
|
+
Usage:
|
|
7
|
+
python3 -I render-take.py <take.webm> <out.mp4> --waits <story>.waits.json [--focus <story>.focus.json]
|
|
8
|
+
[--story <story>.json] [--no-zoom] [--max-zoom 1.8] [--no-pip] [--head 3] [--tail 1.5] [--min 5]
|
|
9
|
+
[--stills <dir>] [--plan <plan.json>]
|
|
10
|
+
|
|
11
|
+
Inputs are what a take writes (`@arbidocs/blocks/record`: `cine.take`), in seconds from the take's
|
|
12
|
+
start: `waits.json` ({"waits": [{label, start, end}]}) and `focus.json` (the focus timeline: each
|
|
13
|
+
click, type and read with its target's box, the key moments `take.focus()` marked, the cards,
|
|
14
|
+
lower-thirds and captions, and the story's config).
|
|
15
|
+
|
|
16
|
+
A take recorded before the focus timeline existed has none: the renderer then finds the action
|
|
17
|
+
from the video itself (`track_cursor`: the kit's green cursor, its click ripples, typing beside a
|
|
18
|
+
resting cursor, the page moving, the caption bar and the cards), and `--story` gives the text of its
|
|
19
|
+
title, chapter and closing cards to lay over the dark card spans it finds. The guards are stricter
|
|
20
|
+
there: a zoom needs a click or typing the video shows, never runs while the cursor is lost or across
|
|
21
|
+
a scroll or reflow, and stays above a caption bar that is in the frames (which is pasted back).
|
|
22
|
+
|
|
23
|
+
How it works:
|
|
24
|
+
1. Sync: the take paints a one-frame marker at a logged time; finding it in the video lines the
|
|
25
|
+
take's clock up with the video's.
|
|
26
|
+
2. Cuts: the middle of each wait longer than --min (keeping --head s of it and its last --tail s),
|
|
27
|
+
the frames before the title card, the overlong part of a card (the setup done behind it), and
|
|
28
|
+
everything after the closing card has held.
|
|
29
|
+
3. Camera: focus events are mapped through the cuts. Typing, key moments and dwells start a zoom
|
|
30
|
+
shot (eased in over ~0.45 s to at most --max-zoom around the target, clamped in the frame);
|
|
31
|
+
clicks and targets close behind join it as a pan, or widen it when both fit; it holds at least
|
|
32
|
+
1.6 s, then eases out. Long typing gets a picture-in-picture inset instead, the full frame kept.
|
|
33
|
+
4. Frames: ffmpeg decodes the cut take; numpy/OpenCV draw callouts (in page space, so they zoom
|
|
34
|
+
with the page), the camera and the inset; ffmpeg encodes H.264 1080p, overlaying the brand
|
|
35
|
+
PNGs (render-overlays.mjs, styled with the brand the take recorded) in their `enable` windows.
|
|
36
|
+
"""
|
|
37
|
+
import argparse
|
|
38
|
+
import json
|
|
39
|
+
import math
|
|
40
|
+
import os
|
|
41
|
+
import shutil
|
|
42
|
+
import subprocess
|
|
43
|
+
import sys
|
|
44
|
+
import tempfile
|
|
45
|
+
|
|
46
|
+
import cv2
|
|
47
|
+
import numpy as np
|
|
48
|
+
|
|
49
|
+
FPS = 25
|
|
50
|
+
W, H = 1920, 1080
|
|
51
|
+
INK_BGR = (26, 16, 8) # brand ink #08101a, for letterboxing a take of another shape
|
|
52
|
+
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# Camera
|
|
56
|
+
EASE_IN, PAN, EASE_OUT = 0.45, 0.6, 0.55
|
|
57
|
+
MIN_HOLD = 1.6 # a shot holds at least this long once in, so it never flickers
|
|
58
|
+
JOIN_GAP = 1.6 # a target this soon after the shot's last one pans instead of out-and-in
|
|
59
|
+
MIN_ZOOM = 1.25 # less than this is not worth moving the camera for
|
|
60
|
+
DWELL_MIN = 1.5 # a glide-and-look (point/read) starts a shot only if it is held this long
|
|
61
|
+
TYPE_MIN = 0.9 # typing shorter than this (a number, a short code) does not start a shot
|
|
62
|
+
POINT_MAX = 5.0
|
|
63
|
+
# Picture-in-picture
|
|
64
|
+
PIP_MIN = 3.0 # typing at least this long gets an inset instead of a zoom
|
|
65
|
+
PIP_W = 0.40 * W
|
|
66
|
+
PIP_MARGIN, PIP_BOTTOM = 40, 150 # bottom corners sit above the caption band
|
|
67
|
+
PIP_RADIUS, PIP_FADE = 20, 0.3
|
|
68
|
+
# Overlays
|
|
69
|
+
CARD_HOLD = {"title": 5.0, "chapter": 4.0, "end": 4.0} # longer cards are cut to this
|
|
70
|
+
CARD_FADE, CAPTION_FADE, LT_FADE, CALLOUT_FADE = 0.4, 0.22, 0.3, 0.25
|
|
71
|
+
CAPTION_MIN = 1.0 # a caption shown for less than this after the cuts is dropped
|
|
72
|
+
DEAD_AIR = 12.0 # this long with no gesture, caption or card is cut like a wait
|
|
73
|
+
SHADOW_PAD = 64 # render-overlays.mjs: room for the shadow around a cropped overlay (its whole blur)
|
|
74
|
+
CALLOUT_RING_PAD, CALLOUT_TAG = 10, 46 # the tag's height plus its gap (render-overlays.mjs)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def probe(src):
|
|
78
|
+
out = subprocess.run(["ffprobe", "-v", "error", "-select_streams", "v:0", "-show_entries",
|
|
79
|
+
"stream=width,height:format=duration", "-of", "json", src],
|
|
80
|
+
capture_output=True, text=True, check=True).stdout
|
|
81
|
+
info = json.loads(out)
|
|
82
|
+
s = info["streams"][0]
|
|
83
|
+
return int(s["width"]), int(s["height"]), float(info["format"]["duration"])
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def find_sync(src, sync_t):
|
|
87
|
+
"""The video time of the take's sync marker (a full magenta frame), or None."""
|
|
88
|
+
span = (sync_t or 0) + 30
|
|
89
|
+
raw = subprocess.run(["ffmpeg", "-v", "error", "-t", f"{span:.2f}", "-i", src, "-vf", f"fps={FPS},scale=32:18",
|
|
90
|
+
"-pix_fmt", "rgb24", "-f", "rawvideo", "-"], capture_output=True, check=True).stdout
|
|
91
|
+
size = 32 * 18 * 3
|
|
92
|
+
for i in range(len(raw) // size):
|
|
93
|
+
f = np.frombuffer(raw[i * size:(i + 1) * size], np.uint8).reshape(-1, 3).astype(int)
|
|
94
|
+
r, g, b = f.mean(axis=0)
|
|
95
|
+
if r > 200 and g < 70 and b > 200:
|
|
96
|
+
return i / FPS
|
|
97
|
+
return None
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
TIMECODE_CELL, TIMECODE_CELLS = 8, 18 # @arbidocs/blocks/record: two guards and 16 bits, in page px
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def timecode_rect(vw, vh, s):
|
|
104
|
+
"""The timecode strip's rect in the take's frames (page px times the take's scale)."""
|
|
105
|
+
w, h = TIMECODE_CELL * TIMECODE_CELLS * s, TIMECODE_CELL * s
|
|
106
|
+
return vw * s - w, vh * s - h, w, h
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def read_timecode(src, vw, vh, sw, sh):
|
|
110
|
+
"""The take's clock against the video's, from the timecode the kit draws in every frame:
|
|
111
|
+
(take seconds, video seconds) where each tenth of a second first shows, or None."""
|
|
112
|
+
s = sw / vw
|
|
113
|
+
x, y, w, h = (int(round(n)) for n in timecode_rect(vw, vh, s))
|
|
114
|
+
raw = subprocess.run(["ffmpeg", "-v", "error", "-i", src, "-vf", f"crop={w}:{h}:{x}:{y},format=gray",
|
|
115
|
+
"-f", "rawvideo", "-"], capture_output=True, check=True).stdout
|
|
116
|
+
cell = w / TIMECODE_CELLS
|
|
117
|
+
first = {}
|
|
118
|
+
for i in range(len(raw) // (w * h)):
|
|
119
|
+
f = np.frombuffer(raw[i * w * h:(i + 1) * w * h], np.uint8).reshape(h, w).astype(np.float32)
|
|
120
|
+
lv = [f[int(h * 0.3):int(h * 0.7) + 1, int(cell * (c + 0.3)):int(cell * (c + 0.7)) + 1].mean() for c in range(TIMECODE_CELLS)]
|
|
121
|
+
if lv[0] < 170 or lv[1] > 80 or any(80 < x_ < 170 for x_ in lv[2:]):
|
|
122
|
+
continue # covered (a card, the cursor) or mid-change
|
|
123
|
+
ds = int("".join("1" if x_ >= 170 else "0" for x_ in lv[2:]), 2)
|
|
124
|
+
first.setdefault(ds, i / FPS)
|
|
125
|
+
if len(first) < 20:
|
|
126
|
+
return None
|
|
127
|
+
pairs = sorted((ds / 10, t) for ds, t in first.items())
|
|
128
|
+
lag = np.median([t - ds for ds, t in pairs])
|
|
129
|
+
pairs = [(ds, t) for ds, t in pairs if abs(t - ds - lag) < 30] # drop misreads far off the clock
|
|
130
|
+
tt = np.array([p[0] for p in pairs])
|
|
131
|
+
vt = np.maximum.accumulate(np.array([p[1] for p in pairs])) # a misread never runs the clock back
|
|
132
|
+
return tt, vt
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def dark_spans(src, fps=10):
|
|
136
|
+
"""Spans of the video where the frame is nearly all dark (a card is up), in seconds."""
|
|
137
|
+
raw = subprocess.run(["ffmpeg", "-v", "error", "-i", src, "-vf", f"fps={fps},scale=32:18,format=gray",
|
|
138
|
+
"-f", "rawvideo", "-"], capture_output=True, check=True).stdout
|
|
139
|
+
n = len(raw) // 576
|
|
140
|
+
dark = [np.frombuffer(raw[i * 576:(i + 1) * 576], np.uint8).mean() < 70 for i in range(n)] + [False]
|
|
141
|
+
spans, start = [], None
|
|
142
|
+
for i, d in enumerate(dark):
|
|
143
|
+
if d and start is None:
|
|
144
|
+
start = i
|
|
145
|
+
elif not d and start is not None:
|
|
146
|
+
if i - start >= 3:
|
|
147
|
+
spans.append((start / fps, i / fps))
|
|
148
|
+
start = None
|
|
149
|
+
return spans
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def card_anchors(src, cards, offset):
|
|
153
|
+
"""A take without the timecode: line its clock up with the video's at each card, which the
|
|
154
|
+
page shows and takes down at once at a logged time (a dark span starts and ends there)."""
|
|
155
|
+
spans = dark_spans(src)
|
|
156
|
+
pairs, lag = [(0.0, offset)], offset
|
|
157
|
+
for c in cards:
|
|
158
|
+
for key, edge in (("start", 0), ("end", 1)):
|
|
159
|
+
t = c.get(key)
|
|
160
|
+
if t is None:
|
|
161
|
+
continue
|
|
162
|
+
near = [s[edge] for s in spans if -3 <= s[edge] - (t + lag) <= 15]
|
|
163
|
+
if near:
|
|
164
|
+
at = min(near, key=lambda x: abs(x - (t + lag)))
|
|
165
|
+
lag = at - t
|
|
166
|
+
pairs.append((t, at - t))
|
|
167
|
+
if len(pairs) < 3:
|
|
168
|
+
return None
|
|
169
|
+
tt = np.array([p[0] for p in pairs])
|
|
170
|
+
vt = np.maximum.accumulate(np.array([p[0] + p[1] for p in pairs]))
|
|
171
|
+
return tt, vt
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
# ---------------------------------------------------------------------------------------------
|
|
175
|
+
# Fallback focus: the kit's cursor, tracked in the video
|
|
176
|
+
# ---------------------------------------------------------------------------------------------
|
|
177
|
+
|
|
178
|
+
MOVE_FRACTION = 0.025 # a frame where this much of the page changes at once is a scroll, reflow or navigation
|
|
179
|
+
TRACK_WINDOW = (520, 90) # the fixed window (page px) a tracked click or typing is framed by
|
|
180
|
+
CURSOR_SIZE = (12, 22) # the cursor disc after erosion, in page px (22 px drawn)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def nearest_change(diff, cx, x0, y0):
|
|
184
|
+
"""The box of the changed pixels nearest the cursor (columns split where 40 px stay unchanged):
|
|
185
|
+
the characters being typed, not a label elsewhere on the row updating with them."""
|
|
186
|
+
cols = np.flatnonzero(diff.any(axis=0))
|
|
187
|
+
groups, start = [], cols[0]
|
|
188
|
+
for a, b in zip(cols, np.append(cols[1:], 10 ** 6)):
|
|
189
|
+
if b - a > 40:
|
|
190
|
+
groups.append((start, a))
|
|
191
|
+
start = b
|
|
192
|
+
g0, g1 = min(groups, key=lambda g: 0 if g[0] <= cx <= g[1] else min(abs(g[0] - cx), abs(g[1] - cx)))
|
|
193
|
+
ys = np.flatnonzero(diff[:, g0:g1 + 1].any(axis=1))
|
|
194
|
+
return (x0 + g0, y0 + ys[0], x0 + g1, y0 + ys[-1])
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def track_cursor(src):
|
|
198
|
+
"""A focus timeline from the video alone, for a take recorded without one.
|
|
199
|
+
|
|
200
|
+
The recording kit draws a green cursor disc (22 px, a white ring) and a ripple on every
|
|
201
|
+
mousedown. Per frame: the green pixels; eroded, the disc is the one round blob of the right
|
|
202
|
+
size; a ripple is a ring around the resting cursor that darkens as it grows (in a compressed
|
|
203
|
+
take it is grey, not green). Clicks are
|
|
204
|
+
ripples. Typing is a click, then the cursor resting while a small band of pixels beside it
|
|
205
|
+
keeps changing (characters arriving), not a scroll (most of the band changing). Frames that are
|
|
206
|
+
nearly all dark are cards: no zoom there. Times are the video's.
|
|
207
|
+
"""
|
|
208
|
+
cap = cv2.VideoCapture(src)
|
|
209
|
+
fps = cap.get(cv2.CAP_PROP_FPS) or FPS
|
|
210
|
+
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
|
|
211
|
+
yy, xx = np.mgrid[-30:31, -30:31]
|
|
212
|
+
rr = np.hypot(xx, yy)
|
|
213
|
+
bins = [(rr >= r) & (rr < r + 2) for r in range(14, 24, 2)]
|
|
214
|
+
track, ripples, activity, dark, profiles, captioned, changed, moved = [], [], [], [], [], [], [], []
|
|
215
|
+
prev_gray, prev_pos, prev_small = None, None, None
|
|
216
|
+
i = 0
|
|
217
|
+
while True:
|
|
218
|
+
ok, f = cap.read()
|
|
219
|
+
if not ok:
|
|
220
|
+
break
|
|
221
|
+
t = i / fps
|
|
222
|
+
i += 1
|
|
223
|
+
sh, sw = f.shape[:2]
|
|
224
|
+
b, g, r = (f[:, :, k].astype(np.int16) for k in range(3))
|
|
225
|
+
green = ((g > r + 45) & (g > b + 12) & (r < 100)).astype(np.uint8)
|
|
226
|
+
core = cv2.erode(green, kernel)
|
|
227
|
+
n, _, st, cen = cv2.connectedComponentsWithStats(core)
|
|
228
|
+
best = None
|
|
229
|
+
for j in range(1, n):
|
|
230
|
+
x, y, w, h, a = st[j]
|
|
231
|
+
if CURSOR_SIZE[0] <= w <= CURSOR_SIZE[1] and CURSOR_SIZE[0] <= h <= CURSOR_SIZE[1] and abs(w - h) <= 4 and a / (w * h) > 0.6:
|
|
232
|
+
c = (float(cen[j][0]), float(cen[j][1]))
|
|
233
|
+
d = 0 if prev_pos is None else math.hypot(c[0] - prev_pos[0], c[1] - prev_pos[1])
|
|
234
|
+
if best is None or d < best[1]:
|
|
235
|
+
best = (c, d)
|
|
236
|
+
pos = best[0] if best else None
|
|
237
|
+
track.append(pos)
|
|
238
|
+
gray = cv2.cvtColor(f, cv2.COLOR_BGR2GRAY)
|
|
239
|
+
dark.append(float(gray.mean()) < 70)
|
|
240
|
+
small = cv2.resize(gray, (sw // 4, sh // 4), interpolation=cv2.INTER_AREA)
|
|
241
|
+
moved.append(prev_small is not None and float((cv2.absdiff(small, prev_small) > 30).mean()) > MOVE_FRACTION)
|
|
242
|
+
prev_small = small
|
|
243
|
+
mid = gray[sh - 75:sh - 45, int(0.45 * sw):int(0.55 * sw)]
|
|
244
|
+
captioned.append(float((mid < 90).mean()) > 0.5)
|
|
245
|
+
rip, act, prof, dbox = 0.0, 0, None, None
|
|
246
|
+
if pos:
|
|
247
|
+
cx, cy = int(round(pos[0])), int(round(pos[1]))
|
|
248
|
+
if 30 <= cx < sw - 30 and 30 <= cy < sh - 30:
|
|
249
|
+
crop = gray[cy - 30:cy + 31, cx - 30:cx + 31].astype(np.float32)
|
|
250
|
+
prof = (pos, np.array([crop[m].mean() for m in bins]))
|
|
251
|
+
back = profiles[-6] if len(profiles) >= 6 else None
|
|
252
|
+
if back and math.hypot(back[0][0] - pos[0], back[0][1] - pos[1]) < 3:
|
|
253
|
+
rip = float(-(prof[1] - back[1]).min()) # the ripple's ring darkens a band as it grows
|
|
254
|
+
if prev_gray is not None:
|
|
255
|
+
y0, y1, x0, x1 = max(cy - 40, 0), min(cy + 40, sh), max(cx - 500, 0), min(cx + 500, sw)
|
|
256
|
+
diff = cv2.absdiff(gray[y0:y1, x0:x1], prev_gray[y0:y1, x0:x1]) > 30
|
|
257
|
+
diff[max(cy - 18 - y0, 0):cy + 18 - y0, max(cx - 18 - x0, 0):cx + 18 - x0] = False
|
|
258
|
+
act = int(diff.sum())
|
|
259
|
+
if act:
|
|
260
|
+
dbox = nearest_change(diff, cx - x0, x0, y0)
|
|
261
|
+
ripples.append(rip)
|
|
262
|
+
activity.append(act)
|
|
263
|
+
changed.append(dbox)
|
|
264
|
+
profiles.append(prof)
|
|
265
|
+
prev_gray, prev_pos = gray, pos or prev_pos
|
|
266
|
+
cap.release()
|
|
267
|
+
nf = len(track)
|
|
268
|
+
|
|
269
|
+
def runs(flags, min_len=1, gap=0):
|
|
270
|
+
out, a, last = [], None, None
|
|
271
|
+
for k, on in enumerate(flags):
|
|
272
|
+
if on:
|
|
273
|
+
if a is None or (last is not None and k - last > gap + 1):
|
|
274
|
+
if a is not None:
|
|
275
|
+
out.append((a, last))
|
|
276
|
+
a = k
|
|
277
|
+
last = k
|
|
278
|
+
if a is not None:
|
|
279
|
+
out.append((a, last))
|
|
280
|
+
return [(a, b) for a, b in out if b - a + 1 >= min_len]
|
|
281
|
+
|
|
282
|
+
def box(p):
|
|
283
|
+
w, h = TRACK_WINDOW
|
|
284
|
+
return {"x": p[0] - w / 2, "y": p[1] - h / 2, "width": w, "height": h}
|
|
285
|
+
|
|
286
|
+
def in_caption_band(p):
|
|
287
|
+
return p[1] > sh - 130 and abs(p[0] - sw / 2) < 0.36 * sw
|
|
288
|
+
|
|
289
|
+
clicks = []
|
|
290
|
+
for a, b in runs([rp > 15 for rp in ripples], min_len=2, gap=1):
|
|
291
|
+
p = track[a]
|
|
292
|
+
if p and not in_caption_band(p):
|
|
293
|
+
clicks.append((max(a - 3, 0) / fps, p))
|
|
294
|
+
|
|
295
|
+
def still(k, p):
|
|
296
|
+
q = track[k]
|
|
297
|
+
return q is not None and math.hypot(q[0] - p[0], q[1] - p[1]) < 4
|
|
298
|
+
|
|
299
|
+
focus, used = [], set()
|
|
300
|
+
for ci, (tc, p) in enumerate(clicks):
|
|
301
|
+
k0 = int(tc * fps) + 3
|
|
302
|
+
# Typing: within 1.5 s of the click, characters arriving beside the resting cursor.
|
|
303
|
+
k, last_hit, hits = k0, None, []
|
|
304
|
+
while k < nf and still(k, p) and (last_hit is None or k - last_hit <= int(0.6 * fps)):
|
|
305
|
+
if 12 <= activity[k] <= 2500:
|
|
306
|
+
last_hit = k
|
|
307
|
+
hits.append(changed[k])
|
|
308
|
+
elif last_hit is None and k - k0 > int(1.5 * fps):
|
|
309
|
+
break
|
|
310
|
+
k += 1
|
|
311
|
+
if last_hit is not None and (last_hit - k0) / fps >= 0.8:
|
|
312
|
+
# Where the characters arrived: the inset frames that, not the cursor's window.
|
|
313
|
+
x0_, y0_ = min(h[0] for h in hits), min(h[1] for h in hits)
|
|
314
|
+
x1_, y1_ = max(h[2] for h in hits), max(h[3] for h in hits)
|
|
315
|
+
text = {"x": float(x0_), "y": float(y0_), "width": float(x1_ - x0_ + 1), "height": float(y1_ - y0_ + 1)}
|
|
316
|
+
focus.append({"kind": "type", "label": "typing (tracked)", "start": tc, "end": last_hit / fps + 0.2, "box": box(p), "textBox": text})
|
|
317
|
+
used.add(ci)
|
|
318
|
+
for ci, (tc, p) in enumerate(clicks):
|
|
319
|
+
if ci in used:
|
|
320
|
+
continue
|
|
321
|
+
# A click the page answers beside the cursor (a tile picked, a popover opened) while the
|
|
322
|
+
# cursor rests on it is worth a look; any other click only joins a shot.
|
|
323
|
+
k0 = int(tc * fps) + 3
|
|
324
|
+
k = k0
|
|
325
|
+
while k < nf and still(k, p) and k - k0 < int(3 * fps):
|
|
326
|
+
k += 1
|
|
327
|
+
# Answered beside the cursor, and only there: a click that scrolls or reflows the page is a
|
|
328
|
+
# change of scene, not a thing to look at.
|
|
329
|
+
window = range(k0, min(k, k0 + int(fps)))
|
|
330
|
+
answered = any(12 <= activity[j] <= 2500 for j in window) and not any(moved[j] for j in range(k0, k))
|
|
331
|
+
if answered and (k - k0) / fps >= 1.2:
|
|
332
|
+
focus.append({"kind": "point", "label": "click, answered (tracked)", "start": tc, "end": k / fps, "box": box(p)})
|
|
333
|
+
else:
|
|
334
|
+
focus.append({"kind": "click", "label": "click (tracked)", "start": tc, "end": tc + 0.5, "box": box(p)})
|
|
335
|
+
# No zoom while the cursor is lost: a shot may not run over a stretch of a second without it.
|
|
336
|
+
lost = [(a / fps, (b + 1) / fps) for a, b in runs([p is None for p in track], min_len=int(fps))]
|
|
337
|
+
cards = [(a / fps, (b + 1) / fps) for a, b in runs(dark, min_len=int(fps / 2), gap=2)]
|
|
338
|
+
return {"viewport": {"width": sw, "height": sh}, "focus": sorted(focus, key=lambda e: e["start"]),
|
|
339
|
+
"blocked": cards + lost, "source": "cursor", "darkSpans": cards,
|
|
340
|
+
"moves": [a / fps for a, _ in runs(moved, gap=2)],
|
|
341
|
+
"captioned": [(a / fps, (b + 1) / fps) for a, b in runs(captioned, gap=3)]}
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
# ---------------------------------------------------------------------------------------------
|
|
345
|
+
# Cuts and the time map
|
|
346
|
+
# ---------------------------------------------------------------------------------------------
|
|
347
|
+
|
|
348
|
+
def cards_from_dark(spans, story, duration):
|
|
349
|
+
"""The brand cards for a take whose cards are only in its frames: the story's title, chapters and
|
|
350
|
+
closing card, laid over the dark spans of the video in order (the first is the title; one that
|
|
351
|
+
runs to the end is the closing card). Spans with no card left keep what is in the frames."""
|
|
352
|
+
spans = [s for s in spans if s[1] - s[0] >= 2.0] # a card holds 2.4 s or more; a dark dialog backdrop is briefer
|
|
353
|
+
cards = []
|
|
354
|
+
if spans and story.get("title"):
|
|
355
|
+
cards.append(dict(story["title"], start=spans[0][0], end=spans[0][1]))
|
|
356
|
+
spans = spans[1:]
|
|
357
|
+
closing = story.get("end")
|
|
358
|
+
if closing and spans and spans[-1][1] >= duration - 1.5:
|
|
359
|
+
last = spans.pop()
|
|
360
|
+
else:
|
|
361
|
+
last = None
|
|
362
|
+
for span, ch in zip(spans, story.get("chapters", [])):
|
|
363
|
+
cards.append(dict(ch, start=span[0], end=span[1]))
|
|
364
|
+
if last:
|
|
365
|
+
cards.append(dict(closing, start=last[0], end=None))
|
|
366
|
+
for c in cards:
|
|
367
|
+
print(f"card {c['start']:6.1f}-{c['end'] if c['end'] is not None else 'end'} {c['title']}")
|
|
368
|
+
return cards
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def dead_air(tl, v, end_at, head, tail):
|
|
372
|
+
"""Stretches of a take where nothing is done and nothing is said: no gesture, no caption, no
|
|
373
|
+
card for DEAD_AIR seconds (a step the server ran that the story did not log as a wait). Their
|
|
374
|
+
middle is cut like a wait's, keeping `head` s and the last `tail` s."""
|
|
375
|
+
marks = []
|
|
376
|
+
for e in tl.get("focus", []):
|
|
377
|
+
marks += [e["start"], e.get("end") or e["start"]]
|
|
378
|
+
for c in tl.get("captions", []) + tl.get("cards", []) + tl.get("lowerThirds", []):
|
|
379
|
+
marks += [c["start"]] + ([c["end"]] if c.get("end") is not None else [])
|
|
380
|
+
for w in tl.get("waits", []):
|
|
381
|
+
marks += [w["start"], w["end"]]
|
|
382
|
+
marks = sorted(v(t) for t in marks)
|
|
383
|
+
cuts = []
|
|
384
|
+
for a, b in zip(marks, marks[1:] + [end_at]):
|
|
385
|
+
if b - a >= DEAD_AIR and b - tail - (a + head) >= 1:
|
|
386
|
+
cuts.append((a + head, b - tail, "nothing on screen happens"))
|
|
387
|
+
return cuts
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def merge(ranges):
|
|
391
|
+
out = []
|
|
392
|
+
for a, b, why in sorted(ranges):
|
|
393
|
+
if b - a < 0.04:
|
|
394
|
+
continue
|
|
395
|
+
if out and a <= out[-1][1]:
|
|
396
|
+
out[-1] = (out[-1][0], max(b, out[-1][1]), out[-1][2] + " + " + why)
|
|
397
|
+
else:
|
|
398
|
+
out.append((a, b, why))
|
|
399
|
+
return out
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def make_map(cuts):
|
|
403
|
+
def m(t):
|
|
404
|
+
return t - sum(min(max(t - a, 0), b - a) for a, b, _ in cuts)
|
|
405
|
+
return m
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
# ---------------------------------------------------------------------------------------------
|
|
409
|
+
# The camera
|
|
410
|
+
# ---------------------------------------------------------------------------------------------
|
|
411
|
+
|
|
412
|
+
FULL = (0.0, 0.0, float(W)) # a camera is the source rect it shows: x, y, width (16:9)
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
CAPTION_TOP = H - 190 # a burned-in caption bar sits below this on the output frame
|
|
416
|
+
BOUNDS = [0.0, 0.0, float(W), float(H)] # where the camera may look: the take's picture, above a burned-in caption bar
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def target_rect(box, max_zoom, safe=None):
|
|
420
|
+
"""The camera rect framing `box`, or None. With `safe` (the top of a burned-in caption bar), the
|
|
421
|
+
rect stays above the bar and the box lands above where the bar sits on the zoomed frame."""
|
|
422
|
+
bx0, by0, bx1, by1 = BOUNDS
|
|
423
|
+
if safe:
|
|
424
|
+
by1 = min(by1, safe)
|
|
425
|
+
x0, y0 = max(box[0], bx0), max(box[1], by0)
|
|
426
|
+
x, y, w, h = x0, y0, min(box[0] + box[2], bx1) - x0, min(box[1] + box[3], by1) - y0
|
|
427
|
+
if w < 8 or h < 8: # off the picture, or under the caption bar
|
|
428
|
+
return None
|
|
429
|
+
rw = max(w * 1.5 + 90, (h * 1.5 + 90) * W / H, W / max_zoom)
|
|
430
|
+
rh = rw * H / W
|
|
431
|
+
if rw > W / MIN_ZOOM or rw > bx1 - bx0 or rh > by1 - by0:
|
|
432
|
+
return None
|
|
433
|
+
cx, cy = x + w / 2, y + h / 2
|
|
434
|
+
f = safe / H if safe else 1.0
|
|
435
|
+
ry = min(max(cy - rh * f / 2, by0), by1 - rh)
|
|
436
|
+
if y + h > ry + rh * f:
|
|
437
|
+
return None
|
|
438
|
+
return (min(max(cx - rw / 2, bx0), bx1 - rw), ry, rw)
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def union(a, b):
|
|
442
|
+
x0, y0 = min(a[0], b[0]), min(a[1], b[1])
|
|
443
|
+
x1, y1 = max(a[0] + a[2], b[0] + b[2]), max(a[1] + a[3], b[1] + b[3])
|
|
444
|
+
return (x0, y0, x1 - x0, y1 - y0)
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def ease(u):
|
|
448
|
+
u = min(max(u, 0.0), 1.0)
|
|
449
|
+
return u * u * u * (u * (u * 6 - 15) + 10) # smootherstep: no jolt at either end
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def lerp_rect(a, b, u):
|
|
453
|
+
"""From rect a to b, zooming at a constant rate about their common fixed point."""
|
|
454
|
+
e = ease(u)
|
|
455
|
+
(ax, ay, aw), (bx, by, bw) = a, b
|
|
456
|
+
if abs(bw - aw) < 1:
|
|
457
|
+
return (ax + (bx - ax) * e, ay + (by - ay) * e, aw)
|
|
458
|
+
w = aw * (bw / aw) ** e
|
|
459
|
+
r = (w - aw) / (bw - aw) # 0 -> 1, the same weight on both edges keeps the move straight
|
|
460
|
+
return (ax + (bx - ax) * r, ay + (by - ay) * r, w)
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def plan_camera(events, blocked, max_zoom, captioned=(), page_moves=()):
|
|
464
|
+
"""`blocked`: windows with no zoom at all (cards, the cursor lost, insets). `page_moves`: moments the
|
|
465
|
+
page itself moves (scroll, reflow, navigation): a shot never runs over one, it is out before."""
|
|
466
|
+
"""Zoom shots from the focus events: [(t0, t1, from, to)] moves of the camera."""
|
|
467
|
+
def clear(a, b):
|
|
468
|
+
return not any(a < bb and b > ba for ba, bb in blocked)
|
|
469
|
+
|
|
470
|
+
def still(a, b):
|
|
471
|
+
return not any(a < t < b for t in page_moves)
|
|
472
|
+
|
|
473
|
+
evs = []
|
|
474
|
+
for e in events:
|
|
475
|
+
if e.get("mode") in ("none", "pip") or not clear(e["start"] - EASE_IN, e["end"] + EASE_OUT) or not still(e["start"], e["end"]):
|
|
476
|
+
continue
|
|
477
|
+
e = dict(e, safe=CAPTION_TOP if any(e["start"] - 1.5 < b and e["end"] + 2.5 > a for a, b in captioned) else None)
|
|
478
|
+
rect = target_rect(e["box"], max_zoom, e["safe"])
|
|
479
|
+
if rect is None:
|
|
480
|
+
continue
|
|
481
|
+
dur = e["end"] - e["start"]
|
|
482
|
+
trigger = (e["kind"] == "focus" or e.get("mode") == "zoom"
|
|
483
|
+
or (e["kind"] == "type" and dur >= TYPE_MIN)
|
|
484
|
+
or (e["kind"] in ("point", "read") and dur >= DWELL_MIN))
|
|
485
|
+
evs.append(dict(e, rect=rect, trigger=trigger))
|
|
486
|
+
|
|
487
|
+
shots, cur = [], None
|
|
488
|
+
for e in sorted(evs, key=lambda e: e["start"]):
|
|
489
|
+
if cur and e["start"] <= cur["end"] + JOIN_GAP and clear(cur["end"], e["end"] + EASE_OUT) and still(cur["end"], e["start"]):
|
|
490
|
+
last = cur["keys"][-1]
|
|
491
|
+
both = union(last["box"], e["box"])
|
|
492
|
+
safe = last.get("safe") or e["safe"]
|
|
493
|
+
wide = target_rect(both, max_zoom, safe)
|
|
494
|
+
if wide and wide[2] <= 1.35 * max(last["rect"][2], e["rect"][2]):
|
|
495
|
+
last["box"], last["rect"], last["safe"] = both, wide, safe
|
|
496
|
+
else:
|
|
497
|
+
cur["keys"].append({"t": e["start"], "box": e["box"], "rect": e["rect"], "safe": e["safe"], "label": e.get("label", "")})
|
|
498
|
+
cur["end"] = max(cur["end"], e["end"])
|
|
499
|
+
elif e["trigger"]:
|
|
500
|
+
cur = {"start": e["start"], "end": e["end"], "keys": [{"t": e["start"], "box": e["box"], "rect": e["rect"], "safe": e["safe"], "label": e.get("label", "")}]}
|
|
501
|
+
shots.append(cur)
|
|
502
|
+
|
|
503
|
+
moves, free_at = [], 0.0
|
|
504
|
+
for s in shots:
|
|
505
|
+
t = max(s["start"] - 0.2, free_at)
|
|
506
|
+
moves.append((t, t + EASE_IN, FULL, s["keys"][0]["rect"]))
|
|
507
|
+
last_end = t + EASE_IN
|
|
508
|
+
for prev, k in zip(s["keys"], s["keys"][1:]):
|
|
509
|
+
ta = max(k["t"] - 0.25, last_end)
|
|
510
|
+
moves.append((ta, ta + PAN, prev["rect"], k["rect"]))
|
|
511
|
+
last_end = ta + PAN
|
|
512
|
+
out = max(s["end"] + 0.25, s["start"] + MIN_HOLD, last_end + 0.3)
|
|
513
|
+
nxt = min([ba for ba, _ in blocked if ba >= s["start"]] + [t for t in page_moves if t > s["end"]], default=None)
|
|
514
|
+
if nxt is not None and out + EASE_OUT > nxt:
|
|
515
|
+
out = max(nxt - EASE_OUT, last_end)
|
|
516
|
+
moves.append((out, out + EASE_OUT, s["keys"][-1]["rect"], FULL))
|
|
517
|
+
free_at = out + EASE_OUT
|
|
518
|
+
s["in"], s["out"] = t, out + EASE_OUT
|
|
519
|
+
return moves, shots
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def camera_at(moves, t):
|
|
523
|
+
cam = FULL
|
|
524
|
+
for a, b, frm, to in moves:
|
|
525
|
+
if t < a:
|
|
526
|
+
break
|
|
527
|
+
cam = lerp_rect(frm, to, (t - a) / (b - a)) if t < b else to
|
|
528
|
+
return cam
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
# ---------------------------------------------------------------------------------------------
|
|
532
|
+
# Picture-in-picture
|
|
533
|
+
# ---------------------------------------------------------------------------------------------
|
|
534
|
+
|
|
535
|
+
def plan_pip(e):
|
|
536
|
+
x, y, w, h = e.get("textBox") or e["box"]
|
|
537
|
+
aspect = min(max((w * 1.12 + 48) / (h * 1.12 + 32), 1.6), 3.0)
|
|
538
|
+
cw = max(w * 1.12 + 48, PIP_W / 2.2)
|
|
539
|
+
ch = cw / aspect
|
|
540
|
+
if ch < h * 1.12 + 32:
|
|
541
|
+
ch = h * 1.12 + 32
|
|
542
|
+
cw = ch * aspect
|
|
543
|
+
if PIP_W / cw < 1.3:
|
|
544
|
+
return None
|
|
545
|
+
cx, cy = x + w / 2, y + h / 2
|
|
546
|
+
crop = (min(max(cx - cw / 2, 0), W - cw), min(max(cy - ch / 2, 0), H - ch), cw, ch)
|
|
547
|
+
iw, ih = int(PIP_W), int(round(PIP_W / aspect))
|
|
548
|
+
corners = [(W - PIP_MARGIN - iw, PIP_MARGIN), (PIP_MARGIN, PIP_MARGIN),
|
|
549
|
+
(W - PIP_MARGIN - iw, H - PIP_BOTTOM - ih), (PIP_MARGIN, H - PIP_BOTTOM - ih)]
|
|
550
|
+
|
|
551
|
+
def overlap(c):
|
|
552
|
+
ox = max(0, min(c[0] + iw, x + w + 40) - max(c[0], x - 40))
|
|
553
|
+
oy = max(0, min(c[1] + ih, y + h + 40) - max(c[1], y - 40))
|
|
554
|
+
return ox * oy
|
|
555
|
+
|
|
556
|
+
pos = min(corners, key=overlap)
|
|
557
|
+
return {"crop": crop, "size": (iw, ih), "pos": pos}
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def rounded(w, h, r, inset=0, ss=4):
|
|
561
|
+
"""A rounded-rect alpha mask (anti-aliased), `inset` px in from the edge."""
|
|
562
|
+
m = np.zeros((h * ss, w * ss), np.uint8)
|
|
563
|
+
x0, y0, x1, y1 = inset * ss, inset * ss, (w - inset) * ss - 1, (h - inset) * ss - 1
|
|
564
|
+
rr = max(r - inset, 1) * ss
|
|
565
|
+
cv2.rectangle(m, (x0 + rr, y0), (x1 - rr, y1), 255, -1)
|
|
566
|
+
cv2.rectangle(m, (x0, y0 + rr), (x1, y1 - rr), 255, -1)
|
|
567
|
+
for cx, cy in ((x0 + rr, y0 + rr), (x1 - rr, y0 + rr), (x0 + rr, y1 - rr), (x1 - rr, y1 - rr)):
|
|
568
|
+
cv2.circle(m, (cx, cy), rr, 255, -1)
|
|
569
|
+
return cv2.resize(m, (w, h), interpolation=cv2.INTER_AREA).astype(np.float32) / 255
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
def pip_layers(iw, ih):
|
|
574
|
+
"""The inset's mask, its border ring and its drop shadow (float alpha arrays)."""
|
|
575
|
+
outer = rounded(iw, ih, PIP_RADIUS)
|
|
576
|
+
inner = rounded(iw, ih, PIP_RADIUS, inset=3)
|
|
577
|
+
pad = 48
|
|
578
|
+
shadow = np.zeros((ih + 2 * pad, iw + 2 * pad), np.float32)
|
|
579
|
+
shadow[pad + 14:pad + 14 + ih, pad:pad + iw] = outer
|
|
580
|
+
shadow = cv2.GaussianBlur(shadow, (0, 0), 18) * 0.5
|
|
581
|
+
return outer, inner, shadow, pad
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
def blend(frame, layer_bgr, alpha, x, y):
|
|
585
|
+
"""Alpha-blend `layer_bgr` (h, w, 3) with alpha (h, w) onto frame at (x, y), clipped."""
|
|
586
|
+
h, w = alpha.shape
|
|
587
|
+
x0, y0, x1, y1 = max(x, 0), max(y, 0), min(x + w, W), min(y + h, H)
|
|
588
|
+
if x0 >= x1 or y0 >= y1:
|
|
589
|
+
return
|
|
590
|
+
a = alpha[y0 - y:y1 - y, x0 - x:x1 - x, None]
|
|
591
|
+
roi = frame[y0:y1, x0:x1].astype(np.float32)
|
|
592
|
+
src = layer_bgr if np.ndim(layer_bgr) == 1 else layer_bgr[y0 - y:y1 - y, x0 - x:x1 - x]
|
|
593
|
+
frame[y0:y1, x0:x1] = (roi * (1 - a) + np.asarray(src, np.float32) * a).astype(np.uint8)
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
def keep_caption(src, dst):
|
|
597
|
+
"""Copy the kit's caption bar (a dark rounded box at the bottom centre) from the unzoomed frame
|
|
598
|
+
onto the zoomed one, so a take with captions in its frames never loses its narration."""
|
|
599
|
+
y0, x0, x1 = H - 190, int(0.172 * W), int(0.83 * W) # clear of the sidebar
|
|
600
|
+
dark = cv2.cvtColor(src[y0:H - 8, x0:x1], cv2.COLOR_BGR2GRAY) < 90
|
|
601
|
+
mid = (W // 2 - x0) - int(0.05 * W), (W // 2 - x0) + int(0.05 * W)
|
|
602
|
+
# The bar's rows: its fill is dark across the middle (its lines of text still leave a third of
|
|
603
|
+
# it dark); a line of the page's own text is not. The longest such run is the bar.
|
|
604
|
+
rows = np.flatnonzero(dark[:, mid[0]:mid[1]].mean(axis=1) > 0.32)
|
|
605
|
+
if len(rows) < 25:
|
|
606
|
+
return
|
|
607
|
+
runs_, start = [], rows[0]
|
|
608
|
+
for r0, r1 in zip(rows, np.append(rows[1:], 10 ** 6)):
|
|
609
|
+
if r1 - r0 > 3:
|
|
610
|
+
runs_.append((start, r0))
|
|
611
|
+
start = r1
|
|
612
|
+
a, b = max(runs_, key=lambda r: r[1] - r[0])
|
|
613
|
+
if b - a < 25:
|
|
614
|
+
return
|
|
615
|
+
on = dark[a:b + 1].mean(axis=0) > 0.35
|
|
616
|
+
best, run = (0, 0), None # the widest run of dark columns is the box (not the sidebar's edge)
|
|
617
|
+
for i, c in enumerate(np.append(on, False)):
|
|
618
|
+
if c and run is None:
|
|
619
|
+
run = i
|
|
620
|
+
elif not c and run is not None:
|
|
621
|
+
if i - run > best[1] - best[0]:
|
|
622
|
+
best = (run, i)
|
|
623
|
+
run = None
|
|
624
|
+
if best[1] - best[0] < 200 or b - a > 170:
|
|
625
|
+
return
|
|
626
|
+
bx0, bx1, by0, by1 = x0 + best[0] - 3, x0 + best[1] + 3, y0 + a - 3, y0 + b + 4
|
|
627
|
+
mask = rounded(bx1 - bx0, by1 - by0, 18)
|
|
628
|
+
blend(dst, src[by0:by1, bx0:bx1], mask, bx0, by0)
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
def window_alpha(t, a, b, fade):
|
|
632
|
+
if t < a or t > b:
|
|
633
|
+
return 0.0
|
|
634
|
+
return ease(min((t - a) / fade, (b - t) / fade, 1.0))
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
# ---------------------------------------------------------------------------------------------
|
|
638
|
+
# Main
|
|
639
|
+
# ---------------------------------------------------------------------------------------------
|
|
640
|
+
|
|
641
|
+
def main():
|
|
642
|
+
ap = argparse.ArgumentParser()
|
|
643
|
+
ap.add_argument("src")
|
|
644
|
+
ap.add_argument("out")
|
|
645
|
+
ap.add_argument("--waits")
|
|
646
|
+
ap.add_argument("--focus")
|
|
647
|
+
ap.add_argument("--no-zoom", action="store_true")
|
|
648
|
+
ap.add_argument("--no-pip", action="store_true")
|
|
649
|
+
ap.add_argument("--max-zoom", type=float)
|
|
650
|
+
ap.add_argument("--head", type=float, default=3)
|
|
651
|
+
ap.add_argument("--tail", type=float, default=1.5)
|
|
652
|
+
ap.add_argument("--min", type=float, default=5)
|
|
653
|
+
ap.add_argument("--stills")
|
|
654
|
+
ap.add_argument("--story", help="a take without a focus timeline: its cards and config, as JSON "
|
|
655
|
+
'{"config": {...}, "title": {kicker, title, subtitle}, "chapters": [...], "end": {...}}')
|
|
656
|
+
ap.add_argument("--keep", action="store_true", help="keep the overlay PNGs and the filter graph")
|
|
657
|
+
ap.add_argument("--plan", help="where to write the edit's plan (default: beside the video)")
|
|
658
|
+
args = ap.parse_args()
|
|
659
|
+
|
|
660
|
+
sw, sh, duration = probe(args.src)
|
|
661
|
+
waits = []
|
|
662
|
+
if args.waits and os.path.exists(args.waits):
|
|
663
|
+
data = json.load(open(args.waits))
|
|
664
|
+
waits = data["waits"] if isinstance(data, dict) else [{"label": "", "start": a, "end": b} for a, b in data]
|
|
665
|
+
tl = json.load(open(args.focus)) if args.focus and os.path.exists(args.focus) else {}
|
|
666
|
+
if not tl and not args.no_zoom:
|
|
667
|
+
# A take recorded without a focus timeline: find the action from the kit's cursor.
|
|
668
|
+
tl = track_cursor(args.src)
|
|
669
|
+
print(f"no focus timeline: tracked the cursor ({len(tl['focus'])} clicks and typing)")
|
|
670
|
+
burned_captions = tl.get("source") == "cursor" # the caption bar is in the frames: keep it unzoomed
|
|
671
|
+
cfg = tl.get("config", {})
|
|
672
|
+
zoom_on = cfg.get("zoom", True) and not args.no_zoom
|
|
673
|
+
pip_on = cfg.get("pip", True) and not args.no_pip
|
|
674
|
+
max_zoom = args.max_zoom or cfg.get("maxZoom", 1.8)
|
|
675
|
+
|
|
676
|
+
# 1. The take's clock on the video's: the timecode in every frame where the take drew one
|
|
677
|
+
# (the video's clock drifts from the wall clock over a long take), else the sync marker.
|
|
678
|
+
offset = 0.0
|
|
679
|
+
vp = tl.get("viewport") or {}
|
|
680
|
+
clock = read_timecode(args.src, vp.get("width", sw), vp.get("height", sh), sw, sh) if tl.get("focus") is not None else None
|
|
681
|
+
if clock is None and tl.get("sync") is not None:
|
|
682
|
+
at = find_sync(args.src, tl["sync"])
|
|
683
|
+
if at is None:
|
|
684
|
+
print("warning: no timecode or sync marker found; the take's clock is used as is", file=sys.stderr)
|
|
685
|
+
else:
|
|
686
|
+
offset = at - tl["sync"]
|
|
687
|
+
if clock is None and tl.get("cards") and tl.get("focus") is not None:
|
|
688
|
+
clock = card_anchors(args.src, tl["cards"], offset)
|
|
689
|
+
if clock:
|
|
690
|
+
tt, vt = clock
|
|
691
|
+
print(f"take {duration:.1f} s, clock from {len(tt)} anchors, drift {vt[-1] - tt[-1] - (vt[0] - tt[0]):+.2f} s")
|
|
692
|
+
else:
|
|
693
|
+
print(f"take {duration:.1f} s, clock offset {offset:+.2f} s")
|
|
694
|
+
|
|
695
|
+
def v(t):
|
|
696
|
+
if t is None:
|
|
697
|
+
return None
|
|
698
|
+
if not clock:
|
|
699
|
+
return t + offset
|
|
700
|
+
if t <= tt[0]:
|
|
701
|
+
return vt[0] + (t - tt[0])
|
|
702
|
+
if t >= tt[-1]:
|
|
703
|
+
return vt[-1] + (t - tt[-1])
|
|
704
|
+
return float(np.interp(t, tt, vt))
|
|
705
|
+
|
|
706
|
+
# 2. Cuts, on the video's clock.
|
|
707
|
+
cuts = []
|
|
708
|
+
for w in waits:
|
|
709
|
+
a, b = v(w["start"]) + args.head, min(v(w["end"]), duration) - args.tail
|
|
710
|
+
if v(w["end"]) - v(w["start"]) >= args.min and b - a >= 1:
|
|
711
|
+
cuts.append((a, b, w["label"]))
|
|
712
|
+
story = json.load(open(args.story)) if args.story else {}
|
|
713
|
+
if tl.get("source") == "cursor" and story:
|
|
714
|
+
tl["cards"] = cards_from_dark(tl.get("darkSpans", []), story, duration)
|
|
715
|
+
cfg = {**cfg, **story.get("config", {})}
|
|
716
|
+
zoom_on = cfg.get("zoom", True) and not args.no_zoom
|
|
717
|
+
pip_on = cfg.get("pip", True) and not args.no_pip
|
|
718
|
+
max_zoom = args.max_zoom or cfg.get("maxZoom", 1.8)
|
|
719
|
+
cards = [dict(c, start=v(c["start"]), end=v(c.get("end"))) for c in tl.get("cards", [])]
|
|
720
|
+
end_at = duration
|
|
721
|
+
if cards:
|
|
722
|
+
cuts.append((0, cards[0]["start"], "before the title card"))
|
|
723
|
+
for i, c in enumerate(cards):
|
|
724
|
+
kind = "title" if i == 0 else ("end" if c["end"] is None else "chapter")
|
|
725
|
+
c["kind"] = kind
|
|
726
|
+
if c["end"] is None:
|
|
727
|
+
end_at = min(duration, c["start"] + CARD_HOLD["end"])
|
|
728
|
+
c["end"] = end_at
|
|
729
|
+
elif c["end"] - c["start"] > CARD_HOLD[kind]:
|
|
730
|
+
cuts.append((c["start"] + CARD_HOLD[kind] - CARD_FADE, c["end"] - CARD_FADE, f"behind the card '{c['title']}'"))
|
|
731
|
+
if end_at < duration:
|
|
732
|
+
cuts.append((end_at, duration + 1, "after the closing card"))
|
|
733
|
+
if tl.get("source") != "cursor" and tl.get("focus") is not None:
|
|
734
|
+
cuts += dead_air(dict(tl, waits=waits), v, end_at, args.head, args.tail)
|
|
735
|
+
cuts = merge(cuts)
|
|
736
|
+
m = make_map(cuts)
|
|
737
|
+
total = m(min(end_at, duration))
|
|
738
|
+
for a, b, why in cuts:
|
|
739
|
+
print(f"cut {a:7.1f} - {min(b, duration):7.1f} ({min(b, duration) - a:5.1f} s) {why}")
|
|
740
|
+
print(f"video {total:.1f} s")
|
|
741
|
+
|
|
742
|
+
# 3. The plan, on the cut video's clock, in output pixels.
|
|
743
|
+
vw, vh = (tl.get("viewport") or {}).get("width", sw), (tl.get("viewport") or {}).get("height", sh)
|
|
744
|
+
k = min(W / sw, H / sh)
|
|
745
|
+
ox, oy = (W - sw * k) / 2, (H - sh * k) / 2
|
|
746
|
+
s = (sw / vw) * k
|
|
747
|
+
|
|
748
|
+
BOUNDS[:] = [ox, oy, ox + sw * k, oy + sh * k]
|
|
749
|
+
timecode = None # the strip's rows in the output frame, painted out with the rows above it
|
|
750
|
+
if clock:
|
|
751
|
+
tx, ty, tw, th = timecode_rect(vw, vh, sw / vw)
|
|
752
|
+
timecode = (int(ty * k + oy) - 1, min(int(np.ceil((ty + th) * k + oy)), H),
|
|
753
|
+
int(tx * k + ox) - 1, min(int(np.ceil((tx + tw) * k + ox)), W))
|
|
754
|
+
|
|
755
|
+
def px(b):
|
|
756
|
+
return (b["x"] * s + ox, b["y"] * s + oy, b["width"] * s, b["height"] * s)
|
|
757
|
+
|
|
758
|
+
def mapped(a, b):
|
|
759
|
+
return m(a), m(b)
|
|
760
|
+
|
|
761
|
+
card_windows = []
|
|
762
|
+
for i, c in enumerate(cards):
|
|
763
|
+
c["start"], c["end"] = mapped(c["start"], c["end"])
|
|
764
|
+
# The brand card is opaque before the page's own card shows and fades out once it has gone,
|
|
765
|
+
# so the two never show at once (their type differs).
|
|
766
|
+
if i:
|
|
767
|
+
c["start"] = max(c["start"] - CARD_FADE, 0)
|
|
768
|
+
if c["kind"] != "end":
|
|
769
|
+
c["end"] = min(c["end"] + CARD_FADE, total)
|
|
770
|
+
card_windows.append((c["start"], c["end"]))
|
|
771
|
+
|
|
772
|
+
focus = sorted(tl.get("focus", []), key=lambda e: e["start"])
|
|
773
|
+
scene_moves = sorted(tl.get("moves", []))
|
|
774
|
+
events = []
|
|
775
|
+
for i, e in enumerate(focus):
|
|
776
|
+
end = e.get("end")
|
|
777
|
+
if end is None: # a glide-and-look lasts until the next gesture
|
|
778
|
+
end = min(focus[i + 1]["start"] if i + 1 < len(focus) else e["start"] + 2, e["start"] + POINT_MAX)
|
|
779
|
+
b_raw = v(end)
|
|
780
|
+
nxt = next((t for t in scene_moves if v(e["start"]) + 0.3 < t < b_raw + 1.0), None)
|
|
781
|
+
if nxt is not None: # the page moved under the target: what was framed is no longer there
|
|
782
|
+
b_raw = min(b_raw, nxt - 0.1)
|
|
783
|
+
a, b = mapped(v(e["start"]), b_raw)
|
|
784
|
+
if b - a < 0.15:
|
|
785
|
+
continue
|
|
786
|
+
events.append(dict(e, start=a, end=b, box=px(e["box"]), textBox=px(e["textBox"]) if e.get("textBox") else None))
|
|
787
|
+
|
|
788
|
+
pips = []
|
|
789
|
+
if pip_on:
|
|
790
|
+
for e in events:
|
|
791
|
+
long_type = e["kind"] == "type" and e["end"] - e["start"] >= PIP_MIN and e.get("mode") != "zoom"
|
|
792
|
+
if (e.get("mode") == "pip" or long_type) and not any(e["start"] < cb and e["end"] > ca for ca, cb in card_windows):
|
|
793
|
+
p = plan_pip(e)
|
|
794
|
+
if p:
|
|
795
|
+
tail = min([0.8] + [m(t) - 0.05 - e["end"] for t in scene_moves if m(t) > e["end"]])
|
|
796
|
+
p.update(start=e["start"] + 0.15, end=e["end"] + max(tail, 0.1), label=e.get("label", ""))
|
|
797
|
+
pips.append(p)
|
|
798
|
+
e["mode"] = "pip"
|
|
799
|
+
moves, shots = ([], [])
|
|
800
|
+
if zoom_on:
|
|
801
|
+
tracked_blocks = [mapped(v(a), v(b)) for a, b in tl.get("blocked", [])]
|
|
802
|
+
blocked = sorted(card_windows + tracked_blocks + [(p["start"], p["end"]) for p in pips])
|
|
803
|
+
captioned = [mapped(v(a), v(b)) for a, b in tl.get("captioned", [])]
|
|
804
|
+
moves, shots = plan_camera(events, blocked, max_zoom, captioned, [m(t) for t in scene_moves])
|
|
805
|
+
|
|
806
|
+
callouts = []
|
|
807
|
+
for e in events:
|
|
808
|
+
if e.get("callout"):
|
|
809
|
+
x, y, w, h = e["box"]
|
|
810
|
+
ring = (x - CALLOUT_RING_PAD, y - CALLOUT_RING_PAD, w + 2 * CALLOUT_RING_PAD, h + 2 * CALLOUT_RING_PAD)
|
|
811
|
+
label = e.get("label") or ""
|
|
812
|
+
below = bool(label) and ring[1] - CALLOUT_TAG < 12
|
|
813
|
+
callouts.append({"start": e["start"], "end": e["end"] + 0.5, "ring": ring, "label": label, "below": below})
|
|
814
|
+
|
|
815
|
+
captions, lts = [], []
|
|
816
|
+
raw_caps = sorted(tl.get("captions", []), key=lambda c: c["start"])
|
|
817
|
+
for i, c in enumerate(raw_caps):
|
|
818
|
+
end = c.get("end")
|
|
819
|
+
if end is None:
|
|
820
|
+
end = raw_caps[i + 1]["start"] if i + 1 < len(raw_caps) else c["start"] + 4
|
|
821
|
+
a, b = mapped(v(c["start"]), v(end))
|
|
822
|
+
if b - a >= CAPTION_MIN and c["text"].strip():
|
|
823
|
+
captions.append({"text": c["text"], "start": a, "end": b})
|
|
824
|
+
for c in tl.get("lowerThirds", []):
|
|
825
|
+
a, b = mapped(v(c["start"]), v(c["end"]))
|
|
826
|
+
if b - a >= 1:
|
|
827
|
+
lts.append({"kicker": c["kicker"], "title": c["title"], "start": a, "end": b})
|
|
828
|
+
|
|
829
|
+
print(f"plan: {len(shots)} zoom shots, {len(pips)} insets, {len(callouts)} callouts, "
|
|
830
|
+
f"{len(cards)} cards, {len(lts)} lower-thirds, {len(captions)} captions")
|
|
831
|
+
|
|
832
|
+
# 4. The overlays, as PNGs.
|
|
833
|
+
tmp = tempfile.mkdtemp(prefix="render-take-")
|
|
834
|
+
items = []
|
|
835
|
+
for i, c in enumerate(cards):
|
|
836
|
+
items.append({"id": f"card-{i}", "kind": c["kind"], "kicker": c.get("kicker", ""), "title": c["title"], "subtitle": c.get("subtitle") or ""})
|
|
837
|
+
for i, c in enumerate(lts):
|
|
838
|
+
items.append({"id": f"lt-{i}", "kind": "lower-third", "kicker": c["kicker"], "title": c["title"]})
|
|
839
|
+
texts = sorted({c["text"] for c in captions})
|
|
840
|
+
for i, t in enumerate(texts):
|
|
841
|
+
items.append({"id": f"cap-{i}", "kind": "caption", "text": t})
|
|
842
|
+
for i, c in enumerate(callouts):
|
|
843
|
+
items.append({"id": f"co-{i}", "kind": "callout", "w": round(c["ring"][2]), "h": round(c["ring"][3]), "label": c["label"], "tagBelow": c["below"]})
|
|
844
|
+
if items:
|
|
845
|
+
json.dump({"width": W, "height": H, "brand": tl.get("brand") or {}, "items": items},
|
|
846
|
+
open(os.path.join(tmp, "overlays.json"), "w"))
|
|
847
|
+
subprocess.run(["node", os.path.join(HERE, "render-overlays.mjs"),
|
|
848
|
+
os.path.join(tmp, "overlays.json"), tmp], check=True)
|
|
849
|
+
|
|
850
|
+
def png(i):
|
|
851
|
+
return os.path.join(tmp, f"{i}.png")
|
|
852
|
+
|
|
853
|
+
for i, c in enumerate(callouts):
|
|
854
|
+
img = cv2.imread(png(f"co-{i}"), cv2.IMREAD_UNCHANGED)
|
|
855
|
+
c["bgr"], c["alpha"] = img[:, :, :3].astype(np.float32), img[:, :, 3].astype(np.float32) / 255
|
|
856
|
+
c["x"] = int(round(c["ring"][0] - SHADOW_PAD))
|
|
857
|
+
c["y"] = int(round(c["ring"][1] - SHADOW_PAD - (CALLOUT_TAG if c["label"] and not c["below"] else 0)))
|
|
858
|
+
for p in pips:
|
|
859
|
+
p["layers"] = pip_layers(*p["size"])
|
|
860
|
+
|
|
861
|
+
# 5. The ffmpeg graph for the overlays drawn over the camera: captions, lower-thirds, cards.
|
|
862
|
+
inputs, graph, last = [], [], "0:v"
|
|
863
|
+
placed = []
|
|
864
|
+
for c in captions:
|
|
865
|
+
placed.append(("cap", f"cap-{texts.index(c['text'])}", c["start"], c["end"], CAPTION_FADE))
|
|
866
|
+
for i, c in enumerate(lts):
|
|
867
|
+
placed.append(("lt", f"lt-{i}", c["start"], c["end"], LT_FADE))
|
|
868
|
+
for i, c in enumerate(cards):
|
|
869
|
+
placed.append(("card", f"card-{i}", c["start"], c["end"], CARD_FADE))
|
|
870
|
+
for n, (kind, pid, a, b, fade) in enumerate(placed, start=1):
|
|
871
|
+
d = max(b - a, 2 * fade + 0.04)
|
|
872
|
+
img = cv2.imread(png(pid), cv2.IMREAD_UNCHANGED)
|
|
873
|
+
ih, iw = img.shape[:2]
|
|
874
|
+
if kind == "cap":
|
|
875
|
+
x, y = (W - iw) // 2, H - ih - 48 + SHADOW_PAD
|
|
876
|
+
elif kind == "lt":
|
|
877
|
+
x, y = 64 - SHADOW_PAD, H - ih - 190 + SHADOW_PAD
|
|
878
|
+
else:
|
|
879
|
+
x, y = 0, 0
|
|
880
|
+
fin = 0 if (kind == "card" and a < 0.05) else fade # the video opens on the title card
|
|
881
|
+
inputs += ["-loop", "1", "-framerate", str(FPS), "-t", f"{d:.3f}", "-i", png(pid)]
|
|
882
|
+
chain = f"[{n}:v]format=rgba"
|
|
883
|
+
if fin:
|
|
884
|
+
chain += f",fade=t=in:st=0:d={fin}:alpha=1"
|
|
885
|
+
chain += f",fade=t=out:st={d - fade:.3f}:d={fade}:alpha=1,setpts=PTS-STARTPTS+{a:.3f}/TB[o{n}]"
|
|
886
|
+
graph.append(chain)
|
|
887
|
+
graph.append(f"[{last}][o{n}]overlay=x={x}:y={y}:eof_action=pass:enable='between(t,{a:.3f},{a + d:.3f})'[v{n}]")
|
|
888
|
+
last = f"v{n}"
|
|
889
|
+
graph.append(f"[{last}]format=yuv420p[vout]")
|
|
890
|
+
graph_file = os.path.join(tmp, "graph.txt")
|
|
891
|
+
open(graph_file, "w").write(";\n".join(graph))
|
|
892
|
+
|
|
893
|
+
# 6. Frames: decode the cut take, draw the camera, the inset and the callouts, encode.
|
|
894
|
+
select = "+".join(f"between(t,{a:.3f},{b:.3f})" for a, b, _ in cuts) or "0"
|
|
895
|
+
dec = subprocess.Popen(
|
|
896
|
+
["ffmpeg", "-v", "error", "-i", args.src, "-an", "-vf",
|
|
897
|
+
f"select='not({select})',setpts=N/({FPS}*TB),"
|
|
898
|
+
f"scale={W}:{H}:force_original_aspect_ratio=decrease:flags=lanczos,pad={W}:{H}:(ow-iw)/2:(oh-ih)/2:color=0x08101a",
|
|
899
|
+
"-r", str(FPS), "-f", "rawvideo", "-pix_fmt", "bgr24", "-"], stdout=subprocess.PIPE)
|
|
900
|
+
enc = subprocess.Popen(
|
|
901
|
+
["ffmpeg", "-v", "error", "-y", "-f", "rawvideo", "-pix_fmt", "bgr24", "-s", f"{W}x{H}", "-r", str(FPS), "-i", "-",
|
|
902
|
+
*inputs, "-filter_complex_script", graph_file, "-map", "[vout]", "-t", f"{total:.3f}",
|
|
903
|
+
"-c:v", "libx264", "-preset", "medium", "-crf", "18", "-threads", "8", "-movflags", "+faststart", args.out],
|
|
904
|
+
stdin=subprocess.PIPE)
|
|
905
|
+
parchment = np.array([230, 239, 243], np.float32)
|
|
906
|
+
frame_bytes = W * H * 3
|
|
907
|
+
n = 0
|
|
908
|
+
while True:
|
|
909
|
+
buf = dec.stdout.read(frame_bytes)
|
|
910
|
+
if len(buf) < frame_bytes:
|
|
911
|
+
break
|
|
912
|
+
t = n / FPS
|
|
913
|
+
n += 1
|
|
914
|
+
if t > total + 0.5:
|
|
915
|
+
continue
|
|
916
|
+
frame = np.frombuffer(buf, np.uint8).reshape(H, W, 3)
|
|
917
|
+
touched = False
|
|
918
|
+
if timecode:
|
|
919
|
+
y0_, y1_, x0_, x1_ = timecode
|
|
920
|
+
frame, touched = frame.copy(), True
|
|
921
|
+
frame[y0_:y1_, x0_:x1_] = frame[2 * y0_ - y1_:y0_, x0_:x1_]
|
|
922
|
+
for c in callouts:
|
|
923
|
+
a = window_alpha(t, c["start"], c["end"], CALLOUT_FADE)
|
|
924
|
+
if a > 0:
|
|
925
|
+
if not touched:
|
|
926
|
+
frame, touched = frame.copy(), True
|
|
927
|
+
blend(frame, c["bgr"], c["alpha"] * a, c["x"], c["y"])
|
|
928
|
+
x, y, w = camera_at(moves, t)
|
|
929
|
+
if w < W - 0.5:
|
|
930
|
+
z = W / w
|
|
931
|
+
before = frame
|
|
932
|
+
frame = cv2.warpAffine(frame, np.float32([[z, 0, -x * z], [0, z, -y * z]]), (W, H), flags=cv2.INTER_CUBIC)
|
|
933
|
+
touched = True
|
|
934
|
+
if burned_captions:
|
|
935
|
+
keep_caption(before, frame)
|
|
936
|
+
for p in pips:
|
|
937
|
+
a = window_alpha(t, p["start"], p["end"], PIP_FADE)
|
|
938
|
+
if a <= 0:
|
|
939
|
+
continue
|
|
940
|
+
if not touched:
|
|
941
|
+
frame, touched = frame.copy(), True
|
|
942
|
+
(cx, cy, cw, ch), (iw, ih), (px_, py_) = p["crop"], p["size"], p["pos"]
|
|
943
|
+
zi = iw / cw
|
|
944
|
+
inset = cv2.warpAffine(frame, np.float32([[zi, 0, -cx * zi], [0, ih / ch, -cy * ih / ch]]), (iw, ih), flags=cv2.INTER_CUBIC)
|
|
945
|
+
outer, inner, shadow, pad = p["layers"]
|
|
946
|
+
dy = int(round((1 - a) * 18))
|
|
947
|
+
blend(frame, np.zeros(3, np.float32), shadow * a, px_ - pad, py_ - pad + dy)
|
|
948
|
+
blend(frame, parchment, outer * a, px_, py_ + dy)
|
|
949
|
+
blend(frame, inset, inner * a, px_, py_ + dy)
|
|
950
|
+
enc.stdin.write(frame.tobytes() if touched else buf)
|
|
951
|
+
enc.stdin.close()
|
|
952
|
+
dec.wait()
|
|
953
|
+
if enc.wait() != 0:
|
|
954
|
+
sys.exit("ffmpeg could not encode the video")
|
|
955
|
+
|
|
956
|
+
# 7. The plan beside the video, and stills of each effect for a check.
|
|
957
|
+
plan = {
|
|
958
|
+
"duration": total, "offset": offset, "cuts": [(a, b, w) for a, b, w in cuts],
|
|
959
|
+
"shots": [{"in": s_["in"], "out": s_["out"], "labels": [k_["label"] for k_ in s_["keys"]],
|
|
960
|
+
"zoom": round(W / min(k_["rect"][2] for k_ in s_["keys"]), 2)} for s_ in shots],
|
|
961
|
+
"pips": [{"start": p["start"], "end": p["end"], "label": p["label"], "corner": p["pos"]} for p in pips],
|
|
962
|
+
"cards": [{"kind": c["kind"], "title": c["title"], "start": c["start"], "end": c["end"]} for c in cards],
|
|
963
|
+
"lowerThirds": [{"title": c["title"], "start": c["start"], "end": c["end"]} for c in lts],
|
|
964
|
+
"callouts": [{"label": c["label"], "start": c["start"], "end": c["end"]} for c in callouts],
|
|
965
|
+
}
|
|
966
|
+
json.dump(plan, open(args.plan or os.path.splitext(args.out)[0] + ".plan.json", "w"), indent=1)
|
|
967
|
+
if args.stills:
|
|
968
|
+
os.makedirs(args.stills, exist_ok=True)
|
|
969
|
+
stem = os.path.splitext(os.path.basename(args.out))[0]
|
|
970
|
+
shots_at = [("zoom", (s_["in"] + s_["out"]) / 2) for s_ in shots]
|
|
971
|
+
shots_at += [("pip", (p["start"] + p["end"]) / 2) for p in pips]
|
|
972
|
+
shots_at += [("callout", (c["start"] + c["end"]) / 2) for c in callouts]
|
|
973
|
+
shots_at += [("card", c["start"] + 1.5) for c in cards[:2]]
|
|
974
|
+
for i, (kind, t) in enumerate(shots_at):
|
|
975
|
+
subprocess.run(["ffmpeg", "-v", "error", "-y", "-ss", f"{t:.2f}", "-i", args.out, "-frames:v", "1",
|
|
976
|
+
os.path.join(args.stills, f"{stem}-{kind}-{i:02d}-{t:05.1f}s.png")], check=True)
|
|
977
|
+
if args.keep:
|
|
978
|
+
print(f"overlays and graph kept in {tmp}")
|
|
979
|
+
else:
|
|
980
|
+
shutil.rmtree(tmp, ignore_errors=True)
|
|
981
|
+
print(f"wrote {args.out} ({total:.1f} s): {len(shots)} zooms, {len(pips)} insets, {len(cards)} cards, {len(captions)} captions")
|
|
982
|
+
|
|
983
|
+
|
|
984
|
+
if __name__ == "__main__":
|
|
985
|
+
main()
|