paper-motion 0.4.1 → 0.5.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 (52) hide show
  1. package/CHANGELOG.md +78 -0
  2. package/bin/pamo.mjs +62 -5
  3. package/lib/edit.mjs +31 -3
  4. package/lib/piece.mjs +121 -10
  5. package/lib/registry.mjs +10 -4
  6. package/lib/studio.mjs +195 -7
  7. package/package.json +2 -1
  8. package/runtime/brand.js +14 -2
  9. package/runtime/player.js +4 -2
  10. package/studio/index.html +236 -57
  11. package/examples/breath/stills/contact-sheet.jpg +0 -0
  12. package/examples/breath/stills/t000.00.png +0 -0
  13. package/examples/breath/stills/t002.00.png +0 -0
  14. package/examples/breath/stills/t004.00.png +0 -0
  15. package/examples/breath/stills/t006.00.png +0 -0
  16. package/examples/breath/stills/t008.00.png +0 -0
  17. package/examples/breath/stills/t010.00.png +0 -0
  18. package/examples/chrome-endcard/stills/contact-sheet.jpg +0 -0
  19. package/examples/chrome-endcard/stills/t000.00.png +0 -0
  20. package/examples/chrome-endcard/stills/t001.33.png +0 -0
  21. package/examples/chrome-endcard/stills/t002.67.png +0 -0
  22. package/examples/chrome-endcard/stills/t004.00.png +0 -0
  23. package/examples/chrome-endcard/stills/t005.33.png +0 -0
  24. package/examples/chrome-endcard/stills/t006.67.png +0 -0
  25. package/examples/halo-explainer/stills/contact-sheet.jpg +0 -0
  26. package/examples/halo-explainer/stills/t000.00.png +0 -0
  27. package/examples/halo-explainer/stills/t006.00.png +0 -0
  28. package/examples/halo-explainer/stills/t012.00.png +0 -0
  29. package/examples/halo-explainer/stills/t018.00.png +0 -0
  30. package/examples/halo-explainer/stills/t024.00.png +0 -0
  31. package/examples/halo-explainer/stills/t030.00.png +0 -0
  32. package/examples/install-loop/stills/contact-sheet.jpg +0 -0
  33. package/examples/install-loop/stills/t000.00.png +0 -0
  34. package/examples/install-loop/stills/t001.00.png +0 -0
  35. package/examples/install-loop/stills/t002.00.png +0 -0
  36. package/examples/install-loop/stills/t003.00.png +0 -0
  37. package/examples/install-loop/stills/t004.00.png +0 -0
  38. package/examples/install-loop/stills/t005.00.png +0 -0
  39. package/examples/orbit-keynote/stills/contact-sheet.jpg +0 -0
  40. package/examples/orbit-keynote/stills/t000.00.png +0 -0
  41. package/examples/orbit-keynote/stills/t006.50.png +0 -0
  42. package/examples/orbit-keynote/stills/t013.00.png +0 -0
  43. package/examples/orbit-keynote/stills/t019.50.png +0 -0
  44. package/examples/orbit-keynote/stills/t026.00.png +0 -0
  45. package/examples/orbit-keynote/stills/t032.50.png +0 -0
  46. package/examples/signal/stills/contact-sheet.jpg +0 -0
  47. package/examples/signal/stills/t000.00.png +0 -0
  48. package/examples/signal/stills/t001.67.png +0 -0
  49. package/examples/signal/stills/t003.33.png +0 -0
  50. package/examples/signal/stills/t005.00.png +0 -0
  51. package/examples/signal/stills/t006.67.png +0 -0
  52. package/examples/signal/stills/t008.33.png +0 -0
package/studio/index.html CHANGED
@@ -52,6 +52,42 @@
52
52
  @keyframes pulse { 50% { opacity: .3; } }
53
53
  .turn b { font-weight: 500; margin-right: 8px; } .turn .tx { color: var(--muted); }
54
54
  .turn .grow { flex: 1; }
55
+ .turn.asking { background: #15130f; } .turn .q { font: 400 16px/1.3 var(--serif); margin-left: 4px; }
56
+ .turn .ans { display: flex; gap: 8px; align-items: center; margin-left: auto; }
57
+ .turn .ans input { width: 300px; background: var(--surface); border: 1px solid var(--rule); padding: 7px 10px; }
58
+ .turn .ans input:focus { outline: none; border-color: var(--paper); }
59
+ .look { display: grid; grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr); gap: 32px; max-width: 1360px; margin: 0 auto; padding: 28px 22px 110px; }
60
+ .lkpv { position: sticky; top: 112px; align-self: start; }
61
+ .lkbox { position: relative; aspect-ratio: 16 / 9; background: #000; border: 1px solid var(--line); overflow: hidden; }
62
+ .lkbox iframe { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; }
63
+ .lkbox .msg { position: absolute; inset: 0; display: grid; place-items: center; color: var(--muted); }
64
+ .lkagain { position: absolute; right: 10px; bottom: 10px; z-index: 2; background: rgba(0,0,0,.7); border: 1px solid var(--rule); padding: 6px 10px; font-size: 12.5px; }
65
+ .lkopts h1 { font: 400 40px/1.05 var(--serif); margin: 0; } .lkopts .sub { color: var(--muted); margin: 8px 0 4px; }
66
+ .lkopts section { border-top: 1px solid var(--line); padding: 18px 0 20px; margin-top: 14px; }
67
+ .lkopts h2 { font: 400 22px/1.1 var(--serif); margin: 0 0 12px; }
68
+ .lkg { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; margin-top: 10px; }
69
+ .lkc { position: relative; padding: 0; border: 1px solid var(--line); background: var(--surface); text-align: left; overflow: hidden; }
70
+ .lkc:hover { border-color: var(--rule); } .lkc.on { border-color: var(--paper); outline: 1px solid var(--paper); }
71
+ .lkf { position: relative; aspect-ratio: 16 / 9; overflow: hidden; background: #111 center / cover; }
72
+ .lkf img, .lkf video { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
73
+ .lkf .yours { position: absolute; left: 6px; top: 6px; background: var(--paper); color: #000; font: 500 10px/1 var(--mono); letter-spacing: .05em; text-transform: uppercase; padding: 4px 5px; }
74
+ .lkn { padding: 7px 9px; font-size: 12.5px; display: flex; justify-content: space-between; gap: 6px; }
75
+ .sw { position: absolute; inset: 0; display: grid; place-items: center; } .sw b { font-size: 30px; font-weight: 400; } .sw i { position: absolute; left: 0; right: 0; bottom: 0; height: 18%; }
76
+ .spec { position: absolute; inset: 0; display: grid; place-content: center; text-align: center; gap: 2px; padding: 6px; } .spec b { font-size: 30px; font-weight: 400; line-height: 1; } .spec span { font-size: 11px; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
77
+ .lkcols { display: flex; gap: 16px; margin-top: 12px; flex-wrap: wrap; } .lkcols label { display: flex; gap: 8px; align-items: center; font-size: 12.5px; color: var(--muted); }
78
+ .lkcols input { width: 34px; height: 26px; border: 1px solid var(--rule); background: none; padding: 0; }
79
+ .seg { display: inline-flex; border: 1px solid var(--rule); } .seg button { background: none; border: 0; padding: 7px 13px; color: var(--muted); } .seg button + button { border-left: 1px solid var(--rule); } .seg button.on { background: var(--paper); color: #000; }
80
+ .steps button:disabled { opacity: .35; cursor: default; }
81
+ .agentsug { margin: 4px 0 16px; padding: 12px; border: 1px solid var(--paper); background: #15130f; }
82
+ .agentnote { font-size: 13px; margin-bottom: 4px; } .agentnote b { font-weight: 500; }
83
+ .sugb { position: absolute; left: 6px; top: 6px; z-index: 2; background: var(--paper); color: #000; font: 500 10px/1 var(--mono); letter-spacing: .05em; text-transform: uppercase; padding: 4px 5px; }
84
+ .chnote { margin: 0; padding: 0 9px 9px; font-size: 12px; color: var(--muted); }
85
+ .agentsug .none { display: flex; gap: 8px; margin-top: 10px; } .agentsug .none input { flex: 1; background: var(--surface); border: 1px solid var(--rule); padding: 6px 9px; }
86
+ .beats { position: absolute; inset: 0; padding: 26px 9px 6px; display: grid; gap: 2px; align-content: start; font-size: 11px; overflow: hidden; } .beats span { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: var(--muted); } .beats b { color: var(--figure); font-weight: 500; }
87
+ .kitf { position: absolute; inset: 0; display: grid; grid-template-columns: repeat(3, 1fr); } .kitf > * { width: 100%; height: 100%; object-fit: cover; display: grid; place-items: center; font-size: 26px; font-weight: 400; }
88
+ .steps .sdot { width: 7px; height: 7px; background: var(--warn); display: inline-block; margin-left: 4px; }
89
+ .center .agentsug { max-width: 980px; }
90
+ .nbadge { background: var(--warn); color: #000; font: 500 11px/1 var(--mono); padding: 3px 5px; }
55
91
  main { min-height: calc(100vh - 97px); }
56
92
 
57
93
  /* 1 · what it says */
@@ -81,7 +117,14 @@
81
117
  .nextbar { margin-top: 30px; display: flex; justify-content: space-between; align-items: center; gap: 16px; flex-wrap: wrap; }
82
118
 
83
119
  /* 2 · your video */
84
- .editor { display: grid; grid-template-columns: minmax(0, 1fr) 372px; min-height: calc(100vh - 97px); }
120
+ .editor { display: grid; grid-template-columns: 272px minmax(0, 1fr) 372px; min-height: calc(100vh - 97px); }
121
+ .scenes { border-right: 1px solid var(--line); padding: 14px 12px 40px; overflow-y: auto; max-height: calc(100vh - 97px); position: sticky; top: 97px; }
122
+ .scenes .part { width: auto; display: grid; grid-template-columns: 22px 104px minmax(0, 1fr); gap: 9px; align-items: center; padding: 7px; margin-bottom: 6px; }
123
+ .scenes .part .n { position: static; background: none; padding: 0; font-size: 15px; color: var(--muted); }
124
+ .scenes .part .th { width: 104px; border: 1px solid var(--line); }
125
+ .scenes .part .meta { padding: 0; min-width: 0; }
126
+ .scenes .part.drop { box-shadow: 0 -3px 0 var(--paper); }
127
+ .addscn { width: 100%; margin-top: 4px; border-style: dashed; }
85
128
  .center { padding: 20px 24px; display: grid; grid-template-columns: minmax(0, 1fr); gap: 14px; align-content: start; min-width: 0; }
86
129
  .player { position: relative; aspect-ratio: 16/9; background: #000; border: 1px solid var(--line); width: min(100%, calc(56vh * 16 / 9)); margin: 0 auto; overflow: hidden; }
87
130
  .player.tall { aspect-ratio: 9/16; width: min(100%, calc(56vh * 9 / 16)); } .player.sq { aspect-ratio: 1; width: min(100%, 56vh); }
@@ -112,7 +155,7 @@
112
155
  .insp h2 { margin: 6px 0 0; font: 400 26px/1.1 var(--serif); }
113
156
  .insp .sum { margin-top: 6px; }
114
157
  .tabs { display: flex; margin: 16px 0 14px; border: 1px solid var(--rule); }
115
- .tabs button { flex: 1; background: none; border: 0; padding: 8px 4px; font-size: 12.5px; color: var(--muted); }
158
+ .tabs button { flex: 1; background: none; border: 0; padding: 8px 3px; font-size: 12px; color: var(--muted); white-space: nowrap; }
116
159
  .tabs button + button { border-left: 1px solid var(--rule); }
117
160
  .tabs button.on { background: var(--paper); color: #000; }
118
161
  .pane { display: grid; gap: 14px; }
@@ -158,9 +201,15 @@
158
201
  .fonts { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
159
202
  .font { padding: 10px 12px; border: 1px solid var(--line); background: none; text-align: left; font-size: 14px; }
160
203
  .font:hover { border-color: var(--rule); } .font.on { border-color: var(--paper); }
161
- .ask { position: fixed; right: 18px; bottom: 18px; z-index: 40; width: 360px; background: var(--surface); border: 1px solid var(--rule); padding: 12px; display: none; }
162
- .ask.on { display: grid; gap: 8px; }
163
- .ask textarea { width: 100%; min-height: 80px; background: var(--ground); border: 1px solid var(--rule); padding: 8px; resize: vertical; }
204
+ .ask { position: fixed; right: 18px; bottom: 18px; z-index: 40; width: 380px; max-height: min(640px, 76vh); background: var(--surface); border: 1px solid var(--rule); display: none; flex-direction: column; }
205
+ .ask.on { display: flex; }
206
+ .ask .hd { display: flex; justify-content: space-between; align-items: center; padding: 12px; border-bottom: 1px solid var(--line); }
207
+ .ask .th { flex: 1; overflow: auto; padding: 12px; display: flex; flex-direction: column; gap: 10px; min-height: 80px; }
208
+ .ask .cmp { padding: 12px; border-top: 1px solid var(--line); display: grid; gap: 8px; }
209
+ .ask textarea { width: 100%; min-height: 70px; background: var(--ground); border: 1px solid var(--rule); padding: 8px; resize: vertical; }
210
+ .msg { max-width: 90%; padding: 8px 10px; border: 1px solid var(--line); background: var(--ground); white-space: pre-wrap; overflow-wrap: anywhere; }
211
+ .msg.you { align-self: flex-end; border-color: var(--rule); } .msg.agent { align-self: flex-start; border-left: 2px solid var(--paper); }
212
+ .msg .st { font: 500 10.5px/1 var(--mono); letter-spacing: .05em; text-transform: uppercase; color: var(--muted); margin-top: 6px; } .msg .st.read { color: var(--ok); }
164
213
  #toast { position: fixed; left: 50%; bottom: 24px; transform: translateX(-50%) translateY(20px); opacity: 0; background: var(--paper); color: #000; padding: 9px 14px; font-size: 13px; transition: .2s; z-index: 80; pointer-events: none; max-width: 80vw; }
165
214
  #toast.on { opacity: 1; transform: translateX(-50%); }
166
215
 
@@ -177,16 +226,18 @@
177
226
  </head>
178
227
  <body>
179
228
  <div id="app"></div>
229
+ <div id="park" hidden></div>
180
230
  <div class="scrim" id="scrim"></div>
181
231
  <aside class="sheet" id="sheet" role="dialog"></aside>
182
- <div class="ask" id="ask"><div style="display:flex;justify-content:space-between;align-items:center"><b>Ask your agent</b><button class="btn sm" data-ask-close>Close</button></div>
183
- <p class="hint">For anything the controls can't do: "make it more playful", "add our pricing", "use the photo of the team".</p>
184
- <textarea id="askText" aria-label="What to ask"></textarea><button class="btn primary" data-ask-send>Send</button></div>
232
+ <div class="ask" id="ask" role="dialog" aria-label="Ask your agent"><div class="hd"><b>Ask your agent</b><button class="btn sm" data-ask-close>Close</button></div>
233
+ <div class="th" id="thread"></div>
234
+ <div class="cmp"><p class="hint">For anything the controls can't do: "make it more playful", "add our pricing", "use the photo of the team".</p>
235
+ <textarea id="askText" aria-label="What to ask"></textarea><button class="btn primary" data-ask-send>Send</button></div></div>
185
236
  <div id="toast" role="status"></div>
186
237
  <script>
187
238
  // ─── state, helpers ────────────────────────────────────────────────────────────────────────────────────────────────
188
239
  const params = new URLSearchParams(location.search);
189
- let piece = params.get('p'), P = null, raw = '', view = null, sel = null, tab = 'words', misses = 0, playing = null;
240
+ let piece = params.get('p'), P = null, raw = '', view = null, sel = null, tab = 'text', misses = 0, playing = null;
190
241
  const esc = (s) => String(s ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
191
242
  const $ = (id) => document.getElementById(id);
192
243
  const I = {
@@ -195,7 +246,8 @@ const I = {
195
246
  };
196
247
  const icon = (n) => `<svg class="ico" viewBox="0 0 20 20">${I[n]}</svg>`;
197
248
  let tt; const toast = (t, ms = 1900) => { const e = $('toast'); e.textContent = t; e.classList.add('on'); clearTimeout(tt); tt = setTimeout(() => e.classList.remove('on'), ms); };
198
- const STEP = { brief: 'what', inventory: 'what', video: 'video', final: 'export' };
249
+ const STEP = { brief: 'what', inventory: 'what', look: 'look', video: 'video', final: 'export' };
250
+ const ORDER = ['what', 'look', 'video', 'export'];
199
251
  const tile = (o, attr, on) => `<button class="tile${on ? ' on' : ''}" ${attr} ${o.clip ? `data-clip="${esc(o.clip)}"` : ''}><div class="ph" ${o.poster ? `style="background-image:url('${esc(o.poster)}')"` : ''}></div><div class="lb">${esc(o.title)}${o.summary ? `<small>${esc(o.summary)}</small>` : ''}</div></button>`;
200
252
  const swatch = (b) => `<div class="ph" data-swatch="${esc(JSON.stringify(b.entry ?? {}))}"><div class="bars">${(b.colors?.length ? b.colors : ['#111', '#333']).map((c) => `<i style="background:${esc(c)}"></i>`).join('')}</div></div>`;
201
253
 
@@ -209,6 +261,9 @@ async function load() {
209
261
  if (!res.ok) { $('app').innerHTML = `<div class="empty">There is no piece “${esc(piece)}” in this project.</div>`; return; }
210
262
  const text = await res.text();
211
263
  if (text === raw) return;
264
+ // a scene's still arriving changes only its thumbnail: no redraw, so the player never reloads under the user
265
+ const noStills = (t) => t.replace(/"still":(?:"[^"]*"|null),?/g, '');
266
+ if (raw && P && noStills(text) === noStills(raw)) { raw = text; P = JSON.parse(text); paintStills(); return; }
212
267
  raw = text; P = JSON.parse(text);
213
268
  if (!view) view = STEP[P.stage] ?? 'what';
214
269
  render();
@@ -228,25 +283,46 @@ function renderList(list) {
228
283
  // ─── the frame: steps, whose turn it is ───────────────────────────────────────────────────────────────────────────
229
284
  function turn() {
230
285
  if (misses >= 3) return ['off', 'The studio stopped', 'Ask your agent to start it again.'];
231
- if (P.ask) return ['ask', 'Your agent asked you something in the chat', P.ask.text];
232
286
  if (P.building) return ['agent', 'Building your video', 'From what you kept. A few seconds.'];
233
287
  if (P.rendering) return ['agent', 'Rendering the final', P.rendering.joining ? 'Joining the parts' : P.rendering.part ? `Part ${P.rendering.part} of ${P.rendering.of}` : 'Starting'];
234
- const st = P.stages[P.stage]?.status, A = P.agent ?? {};
235
- if (st === 'review') return ['yours', 'Your turn', A.listening ? 'Your agent waits while you edit. Every change reaches it.' : 'Edit freely; when you are done, tell your agent “continue” in the chat.'];
236
- if (st === 'approved' && P.stage === 'final') return ['done', 'Finished', 'Your files are in Export.'];
237
- return ['agent', 'Your agent is working', 'It shows up here when it is ready for you.'];
288
+ // only what the studio can check: a question open, a command waiting on the piece, or what the agent said it is doing
289
+ const A = P.agent ?? {};
290
+ if (A.state === 'ask' && P.ask) return ['asking', 'Your agent asks', P.ask.text];
291
+ if (A.state === 'listen') return ['listen', 'Your agent is listening.', 'What you do here reaches it.'];
292
+ if (A.state === 'work') return ['agent', 'Your agent is working', `${A.working?.on ? `on ${A.working.on} · ` : ''}for ${since(A.working?.at)}`];
293
+ if (P.stages.final?.status === 'approved' && P.stage === 'final') return ['done', 'Finished', 'Your files are in Export.'];
294
+ return ['off', 'Your agent isn’t connected.', 'What you send is kept, and it reads it when it’s back.'];
238
295
  }
296
+ // how long ago, in plain words
297
+ function since(at) { const s = Math.max(0, Math.round((Date.now() - Date.parse(at)) / 1000)); return s < 60 ? `${s} s` : s < 3600 ? `${Math.round(s / 60)} min` : `${Math.round(s / 3600)} h`; }
298
+ const hhmm = (at) => new Date(at).toTimeString().slice(0, 5);
299
+ const notesKey = () => `pm:notes:${piece}`;
300
+ function seenNotes() { try { return sessionStorage.getItem(notesKey()) ?? ''; } catch { return ''; } }
301
+ function markNotes() { const last = (P?.thread ?? []).filter((m) => m.who === 'agent').at(-1)?.at; if (last) try { sessionStorage.setItem(notesKey(), last); } catch { /* storage blocked */ } }
302
+ // Ask your agent, as a thread: what the user sent (Sent, then Read with the time) and the agent's replies
303
+ function paintThread() {
304
+ const th = $('thread'); if (!th || !P) return;
305
+ const T = P.thread ?? [], off = P.agent?.state !== 'listen';
306
+ th.innerHTML = T.length ? T.map((m) => `<div class="msg ${m.who}"><div>${esc(m.text)}</div><div class="st ${m.read ? 'read' : ''}">${m.who === 'agent' ? `Your agent · ${hhmm(m.at)}` : m.read ? `Read ${hhmm(m.read)}` : off ? 'Sent · your agent reads it when it’s back' : 'Sent'}</div></div>`).join('')
307
+ : '<p class="hint">Nothing yet. What you send here reaches your agent, and you see when it has read it.</p>';
308
+ th.scrollTop = th.scrollHeight;
309
+ if ($('ask').classList.contains('on')) markNotes();
310
+ }
311
+ setInterval(() => { const el = $('since'); if (el && P?.agent?.working) el.textContent = `${P.agent.working.on ? `on ${P.agent.working.on} · ` : ''}for ${since(P.agent.working.at)}`; }, 10000);
239
312
  function frame(body) {
240
313
  const [k, head, tx] = turn(), step = STEP[P.stage], A = P.agent ?? {};
241
- const done = (s) => ({ what: P.stages.inventory?.status === 'approved', video: P.stages.video?.status === 'approved', export: P.stages.final?.status === 'approved' }[s]);
242
- document.title = `${k === 'yours' ? '● ' : ''}${P.title} · studio`;
314
+ const done = (s) => ({ what: P.stages.inventory?.status === 'approved', look: P.stages.look?.status === 'approved' && P.stages.inventory?.status === 'approved', video: P.stages.video?.status === 'approved', export: P.stages.final?.status === 'approved' }[s]);
315
+ const reached = Math.max(ORDER.indexOf(step), P.video ? 2 : 0, P.stages.video?.status === 'approved' || P.media?.final ? 3 : 0);
316
+ document.title = `${k === 'asking' ? '● ' : ''}${P.title} · studio`;
317
+ const unreadNotes = (P.thread ?? []).filter((m) => m.who === 'agent' && m.at > seenNotes()).length;
243
318
  return `<header class="top"><a class="back" href="/">${icon('back')}Pieces</a><div class="ttl">${esc(P.title)}</div>
244
- <nav class="steps" aria-label="Steps">${[['what', 'What it says'], ['video', 'Your video'], ['export', 'Export']].map(([id, t], i) => `<button class="${view === id ? 'on' : ''}" data-view="${id}"><b>${i + 1}</b>${t}${done(id) ? '<i title="approved"></i>' : ''}</button>`).join('')}</nav>
319
+ <nav class="steps" aria-label="Steps">${[['what', 'What you want to say'], ['look', 'How it looks'], ['video', 'Your video'], ['export', 'Export']].map(([id, t], i) => `<button class="${view === id ? 'on' : ''}" data-view="${id}" ${i > reached && !waitingOn(id) ? 'disabled' : ''}><b>${i + 1}</b>${t}${done(id) ? '<i title="done"></i>' : ''}${waitingOn(id) ? '<em class="sdot" title="Your agent suggests something here"></em>' : ''}</button>`).join('')}</nav>
245
320
  <button class="btn" data-undo ${P.undo ? `title="Undo ${esc(P.undo)}"` : 'disabled'}>${icon('undo')}Undo</button>
246
- <button class="btn" data-style>${icon('style')}Style</button>
247
- <button class="btn" data-ask>${icon('ask')}Ask your agent</button></header>
248
- <div class="turn ${k}"><i class="mk"></i><span><b>${esc(head)}</b><span class="tx">${esc(tx)}</span></span><span class="grow"></span>
249
- ${A.focus && (k === 'yours' || k === 'ask') ? `<button class="btn sm" data-focus>Go to ${esc(A.app)} ${icon('out')}</button>` : ''}</div>
321
+ <button class="btn" data-ask>${icon('ask')}Ask your agent${unreadNotes ? `<span class="nbadge">${unreadNotes}</span>` : ''}</button></header>
322
+ <div class="turn ${k}"><i class="mk"></i>${k === 'asking'
323
+ ? `<span class="lbl">${esc(head)}</span><span class="q">${esc(tx)}</span><span class="ans"><input id="ansText" placeholder="Your answer" aria-label="Your answer"><button class="btn primary sm" data-ans-send>Answer</button><button class="btn sm" data-ans-chat>Answered in the chat</button></span>`
324
+ : `<span><b>${esc(head)}</b> <span class="tx" ${k === 'agent' && A.working ? 'id="since"' : ''}>${esc(tx)}</span></span><span class="grow"></span>
325
+ ${A.focus && k === 'off' ? `<button class="btn sm" data-focus>Go to ${esc(A.app)} ${icon('out')}</button>` : ''}`}</div>
250
326
  <main>${body}</main>`;
251
327
  }
252
328
 
@@ -266,7 +342,7 @@ function whatHTML() {
266
342
  ${sentence(i) && i.status !== 'removed' ? `<div class="t">${inline(i).map(([k, v]) => `<span class="edit" contenteditable="plaintext-only" spellcheck="false" data-inv="${esc(i.id)}" data-key="${esc(k)}" data-placeholder="${esc(k)}">${esc(v)}</span>`).join(i.kind === 'change' ? ' → ' : ' ')}</div>` : `<div class="t">${esc(i.what)}</div>`}
267
343
  <div class="s">From ${esc(i.source || 'you')}${i.missing?.length ? ` · still needs ${esc(i.missing.join(', ').toLowerCase())}` : ''}</div></div>
268
344
  <div class="acts">${i.status === 'removed' ? `<button class="btn sm" data-inv-set="${esc(i.id)}" data-v="kept">Keep it</button>`
269
- : `${i.status === 'proposed' ? `<button class="btn sm primary" data-inv-set="${esc(i.id)}" data-v="kept">Keep</button>` : `<button class="btn sm${i.must ? ' on' : ''}" data-inv-must="${esc(i.id)}" data-v="${i.must ? '' : '1'}">★ ${i.must ? 'Must appear' : 'Must?'}</button>`}<button class="btn sm" data-inv-set="${esc(i.id)}" data-v="removed">Not this</button>`}</div></div>`).join('');
345
+ : `${i.status === 'proposed' ? `<button class="btn sm primary" data-inv-set="${esc(i.id)}" data-v="kept">Keep</button>` : `${P.video && !inVideo(i) ? `<button class="btn sm primary" data-addto="${esc(i.group ?? '')}">Add it to the video</button>` : ''}<button class="btn sm${i.must ? ' on' : ''}" data-inv-must="${esc(i.id)}" data-v="${i.must ? '' : '1'}">★ ${i.must ? 'Must appear' : 'Must?'}</button>`}<button class="btn sm" data-inv-set="${esc(i.id)}" data-v="removed">Not this</button>`}</div></div>`).join('');
270
346
  const atInv = ['brief', 'inventory'].includes(P.stage), hasVideo = !!P.video;
271
347
  return `<div class="what">
272
348
  <h1>What your video says</h1>
@@ -278,31 +354,105 @@ function whatHTML() {
278
354
  ${inv.kinds && Object.keys(inv.kinds).length ? `<div class="addfact"><select id="addKind" aria-label="What is it">${Object.entries(inv.kinds).map(([id, k]) => `<option value="${id}">${esc(k.name)}</option>`).join('')}</select><input id="addText" placeholder="Add something true: a sentence, or a number and what it counts (163 pieces of motion)"><button class="btn" data-add-fact>Add</button></div>` : ''}
279
355
  <div class="nextbar"><span class="muted">${kept} thing${kept === 1 ? '' : 's'} in your video${wait ? ` · ${wait} still to decide` : ''}${inv.moments ? ` · a ${inv.seconds} s video holds about ${inv.moments} moments` : ''}</span>
280
356
  ${hasVideo ? `<button class="btn primary" data-view="video">Edit your video →</button>`
281
- : atInv && P.stages.inventory?.status === 'review' && (kept || wait) ? `<button class="btn primary" data-make>${wait ? 'Keep the rest and make my video →' : 'Make my video →'}</button>`
357
+ : atInv && P.stages.inventory?.status === 'review' && (kept || wait) ? `<button class="btn primary" data-make>${wait ? 'Keep the rest. Next: how it looks →' : 'Next: how it looks →'}</button>`
282
358
  : P.building ? '<span class="muted">Building your video…</span>' : ''}</div></div>`;
283
359
  }
284
360
 
285
- // ─── 2 · your video ───────────────────────────────────────────────────────────────────────────────────────────────
361
+ // ─── 2 · how it looks ────────────────────────────────────────────────────────────────────────────────────────
362
+ // Every option is a still; pointing at one shows it in the single live preview, with the user's own sentence.
363
+ let lookShown = null, lookHover = null, lookTimer = 0;
364
+ const fontCss = new Set();
365
+ function loadFonts(urls) { for (const u of urls) if (u && !fontCss.has(u)) { fontCss.add(u); document.head.append(Object.assign(document.createElement('link'), { rel: 'stylesheet', href: u })); } }
366
+ async function showLook(patch) {
367
+ const key = JSON.stringify(patch ?? {});
368
+ if (key === lookShown || !$('lookPlayer')) return;
369
+ lookShown = key;
370
+ try {
371
+ const r = await (await fetch(`/api/look?p=${encodeURIComponent(piece)}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: key })).json();
372
+ const box = $('lookPlayer'); if (!box || !r.url || lookShown !== key) return;
373
+ let f = box.querySelector('iframe'); if (!f) { f = document.createElement('iframe'); f.title = 'How it looks'; box.append(f); }
374
+ f.src = r.url; box.querySelector('.msg')?.remove();
375
+ } catch { /* the preview is starting */ }
376
+ }
377
+ function hoverLook(patch) { clearTimeout(lookTimer); lookTimer = setTimeout(() => { lookHover = patch; showLook(patch); }, 160); }
378
+ // ─── the agent's suggestions: on the step and section they decide, first, marked as the agent's ─────────────────
379
+ const waitingOn = (stepId) => (P.choices ?? []).some((c) => c.status === 'waiting' && (c.where?.step === stepId || (stepId === 'video' && c.where?.step !== 'look')));
380
+ function choiceFace(o) {
381
+ if (o.beats) return `<div class="beats">${o.beats.map((b) => `<span><b>${esc(b.name)}</b> ${esc(b.what)}</span>`).join('')}</div>`;
382
+ if (o.items) return `<div class="kitf">${o.items.map((x) => x.colors ? `<i style="background:${x.colors.ground};border-bottom:5px solid ${x.colors.accent}"></i>` : x.display ? `<b style="font-family:'${esc(x.display)}'">Aa</b>` : x.poster ? `<img src="${esc(x.poster)}" alt="">` : '').join('')}</div>`;
383
+ if (o.colors) return `<div class="sw" style="background:${o.colors.ground};color:${o.colors.figure}"><b style="font-family:var(--serif)">Aa</b><i style="background:${o.colors.accent}"></i></div>`;
384
+ if (o.display) return `<div class="spec"><b style="font-family:'${esc(o.display)}'">Aa</b><span style="font-family:'${esc(o.text ?? o.display)}'">${esc(o.title)}</span></div>`;
385
+ return o.poster ? `<img src="${esc(o.poster)}" alt="" loading="lazy">` : '';
386
+ }
387
+ const lkPatch = (o) => (o.colors ? { palette: o.id, colors: o.colors } : o.display ? { font: o.id } : o.type === 'background' ? { background: o.id } : null);
388
+ function sug(step, section) {
389
+ const list = (P.choices ?? []).filter((c) => c.status === 'waiting' && (c.where?.step === step || (step === 'video' && c.where?.step !== 'look')) && (!section || c.where?.section === section));
390
+ return list.map((c) => {
391
+ for (const o of c.options) if (o.css) loadFonts(o.css); else for (const x of o.items ?? []) if (x.css) loadFonts(x.css);
392
+ const cards = c.options.map((o) => { const lk = lkPatch(o);
393
+ return `<button class="lkc" data-choose="${c.id}" data-label="${o.label}" ${lk ? `data-lk='${JSON.stringify(lk)}'` : ''} ${o.clip ? `data-clip="${esc(o.clip)}"` : ''}><span class="sugb">Suggested by your agent</span><div class="lkf ${o.clip ? 'ph' : ''}" ${o.clip ? `style="background-image:url('${esc(o.poster)}')"` : ''}>${o.clip ? '' : choiceFace(o)}</div><div class="lkn"><span><b>${o.label}</b> · ${esc(o.title)}</span></div>${o.note ? `<p class="chnote">${esc(o.note)}</p>` : ''}</button>`; }).join('');
394
+ return `<div class="agentsug"><div class="agentnote"><b>Your agent suggests ${c.options.length === 2 ? 'two' : c.options.length === 3 ? 'three' : c.options.length === 4 ? 'four' : ''}.</b> ${esc(c.ask ?? c.title)}</div>
395
+ <div class="lkg">${cards}</div>
396
+ <div class="none"><input data-nonetext="${c.id}" placeholder="None of these? Say what you’d rather see" aria-label="What you would rather see"><button class="btn sm" data-none="${c.id}">None of these</button></div></div>`;
397
+ }).join('');
398
+ }
399
+ function lookHTML() {
400
+ const L = P.lookOptions; if (!L) return '<div class="empty">How it looks is loading…</div>';
401
+ const C = L.current, card = (on, attrs, face, name, sub = '') => `<button class="lkc ${on ? 'on' : ''}" ${attrs}><div class="lkf">${face}</div><div class="lkn"><span>${esc(name)}</span>${sub ? `<span class="muted">${esc(sub)}</span>` : ''}</div></button>`;
402
+ loadFonts(L.fonts.flatMap((f) => f.css));
403
+ const swatch = (c) => `<div class="sw" style="background:${c.ground};color:${c.figure}"><b style="font-family:var(--serif)">Aa</b><i style="background:${c.accent}"></i></div>`;
404
+ const pals = L.palettes.map((x) => card(C.palette === x.id, `data-lpal="${x.id}" data-lk='${JSON.stringify({ palette: x.id })}'`, swatch(x.colors), x.title)).join('');
405
+ const fonts = L.fonts.map((f) => card(C.font === f.id, `data-lfont="${f.id}" data-lk='${JSON.stringify({ font: f.id })}'`, `<div class="spec"><b style="font-family:'${esc(f.display)}'">Aa</b><span style="font-family:'${esc(f.text ?? f.display)}'">${esc(L.headline.slice(0, 28))}</span></div>`, f.title)).join('');
406
+ const bgFace = (b) => b.poster ? `<img src="${esc(b.poster)}" alt="" loading="lazy">` : `<div style="position:absolute;inset:0;background:linear-gradient(135deg, ${(b.colors.length ? b.colors : ['#222', '#555']).join(',')})"></div>`;
407
+ const bgs = L.backgrounds.slice(0, 24).map((b) => card(C.background === b.id, `data-lbg="${b.id}" data-lk='${JSON.stringify({ background: b.id })}'`, `${bgFace(b)}${b.mine ? '<span class="yours">Yours</span>' : ''}`, b.title)).join('');
408
+ const enters = L.entrances.map((e) => `<button class="lkc ${C.entrance === e.value ? 'on' : ''}" data-lenter="${e.value}" data-lk='${JSON.stringify({ entrance: e.value })}' ${e.clip ? `data-clip="${esc(e.clip)}"` : ''}><div class="lkf ph" style="background-image:url('${esc(e.poster ?? '')}')"></div><div class="lkn"><span>${esc(e.title)}</span></div></button>`).join('');
409
+ return `<div class="look">
410
+ <div class="lkpv"><div class="lbl" style="margin-bottom:8px">Your video, with your own sentence</div>
411
+ <div class="lkbox" id="lookPlayer"><button class="lkagain" data-look-replay>↻ Play again</button>${L.live ? '' : '<div class="msg">The live preview is starting…</div>'}</div>
412
+ <p class="hint" style="margin-top:10px">Point at any option to see it here; click it to choose. Your scenes follow.</p></div>
413
+ <div class="lkopts" id="lookOpts">
414
+ <h1>How it looks</h1>
415
+ <p class="sub">${P.stages.look?.status === 'approved' ? 'Your brand already has a look, so this step is done. Change anything you like.' : 'Choose its colours, type, background and movement.'}</p>
416
+ ${sug('look', 'kit')}
417
+ <section><h2>Colours</h2>${sug('look', 'colours')}<div class="lkg">${pals}</div>
418
+ <div class="lkcols">${['ground', 'figure', 'accent'].map((k) => `<label>${{ ground: 'Background', figure: 'Text', accent: 'Accent' }[k]} <input type="color" data-lcol="${k}" value="${esc(C.colors[k] ?? '#000000')}"></label>`).join('')}</div></section>
419
+ <section><h2>Type</h2>${sug('look', 'type')}<div class="lkg">${fonts}</div></section>
420
+ <section><h2>Background</h2>${sug('look', 'background')}<p class="hint">The stills are in the library’s colours: point at one to see it in yours.</p><div class="lkg">${bgs}</div></section>
421
+ <section><h2>Movement</h2>${sug('look', 'movement')}<div class="seg">${L.paces.map((x) => `<button class="${C.pace === x ? 'on' : ''}" data-lpace="${x}">${x[0].toUpperCase() + x.slice(1)}</button>`).join('')}</div>
422
+ <p class="hint" style="margin:14px 0 0">How words arrive</p><div class="lkg">${enters}</div></section>
423
+ </div>
424
+ <div class="nextbar"><span class="muted">${P.video ? 'Your scenes already use this look.' : 'Next, your video is built from what you kept, in this look.'}</span>
425
+ ${P.building ? '<span class="muted">Building your video…</span>' : P.video ? '<button class="btn primary" data-view="video">Back to your video →</button>' : '<button class="btn primary" data-build>Next: make my video →</button>'}</div></div>`;
426
+ }
427
+
428
+ // ─── 3 · your video ───────────────────────────────────────────────────────────────────────────────────────────────
286
429
  const cur = () => P.video?.parts.find((p) => p.dir === sel) ?? P.video?.parts[0];
287
430
  const joinName = (id) => P.library?.joins.find((j) => j.id === id)?.title ?? id;
288
- const firstWords = (p) => { const w = p.words.find((f) => f.kind === 'text' && f.value) ?? p.words.find((f) => f.value); return w ? String(w.value).split('\n')[0] : p.summary; };
431
+ const firstWords = (p) => { const w = p.words.find((f) => f.kind === 'text' && f.value) ?? p.words.find((f) => f.value); return w ? String(w.value).split('\n')[0].replace(/\s*\|\s*/g, ' ') : p.summary; };
432
+ // is a kept item shown by some scene already (the video never rebuilds by itself: the user adds what is new)
433
+ const inVideo = (i) => (P.video?.parts ?? []).some((x) => (x.shows ?? []).includes(i.id));
434
+ function paintStills() {
435
+ for (const x of P.video?.parts ?? []) { const th = document.querySelector(`.scenes .part[data-part="${CSS.escape(x.dir)}"] .th`); if (th) th.style.backgroundImage = `url('${x.still ?? x.poster}')`; }
436
+ }
289
437
  function videoHTML() {
290
438
  const V = P.video;
291
- if (!V) return `<div class="empty">${P.building ? 'Building your video from what you kept…' : 'Your video appears here once you keep what it says.'}<div style="margin-top:16px"><button class="btn" data-view="what">Go to What it says</button></div></div>`;
439
+ if (!V) return `<div class="empty">${P.building ? 'Building your video from what you kept…' : 'Your video appears here once you choose how it looks.'}<div style="margin-top:16px"><button class="btn" data-view="${P.stages.inventory?.status === 'approved' ? 'look' : 'what'}">${P.stages.inventory?.status === 'approved' ? 'Go to How it looks' : 'Go to What you want to say'}</button></div></div>`;
292
440
  const p = cur(); sel = p?.dir;
293
441
  const shape = V.format === '1080x1920' ? ' tall' : V.format === '1080x1080' ? ' sq' : '';
294
- return `<div class="editor"><div class="center">
442
+ // scenes, not a timeline: a list like slides, each with a still of itself, what it says and how long it plays
443
+ return `<div class="editor">
444
+ <aside class="scenes" id="track"><p class="lbl" style="margin:2px 2px 10px">Your scenes · drag to reorder</p>
445
+ ${V.parts.map((x, i) => `<div class="part${x.dir === sel ? ' sel' : ''}" draggable="true" data-part="${esc(x.dir)}">
446
+ <span class="n">${String(i + 1).padStart(2, '0')}</span>
447
+ <div class="th" style="background-image:url('${esc(x.still ?? x.poster)}')"></div>
448
+ <div class="meta"><div class="nm">${esc(firstWords(x))}</div><div class="tx">${esc(x.title)} · ${Number(x.seconds).toFixed(1)} s</div></div></div>`).join('')}
449
+ <button class="btn addscn" data-addat="${V.parts.length}">${icon('add')}Add a scene</button></aside>
450
+ <div class="center">${sug('video')}
295
451
  <div class="player${shape}" id="player"><span class="badge" id="pbadge"></span></div>
296
- <div class="pbar"><span id="pinfo"></span><span class="grow"></span><span>${V.total} s · ${V.parts.length} parts</span>
452
+ <div class="pbar"><span id="pinfo"></span><span class="grow"></span><span>${V.total} s · ${V.parts.length} scenes</span>
297
453
  <button class="btn sm" data-playall>${icon('play')}${playing ? 'Stop' : 'Play the whole video'}</button>
298
454
  ${P.stage === 'video' && P.stages.video?.status === 'review' ? `<button class="btn primary sm" data-approve="video">It looks right → Export</button>` : `<button class="btn sm" data-view="export">Export →</button>`}</div>
299
- <div><p class="lbl" style="margin:4px 0 8px">Your video, in order · drag to reorder · + adds a part · the arrow is the transition</p>
300
- <div class="track" id="track">${V.parts.map((x, i) => `${i === 0 ? `<div class="addp"><button data-addat="0" title="Add a part here">${icon('add')}</button></div>` : ''}
301
- <div class="part${x.dir === sel ? ' sel' : ''}" draggable="true" data-part="${esc(x.dir)}" data-clip="${esc(x.clip)}">
302
- <div class="th" style="background-image:url('${esc(x.poster)}')"><span class="n">${String(i + 1).padStart(2, '0')}</span><span class="sec">${Number(x.seconds).toFixed(1)}s</span></div>
303
- <div class="meta"><div class="nm">${esc(x.title)}</div><div class="tx">${esc(firstWords(x))}</div></div></div>
304
- ${x.join ? `<div class="join"><button data-joinof="${esc(x.dir)}" title="Transition: ${esc(joinName(x.join))}">${icon('join')}</button><small>${esc(joinName(x.join))}</small></div>` : ''}
305
- <div class="addp"><button data-addat="${i + 1}" title="Add a part here">${icon('add')}</button></div>`).join('')}</div></div>
455
+ <p class="hint" style="text-align:center">Each scene plays once and holds its last frame; click it to play it again.</p>
306
456
  </div><aside class="insp" id="insp">${p ? inspHTML(p) : ''}</aside></div>`;
307
457
  }
308
458
  function inspHTML(p) {
@@ -318,19 +468,23 @@ function inspHTML(p) {
318
468
  };
319
469
  const B = P.inventory?.backgrounds ?? { mine: [], library: [] };
320
470
  const panes = {
321
- words: p.words.length ? p.words.map(fld).join('') : '<p class="hint">This part has no words of its own.</p>',
322
- motion: p.motion ? `<p class="hint">How it arrives. Point at one to see it move.</p><div class="tiles">${p.motion.demos.map((d) => tile(d, `data-motion="${esc(d.value)}"`, d.value === p.motion.value)).join('')}</div>`
323
- : `<p class="hint">This part has its own way of moving.</p><div class="tiles">${tile({ title: p.title, summary: p.summary, poster: p.poster, clip: p.clip }, 'disabled', true)}</div><button class="btn sm" data-swap>Show it another way</button>`,
471
+ text: p.words.length ? p.words.map(fld).join('') : '<p class="hint">This scene has no words of its own.</p>',
472
+ anim: `${p.motion ? `<p class="hint">How it arrives. Point at one to see it move.</p><div class="tiles">${p.motion.demos.map((d) => tile(d, `data-motion="${esc(d.value)}"`, d.value === p.motion.value)).join('')}</div>`
473
+ : `<p class="hint">This scene has its own way of moving.</p><div class="tiles">${tile({ title: p.title, summary: p.summary, poster: p.poster, clip: p.clip }, 'disabled', true)}</div><button class="btn sm" data-swap>Show it another way</button>`}
474
+ ${p.tune.length ? `<p class="lbl" style="margin-top:6px">Its settings</p>${p.tune.map((t) => t.kind === 'number'
475
+ ? `<div class="slider"><div>${esc(t.label)}<em id="tv-${esc(t.name)}">${esc(t.value)}</em></div><input type="range" min="${t.min}" max="${t.max}" step="${t.step}" value="${esc(t.value)}" data-tune="${esc(t.name)}"></div>`
476
+ : `<div class="fld">${esc(t.label)}<div class="seg">${t.options.map((o) => `<button class="btn sm${String(t.value) === String(o) ? ' on' : ''}" data-tunev="${esc(t.name)}" data-v="${esc(o)}">${esc(o)}</button>`).join('')}</div></div>`).join('')}` : ''}`,
324
477
  bg: !p.takesBackground ? '<p class="hint">This part draws its own background, so it has none to change. Show it another way to use one.</p>' : `<p class="hint">Behind this part. Point at one to see it move.</p><div class="tiles">${[{ id: '', title: "Your brand's first", colors: [] , entry: null }, ...B.mine].map((b) => `<button class="tile${(p.background ?? '') === b.id ? ' on' : ''}" data-bg="${esc(b.id)}">${b.entry ? swatch(b) : '<div class="ph" style="display:grid;place-items:center;color:var(--muted);font-size:12px">default</div>'}<div class="lb">${esc(b.title)}</div></button>`).join('')}</div>
325
478
  <div style="display:flex;gap:8px;flex-wrap:wrap"><button class="btn sm" data-bgall>Use this background on every part</button>${B.library.length ? `<button class="btn sm" data-bgmore>More backgrounds (${B.library.length})</button>` : ''}</div>`,
326
- tune: `${p.tune.map((t) => t.kind === 'number'
327
- ? `<div class="slider"><div>${esc(t.label)}<em id="tv-${esc(t.name)}">${esc(t.value)}</em></div><input type="range" min="${t.min}" max="${t.max}" step="${t.step}" value="${esc(t.value)}" data-tune="${esc(t.name)}"></div>`
328
- : `<div class="fld">${esc(t.label)}<div class="seg">${t.options.map((o) => `<button class="btn sm${String(t.value) === String(o) ? ' on' : ''}" data-tunev="${esc(t.name)}" data-v="${esc(o)}">${esc(o)}</button>`).join('')}</div></div>`).join('')}
329
- <div class="slider"><div>Length<em id="tv-seconds">${Number(p.seconds).toFixed(1)} s</em></div><input type="range" min="${p.range.min}" max="${p.range.max}" step="0.1" value="${p.seconds}" data-seconds data-natural="${p.duration}"></div>
330
- <p class="hint" id="tv-pace">${pace(p.seconds, p.duration)}</p>`,
479
+ speed: `<div class="slider"><div>Length<em id="tv-seconds">${Number(p.seconds).toFixed(1)} s</em></div><input type="range" min="${p.range.min}" max="${p.range.max}" step="0.1" value="${p.seconds}" data-seconds data-natural="${p.duration}"></div>
480
+ <p class="hint" id="tv-pace">${pace(p.seconds, p.duration)}</p>
481
+ <p class="hint">This scene always plays whole: its own length is ${Number(p.duration).toFixed(1)} s. Shorter plays it faster, longer slower.</p>`,
482
+ next: !p.join ? '<p class="hint">This is the last scene: the video ends here.</p>'
483
+ : `<p class="hint">How it passes to scene ${P.video.parts.indexOf(p) + 2}. Point at one to see it move.</p><div class="tiles">${(P.library?.joins ?? []).map((j) => tile(j, `data-joinp="${esc(j.id)}"`, p.join === j.id)).join('')}</div>
484
+ <button class="btn sm" data-joinallp>Use ${esc(joinName(p.join))} between every scene</button>`,
331
485
  };
332
- return `<div class="lbl">Part ${n} of ${P.video.parts.length}</div><h2>${esc(p.title)}</h2><p class="hint sum">${esc(p.summary)}</p>
333
- <div class="tabs">${[['words', 'Words'], ['motion', 'Motion'], ['bg', 'Background'], ['tune', 'Tune']].map(([k, l]) => `<button class="${tab === k ? 'on' : ''}" data-tab="${k}">${l}</button>`).join('')}</div>
486
+ return `<div class="lbl">Scene ${n} of ${P.video.parts.length}</div><h2>${esc(p.title)}</h2><p class="hint sum">${esc(p.summary)}</p>
487
+ <div class="tabs">${[['text', 'Text'], ['anim', 'Animation'], ['bg', 'Background'], ['speed', 'Speed'], ['next', 'Next scene']].map(([k, l]) => `<button class="${tab === k ? 'on' : ''}" data-tab="${k}">${l}</button>`).join('')}</div>
334
488
  <div class="pane">${panes[tab]}</div>
335
489
  <div class="partacts"><button class="btn sm" data-swap>Show it another way</button><button class="btn sm" data-dup>Duplicate</button><button class="btn sm" data-remove>Remove</button></div>`;
336
490
  }
@@ -363,18 +517,25 @@ function exportHTML() {
363
517
  // ─── drawing ──────────────────────────────────────────────────────────────────────────────────────────────────────
364
518
  function render() {
365
519
  if (!P) return;
520
+ paintThread(); // the thread lives outside the page body, so it is painted even while a field has focus
366
521
  // a field being typed in is never redrawn under the cursor: the page catches up when it loses focus
367
522
  const a = document.activeElement;
368
523
  if (a && a.closest?.('#app') && (a.matches('input, textarea, select') || a.isContentEditable)) { pending = true; return; }
369
524
  pending = false;
370
- const body = view === 'what' ? whatHTML() : view === 'video' ? videoHTML() : exportHTML();
371
- const keepPlayer = $('player')?.querySelector('iframe');
372
- const scroll = { insp: $('insp')?.scrollTop ?? 0, track: $('track')?.scrollLeft ?? 0 };
525
+ const body = view === 'what' ? whatHTML() : view === 'look' ? lookHTML() : view === 'video' ? videoHTML() : exportHTML();
526
+ const keepPlayer = $('player')?.querySelector('iframe'), keepLook = $('lookPlayer')?.querySelector('iframe');
527
+ // live previews survive a redraw: parked outside the page body and moved back without reloading (moveBefore keeps an
528
+ // iframe alive where the browser has it; elsewhere it reloads, as before)
529
+ const park = $('park'), keep = (f, into) => { if (!f || !into) return; if (into.moveBefore && f.isConnected) into.moveBefore(f, null); else into.append(f); };
530
+ keep(keepPlayer, park); keep(keepLook, park);
531
+ const scroll = { insp: $('insp')?.scrollTop ?? 0, track: $('track')?.scrollTop ?? 0 };
373
532
  $('app').innerHTML = frame(body);
374
- if (keepPlayer && $('player')) { $('player').append(keepPlayer); }
533
+ keep(keepPlayer, $('player')); keep(keepLook, $('lookPlayer'));
534
+ for (const f of park.querySelectorAll('iframe')) f.remove(); // a preview whose step is gone
535
+ if (view === 'look') showLook(lookHover);
375
536
  paintPlayer();
376
537
  if ($('insp')) $('insp').scrollTop = scroll.insp;
377
- if ($('track')) $('track').scrollLeft = scroll.track;
538
+ if ($('track')) $('track').scrollTop = scroll.track;
378
539
  if (sheetOpen) paintSheet();
379
540
  }
380
541
  let pending = false;
@@ -391,7 +552,7 @@ function paintSheet() {
391
552
  const head = (small, title) => `<header><div><span class="lbl">${esc(small)}</span><h2>${esc(title)}</h2></div><button class="btn sm" data-close>Close</button></header>`;
392
553
  const L = P.library ?? { intents: [], joins: [] };
393
554
  let h = '';
394
- if (s.kind === 'add') h = head(`Add a part · position ${s.arg + 1}`, 'What do you want to add?') + `<p class="hint" style="margin-top:14px">Point at one to see it move. It arrives with something you kept, when it can show it.</p>` + L.intents.map((g) => `<div class="grp"><h3 class="lbl">${esc(g.title)}</h3><div class="tiles">${g.blocks.map((b) => tile(b, `data-addblock="${esc(b.id)}"`)).join('')}</div></div>`).join('');
555
+ if (s.kind === 'add') h = head(`Add a scene${s.arg ? ` · after scene ${s.arg}` : ' · at the start'}`, 'What is it for?') + `<p class="hint" style="margin-top:14px">Point at one to see it move. It arrives with something you kept, when it can show it.</p>` + L.intents.map((g) => `<div class="grp"><h3 class="lbl">${esc(g.title)}</h3><div class="tiles">${g.blocks.map((b) => tile(b, `data-addblock="${esc(b.id)}"`)).join('')}</div></div>`).join('');
395
556
  else if (s.kind === 'swap') { const p = cur(), mine = L.intents.find((g) => g.blocks.some((b) => b.id === p.id)); h = head(`Part ${P.video.parts.indexOf(p) + 1}`, 'Show it another way') + `<p class="hint" style="margin-top:14px">Same content, shown differently. Point at one to see it move.</p>` + [mine, ...L.intents.filter((g) => g !== mine)].filter(Boolean).map((g, i) => `<div class="grp"><h3 class="lbl">${i ? esc(g.title) : `${esc(g.title)} · like this part`}</h3><div class="tiles">${g.blocks.map((b) => tile(b, `data-swapto="${esc(b.id)}"`, b.id === p.id)).join('')}</div></div>`).join(''); }
396
557
  else if (s.kind === 'join') { const p = P.video.parts.find((x) => x.dir === s.arg), i = P.video.parts.indexOf(p); h = head(`Between part ${i + 1} and ${i + 2}`, 'How one leads to the next') + `<p class="hint" style="margin-top:14px">Point at one to see it move.</p><div class="grp"><div class="tiles">${L.joins.map((j) => tile(j, `data-setjoin="${esc(j.id)}"`, p.join === j.id)).join('')}</div></div><button class="btn sm" data-joinall style="margin-top:16px">Use ${esc(joinName(p.join))} everywhere</button>`; }
397
558
  else if (s.kind === 'bgmore') { const B = P.inventory?.backgrounds ?? { library: [] }; h = head('Backgrounds', 'Add a background') + `<p class="hint" style="margin-top:14px">Point at one to see it move. Adding one puts it in your brand, in your colours, and uses it on this part.</p><div class="grp"><div class="tiles">${B.library.map((b) => `<button class="tile" data-bgadd="${esc(b.id)}">${swatch(b)}<div class="lb">${esc(b.title)}</div></button>`).join('')}</div></div>`; }
@@ -419,22 +580,36 @@ document.addEventListener('click', async (e) => {
419
580
  else if (d.tab) { tab = d.tab; render(); }
420
581
  else if ('undo' in d) act({ type: 'undo' });
421
582
  else if ('style' in d) openSheet('style');
422
- else if ('ask' in d) $('ask').classList.add('on');
583
+ else if ('ask' in d) { $('ask').classList.add('on'); markNotes(); paintThread(); render(); }
423
584
  else if ('askClose' in d) $('ask').classList.remove('on');
424
- else if ('askSend' in d) { const t = $('askText').value.trim(); if (!t) return; await act({ type: 'changes', stage: P.stage, text: t }, true); $('askText').value = ''; $('ask').classList.remove('on'); toast(P.agent?.listening ? 'Sent to your agent' : 'Saved. Tell your agent “continue” in the chat', 3600); }
585
+ else if ('askSend' in d) { const t = $('askText').value.trim(); if (!t) return; $('askText').value = ''; await act({ type: 'message', text: t }, true); toast(P.agent?.state === 'listen' ? 'Sent to your agent' : 'Kept for your agent: it reads it when it’s back', 3200); }
586
+ else if ('ansSend' in d) { const t = $('ansText')?.value.trim(); if (!t) return; await act({ type: 'answer', text: t }, true); toast(P.agent?.listening ? 'Your agent got your answer' : 'Kept: your agent reads your answer when it’s back', 3200); }
587
+ else if ('ansChat' in d) { await act({ type: 'ask-close' }, true); toast('Closed'); }
425
588
  else if ('focus' in d) fetch(`/api/focus?p=${encodeURIComponent(piece)}`, { method: 'POST' });
426
589
  else if (d.approve) { await act({ type: 'approve', stage: d.approve }); if (d.approve === 'video') { view = 'export'; render(); } }
427
- else if ('make' in d) { if (P.inventory.items.some((i) => i.status === 'proposed')) await act({ type: 'inv-rest', value: 'kept' }, true); await act({ type: 'approve', stage: 'inventory' }, true); view = 'video'; toast('Building your video…', 3000); render(); }
590
+ else if ('make' in d) { if (P.inventory.items.some((i) => i.status === 'proposed')) await act({ type: 'inv-rest', value: 'kept' }, true); await act({ type: 'approve', stage: 'inventory' }, true); view = 'look'; lookShown = null; render(); scrollTo({ top: 0 }); }
591
+ else if ('build' in d) { await act({ type: 'approve', stage: 'look' }, true); view = 'video'; if (!P.video) toast('Building your video…', 3000); render(); scrollTo({ top: 0 }); }
592
+ else if (d.lpal) { await act({ type: 'style-palette', value: d.lpal }); lookShown = null; showLook(null); }
593
+ else if (d.lfont) { await act({ type: 'style-font', value: d.lfont }); lookShown = null; showLook(null); }
594
+ else if (d.lbg) { await act({ type: 'style-background', value: d.lbg }); lookShown = null; showLook(null); }
595
+ else if (d.lenter) { await act({ type: 'style-entrance', value: d.lenter }); lookShown = null; showLook(null); }
596
+ else if (d.lpace) { await act({ type: 'style-pace', value: d.lpace }); lookShown = null; showLook(null); }
597
+ else if ('lookReplay' in d) { lookShown = null; showLook(lookHover); }
598
+ else if (d.choose) { const r = await act({ type: 'choose', choice: d.choose, label: d.label }, true); if (!r?.error) toast(`${P.agent?.listening ? 'Sent to your agent' : 'Kept for your agent'}: ${d.label}`, 2800); lookShown = null; if (view === 'look') showLook(null); }
599
+ else if (d.none) { const t = document.querySelector(`[data-nonetext="${d.none}"]`)?.value.trim() ?? ''; await act({ type: 'choose', choice: d.none, none: true, text: t }, true); toast('Your agent will look for others', 2800); }
428
600
  else if (d.invSet) act({ type: 'inv-status', id: d.invSet, value: d.v }, true);
429
601
  else if (d.invMust) act({ type: 'inv-must', id: d.invMust, value: !!d.v }, true);
430
602
  else if ('addFact' in d) { const k = $('addKind').value, t = $('addText').value.trim(); if (!t) return toast('Write it first'); const m = t.match(/^([\d.,]+%?)\s+(.+)$/); const fields = k === 'line' ? { text: t } : k === 'figure' && m ? { value: m[1], label: m[2] } : k === 'share' && m ? { value: m[1].replace('%', ''), label: m[2] } : { text: t, label: t }; const r = await act({ type: 'inv-add', kind: k, fields }, true); if (!r.error) { $('addText').value = ''; toast('Added'); } }
431
603
  else if (d.addat !== undefined) openSheet('add', Number(d.addat));
432
- else if (d.addblock) { closeSheet(); const r = await act({ type: 'v-add', at: sheetOpenAt, block: d.addblock }); if (r.part) { sel = r.part; tab = 'words'; render(); } }
604
+ else if (d.addblock) { closeSheet(); const r = await act({ type: 'v-add', at: sheetOpenAt, block: d.addblock }); if (r.part) { sel = r.part; tab = 'text'; render(); } }
433
605
  else if ('swap' in d) openSheet('swap');
434
606
  else if (d.swapto) { closeSheet(); const r = await vact('swap', { block: d.swapto }); if (r.part) { sel = r.part; render(); } }
435
607
  else if ('dup' in d) { const r = await vact('duplicate'); if (r.part) { sel = r.part; render(); } }
436
608
  else if ('remove' in d) { const i = P.video.parts.findIndex((x) => x.dir === sel); const r = await vact('remove'); if (!r.error) { sel = P.video.parts[Math.max(0, i - 1)]?.dir; render(); toast('Removed. Undo brings it back.'); } }
437
609
  else if (d.joinof) openSheet('join', d.joinof);
610
+ else if (d.addto !== undefined) { view = 'video'; render(); scrollTo({ top: 0 }); openSheet('add', P.video.parts.length); }
611
+ else if (d.joinp) act({ type: 'v-join', part: sel, value: d.joinp });
612
+ else if ('joinallp' in d) { const p = cur(); act({ type: 'v-join', part: p.dir, value: p.join, all: true }); }
438
613
  else if (d.setjoin) { const at = sheetOpen.arg; closeSheet(); act({ type: 'v-join', part: at, value: d.setjoin }); }
439
614
  else if ('joinall' in d) { const p = P.video.parts.find((x) => x.dir === sheetOpen.arg); closeSheet(); act({ type: 'v-join', part: p.dir, value: p.join, all: true }); }
440
615
  else if (d.motion) vact('motion', { value: d.motion });
@@ -464,7 +639,8 @@ function playAll() {
464
639
  }
465
640
  let onEnded = null;
466
641
  addEventListener('message', (e) => { if (e.data?.pm === 'ended' && onEnded && e.source === $('player')?.querySelector('iframe')?.contentWindow) onEnded(); });
467
- document.addEventListener('change', (e) => {
642
+ document.addEventListener('change', async (e) => {
643
+ if (e.target.dataset?.lcol) { await act({ type: 'style-color', key: e.target.dataset.lcol, value: e.target.value }); lookShown = null; showLook(null); return; }
468
644
  const d = e.target.dataset;
469
645
  if (d.word !== undefined) vact('word', { key: d.word, value: e.target.value });
470
646
  else if (d.tune) vact('tune', { key: d.tune, value: e.target.value });
@@ -493,6 +669,9 @@ document.addEventListener('keydown', (e) => {
493
669
  if ((e.metaKey || e.ctrlKey) && e.key === 'z' && !e.target.closest('input, textarea, [contenteditable]') && P?.undo) { e.preventDefault(); act({ type: 'undo' }); }
494
670
  });
495
671
  // samples play while the pointer is on them: a block's clip, or a background's shader running live
672
+ document.addEventListener('mouseover', (e) => {
673
+ if (view === 'look') { const k = e.target.closest?.('[data-lk]'); if (k) hoverLook(JSON.parse(k.dataset.lk)); else if (lookHover && !e.target.closest?.('#lookPlayer')) hoverLook(null); }
674
+ });
496
675
  document.addEventListener('mouseover', (e) => {
497
676
  const c = e.target.closest?.('[data-clip]'); const ph = c?.querySelector('.ph, .th');
498
677
  if (ph && c.dataset.clip && !ph.querySelector('video')) { const v = document.createElement('video'); Object.assign(v, { src: c.dataset.clip, muted: true, loop: true, autoplay: true, playsInline: true }); ph.append(v); }
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file