paper-motion 0.2.7 → 0.3.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 +11 -0
- package/bin/pamo.mjs +123 -2
- package/lib/chain.mjs +298 -0
- package/lib/live-board.js +3 -1
- package/lib/piece.mjs +147 -0
- package/lib/registry.mjs +27 -9
- package/lib/studio.mjs +258 -0
- package/package.json +2 -1
- package/runtime/brand.js +1 -1
- package/runtime/index.js +1 -0
- package/studio/index.html +391 -0
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>paper-motion studio</title>
|
|
7
|
+
<!-- pamo studio: a piece, stage by stage. Reads /api/piece, writes actions back; the agent reads them from piece.json. -->
|
|
8
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
9
|
+
<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
|
+
<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); }
|
|
19
|
+
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); }
|
|
50
|
+
.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; }
|
|
110
|
+
</style>
|
|
111
|
+
</head>
|
|
112
|
+
<body>
|
|
113
|
+
<div id="app"><div class="empty" style="height:100vh;aspect-ratio:auto">loading…</div></div>
|
|
114
|
+
<div id="toast"></div>
|
|
115
|
+
<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' };
|
|
133
|
+
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
|
+
},
|
|
150
|
+
};
|
|
151
|
+
I.approved = I.ok; I.working = I.rendering;
|
|
152
|
+
|
|
153
|
+
const esc = (s) => String(s ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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; } })();
|
|
165
|
+
|
|
166
|
+
async function load() {
|
|
167
|
+
if (!piece) {
|
|
168
|
+
const list = await (await fetch('/api/pieces')).json();
|
|
169
|
+
if (list.length === 1) { piece = list[0].piece; history.replaceState(null, '', `?p=${encodeURIComponent(piece)}`); }
|
|
170
|
+
else return renderList(list);
|
|
171
|
+
}
|
|
172
|
+
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; }
|
|
174
|
+
const text = await res.text();
|
|
175
|
+
if (text === raw) return;
|
|
176
|
+
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;
|
|
180
|
+
render();
|
|
181
|
+
}
|
|
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) {
|
|
201
|
+
const res = await fetch(`/api/piece?p=${encodeURIComponent(piece)}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
|
202
|
+
const r = await res.json();
|
|
203
|
+
toast(r.error ? `✗ ${r.error}` : 'sent to your agent');
|
|
204
|
+
raw = ''; await load();
|
|
205
|
+
}
|
|
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
|
+
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>`;
|
|
212
|
+
}
|
|
213
|
+
|
|
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>`;
|
|
219
|
+
}
|
|
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>`;
|
|
228
|
+
}
|
|
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>`;
|
|
235
|
+
}
|
|
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> ${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>`;
|
|
258
|
+
}
|
|
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>`;
|
|
268
|
+
}
|
|
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()}`;
|
|
271
|
+
}
|
|
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>` : ''}`;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function render() {
|
|
280
|
+
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' });
|
|
323
|
+
}
|
|
324
|
+
const vcache = new Map(); // videos keep playing across re-renders (the key includes the file's mtime)
|
|
325
|
+
|
|
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 });
|
|
335
|
+
});
|
|
336
|
+
document.addEventListener('change', (e) => {
|
|
337
|
+
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 });
|
|
341
|
+
});
|
|
342
|
+
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 });
|
|
346
|
+
});
|
|
347
|
+
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));
|
|
369
|
+
});
|
|
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');
|
|
377
|
+
});
|
|
378
|
+
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();
|
|
386
|
+
});
|
|
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)); });
|
|
388
|
+
load();
|
|
389
|
+
</script>
|
|
390
|
+
</body>
|
|
391
|
+
</html>
|