paper-motion 0.3.0 → 0.4.1
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 +124 -0
- package/bin/pamo.mjs +222 -21
- 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/lib/chain.mjs +95 -18
- package/lib/customize.mjs +40 -2
- package/lib/edit.mjs +342 -0
- package/lib/gather.mjs +241 -0
- package/lib/host.mjs +25 -0
- package/lib/inventory.mjs +531 -0
- package/lib/piece.mjs +93 -17
- package/lib/registry.mjs +22 -6
- package/lib/render.mjs +13 -9
- package/lib/studio.mjs +347 -33
- package/package.json +1 -1
- package/runtime/index.js +10 -1
- package/runtime/player.js +18 -6
- package/runtime/swatch.html +21 -0
- package/studio/fonts/GeistPixel-Square.woff2 +0 -0
- package/studio/fonts/GeistSerif-Regular.woff2 +0 -0
- package/studio/index.html +455 -327
package/studio/index.html
CHANGED
|
@@ -4,164 +4,200 @@
|
|
|
4
4
|
<meta charset="utf-8">
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
6
|
<title>paper-motion studio</title>
|
|
7
|
-
<!--
|
|
7
|
+
<!--
|
|
8
|
+
pamo studio: one piece in three steps. What it says (the brief and the content the user keeps), Your video (every
|
|
9
|
+
part playing live, edited in place: words, how it arrives, background, settings, length, transitions, order, adding,
|
|
10
|
+
swapping, removing, and the whole video's style) and Export. Reads /api/piece, writes edits back; the files stay the
|
|
11
|
+
source of truth and the agent reads every change as one line. paper-motion's own look: black ground, white figure,
|
|
12
|
+
one warm paper accent, hard edges, 1px rules.
|
|
13
|
+
-->
|
|
8
14
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
9
15
|
<link href="https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600&family=Geist+Mono:wght@400;500&display=swap" rel="stylesheet">
|
|
10
|
-
<link href="https://paper-motion.vercel.app/fonts/geist-serif.css" rel="stylesheet">
|
|
11
16
|
<style>
|
|
12
|
-
:
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
--serif: 'Geist Serif', Georgia, serif; --sans: 'Geist', system-ui, sans-serif; --mono: 'Geist Mono', ui-monospace, monospace;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
|
|
17
|
+
@font-face { font-family: 'Geist Serif'; src: url('/fonts/GeistSerif-Regular.woff2') format('woff2'); font-display: swap; }
|
|
18
|
+
@font-face { font-family: 'Geist Pixel'; src: url('/fonts/GeistPixel-Square.woff2') format('woff2'); font-display: swap; }
|
|
19
|
+
:root { --ground: #000; --surface: #0b0b0b; --raised: #141414; --line: #1f1f1f; --rule: #3a3a3a; --figure: #fff; --muted: #8a8a8a; --faint: #4a4a4a; --paper: #e9e4d8; --ok: #3ddc84; --warn: #ff4d2e;
|
|
20
|
+
--serif: 'Geist Serif', Georgia, serif; --sans: 'Geist', system-ui, sans-serif; --mono: 'Geist Mono', ui-monospace, monospace; --pixel: 'Geist Pixel', 'Geist Mono', monospace; }
|
|
21
|
+
* { box-sizing: border-box; border-radius: 0; }
|
|
22
|
+
html { background: var(--ground); color-scheme: dark; }
|
|
23
|
+
body { margin: 0; background: var(--ground); color: var(--figure); font: 14px/1.45 var(--sans); }
|
|
24
|
+
::selection { background: var(--paper); color: #000; }
|
|
19
25
|
button, input, select, textarea { font: inherit; color: inherit; }
|
|
20
|
-
button { cursor: pointer; }
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
.
|
|
24
|
-
.
|
|
25
|
-
.btn { border:
|
|
26
|
-
.btn
|
|
27
|
-
.btn.primary { background:
|
|
28
|
-
.btn.
|
|
29
|
-
.btn.
|
|
30
|
-
.btn
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
.
|
|
35
|
-
.
|
|
36
|
-
.
|
|
37
|
-
|
|
38
|
-
.
|
|
39
|
-
.
|
|
40
|
-
|
|
41
|
-
.
|
|
42
|
-
.
|
|
43
|
-
.
|
|
44
|
-
.
|
|
45
|
-
.
|
|
46
|
-
|
|
47
|
-
.
|
|
48
|
-
.
|
|
49
|
-
|
|
26
|
+
button { cursor: pointer; } a { color: inherit; }
|
|
27
|
+
:focus-visible { outline: 2px solid var(--paper); outline-offset: 2px; }
|
|
28
|
+
.muted { color: var(--muted); } .mono { font-family: var(--mono); } .serif { font-family: var(--serif); font-weight: 400; }
|
|
29
|
+
.ico { width: 15px; height: 15px; stroke: currentColor; fill: none; stroke-width: 1.6; stroke-linecap: square; flex: none; }
|
|
30
|
+
.btn { display: inline-flex; align-items: center; justify-content: center; gap: 7px; border: 1px solid var(--rule); background: transparent; padding: 8px 13px; font-size: 13px; white-space: nowrap; text-decoration: none; }
|
|
31
|
+
.btn:hover { border-color: var(--paper); }
|
|
32
|
+
.btn.primary { background: var(--paper); color: #000; border-color: var(--paper); }
|
|
33
|
+
.btn.primary:hover { background: #fff; border-color: #fff; }
|
|
34
|
+
.btn.sm { padding: 6px 10px; font-size: 12.5px; }
|
|
35
|
+
.btn.on { background: var(--paper); color: #000; border-color: var(--paper); }
|
|
36
|
+
.btn:disabled { opacity: .35; cursor: default; }
|
|
37
|
+
.hint { font-size: 12.5px; color: var(--muted); margin: 0; }
|
|
38
|
+
.lbl { font: 500 11px/1 var(--mono); letter-spacing: .06em; text-transform: uppercase; color: var(--muted); }
|
|
39
|
+
|
|
40
|
+
header.top { display: flex; align-items: center; gap: 14px; height: 56px; padding: 0 18px; border-bottom: 1px solid var(--line); position: sticky; top: 0; z-index: 30; background: var(--ground); }
|
|
41
|
+
.back { display: flex; align-items: center; gap: 6px; color: var(--muted); text-decoration: none; font-size: 13px; }
|
|
42
|
+
.ttl { font: 400 21px/1 var(--serif); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 26vw; }
|
|
43
|
+
.steps { display: flex; margin: 0 auto; border: 1px solid var(--rule); }
|
|
44
|
+
.steps button { background: none; border: 0; padding: 9px 16px; display: flex; gap: 8px; align-items: center; color: var(--muted); }
|
|
45
|
+
.steps button + button { border-left: 1px solid var(--rule); }
|
|
46
|
+
.steps button b { font: 400 15px/1 var(--pixel); }
|
|
47
|
+
.steps button.on { background: var(--paper); color: #000; }
|
|
48
|
+
.steps button i { width: 7px; height: 7px; background: var(--ok); display: inline-block; }
|
|
49
|
+
.turn { display: flex; align-items: center; gap: 12px; min-height: 40px; padding: 6px 18px; border-bottom: 1px solid var(--line); font-size: 13px; }
|
|
50
|
+
.turn .mk { width: 8px; height: 8px; background: var(--paper); flex: none; }
|
|
51
|
+
.turn.agent .mk { background: var(--muted); animation: pulse 1.4s infinite; } .turn.off .mk, .turn.deaf .mk { background: var(--warn); }
|
|
52
|
+
@keyframes pulse { 50% { opacity: .3; } }
|
|
53
|
+
.turn b { font-weight: 500; margin-right: 8px; } .turn .tx { color: var(--muted); }
|
|
54
|
+
.turn .grow { flex: 1; }
|
|
55
|
+
main { min-height: calc(100vh - 97px); }
|
|
56
|
+
|
|
57
|
+
/* 1 · what it says */
|
|
58
|
+
.what { max-width: 900px; margin: 0 auto; padding: 36px 20px 90px; }
|
|
59
|
+
.what h1 { font: 400 46px/1.05 var(--serif); margin: 0; }
|
|
60
|
+
.what .sub { margin: 10px 0 26px; color: var(--muted); font-size: 15px; max-width: 62ch; }
|
|
61
|
+
.brief { border: 1px solid var(--line); margin-bottom: 30px; }
|
|
62
|
+
.brief summary { cursor: pointer; padding: 12px 14px; display: flex; justify-content: space-between; gap: 10px; list-style: none; }
|
|
63
|
+
.brief summary::-webkit-details-marker { display: none; }
|
|
64
|
+
.brief .rows { padding: 0 14px 14px; display: grid; gap: 10px; }
|
|
65
|
+
.brief .row { display: grid; grid-template-columns: 120px 1fr; gap: 12px; align-items: baseline; }
|
|
66
|
+
.edit { padding: 5px 8px; margin: -5px -8px; outline: 1px dashed transparent; cursor: text; min-height: 1.4em; }
|
|
67
|
+
.edit:hover { outline-color: var(--rule); } .edit:focus { outline: 1px solid var(--paper); background: var(--surface); }
|
|
50
68
|
.edit:empty::before { content: attr(data-placeholder); color: var(--faint); }
|
|
51
|
-
.
|
|
52
|
-
.
|
|
53
|
-
.
|
|
54
|
-
.
|
|
55
|
-
.
|
|
56
|
-
.
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
.
|
|
61
|
-
.
|
|
62
|
-
.
|
|
63
|
-
.
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
.
|
|
67
|
-
.
|
|
68
|
-
.
|
|
69
|
-
.
|
|
70
|
-
.
|
|
71
|
-
.
|
|
72
|
-
.
|
|
73
|
-
.
|
|
74
|
-
.
|
|
75
|
-
.
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
|
|
69
|
+
.fact { display: grid; grid-template-columns: 96px 1fr auto; gap: 16px; align-items: center; padding: 14px 0; border-top: 1px solid var(--line); }
|
|
70
|
+
.fact:last-of-type { border-bottom: 1px solid var(--line); }
|
|
71
|
+
.fact img { width: 96px; height: 60px; object-fit: cover; object-position: top; border: 1px solid var(--rule); }
|
|
72
|
+
.fact .k { font: 500 11px/1 var(--mono); letter-spacing: .06em; text-transform: uppercase; color: var(--muted); display: flex; gap: 8px; align-items: center; }
|
|
73
|
+
.fact .k .new { background: var(--paper); color: #000; padding: 3px 5px; }
|
|
74
|
+
.fact .t { font: 400 23px/1.2 var(--serif); margin-top: 6px; overflow-wrap: anywhere; }
|
|
75
|
+
.fact .s { font-size: 12.5px; color: var(--faint); margin-top: 4px; }
|
|
76
|
+
.fact.out { opacity: .38; } .fact.out .t { text-decoration: line-through; }
|
|
77
|
+
.fact .acts { display: flex; gap: 6px; }
|
|
78
|
+
.addfact { margin-top: 16px; display: flex; gap: 8px; }
|
|
79
|
+
.addfact input, .addfact select { background: var(--surface); border: 1px solid var(--rule); padding: 10px 12px; }
|
|
80
|
+
.addfact input { flex: 1; }
|
|
81
|
+
.nextbar { margin-top: 30px; display: flex; justify-content: space-between; align-items: center; gap: 16px; flex-wrap: wrap; }
|
|
82
|
+
|
|
83
|
+
/* 2 · your video */
|
|
84
|
+
.editor { display: grid; grid-template-columns: minmax(0, 1fr) 372px; min-height: calc(100vh - 97px); }
|
|
85
|
+
.center { padding: 20px 24px; display: grid; grid-template-columns: minmax(0, 1fr); gap: 14px; align-content: start; min-width: 0; }
|
|
86
|
+
.player { position: relative; aspect-ratio: 16/9; background: #000; border: 1px solid var(--line); width: min(100%, calc(56vh * 16 / 9)); margin: 0 auto; overflow: hidden; }
|
|
87
|
+
.player.tall { aspect-ratio: 9/16; width: min(100%, calc(56vh * 9 / 16)); } .player.sq { aspect-ratio: 1; width: min(100%, 56vh); }
|
|
88
|
+
.player iframe { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; }
|
|
89
|
+
.player .badge { position: absolute; left: 10px; top: 10px; font: 500 11px/1 var(--mono); letter-spacing: .06em; text-transform: uppercase; background: rgba(0,0,0,.65); border: 1px solid rgba(255,255,255,.2); padding: 5px 7px; z-index: 2; }
|
|
90
|
+
.player .msg { position: absolute; inset: 0; display: grid; place-items: center; text-align: center; padding: 20px; color: var(--muted); font: 13px var(--mono); }
|
|
91
|
+
.pbar { display: flex; align-items: center; gap: 10px; color: var(--muted); font-size: 12.5px; flex-wrap: wrap; }
|
|
92
|
+
.pbar .grow { flex: 1; }
|
|
93
|
+
.track { display: flex; align-items: stretch; overflow-x: auto; padding: 6px 2px 12px; }
|
|
94
|
+
.part { flex: none; width: 170px; border: 1px solid var(--line); background: var(--surface); cursor: pointer; position: relative; }
|
|
95
|
+
.part:hover { border-color: var(--rule); } .part.sel { border-color: var(--paper); box-shadow: 0 0 0 1px var(--paper); }
|
|
96
|
+
.part.dragging { opacity: .4; } .part.drop { box-shadow: -3px 0 0 var(--paper); }
|
|
97
|
+
.part .th { aspect-ratio: 16/9; background: #000 center/cover no-repeat; position: relative; overflow: hidden; }
|
|
98
|
+
.part .th video { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
|
|
99
|
+
.part .meta { padding: 7px 9px 9px; }
|
|
100
|
+
.part .nm { font-size: 12.5px; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
101
|
+
.part .tx { font-size: 11.5px; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
102
|
+
.part .sec { position: absolute; right: 6px; top: 6px; font: 500 11px/1 var(--mono); background: rgba(0,0,0,.72); padding: 4px 5px; }
|
|
103
|
+
.part .n { position: absolute; left: 6px; top: 6px; font: 400 13px/1 var(--pixel); background: rgba(0,0,0,.72); padding: 3px 5px; }
|
|
104
|
+
.join { flex: none; width: 52px; display: grid; place-items: center; position: relative; }
|
|
105
|
+
.join button { width: 34px; height: 34px; border: 1px solid var(--rule); background: var(--ground); display: grid; place-items: center; padding: 0; }
|
|
106
|
+
.join button:hover { border-color: var(--paper); }
|
|
107
|
+
.join small { position: absolute; bottom: 2px; left: 0; right: 0; text-align: center; font: 10px/1.1 var(--mono); color: var(--muted); }
|
|
108
|
+
.addp { flex: none; width: 40px; display: grid; place-items: center; }
|
|
109
|
+
.addp button { width: 30px; height: 100%; min-height: 120px; border: 1px dashed var(--rule); background: none; display: grid; place-items: center; }
|
|
110
|
+
.addp button:hover { border-color: var(--paper); }
|
|
111
|
+
.insp { border-left: 1px solid var(--line); padding: 18px 20px 60px; overflow-y: auto; max-height: calc(100vh - 97px); position: sticky; top: 97px; }
|
|
112
|
+
.insp h2 { margin: 6px 0 0; font: 400 26px/1.1 var(--serif); }
|
|
113
|
+
.insp .sum { margin-top: 6px; }
|
|
114
|
+
.tabs { display: flex; margin: 16px 0 14px; border: 1px solid var(--rule); }
|
|
115
|
+
.tabs button { flex: 1; background: none; border: 0; padding: 8px 4px; font-size: 12.5px; color: var(--muted); }
|
|
116
|
+
.tabs button + button { border-left: 1px solid var(--rule); }
|
|
117
|
+
.tabs button.on { background: var(--paper); color: #000; }
|
|
118
|
+
.pane { display: grid; gap: 14px; }
|
|
119
|
+
.fld { display: grid; gap: 6px; font-size: 12.5px; color: var(--muted); }
|
|
120
|
+
.fld i { font-style: normal; color: var(--faint); }
|
|
121
|
+
.fld input, .fld textarea, .fld select { background: var(--surface); border: 1px solid var(--rule); padding: 9px 10px; font-size: 14px; color: var(--figure); width: 100%; }
|
|
122
|
+
.fld textarea { resize: vertical; min-height: 64px; } .fld textarea.code { font: 13px/1.5 var(--mono); }
|
|
123
|
+
.fld input:focus, .fld textarea:focus, .fld select:focus { outline: 0; border-color: var(--paper); }
|
|
124
|
+
.pick img { width: 100%; aspect-ratio: 16/9; object-fit: cover; object-position: top; border: 1px solid var(--rule); }
|
|
125
|
+
.tiles { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
|
|
126
|
+
.tile { border: 1px solid var(--line); background: none; padding: 0; text-align: left; }
|
|
127
|
+
.tile:hover { border-color: var(--rule); } .tile.on { border-color: var(--paper); box-shadow: 0 0 0 1px var(--paper); }
|
|
128
|
+
.ph { aspect-ratio: 16/9; background: #000 center/cover no-repeat; position: relative; overflow: hidden; }
|
|
129
|
+
.ph video, .ph iframe { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; border: 0; }
|
|
130
|
+
.ph .bars { position: absolute; inset: 0; display: flex; } .ph .bars i { flex: 1; }
|
|
131
|
+
.tile .lb { padding: 7px 8px 8px; font-size: 12.5px; }
|
|
132
|
+
.tile .lb small { display: block; color: var(--muted); font-size: 11.5px; line-height: 1.3; margin-top: 2px; }
|
|
133
|
+
.slider { display: grid; gap: 6px; font-size: 12.5px; color: var(--muted); }
|
|
134
|
+
.slider div { display: flex; justify-content: space-between; gap: 10px; } .slider em { font-style: normal; color: var(--figure); font-family: var(--mono); }
|
|
135
|
+
.slider input { accent-color: var(--paper); width: 100%; }
|
|
136
|
+
.seg { display: flex; flex-wrap: wrap; } .seg .btn { flex: 1; } .seg .btn + .btn { border-left: 0; }
|
|
137
|
+
.partacts { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 18px; padding-top: 16px; border-top: 1px solid var(--line); }
|
|
138
|
+
.empty { padding: 60px 20px; text-align: center; color: var(--muted); }
|
|
139
|
+
|
|
140
|
+
/* sheets */
|
|
141
|
+
.scrim { position: fixed; inset: 0; background: rgba(0,0,0,.62); z-index: 50; display: none; }
|
|
142
|
+
.scrim.on { display: block; }
|
|
143
|
+
.sheet { position: fixed; top: 0; right: 0; bottom: 0; width: min(760px, 100vw); background: var(--ground); border-left: 1px solid var(--paper); z-index: 51; overflow-y: auto; padding: 0 24px 40px; transform: translateX(102%); transition: transform .26s cubic-bezier(.2,.8,.2,1); }
|
|
144
|
+
.sheet.on { transform: none; }
|
|
145
|
+
.sheet header { position: sticky; top: 0; background: var(--ground); padding: 20px 0 14px; border-bottom: 1px solid var(--rule); display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; z-index: 2; }
|
|
146
|
+
.sheet header h2 { margin: 4px 0 0; font: 400 28px/1.1 var(--serif); }
|
|
147
|
+
.sheet .grp { margin-top: 22px; }
|
|
148
|
+
.sheet .grp h3 { margin: 0 0 10px; }
|
|
149
|
+
.sheet .tiles { grid-template-columns: repeat(3, 1fr); }
|
|
150
|
+
.pals { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
|
|
151
|
+
.pal { display: flex; align-items: center; gap: 4px; padding: 8px; border: 1px solid var(--line); background: none; width: 100%; text-align: left; }
|
|
152
|
+
.pal:hover { border-color: var(--rule); } .pal.on { border-color: var(--paper); }
|
|
153
|
+
.pal i { width: 20px; height: 20px; display: block; border: 1px solid rgba(255,255,255,.15); }
|
|
154
|
+
.pal span { margin-left: 8px; font-size: 13px; }
|
|
155
|
+
.colors { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; }
|
|
156
|
+
.colors label { display: grid; gap: 6px; font-size: 12.5px; color: var(--muted); }
|
|
157
|
+
.colors input { width: 100%; height: 38px; padding: 0; border: 1px solid var(--rule); background: none; }
|
|
158
|
+
.fonts { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
|
|
159
|
+
.font { padding: 10px 12px; border: 1px solid var(--line); background: none; text-align: left; font-size: 14px; }
|
|
160
|
+
.font:hover { border-color: var(--rule); } .font.on { border-color: var(--paper); }
|
|
161
|
+
.ask { position: fixed; right: 18px; bottom: 18px; z-index: 40; width: 360px; background: var(--surface); border: 1px solid var(--rule); padding: 12px; display: none; }
|
|
162
|
+
.ask.on { display: grid; gap: 8px; }
|
|
163
|
+
.ask textarea { width: 100%; min-height: 80px; background: var(--ground); border: 1px solid var(--rule); padding: 8px; resize: vertical; }
|
|
164
|
+
#toast { position: fixed; left: 50%; bottom: 24px; transform: translateX(-50%) translateY(20px); opacity: 0; background: var(--paper); color: #000; padding: 9px 14px; font-size: 13px; transition: .2s; z-index: 80; pointer-events: none; max-width: 80vw; }
|
|
165
|
+
#toast.on { opacity: 1; transform: translateX(-50%); }
|
|
166
|
+
|
|
167
|
+
/* 3 · export */
|
|
168
|
+
.exp { max-width: 980px; margin: 0 auto; padding: 32px 20px 80px; display: grid; gap: 22px; }
|
|
169
|
+
.exp h1 { font: 400 42px/1.05 var(--serif); margin: 0; }
|
|
170
|
+
.file { display: flex; justify-content: space-between; align-items: center; gap: 12px; padding: 12px 14px; border: 1px solid var(--line); }
|
|
171
|
+
.exp video { width: 100%; background: #000; border: 1px solid var(--line); }
|
|
172
|
+
.list { max-width: 760px; margin: 0 auto; padding: 40px 20px; }
|
|
173
|
+
.list h1 { font: 400 42px/1 var(--serif); margin: 0 0 20px; }
|
|
174
|
+
.lrow { display: flex; justify-content: space-between; gap: 16px; padding: 14px 0; border-bottom: 1px solid var(--line); text-decoration: none; }
|
|
175
|
+
@media (max-width: 900px) { .editor { grid-template-columns: 1fr; } .insp { position: static; max-height: none; border-left: 0; border-top: 1px solid var(--line); } .ttl { display: none; } .sheet .tiles { grid-template-columns: 1fr 1fr; } }
|
|
110
176
|
</style>
|
|
111
177
|
</head>
|
|
112
178
|
<body>
|
|
113
|
-
<div id="app"
|
|
114
|
-
<div id="
|
|
179
|
+
<div id="app"></div>
|
|
180
|
+
<div class="scrim" id="scrim"></div>
|
|
181
|
+
<aside class="sheet" id="sheet" role="dialog"></aside>
|
|
182
|
+
<div class="ask" id="ask"><div style="display:flex;justify-content:space-between;align-items:center"><b>Ask your agent</b><button class="btn sm" data-ask-close>Close</button></div>
|
|
183
|
+
<p class="hint">For anything the controls can't do: "make it more playful", "add our pricing", "use the photo of the team".</p>
|
|
184
|
+
<textarea id="askText" aria-label="What to ask"></textarea><button class="btn primary" data-ask-send>Send</button></div>
|
|
185
|
+
<div id="toast" role="status"></div>
|
|
115
186
|
<script>
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
['assembly', 'Assembly', 'everything joined, with its transitions'],
|
|
122
|
-
['final', 'Final', 'full quality and formats'],
|
|
123
|
-
];
|
|
124
|
-
const BRIEF_HELP = {
|
|
125
|
-
what: ['what', 'One or two sentences: what the video shows and where it will live (your site, LinkedIn, a launch). Tell the story, not the style.'],
|
|
126
|
-
who: ['for whom', 'Who watches it and what they already know. The more specific, the better the words on screen.'],
|
|
127
|
-
format: ['format', 'Shape and length. 16:9 for web and YouTube, 9:16 for Reels and Stories, 1:1 for feeds. Most pieces run 15–30 s.'],
|
|
128
|
-
tone: ['tone', 'How it should feel, in a few words (calm, bold, playful), and anything to avoid.'],
|
|
129
|
-
action: ['call to action', 'The one thing viewers should do at the end: a URL, a button, a date.'],
|
|
130
|
-
};
|
|
131
|
-
const STATUS = { approved: ['approved', 'var(--ok)'], review: ['to review', 'var(--paper)'], changes: ['changes asked', 'var(--warn)'], rendering: ['rendering', 'var(--dim)'], queued: ['queued', 'var(--faint)'], working: ['your agent is on it', 'var(--dim)'], pending: ['later', 'var(--faint)'] };
|
|
132
|
-
const TINT = { approved: '#3ddc8424', changes: '#ff4d2e2b', review: '#e9e4d81f' };
|
|
187
|
+
// ─── state, helpers ────────────────────────────────────────────────────────────────────────────────────────────────
|
|
188
|
+
const params = new URLSearchParams(location.search);
|
|
189
|
+
let piece = params.get('p'), P = null, raw = '', view = null, sel = null, tab = 'words', misses = 0, playing = null;
|
|
190
|
+
const esc = (s) => String(s ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
|
191
|
+
const $ = (id) => document.getElementById(id);
|
|
133
192
|
const I = {
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
pending: '<svg width="14" height="14" viewBox="0 0 16 16"><circle cx="8" cy="8" r="5.5" fill="none" stroke="currentColor" stroke-dasharray="2 2.4"/></svg>',
|
|
137
|
-
changes: '<svg width="14" height="14" viewBox="0 0 16 16"><path d="M3 12.5 L3.6 9.8 L10.4 3 L13 5.6 L6.2 12.4 Z" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"/></svg>',
|
|
138
|
-
rendering: '<svg class="spin" width="14" height="14" viewBox="0 0 16 16"><path d="M8 2.5 A5.5 5.5 0 1 1 2.5 8" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg>',
|
|
139
|
-
queued: '<svg width="14" height="14" viewBox="0 0 16 16"><path d="M4 8 H12" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-dasharray="1.5 2.5"/></svg>',
|
|
140
|
-
note: '<svg width="14" height="14" viewBox="0 0 16 16"><path d="M2.5 3.5 H13.5 V10.5 H7 L4 13 V10.5 H2.5 Z" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linejoin="round"/></svg>',
|
|
141
|
-
info: '<svg width="14" height="14" viewBox="0 0 16 16"><circle cx="8" cy="8" r="6.3" fill="none" stroke="currentColor" stroke-width="1.2"/><circle cx="8" cy="5" r="0.95" fill="currentColor"/><path d="M8 7.3 V11.4" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/></svg>',
|
|
142
|
-
grip: '<svg width="10" height="16" viewBox="0 0 10 16">' + [3, 8, 13].map((y) => `<circle cx="2.5" cy="${y}" r="1.3" fill="currentColor"/><circle cx="7.5" cy="${y}" r="1.3" fill="currentColor"/>`).join('') + '</svg>',
|
|
143
|
-
down: '<svg width="14" height="14" viewBox="0 0 16 16"><path d="M8 2.5 V10.5 M4.5 7 L8 10.5 L11.5 7 M3 13.5 H13" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/></svg>',
|
|
144
|
-
join: {
|
|
145
|
-
cut: '<svg width="18" height="14" viewBox="0 0 18 14"><path d="M9 1 V13" stroke="currentColor" stroke-width="1.4"/></svg>',
|
|
146
|
-
whip: '<svg width="18" height="14" viewBox="0 0 18 14"><path d="M2 7 H15 M11 3 L15 7 L11 11" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/><path d="M2 4 H7 M2 10 H7" stroke="currentColor" stroke-opacity=".4"/></svg>',
|
|
147
|
-
dissolve: '<svg width="18" height="14" viewBox="0 0 18 14"><circle cx="7" cy="7" r="4.5" fill="none" stroke="currentColor" stroke-width="1.3"/><circle cx="11" cy="7" r="4.5" fill="none" stroke="currentColor" stroke-width="1.3" stroke-opacity=".5"/></svg>',
|
|
148
|
-
dip: '<svg width="18" height="14" viewBox="0 0 18 14"><path d="M1 3 Q9 13 17 3" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/></svg>',
|
|
149
|
-
},
|
|
193
|
+
undo: '<path d="M7 5 3 9l4 4M3 9h9a5 5 0 0 1 0 10H9"/>', style: '<circle cx="10" cy="10" r="7"/><path d="M10 3v14M3 10h7"/>', ask: '<path d="M3 4h14v10H8l-4 3v-3H3z"/>',
|
|
194
|
+
add: '<path d="M10 4v12M4 10h12"/>', join: '<path d="M3 10h14M12 5l5 5-5 5"/>', back: '<path d="M12 4 6 10l6 6"/>', play: '<path d="M6 4l10 6-10 6z"/>', out: '<path d="M8 4H4v12h12v-4M11 3h6v6M17 3l-8 8"/>',
|
|
150
195
|
};
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
const
|
|
154
|
-
const
|
|
155
|
-
const
|
|
156
|
-
const fmt = (s) => (s == null ? '' : `${Number(s).toFixed(1).replace('.0', '')} s`);
|
|
157
|
-
const clock = (iso) => (iso ? new Date(iso).toTimeString().slice(0, 5) : '');
|
|
158
|
-
const order = (k) => STAGES.findIndex(([x]) => x === k);
|
|
159
|
-
|
|
160
|
-
// ─── state ─────────────────────────────────────────────────────────────────────────
|
|
161
|
-
const params = new URLSearchParams(location.search);
|
|
162
|
-
let piece = params.get('p'), P = null, raw = '', view = null, followed = null;
|
|
163
|
-
const RW = [280, 560];
|
|
164
|
-
let rightW = (() => { try { return Math.min(RW[1], Math.max(RW[0], Number(localStorage.getItem('studio:rw')) || 340)); } catch { return 340; } })();
|
|
196
|
+
const icon = (n) => `<svg class="ico" viewBox="0 0 20 20">${I[n]}</svg>`;
|
|
197
|
+
let tt; const toast = (t, ms = 1900) => { const e = $('toast'); e.textContent = t; e.classList.add('on'); clearTimeout(tt); tt = setTimeout(() => e.classList.remove('on'), ms); };
|
|
198
|
+
const STEP = { brief: 'what', inventory: 'what', video: 'video', final: 'export' };
|
|
199
|
+
const tile = (o, attr, on) => `<button class="tile${on ? ' on' : ''}" ${attr} ${o.clip ? `data-clip="${esc(o.clip)}"` : ''}><div class="ph" ${o.poster ? `style="background-image:url('${esc(o.poster)}')"` : ''}></div><div class="lb">${esc(o.title)}${o.summary ? `<small>${esc(o.summary)}</small>` : ''}</div></button>`;
|
|
200
|
+
const swatch = (b) => `<div class="ph" data-swatch="${esc(JSON.stringify(b.entry ?? {}))}"><div class="bars">${(b.colors?.length ? b.colors : ['#111', '#333']).map((c) => `<i style="background:${esc(c)}"></i>`).join('')}</div></div>`;
|
|
165
201
|
|
|
166
202
|
async function load() {
|
|
167
203
|
if (!piece) {
|
|
@@ -170,221 +206,313 @@ async function load() {
|
|
|
170
206
|
else return renderList(list);
|
|
171
207
|
}
|
|
172
208
|
const res = await fetch(`/api/piece?p=${encodeURIComponent(piece)}`);
|
|
173
|
-
if (!res.ok) {
|
|
209
|
+
if (!res.ok) { $('app').innerHTML = `<div class="empty">There is no piece “${esc(piece)}” in this project.</div>`; return; }
|
|
174
210
|
const text = await res.text();
|
|
175
211
|
if (text === raw) return;
|
|
176
212
|
raw = text; P = JSON.parse(text);
|
|
177
|
-
|
|
178
|
-
if (!view || view === followed) view = P.stage;
|
|
179
|
-
followed = P.stage;
|
|
213
|
+
if (!view) view = STEP[P.stage] ?? 'what';
|
|
180
214
|
render();
|
|
181
215
|
}
|
|
182
|
-
|
|
183
|
-
function captureFocus() {
|
|
184
|
-
const a = document.activeElement;
|
|
185
|
-
if (!a || a === document.body || !(a.isContentEditable || /INPUT|TEXTAREA|SELECT/.test(a.tagName))) return null;
|
|
186
|
-
const sel = a.id ? `#${a.id}` : Object.entries(a.dataset).map(([k, v]) => `[data-${k.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`)}="${CSS.escape(v)}"]`).join('');
|
|
187
|
-
if (!sel) return null;
|
|
188
|
-
const range = a.isContentEditable ? (() => { const r = getSelection(); return r.rangeCount ? [r.anchorOffset, r.focusOffset] : null; })() : [a.selectionStart, a.selectionEnd];
|
|
189
|
-
return { sel, value: a.isContentEditable ? a.innerText : a.value, editable: a.isContentEditable, range };
|
|
190
|
-
}
|
|
191
|
-
function restoreFocus(f) {
|
|
192
|
-
const el = f && document.querySelector(f.sel); if (!el) return;
|
|
193
|
-
if (f.editable) el.innerText = f.value; else el.value = f.value;
|
|
194
|
-
el.focus({ preventScroll: true });
|
|
195
|
-
try {
|
|
196
|
-
if (f.editable && el.firstChild && f.range) { const r = document.createRange(), n = el.firstChild, len = n.textContent.length; r.setStart(n, Math.min(f.range[0], len)); r.setEnd(n, Math.min(f.range[1], len)); const g = getSelection(); g.removeAllRanges(); g.addRange(r); }
|
|
197
|
-
else if (f.range && el.setSelectionRange) el.setSelectionRange(f.range[0], f.range[1]);
|
|
198
|
-
} catch {}
|
|
199
|
-
}
|
|
200
|
-
async function act(body) {
|
|
216
|
+
async function act(body, quiet) {
|
|
201
217
|
const res = await fetch(`/api/piece?p=${encodeURIComponent(piece)}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
|
202
218
|
const r = await res.json();
|
|
203
|
-
|
|
219
|
+
if (r.error) toast(`Not done: ${r.error}`, 3200); else if (!quiet) toast(r.say ?? 'Saved');
|
|
204
220
|
raw = ''; await load();
|
|
221
|
+
return r;
|
|
205
222
|
}
|
|
206
|
-
let tt; function toast(t) { const e = document.getElementById('toast'); e.textContent = t; e.classList.add('on'); clearTimeout(tt); tt = setTimeout(() => e.classList.remove('on'), 1800); }
|
|
207
|
-
setInterval(() => load().catch(() => {}), 1500);
|
|
208
|
-
|
|
209
223
|
function renderList(list) {
|
|
210
|
-
|
|
211
|
-
${list.length ? list.map((x) => `<a href="?p=${encodeURIComponent(x.piece)}"
|
|
224
|
+
$('app').innerHTML = `<header class="top"><div class="ttl">paper-motion studio</div></header><div class="list"><h1>Pieces</h1>
|
|
225
|
+
${list.length ? list.map((x) => `<a class="lrow" href="?p=${encodeURIComponent(x.piece)}"><b>${esc(x.title)}</b><span class="muted mono">pieces/${esc(x.piece)}</span></a>`).join('') : '<p class="muted">No pieces yet. Ask your agent for a video.</p>'}</div>`;
|
|
212
226
|
}
|
|
213
227
|
|
|
214
|
-
// ───
|
|
215
|
-
function
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
228
|
+
// ─── the frame: steps, whose turn it is ───────────────────────────────────────────────────────────────────────────
|
|
229
|
+
function turn() {
|
|
230
|
+
if (misses >= 3) return ['off', 'The studio stopped', 'Ask your agent to start it again.'];
|
|
231
|
+
if (P.ask) return ['ask', 'Your agent asked you something in the chat', P.ask.text];
|
|
232
|
+
if (P.building) return ['agent', 'Building your video', 'From what you kept. A few seconds.'];
|
|
233
|
+
if (P.rendering) return ['agent', 'Rendering the final', P.rendering.joining ? 'Joining the parts' : P.rendering.part ? `Part ${P.rendering.part} of ${P.rendering.of}` : 'Starting'];
|
|
234
|
+
const st = P.stages[P.stage]?.status, A = P.agent ?? {};
|
|
235
|
+
if (st === 'review') return ['yours', 'Your turn', A.listening ? 'Your agent waits while you edit. Every change reaches it.' : 'Edit freely; when you are done, tell your agent “continue” in the chat.'];
|
|
236
|
+
if (st === 'approved' && P.stage === 'final') return ['done', 'Finished', 'Your files are in Export.'];
|
|
237
|
+
return ['agent', 'Your agent is working', 'It shows up here when it is ready for you.'];
|
|
219
238
|
}
|
|
220
|
-
function
|
|
221
|
-
const
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
<
|
|
227
|
-
|
|
239
|
+
function frame(body) {
|
|
240
|
+
const [k, head, tx] = turn(), step = STEP[P.stage], A = P.agent ?? {};
|
|
241
|
+
const done = (s) => ({ what: P.stages.inventory?.status === 'approved', video: P.stages.video?.status === 'approved', export: P.stages.final?.status === 'approved' }[s]);
|
|
242
|
+
document.title = `${k === 'yours' ? '● ' : ''}${P.title} · studio`;
|
|
243
|
+
return `<header class="top"><a class="back" href="/">${icon('back')}Pieces</a><div class="ttl">${esc(P.title)}</div>
|
|
244
|
+
<nav class="steps" aria-label="Steps">${[['what', 'What it says'], ['video', 'Your video'], ['export', 'Export']].map(([id, t], i) => `<button class="${view === id ? 'on' : ''}" data-view="${id}"><b>${i + 1}</b>${t}${done(id) ? '<i title="approved"></i>' : ''}</button>`).join('')}</nav>
|
|
245
|
+
<button class="btn" data-undo ${P.undo ? `title="Undo ${esc(P.undo)}"` : 'disabled'}>${icon('undo')}Undo</button>
|
|
246
|
+
<button class="btn" data-style>${icon('style')}Style</button>
|
|
247
|
+
<button class="btn" data-ask>${icon('ask')}Ask your agent</button></header>
|
|
248
|
+
<div class="turn ${k}"><i class="mk"></i><span><b>${esc(head)}</b><span class="tx">${esc(tx)}</span></span><span class="grow"></span>
|
|
249
|
+
${A.focus && (k === 'yours' || k === 'ask') ? `<button class="btn sm" data-focus>Go to ${esc(A.app)} ${icon('out')}</button>` : ''}</div>
|
|
250
|
+
<main>${body}</main>`;
|
|
228
251
|
}
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
252
|
+
|
|
253
|
+
// ─── 1 · what it says ─────────────────────────────────────────────────────────────────────────────────────────────
|
|
254
|
+
const BRIEF = [['what', 'What'], ['who', 'For whom'], ['format', 'Format'], ['tone', 'Tone'], ['action', 'At the end']];
|
|
255
|
+
function whatHTML() {
|
|
256
|
+
const inv = P.inventory ?? { items: [] }, items = inv.items.filter((i) => i.status !== 'removed' || true);
|
|
257
|
+
const wait = items.filter((i) => i.status === 'proposed').length, kept = items.filter((i) => i.status === 'kept').length;
|
|
258
|
+
const briefOpen = P.stage === 'brief' || P.stages.brief?.status !== 'approved';
|
|
259
|
+
// what can be corrected right in the row: a sentence, a command, a number and what it counts
|
|
260
|
+
const EDITS = { line: ['text'], command: ['command'], figure: ['value', 'label'], share: ['value', 'label'], change: ['from', 'to', 'label'], screen: ['caption'] };
|
|
261
|
+
const inline = (i) => (EDITS[i.kind] ?? []).map((k) => i.fields?.find((f) => f[0] === k)).filter(Boolean);
|
|
262
|
+
const sentence = (i) => inline(i).length > 0;
|
|
263
|
+
const rows = items.map((i) => `<div class="fact${i.status === 'removed' ? ' out' : ''}">
|
|
264
|
+
${i.thumb ? `<img src="${esc(i.thumb)}" alt="">` : '<span></span>'}
|
|
265
|
+
<div><div class="k">${esc(i.name)}${i.status === 'proposed' ? '<span class="new">To decide</span>' : ''}${i.must ? '<span class="new">Must appear</span>' : ''}</div>
|
|
266
|
+
${sentence(i) && i.status !== 'removed' ? `<div class="t">${inline(i).map(([k, v]) => `<span class="edit" contenteditable="plaintext-only" spellcheck="false" data-inv="${esc(i.id)}" data-key="${esc(k)}" data-placeholder="${esc(k)}">${esc(v)}</span>`).join(i.kind === 'change' ? ' → ' : ' ')}</div>` : `<div class="t">${esc(i.what)}</div>`}
|
|
267
|
+
<div class="s">From ${esc(i.source || 'you')}${i.missing?.length ? ` · still needs ${esc(i.missing.join(', ').toLowerCase())}` : ''}</div></div>
|
|
268
|
+
<div class="acts">${i.status === 'removed' ? `<button class="btn sm" data-inv-set="${esc(i.id)}" data-v="kept">Keep it</button>`
|
|
269
|
+
: `${i.status === 'proposed' ? `<button class="btn sm primary" data-inv-set="${esc(i.id)}" data-v="kept">Keep</button>` : `<button class="btn sm${i.must ? ' on' : ''}" data-inv-must="${esc(i.id)}" data-v="${i.must ? '' : '1'}">★ ${i.must ? 'Must appear' : 'Must?'}</button>`}<button class="btn sm" data-inv-set="${esc(i.id)}" data-v="removed">Not this</button>`}</div></div>`).join('');
|
|
270
|
+
const atInv = ['brief', 'inventory'].includes(P.stage), hasVideo = !!P.video;
|
|
271
|
+
return `<div class="what">
|
|
272
|
+
<h1>What your video says</h1>
|
|
273
|
+
<p class="sub">Your agent read what you already have and found these. Keep what is true, star what must appear, click a sentence to change it. How it looks is the next step.</p>
|
|
274
|
+
<details class="brief"${briefOpen ? ' open' : ''}><summary><span><span class="lbl">The brief</span> ${esc(P.brief.what)}</span><span class="muted">${P.stages.brief?.status === 'approved' ? 'approved' : 'to check'}</span></summary>
|
|
275
|
+
<div class="rows">${BRIEF.map(([k, l]) => `<div class="row"><span class="muted">${l}</span><div class="edit" contenteditable="plaintext-only" spellcheck="false" data-brief="${k}" data-placeholder="Empty">${esc(P.brief[k])}</div></div>`).join('')}
|
|
276
|
+
${P.stage === 'brief' && P.stages.brief?.status === 'review' ? `<div><button class="btn primary sm" data-approve="brief">The brief is right</button></div>` : ''}</div></details>
|
|
277
|
+
${items.length ? rows : `<p class="muted">${P.stage === 'brief' ? 'Once the brief is right, your agent brings what your video could say.' : 'Your agent is looking for what your video could say.'}</p>`}
|
|
278
|
+
${inv.kinds && Object.keys(inv.kinds).length ? `<div class="addfact"><select id="addKind" aria-label="What is it">${Object.entries(inv.kinds).map(([id, k]) => `<option value="${id}">${esc(k.name)}</option>`).join('')}</select><input id="addText" placeholder="Add something true: a sentence, or a number and what it counts (163 pieces of motion)"><button class="btn" data-add-fact>Add</button></div>` : ''}
|
|
279
|
+
<div class="nextbar"><span class="muted">${kept} thing${kept === 1 ? '' : 's'} in your video${wait ? ` · ${wait} still to decide` : ''}${inv.moments ? ` · a ${inv.seconds} s video holds about ${inv.moments} moments` : ''}</span>
|
|
280
|
+
${hasVideo ? `<button class="btn primary" data-view="video">Edit your video →</button>`
|
|
281
|
+
: atInv && P.stages.inventory?.status === 'review' && (kept || wait) ? `<button class="btn primary" data-make>${wait ? 'Keep the rest and make my video →' : 'Make my video →'}</button>`
|
|
282
|
+
: P.building ? '<span class="muted">Building your video…</span>' : ''}</div></div>`;
|
|
235
283
|
}
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
${
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
284
|
+
|
|
285
|
+
// ─── 2 · your video ───────────────────────────────────────────────────────────────────────────────────────────────
|
|
286
|
+
const cur = () => P.video?.parts.find((p) => p.dir === sel) ?? P.video?.parts[0];
|
|
287
|
+
const joinName = (id) => P.library?.joins.find((j) => j.id === id)?.title ?? id;
|
|
288
|
+
const firstWords = (p) => { const w = p.words.find((f) => f.kind === 'text' && f.value) ?? p.words.find((f) => f.value); return w ? String(w.value).split('\n')[0] : p.summary; };
|
|
289
|
+
function videoHTML() {
|
|
290
|
+
const V = P.video;
|
|
291
|
+
if (!V) return `<div class="empty">${P.building ? 'Building your video from what you kept…' : 'Your video appears here once you keep what it says.'}<div style="margin-top:16px"><button class="btn" data-view="what">Go to What it says</button></div></div>`;
|
|
292
|
+
const p = cur(); sel = p?.dir;
|
|
293
|
+
const shape = V.format === '1080x1920' ? ' tall' : V.format === '1080x1080' ? ' sq' : '';
|
|
294
|
+
return `<div class="editor"><div class="center">
|
|
295
|
+
<div class="player${shape}" id="player"><span class="badge" id="pbadge"></span></div>
|
|
296
|
+
<div class="pbar"><span id="pinfo"></span><span class="grow"></span><span>${V.total} s · ${V.parts.length} parts</span>
|
|
297
|
+
<button class="btn sm" data-playall>${icon('play')}${playing ? 'Stop' : 'Play the whole video'}</button>
|
|
298
|
+
${P.stage === 'video' && P.stages.video?.status === 'review' ? `<button class="btn primary sm" data-approve="video">It looks right → Export</button>` : `<button class="btn sm" data-view="export">Export →</button>`}</div>
|
|
299
|
+
<div><p class="lbl" style="margin:4px 0 8px">Your video, in order · drag to reorder · + adds a part · the arrow is the transition</p>
|
|
300
|
+
<div class="track" id="track">${V.parts.map((x, i) => `${i === 0 ? `<div class="addp"><button data-addat="0" title="Add a part here">${icon('add')}</button></div>` : ''}
|
|
301
|
+
<div class="part${x.dir === sel ? ' sel' : ''}" draggable="true" data-part="${esc(x.dir)}" data-clip="${esc(x.clip)}">
|
|
302
|
+
<div class="th" style="background-image:url('${esc(x.poster)}')"><span class="n">${String(i + 1).padStart(2, '0')}</span><span class="sec">${Number(x.seconds).toFixed(1)}s</span></div>
|
|
303
|
+
<div class="meta"><div class="nm">${esc(x.title)}</div><div class="tx">${esc(firstWords(x))}</div></div></div>
|
|
304
|
+
${x.join ? `<div class="join"><button data-joinof="${esc(x.dir)}" title="Transition: ${esc(joinName(x.join))}">${icon('join')}</button><small>${esc(joinName(x.join))}</small></div>` : ''}
|
|
305
|
+
<div class="addp"><button data-addat="${i + 1}" title="Add a part here">${icon('add')}</button></div>`).join('')}</div></div>
|
|
306
|
+
</div><aside class="insp" id="insp">${p ? inspHTML(p) : ''}</aside></div>`;
|
|
258
307
|
}
|
|
259
|
-
function
|
|
260
|
-
|
|
261
|
-
const
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
308
|
+
function inspHTML(p) {
|
|
309
|
+
const n = P.video.parts.indexOf(p) + 1;
|
|
310
|
+
const fld = (f) => {
|
|
311
|
+
const a = `data-word="${esc(f.key)}"`;
|
|
312
|
+
if (f.kind === 'image') { const imgs = P.style?.images ?? []; return `<div class="fld">${esc(f.label)}<div class="pick"><img src="/files/${esc(String(f.value).replace(/^\//, ''))}" alt=""></div>${imgs.length > 1 ? `<select ${a}>${imgs.map((m) => `<option value="${esc(m)}"${m === f.value ? ' selected' : ''}>${esc(m.split('/').pop())}</option>`).join('')}</select>` : ''}<p class="hint">Another picture? Put it in brand/assets, or ask your agent.</p></div>`; }
|
|
313
|
+
if (f.kind === 'rows') return `<label class="fld">${esc(f.label)} <i>one per line${f.columns > 1 ? ', as value | what it is' : ''}</i><textarea ${a} rows="${Math.min(8, String(f.value).split('\n').length + 1)}">${esc(f.value)}</textarea></label>`;
|
|
314
|
+
if (f.kind === 'lines') return `<label class="fld">${esc(f.label)} <i>one per line</i><textarea ${a} rows="${Math.min(8, String(f.value).split('\n').length + 1)}">${esc(f.value)}</textarea></label>`;
|
|
315
|
+
if (f.kind === 'block') return `<label class="fld">${esc(f.label)}<textarea class="code" ${a} rows="7">${esc(f.value)}</textarea></label>`;
|
|
316
|
+
if (String(f.value).length > 44) return `<label class="fld">${esc(f.label)}<textarea ${a} rows="2">${esc(f.value)}</textarea></label>`;
|
|
317
|
+
return `<label class="fld">${esc(f.label)}<input ${a} value="${esc(f.value)}"></label>`;
|
|
318
|
+
};
|
|
319
|
+
const B = P.inventory?.backgrounds ?? { mine: [], library: [] };
|
|
320
|
+
const panes = {
|
|
321
|
+
words: p.words.length ? p.words.map(fld).join('') : '<p class="hint">This part has no words of its own.</p>',
|
|
322
|
+
motion: p.motion ? `<p class="hint">How it arrives. Point at one to see it move.</p><div class="tiles">${p.motion.demos.map((d) => tile(d, `data-motion="${esc(d.value)}"`, d.value === p.motion.value)).join('')}</div>`
|
|
323
|
+
: `<p class="hint">This part has its own way of moving.</p><div class="tiles">${tile({ title: p.title, summary: p.summary, poster: p.poster, clip: p.clip }, 'disabled', true)}</div><button class="btn sm" data-swap>Show it another way</button>`,
|
|
324
|
+
bg: !p.takesBackground ? '<p class="hint">This part draws its own background, so it has none to change. Show it another way to use one.</p>' : `<p class="hint">Behind this part. Point at one to see it move.</p><div class="tiles">${[{ id: '', title: "Your brand's first", colors: [] , entry: null }, ...B.mine].map((b) => `<button class="tile${(p.background ?? '') === b.id ? ' on' : ''}" data-bg="${esc(b.id)}">${b.entry ? swatch(b) : '<div class="ph" style="display:grid;place-items:center;color:var(--muted);font-size:12px">default</div>'}<div class="lb">${esc(b.title)}</div></button>`).join('')}</div>
|
|
325
|
+
<div style="display:flex;gap:8px;flex-wrap:wrap"><button class="btn sm" data-bgall>Use this background on every part</button>${B.library.length ? `<button class="btn sm" data-bgmore>More backgrounds (${B.library.length})</button>` : ''}</div>`,
|
|
326
|
+
tune: `${p.tune.map((t) => t.kind === 'number'
|
|
327
|
+
? `<div class="slider"><div>${esc(t.label)}<em id="tv-${esc(t.name)}">${esc(t.value)}</em></div><input type="range" min="${t.min}" max="${t.max}" step="${t.step}" value="${esc(t.value)}" data-tune="${esc(t.name)}"></div>`
|
|
328
|
+
: `<div class="fld">${esc(t.label)}<div class="seg">${t.options.map((o) => `<button class="btn sm${String(t.value) === String(o) ? ' on' : ''}" data-tunev="${esc(t.name)}" data-v="${esc(o)}">${esc(o)}</button>`).join('')}</div></div>`).join('')}
|
|
329
|
+
<div class="slider"><div>Length<em id="tv-seconds">${Number(p.seconds).toFixed(1)} s</em></div><input type="range" min="${p.range.min}" max="${p.range.max}" step="0.1" value="${p.seconds}" data-seconds data-natural="${p.duration}"></div>
|
|
330
|
+
<p class="hint" id="tv-pace">${pace(p.seconds, p.duration)}</p>`,
|
|
331
|
+
};
|
|
332
|
+
return `<div class="lbl">Part ${n} of ${P.video.parts.length}</div><h2>${esc(p.title)}</h2><p class="hint sum">${esc(p.summary)}</p>
|
|
333
|
+
<div class="tabs">${[['words', 'Words'], ['motion', 'Motion'], ['bg', 'Background'], ['tune', 'Tune']].map(([k, l]) => `<button class="${tab === k ? 'on' : ''}" data-tab="${k}">${l}</button>`).join('')}</div>
|
|
334
|
+
<div class="pane">${panes[tab]}</div>
|
|
335
|
+
<div class="partacts"><button class="btn sm" data-swap>Show it another way</button><button class="btn sm" data-dup>Duplicate</button><button class="btn sm" data-remove>Remove</button></div>`;
|
|
268
336
|
}
|
|
269
|
-
|
|
270
|
-
|
|
337
|
+
// the player: the part's real scene, playing live; kept across redraws, reloaded only when the part changed
|
|
338
|
+
function paintPlayer() {
|
|
339
|
+
const box = $('player'), p = cur(); if (!box || !p) return;
|
|
340
|
+
$('pbadge').textContent = `${String(P.video.parts.indexOf(p) + 1).padStart(2, '0')} · ${p.title}`;
|
|
341
|
+
const info = [p.motion ? p.motion.demos.find((d) => d.value === p.motion.value)?.title : null, p.background ? `${p.background} background` : null, `${Number(p.seconds).toFixed(1)} s`].filter(Boolean).join(' · ');
|
|
342
|
+
$('pinfo').textContent = info;
|
|
343
|
+
let f = box.querySelector('iframe');
|
|
344
|
+
if (!p.live) { if (!box.querySelector('.msg')) box.insertAdjacentHTML('beforeend', '<div class="msg">The live preview is starting…</div>'); return; }
|
|
345
|
+
box.querySelector('.msg')?.remove();
|
|
346
|
+
if (!f) { f = document.createElement('iframe'); f.title = 'Live preview'; box.append(f); }
|
|
347
|
+
if (f.dataset.src !== p.live) { f.dataset.src = p.live; f.src = p.live; }
|
|
271
348
|
}
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
349
|
+
|
|
350
|
+
// ─── 3 · export ───────────────────────────────────────────────────────────────────────────────────────────────────
|
|
351
|
+
function exportHTML() {
|
|
352
|
+
const M = P.media ?? {}, out = (M.outputs ?? []).filter((o) => /\.(mp4|mov|webm|gif)$/i.test(o.file));
|
|
353
|
+
const stale = P.stages.final?.status === 'changes';
|
|
354
|
+
return `<div class="exp"><h1>Export</h1>
|
|
355
|
+
${!P.video ? '<p class="muted">Your video comes first.</p>' : `
|
|
356
|
+
<p class="muted">${P.video.parts.length} parts, ${P.video.total} s, ${esc(P.video.format)}. Full quality, with motion blur and your sound.${stale ? ' <b style="color:var(--warn)">You changed the video since the last render.</b>' : ''}</p>
|
|
357
|
+
<div>${P.rendering ? `<button class="btn" disabled>Rendering… ${P.rendering.joining ? 'joining' : P.rendering.part ? `part ${P.rendering.part} of ${P.rendering.of}` : ''}</button>` : `<button class="btn primary" data-render>${out.length ? 'Render again' : 'Render in full quality'}</button>`} <span class="muted" style="margin-left:10px">a minute or two</span></div>
|
|
358
|
+
${M.final ? `<video src="${esc(M.final)}" controls playsinline></video>` : ''}
|
|
359
|
+
<div style="display:grid;gap:8px">${out.map((o) => `<div class="file"><span>${esc(o.file)} <span class="muted">· ${(o.bytes / 1e6).toFixed(1)} MB</span></span><a class="btn sm" href="${esc(o.url)}" download>Download</a></div>`).join('')}</div>
|
|
360
|
+
${P.stage === 'final' && P.stages.final?.status === 'review' ? `<div><button class="btn" data-approve="final">This is the one</button></div>` : ''}`}</div>`;
|
|
277
361
|
}
|
|
278
362
|
|
|
363
|
+
// ─── drawing ──────────────────────────────────────────────────────────────────────────────────────────────────────
|
|
279
364
|
function render() {
|
|
280
365
|
if (!P) return;
|
|
281
|
-
|
|
282
|
-
const
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
366
|
+
// a field being typed in is never redrawn under the cursor: the page catches up when it loses focus
|
|
367
|
+
const a = document.activeElement;
|
|
368
|
+
if (a && a.closest?.('#app') && (a.matches('input, textarea, select') || a.isContentEditable)) { pending = true; return; }
|
|
369
|
+
pending = false;
|
|
370
|
+
const body = view === 'what' ? whatHTML() : view === 'video' ? videoHTML() : exportHTML();
|
|
371
|
+
const keepPlayer = $('player')?.querySelector('iframe');
|
|
372
|
+
const scroll = { insp: $('insp')?.scrollTop ?? 0, track: $('track')?.scrollLeft ?? 0 };
|
|
373
|
+
$('app').innerHTML = frame(body);
|
|
374
|
+
if (keepPlayer && $('player')) { $('player').append(keepPlayer); }
|
|
375
|
+
paintPlayer();
|
|
376
|
+
if ($('insp')) $('insp').scrollTop = scroll.insp;
|
|
377
|
+
if ($('track')) $('track').scrollLeft = scroll.track;
|
|
378
|
+
if (sheetOpen) paintSheet();
|
|
379
|
+
}
|
|
380
|
+
let pending = false;
|
|
381
|
+
|
|
382
|
+
// ─── sheets: add, show another way, transition, more backgrounds, style ──────────────────────────────────────────
|
|
383
|
+
let sheetOpen = null;
|
|
384
|
+
function openSheet(kind, arg) { sheetOpen = { kind, arg }; paintSheet(); $('sheet').classList.add('on'); $('scrim').classList.add('on'); }
|
|
385
|
+
function closeSheet() { sheetOpen = null; $('sheet').classList.remove('on'); $('scrim').classList.remove('on'); }
|
|
386
|
+
let lastSheet = '';
|
|
387
|
+
function paintSheet() {
|
|
388
|
+
const s = sheetOpen; if (!s || !P) return;
|
|
389
|
+
const key = JSON.stringify([s, P.style, P.video?.parts.map((p) => [p.dir, p.join, p.background, p.id]), P.inventory?.backgrounds?.mine?.length]);
|
|
390
|
+
if (key === lastSheet) return; lastSheet = key;
|
|
391
|
+
const head = (small, title) => `<header><div><span class="lbl">${esc(small)}</span><h2>${esc(title)}</h2></div><button class="btn sm" data-close>Close</button></header>`;
|
|
392
|
+
const L = P.library ?? { intents: [], joins: [] };
|
|
393
|
+
let h = '';
|
|
394
|
+
if (s.kind === 'add') h = head(`Add a part · position ${s.arg + 1}`, 'What do you want to add?') + `<p class="hint" style="margin-top:14px">Point at one to see it move. It arrives with something you kept, when it can show it.</p>` + L.intents.map((g) => `<div class="grp"><h3 class="lbl">${esc(g.title)}</h3><div class="tiles">${g.blocks.map((b) => tile(b, `data-addblock="${esc(b.id)}"`)).join('')}</div></div>`).join('');
|
|
395
|
+
else if (s.kind === 'swap') { const p = cur(), mine = L.intents.find((g) => g.blocks.some((b) => b.id === p.id)); h = head(`Part ${P.video.parts.indexOf(p) + 1}`, 'Show it another way') + `<p class="hint" style="margin-top:14px">Same content, shown differently. Point at one to see it move.</p>` + [mine, ...L.intents.filter((g) => g !== mine)].filter(Boolean).map((g, i) => `<div class="grp"><h3 class="lbl">${i ? esc(g.title) : `${esc(g.title)} · like this part`}</h3><div class="tiles">${g.blocks.map((b) => tile(b, `data-swapto="${esc(b.id)}"`, b.id === p.id)).join('')}</div></div>`).join(''); }
|
|
396
|
+
else if (s.kind === 'join') { const p = P.video.parts.find((x) => x.dir === s.arg), i = P.video.parts.indexOf(p); h = head(`Between part ${i + 1} and ${i + 2}`, 'How one leads to the next') + `<p class="hint" style="margin-top:14px">Point at one to see it move.</p><div class="grp"><div class="tiles">${L.joins.map((j) => tile(j, `data-setjoin="${esc(j.id)}"`, p.join === j.id)).join('')}</div></div><button class="btn sm" data-joinall style="margin-top:16px">Use ${esc(joinName(p.join))} everywhere</button>`; }
|
|
397
|
+
else if (s.kind === 'bgmore') { const B = P.inventory?.backgrounds ?? { library: [] }; h = head('Backgrounds', 'Add a background') + `<p class="hint" style="margin-top:14px">Point at one to see it move. Adding one puts it in your brand, in your colours, and uses it on this part.</p><div class="grp"><div class="tiles">${B.library.map((b) => `<button class="tile" data-bgadd="${esc(b.id)}">${swatch(b)}<div class="lb">${esc(b.title)}</div></button>`).join('')}</div></div>`; }
|
|
398
|
+
else if (s.kind === 'style') {
|
|
399
|
+
const S = P.style ?? {}, c = S.colors ?? {};
|
|
400
|
+
h = head('Your whole video', 'Style') + `
|
|
401
|
+
<div class="grp"><h3 class="lbl">Colours</h3><div class="pals">${(S.palettes ?? []).map((p) => `<button class="pal" data-palette="${esc(p.id)}">${p.colors.slice(0, 3).map((x) => `<i style="background:${esc(x)}"></i>`).join('')}<span>${esc(p.title)}</span></button>`).join('')}</div>
|
|
402
|
+
<p class="hint" style="margin:14px 0 8px">Or set each one yourself</p>
|
|
403
|
+
<div class="colors">${[['ground', 'Background'], ['figure', 'Text'], ['accent', 'Accent']].map(([k, l]) => `<label>${l}<input type="color" value="${esc(c[k] ?? '#000000')}" data-color="${k}"></label>`).join('')}</div></div>
|
|
404
|
+
<div class="grp"><h3 class="lbl">Type <span class="muted" style="text-transform:none;letter-spacing:0;font-family:var(--sans)">· now ${esc(S.fonts?.display ?? '')} and ${esc(S.fonts?.text ?? '')}</span></h3><div class="fonts">${(S.fontpairs ?? []).map((f) => `<button class="font" data-fontpair="${esc(f.id)}">${esc(f.title)}</button>`).join('')}</div></div>
|
|
405
|
+
<div class="grp"><h3 class="lbl">Pace</h3><div class="seg">${['calm', 'medium', 'fast'].map((x) => `<button class="btn${S.pace === x ? ' on' : ''}" data-pace="${x}">${x[0].toUpperCase() + x.slice(1)}</button>`).join('')}</div></div>
|
|
406
|
+
<p class="hint" style="margin-top:22px">Style is your brand's: it changes every part of this video, and every video after it.</p>`;
|
|
407
|
+
}
|
|
408
|
+
$('sheet').innerHTML = h;
|
|
323
409
|
}
|
|
324
|
-
const vcache = new Map(); // videos keep playing across re-renders (the key includes the file's mtime)
|
|
325
410
|
|
|
326
|
-
// ─── wiring
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
if (
|
|
331
|
-
|
|
332
|
-
else if (
|
|
333
|
-
else if (d.
|
|
334
|
-
else if (d.
|
|
411
|
+
// ─── wiring ───────────────────────────────────────────────────────────────────────────────────────────────────────
|
|
412
|
+
const vact = (type, extra = {}) => act({ type: `v-${type}`, part: sel, ...extra });
|
|
413
|
+
document.addEventListener('click', async (e) => {
|
|
414
|
+
if (e.target.id === 'scrim') return closeSheet();
|
|
415
|
+
const b = e.target.closest('button, .part'); if (!b || b.disabled) return; const d = b.dataset;
|
|
416
|
+
if (d.view) { view = d.view; closeSheet(); render(); scrollTo({ top: 0 }); }
|
|
417
|
+
else if ('close' in d) closeSheet();
|
|
418
|
+
else if (d.part !== undefined && !e.target.closest('button')) { sel = d.part; render(); }
|
|
419
|
+
else if (d.tab) { tab = d.tab; render(); }
|
|
420
|
+
else if ('undo' in d) act({ type: 'undo' });
|
|
421
|
+
else if ('style' in d) openSheet('style');
|
|
422
|
+
else if ('ask' in d) $('ask').classList.add('on');
|
|
423
|
+
else if ('askClose' in d) $('ask').classList.remove('on');
|
|
424
|
+
else if ('askSend' in d) { const t = $('askText').value.trim(); if (!t) return; await act({ type: 'changes', stage: P.stage, text: t }, true); $('askText').value = ''; $('ask').classList.remove('on'); toast(P.agent?.listening ? 'Sent to your agent' : 'Saved. Tell your agent “continue” in the chat', 3600); }
|
|
425
|
+
else if ('focus' in d) fetch(`/api/focus?p=${encodeURIComponent(piece)}`, { method: 'POST' });
|
|
426
|
+
else if (d.approve) { await act({ type: 'approve', stage: d.approve }); if (d.approve === 'video') { view = 'export'; render(); } }
|
|
427
|
+
else if ('make' in d) { if (P.inventory.items.some((i) => i.status === 'proposed')) await act({ type: 'inv-rest', value: 'kept' }, true); await act({ type: 'approve', stage: 'inventory' }, true); view = 'video'; toast('Building your video…', 3000); render(); }
|
|
428
|
+
else if (d.invSet) act({ type: 'inv-status', id: d.invSet, value: d.v }, true);
|
|
429
|
+
else if (d.invMust) act({ type: 'inv-must', id: d.invMust, value: !!d.v }, true);
|
|
430
|
+
else if ('addFact' in d) { const k = $('addKind').value, t = $('addText').value.trim(); if (!t) return toast('Write it first'); const m = t.match(/^([\d.,]+%?)\s+(.+)$/); const fields = k === 'line' ? { text: t } : k === 'figure' && m ? { value: m[1], label: m[2] } : k === 'share' && m ? { value: m[1].replace('%', ''), label: m[2] } : { text: t, label: t }; const r = await act({ type: 'inv-add', kind: k, fields }, true); if (!r.error) { $('addText').value = ''; toast('Added'); } }
|
|
431
|
+
else if (d.addat !== undefined) openSheet('add', Number(d.addat));
|
|
432
|
+
else if (d.addblock) { closeSheet(); const r = await act({ type: 'v-add', at: sheetOpenAt, block: d.addblock }); if (r.part) { sel = r.part; tab = 'words'; render(); } }
|
|
433
|
+
else if ('swap' in d) openSheet('swap');
|
|
434
|
+
else if (d.swapto) { closeSheet(); const r = await vact('swap', { block: d.swapto }); if (r.part) { sel = r.part; render(); } }
|
|
435
|
+
else if ('dup' in d) { const r = await vact('duplicate'); if (r.part) { sel = r.part; render(); } }
|
|
436
|
+
else if ('remove' in d) { const i = P.video.parts.findIndex((x) => x.dir === sel); const r = await vact('remove'); if (!r.error) { sel = P.video.parts[Math.max(0, i - 1)]?.dir; render(); toast('Removed. Undo brings it back.'); } }
|
|
437
|
+
else if (d.joinof) openSheet('join', d.joinof);
|
|
438
|
+
else if (d.setjoin) { const at = sheetOpen.arg; closeSheet(); act({ type: 'v-join', part: at, value: d.setjoin }); }
|
|
439
|
+
else if ('joinall' in d) { const p = P.video.parts.find((x) => x.dir === sheetOpen.arg); closeSheet(); act({ type: 'v-join', part: p.dir, value: p.join, all: true }); }
|
|
440
|
+
else if (d.motion) vact('motion', { value: d.motion });
|
|
441
|
+
else if (d.bg !== undefined) vact('background', { value: d.bg });
|
|
442
|
+
else if ('bgall' in d) vact('background', { value: cur().background ?? '', all: true });
|
|
443
|
+
else if ('bgmore' in d) openSheet('bgmore');
|
|
444
|
+
else if (d.bgadd) { b.querySelector('.lb').textContent = 'Adding…'; const r = await (await fetch(`/api/background?p=${encodeURIComponent(piece)}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: d.bgadd }) })).json(); if (r.error) return toast(`Not added: ${r.error}`); closeSheet(); vact('background', { value: d.bgadd }); }
|
|
445
|
+
else if (d.tunev) vact('tune', { key: d.tunev, value: d.v });
|
|
446
|
+
else if (d.palette) act({ type: 'style-palette', value: d.palette });
|
|
447
|
+
else if (d.fontpair) act({ type: 'style-font', value: d.fontpair });
|
|
448
|
+
else if (d.pace) act({ type: 'style-pace', value: d.pace });
|
|
449
|
+
else if ('render' in d) act({ type: 'render' });
|
|
450
|
+
else if ('playall' in d) playAll();
|
|
335
451
|
});
|
|
452
|
+
let sheetOpenAt = 0;
|
|
453
|
+
const _open = openSheet; openSheet = (k, a) => { if (k === 'add') sheetOpenAt = a; _open(k, a); };
|
|
454
|
+
function playAll() {
|
|
455
|
+
if (playing) { clearTimeout(playing); playing = null; onEnded = null; render(); return; }
|
|
456
|
+
let i = 0; const parts = P.video.parts;
|
|
457
|
+
// each part plays once in the player; the next one starts when it says it ended (or after its length, if it never does)
|
|
458
|
+
const next = () => {
|
|
459
|
+
clearTimeout(playing);
|
|
460
|
+
if (i >= parts.length || view !== 'video') { playing = null; onEnded = null; render(); return; }
|
|
461
|
+
sel = parts[i].dir; render(); onEnded = next; playing = setTimeout(next, (parts[i++].seconds + 3) * 1000);
|
|
462
|
+
};
|
|
463
|
+
playing = 1; next();
|
|
464
|
+
}
|
|
465
|
+
let onEnded = null;
|
|
466
|
+
addEventListener('message', (e) => { if (e.data?.pm === 'ended' && onEnded && e.source === $('player')?.querySelector('iframe')?.contentWindow) onEnded(); });
|
|
336
467
|
document.addEventListener('change', (e) => {
|
|
337
468
|
const d = e.target.dataset;
|
|
338
|
-
if (d.
|
|
339
|
-
if (d.
|
|
340
|
-
if (d
|
|
469
|
+
if (d.word !== undefined) vact('word', { key: d.word, value: e.target.value });
|
|
470
|
+
else if (d.tune) vact('tune', { key: d.tune, value: e.target.value });
|
|
471
|
+
else if ('seconds' in d) vact('seconds', { value: e.target.value });
|
|
472
|
+
else if (d.color) act({ type: 'style-color', key: d.color, value: e.target.value });
|
|
473
|
+
});
|
|
474
|
+
document.addEventListener('input', (e) => {
|
|
475
|
+
const d = e.target.dataset; const k = d.tune ?? ('seconds' in d ? 'seconds' : null); if (!k) return;
|
|
476
|
+
const o = $(`tv-${k}`); if (o) o.textContent = k === 'seconds' ? `${Number(e.target.value).toFixed(1)} s` : e.target.value;
|
|
477
|
+
if (k === 'seconds' && $('tv-pace')) $('tv-pace').textContent = pace(Number(e.target.value), Number(d.natural));
|
|
341
478
|
});
|
|
479
|
+
// a part always plays its whole scene: its length is a speed
|
|
480
|
+
function pace(s, natural) {
|
|
481
|
+
const r = natural / s, pct = Math.round(Math.abs(r - 1) * 100);
|
|
482
|
+
return pct < 1 ? 'Plays its whole scene at its own speed.' : `Plays its whole scene ${pct}% ${r > 1 ? 'faster' : 'slower'}.`;
|
|
483
|
+
}
|
|
342
484
|
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.
|
|
485
|
+
const d = e.target.dataset ?? {}, v = e.target.isContentEditable ? e.target.innerText.trim() : null;
|
|
486
|
+
if (d.brief && v !== P.brief[d.brief]) act({ type: 'brief', key: d.brief, value: v }, true);
|
|
487
|
+
else if (d.inv && v) { const it = P.inventory.items.find((i) => i.id === d.inv); if (v !== String(it?.fields?.find((f) => f[0] === d.key)?.[1] ?? '')) act({ type: 'inv-field', id: d.inv, key: d.key, value: v }); }
|
|
488
|
+
setTimeout(() => { if (pending) render(); }, 0);
|
|
346
489
|
});
|
|
347
490
|
document.addEventListener('keydown', (e) => {
|
|
348
|
-
if (e.key
|
|
349
|
-
if (e.
|
|
350
|
-
|
|
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));
|
|
491
|
+
if (e.key === 'Escape') { closeSheet(); $('ask').classList.remove('on'); }
|
|
492
|
+
if (e.key === 'Enter' && e.target.isContentEditable) { e.preventDefault(); e.target.blur(); }
|
|
493
|
+
if ((e.metaKey || e.ctrlKey) && e.key === 'z' && !e.target.closest('input, textarea, [contenteditable]') && P?.undo) { e.preventDefault(); act({ type: 'undo' }); }
|
|
369
494
|
});
|
|
370
|
-
|
|
371
|
-
document.addEventListener('
|
|
372
|
-
|
|
373
|
-
const
|
|
374
|
-
e.
|
|
375
|
-
const
|
|
376
|
-
el.classList.add((horiz ? e.clientX < r.left + r.width / 2 : e.clientY < r.top + r.height / 2) ? 'drop-before' : 'drop-after');
|
|
495
|
+
// samples play while the pointer is on them: a block's clip, or a background's shader running live
|
|
496
|
+
document.addEventListener('mouseover', (e) => {
|
|
497
|
+
const c = e.target.closest?.('[data-clip]'); const ph = c?.querySelector('.ph, .th');
|
|
498
|
+
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); }
|
|
499
|
+
const sw = e.target.closest?.('[data-swatch]');
|
|
500
|
+
if (sw && !sw.querySelector('iframe') && P?.inventory?.live && sw.dataset.swatch !== '{}') { const f = document.createElement('iframe'); f.src = `${P.inventory.live}/__pm/runtime/swatch.html#${encodeURIComponent(sw.dataset.swatch)}`; f.title = 'Live background'; sw.append(f); }
|
|
377
501
|
});
|
|
502
|
+
document.addEventListener('mouseout', (e) => { for (const s of ['[data-clip]', '[data-swatch]']) { const c = e.target.closest?.(s); if (c && !c.contains(e.relatedTarget)) c.querySelector('.ph > video, .th > video, iframe')?.remove(); } });
|
|
503
|
+
// drag a part to reorder
|
|
504
|
+
let from = null;
|
|
505
|
+
document.addEventListener('dragstart', (e) => { const p = e.target.closest?.('.part'); if (!p) return; from = p.dataset.part; p.classList.add('dragging'); });
|
|
506
|
+
document.addEventListener('dragover', (e) => { const p = e.target.closest?.('.part'); if (from === null || !p) return; e.preventDefault(); document.querySelectorAll('.part.drop').forEach((x) => x.classList.remove('drop')); p.classList.add('drop'); });
|
|
378
507
|
document.addEventListener('drop', (e) => {
|
|
379
|
-
const
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
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();
|
|
508
|
+
const p = e.target.closest?.('.part'); if (from === null || !p) return; e.preventDefault();
|
|
509
|
+
const order = P.video.parts.map((x) => x.dir), to = order.indexOf(p.dataset.part), at = order.indexOf(from);
|
|
510
|
+
if (to !== at) { order.splice(at, 1); order.splice(to, 0, from); act({ type: 'v-order', value: order }); }
|
|
511
|
+
from = null;
|
|
386
512
|
});
|
|
387
|
-
document.addEventListener('dragend', () => {
|
|
513
|
+
document.addEventListener('dragend', () => { from = null; document.querySelectorAll('.dragging, .drop').forEach((x) => x.classList.remove('dragging', 'drop')); });
|
|
514
|
+
// the page keeps up with the studio; a few misses in a row mean the studio itself stopped
|
|
515
|
+
setInterval(() => load().then(() => { misses = 0; }).catch(() => { if (++misses === 3) render(); }), 1500);
|
|
388
516
|
load();
|
|
389
517
|
</script>
|
|
390
518
|
</body>
|