kaizen-agent 1.47.0 → 1.49.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (184) hide show
  1. package/README.md +111 -84
  2. package/cli/build-web.ts +4 -2
  3. package/cli/hermes.ts +65 -0
  4. package/cli/install.ts +24 -10
  5. package/cli/localsend.test.ts +84 -0
  6. package/cli/localsend.ts +131 -0
  7. package/cli/notes.test.ts +6 -4
  8. package/cli/settings.ts +1 -0
  9. package/cli/state.test.ts +37 -0
  10. package/cli/state.ts +35 -20
  11. package/cli/video.test.ts +479 -0
  12. package/cli/video.ts +507 -0
  13. package/cli/web.ts +127 -1
  14. package/cli/worktree.test.ts +3 -1
  15. package/package.json +1 -1
  16. package/skills/kaizen/config.default.yml +4 -0
  17. package/skills/media/kaizen-book/SKILL.md +107 -0
  18. package/skills/media/kaizen-book/assets/reader.html +440 -0
  19. package/skills/media/kaizen-book/assets/styles.json +45 -0
  20. package/skills/media/kaizen-book/examples/book.json +14 -0
  21. package/skills/media/kaizen-book/examples/images/reach.svg +1 -0
  22. package/skills/media/kaizen-book/examples/manuscript.md +127 -0
  23. package/skills/media/kaizen-book/references/interview.md +85 -0
  24. package/skills/media/kaizen-book/references/proofread.md +47 -0
  25. package/skills/media/kaizen-book/references/writing.md +53 -0
  26. package/skills/media/kaizen-book/scripts/build_book.py +279 -0
  27. package/skills/media/kaizen-book/scripts/check.sh +10 -0
  28. package/skills/media/kaizen-book/scripts/check_book.py +128 -0
  29. package/skills/media/kaizen-book/scripts/deploy.py +58 -0
  30. package/skills/media/kaizen-book/scripts/pdf.mjs +99 -0
  31. package/skills/media/kaizen-book/scripts/setup.sh +9 -0
  32. package/skills/media/kaizen-book/scripts/verify.mjs +95 -0
  33. package/skills/{story-deck → media/kaizen-deck}/SKILL.md +1 -1
  34. package/skills/{story-deck → media/kaizen-deck}/scripts/check.sh +1 -1
  35. package/skills/{story-deck → media/kaizen-deck}/scripts/install_fonts.py +2 -2
  36. package/skills/media/kaizen-video/SKILL.md +112 -0
  37. package/skills/media/kaizen-video/assets/CREDITS.txt +16 -0
  38. package/skills/media/kaizen-video/assets/film-fonts/Fraunces.ttf +0 -0
  39. package/skills/media/kaizen-video/assets/film-fonts/Inter.ttf +0 -0
  40. package/skills/media/kaizen-video/assets/film-fonts/PlayfairDisplay-Italic.ttf +0 -0
  41. package/skills/media/kaizen-video/assets/film-fonts/PlayfairDisplay.ttf +0 -0
  42. package/skills/media/kaizen-video/assets/fonts/Fredoka.ttf +0 -0
  43. package/skills/media/kaizen-video/assets/fonts/Montserrat.ttf +0 -0
  44. package/skills/media/kaizen-video/assets/fonts/SpecialElite.ttf +0 -0
  45. package/skills/media/kaizen-video/assets/music/investigations.mp3 +0 -0
  46. package/skills/media/kaizen-video/assets/music/lights.mp3 +0 -0
  47. package/skills/media/kaizen-video/assets/music/sunny.mp3 +0 -0
  48. package/skills/media/kaizen-video/examples/config.json +152 -0
  49. package/skills/media/kaizen-video/examples/sample-film/README.md +10 -0
  50. package/skills/media/kaizen-video/examples/sample-film/film.json +63 -0
  51. package/skills/media/kaizen-video/film.html +246 -0
  52. package/skills/media/kaizen-video/hyperframes.json +12 -0
  53. package/skills/media/kaizen-video/presets/educational-warning/CREDITS.txt +3 -0
  54. package/skills/media/kaizen-video/presets/educational-warning/README.md +26 -0
  55. package/skills/media/kaizen-video/presets/educational-warning/assets/sfx/ending.wav +0 -0
  56. package/skills/media/kaizen-video/presets/educational-warning/assets/sfx/reveal.wav +0 -0
  57. package/skills/media/kaizen-video/presets/educational-warning/assets/sfx/shutter.wav +0 -0
  58. package/skills/media/kaizen-video/presets/educational-warning/assets/sfx/stamp.wav +0 -0
  59. package/skills/media/kaizen-video/presets/educational-warning/index.html +236 -0
  60. package/skills/media/kaizen-video/presets/educational-warning/package.json +10 -0
  61. package/skills/media/kaizen-video/presets/film-cinematic-drama/CREDITS.txt +1 -0
  62. package/skills/media/kaizen-video/presets/film-cinematic-drama/README.md +2 -0
  63. package/skills/media/kaizen-video/presets/film-cinematic-drama/style.json +39 -0
  64. package/skills/media/kaizen-video/presets/film-fun-energetic/CREDITS.txt +1 -0
  65. package/skills/media/kaizen-video/presets/film-fun-energetic/README.md +2 -0
  66. package/skills/media/kaizen-video/presets/film-fun-energetic/style.json +39 -0
  67. package/skills/media/kaizen-video/presets/film-promo-booth/CREDITS.txt +1 -0
  68. package/skills/media/kaizen-video/presets/film-promo-booth/README.md +2 -0
  69. package/skills/media/kaizen-video/presets/film-promo-booth/style.json +39 -0
  70. package/skills/media/kaizen-video/presets/film-viral-social/CREDITS.txt +1 -0
  71. package/skills/media/kaizen-video/presets/film-viral-social/README.md +2 -0
  72. package/skills/media/kaizen-video/presets/film-viral-social/style.json +39 -0
  73. package/skills/media/kaizen-video/presets/tiktok-cheerful/CREDITS.txt +3 -0
  74. package/skills/media/kaizen-video/presets/tiktok-cheerful/README.md +35 -0
  75. package/skills/media/kaizen-video/presets/tiktok-cheerful/assets/sfx/boing.wav +0 -0
  76. package/skills/media/kaizen-video/presets/tiktok-cheerful/assets/sfx/chime.wav +0 -0
  77. package/skills/media/kaizen-video/presets/tiktok-cheerful/assets/sfx/ding.wav +0 -0
  78. package/skills/media/kaizen-video/presets/tiktok-cheerful/assets/sfx/ending.wav +0 -0
  79. package/skills/media/kaizen-video/presets/tiktok-cheerful/assets/sfx/pop.wav +0 -0
  80. package/skills/media/kaizen-video/presets/tiktok-cheerful/assets/sfx/reveal.wav +0 -0
  81. package/skills/media/kaizen-video/presets/tiktok-cheerful/assets/sfx/shutter.wav +0 -0
  82. package/skills/media/kaizen-video/presets/tiktok-cheerful/assets/sfx/stamp.wav +0 -0
  83. package/skills/media/kaizen-video/presets/tiktok-cheerful/assets/sfx/whoosh.wav +0 -0
  84. package/skills/media/kaizen-video/presets/tiktok-cheerful/index.html +260 -0
  85. package/skills/media/kaizen-video/presets/tiktok-cheerful/package.json +10 -0
  86. package/skills/media/kaizen-video/presets/tiktok-explainer/CREDITS.txt +3 -0
  87. package/skills/media/kaizen-video/presets/tiktok-explainer/README.md +52 -0
  88. package/skills/media/kaizen-video/presets/tiktok-explainer/assets/fonts/InstrumentSerif.ttf +0 -0
  89. package/skills/media/kaizen-video/presets/tiktok-explainer/assets/fonts/SpaceMono.ttf +0 -0
  90. package/skills/media/kaizen-video/presets/tiktok-explainer/assets/sfx/boing.wav +0 -0
  91. package/skills/media/kaizen-video/presets/tiktok-explainer/assets/sfx/ding.wav +0 -0
  92. package/skills/media/kaizen-video/presets/tiktok-explainer/assets/sfx/ending.wav +0 -0
  93. package/skills/media/kaizen-video/presets/tiktok-explainer/assets/sfx/pop.wav +0 -0
  94. package/skills/media/kaizen-video/presets/tiktok-explainer/assets/sfx/reveal.wav +0 -0
  95. package/skills/media/kaizen-video/presets/tiktok-explainer/assets/sfx/shutter.wav +0 -0
  96. package/skills/media/kaizen-video/presets/tiktok-explainer/assets/sfx/stamp.wav +0 -0
  97. package/skills/media/kaizen-video/presets/tiktok-explainer/assets/sfx/tick.wav +0 -0
  98. package/skills/media/kaizen-video/presets/tiktok-explainer/assets/sfx/whoosh.wav +0 -0
  99. package/skills/media/kaizen-video/presets/tiktok-explainer/index.html +272 -0
  100. package/skills/media/kaizen-video/presets/tiktok-explainer/package.json +10 -0
  101. package/skills/media/kaizen-video/presets/tiktok-mystery/CREDITS.txt +12 -0
  102. package/skills/media/kaizen-video/presets/tiktok-mystery/README.md +51 -0
  103. package/skills/media/kaizen-video/presets/tiktok-mystery/assets/sfx/clock.wav +0 -0
  104. package/skills/media/kaizen-video/presets/tiktok-mystery/assets/sfx/ding.wav +0 -0
  105. package/skills/media/kaizen-video/presets/tiktok-mystery/assets/sfx/ending.wav +0 -0
  106. package/skills/media/kaizen-video/presets/tiktok-mystery/assets/sfx/heart.wav +0 -0
  107. package/skills/media/kaizen-video/presets/tiktok-mystery/assets/sfx/projector.wav +0 -0
  108. package/skills/media/kaizen-video/presets/tiktok-mystery/assets/sfx/reveal.wav +0 -0
  109. package/skills/media/kaizen-video/presets/tiktok-mystery/assets/sfx/shutter.wav +0 -0
  110. package/skills/media/kaizen-video/presets/tiktok-mystery/assets/sfx/stamp.wav +0 -0
  111. package/skills/media/kaizen-video/presets/tiktok-mystery/assets/sfx/whoosh.wav +0 -0
  112. package/skills/media/kaizen-video/presets/tiktok-mystery/index.html +260 -0
  113. package/skills/media/kaizen-video/presets/tiktok-mystery/package.json +10 -0
  114. package/skills/media/kaizen-video/presets/tiktok-scam-alert/CREDITS.txt +8 -0
  115. package/skills/media/kaizen-video/presets/tiktok-scam-alert/README.md +24 -0
  116. package/skills/media/kaizen-video/presets/tiktok-scam-alert/assets/sfx/clock.wav +0 -0
  117. package/skills/media/kaizen-video/presets/tiktok-scam-alert/assets/sfx/ding.wav +0 -0
  118. package/skills/media/kaizen-video/presets/tiktok-scam-alert/assets/sfx/ending.wav +0 -0
  119. package/skills/media/kaizen-video/presets/tiktok-scam-alert/assets/sfx/heart.wav +0 -0
  120. package/skills/media/kaizen-video/presets/tiktok-scam-alert/assets/sfx/reveal.wav +0 -0
  121. package/skills/media/kaizen-video/presets/tiktok-scam-alert/assets/sfx/shutter.wav +0 -0
  122. package/skills/media/kaizen-video/presets/tiktok-scam-alert/assets/sfx/stamp.wav +0 -0
  123. package/skills/media/kaizen-video/presets/tiktok-scam-alert/assets/sfx/whoosh.wav +0 -0
  124. package/skills/media/kaizen-video/presets/tiktok-scam-alert/index.html +266 -0
  125. package/skills/media/kaizen-video/presets/tiktok-scam-alert/package.json +10 -0
  126. package/skills/media/kaizen-video/references/presets.md +76 -0
  127. package/skills/{story-video → media/kaizen-video}/references/production.md +2 -2
  128. package/skills/{story-video → media/kaizen-video}/references/revisions.md +9 -9
  129. package/skills/media/kaizen-video/scripts/__pycache__/intake.cpython-314.pyc +0 -0
  130. package/skills/media/kaizen-video/scripts/__pycache__/make_film.cpython-314.pyc +0 -0
  131. package/skills/media/kaizen-video/scripts/__pycache__/make_tiktok.cpython-314.pyc +0 -0
  132. package/skills/media/kaizen-video/scripts/__pycache__/render_cut.cpython-314.pyc +0 -0
  133. package/skills/media/kaizen-video/scripts/__pycache__/sfx.cpython-314.pyc +0 -0
  134. package/skills/media/kaizen-video/scripts/__pycache__/srt.cpython-314.pyc +0 -0
  135. package/skills/media/kaizen-video/scripts/__pycache__/transcribe.cpython-314.pyc +0 -0
  136. package/skills/media/kaizen-video/scripts/__pycache__/vo.cpython-314.pyc +0 -0
  137. package/skills/media/kaizen-video/scripts/check.sh +22 -0
  138. package/skills/{story-video → media/kaizen-video}/scripts/intake.py +2 -2
  139. package/skills/media/kaizen-video/scripts/make_film.py +263 -0
  140. package/skills/media/kaizen-video/scripts/make_tiktok.py +213 -0
  141. package/skills/{story-video → media/kaizen-video}/scripts/master.sh +12 -11
  142. package/skills/media/kaizen-video/scripts/render_cut.py +88 -0
  143. package/skills/media/kaizen-video/scripts/setup.sh +14 -0
  144. package/skills/{story-video → media/kaizen-video}/scripts/sfx.py +3 -3
  145. package/skills/media/kaizen-video/scripts/srt.py +9 -0
  146. package/skills/media/kaizen-video/scripts/still.sh +7 -0
  147. package/skills/media/kaizen-video/scripts/transcribe.py +45 -0
  148. package/skills/media/kaizen-video/tools/carve.mjs +560 -0
  149. package/skills/media/kaizen-video/tools/lib/main-module.mjs +13 -0
  150. package/web/board/views.ts +1 -1
  151. package/web/board.html +3 -1
  152. package/web/board.js +2 -2
  153. package/web/notebook.js +1 -1
  154. package/web/notes.html +2 -0
  155. package/web/term.js +38 -36
  156. package/web/term.ts +43 -10
  157. package/web/video.html +298 -0
  158. package/web/video.js +81 -0
  159. package/web/video.ts +610 -0
  160. package/skills/story-video/SKILL.md +0 -108
  161. package/skills/story-video/engine/package.json +0 -12
  162. package/skills/story-video/engine/src/Root.tsx +0 -24
  163. package/skills/story-video/engine/src/engine/Film.tsx +0 -405
  164. package/skills/story-video/engine/src/engine/presets.json +0 -58
  165. package/skills/story-video/engine/src/index.ts +0 -3
  166. package/skills/story-video/engine/tsconfig.json +0 -1
  167. package/skills/story-video/examples/sample-film/README.md +0 -12
  168. package/skills/story-video/examples/sample-film/film.json +0 -16
  169. package/skills/story-video/references/presets.md +0 -21
  170. package/skills/story-video/scripts/check.sh +0 -19
  171. package/skills/story-video/scripts/setup.sh +0 -25
  172. package/skills/story-video/scripts/srt.py +0 -30
  173. package/skills/story-video/scripts/still.sh +0 -10
  174. /package/skills/{story-deck → media/kaizen-deck}/examples/build-example.js +0 -0
  175. /package/skills/{story-deck → media/kaizen-deck}/references/rules.md +0 -0
  176. /package/skills/{story-deck → media/kaizen-deck}/references/story-arc.md +0 -0
  177. /package/skills/{story-deck → media/kaizen-deck}/scripts/kit.js +0 -0
  178. /package/skills/{story-deck → media/kaizen-deck}/scripts/prep_images.py +0 -0
  179. /package/skills/{story-deck → media/kaizen-deck}/scripts/qa.sh +0 -0
  180. /package/skills/{story-video → media/kaizen-video}/examples/sample-film/lines.json +0 -0
  181. /package/skills/{story-video → media/kaizen-video}/references/licensing.md +0 -0
  182. /package/skills/{story-video → media/kaizen-video}/references/script.md +0 -0
  183. /package/skills/{story-video → media/kaizen-video}/scripts/music.sh +0 -0
  184. /package/skills/{story-video → media/kaizen-video}/scripts/vo.py +0 -0
@@ -0,0 +1,272 @@
1
+ <!doctype html>
2
+ <html lang="id" data-resolution="portrait">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=1080, height=1920" />
6
+ <script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
7
+ <script src="captions.js"></script>
8
+ <style>
9
+ @font-face { font-family: "Montserrat"; src: url("assets/fonts/Montserrat.ttf"); font-weight: 100 900; }
10
+ @font-face { font-family: "Instrument Serif"; src: url("assets/fonts/InstrumentSerif.ttf"); }
11
+ @font-face { font-family: "Space Mono"; src: url("assets/fonts/SpaceMono.ttf"); font-weight: 700; }
12
+ :root { --ink: #0c0b0a; --cream: #efe6d6; --orange: #f7a93b; --red: #ef4b3a; --dim: #8d867b; }
13
+ * { margin: 0; padding: 0; box-sizing: border-box; }
14
+ html, body { width: 1080px; height: 1920px; overflow: hidden; background: var(--ink); }
15
+ body { font-family: "Montserrat", sans-serif; color: var(--cream); }
16
+ #root { position: relative; width: 100%; height: 100%; overflow: hidden; background: var(--ink); }
17
+ .fill { position: absolute; inset: 0; }
18
+ .serif { font-family: "Instrument Serif", serif; font-weight: 400; }
19
+ .mono { font-family: "Space Mono", monospace; font-weight: 700; }
20
+ .acc { color: var(--orange); }
21
+
22
+ /* Background: near-black with two soft diagonal light streaks that drift. */
23
+ #streaks { inset: -30%; opacity: .9;
24
+ background:
25
+ linear-gradient(115deg, transparent 38%, rgba(239,230,214,.045) 44%, transparent 50%),
26
+ linear-gradient(115deg, transparent 58%, rgba(247,169,59,.05) 62%, transparent 67%); }
27
+ #vignette { background: radial-gradient(ellipse 85% 70% at 50% 45%, transparent 55%, rgba(0,0,0,.55) 100%); }
28
+
29
+ /* The speaker sits in a cream-framed clip card, like a frame pulled from an editor. */
30
+ #pose { position: absolute; left: 190px; top: 470px; width: 700px; height: 930px; }
31
+ #cam { position: absolute; inset: 0; border-radius: 34px; overflow: hidden;
32
+ border: 6px solid var(--cream); background: #1a1816; box-shadow: 0 40px 100px rgba(0,0,0,.75); transform-origin: 50% 50%; }
33
+ #a-roll { width: 100%; height: 100%; object-fit: cover; object-position: 50% 30%; filter: contrast(1.06) saturate(.92); }
34
+ #cam .chip { position: absolute; top: 22px; left: 22px; display: flex; align-items: center; gap: 10px; padding: 6px 14px;
35
+ border-radius: 10px; background: rgba(12,11,10,.72); font-size: 30px; color: var(--orange); }
36
+ #cam .chip b { width: 14px; height: 14px; border-radius: 50%; background: var(--red); }
37
+ #cam .shade { position: absolute; inset: 0; background: #000; opacity: 0; }
38
+
39
+ /* Hook: small serif setup line, then one huge headline. */
40
+ #hook { position: absolute; left: 70px; right: 70px; top: 150px; text-align: center; opacity: 0; }
41
+ #hook .setup { font-size: 64px; line-height: 1; opacity: .9; }
42
+ #hook h1 { margin-top: 8px; white-space: nowrap; font-weight: 900; font-size: 132px; line-height: .95; letter-spacing: -4px; }
43
+ #hook .sub { margin-top: 18px; font-size: 52px; color: var(--orange); }
44
+
45
+ /* Kinetic captions: words pop in as they are spoken; the spoken word is orange. */
46
+ #captions { position: absolute; left: 100px; right: 100px; top: 1450px; height: 250px; }
47
+ .chunk { position: absolute; inset: 0; display: flex; flex-wrap: wrap; justify-content: center; align-content: flex-start; gap: 0 22px; opacity: 0; }
48
+ .w { display: block; font-weight: 900; font-size: 80px; line-height: 1.08; letter-spacing: -2px; text-transform: lowercase;
49
+ color: var(--cream); opacity: 0; text-shadow: 0 6px 24px rgba(0,0,0,.85); }
50
+
51
+ /* Big numeral scene ("title" with "big"): outlined digit that fills up in orange. */
52
+ .scene { position: absolute; inset: 0; opacity: 0; }
53
+ .big { position: absolute; left: 40px; top: 40px; font-weight: 900; font-size: 980px; line-height: .9; letter-spacing: -40px; }
54
+ .big .o { color: transparent; -webkit-text-stroke: 6px var(--orange); }
55
+ .big .f { position: absolute; inset: 0; color: var(--orange); clip-path: inset(100% 0 0 0); }
56
+ .txt { position: absolute; left: 90px; right: 90px; top: 1130px; }
57
+ .txt .k { font-size: 62px; line-height: 1; }
58
+ .txt h1 { white-space: nowrap; font-weight: 900; font-size: 130px; line-height: .95; letter-spacing: -4px; margin-top: 6px; }
59
+ .txt .s { font-size: 64px; color: var(--orange); margin-top: 10px; }
60
+ .scene.center .txt { top: 760px; text-align: center; }
61
+ .scene .frame { width: 900px; height: 560px; margin: 0 auto 40px; border-radius: 30px; overflow: hidden; border: 6px solid var(--cream); }
62
+ .frame img { width: 100%; height: 100%; object-fit: cover; }
63
+ .scene.has-img .txt { top: 330px; }
64
+
65
+ /* Banner scene ("case"): headline on top, orange tape across the greyed clip, optional red stamp. */
66
+ .banner .head { position: absolute; left: 80px; right: 80px; top: 170px; text-align: center; }
67
+ .banner .head .k { font-size: 58px; line-height: 1; }
68
+ .banner .head h2 { font-weight: 900; font-size: 96px; line-height: 1; letter-spacing: -3px; margin-top: 8px; }
69
+ .tape { position: absolute; left: -60px; right: -60px; top: 800px; padding: 26px 80px; background: var(--orange); color: var(--ink);
70
+ transform: rotate(-6deg); text-align: center; font-weight: 900; font-size: 96px; line-height: 1; letter-spacing: -3px;
71
+ box-shadow: 0 0 0 10px var(--red), 0 30px 80px rgba(0,0,0,.7); }
72
+ .tape span { display: inline-block; }
73
+ .banner .stamp { position: absolute; left: 0; right: 0; top: 1130px; margin: auto; width: max-content; padding: 6px 30px;
74
+ border: 9px solid var(--red); color: var(--red); background: rgba(12,11,10,.55); font-weight: 900; font-size: 110px; letter-spacing: 2px; opacity: 0; }
75
+ .banner .s { position: absolute; left: 90px; right: 90px; top: 1480px; text-align: center; font-size: 60px; }
76
+
77
+ /* Photo card: slides in over the clip card's place. */
78
+ .photo { position: absolute; left: 150px; top: 430px; width: 780px; opacity: 0; display: flex; flex-direction: column; align-items: center; }
79
+ .photo .frame { width: 780px; height: 940px; border-radius: 34px; overflow: hidden; border: 6px solid var(--cream); box-shadow: 0 40px 100px rgba(0,0,0,.75); }
80
+ .photo p { margin-top: 26px; padding: 6px 18px; border-radius: 10px; background: rgba(239,230,214,.08); font-size: 34px; color: var(--orange); }
81
+
82
+ /* Steps list ("item"). */
83
+ #list { position: absolute; left: 90px; right: 90px; top: 700px; display: flex; flex-direction: column; gap: 40px; opacity: 0; }
84
+ #list .head { font-size: 66px; color: var(--orange); opacity: 0; }
85
+ .item { display: flex; align-items: center; gap: 30px; opacity: 0; }
86
+ .item b { flex: none; min-width: 120px; padding: 6px 14px; border-radius: 12px; background: var(--orange); color: var(--ink); text-align: center; font-size: 44px; }
87
+ .item span { font-weight: 900; font-size: 74px; line-height: 1; letter-spacing: -2px; }
88
+
89
+ /* Ending: stacked question, a giant orange glyph, a call to comment. */
90
+ .end .txt { top: 300px; text-align: center; }
91
+ .end .txt h1 { font-size: 120px; }
92
+ .end .glyph { position: absolute; left: 0; right: 0; top: 640px; text-align: center; font-weight: 900; font-size: 640px; line-height: 1; color: var(--orange);
93
+ text-shadow: 0 30px 90px rgba(247,169,59,.25); }
94
+ .end .s { position: absolute; left: 0; right: 0; top: 1500px; text-align: center; font-size: 58px; }
95
+
96
+ /* Optional mascot: drop mascot.png in the video's images/ folder. */
97
+ #mascot { position: absolute; left: 36px; bottom: 70px; width: 230px; height: 230px; opacity: 0; }
98
+ #mascot img { width: 100%; height: 100%; object-fit: contain; object-position: center bottom; }
99
+ </style>
100
+ </head>
101
+ <body>
102
+ <div id="root" data-composition-id="main" data-start="0" data-duration="{{DURATION}}" data-width="1080" data-height="1920">
103
+ <div id="streaks" class="fill"></div>
104
+ <div id="pose" data-layout-allow-overflow><div id="cam">
105
+ <video id="a-roll" class="clip" src="{{VIDEO}}" playsinline data-start="0" data-duration="{{VIDEO_DURATION}}" data-track-index="0" data-has-audio="true" data-fx-chain="{&quot;version&quot;: 1, &quot;nodes&quot;: [{&quot;type&quot;: &quot;highpass&quot;, &quot;id&quot;: &quot;n1&quot;, &quot;label&quot;: &quot;Remove Rumble&quot;, &quot;params&quot;: {&quot;frequency&quot;: 90, &quot;q&quot;: 0.707, &quot;poles&quot;: &quot;2&quot;}}, {&quot;type&quot;: &quot;compressor&quot;, &quot;id&quot;: &quot;n2&quot;, &quot;label&quot;: &quot;Even Out Loudness&quot;, &quot;params&quot;: {&quot;threshold&quot;: -26, &quot;ratio&quot;: 3, &quot;attack&quot;: 10, &quot;release&quot;: 200, &quot;knee&quot;: 2.83, &quot;makeup&quot;: 6, &quot;mix&quot;: 1}}, {&quot;type&quot;: &quot;gain&quot;, &quot;id&quot;: &quot;n3&quot;, &quot;label&quot;: &quot;Level Up&quot;, &quot;params&quot;: {&quot;gain&quot;: 5}}, {&quot;type&quot;: &quot;truepeak&quot;, &quot;id&quot;: &quot;n4&quot;, &quot;label&quot;: &quot;Peak Ceiling&quot;, &quot;params&quot;: {&quot;ceiling&quot;: -1.5, &quot;lookahead&quot;: 3, &quot;release&quot;: 80}}]}"></video>
106
+ <div class="shade"></div>
107
+ <div class="chip mono"><b></b><span id="tc">0:00</span></div>
108
+ </div></div>
109
+ <div id="vignette" class="fill"></div>
110
+
111
+ <div id="hook"><div class="setup serif">{{HOOK_1}}</div><h1>{{HOOK_2}}</h1><div class="sub serif">{{HOOK_SUB}}</div></div>
112
+ <div id="beats"></div>
113
+ <div id="list"></div>
114
+ <div id="captions"></div>
115
+ <div id="mascot"></div>
116
+
117
+ <audio id="music" src="assets/music.wav" data-start="0" data-duration="{{DURATION}}" data-track-index="2" data-volume="{{MUSIC_VOLUME}}"></audio>
118
+ <!-- SFX:BEGIN --><!-- SFX:END -->
119
+ </div>
120
+
121
+ <script>
122
+ const END = {{DURATION}}, VEND = {{VIDEO_DURATION}}, HOOK = 3.0, DUR = { title: 1.8, case: 3.2, photo: 2.8 };
123
+ const tl = gsap.timeline({ paused: true });
124
+ const el = (tag, cls, html) => { const e = document.createElement(tag); if (cls) e.className = cls; if (html) e.innerHTML = html; return e; };
125
+ const esc = s => String(s ?? "").replace(/[&<>"]/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c]));
126
+ // Trailing ? or ! in a headline turns orange, as in the reference ("BASA-BASI?").
127
+ const punch = s => esc(s).replace(/([?!.]+)$/, '<span class="acc">$1</span>');
128
+ // Shrink a headline wider than the safe area.
129
+ const fit = (n, max = 900) => n.querySelectorAll("h1, h2").forEach(h => { const w = h.scrollWidth; if (w > max) h.style.fontSize = Math.floor(parseFloat(getComputedStyle(h).fontSize) * max / w) + "px"; });
130
+
131
+ // Clip card idles with a slight tilt that flips on every jump cut, plus a slow push.
132
+ const REST = i => ({ x: 0, y: 0, rotation: i % 2 ? 1.6 : -1.6, scale: i % 2 ? 1.03 : 1 });
133
+ SEG_STARTS.forEach((s, i) => tl.fromTo("#cam", { ...REST(i) }, { scale: REST(i).scale + 0.02, duration: (SEG_STARTS[i + 1] ?? END) - s, ease: "none", immediateRender: i === 0 }, s));
134
+ tl.fromTo("#streaks", { x: -60 }, { x: 60, duration: END, ease: "none" }, 0);
135
+ // Timecode on the card counts the footage time.
136
+ const tc = document.getElementById("tc");
137
+ for (let i = 0; i <= Math.floor(VEND); i++) tl.call(() => { tc.textContent = `${Math.floor(i / 60)}:${String(i % 60).padStart(2, "0")}`; }, null, i);
138
+
139
+ // Clip card poses while a card is on screen: tucked top-right, greyed in place, or swept off to the left.
140
+ const POSE = {
141
+ aside: { x: 250, y: -330, rotation: 5, scale: 0.42 },
142
+ grey: { scale: 0.96 },
143
+ away: { x: -980, rotation: -10 },
144
+ };
145
+ const pose = (kind, t, d) => {
146
+ tl.to("#pose", { ...POSE[kind], duration: 0.4, ease: "power3.inOut" }, t);
147
+ tl.to("#cam .shade", { opacity: kind === "grey" ? 0.55 : kind === "aside" ? 0.2 : 0, duration: 0.3 }, t);
148
+ if (kind === "grey") tl.to("#a-roll", { filter: "contrast(1.06) saturate(0) blur(3px)", duration: 0.3 }, t);
149
+ if (t + d >= END) return;
150
+ const back = t + d - 0.35;
151
+ tl.to("#pose", { x: 0, y: 0, rotation: 0, scale: 1, duration: 0.35, ease: "power3.inOut" }, back);
152
+ tl.to("#cam .shade", { opacity: 0, duration: 0.3 }, back);
153
+ if (kind === "grey") tl.to("#a-roll", { filter: "contrast(1.06) saturate(.92) blur(0px)", duration: 0.3 }, back);
154
+ };
155
+
156
+ // Hook.
157
+ const hook = document.getElementById("hook");
158
+ hook.querySelector("h1").innerHTML = punch(hook.querySelector("h1").textContent);
159
+ fit(hook);
160
+ tl.fromTo("#hook", { opacity: 0, y: -30 }, { opacity: 1, y: 0, duration: 0.4, ease: "power3.out" }, 0.05);
161
+ tl.fromTo("#hook h1", { scale: 1.25 }, { scale: 1, duration: 0.45, ease: "back.out(1.6)" }, 0.12);
162
+ tl.to("#hook", { opacity: 0, duration: 0.3 }, HOOK - 0.3);
163
+
164
+ const beats = document.getElementById("beats"), list = document.getElementById("list");
165
+ const pic = b => b.img ? `<div class="frame"><img src="assets/img/${esc(b.img)}" alt="" data-layout-allow-overflow></div>` : "";
166
+ const kb = (n, t, d) => { const im = n.querySelector(".frame img"); if (im) tl.fromTo(im, { scale: 1.12 }, { scale: 1, duration: d, ease: "none" }, t); };
167
+ let listStart = null;
168
+ BEATS.forEach((b, i) => {
169
+ const d = b.dur ?? DUR[b.kind];
170
+ if (b.kind === "title") {
171
+ // Keyword scene: card tucks aside; optional giant outlined numeral that fills up in orange.
172
+ const big = b.big ? `<div class="big"><span class="o">${esc(b.big)}</span><span class="f">${esc(b.big)}</span></div>` : "";
173
+ const n = el("div", "scene" + (b.big ? "" : " center") + (b.img ? " has-img" : ""), big +
174
+ `<div class="txt">${pic(b)}${b.kicker ? `<div class="k serif">${esc(b.kicker)}</div>` : ""}<h1>${punch(b.title)}</h1>` +
175
+ (b.sub ? `<div class="s serif">${esc(b.sub)}</div>` : "") + "</div>");
176
+ beats.append(n); fit(n); pose("aside", b.t, d); kb(n, b.t, d);
177
+ tl.set(n, { opacity: 1 }, b.t);
178
+ if (b.big) {
179
+ tl.fromTo(n.querySelector(".big"), { opacity: 0, y: 80 }, { opacity: 1, y: 0, duration: 0.35, ease: "power3.out" }, b.t);
180
+ tl.fromTo(n.querySelector(".big .f"), { clipPath: "inset(100% 0 0 0)" }, { clipPath: "inset(0% 0 0 0)", duration: Math.min(1.2, d * 0.6), ease: "power2.inOut" }, b.t + 0.3);
181
+ }
182
+ const parts = n.querySelectorAll(".txt > *");
183
+ parts.forEach((p, j) => tl.fromTo(p, { opacity: 0, y: 40 }, { opacity: 1, y: 0, duration: 0.3, ease: "power3.out" }, b.t + 0.12 + j * 0.22));
184
+ tl.to(n, { opacity: 0, duration: 0.25 }, b.t + d - 0.25);
185
+ } else if (b.kind === "case") {
186
+ // Banner scene: headline on top, orange tape across the greyed clip card, red stamp slammed on.
187
+ const n = el("div", "scene banner",
188
+ `<div class="head">${b.kicker ? `<div class="k serif">${esc(b.kicker)}</div>` : ""}${b.sub ? `<h2>${punch(b.sub)}</h2>` : ""}</div>` +
189
+ `<div class="tape" data-layout-allow-overflow>${esc(b.title).split(" ").map(w => `<span>${w}</span>`).join(" ")}</div>` +
190
+ (b.stamp ? `<div class="stamp" data-layout-allow-overlap>${esc(b.stamp)}</div>` : "") +
191
+ (b.note ? `<div class="s serif">${esc(b.note)}</div>` : ""));
192
+ beats.append(n); fit(n); pose("grey", b.t, d);
193
+ tl.set(n, { opacity: 1 }, b.t);
194
+ tl.fromTo(n.querySelector(".head"), { opacity: 0, y: -40 }, { opacity: 1, y: 0, duration: 0.3, ease: "power3.out" }, b.t + 0.05);
195
+ tl.fromTo(n.querySelector(".tape"), { xPercent: -110 }, { xPercent: 0, duration: 0.35, ease: "power4.out" }, b.t + 0.25);
196
+ tl.fromTo(n.querySelectorAll(".tape span"), { scale: 1.4, opacity: 0 }, { scale: 1, opacity: 1, duration: 0.2, stagger: 0.07, ease: "back.out(2)" }, b.t + 0.4);
197
+ const st = n.querySelector(".stamp");
198
+ if (st) tl.fromTo(st, { opacity: 0, scale: 2.4, rotation: -20 }, { opacity: 1, scale: 1, rotation: -8, duration: 0.22, ease: "power4.in" }, b.t + 1.12 - 0.22);
199
+ const s = n.querySelector(".s");
200
+ if (s) tl.fromTo(s, { opacity: 0 }, { opacity: 1, duration: 0.3 }, b.t + 0.6);
201
+ tl.to(n, { opacity: 0, duration: 0.3 }, b.t + d - 0.3);
202
+ } else if (b.kind === "photo") {
203
+ // Photo card sweeps the clip card off and takes its place.
204
+ const n = el("div", "photo", pic(b) + (b.label ? `<p class="mono">${esc(b.label)}</p>` : ""));
205
+ beats.append(n); pose("away", b.t, d); kb(n, b.t, d);
206
+ tl.fromTo(n, { opacity: 0, x: 900, rotation: 8 }, { opacity: 1, x: 0, rotation: 1.5, duration: 0.45, ease: "power3.out" }, b.t + 0.05);
207
+ if (b.t + d < END) tl.to(n, { opacity: 0, x: 900, rotation: 8, duration: 0.35, ease: "power3.in" }, b.t + d - 0.35);
208
+ } else if (b.kind === "item") {
209
+ // Steps: orange mono chip plus a bold line, popped in on the word.
210
+ if (listStart === null) { listStart = b.t; if (b.head) { const h = el("div", "head serif"); h.textContent = b.head; list.append(h); } }
211
+ const k = list.querySelectorAll(".item").length + 1;
212
+ const n = el("div", "item", `<b class="mono">${esc(b.label ?? String(k).padStart(2, "0"))}</b><span>${punch(b.text)}</span>`);
213
+ list.append(n);
214
+ tl.fromTo(n, { opacity: 0, x: -60 }, { opacity: 1, x: 0, duration: 0.3, ease: "back.out(1.8)" }, b.t);
215
+ tl.fromTo(n.querySelector("b"), { scale: 1.5 }, { scale: 1, duration: 0.25, ease: "power3.out" }, b.t);
216
+ } else if (b.kind === "mascot") {
217
+ // Optional corner mascot from the video's images/, shown from its word on.
218
+ const m = document.getElementById("mascot"); m.innerHTML = `<img src="assets/img/${esc(b.img)}" alt="">`;
219
+ tl.fromTo(m, { opacity: 0, y: 120 }, { opacity: 1, y: 0, duration: 0.4, ease: "back.out(1.8)" }, b.t);
220
+ tl.fromTo(m.firstChild, { y: 0 }, { y: -10, duration: 0.6, ease: "sine.inOut", yoyo: true, repeat: Math.max(0, Math.floor((END - b.t) / 0.6) - 1) }, b.t + 0.4);
221
+ } else if (b.kind === "end") {
222
+ // Ending: the question stacks in, a giant orange glyph bounces in on the hit, then a call to comment.
223
+ const n = el("div", "scene end", `<div class="txt">${String(b.title).split("\n").map(l => `<h1>${punch(l)}</h1>`).join("")}</div>` +
224
+ `<div class="glyph">${esc(b.big ?? "?")}</div>` + (b.sub ? `<div class="s serif">${esc(b.sub)}</div>` : ""));
225
+ beats.append(n); fit(n); pose("aside", b.t, END - b.t);
226
+ tl.to("#pose", { opacity: 0, duration: 0.4 }, b.t + 0.2);
227
+ tl.set(n, { opacity: 1 }, b.t);
228
+ n.querySelectorAll(".txt h1").forEach((h, j) => tl.fromTo(h, { opacity: 0, y: 50 }, { opacity: 1, y: 0, duration: 0.3, ease: "power3.out" }, b.t + j * 0.18));
229
+ const land = b.t + 0.54; // the ending sting's hit
230
+ const g = n.querySelector(".glyph");
231
+ tl.fromTo(g, { opacity: 0, scale: 0.2, rotation: -20 }, { opacity: 1, scale: 1, rotation: 0, duration: 0.5, ease: "back.out(2.2)" }, land - 0.2);
232
+ tl.to(g, { rotation: 6, duration: 0.5, ease: "sine.inOut", yoyo: true, repeat: Math.max(0, Math.floor((END - land - 0.3) / 0.5)) }, land + 0.3);
233
+ const s = n.querySelector(".s");
234
+ if (s) tl.fromTo(s, { opacity: 0, y: 20 }, { opacity: 1, y: 0, duration: 0.4 }, land + 0.4);
235
+ }
236
+ });
237
+ // The steps leave at the first jump cut after the last item (max 6 s later).
238
+ const items = BEATS.filter(b => b.kind === "item"), lastItem = items.length ? items[items.length - 1].t : 0;
239
+ const listEnd = Math.min(SEG_STARTS.find(s => s > lastItem + 1) ?? END, lastItem + 6);
240
+ if (items.length) {
241
+ const a = listStart - 0.4;
242
+ pose("aside", a, listEnd - a);
243
+ tl.fromTo("#list", { opacity: 0 }, { opacity: 1, duration: 0.25 }, a);
244
+ const h = list.querySelector(".head");
245
+ if (h) tl.fromTo(h, { opacity: 0, y: 30 }, { opacity: 1, y: 0, duration: 0.3 }, a);
246
+ tl.to("#list", { opacity: 0, duration: 0.3 }, listEnd - 0.3);
247
+ }
248
+
249
+ // Captions: hidden while a scene owns the screen (photo cards keep them).
250
+ const busy = BEATS.filter(b => !["item", "photo", "mascot"].includes(b.kind)).map(b => [b.t, b.kind === "end" ? END : b.t + (b.dur ?? DUR[b.kind])])
251
+ .concat(items.length ? [[listStart - 0.4, listEnd]] : []).concat([[0, HOOK]]);
252
+ const cap = document.getElementById("captions");
253
+ CAPTIONS.forEach(chunk => {
254
+ const start = chunk[0].s, end = chunk[chunk.length - 1].hold;
255
+ if (busy.some(([a, b]) => start < b && end > a)) return;
256
+ const n = el("div", "chunk"); cap.append(n);
257
+ tl.set(n, { opacity: 1 }, start);
258
+ tl.set(n, { opacity: 0 }, end);
259
+ chunk.forEach((w, j) => {
260
+ const s = el("span", "w"); s.textContent = w.w.replace(/[,.]+$/, ""); n.append(s);
261
+ const until = chunk[j + 1] ? chunk[j + 1].s : end;
262
+ const t0 = Math.max(start, w.s - 1 / 30);
263
+ tl.fromTo(s, { opacity: 0, y: 30, scale: 0.7 }, { opacity: 1, y: 0, scale: 1, duration: 0.16, ease: "back.out(2.4)" }, t0);
264
+ tl.set(s, { color: "#f7a93b" }, t0);
265
+ tl.set(s, { color: "#efe6d6" }, until);
266
+ });
267
+ });
268
+
269
+ window.__timelines["main"] = tl;
270
+ </script>
271
+ </body>
272
+ </html>
@@ -0,0 +1,10 @@
1
+ {
2
+ "name": "tiktok-explainer-video",
3
+ "private": true,
4
+ "type": "module",
5
+ "scripts": {
6
+ "dev": "npx --yes hyperframes@0.8.176 preview",
7
+ "check": "npx --yes hyperframes@0.8.176 check"
8
+ },
9
+ "devDependencies": { "@hyperframes/core": "0.8.176" }
10
+ }
@@ -0,0 +1,12 @@
1
+ Sound effects (Wikimedia Commons):
2
+ - shutter.wav: "Holga shuttersound.ogg" by Doitashimashite at Dutch Wikipedia, public domain.
3
+ - heart.wav: "Heartbeat speedsup.wav" by Bob Smaude, CC BY 4.0.
4
+ - clock.wav: "Clock ticking.ogg" by natalie, public domain.
5
+ - projector.wav: "Cinema projector Mayafot.mp3" by Carlosjvives, CC BY 4.0.
6
+ - stamp.wav: "Rubber seal dabbed on ink pad.ogg" by Subhashish Panigrahi, CC BY 3.0.
7
+ - ding.wav: "406243 stubb typewriter-ding-near-mono.wav" by stubb, CC0.
8
+ - reveal.wav: "WWS Typewriter.ogg" by Work With Sounds / Konrad Gutkowski, CC BY 4.0, mixed with "Gong or bell vibrant (short).ogg" (PDSounds), CC0.
9
+ - ending.wav: "Gong or bell vibrant (short).ogg" (PDSounds), CC0, reversed into the hit; may contain "WWS Typewriter.ogg" by Work With Sounds / Konrad Gutkowski, CC BY 4.0.
10
+ - whoosh.wav: synthesized with ffmpeg, CC0.
11
+
12
+ Fonts: "Montserrat" by Julieta Ulanovsky et al. (Google Fonts, SIL Open Font License 1.1); "Special Elite" by Astigmatic (Google Fonts, Apache License 2.0).
@@ -0,0 +1,51 @@
1
+ # TikTok mystery preset
2
+
3
+ A 1080×1920 HyperFrames style for talking-head explainers with a mystery tone. It was modeled on a mystery-explainer reference video.
4
+
5
+ ## Look and motion
6
+
7
+ - **Cold open plus hook:** an optional replay of one strong line opens the video. The two-line hook title (white and red, with a white-box subtitle) sits above the speaker's face, which stays unblurred, while captions run underneath.
8
+ - **Grade:** muted cold teal tint, light vignette, moving film grain, and a few light flickers.
9
+ - **Jump cuts:** framing alternates between 1.0× and a 1.14× punch-in at every cut, with a slow push-in inside each segment.
10
+ - **Captions:** 3-word chunks in a karaoke fill. Each word is grey before it is spoken, sweeps red left to right over exactly its spoken duration (starting one frame early), and turns white once the next word starts. Every word follows the same rule.
11
+ - **Cards**, which all enter with a camera-capture move (viewfinder corners, REC tag, a focus box pulling in, shutter blades snapping, and a shutter click). The speaker blurs and darkens behind each card.
12
+ - `title`: big word with a white-box subtitle and an optional photo above.
13
+ - `case`: a typewriter case file with an optional pinned photo and a red stamp that slams on with a stamp sound.
14
+ - `photo`: a floating rounded photo with a label, slowly zooming.
15
+ - `item`: a checklist entry. A typewritten "KEMUNGKINAN:" header comes first. Each "?" box flickers on like a failing bulb, glows and pulses, the item jolts with a glitch, and its text types out letter by letter with a typewriter-and-gong `reveal` sound. The list leaves at the first jump cut after its last item, at most 6 s later.
16
+ - `end`: one huge red word per line, slammed in one after another, followed by a flash and a camera shake over a near-black screen. It lands on the `ending` sting, a reversed gong swelling into the gong hit.
17
+ - **Layout:** content is centered with equal side margins (captions 110 px, cards 125 px). Titles shrink to fit 840 px. This keeps everything mostly clear of TikTok's side buttons.
18
+
19
+ ## Audio
20
+
21
+ - **Music:** `assets/music/lights.mp3` (Rafael Krux, CC BY 4.0) or `investigations.mp3` (Kevin MacLeod, CC BY 4.0). The music is carved around the voice with `tools/carve.mjs` from hyperframes-audio, so it dips only in the frequency bands the voice uses.
22
+ - **Voice:** `afftdn` denoise and a de-esser during source processing, then a rumble cut, compressor, gain and a true-peak ceiling at −1.5 dBTP inside the composition. Results land at about −16 LUFS.
23
+ - **Sound effects** in `assets/sfx/`:
24
+ - Automatic: `shutter` (every card), `stamp`, `reveal` (checklist items) and `ending`.
25
+ - Usable as reactions in the config: `heart`, `clock`, `projector`, `whoosh`, `ding`.
26
+
27
+ ## New video
28
+
29
+ 1. Put the footage in `videos/<name>/raw/` and transcribe it (see the workflow in the skill's `SKILL.md`). If you cut it, keep `transcript/render-plan.json`.
30
+ 2. Copy the skill's `examples/config.json` to `videos/<name>/config.json` and put card images in `videos/<name>/images/`.
31
+ - Beats and reactions anchor to the first word starting with `word`, at or after `after` seconds of the cut.
32
+ 3. From the project folder, run `python3 <skill>/scripts/make_tiktok.py videos/<name> --render`. The output always lands at `output/<name>/tiktok-mystery/<name>.mp4` with `CREDITS.txt`, and a revision replaces it. For a live preview, run `cd videos/<name>/tiktok && npm run dev`.
33
+
34
+ ## Config keys
35
+
36
+ | Key | Meaning |
37
+ |---|---|
38
+ | `video`, `words`, `plan` | Input cut, WhisperX words, and an optional render plan (the cut's source ranges) |
39
+ | `hook` | `line1`, `line2`, `sub` |
40
+ | `cold_open` | `{"from": word, "after": s, "to": word}` replays that line first; everything else shifts automatically |
41
+ | `fix` | Caption spelling corrections, e.g. `{"Mainan": "Mainland"}` |
42
+ | `beats` | Cards: `title` / `case` / `photo` / `item` / `end` with their text and `img` |
43
+ | `reactions` | `{"sfx", "word", "after", "offset", "volume"}` |
44
+ | `music`, `music_volume` | Music bed id and its level (default 0.32) |
45
+ | `tail` | Seconds the ending card holds after the footage ends |
46
+ | `crf` | Encoder quality (default 23, about 50–60 MB for 2 minutes); lower is sharper and bigger |
47
+
48
+ ## Notes
49
+
50
+ - Source processing (cold open, lanczos upscale with light unsharp, dialogue cleanup) is cached by `tiktok/source.key`. `source.mp4` is always written as a new file. Never hard-link it to the input: an earlier version did, and overwriting it wiped `work/cut.mp4`.
51
+ - Measured WhisperX timing on this footage had a median error of about −12 ms, so no global caption offset is applied.