paper-motion 0.3.0 → 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 (59) hide show
  1. package/CHANGELOG.md +108 -0
  2. package/bin/pamo.mjs +221 -20
  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 +75 -7
  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/piece.mjs +93 -17
  52. package/lib/registry.mjs +22 -6
  53. package/lib/studio.mjs +347 -33
  54. package/package.json +1 -1
  55. package/runtime/player.js +6 -3
  56. package/runtime/swatch.html +21 -0
  57. package/studio/fonts/GeistPixel-Square.woff2 +0 -0
  58. package/studio/fonts/GeistSerif-Regular.woff2 +0 -0
  59. package/studio/index.html +439 -327
package/studio/index.html CHANGED
@@ -4,164 +4,200 @@
4
4
  <meta charset="utf-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
6
  <title>paper-motion studio</title>
7
- <!-- pamo studio: a piece, stage by stage. Reads /api/piece, writes actions back; the agent reads them from piece.json. -->
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
+ -->
8
14
  <link rel="preconnect" href="https://fonts.googleapis.com">
9
15
  <link href="https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600&family=Geist+Mono:wght@400;500&display=swap" rel="stylesheet">
10
- <link href="https://paper-motion.vercel.app/fonts/geist-serif.css" rel="stylesheet">
11
16
  <style>
12
- :root {
13
- --bg: #0a0a0a; --panel: #111111; --panel2: #161616; --line: #ffffff14; --line2: #ffffff24;
14
- --fg: #ece9e2; --dim: #8e8b85; --faint: #5a5853; --paper: #e9e4d8; --warn: #ff4d2e; --ok: #3ddc84;
15
- --serif: 'Geist Serif', Georgia, serif; --sans: 'Geist', system-ui, sans-serif; --mono: 'Geist Mono', ui-monospace, monospace;
16
- }
17
- * { box-sizing: border-box; }
18
- html, body { margin: 0; background: var(--bg); color: var(--fg); font: 14px/1.5 var(--sans); }
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; }
19
25
  button, input, select, textarea { font: inherit; color: inherit; }
20
- button { cursor: pointer; }
21
- video, img { display: block; width: 100%; background: #000; }
22
- a { color: inherit; }
23
- .mono { font-family: var(--mono); } .serif { font-family: var(--serif); font-weight: 400; }
24
- .dim { color: var(--dim); } .faint { color: var(--faint); }
25
- .btn { border: 1px solid var(--line2); background: transparent; border-radius: 999px; padding: 7px 14px; display: inline-flex; align-items: center; gap: 8px; }
26
- .btn:hover { background: #ffffff0a; }
27
- .btn.primary { background: var(--paper); color: #111; border-color: var(--paper); }
28
- .btn.primary:hover { background: #fff; }
29
- .btn.small { padding: 4px 10px; font-size: 12px; }
30
- .btn[disabled] { opacity: .35; cursor: default; }
31
- textarea { width: 100%; background: var(--panel2); border: 1px solid var(--line2); border-radius: 10px; padding: 10px 12px; resize: vertical; min-height: 64px; }
32
- textarea:focus, input:focus, select:focus { outline: 1px solid var(--paper); }
33
- select, input[type=number] { background: var(--panel2); border: 1px solid var(--line2); border-radius: 8px; padding: 4px 8px; }
34
- .chip { font: 12px var(--mono); border: 1px solid var(--line2); border-radius: 999px; padding: 2px 9px; color: var(--dim); white-space: nowrap; }
35
- .sw { width: 34px; height: 34px; border-radius: 8px; border: 1px solid var(--line2); }
36
- .spin { animation: spin 1s linear infinite; transform-origin: 50% 50%; }
37
- @keyframes spin { to { transform: rotate(360deg); } }
38
- .rendering { position: relative; }
39
- .rendering::after { content: ''; position: absolute; inset: 0; background: repeating-linear-gradient(110deg, #0000 0 12px, #ffffff08 12px 24px); animation: slide 1s linear infinite; }
40
- @keyframes slide { to { background-position: 48px 0; } }
41
- .empty { aspect-ratio: 16/9; display: grid; place-items: center; background: #0d0d0d; color: var(--faint); font: 12px var(--mono); }
42
- .info { position: relative; display: inline-grid; place-items: center; color: var(--faint); cursor: help; border-radius: 50%; }
43
- .info:hover, .info:focus { color: var(--fg); outline: none; }
44
- .info .tip { position: absolute; left: -10px; top: calc(100% + 8px); width: 280px; z-index: 20; padding: 10px 12px; border-radius: 10px; background: #1d1d1d; border: 1px solid var(--line2); box-shadow: 0 10px 30px #000a; color: var(--fg); font: 13px/1.45 var(--sans); white-space: normal; opacity: 0; transform: translateY(-4px); pointer-events: none; transition: opacity .12s, transform .12s; }
45
- .info .tip::before { content: ''; position: absolute; left: 14px; top: -5px; width: 8px; height: 8px; background: #1d1d1d; border-left: 1px solid var(--line2); border-top: 1px solid var(--line2); transform: rotate(45deg); }
46
- .info:hover .tip, .info:focus .tip { opacity: 1; transform: none; }
47
- .edit { border-radius: 6px; padding: 4px 8px; margin: -4px -8px; outline: 1px solid transparent; cursor: text; min-height: 1.5em; }
48
- .edit:hover { outline-color: var(--line2); }
49
- .edit:focus { outline: 1px solid var(--paper); background: var(--panel2); color: var(--fg); }
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); }
50
68
  .edit:empty::before { content: attr(data-placeholder); color: var(--faint); }
51
- .txt { background: transparent; border: 1px solid var(--line); border-radius: 6px; padding: 3px 8px; font-size: 13px; min-width: 0; }
52
- .txt:hover { border-color: var(--line2); } .txt:focus { background: var(--panel2); }
53
- .grip { cursor: grab; color: var(--faint); display: grid; place-items: center; align-self: stretch; border-radius: 6px; }
54
- .grip:hover { color: var(--fg); background: #ffffff0d; }
55
- .dragging { opacity: .35; }
56
- .drop-before { box-shadow: 0 -3px 0 0 var(--paper); } .drop-after { box-shadow: 0 3px 0 0 var(--paper); }
57
- [data-trackdrag].drop-before { box-shadow: -3px 0 0 0 var(--paper); } [data-trackdrag].drop-after { box-shadow: 3px 0 0 0 var(--paper); }
58
- body.resizing { cursor: col-resize; user-select: none; } body.resizing video { pointer-events: none; }
59
- /* layout: rail · stage · decision, then the decision moves above the stage, then the rail becomes a strip */
60
- .shell { display: grid; grid-template-columns: 280px minmax(0, 1fr) var(--rw, 340px); grid-template-areas: 'rail stage decide'; min-height: 100vh; }
61
- .rail { grid-area: rail; border-right: 1px solid var(--line); padding: 22px 12px; display: grid; align-content: start; gap: 4px; }
62
- .rail .head { padding: 0 14px 18px; }
63
- .steps { display: grid; gap: 4px; }
64
- .step { all: unset; cursor: pointer; display: grid; grid-template-columns: 28px 1fr 18px; gap: 10px; align-items: start; padding: 12px 14px; border-radius: 10px; }
65
- .step.on { background: var(--panel2); }
66
- .step:disabled { cursor: default; opacity: .45; }
67
- .step:focus-visible { outline: 1px solid var(--paper); }
68
- .stage { grid-area: stage; padding: 28px 36px 90px; display: grid; align-content: start; gap: 22px; max-width: 1000px; min-width: 0; }
69
- .stage h1 { font-size: 40px; margin: 4px 0 2px; line-height: 1.05; }
70
- .decide { grid-area: decide; border-left: 1px solid var(--line); padding: 28px 22px; display: grid; align-content: start; gap: 18px; position: sticky; top: 0; height: 100vh; overflow: auto; }
71
- .brow { display: grid; grid-template-columns: 130px 1fr; gap: 16px; align-items: start; }
72
- .blabel { font-size: 12px; padding-top: 9px; display: flex; gap: 6px; align-items: center; }
73
- .card { display: grid; grid-template-columns: 18px minmax(180px, 260px) minmax(0, 1fr); gap: 14px; padding: 14px 14px 14px 8px; border: 1px solid var(--line); border-radius: 12px; background: var(--panel); }
74
- .track { display: flex; align-items: stretch; margin-top: 14px; }
75
- .tblock { cursor: grab; min-width: 0; }
76
- @media (max-width: 1180px) {
77
- .shell { grid-template-columns: 220px minmax(0, 1fr); grid-template-areas: 'rail decide' 'rail stage'; grid-template-rows: auto 1fr; }
78
- .decide { position: static; height: auto; border-left: 0; border-bottom: 1px solid var(--line); padding: 20px 36px; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); align-items: start; gap: 12px 24px; }
79
- .decide > * { grid-column: 1 / -1; }
80
- .decide > .btn.primary { grid-column: 1; align-self: start; }
81
- .decide > div:has(> #note) { grid-column: 2; grid-row: span 2; }
82
- #resize { display: none; }
83
- .decide .activity { display: none !important; }
84
- .decide .notes > div:nth-last-child(n+3) { display: none; }
85
- .decide .notes > div:first-child { display: block; }
86
- }
87
- @media (max-width: 760px) {
88
- .shell { grid-template-columns: minmax(0, 1fr); grid-template-areas: 'rail' 'decide' 'stage'; }
89
- .rail { border-right: 0; border-bottom: 1px solid var(--line); padding: 16px 0 10px; position: sticky; top: 0; z-index: 10; background: var(--bg); gap: 10px; }
90
- .rail .head { padding: 0 16px; }
91
- .rail .head .serif { font-size: 20px !important; margin-top: 6px !important; }
92
- .steps { display: flex; overflow-x: auto; gap: 4px; padding: 0 12px; scrollbar-width: none; }
93
- .steps::-webkit-scrollbar { display: none; }
94
- .step { grid-template-columns: auto auto auto; gap: 7px; padding: 7px 12px; flex: 0 0 auto; border-radius: 999px; align-items: center; }
95
- .step .sub, .step > .mono { display: none; }
96
- .stage { padding: 20px 16px 90px; gap: 18px; }
97
- .stage h1 { font-size: 32px; }
98
- .decide { padding: 16px; grid-template-columns: minmax(0, 1fr); }
99
- .decide > .btn.primary, .decide > div:has(> #note) { grid-column: 1; grid-row: auto; }
100
- .brow { grid-template-columns: minmax(0, 1fr); gap: 4px; }
101
- .blabel { padding-top: 0; }
102
- .card { grid-template-columns: minmax(0, 1fr); padding: 12px; }
103
- .card .grip { display: none; }
104
- .track { overflow-x: auto; padding-bottom: 6px; }
105
- .tblock { min-width: 96px; }
106
- .info .tip { width: min(280px, calc(100vw - 48px)); }
107
- }
108
- #toast { position: fixed; left: 50%; bottom: 18px; transform: translateX(-50%); background: #1d1d1d; border: 1px solid var(--line2); border-radius: 999px; padding: 8px 16px; font: 12px var(--mono); opacity: 0; transition: opacity .2s; pointer-events: none; }
109
- #toast.on { opacity: 1; }
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; } }
110
176
  </style>
111
177
  </head>
112
178
  <body>
113
- <div id="app"><div class="empty" style="height:100vh;aspect-ratio:auto">loading…</div></div>
114
- <div id="toast"></div>
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>
115
186
  <script>
116
- const STAGES = [
117
- ['brief', 'Brief', 'what we say, to whom and how'],
118
- ['look', 'Look', 'colors, type, background and moves'],
119
- ['structure', 'Structure', 'the beats, and the block that shows each'],
120
- ['parts', 'Parts', 'each block in your brand, one by one'],
121
- ['assembly', 'Assembly', 'everything joined, with its transitions'],
122
- ['final', 'Final', 'full quality and formats'],
123
- ];
124
- const BRIEF_HELP = {
125
- what: ['what', 'One or two sentences: what the video shows and where it will live (your site, LinkedIn, a launch). Tell the story, not the style.'],
126
- who: ['for whom', 'Who watches it and what they already know. The more specific, the better the words on screen.'],
127
- format: ['format', 'Shape and length. 16:9 for web and YouTube, 9:16 for Reels and Stories, 1:1 for feeds. Most pieces run 15–30 s.'],
128
- tone: ['tone', 'How it should feel, in a few words (calm, bold, playful), and anything to avoid.'],
129
- action: ['call to action', 'The one thing viewers should do at the end: a URL, a button, a date.'],
130
- };
131
- const STATUS = { approved: ['approved', 'var(--ok)'], review: ['to review', 'var(--paper)'], changes: ['changes asked', 'var(--warn)'], rendering: ['rendering', 'var(--dim)'], queued: ['queued', 'var(--faint)'], working: ['your agent is on it', 'var(--dim)'], pending: ['later', 'var(--faint)'] };
132
- const TINT = { approved: '#3ddc8424', changes: '#ff4d2e2b', review: '#e9e4d81f' };
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);
133
192
  const I = {
134
- ok: '<svg width="14" height="14" viewBox="0 0 16 16"><path d="M3 8.5 L6.5 12 L13 4.5" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg>',
135
- review: '<svg width="14" height="14" viewBox="0 0 16 16"><circle cx="8" cy="8" r="3.2" fill="currentColor"/><circle cx="8" cy="8" r="6.2" fill="none" stroke="currentColor" stroke-opacity=".35"/></svg>',
136
- pending: '<svg width="14" height="14" viewBox="0 0 16 16"><circle cx="8" cy="8" r="5.5" fill="none" stroke="currentColor" stroke-dasharray="2 2.4"/></svg>',
137
- changes: '<svg width="14" height="14" viewBox="0 0 16 16"><path d="M3 12.5 L3.6 9.8 L10.4 3 L13 5.6 L6.2 12.4 Z" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"/></svg>',
138
- rendering: '<svg class="spin" width="14" height="14" viewBox="0 0 16 16"><path d="M8 2.5 A5.5 5.5 0 1 1 2.5 8" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg>',
139
- queued: '<svg width="14" height="14" viewBox="0 0 16 16"><path d="M4 8 H12" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-dasharray="1.5 2.5"/></svg>',
140
- note: '<svg width="14" height="14" viewBox="0 0 16 16"><path d="M2.5 3.5 H13.5 V10.5 H7 L4 13 V10.5 H2.5 Z" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linejoin="round"/></svg>',
141
- info: '<svg width="14" height="14" viewBox="0 0 16 16"><circle cx="8" cy="8" r="6.3" fill="none" stroke="currentColor" stroke-width="1.2"/><circle cx="8" cy="5" r="0.95" fill="currentColor"/><path d="M8 7.3 V11.4" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/></svg>',
142
- grip: '<svg width="10" height="16" viewBox="0 0 10 16">' + [3, 8, 13].map((y) => `<circle cx="2.5" cy="${y}" r="1.3" fill="currentColor"/><circle cx="7.5" cy="${y}" r="1.3" fill="currentColor"/>`).join('') + '</svg>',
143
- down: '<svg width="14" height="14" viewBox="0 0 16 16"><path d="M8 2.5 V10.5 M4.5 7 L8 10.5 L11.5 7 M3 13.5 H13" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/></svg>',
144
- join: {
145
- cut: '<svg width="18" height="14" viewBox="0 0 18 14"><path d="M9 1 V13" stroke="currentColor" stroke-width="1.4"/></svg>',
146
- whip: '<svg width="18" height="14" viewBox="0 0 18 14"><path d="M2 7 H15 M11 3 L15 7 L11 11" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/><path d="M2 4 H7 M2 10 H7" stroke="currentColor" stroke-opacity=".4"/></svg>',
147
- dissolve: '<svg width="18" height="14" viewBox="0 0 18 14"><circle cx="7" cy="7" r="4.5" fill="none" stroke="currentColor" stroke-width="1.3"/><circle cx="11" cy="7" r="4.5" fill="none" stroke="currentColor" stroke-width="1.3" stroke-opacity=".5"/></svg>',
148
- dip: '<svg width="18" height="14" viewBox="0 0 18 14"><path d="M1 3 Q9 13 17 3" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/></svg>',
149
- },
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"/>',
150
195
  };
151
- I.approved = I.ok; I.working = I.rendering;
152
-
153
- const esc = (s) => String(s ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
154
- const badge = (st) => { const [l, c] = STATUS[st] ?? [st, 'var(--dim)']; return `<span class="mono" style="display:inline-flex;gap:6px;align-items:center;white-space:nowrap;flex-shrink:0;font-size:12px;font-weight:500;color:${c};${TINT[st] ? `background:${TINT[st]};padding:3px 10px 3px 8px;border-radius:999px` : ''}">${I[st] ?? ''}${l}</span>`; };
155
- const vid = (src, extra = '') => `<video data-k="${esc(src)}" src="${esc(src)}" muted loop autoplay playsinline ${extra}></video>`;
156
- const fmt = (s) => (s == null ? '' : `${Number(s).toFixed(1).replace('.0', '')} s`);
157
- const clock = (iso) => (iso ? new Date(iso).toTimeString().slice(0, 5) : '');
158
- const order = (k) => STAGES.findIndex(([x]) => x === k);
159
-
160
- // ─── state ─────────────────────────────────────────────────────────────────────────
161
- const params = new URLSearchParams(location.search);
162
- let piece = params.get('p'), P = null, raw = '', view = null, followed = null;
163
- const RW = [280, 560];
164
- let rightW = (() => { try { return Math.min(RW[1], Math.max(RW[0], Number(localStorage.getItem('studio:rw')) || 340)); } catch { return 340; } })();
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>`;
165
201
 
166
202
  async function load() {
167
203
  if (!piece) {
@@ -170,221 +206,297 @@ async function load() {
170
206
  else return renderList(list);
171
207
  }
172
208
  const res = await fetch(`/api/piece?p=${encodeURIComponent(piece)}`);
173
- if (!res.ok) { document.getElementById('app').innerHTML = `<div class="empty" style="height:100vh;aspect-ratio:auto">no piece “${esc(piece)}” here</div>`; return; }
209
+ if (!res.ok) { $('app').innerHTML = `<div class="empty">There is no piece “${esc(piece)}” in this project.</div>`; return; }
174
210
  const text = await res.text();
175
211
  if (text === raw) return;
176
212
  raw = text; P = JSON.parse(text);
177
- // follow the piece when the agent moves it on, unless the user is looking at something else on purpose
178
- if (!view || view === followed) view = P.stage;
179
- followed = P.stage;
213
+ if (!view) view = STEP[P.stage] ?? 'what';
180
214
  render();
181
215
  }
182
- // a re-render keeps the field you're in: same element (by its data attributes), same unsaved text, same caret
183
- function captureFocus() {
184
- const a = document.activeElement;
185
- if (!a || a === document.body || !(a.isContentEditable || /INPUT|TEXTAREA|SELECT/.test(a.tagName))) return null;
186
- const sel = a.id ? `#${a.id}` : Object.entries(a.dataset).map(([k, v]) => `[data-${k.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`)}="${CSS.escape(v)}"]`).join('');
187
- if (!sel) return null;
188
- const range = a.isContentEditable ? (() => { const r = getSelection(); return r.rangeCount ? [r.anchorOffset, r.focusOffset] : null; })() : [a.selectionStart, a.selectionEnd];
189
- return { sel, value: a.isContentEditable ? a.innerText : a.value, editable: a.isContentEditable, range };
190
- }
191
- function restoreFocus(f) {
192
- const el = f && document.querySelector(f.sel); if (!el) return;
193
- if (f.editable) el.innerText = f.value; else el.value = f.value;
194
- el.focus({ preventScroll: true });
195
- try {
196
- if (f.editable && el.firstChild && f.range) { const r = document.createRange(), n = el.firstChild, len = n.textContent.length; r.setStart(n, Math.min(f.range[0], len)); r.setEnd(n, Math.min(f.range[1], len)); const g = getSelection(); g.removeAllRanges(); g.addRange(r); }
197
- else if (f.range && el.setSelectionRange) el.setSelectionRange(f.range[0], f.range[1]);
198
- } catch {}
199
- }
200
- async function act(body) {
216
+ async function act(body, quiet) {
201
217
  const res = await fetch(`/api/piece?p=${encodeURIComponent(piece)}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
202
218
  const r = await res.json();
203
- toast(r.error ? `✗ ${r.error}` : 'sent to your agent');
219
+ if (r.error) toast(`Not done: ${r.error}`, 3200); else if (!quiet) toast(r.say ?? 'Saved');
204
220
  raw = ''; await load();
221
+ return r;
205
222
  }
206
- let tt; function toast(t) { const e = document.getElementById('toast'); e.textContent = t; e.classList.add('on'); clearTimeout(tt); tt = setTimeout(() => e.classList.remove('on'), 1800); }
207
- setInterval(() => load().catch(() => {}), 1500);
208
-
209
223
  function renderList(list) {
210
- document.getElementById('app').innerHTML = `<div style="max-width:720px;margin:0 auto;padding:60px 24px"><h1 class="serif" style="font-size:44px;margin:0 0 6px">Your pieces</h1><p class="dim" style="margin:0 0 28px">Each one is a video in progress. Your agent starts one with <span class="mono">pamo piece init</span>.</p>
211
- ${list.length ? list.map((x) => `<a href="?p=${encodeURIComponent(x.piece)}" style="display:flex;justify-content:space-between;align-items:center;padding:14px 0;border-bottom:1px solid var(--line);text-decoration:none"><span><span style="font-size:17px">${esc(x.title)}</span> <span class="faint mono" style="font-size:12px">pieces/${esc(x.piece)}</span></span><span class="dim mono" style="font-size:12px">${esc(x.stage)} · ${badge(x.status)}</span></a>`).join('') : '<p class="faint">No pieces yet.</p>'}</div>`;
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>`;
212
226
  }
213
227
 
214
- // ─── stages ────────────────────────────────────────────────────────────────────────
215
- function briefHTML() {
216
- return `<div style="display:grid;gap:14px"><p class="faint" style="margin:0;font-size:13px">Click any text to edit it. Your agent works from your version.</p>${Object.entries(BRIEF_HELP).map(([k, [l, help]]) => `<div class="brow">
217
- <span class="dim mono blabel">${l}<span class="info" tabindex="0" role="button" aria-label="What goes in ${l}">${I.info}<span class="tip" role="tooltip">${help}</span></span></span>
218
- <div class="edit" contenteditable="plaintext-only" spellcheck="false" data-brief="${k}" data-placeholder="empty" style="${k === 'what' ? 'font:22px/1.35 var(--serif)' : ''}">${esc(P.brief[k])}</div></div>`).join('')}</div>`;
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.'];
219
238
  }
220
- function lookHTML() {
221
- const L = P.look, row = (label, inner) => `<div><div class="dim mono" style="font-size:12px;margin-bottom:8px">${label}</div>${inner}</div>`;
222
- if (!L.colors.length && !L.fonts.length && !L.backgrounds.length && !L.moves.length) return `<p class="dim">Nothing chosen yet. Your agent proposes palettes, type, backgrounds and moves on boards; what you pick lands here.</p>`;
223
- return `<div style="display:grid;gap:22px">
224
- ${L.colors.length ? row('palette', `<div style="display:flex;gap:10px;flex-wrap:wrap">${L.colors.map(([n, h]) => `<div style="display:grid;gap:4px;justify-items:center"><div class="sw" style="background:${esc(h)}"></div><span class="faint mono" style="font-size:10px">${esc(n)}</span></div>`).join('')}</div>`) : ''}
225
- ${L.fonts.length ? row('type', L.fonts.map(([r, f]) => `<div style="display:flex;justify-content:space-between;border-bottom:1px solid var(--line);padding:6px 0"><span>${esc(f)}</span><span class="faint mono" style="font-size:12px">${esc(r)}</span></div>`).join('')) : ''}
226
- <div style="display:flex;gap:24px;flex-wrap:wrap">${L.backgrounds.length ? row('backgrounds', `<div style="display:flex;gap:6px;flex-wrap:wrap">${L.backgrounds.map((b) => `<span class="chip">${esc(b)}</span>`).join('')}</div>`) : ''}
227
- ${L.moves.length ? row('moves', `<div style="display:flex;gap:6px;flex-wrap:wrap">${L.moves.map((m) => `<span class="chip">${esc(m)}</span>`).join('')}</div>`) : ''}</div></div>`;
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>`;
228
251
  }
229
- function structureHTML() {
230
- if (!P.parts.length) return `<p class="dim">No structure yet. Your agent proposes a few on a board; the one you choose shows here, beat by beat.</p>`;
231
- return `<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:14px">${P.parts.map((p, i) => `<div>
232
- <div style="border-radius:8px;overflow:hidden;aspect-ratio:16/9">${p.clip ? vid(p.clip) : '<div class="empty">no block yet</div>'}</div>
233
- <div style="display:flex;justify-content:space-between;margin-top:6px;gap:8px"><span><span class="faint mono" style="font-size:12px">0${i + 1}</span> ${esc(p.name)}</span><span class="faint mono" style="font-size:12px">${fmt(p.seconds)}</span></div>
234
- <div class="dim" style="font-size:12px">${esc(p.what)}</div></div>`).join('')}</div>`;
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>`;
235
283
  }
236
- function partsHTML() {
237
- if (!P.parts.length) return `<p class="dim">The parts appear here once the structure is set.</p>`;
238
- return `<div style="display:grid;gap:14px">${P.parts.map((p, i) => {
239
- const media = p.status === 'rendering' || p.status === 'queued'
240
- ? `<div class="empty ${p.status === 'rendering' ? 'rendering' : ''}">${p.status}</div>`
241
- : p.draft ? vid(p.draft) : '<div class="empty">no draft yet</div>';
242
- return `<div class="card" data-drop="${i}" data-dir="${esc(p.dir)}" >
243
- <span class="grip" data-grip title="drag to move">${I.grip}</span>
244
- <div style="border-radius:8px;overflow:hidden;aspect-ratio:16/9;background:#000">${media}</div>
245
- <div style="display:grid;gap:10px;align-content:start;min-width:0">
246
- <div style="display:flex;justify-content:space-between;align-items:center;gap:10px"><span style="min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap"><span class="faint mono" style="font-size:12px">0${i + 1}</span>&nbsp; ${esc(p.name)} <span class="faint mono" style="font-size:12px">· ${esc(p.id)}</span></span>${badge(p.status)}</div>
247
- <div class="dim edit" contenteditable="plaintext-only" spellcheck="false" data-what="${esc(p.dir)}" data-placeholder="what this part shows" style="font-size:13px">${esc(p.what)}</div>
248
- ${p.note ? `<div style="color:var(--warn);font-size:13px">you asked: “${esc(p.note)}”</div>` : ''}
249
- ${p.error ? `<div class="mono" style="color:var(--warn);font-size:12px">render failed: ${esc(p.error)}</div>` : ''}
250
- ${Object.keys(p.texts).length ? `<div style="display:grid;grid-template-columns:auto 1fr;gap:6px 12px;align-items:center">${Object.entries(p.texts).map(([k, v]) => `<span class="mono faint" style="font-size:12px">${esc(k)}</span><input class="txt" value="${esc(v)}" data-text="${esc(p.dir)}" data-key="${esc(k)}">`).join('')}</div>` : ''}
251
- <div style="display:flex;gap:14px;align-items:center;flex-wrap:wrap">
252
- <label class="mono dim" style="font-size:12px">length <input type="number" step="0.1" min="0.5" max="30" value="${p.seconds ?? ''}" data-sec="${esc(p.dir)}" style="width:68px"> s</label>
253
- <label class="mono dim" style="font-size:12px">comes in with <select data-join="${esc(p.dir)}" ${i === 0 ? 'disabled' : ''}>${P.joins.map((j) => `<option ${j === p.join ? 'selected' : ''}>${j}</option>`).join('')}</select></label>
254
- </div>
255
- ${p.status === 'review' ? `<div style="display:flex;gap:8px;align-items:flex-start;flex-wrap:wrap"><button class="btn small primary" data-okp="${esc(p.dir)}">${I.ok} approve part</button><input placeholder="or ask for a change, then Enter" data-cp="${esc(p.dir)}" style="flex:1;min-width:180px;background:var(--panel2);border:1px solid var(--line2);border-radius:999px;padding:5px 12px;font-size:13px"></div>` : ''}
256
- </div></div>`;
257
- }).join('')}</div>`;
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>`;
258
307
  }
259
- function trackHTML() {
260
- if (!P.parts.length) return '';
261
- const T = P.parts.reduce((a, p) => a + Number(p.seconds ?? 0), 0);
262
- return `<div class="track">${P.parts.map((p, i) => `
263
- ${i ? `<span title="${p.join}" style="display:grid;place-items:center;width:28px;color:var(--dim)">${I.join[p.join] ?? ''}</span>` : ''}
264
- <div data-drop="${i}" data-dir="${esc(p.dir)}" draggable="true" data-trackdrag class="tblock" style="flex:${p.seconds ?? 1} 1 0;border-radius:8px;overflow:hidden;position:relative;outline:1px solid var(--line2);height:60px;background:#000">
265
- ${p.draft ? `<video src="${esc(p.draft)}#t=${Math.min(1, (p.seconds ?? 2) / 2)}" preload="metadata" muted style="height:100%;object-fit:cover;pointer-events:none"></video>` : ''}
266
- <span style="position:absolute;inset:auto 0 0 0;padding:4px 8px;background:linear-gradient(#0000,#000c);display:flex;justify-content:space-between;font-size:12px;pointer-events:none"><span>${esc(p.name)}</span><span class="mono dim">${fmt(p.seconds)}</span></span>
267
- </div>`).join('')}</div><div class="faint mono" style="font-size:12px;margin-top:6px;text-align:right">${fmt(T)} in total · drag to reorder</div>`;
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>`;
268
336
  }
269
- function assemblyHTML(st) {
270
- return `${P.media.assembly ? `<div style="border-radius:12px;overflow:hidden">${vid(P.media.assembly, 'controls')}</div>` : `<div class="empty ${st === 'working' ? 'rendering' : ''}" style="border-radius:12px">${st === 'working' ? 'your agent is joining the parts' : 'not joined yet'}</div>`}${trackHTML()}`;
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; }
271
348
  }
272
- function finalHTML(st) {
273
- const out = P.media.outputs;
274
- return `${P.media.final ? `<div style="border-radius:12px;overflow:hidden">${vid(P.media.final, 'controls')}</div>` : `<div class="empty ${st === 'working' ? 'rendering' : ''}" style="border-radius:12px">${st === 'working' ? 'rendering at full quality' : 'not rendered yet'}</div>`}
275
- ${out.length ? `<div style="display:grid;gap:0">${out.map((o) => `<div style="display:flex;justify-content:space-between;align-items:center;border-bottom:1px solid var(--line);padding:9px 0"><span class="mono">${esc(o.file)}</span><span style="display:flex;gap:14px;align-items:center"><span class="faint mono" style="font-size:12px">${(o.bytes / 1e6).toFixed(1)} MB</span><a class="btn small" href="${esc(o.url)}" download="${esc(o.file)}">${I.down} download</a></span></div>`).join('')}</div>
276
- <p class="faint" style="font-size:13px;margin:0">Need another shape (9:16, 1:1), a ProRes master or a GIF? Ask for it in the changes box.</p>` : ''}`;
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>`;
277
361
  }
278
362
 
363
+ // ─── drawing ──────────────────────────────────────────────────────────────────────────────────────────────────────
279
364
  function render() {
280
365
  if (!P) return;
281
- const focus = captureFocus();
282
- const k = view, st = P.stages[k].status, [, title, sub] = STAGES[order(k)];
283
- const approvedParts = P.parts.filter((p) => p.status === 'approved').length;
284
- const rail = STAGES.map(([key, t, s], n) => {
285
- const ss = P.stages[key].status, on = key === k, can = ss !== 'pending' || key === P.stage;
286
- return `<button data-go="${key}" ${can ? '' : 'disabled'} class="step${on ? ' on' : ''}">
287
- <span class="mono faint" style="font-size:12px;padding-top:2px">0${n + 1}</span>
288
- <span><span style="display:block">${t}</span><span class="dim sub" style="font-size:12px">${key === 'parts' && P.parts.length ? `${approvedParts} of ${P.parts.length} approved` : s}</span></span>
289
- <span style="color:${(STATUS[ss] ?? [])[1]};padding-top:2px">${I[ss] ?? ''}</span></button>`;
290
- }).join('');
291
- const body = { brief: briefHTML, look: lookHTML, structure: structureHTML, parts: partsHTML, assembly: () => assemblyHTML(st), final: () => finalHTML(st) }[k]();
292
- const canApprove = st === 'review' && (k !== 'parts' || (P.parts.length && approvedParts === P.parts.length));
293
- const notes = P.events.filter((e) => e.stage === k && e.kind === 'changes').slice(-8);
294
- const activity = P.events.filter((e) => e.stage === k).slice(-6).reverse();
295
- const total = P.parts.reduce((a, p) => a + Number(p.seconds ?? 0), 0);
296
- const app = document.getElementById('app');
297
- app.innerHTML = `<div id="grid" class="shell" style="--rw:${rightW}px">
298
- <aside class="rail">
299
- <div class="head"><a href="/" class="faint mono" style="font-size:12px;text-decoration:none">← pieces</a><div class="serif" style="font-size:24px;line-height:1.1;margin-top:10px">${esc(P.title)}</div><div class="dim mono" style="font-size:12px;margin-top:6px">${[P.brand, P.dir, P.format, total ? fmt(total) : ''].filter(Boolean).map(esc).join(' · ')}</div></div>
300
- <div class="steps">${rail}</div>
301
- </aside>
302
- <main class="stage">
303
- <header><div class="dim mono" style="font-size:12px">stage ${order(k) + 1} of 6</div><h1 class="serif">${title}</h1><div class="dim">${sub}</div></header>
304
- ${body}
305
- </main>
306
- <aside class="decide">
307
- <div id="resize" title="drag to resize · double-click to reset" style="position:absolute;left:-5px;top:0;bottom:0;width:10px;cursor:col-resize;z-index:5"><span style="position:absolute;left:4px;top:50%;width:2px;height:44px;margin-top:-22px;border-radius:2px;background:var(--line2)"></span></div>
308
- <div>${badge(st)}${P.stages[k].at ? `<span class="faint mono" style="font-size:12px"> · ${clock(P.stages[k].at)}</span>` : ''}</div>
309
- ${P.stages[k].note && st !== 'approved' ? `<div style="font-size:13px;border-left:2px solid var(--warn);padding-left:10px">${esc(P.stages[k].note)}</div>` : ''}
310
- ${k === 'parts' && st === 'review' && !canApprove ? `<p class="dim" style="margin:0;font-size:13px">Approve each part, or ask for changes. Once they all are, your agent joins them.</p>` : ''}
311
- ${st === 'review' ? `<button class="btn primary" data-oks="${k}" ${canApprove ? '' : 'disabled'} style="justify-content:center">${I.ok} approve ${title.toLowerCase()}</button>
312
- <div><textarea id="note" placeholder="Anything to change? Your agent reads it and brings this stage back to you."></textarea><button class="btn small" data-cs="${k}" style="margin-top:8px">${I.note} ask for changes</button></div>` : ''}
313
- ${st === 'approved' ? `<p class="dim" style="margin:0;font-size:13px">Approved. Reopen it to ask for a change.</p><button class="btn small" data-reopen="${k}" style="justify-self:start">${I.changes} reopen</button>` : ''}
314
- ${st === 'working' || st === 'changes' ? `<p class="dim" style="margin:0;font-size:13px">Your agent is working on this stage. It shows up here when it's ready.</p>` : ''}
315
- ${st === 'pending' ? `<p class="dim" style="margin:0;font-size:13px">This comes after ${esc(STAGES[order(k) - 1]?.[1] ?? '')}.</p>` : ''}
316
- ${notes.length ? `<div class="notes" style="display:grid;gap:8px"><div class="faint mono" style="font-size:12px">your notes</div>${notes.map((f) => `<div style="font-size:13px;border-left:2px solid var(--line2);padding-left:10px">${f.part ? `<span class="faint mono">${esc(f.part)}</span> · ` : ''}${esc(f.text)} <span class="faint mono" style="font-size:11px">${clock(f.at)}</span></div>`).join('')}</div>` : ''}
317
- ${activity.length ? `<div class="activity" style="display:grid;gap:6px"><div class="faint mono" style="font-size:12px">sent to your agent</div>${activity.map((e) => `<div class="mono faint" style="font-size:11px;line-height:1.4">${clock(e.at)} · ${esc(e.part ? `${e.part} ` : '')}${esc(e.kind === 'edit' ? `${e.key} → ${e.text}` : e.kind === 'changes' ? 'changes asked' : e.kind)}</div>`).join('')}</div>` : ''}
318
- </aside></div>`;
319
- app.querySelectorAll('video[data-k]').forEach((v) => { const c = vcache.get(v.dataset.k); if (c) v.replaceWith(c); else vcache.set(v.dataset.k, v); });
320
- restoreFocus(focus);
321
- // on a phone the stages are a strip: keep the open one in view
322
- const on = app.querySelector('.step.on'); if (on && on.parentElement.scrollWidth > on.parentElement.clientWidth) on.scrollIntoView({ block: 'nearest', inline: 'center' });
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();
323
379
  }
324
- const vcache = new Map(); // videos keep playing across re-renders (the key includes the file's mtime)
380
+ let pending = false;
325
381
 
326
- // ─── wiring ────────────────────────────────────────────────────────────────────────
327
- document.addEventListener('click', (e) => {
328
- const t = e.target.closest('button'); if (!t || t.disabled) return;
329
- const d = t.dataset;
330
- if (d.go) { view = d.go; render(); }
331
- else if (d.okp) act({ type: 'approve-part', part: d.okp });
332
- else if (d.oks) act({ type: 'approve', stage: d.oks });
333
- else if (d.cs) { const v = document.getElementById('note').value; if (v.trim()) act({ type: 'changes', stage: d.cs, text: v }); else toast('write what to change first'); }
334
- else if (d.reopen) act({ type: 'reopen', stage: d.reopen });
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();
335
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
+ }
336
460
  document.addEventListener('change', (e) => {
337
461
  const d = e.target.dataset;
338
- if (d.text) act({ type: 'text', part: d.text, key: d.key, value: e.target.value });
339
- if (d.sec) act({ type: 'seconds', part: d.sec, value: e.target.value });
340
- if (d.join) act({ type: 'join', part: d.join, value: e.target.value });
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 });
341
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; } });
342
468
  document.addEventListener('focusout', (e) => {
343
- const d = e.target.dataset, v = e.target.isContentEditable ? e.target.innerText.trim() : null;
344
- if (d.brief && v !== P.brief[d.brief]) act({ type: 'brief', key: d.brief, value: v });
345
- else if (d.what && v !== P.parts.find((p) => p.dir === d.what)?.what) act({ type: 'what', part: d.what, value: v });
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);
346
473
  });
347
474
  document.addEventListener('keydown', (e) => {
348
- if (e.key !== 'Enter') return;
349
- if (e.target.isContentEditable && !e.shiftKey) { e.preventDefault(); e.target.blur(); return; } // Enter saves; Shift+Enter breaks a line
350
- const d = e.target.dataset;
351
- if (d.cp && e.target.value.trim()) act({ type: 'changes', stage: 'parts', part: d.cp, text: e.target.value });
352
- });
353
- // the decision column: drag its left edge, between RW[0] and RW[1]; double-click resets
354
- document.addEventListener('pointerdown', (e) => {
355
- if (!e.target.closest('#resize')) return;
356
- e.preventDefault(); document.body.classList.add('resizing');
357
- const grid = document.getElementById('grid');
358
- const move = (ev) => { rightW = Math.round(Math.min(RW[1], Math.max(RW[0], innerWidth - ev.clientX))); grid.style.setProperty('--rw', `${rightW}px`); };
359
- const up = () => { document.body.classList.remove('resizing'); removeEventListener('pointermove', move); removeEventListener('pointerup', up); try { localStorage.setItem('studio:rw', rightW); } catch {} };
360
- addEventListener('pointermove', move); addEventListener('pointerup', up);
361
- });
362
- document.addEventListener('dblclick', (e) => { if (e.target.closest('#resize')) { rightW = 340; try { localStorage.removeItem('studio:rw'); } catch {} render(); } });
363
- // drag to reorder: a card only while held by its grip (its fields stay usable); track blocks always
364
- let dragFrom = null;
365
- document.addEventListener('pointerdown', (e) => { if (e.target.closest('[data-grip]')) e.target.closest('.card').draggable = true; });
366
- document.addEventListener('dragstart', (e) => {
367
- const el = e.target.closest?.('.card[draggable=true],[data-trackdrag]'); if (!el) return;
368
- dragFrom = Number(el.dataset.drop); el.classList.add('dragging'); e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', String(dragFrom));
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' }); }
369
478
  });
370
- const clearMarks = () => document.querySelectorAll('.drop-before,.drop-after').forEach((x) => x.classList.remove('drop-before', 'drop-after'));
371
- document.addEventListener('dragover', (e) => {
372
- if (dragFrom === null) return;
373
- const el = e.target.closest('[data-drop]'); if (!el) return;
374
- e.preventDefault(); clearMarks();
375
- const r = el.getBoundingClientRect(), horiz = el.hasAttribute('data-trackdrag');
376
- el.classList.add((horiz ? e.clientX < r.left + r.width / 2 : e.clientY < r.top + r.height / 2) ? 'drop-before' : 'drop-after');
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); }
377
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'); });
378
491
  document.addEventListener('drop', (e) => {
379
- const el = e.target.closest('[data-drop]'); if (dragFrom === null || !el) return;
380
- e.preventDefault();
381
- let to = Number(el.dataset.drop) + (el.classList.contains('drop-after') ? 1 : 0);
382
- if (to > dragFrom) to--;
383
- const dirs = P.parts.map((p) => p.dir);
384
- if (to !== dragFrom) { const [m] = dirs.splice(dragFrom, 1); dirs.splice(to, 0, m); act({ type: 'order', order: dirs }); }
385
- dragFrom = null; clearMarks();
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;
386
496
  });
387
- document.addEventListener('dragend', () => { dragFrom = null; clearMarks(); document.querySelectorAll('.dragging').forEach((x) => x.classList.remove('dragging')); document.querySelectorAll('.card[draggable=true]').forEach((x) => (x.draggable = false)); });
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);
388
500
  load();
389
501
  </script>
390
502
  </body>