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