paper-motion 0.2.7 → 0.4.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 (62) hide show
  1. package/CHANGELOG.md +119 -0
  2. package/bin/pamo.mjs +332 -10
  3. package/examples/breath/stills/contact-sheet.jpg +0 -0
  4. package/examples/breath/stills/t000.00.png +0 -0
  5. package/examples/breath/stills/t002.00.png +0 -0
  6. package/examples/breath/stills/t004.00.png +0 -0
  7. package/examples/breath/stills/t006.00.png +0 -0
  8. package/examples/breath/stills/t008.00.png +0 -0
  9. package/examples/breath/stills/t010.00.png +0 -0
  10. package/examples/chrome-endcard/stills/contact-sheet.jpg +0 -0
  11. package/examples/chrome-endcard/stills/t000.00.png +0 -0
  12. package/examples/chrome-endcard/stills/t001.33.png +0 -0
  13. package/examples/chrome-endcard/stills/t002.67.png +0 -0
  14. package/examples/chrome-endcard/stills/t004.00.png +0 -0
  15. package/examples/chrome-endcard/stills/t005.33.png +0 -0
  16. package/examples/chrome-endcard/stills/t006.67.png +0 -0
  17. package/examples/halo-explainer/stills/contact-sheet.jpg +0 -0
  18. package/examples/halo-explainer/stills/t000.00.png +0 -0
  19. package/examples/halo-explainer/stills/t006.00.png +0 -0
  20. package/examples/halo-explainer/stills/t012.00.png +0 -0
  21. package/examples/halo-explainer/stills/t018.00.png +0 -0
  22. package/examples/halo-explainer/stills/t024.00.png +0 -0
  23. package/examples/halo-explainer/stills/t030.00.png +0 -0
  24. package/examples/install-loop/stills/contact-sheet.jpg +0 -0
  25. package/examples/install-loop/stills/t000.00.png +0 -0
  26. package/examples/install-loop/stills/t001.00.png +0 -0
  27. package/examples/install-loop/stills/t002.00.png +0 -0
  28. package/examples/install-loop/stills/t003.00.png +0 -0
  29. package/examples/install-loop/stills/t004.00.png +0 -0
  30. package/examples/install-loop/stills/t005.00.png +0 -0
  31. package/examples/orbit-keynote/stills/contact-sheet.jpg +0 -0
  32. package/examples/orbit-keynote/stills/t000.00.png +0 -0
  33. package/examples/orbit-keynote/stills/t006.50.png +0 -0
  34. package/examples/orbit-keynote/stills/t013.00.png +0 -0
  35. package/examples/orbit-keynote/stills/t019.50.png +0 -0
  36. package/examples/orbit-keynote/stills/t026.00.png +0 -0
  37. package/examples/orbit-keynote/stills/t032.50.png +0 -0
  38. package/examples/signal/stills/contact-sheet.jpg +0 -0
  39. package/examples/signal/stills/t000.00.png +0 -0
  40. package/examples/signal/stills/t001.67.png +0 -0
  41. package/examples/signal/stills/t003.33.png +0 -0
  42. package/examples/signal/stills/t005.00.png +0 -0
  43. package/examples/signal/stills/t006.67.png +0 -0
  44. package/examples/signal/stills/t008.33.png +0 -0
  45. package/lib/chain.mjs +366 -0
  46. package/lib/customize.mjs +40 -2
  47. package/lib/edit.mjs +326 -0
  48. package/lib/gather.mjs +241 -0
  49. package/lib/host.mjs +25 -0
  50. package/lib/inventory.mjs +523 -0
  51. package/lib/live-board.js +3 -1
  52. package/lib/piece.mjs +223 -0
  53. package/lib/registry.mjs +45 -11
  54. package/lib/studio.mjs +572 -0
  55. package/package.json +2 -1
  56. package/runtime/brand.js +1 -1
  57. package/runtime/index.js +1 -0
  58. package/runtime/player.js +6 -3
  59. package/runtime/swatch.html +21 -0
  60. package/studio/fonts/GeistPixel-Square.woff2 +0 -0
  61. package/studio/fonts/GeistSerif-Regular.woff2 +0 -0
  62. package/studio/index.html +503 -0
package/runtime/player.js CHANGED
@@ -1,9 +1,11 @@
1
1
  // Preview player: real-time playback, scrubbing and frame stepping.
2
- // Only mounted in preview (never in renders).
2
+ // Only mounted in preview (never in renders). With ?embed in the address it plays and loops with no controls,
3
+ // for a scene shown inside another page (the studio's live previews).
3
4
  // space play/pause · ←/→ one frame · shift+←/→ one second · home start · L loop · H hide UI
4
5
 
5
6
  export function mountPlayer(scene, startT = 0) {
6
7
  const { stage, duration, fps } = scene;
8
+ const embed = new URLSearchParams(location.search).has('embed');
7
9
  let t = startT, playing = true, loop = true, last = performance.now(), busy = false;
8
10
 
9
11
  const bar = document.createElement('div');
@@ -25,10 +27,11 @@ export function mountPlayer(scene, startT = 0) {
25
27
  const range = bar.querySelector('input');
26
28
  const label = bar.querySelector('.pm-time');
27
29
  loopBtn.classList.toggle('on', loop);
30
+ if (embed) bar.style.display = 'none';
28
31
 
29
32
  function fit() {
30
33
  const avail = { w: innerWidth, h: innerHeight - (bar.style.display === 'none' ? 0 : 44) };
31
- const s = Math.min(avail.w / scene.width, avail.h / scene.height, 1);
34
+ const s = Math.min(avail.w / scene.width, avail.h / scene.height, embed ? Infinity : 1);
32
35
  stage.style.transform = `translate(${(avail.w - scene.width * s) / 2}px, ${(avail.h - scene.height * s) / 2}px) scale(${s})`;
33
36
  }
34
37
  addEventListener('resize', fit); fit();
@@ -37,7 +40,7 @@ export function mountPlayer(scene, startT = 0) {
37
40
  t = Math.max(0, Math.min(duration, nt));
38
41
  range.value = t;
39
42
  label.textContent = `${t.toFixed(2)}s · f${String(Math.round(t * fps)).padStart(4, '0')} / ${Math.round(duration * fps)}`;
40
- try { sessionStorage.setItem(`pm:t:${location.pathname}`, String(t)); } catch { /* storage blocked */ }
43
+ if (!embed) { try { sessionStorage.setItem(`pm:t:${location.pathname}`, String(t)); } catch { /* storage blocked */ } }
41
44
  busy = true; await scene.seek(t); busy = false;
42
45
  }
43
46
  function setPlaying(p) { playing = p; playBtn.textContent = p ? '❚❚' : '▶'; last = performance.now(); }
@@ -0,0 +1,21 @@
1
+ <!doctype html>
2
+ <html>
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <title>swatch</title>
6
+ <style>html, body { margin: 0; height: 100%; background: #000; overflow: hidden; } #s { position: absolute; inset: 0; }</style>
7
+ </head>
8
+ <body>
9
+ <div id="s"></div>
10
+ <script type="module">
11
+ // One background playing live, for the studio's small demos: the entry (shader, props, background) rides in the hash.
12
+ import { live } from 'paper-motion';
13
+ try {
14
+ const e = JSON.parse(decodeURIComponent(location.hash.slice(1)));
15
+ const el = document.getElementById('s');
16
+ if (e.background) el.style.background = e.background;
17
+ await live(el, e.shader, e.props ?? {});
18
+ } catch (err) { document.body.style.background = '#111'; console.warn(err); }
19
+ </script>
20
+ </body>
21
+ </html>
@@ -0,0 +1,503 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>paper-motion studio</title>
7
+ <!--
8
+ pamo studio: one piece in three steps. What it says (the brief and the content the user keeps), Your video (every
9
+ part playing live, edited in place: words, how it arrives, background, settings, length, transitions, order, adding,
10
+ swapping, removing, and the whole video's style) and Export. Reads /api/piece, writes edits back; the files stay the
11
+ source of truth and the agent reads every change as one line. paper-motion's own look: black ground, white figure,
12
+ one warm paper accent, hard edges, 1px rules.
13
+ -->
14
+ <link rel="preconnect" href="https://fonts.googleapis.com">
15
+ <link href="https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600&family=Geist+Mono:wght@400;500&display=swap" rel="stylesheet">
16
+ <style>
17
+ @font-face { font-family: 'Geist Serif'; src: url('/fonts/GeistSerif-Regular.woff2') format('woff2'); font-display: swap; }
18
+ @font-face { font-family: 'Geist Pixel'; src: url('/fonts/GeistPixel-Square.woff2') format('woff2'); font-display: swap; }
19
+ :root { --ground: #000; --surface: #0b0b0b; --raised: #141414; --line: #1f1f1f; --rule: #3a3a3a; --figure: #fff; --muted: #8a8a8a; --faint: #4a4a4a; --paper: #e9e4d8; --ok: #3ddc84; --warn: #ff4d2e;
20
+ --serif: 'Geist Serif', Georgia, serif; --sans: 'Geist', system-ui, sans-serif; --mono: 'Geist Mono', ui-monospace, monospace; --pixel: 'Geist Pixel', 'Geist Mono', monospace; }
21
+ * { box-sizing: border-box; border-radius: 0; }
22
+ html { background: var(--ground); color-scheme: dark; }
23
+ body { margin: 0; background: var(--ground); color: var(--figure); font: 14px/1.45 var(--sans); }
24
+ ::selection { background: var(--paper); color: #000; }
25
+ button, input, select, textarea { font: inherit; color: inherit; }
26
+ button { cursor: pointer; } a { color: inherit; }
27
+ :focus-visible { outline: 2px solid var(--paper); outline-offset: 2px; }
28
+ .muted { color: var(--muted); } .mono { font-family: var(--mono); } .serif { font-family: var(--serif); font-weight: 400; }
29
+ .ico { width: 15px; height: 15px; stroke: currentColor; fill: none; stroke-width: 1.6; stroke-linecap: square; flex: none; }
30
+ .btn { display: inline-flex; align-items: center; justify-content: center; gap: 7px; border: 1px solid var(--rule); background: transparent; padding: 8px 13px; font-size: 13px; white-space: nowrap; text-decoration: none; }
31
+ .btn:hover { border-color: var(--paper); }
32
+ .btn.primary { background: var(--paper); color: #000; border-color: var(--paper); }
33
+ .btn.primary:hover { background: #fff; border-color: #fff; }
34
+ .btn.sm { padding: 6px 10px; font-size: 12.5px; }
35
+ .btn.on { background: var(--paper); color: #000; border-color: var(--paper); }
36
+ .btn:disabled { opacity: .35; cursor: default; }
37
+ .hint { font-size: 12.5px; color: var(--muted); margin: 0; }
38
+ .lbl { font: 500 11px/1 var(--mono); letter-spacing: .06em; text-transform: uppercase; color: var(--muted); }
39
+
40
+ header.top { display: flex; align-items: center; gap: 14px; height: 56px; padding: 0 18px; border-bottom: 1px solid var(--line); position: sticky; top: 0; z-index: 30; background: var(--ground); }
41
+ .back { display: flex; align-items: center; gap: 6px; color: var(--muted); text-decoration: none; font-size: 13px; }
42
+ .ttl { font: 400 21px/1 var(--serif); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 26vw; }
43
+ .steps { display: flex; margin: 0 auto; border: 1px solid var(--rule); }
44
+ .steps button { background: none; border: 0; padding: 9px 16px; display: flex; gap: 8px; align-items: center; color: var(--muted); }
45
+ .steps button + button { border-left: 1px solid var(--rule); }
46
+ .steps button b { font: 400 15px/1 var(--pixel); }
47
+ .steps button.on { background: var(--paper); color: #000; }
48
+ .steps button i { width: 7px; height: 7px; background: var(--ok); display: inline-block; }
49
+ .turn { display: flex; align-items: center; gap: 12px; min-height: 40px; padding: 6px 18px; border-bottom: 1px solid var(--line); font-size: 13px; }
50
+ .turn .mk { width: 8px; height: 8px; background: var(--paper); flex: none; }
51
+ .turn.agent .mk { background: var(--muted); animation: pulse 1.4s infinite; } .turn.off .mk, .turn.deaf .mk { background: var(--warn); }
52
+ @keyframes pulse { 50% { opacity: .3; } }
53
+ .turn b { font-weight: 500; margin-right: 8px; } .turn .tx { color: var(--muted); }
54
+ .turn .grow { flex: 1; }
55
+ main { min-height: calc(100vh - 97px); }
56
+
57
+ /* 1 · what it says */
58
+ .what { max-width: 900px; margin: 0 auto; padding: 36px 20px 90px; }
59
+ .what h1 { font: 400 46px/1.05 var(--serif); margin: 0; }
60
+ .what .sub { margin: 10px 0 26px; color: var(--muted); font-size: 15px; max-width: 62ch; }
61
+ .brief { border: 1px solid var(--line); margin-bottom: 30px; }
62
+ .brief summary { cursor: pointer; padding: 12px 14px; display: flex; justify-content: space-between; gap: 10px; list-style: none; }
63
+ .brief summary::-webkit-details-marker { display: none; }
64
+ .brief .rows { padding: 0 14px 14px; display: grid; gap: 10px; }
65
+ .brief .row { display: grid; grid-template-columns: 120px 1fr; gap: 12px; align-items: baseline; }
66
+ .edit { padding: 5px 8px; margin: -5px -8px; outline: 1px dashed transparent; cursor: text; min-height: 1.4em; }
67
+ .edit:hover { outline-color: var(--rule); } .edit:focus { outline: 1px solid var(--paper); background: var(--surface); }
68
+ .edit:empty::before { content: attr(data-placeholder); color: var(--faint); }
69
+ .fact { display: grid; grid-template-columns: 96px 1fr auto; gap: 16px; align-items: center; padding: 14px 0; border-top: 1px solid var(--line); }
70
+ .fact:last-of-type { border-bottom: 1px solid var(--line); }
71
+ .fact img { width: 96px; height: 60px; object-fit: cover; object-position: top; border: 1px solid var(--rule); }
72
+ .fact .k { font: 500 11px/1 var(--mono); letter-spacing: .06em; text-transform: uppercase; color: var(--muted); display: flex; gap: 8px; align-items: center; }
73
+ .fact .k .new { background: var(--paper); color: #000; padding: 3px 5px; }
74
+ .fact .t { font: 400 23px/1.2 var(--serif); margin-top: 6px; overflow-wrap: anywhere; }
75
+ .fact .s { font-size: 12.5px; color: var(--faint); margin-top: 4px; }
76
+ .fact.out { opacity: .38; } .fact.out .t { text-decoration: line-through; }
77
+ .fact .acts { display: flex; gap: 6px; }
78
+ .addfact { margin-top: 16px; display: flex; gap: 8px; }
79
+ .addfact input, .addfact select { background: var(--surface); border: 1px solid var(--rule); padding: 10px 12px; }
80
+ .addfact input { flex: 1; }
81
+ .nextbar { margin-top: 30px; display: flex; justify-content: space-between; align-items: center; gap: 16px; flex-wrap: wrap; }
82
+
83
+ /* 2 · your video */
84
+ .editor { display: grid; grid-template-columns: minmax(0, 1fr) 372px; min-height: calc(100vh - 97px); }
85
+ .center { padding: 20px 24px; display: grid; grid-template-columns: minmax(0, 1fr); gap: 14px; align-content: start; min-width: 0; }
86
+ .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
+ .player.tall { aspect-ratio: 9/16; width: min(100%, calc(56vh * 9 / 16)); } .player.sq { aspect-ratio: 1; width: min(100%, 56vh); }
88
+ .player iframe { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; }
89
+ .player .badge { position: absolute; left: 10px; top: 10px; font: 500 11px/1 var(--mono); letter-spacing: .06em; text-transform: uppercase; background: rgba(0,0,0,.65); border: 1px solid rgba(255,255,255,.2); padding: 5px 7px; z-index: 2; }
90
+ .player .msg { position: absolute; inset: 0; display: grid; place-items: center; text-align: center; padding: 20px; color: var(--muted); font: 13px var(--mono); }
91
+ .pbar { display: flex; align-items: center; gap: 10px; color: var(--muted); font-size: 12.5px; flex-wrap: wrap; }
92
+ .pbar .grow { flex: 1; }
93
+ .track { display: flex; align-items: stretch; overflow-x: auto; padding: 6px 2px 12px; }
94
+ .part { flex: none; width: 170px; border: 1px solid var(--line); background: var(--surface); cursor: pointer; position: relative; }
95
+ .part:hover { border-color: var(--rule); } .part.sel { border-color: var(--paper); box-shadow: 0 0 0 1px var(--paper); }
96
+ .part.dragging { opacity: .4; } .part.drop { box-shadow: -3px 0 0 var(--paper); }
97
+ .part .th { aspect-ratio: 16/9; background: #000 center/cover no-repeat; position: relative; overflow: hidden; }
98
+ .part .th video { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
99
+ .part .meta { padding: 7px 9px 9px; }
100
+ .part .nm { font-size: 12.5px; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
101
+ .part .tx { font-size: 11.5px; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
102
+ .part .sec { position: absolute; right: 6px; top: 6px; font: 500 11px/1 var(--mono); background: rgba(0,0,0,.72); padding: 4px 5px; }
103
+ .part .n { position: absolute; left: 6px; top: 6px; font: 400 13px/1 var(--pixel); background: rgba(0,0,0,.72); padding: 3px 5px; }
104
+ .join { flex: none; width: 52px; display: grid; place-items: center; position: relative; }
105
+ .join button { width: 34px; height: 34px; border: 1px solid var(--rule); background: var(--ground); display: grid; place-items: center; padding: 0; }
106
+ .join button:hover { border-color: var(--paper); }
107
+ .join small { position: absolute; bottom: 2px; left: 0; right: 0; text-align: center; font: 10px/1.1 var(--mono); color: var(--muted); }
108
+ .addp { flex: none; width: 40px; display: grid; place-items: center; }
109
+ .addp button { width: 30px; height: 100%; min-height: 120px; border: 1px dashed var(--rule); background: none; display: grid; place-items: center; }
110
+ .addp button:hover { border-color: var(--paper); }
111
+ .insp { border-left: 1px solid var(--line); padding: 18px 20px 60px; overflow-y: auto; max-height: calc(100vh - 97px); position: sticky; top: 97px; }
112
+ .insp h2 { margin: 6px 0 0; font: 400 26px/1.1 var(--serif); }
113
+ .insp .sum { margin-top: 6px; }
114
+ .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); }
116
+ .tabs button + button { border-left: 1px solid var(--rule); }
117
+ .tabs button.on { background: var(--paper); color: #000; }
118
+ .pane { display: grid; gap: 14px; }
119
+ .fld { display: grid; gap: 6px; font-size: 12.5px; color: var(--muted); }
120
+ .fld i { font-style: normal; color: var(--faint); }
121
+ .fld input, .fld textarea, .fld select { background: var(--surface); border: 1px solid var(--rule); padding: 9px 10px; font-size: 14px; color: var(--figure); width: 100%; }
122
+ .fld textarea { resize: vertical; min-height: 64px; } .fld textarea.code { font: 13px/1.5 var(--mono); }
123
+ .fld input:focus, .fld textarea:focus, .fld select:focus { outline: 0; border-color: var(--paper); }
124
+ .pick img { width: 100%; aspect-ratio: 16/9; object-fit: cover; object-position: top; border: 1px solid var(--rule); }
125
+ .tiles { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
126
+ .tile { border: 1px solid var(--line); background: none; padding: 0; text-align: left; }
127
+ .tile:hover { border-color: var(--rule); } .tile.on { border-color: var(--paper); box-shadow: 0 0 0 1px var(--paper); }
128
+ .ph { aspect-ratio: 16/9; background: #000 center/cover no-repeat; position: relative; overflow: hidden; }
129
+ .ph video, .ph iframe { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; border: 0; }
130
+ .ph .bars { position: absolute; inset: 0; display: flex; } .ph .bars i { flex: 1; }
131
+ .tile .lb { padding: 7px 8px 8px; font-size: 12.5px; }
132
+ .tile .lb small { display: block; color: var(--muted); font-size: 11.5px; line-height: 1.3; margin-top: 2px; }
133
+ .slider { display: grid; gap: 6px; font-size: 12.5px; color: var(--muted); }
134
+ .slider div { display: flex; justify-content: space-between; gap: 10px; } .slider em { font-style: normal; color: var(--figure); font-family: var(--mono); }
135
+ .slider input { accent-color: var(--paper); width: 100%; }
136
+ .seg { display: flex; flex-wrap: wrap; } .seg .btn { flex: 1; } .seg .btn + .btn { border-left: 0; }
137
+ .partacts { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 18px; padding-top: 16px; border-top: 1px solid var(--line); }
138
+ .empty { padding: 60px 20px; text-align: center; color: var(--muted); }
139
+
140
+ /* sheets */
141
+ .scrim { position: fixed; inset: 0; background: rgba(0,0,0,.62); z-index: 50; display: none; }
142
+ .scrim.on { display: block; }
143
+ .sheet { position: fixed; top: 0; right: 0; bottom: 0; width: min(760px, 100vw); background: var(--ground); border-left: 1px solid var(--paper); z-index: 51; overflow-y: auto; padding: 0 24px 40px; transform: translateX(102%); transition: transform .26s cubic-bezier(.2,.8,.2,1); }
144
+ .sheet.on { transform: none; }
145
+ .sheet header { position: sticky; top: 0; background: var(--ground); padding: 20px 0 14px; border-bottom: 1px solid var(--rule); display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; z-index: 2; }
146
+ .sheet header h2 { margin: 4px 0 0; font: 400 28px/1.1 var(--serif); }
147
+ .sheet .grp { margin-top: 22px; }
148
+ .sheet .grp h3 { margin: 0 0 10px; }
149
+ .sheet .tiles { grid-template-columns: repeat(3, 1fr); }
150
+ .pals { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
151
+ .pal { display: flex; align-items: center; gap: 4px; padding: 8px; border: 1px solid var(--line); background: none; width: 100%; text-align: left; }
152
+ .pal:hover { border-color: var(--rule); } .pal.on { border-color: var(--paper); }
153
+ .pal i { width: 20px; height: 20px; display: block; border: 1px solid rgba(255,255,255,.15); }
154
+ .pal span { margin-left: 8px; font-size: 13px; }
155
+ .colors { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; }
156
+ .colors label { display: grid; gap: 6px; font-size: 12.5px; color: var(--muted); }
157
+ .colors input { width: 100%; height: 38px; padding: 0; border: 1px solid var(--rule); background: none; }
158
+ .fonts { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
159
+ .font { padding: 10px 12px; border: 1px solid var(--line); background: none; text-align: left; font-size: 14px; }
160
+ .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; }
164
+ #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
+ #toast.on { opacity: 1; transform: translateX(-50%); }
166
+
167
+ /* 3 · export */
168
+ .exp { max-width: 980px; margin: 0 auto; padding: 32px 20px 80px; display: grid; gap: 22px; }
169
+ .exp h1 { font: 400 42px/1.05 var(--serif); margin: 0; }
170
+ .file { display: flex; justify-content: space-between; align-items: center; gap: 12px; padding: 12px 14px; border: 1px solid var(--line); }
171
+ .exp video { width: 100%; background: #000; border: 1px solid var(--line); }
172
+ .list { max-width: 760px; margin: 0 auto; padding: 40px 20px; }
173
+ .list h1 { font: 400 42px/1 var(--serif); margin: 0 0 20px; }
174
+ .lrow { display: flex; justify-content: space-between; gap: 16px; padding: 14px 0; border-bottom: 1px solid var(--line); text-decoration: none; }
175
+ @media (max-width: 900px) { .editor { grid-template-columns: 1fr; } .insp { position: static; max-height: none; border-left: 0; border-top: 1px solid var(--line); } .ttl { display: none; } .sheet .tiles { grid-template-columns: 1fr 1fr; } }
176
+ </style>
177
+ </head>
178
+ <body>
179
+ <div id="app"></div>
180
+ <div class="scrim" id="scrim"></div>
181
+ <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>
185
+ <div id="toast" role="status"></div>
186
+ <script>
187
+ // ─── state, helpers ────────────────────────────────────────────────────────────────────────────────────────────────
188
+ 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;
190
+ const esc = (s) => String(s ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
191
+ const $ = (id) => document.getElementById(id);
192
+ const I = {
193
+ undo: '<path d="M7 5 3 9l4 4M3 9h9a5 5 0 0 1 0 10H9"/>', style: '<circle cx="10" cy="10" r="7"/><path d="M10 3v14M3 10h7"/>', ask: '<path d="M3 4h14v10H8l-4 3v-3H3z"/>',
194
+ add: '<path d="M10 4v12M4 10h12"/>', join: '<path d="M3 10h14M12 5l5 5-5 5"/>', back: '<path d="M12 4 6 10l6 6"/>', play: '<path d="M6 4l10 6-10 6z"/>', out: '<path d="M8 4H4v12h12v-4M11 3h6v6M17 3l-8 8"/>',
195
+ };
196
+ const icon = (n) => `<svg class="ico" viewBox="0 0 20 20">${I[n]}</svg>`;
197
+ 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' };
199
+ 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
+ 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
+
202
+ async function load() {
203
+ if (!piece) {
204
+ const list = await (await fetch('/api/pieces')).json();
205
+ if (list.length === 1) { piece = list[0].piece; history.replaceState(null, '', `?p=${encodeURIComponent(piece)}`); }
206
+ else return renderList(list);
207
+ }
208
+ const res = await fetch(`/api/piece?p=${encodeURIComponent(piece)}`);
209
+ if (!res.ok) { $('app').innerHTML = `<div class="empty">There is no piece “${esc(piece)}” in this project.</div>`; return; }
210
+ const text = await res.text();
211
+ if (text === raw) return;
212
+ raw = text; P = JSON.parse(text);
213
+ if (!view) view = STEP[P.stage] ?? 'what';
214
+ render();
215
+ }
216
+ async function act(body, quiet) {
217
+ const res = await fetch(`/api/piece?p=${encodeURIComponent(piece)}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
218
+ const r = await res.json();
219
+ if (r.error) toast(`Not done: ${r.error}`, 3200); else if (!quiet) toast(r.say ?? 'Saved');
220
+ raw = ''; await load();
221
+ return r;
222
+ }
223
+ function renderList(list) {
224
+ $('app').innerHTML = `<header class="top"><div class="ttl">paper-motion studio</div></header><div class="list"><h1>Pieces</h1>
225
+ ${list.length ? list.map((x) => `<a class="lrow" href="?p=${encodeURIComponent(x.piece)}"><b>${esc(x.title)}</b><span class="muted mono">pieces/${esc(x.piece)}</span></a>`).join('') : '<p class="muted">No pieces yet. Ask your agent for a video.</p>'}</div>`;
226
+ }
227
+
228
+ // ─── the frame: steps, whose turn it is ───────────────────────────────────────────────────────────────────────────
229
+ function turn() {
230
+ 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
+ if (P.building) return ['agent', 'Building your video', 'From what you kept. A few seconds.'];
233
+ 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.'];
238
+ }
239
+ function frame(body) {
240
+ 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`;
243
+ 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>
245
+ <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>
250
+ <main>${body}</main>`;
251
+ }
252
+
253
+ // ─── 1 · what it says ─────────────────────────────────────────────────────────────────────────────────────────────
254
+ const BRIEF = [['what', 'What'], ['who', 'For whom'], ['format', 'Format'], ['tone', 'Tone'], ['action', 'At the end']];
255
+ function whatHTML() {
256
+ const inv = P.inventory ?? { items: [] }, items = inv.items.filter((i) => i.status !== 'removed' || true);
257
+ const wait = items.filter((i) => i.status === 'proposed').length, kept = items.filter((i) => i.status === 'kept').length;
258
+ const briefOpen = P.stage === 'brief' || P.stages.brief?.status !== 'approved';
259
+ // what can be corrected right in the row: a sentence, a command, a number and what it counts
260
+ const EDITS = { line: ['text'], command: ['command'], figure: ['value', 'label'], share: ['value', 'label'], change: ['from', 'to', 'label'], screen: ['caption'] };
261
+ const inline = (i) => (EDITS[i.kind] ?? []).map((k) => i.fields?.find((f) => f[0] === k)).filter(Boolean);
262
+ const sentence = (i) => inline(i).length > 0;
263
+ const rows = items.map((i) => `<div class="fact${i.status === 'removed' ? ' out' : ''}">
264
+ ${i.thumb ? `<img src="${esc(i.thumb)}" alt="">` : '<span></span>'}
265
+ <div><div class="k">${esc(i.name)}${i.status === 'proposed' ? '<span class="new">To decide</span>' : ''}${i.must ? '<span class="new">Must appear</span>' : ''}</div>
266
+ ${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
+ <div class="s">From ${esc(i.source || 'you')}${i.missing?.length ? ` · still needs ${esc(i.missing.join(', ').toLowerCase())}` : ''}</div></div>
268
+ <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('');
270
+ const atInv = ['brief', 'inventory'].includes(P.stage), hasVideo = !!P.video;
271
+ return `<div class="what">
272
+ <h1>What your video says</h1>
273
+ <p class="sub">Your agent read what you already have and found these. Keep what is true, star what must appear, click a sentence to change it. How it looks is the next step.</p>
274
+ <details class="brief"${briefOpen ? ' open' : ''}><summary><span><span class="lbl">The brief</span>&nbsp;&nbsp;${esc(P.brief.what)}</span><span class="muted">${P.stages.brief?.status === 'approved' ? 'approved' : 'to check'}</span></summary>
275
+ <div class="rows">${BRIEF.map(([k, l]) => `<div class="row"><span class="muted">${l}</span><div class="edit" contenteditable="plaintext-only" spellcheck="false" data-brief="${k}" data-placeholder="Empty">${esc(P.brief[k])}</div></div>`).join('')}
276
+ ${P.stage === 'brief' && P.stages.brief?.status === 'review' ? `<div><button class="btn primary sm" data-approve="brief">The brief is right</button></div>` : ''}</div></details>
277
+ ${items.length ? rows : `<p class="muted">${P.stage === 'brief' ? 'Once the brief is right, your agent brings what your video could say.' : 'Your agent is looking for what your video could say.'}</p>`}
278
+ ${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
+ <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
+ ${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>`
282
+ : P.building ? '<span class="muted">Building your video…</span>' : ''}</div></div>`;
283
+ }
284
+
285
+ // ─── 2 · your video ───────────────────────────────────────────────────────────────────────────────────────────────
286
+ const cur = () => P.video?.parts.find((p) => p.dir === sel) ?? P.video?.parts[0];
287
+ 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; };
289
+ function videoHTML() {
290
+ 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>`;
292
+ const p = cur(); sel = p?.dir;
293
+ const shape = V.format === '1080x1920' ? ' tall' : V.format === '1080x1080' ? ' sq' : '';
294
+ return `<div class="editor"><div class="center">
295
+ <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>
297
+ <button class="btn sm" data-playall>${icon('play')}${playing ? 'Stop' : 'Play the whole video'}</button>
298
+ ${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>
306
+ </div><aside class="insp" id="insp">${p ? inspHTML(p) : ''}</aside></div>`;
307
+ }
308
+ function inspHTML(p) {
309
+ const n = P.video.parts.indexOf(p) + 1;
310
+ const fld = (f) => {
311
+ const a = `data-word="${esc(f.key)}"`;
312
+ if (f.kind === 'image') { const imgs = P.style?.images ?? []; return `<div class="fld">${esc(f.label)}<div class="pick"><img src="/files/${esc(String(f.value).replace(/^\//, ''))}" alt=""></div>${imgs.length > 1 ? `<select ${a}>${imgs.map((m) => `<option value="${esc(m)}"${m === f.value ? ' selected' : ''}>${esc(m.split('/').pop())}</option>`).join('')}</select>` : ''}<p class="hint">Another picture? Put it in brand/assets, or ask your agent.</p></div>`; }
313
+ if (f.kind === 'rows') return `<label class="fld">${esc(f.label)} <i>one per line${f.columns > 1 ? ', as value | what it is' : ''}</i><textarea ${a} rows="${Math.min(8, String(f.value).split('\n').length + 1)}">${esc(f.value)}</textarea></label>`;
314
+ if (f.kind === 'lines') return `<label class="fld">${esc(f.label)} <i>one per line</i><textarea ${a} rows="${Math.min(8, String(f.value).split('\n').length + 1)}">${esc(f.value)}</textarea></label>`;
315
+ if (f.kind === 'block') return `<label class="fld">${esc(f.label)}<textarea class="code" ${a} rows="7">${esc(f.value)}</textarea></label>`;
316
+ if (String(f.value).length > 44) return `<label class="fld">${esc(f.label)}<textarea ${a} rows="2">${esc(f.value)}</textarea></label>`;
317
+ return `<label class="fld">${esc(f.label)}<input ${a} value="${esc(f.value)}"></label>`;
318
+ };
319
+ const B = P.inventory?.backgrounds ?? { mine: [], library: [] };
320
+ 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>`,
324
+ 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
+ <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="1.5" max="${Math.max(10, Math.ceil((p.duration ?? 6) * 1.6))}" step="0.1" value="${p.seconds}" data-seconds></div>
330
+ ${p.duration && p.seconds < p.duration * 0.7 ? '<p class="hint" style="color:var(--warn)">Shorter than this part needs to land: it may be cut.</p>' : ''}`,
331
+ };
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>
334
+ <div class="pane">${panes[tab]}</div>
335
+ <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
+ }
337
+ // the player: the part's real scene, playing live; kept across redraws, reloaded only when the part changed
338
+ function paintPlayer() {
339
+ const box = $('player'), p = cur(); if (!box || !p) return;
340
+ $('pbadge').textContent = `${String(P.video.parts.indexOf(p) + 1).padStart(2, '0')} · ${p.title}`;
341
+ const info = [p.motion ? p.motion.demos.find((d) => d.value === p.motion.value)?.title : null, p.background ? `${p.background} background` : null, `${Number(p.seconds).toFixed(1)} s`].filter(Boolean).join(' · ');
342
+ $('pinfo').textContent = info;
343
+ let f = box.querySelector('iframe');
344
+ if (!p.live) { if (!box.querySelector('.msg')) box.insertAdjacentHTML('beforeend', '<div class="msg">The live preview is starting…</div>'); return; }
345
+ box.querySelector('.msg')?.remove();
346
+ if (!f) { f = document.createElement('iframe'); f.title = 'Live preview'; box.append(f); }
347
+ if (f.dataset.src !== p.live) { f.dataset.src = p.live; f.src = p.live; }
348
+ }
349
+
350
+ // ─── 3 · export ───────────────────────────────────────────────────────────────────────────────────────────────────
351
+ function exportHTML() {
352
+ const M = P.media ?? {}, out = (M.outputs ?? []).filter((o) => /\.(mp4|mov|webm|gif)$/i.test(o.file));
353
+ const stale = P.stages.final?.status === 'changes';
354
+ return `<div class="exp"><h1>Export</h1>
355
+ ${!P.video ? '<p class="muted">Your video comes first.</p>' : `
356
+ <p class="muted">${P.video.parts.length} parts, ${P.video.total} s, ${esc(P.video.format)}. Full quality, with motion blur and your sound.${stale ? ' <b style="color:var(--warn)">You changed the video since the last render.</b>' : ''}</p>
357
+ <div>${P.rendering ? `<button class="btn" disabled>Rendering… ${P.rendering.joining ? 'joining' : P.rendering.part ? `part ${P.rendering.part} of ${P.rendering.of}` : ''}</button>` : `<button class="btn primary" data-render>${out.length ? 'Render again' : 'Render in full quality'}</button>`} <span class="muted" style="margin-left:10px">a minute or two</span></div>
358
+ ${M.final ? `<video src="${esc(M.final)}" controls playsinline></video>` : ''}
359
+ <div style="display:grid;gap:8px">${out.map((o) => `<div class="file"><span>${esc(o.file)} <span class="muted">· ${(o.bytes / 1e6).toFixed(1)} MB</span></span><a class="btn sm" href="${esc(o.url)}" download>Download</a></div>`).join('')}</div>
360
+ ${P.stage === 'final' && P.stages.final?.status === 'review' ? `<div><button class="btn" data-approve="final">This is the one</button></div>` : ''}`}</div>`;
361
+ }
362
+
363
+ // ─── drawing ──────────────────────────────────────────────────────────────────────────────────────────────────────
364
+ function render() {
365
+ if (!P) return;
366
+ // a field being typed in is never redrawn under the cursor: the page catches up when it loses focus
367
+ const a = document.activeElement;
368
+ if (a && a.closest?.('#app') && (a.matches('input, textarea, select') || a.isContentEditable)) { pending = true; return; }
369
+ 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 };
373
+ $('app').innerHTML = frame(body);
374
+ if (keepPlayer && $('player')) { $('player').append(keepPlayer); }
375
+ paintPlayer();
376
+ if ($('insp')) $('insp').scrollTop = scroll.insp;
377
+ if ($('track')) $('track').scrollLeft = scroll.track;
378
+ if (sheetOpen) paintSheet();
379
+ }
380
+ let pending = false;
381
+
382
+ // ─── sheets: add, show another way, transition, more backgrounds, style ──────────────────────────────────────────
383
+ let sheetOpen = null;
384
+ function openSheet(kind, arg) { sheetOpen = { kind, arg }; paintSheet(); $('sheet').classList.add('on'); $('scrim').classList.add('on'); }
385
+ function closeSheet() { sheetOpen = null; $('sheet').classList.remove('on'); $('scrim').classList.remove('on'); }
386
+ let lastSheet = '';
387
+ function paintSheet() {
388
+ const s = sheetOpen; if (!s || !P) return;
389
+ const key = JSON.stringify([s, P.style, P.video?.parts.map((p) => [p.dir, p.join, p.background, p.id]), P.inventory?.backgrounds?.mine?.length]);
390
+ if (key === lastSheet) return; lastSheet = key;
391
+ 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
+ const L = P.library ?? { intents: [], joins: [] };
393
+ 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('');
395
+ 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
+ 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
+ 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>`; }
398
+ else if (s.kind === 'style') {
399
+ const S = P.style ?? {}, c = S.colors ?? {};
400
+ h = head('Your whole video', 'Style') + `
401
+ <div class="grp"><h3 class="lbl">Colours</h3><div class="pals">${(S.palettes ?? []).map((p) => `<button class="pal" data-palette="${esc(p.id)}">${p.colors.slice(0, 3).map((x) => `<i style="background:${esc(x)}"></i>`).join('')}<span>${esc(p.title)}</span></button>`).join('')}</div>
402
+ <p class="hint" style="margin:14px 0 8px">Or set each one yourself</p>
403
+ <div class="colors">${[['ground', 'Background'], ['figure', 'Text'], ['accent', 'Accent']].map(([k, l]) => `<label>${l}<input type="color" value="${esc(c[k] ?? '#000000')}" data-color="${k}"></label>`).join('')}</div></div>
404
+ <div class="grp"><h3 class="lbl">Type <span class="muted" style="text-transform:none;letter-spacing:0;font-family:var(--sans)">· now ${esc(S.fonts?.display ?? '')} and ${esc(S.fonts?.text ?? '')}</span></h3><div class="fonts">${(S.fontpairs ?? []).map((f) => `<button class="font" data-fontpair="${esc(f.id)}">${esc(f.title)}</button>`).join('')}</div></div>
405
+ <div class="grp"><h3 class="lbl">Pace</h3><div class="seg">${['calm', 'medium', 'fast'].map((x) => `<button class="btn${S.pace === x ? ' on' : ''}" data-pace="${x}">${x[0].toUpperCase() + x.slice(1)}</button>`).join('')}</div></div>
406
+ <p class="hint" style="margin-top:22px">Style is your brand's: it changes every part of this video, and every video after it.</p>`;
407
+ }
408
+ $('sheet').innerHTML = h;
409
+ }
410
+
411
+ // ─── wiring ───────────────────────────────────────────────────────────────────────────────────────────────────────
412
+ const vact = (type, extra = {}) => act({ type: `v-${type}`, part: sel, ...extra });
413
+ document.addEventListener('click', async (e) => {
414
+ if (e.target.id === 'scrim') return closeSheet();
415
+ const b = e.target.closest('button, .part'); if (!b || b.disabled) return; const d = b.dataset;
416
+ if (d.view) { view = d.view; closeSheet(); render(); scrollTo({ top: 0 }); }
417
+ else if ('close' in d) closeSheet();
418
+ else if (d.part !== undefined && !e.target.closest('button')) { sel = d.part; render(); }
419
+ else if (d.tab) { tab = d.tab; render(); }
420
+ else if ('undo' in d) act({ type: 'undo' });
421
+ else if ('style' in d) openSheet('style');
422
+ else if ('ask' in d) $('ask').classList.add('on');
423
+ 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); }
425
+ else if ('focus' in d) fetch(`/api/focus?p=${encodeURIComponent(piece)}`, { method: 'POST' });
426
+ 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(); }
428
+ else if (d.invSet) act({ type: 'inv-status', id: d.invSet, value: d.v }, true);
429
+ else if (d.invMust) act({ type: 'inv-must', id: d.invMust, value: !!d.v }, true);
430
+ 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
+ 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(); } }
433
+ else if ('swap' in d) openSheet('swap');
434
+ else if (d.swapto) { closeSheet(); const r = await vact('swap', { block: d.swapto }); if (r.part) { sel = r.part; render(); } }
435
+ else if ('dup' in d) { const r = await vact('duplicate'); if (r.part) { sel = r.part; render(); } }
436
+ 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
+ else if (d.joinof) openSheet('join', d.joinof);
438
+ else if (d.setjoin) { const at = sheetOpen.arg; closeSheet(); act({ type: 'v-join', part: at, value: d.setjoin }); }
439
+ 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
+ else if (d.motion) vact('motion', { value: d.motion });
441
+ else if (d.bg !== undefined) vact('background', { value: d.bg });
442
+ else if ('bgall' in d) vact('background', { value: cur().background ?? '', all: true });
443
+ else if ('bgmore' in d) openSheet('bgmore');
444
+ else if (d.bgadd) { b.querySelector('.lb').textContent = 'Adding…'; const r = await (await fetch(`/api/background?p=${encodeURIComponent(piece)}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: d.bgadd }) })).json(); if (r.error) return toast(`Not added: ${r.error}`); closeSheet(); vact('background', { value: d.bgadd }); }
445
+ else if (d.tunev) vact('tune', { key: d.tunev, value: d.v });
446
+ else if (d.palette) act({ type: 'style-palette', value: d.palette });
447
+ else if (d.fontpair) act({ type: 'style-font', value: d.fontpair });
448
+ else if (d.pace) act({ type: 'style-pace', value: d.pace });
449
+ else if ('render' in d) act({ type: 'render' });
450
+ else if ('playall' in d) playAll();
451
+ });
452
+ let sheetOpenAt = 0;
453
+ const _open = openSheet; openSheet = (k, a) => { if (k === 'add') sheetOpenAt = a; _open(k, a); };
454
+ function playAll() {
455
+ if (playing) { clearTimeout(playing); playing = null; render(); return; }
456
+ let i = 0; const parts = P.video.parts;
457
+ const next = () => { if (i >= parts.length || view !== 'video') { playing = null; render(); return; } sel = parts[i].dir; render(); playing = setTimeout(next, (parts[i++].seconds + 0.6) * 1000); };
458
+ playing = 1; next();
459
+ }
460
+ document.addEventListener('change', (e) => {
461
+ const d = e.target.dataset;
462
+ if (d.word !== undefined) vact('word', { key: d.word, value: e.target.value });
463
+ else if (d.tune) vact('tune', { key: d.tune, value: e.target.value });
464
+ else if ('seconds' in d) vact('seconds', { value: e.target.value });
465
+ else if (d.color) act({ type: 'style-color', key: d.color, value: e.target.value });
466
+ });
467
+ document.addEventListener('input', (e) => { const d = e.target.dataset; const k = d.tune ?? ('seconds' in d ? 'seconds' : null); if (k) { const o = $(`tv-${k}`); if (o) o.textContent = k === 'seconds' ? `${Number(e.target.value).toFixed(1)} s` : e.target.value; } });
468
+ document.addEventListener('focusout', (e) => {
469
+ const d = e.target.dataset ?? {}, v = e.target.isContentEditable ? e.target.innerText.trim() : null;
470
+ if (d.brief && v !== P.brief[d.brief]) act({ type: 'brief', key: d.brief, value: v }, true);
471
+ else if (d.inv && v) { const it = P.inventory.items.find((i) => i.id === d.inv); if (v !== String(it?.fields?.find((f) => f[0] === d.key)?.[1] ?? '')) act({ type: 'inv-field', id: d.inv, key: d.key, value: v }); }
472
+ setTimeout(() => { if (pending) render(); }, 0);
473
+ });
474
+ document.addEventListener('keydown', (e) => {
475
+ if (e.key === 'Escape') { closeSheet(); $('ask').classList.remove('on'); }
476
+ if (e.key === 'Enter' && e.target.isContentEditable) { e.preventDefault(); e.target.blur(); }
477
+ if ((e.metaKey || e.ctrlKey) && e.key === 'z' && !e.target.closest('input, textarea, [contenteditable]') && P?.undo) { e.preventDefault(); act({ type: 'undo' }); }
478
+ });
479
+ // samples play while the pointer is on them: a block's clip, or a background's shader running live
480
+ document.addEventListener('mouseover', (e) => {
481
+ const c = e.target.closest?.('[data-clip]'); const ph = c?.querySelector('.ph, .th');
482
+ 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); }
483
+ const sw = e.target.closest?.('[data-swatch]');
484
+ if (sw && !sw.querySelector('iframe') && P?.inventory?.live && sw.dataset.swatch !== '{}') { const f = document.createElement('iframe'); f.src = `${P.inventory.live}/__pm/runtime/swatch.html#${encodeURIComponent(sw.dataset.swatch)}`; f.title = 'Live background'; sw.append(f); }
485
+ });
486
+ document.addEventListener('mouseout', (e) => { for (const s of ['[data-clip]', '[data-swatch]']) { const c = e.target.closest?.(s); if (c && !c.contains(e.relatedTarget)) c.querySelector('.ph > video, .th > video, iframe')?.remove(); } });
487
+ // drag a part to reorder
488
+ let from = null;
489
+ document.addEventListener('dragstart', (e) => { const p = e.target.closest?.('.part'); if (!p) return; from = p.dataset.part; p.classList.add('dragging'); });
490
+ document.addEventListener('dragover', (e) => { const p = e.target.closest?.('.part'); if (from === null || !p) return; e.preventDefault(); document.querySelectorAll('.part.drop').forEach((x) => x.classList.remove('drop')); p.classList.add('drop'); });
491
+ document.addEventListener('drop', (e) => {
492
+ const p = e.target.closest?.('.part'); if (from === null || !p) return; e.preventDefault();
493
+ const order = P.video.parts.map((x) => x.dir), to = order.indexOf(p.dataset.part), at = order.indexOf(from);
494
+ if (to !== at) { order.splice(at, 1); order.splice(to, 0, from); act({ type: 'v-order', value: order }); }
495
+ from = null;
496
+ });
497
+ document.addEventListener('dragend', () => { from = null; document.querySelectorAll('.dragging, .drop').forEach((x) => x.classList.remove('dragging', 'drop')); });
498
+ // the page keeps up with the studio; a few misses in a row mean the studio itself stopped
499
+ setInterval(() => load().then(() => { misses = 0; }).catch(() => { if (++misses === 3) render(); }), 1500);
500
+ load();
501
+ </script>
502
+ </body>
503
+ </html>