@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.
Files changed (67) hide show
  1. package/bin/arbidocs-render-take.mjs +27 -0
  2. package/dist/NativeDocxEditor-UAZUKZ77.js +10 -0
  3. package/dist/NativeDocxEditor-UAZUKZ77.js.map +1 -0
  4. package/dist/NativeDocxEditor-YX7B7OFH.cjs +31 -0
  5. package/dist/NativeDocxEditor-YX7B7OFH.cjs.map +1 -0
  6. package/dist/{chunk-W2RTZOK3.js → chunk-A2MN2ZLX.js} +1750 -1779
  7. package/dist/chunk-A2MN2ZLX.js.map +1 -0
  8. package/dist/{chunk-3R6XBYL7.cjs → chunk-DTBSGG36.cjs} +7 -2
  9. package/dist/chunk-DTBSGG36.cjs.map +1 -0
  10. package/dist/{chunk-4GFB5U3K.js → chunk-EGDWNEAG.js} +4 -3
  11. package/dist/chunk-EGDWNEAG.js.map +1 -0
  12. package/dist/chunk-GYAUJ4KT.js +71 -0
  13. package/dist/chunk-GYAUJ4KT.js.map +1 -0
  14. package/dist/{chunk-PIT2YZCK.js → chunk-JQVPGMLU.js} +3 -3
  15. package/dist/{chunk-PIT2YZCK.js.map → chunk-JQVPGMLU.js.map} +1 -1
  16. package/dist/{chunk-LGX6UFYG.js → chunk-P2KPDPFF.js} +8 -3
  17. package/dist/chunk-P2KPDPFF.js.map +1 -0
  18. package/dist/{chunk-ZWWN4CUL.cjs → chunk-QT6EGMAJ.cjs} +1748 -1778
  19. package/dist/chunk-QT6EGMAJ.cjs.map +1 -0
  20. package/dist/chunk-S24XEVPS.cjs +73 -0
  21. package/dist/chunk-S24XEVPS.cjs.map +1 -0
  22. package/dist/{chunk-TP3FAP5J.cjs → chunk-TTXK62YI.cjs} +5 -5
  23. package/dist/{chunk-TP3FAP5J.cjs.map → chunk-TTXK62YI.cjs.map} +1 -1
  24. package/dist/{chunk-7LLLQUM7.cjs → chunk-ZYSY4L5L.cjs} +6 -5
  25. package/dist/chunk-ZYSY4L5L.cjs.map +1 -0
  26. package/dist/collaboration/index.cjs +15 -15
  27. package/dist/collaboration/index.js +1 -1
  28. package/dist/collaboration/mail/MailMessageView.d.ts +3 -1
  29. package/dist/connect/docx.cjs +7 -6
  30. package/dist/connect/docx.cjs.map +1 -1
  31. package/dist/connect/docx.js +5 -4
  32. package/dist/connect/docx.js.map +1 -1
  33. package/dist/connect/index.cjs +368 -52
  34. package/dist/connect/index.cjs.map +1 -1
  35. package/dist/connect/index.js +356 -41
  36. package/dist/connect/index.js.map +1 -1
  37. package/dist/connection/mail/ConnectedMailComposer.d.ts +12 -2
  38. package/dist/connection/mail/ConnectedMailMessage.d.ts +19 -0
  39. package/dist/connection/mail/ConnectedMailbox.d.ts +7 -3
  40. package/dist/connection/mail/MailComposerForm.d.ts +12 -2
  41. package/dist/connection/mail/index.d.ts +2 -1
  42. package/dist/connection/mail/types.d.ts +15 -0
  43. package/dist/connection/mail/useWordBody.d.ts +48 -0
  44. package/dist/docx/NativeDocxEditor.d.ts +9 -1
  45. package/dist/docx/index.cjs +51 -50
  46. package/dist/docx/index.cjs.map +1 -1
  47. package/dist/docx/index.js +4 -3
  48. package/dist/docx/index.js.map +1 -1
  49. package/dist/docx/template/HouseTemplate.d.ts +5 -0
  50. package/dist/index.cjs +15 -15
  51. package/dist/index.js +1 -1
  52. package/dist/record/index.cjs +307 -66
  53. package/dist/record/index.cjs.map +1 -1
  54. package/dist/record/index.d.ts +145 -26
  55. package/dist/record/index.js +301 -67
  56. package/dist/record/index.js.map +1 -1
  57. package/node_modules/@arbidocs/coedit/package.json +1 -1
  58. package/package.json +8 -5
  59. package/record/render-overlays.mjs +152 -0
  60. package/record/render-take.py +985 -0
  61. package/src/docx/styles.css +22 -0
  62. package/dist/chunk-3R6XBYL7.cjs.map +0 -1
  63. package/dist/chunk-4GFB5U3K.js.map +0 -1
  64. package/dist/chunk-7LLLQUM7.cjs.map +0 -1
  65. package/dist/chunk-LGX6UFYG.js.map +0 -1
  66. package/dist/chunk-W2RTZOK3.js.map +0 -1
  67. 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()