luckiest-co 1.0.30 → 1.0.32

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 (32) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/package.json +1 -1
  4. package/skills/luckiest-brandkit/CHANGELOG.md +18 -0
  5. package/skills/luckiest-brandkit/SKILL.md +95 -59
  6. package/skills/luckiest-brandkit/references/picks-and-review.md +1 -1
  7. package/skills/luckiest-brandkit/scripts/build_review.mjs +3 -2
  8. package/skills/luckiest-brandkit/scripts/build_review.test.mjs +11 -0
  9. package/skills/luckiest-brandkit/scripts/contact_sheet.py +3 -2
  10. package/skills/luckiest-brandkit-sizing/CHANGELOG.md +13 -0
  11. package/skills/luckiest-brandkit-sizing/SKILL.md +126 -0
  12. package/skills/luckiest-brandkit-sizing/references/signage-and-print.md +60 -0
  13. package/skills/luckiest-video-studio/ATTRIBUTION.md +2 -1
  14. package/skills/luckiest-video-studio/CHANGELOG.md +10 -0
  15. package/skills/luckiest-video-studio/SECURITY.md +5 -2
  16. package/skills/luckiest-video-studio/SKILL.md +39 -27
  17. package/skills/luckiest-video-studio/references/concat.md +3 -2
  18. package/skills/luckiest-video-studio-cuts/ATTRIBUTION.md +5 -4
  19. package/skills/luckiest-video-studio-cuts/CHANGELOG.md +6 -0
  20. package/skills/luckiest-video-studio-cuts/SKILL.md +19 -11
  21. package/skills/luckiest-video-studio-cuts/scripts/build-cuts-reel.mjs +63 -0
  22. package/skills/luckiest-video-studio-cuts/scripts/build-cuts-reel.test.mjs +35 -0
  23. package/skills/luckiest-video-studio-review/ATTRIBUTION.md +50 -0
  24. package/skills/luckiest-video-studio-review/CHANGELOG.md +44 -0
  25. package/skills/luckiest-video-studio-review/LICENSE-motion-os +21 -0
  26. package/skills/luckiest-video-studio-review/SKILL.md +125 -0
  27. package/skills/luckiest-video-studio-review/editor/index.html +885 -0
  28. package/skills/luckiest-video-studio-review/editor/serve.mjs +111 -0
  29. package/skills/luckiest-video-studio-review/references/apply-feedback.md +71 -0
  30. package/skills/luckiest-video-studio-review/scripts/build-reel.mjs +96 -0
  31. package/skills/luckiest-video-studio-review/scripts/build-reel.test.mjs +62 -0
  32. package/skills/luckiest-video-studio-cuts/scripts/build-edl-review.mjs +0 -114
@@ -0,0 +1,885 @@
1
+ <meta charset="utf-8">
2
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
3
+ <title>Motion OS</title>
4
+ <link rel="preconnect" href="https://fonts.googleapis.com">
5
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
6
+ <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600;700&family=Geist+Mono:wght@400;500&family=Inter:wght@400;500;600&family=Inter+Tight:wght@600;800&family=Bricolage+Grotesque:wght@500;700;800&family=Instrument+Serif&family=Space+Grotesk:wght@500;700&family=DM+Sans:wght@400;500;700&display=swap">
7
+ <style>
8
+ /* Motion OS player. Layout: one calm desk. Player + timeline on the left, a single panel on the right. Labels short, explanations live in (i) tips. */
9
+ :root{
10
+ --bg:#ECEEF2; --panel:#FFFFFF; --panel-2:#F5F6F8; --line:#E1E4EA; --line-2:#CED3DB;
11
+ --ink:#14161B; --mute:#646C7A; --faint:#9AA1AD;
12
+ --accent:#2F5BFF; --accent-ink:#FFFFFF; --accent-soft:#E7ECFF;
13
+ --pin:#F2A516; --pin-ink:#2E2000; --pin-soft:#FFF4DC;
14
+ --ok:#157A3D; --ok-soft:#DDF2E4; --warn:#A8530A; --warn-soft:#FCEEDB;
15
+ --stage:#DADEE5; --tip:#14161B; --tip-ink:#F3F4F6;
16
+ --shadow:0 1px 2px rgba(20,22,27,.06),0 8px 24px rgba(20,22,27,.07);
17
+ --sans:"Geist",ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif;
18
+ --mono:"Geist Mono",ui-monospace,SFMono-Regular,Menlo,monospace;
19
+ }
20
+ @media (prefers-color-scheme: dark){:root:not([data-theme="light"]){
21
+ --bg:#0C0D10; --panel:#15171C; --panel-2:#1C1F25; --line:#262A31; --line-2:#343A44;
22
+ --ink:#ECEEF2; --mute:#9BA2AE; --faint:#636B77;
23
+ --accent:#7590FF; --accent-ink:#0B0F24; --accent-soft:#1B2242;
24
+ --pin:#F5B83D; --pin-ink:#2A1C00; --pin-soft:#2E2410;
25
+ --ok:#4ADE80; --ok-soft:#11281A; --warn:#FBBF24; --warn-soft:#2D2210;
26
+ --stage:#07080A; --tip:#ECEEF2; --tip-ink:#14161B;
27
+ --shadow:0 1px 2px rgba(0,0,0,.4),0 8px 24px rgba(0,0,0,.3); color-scheme:dark}}
28
+ :root[data-theme="dark"]{
29
+ --bg:#0C0D10; --panel:#15171C; --panel-2:#1C1F25; --line:#262A31; --line-2:#343A44;
30
+ --ink:#ECEEF2; --mute:#9BA2AE; --faint:#636B77;
31
+ --accent:#7590FF; --accent-ink:#0B0F24; --accent-soft:#1B2242;
32
+ --pin:#F5B83D; --pin-ink:#2A1C00; --pin-soft:#2E2410;
33
+ --ok:#4ADE80; --ok-soft:#11281A; --warn:#FBBF24; --warn-soft:#2D2210;
34
+ --stage:#07080A; --tip:#ECEEF2; --tip-ink:#14161B;
35
+ --shadow:0 1px 2px rgba(0,0,0,.4),0 8px 24px rgba(0,0,0,.3); color-scheme:dark}
36
+
37
+ html,body{height:100%}
38
+ body{background:var(--bg);color:var(--ink);font:13.5px/1.45 var(--sans);-webkit-font-smoothing:antialiased}
39
+ *{box-sizing:border-box}
40
+ button,input,textarea,select{font:inherit;color:inherit}
41
+ button{cursor:pointer}
42
+ :focus-visible{outline:2px solid var(--accent);outline-offset:2px}
43
+ .mono{font-family:var(--mono);font-variant-numeric:tabular-nums}
44
+ .app{display:grid;grid-template-rows:auto 1fr;height:100%;min-height:0}
45
+
46
+ /* top bar */
47
+ .bar{display:flex;align-items:center;gap:10px;flex-wrap:wrap;padding:10px 16px;background:var(--panel);border-bottom:1px solid var(--line)}
48
+ .logo{display:flex;align-items:center;gap:8px;font-weight:700;letter-spacing:-.01em;white-space:nowrap}
49
+ .projsel{display:flex;align-items:center;gap:6px;padding-left:12px;border-left:1px solid var(--line);min-width:0}
50
+ .projsel b{font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:360px}
51
+ .ver{font-size:11px;color:var(--mute);padding:1px 6px;border:1px solid var(--line-2);border-radius:5px}
52
+ .spacer{flex:1}
53
+ .saved{font-size:12px;color:var(--mute);display:flex;align-items:center;gap:6px;white-space:nowrap}
54
+ .saved i{width:7px;height:7px;border-radius:50%;background:var(--ok)}
55
+ .saved.dirty i{background:var(--pin)}
56
+ .btn{display:inline-flex;align-items:center;gap:6px;height:32px;padding:0 12px;border-radius:8px;border:1px solid var(--line-2);background:var(--panel);font-weight:500;white-space:nowrap}
57
+ .btn:hover{background:var(--panel-2)}
58
+ .btn.primary{background:var(--accent);border-color:var(--accent);color:var(--accent-ink)}
59
+ .btn.primary:hover{filter:brightness(1.06)}
60
+ .btn.ghost{border-color:transparent;background:transparent;color:var(--mute)}
61
+ .btn.ghost:hover{color:var(--ink);background:var(--panel-2)}
62
+ .btn.sm{height:28px;padding:0 9px;font-size:12.5px}
63
+ .btn .n{font-family:var(--mono);font-size:11px;padding:0 5px;border-radius:4px;background:rgba(255,255,255,.25)}
64
+ .btn svg{width:15px;height:15px}
65
+
66
+ .info{width:16px;height:16px;border-radius:50%;border:1px solid var(--line-2);background:none;color:var(--faint);font:600 10px/14px var(--sans);display:inline-grid;place-items:center;padding:0;flex:none;cursor:help}
67
+ .info:hover,.info:focus-visible{color:var(--ink);border-color:var(--ink)}
68
+ #tip{position:fixed;z-index:50;max-width:260px;background:var(--tip);color:var(--tip-ink);font-size:12px;line-height:1.45;padding:7px 10px;border-radius:8px;pointer-events:none;box-shadow:var(--shadow)}
69
+
70
+ /* desk */
71
+ .desk{display:grid;grid-template-columns:minmax(0,1fr) 380px;min-height:0}
72
+ .stage{display:flex;flex-direction:column;min-height:0;min-width:0;overflow:auto;padding:14px 16px;gap:10px}
73
+ .viewer-wrap{background:var(--stage);border-radius:12px;padding:14px;display:grid;place-items:center}
74
+ .viewer{position:relative;width:100%;max-width:calc((100vh - 330px) * 16 / 9);min-width:min(100%,420px);aspect-ratio:16/9;border-radius:6px;overflow:hidden;background:#fff;box-shadow:var(--shadow)}
75
+ .viewer video{position:absolute;inset:0;width:100%;height:100%;display:block;background:#fff}
76
+ #live{position:absolute;left:0;top:0;width:1920px;height:1080px;transform-origin:0 0;overflow:hidden}
77
+ .overlay{position:absolute;inset:0}
78
+ .overlay.note-mode{cursor:crosshair}
79
+ .hot{position:absolute;border:2px solid var(--accent);border-radius:6px;pointer-events:none;background:color-mix(in srgb,var(--accent) 7%,transparent)}
80
+ .hot.hover{border-style:dashed;background:none}
81
+ .hot span{position:absolute;left:-2px;top:-22px;background:var(--accent);color:var(--accent-ink);font-size:11px;font-weight:600;padding:2px 7px;border-radius:5px 5px 5px 0;white-space:nowrap}
82
+ .hot.in span{top:4px;left:4px;border-radius:5px}
83
+ .overlay.move-mode{cursor:default}
84
+ .hot .hd{position:absolute;width:14px;height:14px;margin:-7px 0 0 -7px;background:#fff;border:2px solid var(--accent);border-radius:3px;pointer-events:auto;box-shadow:0 1px 4px rgba(0,0,0,.35)}
85
+ .hot.can{border:1.5px dashed rgba(255,255,255,.75);background:none;box-shadow:0 0 0 1px rgba(0,0,0,.25)}
86
+ #rp{position:absolute;inset:0}
87
+ .hot .hd[data-h=nw]{left:0;top:0;cursor:nwse-resize} .hot .hd[data-h=ne]{left:100%;top:0;cursor:nesw-resize}
88
+ .hot .hd[data-h=sw]{left:0;top:100%;cursor:nesw-resize} .hot .hd[data-h=se]{left:100%;top:100%;cursor:nwse-resize}
89
+ .hot.mv{pointer-events:auto;cursor:move}
90
+ .hot em{position:absolute;left:50%;top:100%;transform:translate(-50%,6px);background:var(--ink);color:var(--panel);font:500 10.5px var(--mono);font-style:normal;padding:2px 6px;border-radius:4px;white-space:nowrap}
91
+ .was{position:absolute;border:1.5px dashed rgba(255,255,255,.8);border-radius:6px;backdrop-filter:blur(8px) brightness(.85);-webkit-backdrop-filter:blur(8px) brightness(.85);pointer-events:none}
92
+ .ghost{position:absolute;pointer-events:none}
93
+ .pin{position:absolute;width:24px;height:24px;margin:-12px 0 0 -12px;border-radius:50% 50% 50% 4px;background:var(--pin);color:var(--pin-ink);font:600 11px/20px var(--mono);text-align:center;box-shadow:0 2px 8px rgba(0,0,0,.25);border:2px solid #fff;cursor:pointer;transition:opacity .2s,transform .2s}
94
+ .pin.dim{opacity:0;transform:scale(.6);pointer-events:none}
95
+ .pop{position:absolute;width:250px;background:var(--panel);border:1px solid var(--line-2);border-radius:10px;box-shadow:var(--shadow);padding:10px;display:grid;gap:8px;z-index:3}
96
+ .pop .meta{font-size:11px;color:var(--mute)}
97
+ .pop textarea{width:100%;min-height:60px;resize:vertical;border:1px solid var(--line-2);border-radius:7px;padding:7px 8px;background:var(--panel-2)}
98
+ .pop .row{display:flex;gap:6px;justify-content:flex-end}
99
+ .chip{position:absolute;right:10px;top:10px;font-size:11.5px;font-weight:600;padding:4px 6px 4px 10px;border-radius:99px;background:rgba(20,22,27,.8);color:#fff;display:flex;gap:6px;align-items:center}
100
+ .chip .info{border-color:rgba(255,255,255,.5);color:#fff}
101
+ .chip.left{left:10px;right:auto;padding-right:10px;pointer-events:none}
102
+ .chip i{width:7px;height:7px;border-radius:50%;background:var(--pin)}
103
+
104
+ /* transport */
105
+ .transport{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
106
+ .ib{width:34px;height:32px;display:grid;place-items:center;border-radius:8px;border:1px solid var(--line-2);background:var(--panel)}
107
+ .ib:hover{background:var(--panel-2)}
108
+ .ib.on{background:var(--pin);border-color:var(--pin);color:var(--pin-ink)}
109
+ .ib svg{width:16px;height:16px}
110
+ .tc{font-family:var(--mono);font-variant-numeric:tabular-nums;font-size:13px;padding:0 4px;white-space:nowrap}
111
+ .tc small{color:var(--mute)}
112
+ .seg{display:inline-flex;border:1px solid var(--line-2);border-radius:8px;overflow:hidden;background:var(--panel)}
113
+ .seg button{border:0;background:none;height:30px;padding:0 9px;font-size:12.5px;font-weight:500;color:var(--mute)}
114
+ .seg button.on{background:var(--ink);color:var(--panel)}
115
+ .seg.modes button{height:30px;padding:0 12px;font-weight:600}
116
+ .seg.modes button.on[data-m=edit]{background:var(--accent);color:var(--accent-ink)}
117
+
118
+ /* timeline */
119
+ .tl{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:10px 12px;overflow-x:auto}
120
+ .tl-in{min-width:560px;position:relative;display:grid;gap:6px}
121
+ .trk{position:relative;cursor:ew-resize}
122
+ .trk.scenes{height:44px}
123
+ .trk.pins{height:18px}
124
+ .trk.beats{height:10px}
125
+ .sb{position:absolute;top:0;bottom:0;border-radius:7px;background:var(--panel-2);border:1px solid var(--line);padding:5px 8px;overflow:hidden;cursor:pointer;display:grid;align-content:center}
126
+ .sb b{font-size:12px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
127
+ .sb small{font-size:10.5px;color:var(--mute);display:flex;gap:5px;align-items:center;white-space:nowrap}
128
+ .sb.on{background:var(--accent-soft);border-color:var(--accent)}
129
+ .sd{width:7px;height:7px;border-radius:50%;background:var(--line-2);flex:none}
130
+ .sd.approved{background:var(--ok)} .sd.changes{background:var(--pin)}
131
+ .tp{position:absolute;top:50%;width:14px;height:14px;margin:-7px 0 0 -7px;border-radius:50% 50% 50% 2px;background:var(--pin);border:1.5px solid var(--panel);cursor:pointer}
132
+ .bt{position:absolute;top:2px;bottom:2px;width:1px;background:var(--line-2)}
133
+ .bt.down{background:var(--faint)}
134
+ .bt.dropline{width:2px;top:0;bottom:0;background:var(--accent)}
135
+ .ph{position:absolute;top:-4px;bottom:-4px;width:2px;background:var(--ink);pointer-events:none;z-index:2}
136
+ .ph::before{content:"";position:absolute;top:-2px;left:-5px;width:12px;height:8px;border-radius:3px;background:var(--ink)}
137
+
138
+ /* panel */
139
+ .panel{background:var(--panel);border-left:1px solid var(--line);min-height:0;overflow:auto;display:flex;flex-direction:column}
140
+ .tabs{display:flex;gap:0;padding:6px 8px 0;border-bottom:1px solid var(--line);position:sticky;top:0;background:var(--panel);z-index:2;overflow-x:auto}
141
+ .tab{border:0;background:none;padding:9px 10px;font-weight:500;color:var(--mute);border-bottom:2px solid transparent;margin-bottom:-1px;display:flex;gap:5px;align-items:center;white-space:nowrap}
142
+ .tab.on{color:var(--ink);border-bottom-color:var(--ink)}
143
+ .tab .c{font-family:var(--mono);font-size:10.5px;padding:0 5px;border-radius:4px;background:var(--pin-soft);color:var(--warn)}
144
+ .pane{padding:14px;display:grid;gap:12px;align-content:start}
145
+ .ph2{display:flex;align-items:center;gap:8px}
146
+ .ph2 h2{margin:0;font-size:16px;letter-spacing:-.01em;flex:1;min-width:0;text-wrap:balance}
147
+ .lbl{font-size:10.5px;letter-spacing:.08em;text-transform:uppercase;color:var(--faint);font-weight:600;display:flex;align-items:center;gap:6px}
148
+ .status{display:inline-flex;border:1px solid var(--line-2);border-radius:99px;overflow:hidden}
149
+ .status button{border:0;background:none;padding:3px 9px;font-size:11.5px;font-weight:500;color:var(--mute);display:flex;gap:5px;align-items:center}
150
+ .status button.on{background:var(--panel-2);color:var(--ink)}
151
+
152
+ .el{border:1px solid var(--line);border-radius:10px;background:var(--panel)}
153
+ .el.sel{border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-soft)}
154
+ .elh{display:flex;align-items:center;gap:8px;padding:10px;cursor:pointer;width:100%;border:0;background:none;text-align:left}
155
+ .elh .ico{width:22px;height:22px;border-radius:6px;background:var(--panel-2);display:grid;place-items:center;color:var(--mute);flex:none}
156
+ .elh .ico svg{width:13px;height:13px}
157
+ .elh b{font-weight:600;font-size:13px;flex:1;min-width:0}
158
+ .edot{width:7px;height:7px;border-radius:50%;background:var(--accent);visibility:hidden}
159
+ .el.edited .edot{visibility:visible}
160
+ .chev{color:var(--faint);transition:transform .15s}
161
+ .el.sel .chev{transform:rotate(90deg)}
162
+ .fields{display:none;gap:10px;padding:0 10px 12px}
163
+ .el.sel .fields{display:grid}
164
+ .f{display:grid;gap:4px}
165
+ .fl{display:flex;align-items:center;gap:6px;font-size:11.5px;color:var(--mute);min-height:18px}
166
+ .fl .sp{flex:1}
167
+ .undo{border:0;background:none;color:var(--accent);font-size:11.5px;padding:0;visibility:hidden}
168
+ .f.changed .undo{visibility:visible}
169
+ .f input[type=text],.f input[type=number],.f textarea,.f select{width:100%;border:1px solid var(--line-2);border-radius:7px;padding:6px 8px;background:var(--panel-2);min-width:0}
170
+ .f textarea{min-height:52px;resize:vertical}
171
+ .f.changed input[type=text],.f.changed input[type=number],.f.changed textarea,.f.changed select{border-color:var(--accent);background:var(--panel)}
172
+ .claude{font-size:10px;font-weight:600;color:var(--warn);background:var(--warn-soft);padding:0 5px;border-radius:4px}
173
+ .row2{display:grid;grid-template-columns:1fr 1fr;gap:6px}
174
+ .colorf{display:flex;gap:6px;flex-wrap:wrap}
175
+ .sw{width:26px;height:26px;border-radius:7px;border:2px solid transparent;padding:0;box-shadow:inset 0 0 0 1px rgba(0,0,0,.12)}
176
+ .sw.on{border-color:var(--ink)}
177
+ .media{display:flex;gap:10px;align-items:center}
178
+ .media .th{width:48px;height:48px;border-radius:8px;background:var(--panel-2) center/cover no-repeat;border:1px solid var(--line);flex:none;display:grid;place-items:center;color:var(--faint);font-size:10px}
179
+ .media .nm{flex:1;min-width:0;font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
180
+ .list{display:grid;gap:6px}
181
+ .lr{display:grid;gap:6px;align-items:center}
182
+ .lr input,.lr select{width:100%;border:1px solid var(--line-2);border-radius:7px;padding:5px 7px;background:var(--panel-2);min-width:0}
183
+ .lr .x{border:0;background:none;color:var(--faint);padding:0 2px;font-size:15px}
184
+ .lr .mth{width:30px;height:30px;border-radius:50%;background:var(--panel-2) center/cover;border:1px solid var(--line);padding:0}
185
+ .add{border:1px dashed var(--line-2);background:none;border-radius:7px;padding:5px;font-size:12px;color:var(--mute)}
186
+
187
+ .scr{display:grid;gap:4px}
188
+ .scr .t{border:0;background:none;padding:0;font:11px var(--mono);color:var(--accent);justify-self:start}
189
+ .scr input{width:100%;border:1px solid transparent;border-radius:6px;padding:5px 7px;background:var(--panel-2)}
190
+ .scr input:focus{border-color:var(--line-2);background:var(--panel)}
191
+ .scr.changed input{border-color:var(--accent)}
192
+ .sgrp{display:grid;gap:8px}
193
+ .sgrp + .sgrp{padding-top:12px;border-top:1px solid var(--line)}
194
+
195
+ .brand{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:8px}
196
+ .bc{display:flex;gap:8px;align-items:center;border:1px solid var(--line);border-radius:9px;padding:6px 8px}
197
+ .bc input[type=color]{width:30px;height:30px;border:0;padding:0;background:none;border-radius:6px;cursor:pointer}
198
+ .bc b{font-size:12px;font-weight:600;display:block}
199
+ .bc small{font:10.5px var(--mono);color:var(--mute)}
200
+ .bc.changed{border-color:var(--accent)}
201
+
202
+ .drop{border:1.5px dashed var(--line-2);border-radius:12px;padding:14px;display:grid;gap:10px;text-align:center;transition:border-color .15s,background .15s}
203
+ .drop.over{border-color:var(--accent);background:var(--accent-soft)}
204
+ .drop b{font-size:13.5px}
205
+ .drop .ways{display:flex;gap:6px;justify-content:center;flex-wrap:wrap}
206
+ .lib{display:grid;grid-template-columns:repeat(auto-fill,minmax(70px,1fr));gap:8px}
207
+ .ast{display:grid;gap:4px;font-size:10.5px;color:var(--mute);text-align:center;border:0;background:none;padding:0;min-width:0}
208
+ .ast .th{aspect-ratio:1;border-radius:8px;background:var(--panel-2) center/cover no-repeat;border:1px solid var(--line);display:grid;place-items:center;color:var(--mute);font-size:10px;font-weight:600}
209
+ .ast span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
210
+ .ast.mine .th{border-color:var(--accent)}
211
+ .linkrow{display:flex;gap:6px}
212
+ .linkrow input{flex:1;min-width:0;height:32px;border:1px solid var(--line-2);border-radius:8px;padding:0 9px;background:var(--panel-2)}
213
+ .links{display:grid;gap:4px;font-size:12px}
214
+ .links div{display:flex;gap:6px;align-items:center;min-width:0}
215
+ .links span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0;font-family:var(--mono);font-size:11px}
216
+ .vol{display:flex;gap:10px;align-items:center}
217
+ .vol input{flex:1;accent-color:var(--accent)}
218
+
219
+ .note{border:1px solid var(--line);border-radius:10px;padding:10px;display:grid;gap:6px}
220
+ .note.sel{border-color:var(--pin);box-shadow:0 0 0 3px var(--pin-soft)}
221
+ .nh{display:flex;gap:8px;align-items:center;font-size:12px;color:var(--mute)}
222
+ .num{width:20px;height:20px;border-radius:50% 50% 50% 3px;background:var(--pin);color:var(--pin-ink);font:600 10.5px/20px var(--mono);text-align:center;flex:none}
223
+ .nh button{border:0;background:none;color:var(--faint);padding:0;font-size:15px;line-height:1}
224
+ .nh .go{color:var(--accent);font-size:12px}
225
+ .note textarea{width:100%;border:1px solid var(--line-2);border-radius:7px;padding:6px 8px;background:var(--panel-2);min-height:44px;resize:vertical}
226
+ .tag{font-size:10px;font-weight:600;padding:0 5px;border-radius:4px;background:var(--panel-2);border:1px solid var(--line);color:var(--mute)}
227
+ .tag.ok{background:var(--ok-soft);color:var(--ok);border-color:transparent}
228
+ .empty{font-size:12.5px;color:var(--mute);padding:18px;border:1px dashed var(--line-2);border-radius:10px;text-align:center}
229
+
230
+ /* sheets */
231
+ .scrim{position:fixed;inset:0;background:rgba(10,11,14,.45);display:grid;place-items:center;padding:16px;z-index:20}
232
+ .sheet{width:min(640px,100%);max-height:calc(100% - 32px);overflow:auto;background:var(--panel);border-radius:14px;border:1px solid var(--line-2);box-shadow:0 20px 60px rgba(0,0,0,.3);padding:20px;display:grid;gap:14px}
233
+ .sheet h3{margin:0;font-size:18px;letter-spacing:-.01em;display:flex;gap:8px;align-items:center}
234
+ .sheet p{margin:0;color:var(--mute)}
235
+ .sheet textarea.out{width:100%;min-height:280px;font:12px/1.5 var(--mono);border:1px solid var(--line-2);border-radius:9px;padding:12px;background:var(--panel-2);resize:vertical}
236
+ .sheet .row{display:flex;gap:8px;justify-content:flex-end;flex-wrap:wrap;align-items:center}
237
+ .stepn{display:grid;grid-template-columns:24px 1fr;gap:10px;align-items:start}
238
+ .stepn > i{width:24px;height:24px;border-radius:50%;background:var(--panel-2);border:1px solid var(--line-2);font:600 12px/22px var(--mono);text-align:center;font-style:normal}
239
+ .stepn > div{display:grid;gap:8px;min-width:0}
240
+ .stepn b{display:flex;gap:6px;align-items:center}
241
+ .opts{display:grid;gap:8px}
242
+ .opt{display:flex;gap:10px;align-items:flex-start;border:1px solid var(--line);border-radius:10px;padding:10px 12px;cursor:pointer}
243
+ .opt input{margin-top:3px;accent-color:var(--accent)}
244
+ .steps{display:grid;gap:8px;margin:0;padding:0;list-style:none}
245
+ .steps li{display:flex;gap:10px;align-items:center;color:var(--faint)}
246
+ .steps li i{width:16px;height:16px;border-radius:50%;border:2px solid var(--line-2);flex:none}
247
+ .steps li.doing{color:var(--ink)} .steps li.doing i{border-color:var(--accent);border-top-color:transparent;animation:spin .8s linear infinite}
248
+ .steps li.done{color:var(--ink)} .steps li.done i{background:var(--ok);border-color:var(--ok)}
249
+ @keyframes spin{to{transform:rotate(360deg)}}
250
+ .pick{display:grid;grid-template-columns:repeat(auto-fill,minmax(84px,1fr));gap:10px}
251
+ .toast{position:fixed;left:50%;bottom:20px;transform:translateX(-50%);background:var(--ink);color:var(--panel);padding:8px 14px;border-radius:9px;font-size:13px;font-weight:500;z-index:60;box-shadow:var(--shadow)}
252
+
253
+ /* live sample stage */
254
+ #live .scene{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:34px;padding:120px 160px}
255
+ #live .scene.left{align-items:flex-start}
256
+ #live [data-el]{will-change:opacity,transform}
257
+
258
+ @media (max-width:1100px){ .desk{grid-template-columns:minmax(0,1fr) 340px} }
259
+ @media (max-width:820px){
260
+ .app{height:auto;min-height:100%}
261
+ .desk{grid-template-columns:minmax(0,1fr)}
262
+ .stage{overflow:visible;padding:12px 16px}
263
+ .viewer-wrap{padding:8px}
264
+ .viewer{min-width:0;max-width:100%}
265
+ .panel{border-left:0;border-top:1px solid var(--line);overflow:visible}
266
+ .projsel{border-left:0;padding-left:0;order:3;flex-basis:100%}
267
+ .projsel b{max-width:none;flex:1}
268
+ .hide-sm{display:none}
269
+ }
270
+ @media (prefers-reduced-motion:reduce){*{animation:none!important;transition:none!important}}
271
+ </style>
272
+
273
+ <div class="app">
274
+ <header class="bar">
275
+ <div class="logo">
276
+ <svg width="22" height="22" viewBox="0 0 24 24" aria-hidden="true"><rect x="2" y="4" width="20" height="16" rx="3" fill="none" stroke="currentColor" stroke-width="2"/><path d="M2 9h20M7 4l-2 5M12 4l-2 5M17 4l-2 5" stroke="currentColor" stroke-width="2"/><circle cx="16" cy="15" r="2.2" fill="var(--pin)"/></svg>
277
+ Motion OS
278
+ </div>
279
+ <div class="projsel">
280
+ <b id="projTitle"></b><span class="ver mono" id="projVer"></span>
281
+ <button class="info" data-tip-id="proj">i</button>
282
+ </div>
283
+ <div class="spacer"></div>
284
+ <div class="saved"><i></i><span>Auto-saved</span></div>
285
+ <button class="btn" id="exportBtn" hidden data-tip="Renders the video with your changes baked in and saves a new mp4. The original is never overwritten.">Export MP4</button>
286
+ <button class="btn primary" id="sendBtn" data-tip="Turns every edit and note into one prompt. Copy it, then paste it into Claude.">Send to Claude <span class="n" id="sendN">0</span></button>
287
+ </header>
288
+
289
+ <div class="desk">
290
+ <section class="stage">
291
+ <div class="viewer-wrap">
292
+ <div class="viewer" id="viewer">
293
+ <video id="vid" playsinline preload="auto"></video>
294
+ <div class="overlay" id="overlay"><div id="ghosts"></div><div id="hots"></div><div id="popLayer"></div></div>
295
+ </div>
296
+ </div>
297
+ <div class="transport">
298
+ <button class="ib" id="playBtn" aria-label="Play"><svg viewBox="0 0 16 16" id="playIco"><path d="M4 2.5v11l9-5.5z" fill="currentColor"/></svg></button>
299
+ <div class="seg modes" id="modes"><button data-m="preview" class="on" data-tip="Watch it. Click the frame to play or pause.">Preview</button><button data-m="edit" data-tip="Edit mode (E). The video pauses. Click anything in the frame, drag it to move, drag a corner to resize. Changes save automatically.">Edit</button></div>
300
+ <div class="tc" id="tc">00:00.0</div>
301
+ <div class="spacer"></div>
302
+ <button class="ib" id="noteBtn" aria-label="Add a note" data-tip="Note mode (N). Click anywhere on the frame to pin a note to that exact moment and spot."><svg viewBox="0 0 16 16"><path d="M8 1.5c-3 0-5 2.1-5 4.8 0 3.4 5 8.2 5 8.2s5-4.8 5-8.2c0-2.7-2-4.8-5-4.8z" fill="none" stroke="currentColor" stroke-width="1.5"/><circle cx="8" cy="6.3" r="1.6" fill="currentColor"/></svg></button>
303
+ <div class="seg" id="speed" aria-label="Speed"><button data-r="0.5">½×</button><button data-r="1" class="on">1×</button><button data-r="2">2×</button></div>
304
+ <button class="ib" id="loopBtn" aria-label="Loop this scene" data-tip="Loop the current scene."><svg viewBox="0 0 16 16"><path d="M3 6.5A4.5 4.5 0 0 1 12.2 5M13 9.5A4.5 4.5 0 0 1 3.8 11" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><path d="M12.5 2v3.2H9.3M3.5 14v-3.2h3.2" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg></button>
305
+ <button class="info hide-sm" data-tip="Space play · ← → one frame · Shift + ← → one second · [ ] previous or next scene · E edit or preview · N note mode · Click any text or image in the frame to edit it.">i</button>
306
+ </div>
307
+ <div class="tl"><div class="tl-in" id="tl"></div></div>
308
+ </section>
309
+ <aside class="panel">
310
+ <div class="tabs" role="tablist" id="tabs"></div>
311
+ <div id="pane"></div>
312
+ </aside>
313
+ </div>
314
+ </div>
315
+ <div id="tip" hidden></div>
316
+
317
+ <script>
318
+ /* ================= project: everything comes from reel.json next to this page ================= */
319
+ const FONTS = ['Bricolage Grotesque','Inter Tight','Instrument Serif','Space Grotesk','DM Sans','Inter','Geist'];
320
+ const TIPS = {
321
+ proj:'The project file (reel.json) Claude wrote when it built this video. Every text, timing and asset in the panel comes from it.',
322
+ scene:'The pieces on screen in this scene. Click one here or in the frame to edit it.',
323
+ status:'Approved scenes are left alone when Claude makes the next version.',
324
+ motion:'How it moves. Only Claude can change this, so it goes into the prompt when you Send to Claude.',
325
+ box:'Where it sits and how big, in % of the frame. In Edit mode you can drag it on the frame instead.',
326
+ timing:'When it appears and leaves, in seconds.',
327
+ script:'Every word in the video, in order. The fastest way to fix copy.',
328
+ colors:'Change a color once and every element using it updates.',
329
+ fonts:'Headline and body typefaces for the whole video.',
330
+ assets:'Images, video and sound used in the video. Add your own here, then swap them into any scene.',
331
+ add:'Drop new files into the assets/ folder next to reel.json, then say in a note where they go. Links are passed to Claude to download.',
332
+ music:'Background track. Claude lines the beat drop up with the scene you pick.',
333
+ notes:'Notes pin to an exact moment and spot. Use them for things a text box can\'t change, like motion or pacing.',
334
+ pending:'These reach the video when you Send to Claude and Claude re-renders it.',
335
+ };
336
+
337
+ /* ================= state ================= */
338
+ const $ = s => document.querySelector(s);
339
+ const esc = s => String(s ?? '').replace(/[&<>"]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
340
+ const clamp = (v, a = 0, b = 1) => Math.max(a, Math.min(b, v));
341
+ const ease = p => 1 - Math.pow(1 - clamp(p), 3);
342
+ const fmt = t => { const m = Math.floor(t / 60), s = t - m * 60; return `${String(m).padStart(2,'0')}:${s.toFixed(1).padStart(4,'0')}`; };
343
+ let vid = $('#vid'); const overlay = $('#overlay');
344
+
345
+ let P, st;
346
+ let ui = {tab:'scene', scene:null, sel:null, mode:'preview', pop:null, selNote:null, loop:false, hover:null};
347
+ const clock = {rate:1};
348
+ let music = null; // <audio> for an uploaded music track
349
+
350
+ // Edits and notes live in this browser, per project. When a new version loads, what was already sent is cleared.
351
+ const KEY = () => 'motionos:' + P.id;
352
+ function loadProject(){
353
+ const ver = P.version || 1;
354
+ try { st = JSON.parse(localStorage.getItem(KEY()) || 'null'); } catch (e) { st = null; }
355
+ st = Object.assign({over:{}, notes:JSON.parse(JSON.stringify(P.notes || [])), status:{}, links:[], sent:[], sentSnap:{}, ver}, st || {});
356
+ if (st.ver !== ver) {
357
+ st.over = Object.fromEntries(Object.entries(st.over).filter(([k, v]) => JSON.stringify(st.sentSnap[k]) !== JSON.stringify(v)));
358
+ st.notes = st.notes.filter(n => !st.sent.includes(n.id)); st.links = st.links.filter(l => !l.sent);
359
+ st.sent = []; st.sentSnap = {}; st.ver = ver;
360
+ }
361
+ P.scenes.forEach(s => st.status[s.id] ??= 'review');
362
+ ui = {...ui, scene:P.scenes[0].id, sel:null, pop:null, selNote:null};
363
+ $('#projTitle').textContent = P.title; $('#projVer').textContent = 'v' + ver; document.title = P.title + ' · Motion OS';
364
+ vid.src = P.src + '?v=' + ver; vid.currentTime = 0.7;
365
+ setupMusic(); renderAll(); tick();
366
+ }
367
+
368
+ /* overrides: every edit is a path -> value */
369
+ const get = (path, base) => path in st.over ? st.over[path] : base;
370
+ const set = (path, v, base) => { if (JSON.stringify(v) === JSON.stringify(base)) delete st.over[path]; else st.over[path] = v; markDirty(); };
371
+ const sceneOf = id => P.scenes.find(s => s.id === id);
372
+ const allEls = () => P.scenes.flatMap(s => s.els.map(e => ({...e, s:s.id})));
373
+ const elById = id => allEls().find(e => e.id === id);
374
+ const pv = (e, k) => get(`${e.id}.${k}`, e.props[k].v);
375
+ const elTime = e => get(`${e.id}.@time`, e.t);
376
+ const elBox = e => get(`${e.id}.@box`, e.box);
377
+ const brandColor = id => { const c = P.brand.colors.find(c => c.id === id); return c ? get('brand.color.' + id, c.v) : id; };
378
+ const color = v => typeof v === 'string' && v.startsWith('brand.') ? brandColor(v.slice(6)) : v;
379
+ const brandFont = id => get('brand.font.' + id, P.brand.fonts.find(f => f.id === id)?.v);
380
+ const assetSrc = id => id === 'brand.logo' ? assetSrc(get('brand.logo', P.brand.logo)) : id || null;
381
+ const assetName = id => { if (id === 'brand.logo') return 'Brand logo'; const a = P.assets.find(a => a.id === id); return a?.name || id; };
382
+ const elEdited = e => Object.keys(st.over).some(k => k.startsWith(e.id + '.'));
383
+ const sceneAt = t => P.scenes.find(s => t < s.t[1] - 1e-6) || P.scenes[P.scenes.length - 1];
384
+ const now = () => vid.currentTime;
385
+ const openNotes = () => st.notes.filter(n => !st.sent.includes(n.id));
386
+ const pendingEdits = () => Object.entries(st.over).filter(([k, v]) => JSON.stringify(st.sentSnap[k]) !== JSON.stringify(v)).length + st.links.filter(l => !l.sent).length;
387
+
388
+ function markDirty(){
389
+ if (!P) return;
390
+ try { localStorage.setItem(KEY(), JSON.stringify(st)); } catch (e) {}
391
+ $('#sendN').textContent = pendingEdits() + openNotes().length;
392
+ }
393
+
394
+ /* ================= playback ================= */
395
+ function play(){ if (ui.mode === 'edit') setMode('preview'); vid.play(); syncMusic(true); closePop(); setPlayIcon(true); }
396
+ function pause(){ if (!P) return; vid.pause(); syncMusic(false); setPlayIcon(false); }
397
+ const playing = () => !vid.paused;
398
+ function setPlayIcon(on){ $('#playIco').innerHTML = on ? '<path d="M4 2.5h3v11H4zM9 2.5h3v11H9z" fill="currentColor"/>' : '<path d="M4 2.5v11l9-5.5z" fill="currentColor"/>'; }
399
+ function seek(t){
400
+ t = clamp(t, 0, P.duration - 0.02);
401
+ vid.currentTime = t;
402
+ syncMusic(playing());
403
+ const s = sceneAt(t).id;
404
+ if (s !== ui.scene) { ui.scene = s; if (ui.sel && elById(ui.sel)?.s !== s) ui.sel = null; renderTL(); if (ui.tab === 'scene') renderPane(); }
405
+ tick();
406
+ }
407
+ function vframe(){ if (!vid.paused) { if (ui.loop) { const s = sceneOf(ui.scene); if (vid.currentTime >= s.t[1] - 0.03) vid.currentTime = s.t[0]; } tick(); requestAnimationFrame(vframe); } }
408
+ function wireVideo(){
409
+ vid.addEventListener('play', () => { setPlayIcon(true); vframe(); });
410
+ vid.addEventListener('pause', () => { setPlayIcon(false); tick(); });
411
+ vid.addEventListener('seeked', tick);
412
+ vid.addEventListener('loadedmetadata', () => { if (vid.currentTime < 0.1) vid.currentTime = 0.7; tick(); });
413
+ vid.addEventListener('ended', () => setPlayIcon(false));
414
+ }
415
+ // Live projects ("live": "bundle.js" in reel.json) render the real composition in the page instead of the mp4,
416
+ // so size and position edits show for real. This wraps it in the few <video> calls the player uses.
417
+ let live = null;
418
+ function liveVideo(api){
419
+ const on = {}, fire = n => (on[n] || []).forEach(f => f()), p = () => api.ref.current;
420
+ let rate = 1, t0 = 0;
421
+ const wait = setInterval(() => { if (!p()) return; clearInterval(wait);
422
+ ['play', 'pause', 'ended'].forEach(n => p().addEventListener(n, () => fire(n)));
423
+ p().seekTo(Math.round(t0 * P.fps)); fire('loadedmetadata'); }, 30);
424
+ return {
425
+ get currentTime(){ return p() ? p().getCurrentFrame() / P.fps : t0; },
426
+ set currentTime(t){ t0 = t; if (p()) { p().seekTo(Math.round(t * P.fps)); setTimeout(() => fire('seeked'), 30); } },
427
+ get paused(){ return !p()?.isPlaying(); },
428
+ play(){ p()?.play(); return Promise.resolve(); }, pause(){ p()?.pause(); },
429
+ get playbackRate(){ return rate; }, set playbackRate(r){ rate = r; api.setRate(r); },
430
+ addEventListener(n, f){ (on[n] ||= []).push(f); }, set src(v){}, videoWidth: 0,
431
+ };
432
+ }
433
+ let sentBoxes = '';
434
+ function liveBoxes(){ // the edited boxes the composition needs: id -> [base box, new box]
435
+ const b = {}; for (const [k, v] of Object.entries(st.over)) if (k.endsWith('.@box')) { const e = elById(k.slice(0, -5)); if (e?.box) b[e.id] = [...e.box, ...v]; }
436
+ return b;
437
+ }
438
+ function tick(){
439
+ const t = now(), s = sceneAt(t);
440
+ if (s.id !== ui.scene && playing() && !ui.loop) { ui.scene = s.id; if (ui.sel && elById(ui.sel)?.s !== s.id) ui.sel = null; renderTL(); if (ui.tab === 'scene') renderPane(); }
441
+ $('#tc').innerHTML = `${fmt(t)} <small>/ ${fmt(P.duration)} · ${s.id} ${esc(s.name)}</small>`;
442
+ placePH(); renderHots();
443
+ }
444
+ function setupMusic(){
445
+ if (music) { music.pause(); music = null; }
446
+ const id = get('audio.music', P.audio.music), src = id && assetSrc(id);
447
+ if (src) { music = new Audio(src); music.volume = get('audio.musicVol', P.audio.musicVol); }
448
+ }
449
+ function syncMusic(on){ if (!music) return; if (on) { music.currentTime = now(); music.playbackRate = clock.rate; music.play().catch(() => {}); } else music.pause(); }
450
+
451
+ new ResizeObserver(() => { placePH(); renderHots(); }).observe($('#viewer'));
452
+
453
+ /* ================= frame overlay: select + pins ================= */
454
+ function boxOf(e){
455
+ const t = now(), [a, b] = elTime(e);
456
+ if (t < a || t > b) return null;
457
+ return elBox(e);
458
+ }
459
+ function hit(x, y){
460
+ return sceneOf(ui.scene).els.map(e => ({e, b: boxOf({...e, s:ui.scene})})).filter(o => o.b && x >= o.b[0] && x <= o.b[0] + o.b[2] && y >= o.b[1] && y <= o.b[1] + o.b[3])
461
+ .sort((p, q) => p.b[2] * p.b[3] - q.b[2] * q.b[3])[0]?.e;
462
+ }
463
+ function renderHots(){
464
+ const t = now(), out = [];
465
+ const ids = ui.mode === 'edit' ? [ui.sel, ...sceneOf(ui.scene).els.filter(e => e.box && e.id !== ui.sel).map(e => e.id)] : [ui.sel, ui.hover];
466
+ for (const id of ids) {
467
+ if (!id || (id === ui.hover && id === ui.sel && out.length)) continue;
468
+ const e = elById(id); if (!e || e.s !== ui.scene) continue;
469
+ const b = boxOf(e); if (!b) continue;
470
+ const mv = id === ui.sel && ui.mode === 'edit', sc = e.box && Math.round(b[2] / e.box[2] * 100);
471
+ out.push(`<div class="hot ${id === ui.sel ? '' : ui.mode === 'edit' && id !== ui.hover ? 'can' : 'hover'} ${b[1] < 8 ? 'in' : ''} ${mv ? 'mv' : ''}" style="left:${b[0]}%;top:${b[1]}%;width:${b[2]}%;height:${b[3]}%">${id === ui.sel ? `<span>${esc(e.label)}</span>` : ''}${mv ? ['nw','ne','sw','se'].map(h => `<i class="hd" data-h="${h}"></i>`).join('') + (sc !== 100 ? `<em>${sc}%</em>` : '') : ''}</div>`);
472
+ }
473
+ st.notes.forEach((n, i) => out.push(`<div class="pin ${Math.abs(n.t - t) > 0.9 || st.sent.includes(n.id) ? 'dim' : ''}" data-pin="${n.id}" style="left:${n.x}%;top:${n.y}%">${i + 1}</div>`));
474
+ if (ui.mode === 'note') out.push(`<div class="chip left"><i></i>Click to add a note</div>`);
475
+ else if (ui.mode === 'edit') out.push(`<div class="chip left"><i style="background:var(--accent)"></i>Edit: drag to move, corners to resize · saves automatically</div>`);
476
+ else if (pendingEdits()) out.push(`<div class="chip">${pendingEdits()} edit${pendingEdits() === 1 ? '' : 's'} waiting <button class="info" data-tip-id="pending">i</button></div>`);
477
+ $('#hots').innerHTML = out.join('');
478
+ drawGhosts();
479
+ }
480
+ // Moved or resized elements: blur where it was, and draw that patch of the video at the new spot as a live preview.
481
+ function drawGhosts(){
482
+ if (live) { const j = JSON.stringify(liveBoxes()); if (j !== sentBoxes) { sentBoxes = j; live.setBoxes(JSON.parse(j)); } return; }
483
+ const layer = $('#ghosts'), keep = new Set(), r = overlay.getBoundingClientRect(), dpr = devicePixelRatio || 1;
484
+ for (const e of sceneOf(ui.scene).els.map(e => ({...e, s:ui.scene}))) {
485
+ if (!e.box || !(`${e.id}.@box` in st.over)) continue;
486
+ const b = boxOf(e); if (!b) continue;
487
+ keep.add(e.id);
488
+ let g = layer.querySelector(`[data-g="${e.id}"]`);
489
+ if (!g) { g = document.createElement('div'); g.dataset.g = e.id; g.innerHTML = '<div class="was"></div><canvas class="ghost"></canvas>'; layer.appendChild(g); }
490
+ const [was, cv] = g.children, o = e.box, pos = (el, x) => Object.assign(el.style, {left:x[0] + '%', top:x[1] + '%', width:x[2] + '%', height:x[3] + '%'});
491
+ pos(was, o); pos(cv, b);
492
+ const w = Math.max(1, Math.round(b[2] / 100 * r.width * dpr)), h = Math.max(1, Math.round(b[3] / 100 * r.height * dpr));
493
+ if (cv.width !== w || cv.height !== h) { cv.width = w; cv.height = h; }
494
+ const vw = vid.videoWidth, vh = vid.videoHeight;
495
+ if (vw) cv.getContext('2d').drawImage(vid, o[0] / 100 * vw, o[1] / 100 * vh, o[2] / 100 * vw, o[3] / 100 * vh, 0, 0, w, h);
496
+ }
497
+ [...layer.children].forEach(g => keep.has(g.dataset.g) || g.remove());
498
+ }
499
+ const pt = ev => { const r = overlay.getBoundingClientRect(); return [(ev.clientX - r.left) / r.width * 100, (ev.clientY - r.top) / r.height * 100]; };
500
+ overlay.addEventListener('mousemove', ev => {
501
+ if (ui.mode === 'note' || drag || ev.target.closest('.pop,.chip')) return;
502
+ if (ui.mode === 'preview') { if (ui.hover) { ui.hover = null; renderHots(); } overlay.style.cursor = ''; return; }
503
+ const [x, y] = pt(ev), g = grab(x, y), h = hit(x, y)?.id || null;
504
+ if (h !== ui.hover) { ui.hover = h; renderHots(); }
505
+ overlay.style.cursor = g?.h ? (g.h === 'nw' || g.h === 'se' ? 'nwse-resize' : 'nesw-resize') : g ? 'move' : h ? 'move' : '';
506
+ });
507
+ // What's under the pointer on the selected element: a corner handle (h), or its body.
508
+ function grab(x, y){
509
+ const sel = ui.sel && elById(ui.sel), sb = sel && sel.s === ui.scene && sel.box && boxOf(sel); if (!sb) return null;
510
+ const r = overlay.getBoundingClientRect(), near = (cx, cy) => Math.hypot((x - cx) / 100 * r.width, (y - cy) / 100 * r.height) < 14;
511
+ const h = [['nw', 0, 0], ['ne', 1, 0], ['sw', 0, 1], ['se', 1, 1]].find(([, i, j]) => near(sb[0] + sb[2] * i, sb[1] + sb[3] * j))?.[0];
512
+ return h || (x >= sb[0] && x <= sb[0] + sb[2] && y >= sb[1] && y <= sb[1] + sb[3]) ? {h, sel, sb} : null;
513
+ }
514
+ overlay.addEventListener('mouseleave', () => { ui.hover = null; renderHots(); });
515
+ let drag = null;
516
+ overlay.addEventListener('pointerdown', ev => {
517
+ if (ui.mode !== 'edit' || ev.button || ev.target.closest('.pop,.chip,[data-pin]')) return;
518
+ const [x, y] = pt(ev);
519
+ let g = grab(x, y);
520
+ if (!g) { const e = hit(x, y); if (!e?.box) { if (ui.sel) { ui.sel = null; renderPane(); renderHots(); } return; } selectEl(e.id, true); g = grab(x, y); if (!g) return; }
521
+ pause(); drag = {h:g.h, x, y, b:[...g.sb], e:g.sel}; overlay.setPointerCapture(ev.pointerId); ev.preventDefault();
522
+ });
523
+ overlay.addEventListener('pointermove', ev => {
524
+ if (!drag) return;
525
+ const [x, y] = pt(ev), [bx, by, bw, bh] = drag.b, r1 = v => Math.round(v * 10) / 10;
526
+ let nb;
527
+ if (!drag.h) nb = [bx + x - drag.x, by + y - drag.y, bw, bh];
528
+ else { // corners scale uniformly, anchored at the opposite corner (it's a picture of the frame, so no stretching)
529
+ const L = drag.h[1] === 'w', T = drag.h[0] === 'n', ax = L ? bx + bw : bx, ay = T ? by + bh : by;
530
+ const s = Math.max(0.1, ((L ? ax - x : x - ax) / bw + (T ? ay - y : y - ay) / bh) / 2);
531
+ nb = [L ? ax - bw * s : ax, T ? ay - bh * s : ay, bw * s, bh * s];
532
+ }
533
+ set(`${drag.e.id}.@box`, nb.map(r1), drag.e.box); touchScene(drag.e.s); renderHots();
534
+ });
535
+ overlay.addEventListener('pointerup', () => { if (drag) { drag = null; renderPane(); changed(); } });
536
+ overlay.addEventListener('click', ev => {
537
+ if (ev.target.closest('.pop,.chip')) return;
538
+ if (ui.mode === 'edit' && !ev.target.closest('[data-pin]')) return;
539
+ const pin = ev.target.closest('[data-pin]'); if (pin) return focusNote(+pin.dataset.pin);
540
+ const [x, y] = pt(ev), h = hit(x, y);
541
+ if (ui.mode === 'note' || ev.altKey) { pause(); ui.pop = {x, y, t:now(), el:h?.id || null}; return renderPop(); }
542
+ if (ui.pop) return closePop();
543
+ playing() ? pause() : play();
544
+ });
545
+ function renderPop(){
546
+ const p = ui.pop;
547
+ $('#popLayer').innerHTML = p ? `<div class="pin" style="left:${p.x}%;top:${p.y}%">${st.notes.length + 1}</div>
548
+ <div class="pop" style="left:${Math.min(Math.max(p.x, 2), 68)}%;${p.y > 55 ? `bottom:${100 - p.y + 4}%` : `top:${p.y + 4}%`}">
549
+ <div class="meta mono">${fmt(p.t)} · ${sceneAt(p.t).id}${p.el ? ' · ' + esc(elById(p.el).label) : ''}</div>
550
+ <textarea id="popTa" placeholder="What should change?"></textarea>
551
+ <div class="row"><button class="btn ghost sm" data-act="cancel">Cancel</button><button class="btn primary sm" data-act="add">Add note</button></div></div>` : '';
552
+ if (p) $('#popTa').focus();
553
+ }
554
+ $('#popLayer').addEventListener('click', ev => {
555
+ const a = ev.target.closest('[data-act]'); if (!a) return;
556
+ if (a.dataset.act === 'cancel') return closePop();
557
+ const text = $('#popTa').value.trim(); if (!text) return $('#popTa').focus();
558
+ const p = ui.pop, id = Math.max(0, ...st.notes.map(n => n.id)) + 1;
559
+ st.notes.push({id, t:+p.t.toFixed(2), x:+p.x.toFixed(1), y:+p.y.toFixed(1), el:p.el, text}); st.notes.sort((a, b) => a.t - b.t);
560
+ const sid = sceneAt(p.t).id; if (st.status[sid] === 'review') st.status[sid] = 'changes';
561
+ ui.pop = null; ui.selNote = id; renderPop(); markDirty(); renderAll(); toast('Note added');
562
+ });
563
+ $('#popLayer').addEventListener('keydown', ev => { if (ev.key === 'Enter' && (ev.metaKey || ev.ctrlKey)) $('[data-act="add"]').click(); if (ev.key === 'Escape') closePop(); });
564
+ function closePop(){ if (ui.pop) { ui.pop = null; renderPop(); } }
565
+ function setMode(m){ if (m === 'edit') pause(); ui.mode = m; ui.hover = null; [...$('#modes').children].forEach(b => b.classList.toggle('on', b.dataset.m === (m === 'edit' ? 'edit' : 'preview'))); $('#noteBtn').classList.toggle('on', m === 'note'); overlay.classList.toggle('note-mode', m === 'note'); overlay.classList.toggle('move-mode', m === 'edit'); closePop(); renderHots(); }
566
+
567
+ /* ================= timeline ================= */
568
+ const pct = t => (t / P.duration * 100) + '%';
569
+ function renderTL(){
570
+ let beats = '';
571
+ if (P.audio.bpm) { const bt = 60 / P.audio.bpm; let i = 0; for (let t = P.audio.drop % bt; t < P.duration; t += bt, i++) { const k = Math.round((t - P.audio.drop) / bt); beats += `<span class="bt ${Math.abs(t - P.audio.drop) < 0.01 ? 'dropline' : k % 4 === 0 ? 'down' : ''}" style="left:${pct(t)}"></span>`; } }
572
+ $('#tl').innerHTML = `
573
+ <div class="trk scenes">${P.scenes.map(s => `<div class="sb ${ui.scene === s.id ? 'on' : ''}" data-scene="${s.id}" style="left:${pct(s.t[0])};width:calc(${pct(s.t[1] - s.t[0])} - 3px)"><b>${esc(s.name)}</b><small><span class="sd ${st.status[s.id]}"></span>${(s.t[1] - s.t[0]).toFixed(1)}s</small></div>`).join('')}</div>
574
+ <div class="trk pins">${st.notes.map(n => `<span class="tp" data-note="${n.id}" style="left:${pct(n.t)};${st.sent.includes(n.id) ? 'opacity:.35' : ''}" data-tip="${esc(n.text)}"></span>`).join('')}</div>
575
+ ${beats ? `<div class="trk beats" data-tip="Beat grid at ${P.audio.bpm} BPM. The blue line is where the music drop lands.">${beats}</div>` : ''}
576
+ <div class="ph" id="ph"></div>`;
577
+ placePH();
578
+ }
579
+ function placePH(){ const ph = $('#ph'); if (ph && P) ph.style.left = `calc(${pct(now())} - 1px)`; }
580
+ let scrubbing = false;
581
+ const scrubTo = ev => { const r = $('#tl .trk').getBoundingClientRect(); seek((ev.clientX - r.left) / r.width * P.duration); };
582
+ $('#tl').addEventListener('pointerdown', ev => {
583
+ const n = ev.target.closest('[data-note]'); if (n) return focusNote(+n.dataset.note);
584
+ if (!ev.target.closest('.trk')) return;
585
+ scrubbing = true; pause(); scrubTo(ev); ev.target.setPointerCapture?.(ev.pointerId);
586
+ });
587
+ window.addEventListener('pointermove', ev => { if (scrubbing) scrubTo(ev); });
588
+ window.addEventListener('pointerup', () => scrubbing = false);
589
+
590
+ /* ================= panel ================= */
591
+ const ICON = {text:'<path d="M3 4h10M8 4v9" stroke="currentColor" stroke-width="1.6" fill="none" stroke-linecap="round"/>',
592
+ media:'<rect x="2.5" y="3" width="11" height="10" rx="2" fill="none" stroke="currentColor" stroke-width="1.4"/><path d="M3 11l3-3 3 3 2-2 2 2" fill="none" stroke="currentColor" stroke-width="1.4"/>',
593
+ list:'<path d="M3 4h10M3 8h10M3 12h7" stroke="currentColor" stroke-width="1.6" fill="none" stroke-linecap="round"/>',
594
+ number:'<path d="M5 3l-1 10M11 3l-1 10M3 6h10M2.5 10h10" stroke="currentColor" stroke-width="1.4" fill="none"/>'};
595
+ const elIcon = e => { const t = Object.values(e.props).map(p => p.type); return ICON[t.includes('list') ? 'list' : t.includes('media') ? 'media' : e.view === 'counter' ? 'number' : 'text']; };
596
+ const TABS = [['scene','Scene'],['script','Script'],['style','Style'],['media','Media'],['notes','Notes']];
597
+ function renderTabs(){ $('#tabs').innerHTML = TABS.map(([k, l]) => `<button class="tab ${ui.tab === k ? 'on' : ''}" data-tab="${k}">${l}${k === 'notes' && openNotes().length ? ` <span class="c">${openNotes().length}</span>` : ''}</button>`).join(''); }
598
+ $('#tabs').onclick = e => { const b = e.target.closest('[data-tab]'); if (b) { ui.tab = b.dataset.tab; renderTabs(); renderPane(); } };
599
+ function renderPane(){ $('#pane').innerHTML = ({scene:scenePane, script:scriptPane, style:stylePane, media:mediaPane, notes:notesPane})[ui.tab](); }
600
+ function renderAll(){ renderTabs(); renderPane(); renderTL(); renderHots(); markDirty(); }
601
+
602
+ function scenePane(){
603
+ const s = sceneOf(ui.scene), stv = st.status[s.id];
604
+ return `<div class="pane">
605
+ <div class="ph2"><h2>${esc(s.name)}</h2>
606
+ <div class="status" data-status>${[['review','Review'],['changes','Fix'],['approved','Approved']].map(([v, l]) => `<button class="${stv === v ? 'on' : ''}" data-v="${v}"><span class="sd ${v}"></span>${l}</button>`).join('')}</div>
607
+ <button class="info" data-tip-id="status">i</button></div>
608
+ <div class="lbl">On screen <button class="info" data-tip-id="scene">i</button></div>
609
+ ${s.els.map(e => elCard({...e, s:s.id})).join('')}
610
+ </div>`;
611
+ }
612
+ function elCard(e){
613
+ return `<div class="el ${ui.sel === e.id ? 'sel' : ''} ${elEdited(e) ? 'edited' : ''}" data-id="${e.id}">
614
+ <button class="elh" data-pick="${e.id}"><span class="ico"><svg viewBox="0 0 16 16">${elIcon(e)}</svg></span><b>${esc(e.label)}</b><span class="edot"></span><svg class="chev" width="12" height="12" viewBox="0 0 12 12"><path d="M4 2l4 4-4 4" stroke="currentColor" stroke-width="1.5" fill="none"/></svg></button>
615
+ ${ui.sel === e.id ? `<div class="fields">${Object.entries(e.props).map(([k, p]) => fieldHTML(e, k, p)).join('')}${boxHTML(e)}${timingHTML(e)}</div>` : ''}
616
+ </div>`;
617
+ }
618
+ function fieldHTML(e, k, p){
619
+ const path = `${e.id}.${k}`, v = get(path, p.v), ch = path in st.over;
620
+ const head = (extra = '') => `<div class="fl">${esc(p.label)}${extra}<span class="sp"></span><button class="undo" data-undo="${path}">Undo</button></div>`;
621
+ let body = '';
622
+ if (p.type === 'text') body = `<input type="text" data-path="${path}" value="${esc(v)}">`;
623
+ else if (p.type === 'longtext') body = `<textarea data-path="${path}" rows="2">${esc(v)}</textarea>`;
624
+ else if (p.type === 'number') body = `<input type="number" data-path="${path}" data-num value="${esc(v)}" ${p.min != null ? `min="${p.min}"` : ''} ${p.max != null ? `max="${p.max}"` : ''}>`;
625
+ else if (p.type === 'choice') body = `<div class="seg" style="justify-self:start">${p.options.map(o => `<button data-choice="${path}" data-val="${esc(o)}" class="${o === v ? 'on' : ''}">${esc(o)}</button>`).join('')}</div>`;
626
+ else if (p.type === 'motion') return `<div class="f ${ch ? 'changed' : ''}">${head(` <span class="claude">Claude</span><button class="info" data-tip-id="motion">i</button>`)}<textarea data-path="${path}" rows="2">${esc(v)}</textarea></div>`;
627
+ else if (p.type === 'color') body = `<div class="colorf">${P.brand.colors.map(c => `<button class="sw ${v === 'brand.' + c.id ? 'on' : ''}" style="background:${brandColor(c.id)}" data-color="${path}" data-val="brand.${c.id}" data-tip="${esc(c.name)}"></button>`).join('')}</div>`;
628
+ else if (p.type === 'media') body = `<div class="media"><div class="th" style="${thumbStyle(v)}">${thumbLabel(v)}</div><span class="nm">${esc(assetName(v))}</span><button class="btn sm" data-swap="${path}">Swap</button></div>`;
629
+ else if (p.type === 'list') body = listHTML(path, p, v);
630
+ return `<div class="f ${ch ? 'changed' : ''}">${head()}${body}</div>`;
631
+ }
632
+ function listHTML(path, p, rows){
633
+ const tmpl = p.cols.map(c => c.type === 'media' ? '30px' : c.type === 'choice' ? '86px' : c.type === 'number' ? '70px' : '1fr').join(' ') + ' 16px';
634
+ return `<div class="list">${rows.map((r, i) => `<div class="lr" style="grid-template-columns:${tmpl}">${p.cols.map(c => {
635
+ const at = `data-list="${path}" data-i="${i}" data-k="${c.k}"`;
636
+ if (c.type === 'media') return `<button class="mth" style="${thumbStyle(r[c.k])}" data-swap="${path}" data-i="${i}" data-k="${c.k}" aria-label="Swap image"></button>`;
637
+ if (c.type === 'choice') return `<select ${at}>${c.options.map(o => `<option ${o === r[c.k] ? 'selected' : ''}>${esc(o)}</option>`).join('')}</select>`;
638
+ return `<input type="${c.type === 'number' ? 'number' : 'text'}" ${at} value="${esc(r[c.k])}">`;
639
+ }).join('')}<button class="x" data-del-row="${path}" data-i="${i}" aria-label="Remove row">×</button></div>`).join('')}
640
+ <button class="add" data-add-row="${path}">+ Add row</button></div>`;
641
+ }
642
+ function boxHTML(e){
643
+ if (!e.box) return '';
644
+ const path = `${e.id}.@box`, b = elBox(e), ch = path in st.over, sc = Math.round(b[2] / e.box[2] * 100);
645
+ const n = (ix, l, v) => `<label class="fl" style="gap:4px">${l}<input type="number" step="${ix === 's' ? 5 : 0.5}" data-box="${e.id}" data-ix="${ix}" value="${v}"></label>`;
646
+ return `<div class="f ${ch ? 'changed' : ''}"><div class="fl">Size and position<button class="info" data-tip-id="box">i</button><span class="sp"></span><button class="undo" data-undo="${path}">Undo</button></div>
647
+ <div class="row2" style="grid-template-columns:1fr 1fr 1fr">${n('s', 'Scale %', sc)}${n(0, 'X', b[0])}${n(1, 'Y', b[1])}</div></div>`;
648
+ }
649
+ function timingHTML(e){
650
+ const path = `${e.id}.@time`, v = elTime(e), ch = path in st.over;
651
+ return `<div class="f ${ch ? 'changed' : ''}"><div class="fl">Timing<button class="info" data-tip-id="timing">i</button><span class="sp"></span><button class="undo" data-undo="${path}">Undo</button></div>
652
+ <div class="row2"><input type="number" step="0.1" data-time="${e.id}" data-ix="0" value="${v[0]}" aria-label="In"><input type="number" step="0.1" data-time="${e.id}" data-ix="1" value="${v[1]}" aria-label="Out"></div></div>`;
653
+ }
654
+ const thumbStyle = id => { const s = assetSrc(id); return s ? `background-image:url('${s}')` : ''; };
655
+ const thumbLabel = id => assetSrc(id) ? '' : 'None';
656
+
657
+ function scriptPane(){
658
+ const groups = P.scenes.map(s => {
659
+ const items = [];
660
+ s.els.forEach(e => Object.entries(e.props).forEach(([k, p]) => {
661
+ if (p.type === 'text' || p.type === 'longtext') items.push({e, path:`${e.id}.${k}`, base:p.v, label:`${e.label}${Object.keys(e.props).length > 2 ? ' · ' + p.label : ''}`});
662
+ if (p.type === 'list') get(`${e.id}.${k}`, p.v).forEach((r, i) => p.cols.forEach(c => { if (c.type === 'text') items.push({e, list:`${e.id}.${k}`, i, k:c.k, val:r[c.k], label:`${e.label} ${i + 1}${r.from ? ' · ' + r.from : ''}`}); }));
663
+ }));
664
+ return `<div class="sgrp"><div class="lbl">${esc(s.name)}</div>${items.map(it => it.list
665
+ ? `<div class="scr ${it.list in st.over ? 'changed' : ''}"><button class="t" data-seek="${elTime(it.e)[0] + 0.5}">${fmt(elTime(it.e)[0])} · ${esc(it.label)}</button><input data-list="${it.list}" data-i="${it.i}" data-k="${it.k}" value="${esc(it.val)}"></div>`
666
+ : `<div class="scr ${it.path in st.over ? 'changed' : ''}"><button class="t" data-seek="${elTime(it.e)[0] + 0.5}">${fmt(elTime(it.e)[0])} · ${esc(it.label)}</button><input data-path="${it.path}" value="${esc(get(it.path, it.base))}"></div>`).join('')}</div>`;
667
+ }).join('');
668
+ return `<div class="pane"><div class="lbl">Every word <button class="info" data-tip-id="script">i</button></div>${groups}</div>`;
669
+ }
670
+ function stylePane(){
671
+ return `<div class="pane">
672
+ <div class="lbl">Colors <button class="info" data-tip-id="colors">i</button></div>
673
+ <div class="brand">${P.brand.colors.map(c => { const path = 'brand.color.' + c.id, v = get(path, c.v);
674
+ return `<label class="bc ${path in st.over ? 'changed' : ''}"><input type="color" data-bcolor="${c.id}" value="${v}"><span><b>${esc(c.name)}</b><small>${v.toUpperCase()}</small></span></label>`; }).join('')}</div>
675
+ <div class="lbl">Fonts <button class="info" data-tip-id="fonts">i</button></div>
676
+ ${P.brand.fonts.map(f => { const path = 'brand.font.' + f.id, v = get(path, f.v);
677
+ return `<div class="f ${path in st.over ? 'changed' : ''}"><div class="fl">${esc(f.name)}<span class="sp"></span><button class="undo" data-undo="${path}">Undo</button></div>
678
+ <select data-path="${path}" style="font-family:'${v}'">${[...new Set([v, ...FONTS])].map(o => `<option ${o === v ? 'selected' : ''} style="font-family:'${o}'">${esc(o)}</option>`).join('')}</select></div>`; }).join('')}
679
+ <div class="lbl">Logo</div>
680
+ ${(() => { const path = 'brand.logo', v = get(path, P.brand.logo);
681
+ return `<div class="f ${path in st.over ? 'changed' : ''}"><div class="media"><div class="th" style="${thumbStyle(v)}">${thumbLabel(v)}</div><span class="nm">${v ? esc(assetName(v)) : 'No logo yet'}</span><button class="btn sm" data-swap="brand.logo">${v ? 'Swap' : 'Add'}</button><button class="undo" data-undo="${path}">Undo</button></div></div>`; })()}
682
+ </div>`;
683
+ }
684
+ function mediaPane(){
685
+ const mus = get('audio.music', P.audio.music), lib = P.assets;
686
+ return `<div class="pane">
687
+ <div class="drop"><b>Add your files</b>
688
+ <div class="fl" style="justify-content:center">Put them in this project's assets/ folder, or paste a link <button class="info" data-tip-id="add">i</button></div>
689
+ <div class="linkrow"><input type="url" id="linkIn" placeholder="Drive, Dropbox or a website"><button class="btn sm" data-add="link">Add</button></div>
690
+ </div>
691
+ ${st.links.length ? `<div class="links">${st.links.map((l, i) => `<div><span>${esc(l.url)}</span>${l.sent ? '<span class="tag ok">Sent</span>' : '<span class="tag">For Claude</span>'}<button class="btn ghost sm" data-del-link="${i}" aria-label="Remove link">×</button></div>`).join('')}</div>` : ''}
692
+ <div class="lbl">In this video <button class="info" data-tip-id="assets">i</button></div>
693
+ <div class="lib">${lib.map(a => `<div class="ast ${a.mine ? 'mine' : ''}"><div class="th" style="${a.type === 'image' ? thumbStyle(a.id) : ''}">${a.type === 'image' ? '' : a.type.toUpperCase()}</div><span>${esc(a.name)}</span></div>`).join('')}</div>
694
+ <div class="lbl">Music <button class="info" data-tip-id="music">i</button></div>
695
+ <div class="media"><div class="th">♪</div><span class="nm">${mus ? esc(assetName(mus)) : 'No track yet'}</span><button class="btn sm" data-swap="audio.music" data-type="audio">${mus ? 'Swap' : 'Add'}</button></div>
696
+ ${P.audio.bpm ? `<div class="fl">Beat drop lands at <b class="mono" style="color:var(--ink)">${P.audio.drop.toFixed(1)}s</b> · ${P.audio.bpm} BPM</div>` : ''}
697
+ <div class="vol"><span class="fl" style="width:56px">Music</span><input type="range" min="0" max="1" step="0.05" data-vol="audio.musicVol" value="${get('audio.musicVol', P.audio.musicVol)}"></div>
698
+ <div class="vol"><span class="fl" style="width:56px">Effects</span><input type="range" min="0" max="1" step="0.05" data-vol="audio.sfxVol" value="${get('audio.sfxVol', P.audio.sfxVol)}"></div>
699
+ </div>`;
700
+ }
701
+ function notesPane(){
702
+ if (!st.notes.length) return `<div class="pane"><div class="empty">No notes yet.<br>Press <b>N</b>, then click the frame.</div></div>`;
703
+ return `<div class="pane"><div class="lbl">Notes <button class="info" data-tip-id="notes">i</button></div>${st.notes.map((n, i) => `<div class="note ${ui.selNote === n.id ? 'sel' : ''}" data-id="${n.id}">
704
+ <div class="nh"><span class="num">${i + 1}</span><button class="go mono" data-go-note="${n.id}">${fmt(n.t)}</button><span>${n.el && elById(n.el) ? esc(elById(n.el).label) : sceneAt(n.t).name}</span>${n.example ? '<span class="tag">Example</span>' : ''}${st.sent.includes(n.id) ? '<span class="tag ok">Sent</span>' : ''}<span class="spacer"></span><button data-del-note="${n.id}" aria-label="Delete note">×</button></div>
705
+ <textarea data-note-text="${n.id}">${esc(n.text)}</textarea></div>`).join('')}</div>`;
706
+ }
707
+
708
+ /* panel events */
709
+ function changed(){ renderTL(); renderHots(); markDirty(); }
710
+ function touchScene(sid){ if (sid && st.status[sid] === 'review') st.status[sid] = 'changes'; }
711
+ const baseOf = path => {
712
+ if (path.startsWith('brand.color.')) return P.brand.colors.find(c => c.id === path.slice(12)).v;
713
+ if (path.startsWith('brand.font.')) return P.brand.fonts.find(f => f.id === path.slice(11)).v;
714
+ if (path === 'brand.logo') return P.brand.logo;
715
+ if (path.startsWith('audio.')) return P.audio[path.slice(6)];
716
+ const [id, k] = path.split('.'), e = elById(id); return k === '@time' ? e.t : k === '@box' ? e.box : e.props[k].v;
717
+ };
718
+ const sidOf = path => elById(path.split('.')[0])?.s;
719
+ $('#pane').addEventListener('input', ev => {
720
+ const t = ev.target, d = t.dataset;
721
+ if (d.path) { const v = 'num' in d ? Number(t.value) : t.value; set(d.path, v, baseOf(d.path)); touchScene(sidOf(d.path));
722
+ t.closest('.f,.scr')?.classList.toggle('changed', d.path in st.over); if (d.path.startsWith('brand.font')) t.style.fontFamily = `'${t.value}'`;
723
+ const card = t.closest('.el'); if (card) card.classList.toggle('edited', elEdited(elById(card.dataset.id)));
724
+ return changed(); }
725
+ if (d.list) { const base = baseOf(d.list), rows = JSON.parse(JSON.stringify(get(d.list, base))); rows[+d.i][d.k] = t.type === 'number' ? Number(t.value) : t.value; set(d.list, rows, base); touchScene(sidOf(d.list));
726
+ t.closest('.f,.scr')?.classList.toggle('changed', d.list in st.over); return changed(); }
727
+ if (d.time) { const e = elById(d.time), v = [...elTime(e)]; v[+d.ix] = Math.round(Number(t.value) * 10) / 10; set(`${d.time}.@time`, v, e.t); touchScene(e.s); t.closest('.f').classList.toggle('changed', `${d.time}.@time` in st.over); return changed(); }
728
+ if (d.box) { const e = elById(d.box), b = [...elBox(e)], v = Number(t.value); if (!t.value || isNaN(v)) return;
729
+ if (d.ix === 's') { const s = Math.max(10, v) / 100; b[2] = Math.round(e.box[2] * s * 10) / 10; b[3] = Math.round(e.box[3] * s * 10) / 10; } else b[+d.ix] = v;
730
+ set(`${d.box}.@box`, b, e.box); touchScene(e.s); t.closest('.f').classList.toggle('changed', `${d.box}.@box` in st.over); return changed(); }
731
+ if (d.bcolor) { set('brand.color.' + d.bcolor, t.value, baseOf('brand.color.' + d.bcolor)); t.closest('.bc').classList.toggle('changed', ('brand.color.' + d.bcolor) in st.over); t.nextElementSibling.querySelector('small').textContent = t.value.toUpperCase(); return changed(); }
732
+ if (d.vol) { set(d.vol, Number(t.value), baseOf(d.vol)); if (music && d.vol === 'audio.musicVol') music.volume = Number(t.value); return changed(false); }
733
+ if (d.noteText) { st.notes.find(n => n.id === +d.noteText).text = t.value; markDirty(); }
734
+ });
735
+ $('#pane').addEventListener('change', ev => { if (ev.target.tagName === 'SELECT') ev.target.dispatchEvent(new Event('input', {bubbles:true})); });
736
+ $('#pane').addEventListener('click', ev => {
737
+ const q = s => ev.target.closest(s);
738
+ let b;
739
+ if (b = q('[data-status] button')) { st.status[ui.scene] = b.dataset.v; markDirty(); renderPane(); renderTL(); return; }
740
+ if (b = q('[data-pick]')) { const id = b.dataset.pick; return ui.sel === id ? (ui.sel = null, renderPane(), renderHots()) : selectEl(id); }
741
+ if (b = q('[data-undo]')) { delete st.over[b.dataset.undo]; if (b.dataset.undo === 'audio.music' || b.dataset.undo === 'brand.logo') setupMusic(); renderPane(); return changed(); }
742
+ if (b = q('[data-choice]')) { set(b.dataset.choice, b.dataset.val, baseOf(b.dataset.choice)); touchScene(sidOf(b.dataset.choice)); renderPane(); return changed(); }
743
+ if (b = q('[data-color]')) { set(b.dataset.color, b.dataset.val, baseOf(b.dataset.color)); touchScene(sidOf(b.dataset.color)); renderPane(); return changed(); }
744
+ if (b = q('[data-swap]')) return openPicker(b.dataset.swap, b.dataset.i, b.dataset.k, b.dataset.type || 'image');
745
+ if (b = q('[data-add-row]')) { const p = b.dataset.addRow, base = baseOf(p), rows = JSON.parse(JSON.stringify(get(p, base))); rows.push({...rows[rows.length - 1]}); set(p, rows, base); touchScene(sidOf(p)); renderPane(); return changed(); }
746
+ if (b = q('[data-del-row]')) { const p = b.dataset.delRow, base = baseOf(p), rows = JSON.parse(JSON.stringify(get(p, base))); rows.splice(+b.dataset.i, 1); set(p, rows, base); touchScene(sidOf(p)); renderPane(); return changed(); }
747
+ if (b = q('[data-seek]')) { pause(); return seek(+b.dataset.seek); }
748
+ if (b = q('[data-go-note]')) return focusNote(+b.dataset.goNote);
749
+ if (b = q('[data-del-note]')) { st.notes = st.notes.filter(n => n.id !== +b.dataset.delNote); markDirty(); return renderAll(); }
750
+ if (b = q('[data-add]')) return addLink();
751
+ if (b = q('[data-del-link]')) { st.links.splice(+b.dataset.delLink, 1); markDirty(); return renderPane(); }
752
+ });
753
+ $('#pane').addEventListener('keydown', ev => { if (ev.target.id === 'linkIn' && ev.key === 'Enter') addLink(); });
754
+ function addLink(){ const i = $('#linkIn'), url = i.value.trim(); if (!/^https?:\/\//i.test(url)) { toast('Paste a full link starting with https://'); return; } st.links.push({url}); i.value = ''; markDirty(); renderPane(); toast('Link added. Claude downloads it when you send.'); }
755
+
756
+ /* ================= asset picker (assets listed in reel.json) ================= */
757
+ function openPicker(path, i, k, type){
758
+ const lib = P.assets.filter(a => a.type === type);
759
+ const d = sheet(`<h3>Choose ${type === 'audio' ? 'a track' : 'an image'}</h3>
760
+ ${lib.length ? `<div class="pick">${lib.map(a => `<button class="ast ${a.id.startsWith('up-') ? 'mine' : ''}" data-pickasset="${a.id}"><div class="th" style="${type === 'image' ? thumbStyle(a.id) : ''}">${type === 'image' ? '' : '♪'}</div><span>${esc(a.name)}</span></button>`).join('')}</div>` : `<p>Nothing here yet.</p>`}
761
+ <div class="row"><button class="btn ghost" data-close>Cancel</button></div>`);
762
+ const apply = id => {
763
+ if (i != null) { const base = baseOf(path), rows = JSON.parse(JSON.stringify(get(path, base))); rows[+i][k] = id; set(path, rows, base); }
764
+ else set(path, id, baseOf(path));
765
+ touchScene(sidOf(path)); if (path === 'audio.music') setupMusic();
766
+ d.remove(); renderPane(); changed(); toast('Swapped');
767
+ };
768
+ d.addEventListener('click', ev => { const b = ev.target.closest('[data-pickasset]'); if (b) apply(b.dataset.pickasset); });
769
+ }
770
+
771
+ /* ================= selection ================= */
772
+ function selectEl(id, keepPlaying){
773
+ const e = elById(id), [a, b] = elTime(e); ui.sel = id; ui.tab = 'scene';
774
+ if (ui.scene !== e.s) { ui.scene = e.s; }
775
+ const t = now(); if (!keepPlaying && (t < a || t > b)) seek(Math.min(a + 0.8, b - 0.05));
776
+ renderTabs(); renderPane(); renderTL(); renderHots();
777
+ document.querySelector(`.el[data-id="${id}"]`)?.scrollIntoView({block:'nearest', behavior:'smooth'});
778
+ }
779
+ function focusNote(id){ const n = st.notes.find(n => n.id === id); ui.selNote = id; ui.tab = 'notes'; pause(); seek(n.t); renderTabs(); renderPane();
780
+ document.querySelector(`.note[data-id="${id}"]`)?.scrollIntoView({block:'nearest', behavior:'smooth'}); }
781
+
782
+ /* ================= send to Claude: one prompt to copy and paste ================= */
783
+ function describe(path, v){
784
+ const show = x => Array.isArray(x) ? x.map(r => Object.values(r).join(' | ')).join(' / ') : typeof x === 'string' && x.includes('/') ? assetName(x) : JSON.stringify(x);
785
+ const nice = path.startsWith('brand.color.') ? `Style · ${P.brand.colors.find(c => c.id === path.slice(12)).name} color (used everywhere)` : path.startsWith('brand.font.') ? `Style · ${P.brand.fonts.find(f => f.id === path.slice(11)).name} font` : path === 'brand.logo' ? 'Style · Logo' : path === 'audio.music' ? 'Audio · Music track' : path === 'audio.musicVol' ? 'Audio · Music volume' : path === 'audio.sfxVol' ? 'Audio · Effects volume' : null;
786
+ if (nice) return `- ${nice}: ${show(baseOf(path))} -> ${show(v)}`;
787
+ const [id, k] = path.split('.'), e = elById(id);
788
+ if (k === '@box') return `- [${fmt(elTime(e)[0])} · ${e.s} · ${e.label}] size and position, [x, y, w, h] in % of frame${e.src ? ` (${e.src})` : ''}: ${JSON.stringify(e.box)} -> ${JSON.stringify(v)} (scale ${Math.round(v[2] / e.box[2] * 100)}%)`;
789
+ return `- [${fmt(elTime(e)[0])} · ${e.s} · ${e.label}] ${k === '@time' ? 'timing (s)' : e.props[k].label}${e.src ? ` (${e.src})` : ''}: ${show(baseOf(path))} -> ${show(v)}`;
790
+ }
791
+ function prompt(){
792
+ const ver = P.version || 1, fresh = ([k, v]) => JSON.stringify(st.sentSnap[k]) !== JSON.stringify(v);
793
+ const L = [`Motion OS feedback for "${P.title}" v${ver}`, `Project: ${P.path || P.id} · ${P.w}x${P.h} · ${P.fps} fps · ${P.duration.toFixed(1)}s`, ''];
794
+ const edits = Object.entries(st.over).filter(fresh).filter(([k]) => !k.endsWith('.motion'));
795
+ const motion = Object.entries(st.over).filter(fresh).filter(([k]) => k.endsWith('.motion'));
796
+ if (edits.length) { L.push('## Edits (apply exactly)'); edits.forEach(([k, v]) => L.push(describe(k, v))); L.push(''); }
797
+ const notes = openNotes();
798
+ if (notes.length || motion.length) { L.push('## Notes (what I want changed)');
799
+ notes.forEach((n, i) => { const e = n.el && elById(n.el), s = sceneAt(n.t); L.push(`${i + 1}. [${fmt(n.t)} · ${s.id} ${s.name} · x ${n.x}% y ${n.y}%${e ? ` · on "${e.label}"${e.src ? ' (' + e.src + ')' : ''}` : ''}] ${n.text}`); });
800
+ motion.forEach(([k, v], i) => { const e = elById(k.split('.')[0]); L.push(`${notes.length + i + 1}. [${fmt(elTime(e)[0])} · ${e.s} · ${e.label} motion] ${v}`); }); L.push(''); }
801
+ const links = st.links.filter(l => !l.sent);
802
+ if (links.length) { L.push('## Links to download into assets/'); links.forEach(l => L.push('- ' + l.url)); L.push(''); }
803
+ L.push('## Scenes', P.scenes.map(s => `${s.id} ${s.name} (${fmt(s.t[0])}-${fmt(s.t[1])}): ${st.status[s.id] === 'approved' ? 'approved, do not touch' : st.status[s.id] === 'changes' ? 'needs changes' : 'in review'}`).join('\n'), '');
804
+ L.push('## Do', `Use the luckiest-video-studio-review skill. Apply the edits exactly and work through the notes. Leave approved scenes alone. Re-render, check it, then update reel.json and set "version" to ${ver + 1} so Motion OS reloads.`);
805
+ return L.join('\n');
806
+ }
807
+ function sheet(html){ const d = document.createElement('div'); d.className = 'scrim'; d.innerHTML = `<div class="sheet" role="dialog" aria-modal="true">${html}</div>`;
808
+ d.addEventListener('click', e => { if (e.target === d || e.target.closest('[data-close]')) d.remove(); }); document.body.appendChild(d); return d; }
809
+ $('#sendBtn').onclick = () => {
810
+ if (!(pendingEdits() + openNotes().length)) return toast('Nothing to send yet. Edit something, or press N to pin a note.');
811
+ const text = prompt();
812
+ const d = sheet(`<h3>Send to Claude</h3><p>Copy this, then paste it into Claude.</p><textarea class="out" readonly></textarea><div class="row"><button class="btn ghost" data-close>Close</button><button class="btn primary" id="doCopy">Copy prompt</button></div>`);
813
+ d.querySelector('textarea').value = text;
814
+ const sent = () => { st.sent = [...new Set([...st.sent, ...openNotes().map(n => n.id)])]; st.sentSnap = JSON.parse(JSON.stringify(st.over)); st.links.forEach(l => l.sent = true); renderAll(); };
815
+ d.querySelector('#doCopy').onclick = async () => {
816
+ try { await navigator.clipboard.writeText(text); sent(); d.remove(); toast('Copied. Paste it into Claude.'); }
817
+ catch (e) { const ta = d.querySelector('textarea'); ta.focus(); ta.select(); sent(); toast('Selected. Press Cmd+C, then paste it into Claude.'); }
818
+ };
819
+ };
820
+
821
+ /* ================= export: render a new mp4 with the edits baked in (live projects) ================= */
822
+ $('#exportBtn').onclick = async () => {
823
+ const d = sheet(`<h3>Export MP4</h3><p id="exMsg">Starting the render…</p><div style="height:6px;border-radius:3px;background:var(--panel-2);overflow:hidden"><i id="exBar" style="display:block;height:100%;width:0;background:var(--accent);transition:width .3s"></i></div><p class="mono" id="exLine" style="font-size:11px"></p><div class="row"><button class="btn ghost" data-close>Close</button></div>`);
824
+ let j = await fetch('export', {method:'POST', body:JSON.stringify({boxes:liveBoxes()})}).then(r => r.json());
825
+ while (j.state === 'running') {
826
+ d.querySelector('#exBar').style.width = j.pct + '%'; d.querySelector('#exMsg').textContent = `Rendering… ${j.pct}%. You can keep working.`; d.querySelector('#exLine').textContent = j.line || '';
827
+ await new Promise(r => setTimeout(r, 1000)); j = await fetch('export').then(r => r.json());
828
+ }
829
+ d.querySelector('#exBar').style.width = j.state === 'done' ? '100%' : '0';
830
+ d.querySelector('#exMsg').innerHTML = j.state === 'done' ? `Done. Saved as <b class="mono">${esc(j.out)}</b> · <a href="${esc(j.out)}" target="_blank">Open it</a>` : 'Export failed.';
831
+ d.querySelector('#exLine').textContent = j.state === 'done' ? '' : j.line || '';
832
+ if (j.state === 'done') toast('Exported');
833
+ };
834
+
835
+ /* ================= tooltips ================= */
836
+ const tip = $('#tip');
837
+ function showTip(el){ const text = el.dataset.tip || TIPS[el.dataset.tipId]; if (!text) return; tip.textContent = text; tip.hidden = false;
838
+ const r = el.getBoundingClientRect(), w = tip.offsetWidth, h = tip.offsetHeight;
839
+ tip.style.left = clamp(r.left + r.width / 2 - w / 2, 8, innerWidth - w - 8) + 'px';
840
+ tip.style.top = (r.top - h - 8 < 8 ? r.bottom + 8 : r.top - h - 8) + 'px'; }
841
+ document.addEventListener('mouseover', ev => { const el = ev.target.closest('[data-tip],[data-tip-id]'); el ? showTip(el) : tip.hidden = true; });
842
+ document.addEventListener('focusin', ev => { const el = ev.target.closest('[data-tip],[data-tip-id]'); el ? showTip(el) : tip.hidden = true; });
843
+ document.addEventListener('click', ev => { const el = ev.target.closest('.info'); if (el) { ev.stopPropagation(); showTip(el); } }, true);
844
+ document.addEventListener('scroll', () => tip.hidden = true, true);
845
+
846
+ /* ================= transport + keys ================= */
847
+ $('#playBtn').onclick = () => playing() ? pause() : play();
848
+ $('#noteBtn').onclick = () => setMode(ui.mode === 'note' ? 'preview' : 'note');
849
+ $('#modes').onclick = e => { const b = e.target.closest('[data-m]'); if (b) setMode(b.dataset.m); };
850
+ $('#loopBtn').onclick = () => { ui.loop = !ui.loop; $('#loopBtn').classList.toggle('on', ui.loop); };
851
+ $('#speed').onclick = e => { const b = e.target.closest('button'); if (!b) return; clock.rate = +b.dataset.r; vid.playbackRate = clock.rate; if (music) music.playbackRate = clock.rate; [...$('#speed').children].forEach(x => x.classList.toggle('on', x === b)); };
852
+ const sIdx = () => P.scenes.findIndex(s => s.id === ui.scene);
853
+ const goScene = i => { const s = P.scenes[clamp(i, 0, P.scenes.length - 1)]; pause(); ui.sel = null; seek(s.t[0] + 0.05); };
854
+ document.addEventListener('keydown', ev => {
855
+ if (document.querySelector('.scrim')) { if (ev.key === 'Escape') document.querySelector('.scrim').remove(); return; }
856
+ if (ev.target.closest('input,textarea,select')) return;
857
+ const f = 1 / P.fps;
858
+ if (ev.key === ' ') { ev.preventDefault(); playing() ? pause() : play(); }
859
+ else if (ev.key === 'ArrowRight') { ev.preventDefault(); pause(); seek(now() + (ev.shiftKey ? 1 : f)); }
860
+ else if (ev.key === 'ArrowLeft') { ev.preventDefault(); pause(); seek(now() - (ev.shiftKey ? 1 : f)); }
861
+ else if (ev.key === ']') goScene(sIdx() + 1);
862
+ else if (ev.key === '[') goScene(sIdx() - 1);
863
+ else if (ev.key === 'e' || ev.key === 'E') setMode(ui.mode === 'edit' ? 'preview' : 'edit');
864
+ else if (ev.key === 'n' || ev.key === 'N') setMode(ui.mode === 'note' ? 'preview' : 'note');
865
+ else if (ev.key === 'Escape') { closePop(); setMode('preview'); }
866
+ });
867
+ let toastT; function toast(msg){ document.querySelector('.toast')?.remove(); const t = document.createElement('div'); t.className = 'toast'; t.textContent = msg; document.body.appendChild(t); clearTimeout(toastT); toastT = setTimeout(() => t.remove(), 2200); }
868
+
869
+ // Load reel.json, optionally jump to #t=12.5&el=id, then watch for the next version Claude renders.
870
+ const hash = new URLSearchParams(location.hash.slice(1));
871
+ fetch('reel.json', {cache:'no-store'}).then(r => r.json()).then(async j => {
872
+ P = j;
873
+ if (P.live) {
874
+ await new Promise((ok, no) => { const s = document.createElement('script'); s.src = P.live + '?v=' + (P.version || 1); s.onload = ok; s.onerror = no; document.head.appendChild(s); });
875
+ $('#vid').hidden = true; const el = document.createElement('div'); el.id = 'rp'; $('#viewer').insertBefore(el, overlay);
876
+ live = window.MotionOSLive(el); vid = liveVideo(live);
877
+ }
878
+ wireVideo(); loadProject();
879
+ $('#exportBtn').hidden = !P.export;
880
+ if (hash.get('t')) vid.addEventListener('loadedmetadata', () => { seek(+hash.get('t')); if (hash.get('el') && elById(hash.get('el'))) selectEl(hash.get('el')); }, {once:true});
881
+ setInterval(() => fetch('reel.json', {cache:'no-store'}).then(r => r.json()).then(n => {
882
+ if ((n.version || 1) !== (P.version || 1)) { toast(`v${n.version} is ready. Loading it.`); setTimeout(() => location.reload(), 900); }
883
+ }).catch(() => {}), 3000);
884
+ }).catch(() => { $('#pane').innerHTML = '<div class="pane"><div class="empty">No reel.json next to this page.<br>Start Motion OS from Claude, or run: node player/serve.mjs your-project</div></div>'; });
885
+ </script>