retake-demos 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +64 -0
- package/README.md +14 -11
- package/dist/captions.js +9 -10
- package/dist/cards.js +57 -14
- package/dist/cli.js +197 -20
- package/dist/describe.js +2 -1
- package/dist/dryrun.js +182 -2
- package/dist/manifest.js +67 -4
- package/dist/operator/tools.js +50 -9
- package/dist/presets.js +13 -13
- package/dist/record.js +440 -43
- package/dist/render.js +255 -23
- package/dist/tidy.js +103 -0
- package/dist/ui/chat.html +233 -54
- package/dist/ui/server.js +63 -10
- package/package.json +2 -3
- package/site/releases.json +45 -0
- package/skill/SKILL.md +148 -2
- package/demos/example.yaml +0 -65
- package/dist/ui/index.html +0 -724
- package/site/index.html +0 -260
- package/site/logos/avexjets.png +0 -0
package/dist/ui/chat.html
CHANGED
|
@@ -16,13 +16,13 @@
|
|
|
16
16
|
html,body { margin:0; height:100%; background:var(--paper); color:var(--ink); font:15px/1.6 -apple-system, BlinkMacSystemFont, "SF Pro Text", "Helvetica Neue", Inter, Arial, sans-serif; -webkit-font-smoothing:antialiased; }
|
|
17
17
|
button, input { font:inherit; color:inherit; }
|
|
18
18
|
.app { min-height:100%; display:flex; flex-direction:column; }
|
|
19
|
-
.top { display:flex; align-items:center; gap:10px; padding:
|
|
19
|
+
.top { display:flex; align-items:center; gap:10px; padding:0 24px; height:56px; border-bottom:1px solid var(--rule); background:var(--paper); }
|
|
20
20
|
.mark { width:22px; height:22px; border-radius:7px; background:var(--accent); position:relative; flex:none; }
|
|
21
21
|
.mark i { position:absolute; left:5.5px; height:2.5px; border-radius:2px; background:#eceee9; }
|
|
22
22
|
.mark i:nth-child(1){top:4px;width:4px;opacity:.55} .mark i:nth-child(2){top:8px;width:7px;opacity:.8} .mark i:nth-child(3){top:12px;width:11px;background:var(--amber)} .mark i:nth-child(4){top:16px;width:7px;opacity:.8}
|
|
23
23
|
.top h1 { font-size:15px; font-weight:600; margin:0; }
|
|
24
24
|
.controls { position:relative; display:flex; align-items:center; gap:8px; margin-left:8px; }
|
|
25
|
-
.chip { border:1px solid var(--rule2); background:#fff; border-radius:999px; padding:
|
|
25
|
+
.chip { border:1px solid var(--rule2); background:#fff; border-radius:999px; height:32px; padding:0 14px; display:inline-flex; align-items:center; font-size:13px; font-weight:500; color:var(--ink); cursor:pointer; transition:border-color .12s; }
|
|
26
26
|
.chip:hover { border-color:var(--ink); }
|
|
27
27
|
.pop { position:absolute; left:0; top:38px; z-index:40; background:#fff; border:1px solid var(--rule2); border-radius:14px; box-shadow:0 12px 34px rgba(24,28,24,.13); padding:8px; width:290px; display:none; }
|
|
28
28
|
.pop.show { display:block; }
|
|
@@ -36,13 +36,13 @@
|
|
|
36
36
|
@keyframes pulse { 0%,100%{opacity:.35} 50%{opacity:1} }
|
|
37
37
|
.quiet { padding:8px 10px; color:var(--muted); font-size:13px; }
|
|
38
38
|
.sp { flex:1; }
|
|
39
|
-
.wrap { width:100%; margin:0; padding:
|
|
40
|
-
.col-main { max-width:
|
|
39
|
+
.wrap { width:100%; margin:0; padding:24px 24px 56px; display:grid; grid-template-columns:minmax(0,1fr); gap:24px; align-items:start; }
|
|
40
|
+
.col-main { max-width:760px; width:100%; margin:0 auto; min-width:0; }
|
|
41
41
|
/* wide screens: the library floats as its own card at the left, detached
|
|
42
42
|
from the window edge; the video centers in the space beside it. */
|
|
43
43
|
@media (min-width: 1000px) {
|
|
44
|
-
.wrap.two { grid-template-columns:
|
|
45
|
-
.wrap.two .lib { position:sticky; top:
|
|
44
|
+
.wrap.two { grid-template-columns:272px minmax(0,1fr); gap:32px; }
|
|
45
|
+
.wrap.two .lib { position:sticky; top:24px; order:-1; }
|
|
46
46
|
}
|
|
47
47
|
|
|
48
48
|
/* live activity — what an agent is doing to this library right now */
|
|
@@ -58,46 +58,58 @@
|
|
|
58
58
|
.pin.show { display:block; }
|
|
59
59
|
/* The element takes the video's own shape: width follows the height cap, so
|
|
60
60
|
a 1.6:1 take gets no black bars at the sides. */
|
|
61
|
-
.pin video { width:auto; max-width:100%; height:auto; max-height:
|
|
62
|
-
.under { display:flex; align-items:
|
|
63
|
-
.
|
|
61
|
+
.pin video { width:auto; max-width:100%; height:auto; max-height:60vh; margin:0 auto; border-radius:14px; background:#000; display:block; border:1px solid var(--rule2); box-shadow:0 16px 40px rgba(24,28,24,.10); }
|
|
62
|
+
.under { display:flex; align-items:baseline; gap:8px; margin-top:16px; flex-wrap:wrap; }
|
|
63
|
+
.vtitle { font-size:15px; font-weight:600; color:var(--ink); }
|
|
64
|
+
.vmeta { font-size:13px; color:var(--muted); }
|
|
65
|
+
.under a, .under button { color:var(--ink); text-decoration:none; border:1px solid var(--rule2); background:#fff; border-radius:999px; padding:0 14px; cursor:pointer; font-size:13px; font-weight:500; display:inline-flex; align-items:center; transition:border-color .12s; }
|
|
64
66
|
.under a:hover, .under button:hover { border-color:var(--ink); }
|
|
65
67
|
/* A native select with its arrow removed leaves lopsided padding; make it
|
|
66
68
|
sit exactly like the pills beside it, text centred, same height. */
|
|
67
|
-
.under select { color:var(--ink); border:1px solid var(--rule2); background:#fff; border-radius:999px; padding:0 12px; height:
|
|
68
|
-
.under a, .under button { height:
|
|
69
|
+
.under select { color:var(--ink); border:1px solid var(--rule2); background:#fff; border-radius:999px; padding:0 12px; height:32px; font-size:13px; font-weight:500; font-family:inherit; text-align:center; text-align-last:center; cursor:pointer; -webkit-appearance:none; appearance:none; min-width:58px; transition:border-color .12s; }
|
|
70
|
+
.under a, .under button { height:32px; box-sizing:border-box; align-self:center; }
|
|
69
71
|
.under select:hover { border-color:var(--ink); }
|
|
70
|
-
.
|
|
71
|
-
.
|
|
72
|
+
.frames { margin-top:16px; border:1px solid var(--rule); background:var(--card); border-radius:16px; padding:14px 16px; }
|
|
73
|
+
.frames .fhead { display:flex; align-items:center; margin-bottom:10px; }
|
|
74
|
+
.frames .fhead .lab { font:11px ui-monospace, Menlo, monospace; letter-spacing:.08em; text-transform:uppercase; color:var(--muted); }
|
|
75
|
+
.frames .fhead .hint { margin-left:10px; font-size:12.5px; color:var(--muted); }
|
|
76
|
+
.stills { display:flex; gap:8px; overflow-x:auto; padding-bottom:2px; align-items:center; }
|
|
77
|
+
.stills label.up { font-size:12.5px; font-weight:500; color:var(--muted); border:1px dashed var(--rule2); border-radius:8px; height:58px; display:inline-flex; align-items:center; padding:0 14px; cursor:pointer; flex:none; transition:border-color .12s, color .12s; }
|
|
78
|
+
.stills label.up:hover { border-color:var(--ink); color:var(--ink); }
|
|
79
|
+
.stills img { height:58px; border-radius:8px; border:1px solid var(--rule2); cursor:pointer; flex:none; transition:border-color .12s; }
|
|
72
80
|
.stills img:hover { border-color:var(--ink); }
|
|
73
|
-
.stills img.poster, .coverrow img.poster { border-color:var(--
|
|
74
|
-
.coverrow { display:flex; gap:8px; align-items:center;
|
|
75
|
-
.coverrow
|
|
81
|
+
.stills img.poster, .coverrow img.poster { border-color:var(--ink); box-shadow:0 0 0 2px var(--ink); }
|
|
82
|
+
.coverrow { display:flex; gap:8px; align-items:center; flex-wrap:wrap; margin-bottom:8px; }
|
|
83
|
+
.coverrow:empty { display:none; margin:0; }
|
|
84
|
+
.coverrow img { height:58px; border-radius:8px; border:1px solid var(--rule2); cursor:pointer; transition:border-color .12s; }
|
|
76
85
|
.coverrow img:hover { border-color:var(--ink); }
|
|
77
|
-
.coverrow .lab {
|
|
78
|
-
.coverrow label.up { font-size:12.5px; color:var(--muted); border:1px dashed var(--rule2); border-radius:8px; padding:
|
|
86
|
+
.coverrow .lab { display:none; }
|
|
87
|
+
.coverrow label.up { font-size:12.5px; font-weight:500; color:var(--muted); border:1px dashed var(--rule2); border-radius:8px; height:58px; display:inline-flex; align-items:center; padding:0 14px; cursor:pointer; transition:border-color .12s, color .12s; }
|
|
79
88
|
.coverrow label.up:hover { border-color:var(--ink); color:var(--ink); }
|
|
80
89
|
/* the library: one quiet card at the video's side */
|
|
81
|
-
.lib { border:1px solid var(--rule); background:var(--card); border-radius:16px; padding:
|
|
82
|
-
.lib h2 { font:11px ui-monospace, Menlo, monospace; letter-spacing:.
|
|
90
|
+
.lib { border:1px solid var(--rule); background:var(--card); border-radius:16px; padding:12px 8px; position:sticky; top:24px; line-height:1.4; }
|
|
91
|
+
.lib h2 { font:11px ui-monospace, Menlo, monospace; letter-spacing:.08em; text-transform:uppercase; color:var(--muted); font-weight:500; margin:4px 4px 10px 14px; }
|
|
83
92
|
details.group { margin-bottom:0; }
|
|
84
|
-
details.group summary { list-style:none; cursor:pointer; display:flex; align-items:center; gap:
|
|
93
|
+
details.group summary { list-style:none; cursor:pointer; display:flex; align-items:center; gap:8px; padding:0 12px 0 6px; border-radius:10px; font-size:14px; color:var(--muted); height:38px; }
|
|
85
94
|
details.group summary::-webkit-details-marker { display:none; }
|
|
86
95
|
details.group summary:hover { background:#eef1ea; color:var(--ink); }
|
|
87
|
-
details.group summary .tri { transition:transform .15s; font-size:
|
|
88
|
-
details.group summary:hover .tri {
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
details.group summary .fold
|
|
96
|
+
details.group summary .tri { transition:transform .15s; font-size:9px; color:var(--rule2); width:18px; height:18px; display:flex; align-items:center; justify-content:center; flex:none; }
|
|
97
|
+
details.group summary:hover .tri { color:var(--muted); }
|
|
98
|
+
/* The folder mark is quiet: amber belongs to the brand and the live dot,
|
|
99
|
+
not to every row of a list. */
|
|
100
|
+
details.group summary .fold { width:15px; height:12px; flex:none; position:relative; opacity:.8; }
|
|
101
|
+
details.group summary .fold::before { content:""; position:absolute; inset:0; border-radius:2.5px; background:var(--rule2); }
|
|
102
|
+
details.group summary .fold::after { content:""; position:absolute; left:0; top:-2.5px; width:7px; height:4px; border-radius:2px 2px 0 0; background:var(--rule2); }
|
|
103
|
+
details.group[open] summary .fold::before, details.group[open] summary .fold::after { background:var(--amber); opacity:.85; }
|
|
92
104
|
details.group[open] summary .tri { transform:rotate(90deg); }
|
|
93
|
-
details.group summary b { color:var(--ink); font-weight:
|
|
94
|
-
details.group summary small { margin-left:auto; font-size:
|
|
95
|
-
details.group .demo { margin-left:
|
|
96
|
-
.demo { display:flex; align-items:center; gap:
|
|
105
|
+
details.group summary b { color:var(--ink); font-weight:500; font-size:13.5px; flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
|
106
|
+
details.group summary small { margin-left:auto; font-size:11.5px; color:var(--muted); font-variant-numeric:tabular-nums; }
|
|
107
|
+
details.group .demo { margin-left:24px; }
|
|
108
|
+
.demo { display:flex; align-items:center; gap:9px; padding:0 10px; border-radius:9px; cursor:pointer; height:34px; }
|
|
97
109
|
.demo:hover { background:#eef1ea; }
|
|
98
|
-
.demo.on { background:#fff; border:1px solid var(--rule); }
|
|
99
|
-
.demo .nm { flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-size:
|
|
100
|
-
.demo small { color:var(--muted); font-size:
|
|
110
|
+
.demo.on { background:#fff; border:1px solid var(--rule); box-shadow:0 1px 3px rgba(24,28,24,.05); }
|
|
111
|
+
.demo .nm { flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-size:13.5px; }
|
|
112
|
+
.demo small { color:var(--muted); font-size:11.5px; flex:none; font-variant-numeric:tabular-nums; }
|
|
101
113
|
.demo .zap { flex:none; border:0; background:transparent; color:var(--muted); cursor:pointer; font-size:14px; width:26px; height:26px; border-radius:7px; visibility:hidden; }
|
|
102
114
|
.demo .zap:hover { background:#f3e9e6; }
|
|
103
115
|
.demo:hover .zap { visibility:visible; }
|
|
@@ -123,6 +135,42 @@
|
|
|
123
135
|
.empty h2 { color:var(--ink); font-size:17px; margin:0 0 6px; }
|
|
124
136
|
.empty p { margin:0; font-size:14px; }
|
|
125
137
|
.empty code { background:var(--card); border:1px solid var(--rule); border-radius:6px; padding:2px 7px; font:13px ui-monospace, Menlo, monospace; }
|
|
138
|
+
.modal.wide { width:min(94vw, 560px); max-height:82vh; display:flex; flex-direction:column; }
|
|
139
|
+
.modal.wide h3 { display:flex; align-items:baseline; gap:9px; }
|
|
140
|
+
.modal.wide .scroll { overflow-y:auto; margin:0 -4px; padding:0 4px; }
|
|
141
|
+
.rel { border-top:1px solid var(--rule); padding:15px 0; }
|
|
142
|
+
.rel:first-child { border-top:0; padding-top:2px; }
|
|
143
|
+
.relhead { display:flex; flex-wrap:wrap; align-items:baseline; gap:9px; margin-bottom:7px; }
|
|
144
|
+
.relhead b { font-size:14.5px; color:var(--ink); letter-spacing:-.01em; }
|
|
145
|
+
.relmeta { font-size:12px; color:var(--muted); font-variant-numeric:tabular-nums; }
|
|
146
|
+
/* --accent is near-black in this window; the badge needs the amber token or
|
|
147
|
+
it renders dark ink on a dark pill and reads as a redaction bar. */
|
|
148
|
+
.rel.latest .relmeta::after { content:"newest"; margin-left:7px; padding:1.5px 7px; border-radius:99px; background:var(--amber); color:#241d0c; font-size:10.5px; font-weight:600; letter-spacing:.01em; }
|
|
149
|
+
.rel ul { margin:0; padding-left:17px; }
|
|
150
|
+
.rel li { margin:4px 0; font-size:13.5px; line-height:1.55; color:var(--muted); }
|
|
151
|
+
.rel:not(.latest) li { opacity:.8; }
|
|
152
|
+
.chip .ndot { display:inline-block; width:6px; height:6px; border-radius:99px; background:var(--amber); margin-left:6px; vertical-align:middle; }
|
|
153
|
+
.lib h2 { display:flex; align-items:center; gap:8px; }
|
|
154
|
+
.lib h2 .selbtn { margin-left:auto; border:1px solid var(--rule2); background:#fff; color:var(--muted); border-radius:8px; font-size:11.5px; padding:3px 9px; cursor:pointer; line-height:1.4; }
|
|
155
|
+
.lib h2 .selbtn:hover { border-color:var(--ink); color:var(--ink); }
|
|
156
|
+
.lib h2 .selbtn.on { background:var(--ink); border-color:var(--ink); color:#fff; }
|
|
157
|
+
.demo .pick { display:none; width:15px; height:15px; margin-right:2px; accent-color:var(--ink); flex:none; cursor:pointer; }
|
|
158
|
+
body.selecting .demo .pick { display:inline-block; }
|
|
159
|
+
body.selecting .demo .zap { display:none; }
|
|
160
|
+
body.selecting .demo { cursor:pointer; }
|
|
161
|
+
.selbar { position:sticky; bottom:0; margin-top:10px; padding:10px 12px; background:var(--paper); border-top:1px solid var(--rule2); display:flex; align-items:center; gap:9px; }
|
|
162
|
+
.selbar small { color:var(--muted); font-size:12px; }
|
|
163
|
+
.selbar .sp { flex:1; }
|
|
164
|
+
.selbar button { border:1px solid var(--rule2); background:#fff; border-radius:8px; font-size:12px; padding:5px 11px; cursor:pointer; }
|
|
165
|
+
.selbar button:hover { border-color:var(--ink); }
|
|
166
|
+
.selbar button.go { background:var(--bad); border-color:var(--bad); color:#fff; }
|
|
167
|
+
.selbar button.go:disabled { opacity:.4; cursor:default; }
|
|
168
|
+
.selbar button.go:not(:disabled):hover { filter:brightness(.92); }
|
|
169
|
+
.group summary .gpick { display:none; width:15px; height:15px; margin-right:2px; accent-color:var(--ink); flex:none; cursor:pointer; vertical-align:middle; }
|
|
170
|
+
body.selecting .group summary .gpick { display:inline-block; }
|
|
171
|
+
.arch summary .emptybtn { margin-left:auto; border:1px solid var(--rule2); background:#fff; color:var(--muted); border-radius:8px; font-size:11px; padding:2px 8px; cursor:pointer; }
|
|
172
|
+
.arch summary .emptybtn:hover { background:var(--bad); border-color:var(--bad); color:#fff; }
|
|
173
|
+
.arch summary { display:flex; align-items:center; gap:6px; }
|
|
126
174
|
</style>
|
|
127
175
|
</head>
|
|
128
176
|
<body>
|
|
@@ -132,6 +180,7 @@
|
|
|
132
180
|
<h1>Retake</h1>
|
|
133
181
|
<span class="sp"></span>
|
|
134
182
|
<a class="chip" href="/guide" style="text-decoration:none">How it works</a>
|
|
183
|
+
<button class="chip" id="newsBtn" title="What changed in this version">What's new</button>
|
|
135
184
|
<button class="chip" id="gremlinBtn" title="Something's off? Tell me.">Bug?</button>
|
|
136
185
|
</div>
|
|
137
186
|
<div class="wrap two">
|
|
@@ -143,18 +192,25 @@
|
|
|
143
192
|
<div class="pin" id="pin">
|
|
144
193
|
<video id="vid" controls playsinline></video>
|
|
145
194
|
<div class="under">
|
|
146
|
-
<span id="
|
|
195
|
+
<span class="vtitle" id="vidName"></span>
|
|
196
|
+
<span class="vmeta" id="vidMeta"></span>
|
|
147
197
|
<span class="sp"></span>
|
|
148
198
|
<select id="vidSpeed" title="Playback speed — preview now, and Download gets this speed">
|
|
149
199
|
<option value="0.75">0.75×</option><option value="0.85">0.85×</option><option value="1" selected>1×</option><option value="1.15">1.15×</option><option value="1.3">1.3×</option>
|
|
150
200
|
</select>
|
|
151
201
|
<a id="vidDl" download>Download</a>
|
|
152
202
|
<button id="vidFolder">Show in folder</button>
|
|
153
|
-
<button id="vidCover" title="Make the frame showing right now the poster">Use this frame</button>
|
|
154
|
-
<!-- The workshop (/classic) still exists but is not advertised until it has been tested as carefully as this page. -->
|
|
155
203
|
</div>
|
|
156
|
-
<div class="
|
|
157
|
-
|
|
204
|
+
<div class="frames">
|
|
205
|
+
<div class="fhead">
|
|
206
|
+
<span class="lab">Poster & frames</span>
|
|
207
|
+
<span class="hint">click any frame to make it the poster</span>
|
|
208
|
+
<span class="sp"></span>
|
|
209
|
+
<button class="chip" id="vidCover" title="Make the frame showing right now the poster">Use current frame</button>
|
|
210
|
+
</div>
|
|
211
|
+
<div class="coverrow" id="coverrow"></div>
|
|
212
|
+
<div class="stills" id="stills"></div>
|
|
213
|
+
</div>
|
|
158
214
|
</div>
|
|
159
215
|
<div class="empty" id="empty" style="display:none">
|
|
160
216
|
<div class="mark"><i></i><i></i><i></i><i></i></div>
|
|
@@ -163,7 +219,7 @@
|
|
|
163
219
|
</div>
|
|
164
220
|
</div>
|
|
165
221
|
<aside class="lib" id="lib" style="display:none">
|
|
166
|
-
<h2 id="libTtl">
|
|
222
|
+
<h2><span id="libTtl">Library</span><button class="selbtn" id="selBtn" title="Pick several to archive at once">Select</button></h2>
|
|
167
223
|
<div id="demos"></div>
|
|
168
224
|
</aside>
|
|
169
225
|
</div>
|
|
@@ -188,6 +244,45 @@ async function setCover(body, marked) {
|
|
|
188
244
|
} catch (e) { $('vidMeta').textContent = 'could not set the poster: ' + e.message; }
|
|
189
245
|
}
|
|
190
246
|
|
|
247
|
+
|
|
248
|
+
// What's new — reads the same list the landing page does. The dot appears when
|
|
249
|
+
// the newest entry is one this browser has not seen, and only until it is read;
|
|
250
|
+
// nothing interrupts a person who is in the middle of something.
|
|
251
|
+
const SEEN_KEY = 'retake:seen-release';
|
|
252
|
+
let releases = null;
|
|
253
|
+
async function loadReleases() {
|
|
254
|
+
if (releases) return releases;
|
|
255
|
+
try { releases = (await api('/api/releases')).releases || []; } catch { releases = []; }
|
|
256
|
+
return releases;
|
|
257
|
+
}
|
|
258
|
+
(async () => {
|
|
259
|
+
const rs = await loadReleases();
|
|
260
|
+
const btn = $('newsBtn');
|
|
261
|
+
if (!rs.length) { btn.style.display = 'none'; return; }
|
|
262
|
+
if (localStorage.getItem(SEEN_KEY) !== rs[0].version) btn.insertAdjacentHTML('beforeend', '<span class="ndot"></span>');
|
|
263
|
+
})();
|
|
264
|
+
$('newsBtn').onclick = async () => {
|
|
265
|
+
const rs = await loadReleases();
|
|
266
|
+
if (!rs.length) return;
|
|
267
|
+
const fmt = (d) => { const x = new Date(d + 'T00:00:00'); return isNaN(x) ? '' : x.toLocaleDateString(undefined, { month: 'long', day: 'numeric', year: 'numeric' }); };
|
|
268
|
+
const veil = document.createElement('div');
|
|
269
|
+
veil.className = 'veil';
|
|
270
|
+
veil.innerHTML = '<div class="modal wide"><h3>What\'s new</h3>' +
|
|
271
|
+
'<div class="scroll">' + rs.map((r, i) =>
|
|
272
|
+
'<div class="rel' + (i ? '' : ' latest') + '">' +
|
|
273
|
+
'<div class="relhead"><b>' + esc(r.headline) + '</b><span class="relmeta">' + esc(r.version) + (r.date ? ' · ' + fmt(r.date) : '') + '</span></div>' +
|
|
274
|
+
'<ul>' + (r.notes || []).map((n) => '<li>' + esc(n) + '</li>').join('') + '</ul>' +
|
|
275
|
+
'</div>').join('') + '</div>' +
|
|
276
|
+
'<div class="row" style="margin-top:18px"><button class="btn" id="newsClose">Close</button></div></div>';
|
|
277
|
+
document.body.appendChild(veil);
|
|
278
|
+
localStorage.setItem(SEEN_KEY, rs[0].version);
|
|
279
|
+
$('newsBtn').querySelector('.ndot')?.remove();
|
|
280
|
+
const shut = () => veil.remove();
|
|
281
|
+
veil.querySelector('#newsClose').onclick = shut;
|
|
282
|
+
veil.onclick = (e) => { if (e.target === veil) shut(); };
|
|
283
|
+
document.addEventListener('keydown', function esc2(e) { if (e.key === 'Escape') { shut(); document.removeEventListener('keydown', esc2); } });
|
|
284
|
+
};
|
|
285
|
+
|
|
191
286
|
document.getElementById('gremlinBtn').onclick = async () => {
|
|
192
287
|
const veil = document.createElement('div');
|
|
193
288
|
veil.className = 'veil';
|
|
@@ -266,7 +361,8 @@ async function pin(name) {
|
|
|
266
361
|
}
|
|
267
362
|
const t = d.take;
|
|
268
363
|
const failed = (t.timeline || []).find((e) => e.ok === false);
|
|
269
|
-
$('
|
|
364
|
+
$('vidName').textContent = name;
|
|
365
|
+
$('vidMeta').textContent = `${(t.duration - t.trimBefore).toFixed(0)}s · ${t.ok ? 'all steps passed' : failed ? `stopped at step ${failed.index}: ${failed.summary}` : 'some steps failed'}${d.facts ? ` · ${d.facts.width}×${d.facts.height}` : ''}`;
|
|
270
366
|
$('vidMeta').title = failed?.screen ? 'On screen at that moment: ' + failed.screen : '';
|
|
271
367
|
$('vidDl').href = `/out/${name}/demo.mp4${q}`; $('vidDl').setAttribute('download', name + '.mp4');
|
|
272
368
|
$('vidFolder').onclick = () => api('/api/open/' + name, { method: 'POST' }).catch(() => {});
|
|
@@ -278,15 +374,17 @@ $('vidCover').onclick = () => setCover({ at: $('vid').currentTime });
|
|
|
278
374
|
// here, not guessed: any scene still, any frame of the take, a generated
|
|
279
375
|
// cover, or the person's own image. All of it just rewrites thumbnail.png,
|
|
280
376
|
// so it costs no re-render.
|
|
377
|
+
// One tray: generated covers first, then every scene frame, then upload —
|
|
378
|
+
// a single row a person scans once, left to right.
|
|
281
379
|
const st = (d.files || []).filter((f) => f.startsWith('stills/')).sort();
|
|
282
|
-
$('stills').innerHTML = st.map((f) => `<img src="/out/${name}/${f}${q}" title="Make this the poster — ${esc(f.slice(7, -4))}" loading="lazy" data-still="${esc(f.slice(7))}">`).join('');
|
|
283
|
-
for (const img of $('stills').querySelectorAll('img')) img.onclick = () => setCover({ still: img.dataset.still }, img);
|
|
284
|
-
|
|
285
380
|
const covers = (d.files || []).filter((f) => f === 'cover.png' || f === 'cover-titled.png');
|
|
286
|
-
$('coverrow').innerHTML =
|
|
287
|
-
|
|
381
|
+
$('coverrow').innerHTML = '';
|
|
382
|
+
$('stills').innerHTML =
|
|
383
|
+
covers.map((f) => `<img src="/out/${name}/${f}${q}" title="${f === 'cover.png' ? 'The title card' : 'This frame, with the title on it'}" data-file="${f}">`).join('') +
|
|
384
|
+
st.map((f) => `<img src="/out/${name}/${f}${q}" title="Make this the poster — ${esc(f.slice(7, -4))}" loading="lazy" data-still="${esc(f.slice(7))}">`).join('') +
|
|
288
385
|
`<label class="up">Upload an image<input type="file" accept="image/png,image/jpeg,image/webp" hidden id="coverUp"></label>`;
|
|
289
|
-
for (const img of $('
|
|
386
|
+
for (const img of $('stills').querySelectorAll('img[data-still]')) img.onclick = () => setCover({ still: img.dataset.still }, img);
|
|
387
|
+
for (const img of $('stills').querySelectorAll('img[data-file]')) img.onclick = () => setCover({ file: img.dataset.file }, img);
|
|
290
388
|
$('coverUp').onchange = (e) => {
|
|
291
389
|
const f = e.target.files?.[0];
|
|
292
390
|
if (!f) return;
|
|
@@ -306,7 +404,7 @@ async function drawDemos() {
|
|
|
306
404
|
$('empty').style.display = has ? 'none' : '';
|
|
307
405
|
$('lib').style.display = has ? '' : 'none';
|
|
308
406
|
document.body.classList.toggle('rail', has);
|
|
309
|
-
$('libTtl').textContent = `
|
|
407
|
+
$('libTtl').textContent = `Library · ${demos.length}`;
|
|
310
408
|
$('demos').innerHTML = '';
|
|
311
409
|
const groups = new Map();
|
|
312
410
|
for (const p of demos) { const g = p.group || 'unsorted'; if (!groups.has(g)) groups.set(g, []); groups.get(g).push(p); }
|
|
@@ -316,7 +414,26 @@ async function drawDemos() {
|
|
|
316
414
|
det.className = 'group';
|
|
317
415
|
const holdsCurrent = items.some((x) => x.name === current);
|
|
318
416
|
if (open.has(g) || groups.size === 1 || holdsCurrent) det.open = true;
|
|
319
|
-
det.innerHTML = `<summary><span class="tri">▶</span><span class="fold" aria-hidden="true"></span><b>${esc(g)}</b><small>${items.length}</small></summary>`;
|
|
417
|
+
det.innerHTML = `<summary><input type="checkbox" class="gpick"><span class="tri">▶</span><span class="fold" aria-hidden="true"></span><b>${esc(g)}</b><small>${items.length}</small></summary>`;
|
|
418
|
+
// A whole folder in one click — with 35 demos in a handful of projects,
|
|
419
|
+
// ticking them one at a time is the thing this is meant to replace.
|
|
420
|
+
const gbox = det.querySelector('.gpick');
|
|
421
|
+
const names = items.map((x) => x.name);
|
|
422
|
+
gbox.checked = names.length > 0 && names.every((n) => picked.has(n));
|
|
423
|
+
// Clicking a checkbox inside <summary> also toggles the folder open/shut,
|
|
424
|
+
// and preventDefault would stop BOTH — including the tick, which is what
|
|
425
|
+
// makes "select the folder" look like it did nothing. So the fold is
|
|
426
|
+
// allowed to happen and then put straight back, and the tick is left
|
|
427
|
+
// entirely alone.
|
|
428
|
+
gbox.onclick = (ev) => {
|
|
429
|
+
ev.stopPropagation();
|
|
430
|
+
const wasOpen = det.open;
|
|
431
|
+
const all = names.every((n) => picked.has(n));
|
|
432
|
+
for (const n of names) all ? picked.delete(n) : picked.add(n);
|
|
433
|
+
for (const b of det.querySelectorAll('.demo .pick')) b.checked = !all;
|
|
434
|
+
drawSelBar();
|
|
435
|
+
setTimeout(() => { det.open = wasOpen; }, 0);
|
|
436
|
+
};
|
|
320
437
|
det.addEventListener('toggle', () => {
|
|
321
438
|
const now = [...document.querySelectorAll('details.group[open] summary b')].map((b) => b.textContent);
|
|
322
439
|
localStorage.setItem('retake.openGroups', JSON.stringify(now));
|
|
@@ -326,8 +443,16 @@ async function drawDemos() {
|
|
|
326
443
|
el.className = 'demo' + (p.name === current ? ' on' : '');
|
|
327
444
|
const dot = p.lastTake ? (p.lastTake.ok && !p.lastTake.partial ? 'ok' : 'bad') : '';
|
|
328
445
|
const when = p.lastTake ? new Date(p.lastTake.finishedAt).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) : 'no take yet';
|
|
329
|
-
el.innerHTML = `<span class="dot ${dot}"></span><span class="nm">${esc(p.name)}</span><small>${esc(when)}</small><button class="zap" title="Archive this demo">✕</button>`;
|
|
330
|
-
|
|
446
|
+
el.innerHTML = `<input type="checkbox" class="pick"${picked.has(p.name) ? ' checked' : ''}><span class="dot ${dot}"></span><span class="nm">${esc(p.name)}</span><small>${esc(when)}</small><button class="zap" title="Archive this demo">✕</button>`;
|
|
447
|
+
const box = el.querySelector('.pick');
|
|
448
|
+
const syncGroup = () => { const gb = det.querySelector('.gpick'); if (gb) gb.checked = items.every((x) => picked.has(x.name)); };
|
|
449
|
+
box.onclick = (ev) => { ev.stopPropagation(); box.checked ? picked.add(p.name) : picked.delete(p.name); syncGroup(); drawSelBar(); };
|
|
450
|
+
el.onclick = () => {
|
|
451
|
+
// In select mode the whole row is the checkbox — hunting for a 15px
|
|
452
|
+
// target is exactly the fiddliness this is meant to remove.
|
|
453
|
+
if (selecting) { box.checked = !box.checked; box.checked ? picked.add(p.name) : picked.delete(p.name); syncGroup(); drawSelBar(); return; }
|
|
454
|
+
if (p.lastTake) pin(p.name);
|
|
455
|
+
};
|
|
331
456
|
el.querySelector('.zap').onclick = async (ev) => {
|
|
332
457
|
ev.stopPropagation();
|
|
333
458
|
// Archive, not destroy — but moving someone's work still deserves a beat.
|
|
@@ -341,8 +466,59 @@ async function drawDemos() {
|
|
|
341
466
|
$('demos').appendChild(det);
|
|
342
467
|
}
|
|
343
468
|
await drawArchive();
|
|
469
|
+
drawSelBar();
|
|
344
470
|
}
|
|
345
471
|
|
|
472
|
+
|
|
473
|
+
// Bulk archive. Off by default: a list you can accidentally multi-select from
|
|
474
|
+
// is worse than one that needs a deliberate "Select" first.
|
|
475
|
+
let selecting = false;
|
|
476
|
+
const picked = new Set();
|
|
477
|
+
|
|
478
|
+
function drawSelBar() {
|
|
479
|
+
const old = document.querySelector('.selbar'); if (old) old.remove();
|
|
480
|
+
if (!selecting) return;
|
|
481
|
+
const bar = document.createElement('div');
|
|
482
|
+
bar.className = 'selbar';
|
|
483
|
+
const n = picked.size;
|
|
484
|
+
bar.innerHTML = `<small>${n ? n + ' selected' : 'none selected'}</small><span class="sp"></span>` +
|
|
485
|
+
`<button id="selAll">${n === demos.length ? 'None' : 'All'}</button>` +
|
|
486
|
+
`<button class="go" id="selGo"${n ? '' : ' disabled'}>Archive${n ? ' ' + n : ''}</button>`;
|
|
487
|
+
$('lib').appendChild(bar);
|
|
488
|
+
bar.querySelector('#selAll').onclick = () => {
|
|
489
|
+
if (picked.size === demos.length) picked.clear(); else for (const d of demos) picked.add(d.name);
|
|
490
|
+
for (const b of document.querySelectorAll('.demo .pick')) b.checked = picked.has(b.parentElement.querySelector('.nm').textContent);
|
|
491
|
+
drawSelBar();
|
|
492
|
+
};
|
|
493
|
+
bar.querySelector('#selGo').onclick = async () => {
|
|
494
|
+
const names = [...picked];
|
|
495
|
+
if (!names.length) return;
|
|
496
|
+
if (!(await ask(`Archive ${names.length} demo${names.length === 1 ? '' : 's'}?`,
|
|
497
|
+
names.slice(0, 8).join(', ') + (names.length > 8 ? ` and ${names.length - 8} more` : '') +
|
|
498
|
+
'. Their videos are removed. The demos move to the archive below and can be restored any time.', 'Archive'))) return;
|
|
499
|
+
const failed = [];
|
|
500
|
+
for (const nm of names) {
|
|
501
|
+
try { await api('/api/demos/' + nm, { method: 'DELETE' }); } catch (e) { failed.push(nm + ' (' + e.message + ')'); }
|
|
502
|
+
}
|
|
503
|
+
if (current && picked.has(current)) { current = null; $('pin').classList.remove('show'); }
|
|
504
|
+
picked.clear();
|
|
505
|
+
selecting = false;
|
|
506
|
+
document.body.classList.remove('selecting');
|
|
507
|
+
$('selBtn').classList.remove('on');
|
|
508
|
+
await drawDemos();
|
|
509
|
+
if (failed.length) alert('Could not archive:\n' + failed.join('\n'));
|
|
510
|
+
};
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
$('selBtn').onclick = () => {
|
|
514
|
+
selecting = !selecting;
|
|
515
|
+
picked.clear();
|
|
516
|
+
document.body.classList.toggle('selecting', selecting);
|
|
517
|
+
$('selBtn').classList.toggle('on', selecting);
|
|
518
|
+
$('selBtn').textContent = selecting ? 'Done' : 'Select';
|
|
519
|
+
drawDemos();
|
|
520
|
+
};
|
|
521
|
+
|
|
346
522
|
async function drawArchive() {
|
|
347
523
|
let items = [];
|
|
348
524
|
try { items = await api('/api/trash'); } catch { return; }
|
|
@@ -350,7 +526,7 @@ async function drawArchive() {
|
|
|
350
526
|
if (!items.length) return;
|
|
351
527
|
const det = document.createElement('details');
|
|
352
528
|
det.className = 'group arch';
|
|
353
|
-
det.innerHTML = `<summary><span class="tri">▶</span><b>Archive</b><small>${items.length}</small></summary>`;
|
|
529
|
+
det.innerHTML = `<summary><span class="tri">▶</span><b>Archive</b><small>${items.length}</small><button class="emptybtn" id="emptyArch" title="Delete every archived demo, permanently">Empty</button></summary>`;
|
|
354
530
|
for (const t of items) {
|
|
355
531
|
const el = document.createElement('div');
|
|
356
532
|
el.className = 'row2';
|
|
@@ -360,12 +536,15 @@ async function drawArchive() {
|
|
|
360
536
|
}
|
|
361
537
|
const clear = document.createElement('button');
|
|
362
538
|
clear.className = 'clear';
|
|
363
|
-
clear.textContent =
|
|
364
|
-
|
|
365
|
-
if (
|
|
539
|
+
clear.textContent = `Empty the archive — delete ${items.length} demo${items.length === 1 ? '' : 's'} for good`;
|
|
540
|
+
const empty = async (ev) => {
|
|
541
|
+
if (ev) { ev.stopPropagation(); ev.preventDefault(); } // the summary must not fold
|
|
542
|
+
if (!(await ask('Empty the archive?', `${items.length} archived demo${items.length === 1 ? '' : 's'} will be deleted permanently — the manifests and everything recorded from them. There is no way back from this one.`, 'Delete forever'))) return;
|
|
366
543
|
await api('/api/trash/clear', { method: 'POST' });
|
|
367
544
|
await drawDemos();
|
|
368
545
|
};
|
|
546
|
+
clear.onclick = empty;
|
|
547
|
+
det.querySelector('#emptyArch').onclick = empty;
|
|
369
548
|
det.appendChild(clear);
|
|
370
549
|
$('demos').appendChild(det);
|
|
371
550
|
}
|
package/dist/ui/server.js
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
* POST /api/describe { name, url, describe } → scout + model → draft manifest (saved)
|
|
11
11
|
* GET /api/provider { active, available }
|
|
12
12
|
* GET /api/presets [{name, ...}]
|
|
13
|
+
* GET /api/releases what's new, from site/releases.json
|
|
13
14
|
* GET/PUT/DELETE /api/drafts/:id an unfinished "new demo" (auto-saved as you go)
|
|
14
15
|
* DELETE /api/demos/:name remove a demo (manifest + outputs)
|
|
15
16
|
* PATCH /api/demos/:name/settings { preset?, layout?, camera?, cursor? } → edits keys, keeps comments
|
|
@@ -34,7 +35,7 @@ import { applyEdits, receiptsFor } from "../edits.js";
|
|
|
34
35
|
import { dryRun } from "../dryrun.js";
|
|
35
36
|
import { startOperator, getSession, addPending, answerPending, markDone, stopSession } from "../operator/run.js";
|
|
36
37
|
import { ffmpegBin, gifskiBin, makeGif } from "../render.js";
|
|
37
|
-
import { captureHash } from "../record.js";
|
|
38
|
+
import { captureHash, restorePrevious } from "../record.js";
|
|
38
39
|
import { digest } from "../digest.js";
|
|
39
40
|
import { startOffer, startApp, listeningPorts } from "../appserver.js";
|
|
40
41
|
import { PKG_ROOT, PROJECT_ROOT, VERSION, entry } from "../paths.js";
|
|
@@ -163,6 +164,34 @@ function unassignDemoProject(name) {
|
|
|
163
164
|
delete map[name];
|
|
164
165
|
fs.writeFileSync(DEMO_PROJECTS, JSON.stringify(map, null, 2) + "\n");
|
|
165
166
|
}
|
|
167
|
+
/** Parsed manifests, keyed by path and invalidated by mtime+size.
|
|
168
|
+
Listing 32 demos meant 64 YAML parses and zod validations per request —
|
|
169
|
+
two seconds, on a call the window makes on load, on every agent event and
|
|
170
|
+
every 30s. Nothing about a file that has not changed can have changed. */
|
|
171
|
+
const manifestCache = new Map();
|
|
172
|
+
function cachedManifest(file) {
|
|
173
|
+
let key;
|
|
174
|
+
try {
|
|
175
|
+
const st = fs.statSync(file);
|
|
176
|
+
key = `${st.mtimeMs}:${st.size}`;
|
|
177
|
+
}
|
|
178
|
+
catch {
|
|
179
|
+
manifestCache.delete(file);
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
const hit = manifestCache.get(file);
|
|
183
|
+
if (hit && hit.key === key)
|
|
184
|
+
return hit.manifest;
|
|
185
|
+
let manifest = null;
|
|
186
|
+
try {
|
|
187
|
+
manifest = loadManifest(file).manifest;
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
manifest = null;
|
|
191
|
+
}
|
|
192
|
+
manifestCache.set(file, { key, manifest });
|
|
193
|
+
return manifest;
|
|
194
|
+
}
|
|
166
195
|
function listDemos(project) {
|
|
167
196
|
if (!fs.existsSync(DEMOS))
|
|
168
197
|
return [];
|
|
@@ -170,13 +199,12 @@ function listDemos(project) {
|
|
|
170
199
|
const assignments = demoProjects();
|
|
171
200
|
const byUrl = new Map();
|
|
172
201
|
for (const f of fs.readdirSync(DEMOS).filter((x) => /\.ya?ml$/.test(x))) {
|
|
173
|
-
|
|
174
|
-
|
|
202
|
+
const mm = cachedManifest(path.join(DEMOS, f));
|
|
203
|
+
if (mm) {
|
|
175
204
|
const asg = assignments[mm.name];
|
|
176
205
|
if (asg && mm.url && !byUrl.has(mm.url))
|
|
177
206
|
byUrl.set(mm.url, asg);
|
|
178
207
|
}
|
|
179
|
-
catch { /* invalid manifest: no vote */ }
|
|
180
208
|
}
|
|
181
209
|
return fs
|
|
182
210
|
.readdirSync(DEMOS)
|
|
@@ -185,7 +213,9 @@ function listDemos(project) {
|
|
|
185
213
|
const file = path.join(DEMOS, f);
|
|
186
214
|
let title = "", name = f.replace(/\.ya?ml$/, ""), valid = true, url = "", settings = {};
|
|
187
215
|
try {
|
|
188
|
-
const m =
|
|
216
|
+
const m = cachedManifest(file);
|
|
217
|
+
if (!m)
|
|
218
|
+
throw new Error("invalid");
|
|
189
219
|
title = m.title ?? "";
|
|
190
220
|
name = m.name;
|
|
191
221
|
url = m.url;
|
|
@@ -206,6 +236,11 @@ function listDemos(project) {
|
|
|
206
236
|
// Capture lands in a second "localhost:3100" fold next to "capture".
|
|
207
237
|
const assigned = assignments[name] ?? (url ? byUrl.get(url) : undefined);
|
|
208
238
|
const group = assigned ? path.basename(assigned) : shortGroup(url);
|
|
239
|
+
// Heal a folder whose run was killed mid-recording: the previous take is
|
|
240
|
+
// in its stash and nothing has put it back yet. The window must never
|
|
241
|
+
// show someone an empty demo when their recording is safe on disk.
|
|
242
|
+
if (!fs.existsSync(path.join(outRoot(), name, "take.json")))
|
|
243
|
+
restorePrevious(path.join(outRoot(), name));
|
|
209
244
|
const take = path.join(outRoot(), name, "take.json");
|
|
210
245
|
let lastTake = null;
|
|
211
246
|
// Does the browser need to run again, or is a re-render enough? The
|
|
@@ -415,7 +450,6 @@ function noteActivity(b) {
|
|
|
415
450
|
}
|
|
416
451
|
export function serve(port) {
|
|
417
452
|
loadDotenv(ROOT);
|
|
418
|
-
const page = fs.readFileSync(path.join(HERE, "index.html"), "utf8");
|
|
419
453
|
const chatPage = fs.readFileSync(path.join(HERE, "chat.html"), "utf8");
|
|
420
454
|
const guidePage = fs.readFileSync(path.join(HERE, "guide.html"), "utf8");
|
|
421
455
|
const server = http.createServer(async (req, res) => {
|
|
@@ -435,6 +469,16 @@ export function serve(port) {
|
|
|
435
469
|
}
|
|
436
470
|
// Both prefixes: the page is served at /landing here and from a site
|
|
437
471
|
// root when deployed, so its asset paths are relative either way.
|
|
472
|
+
// releases.json sits beside index.html when deployed, so the preview
|
|
473
|
+
// has to serve it from the same two prefixes the page might be under.
|
|
474
|
+
const mrel = /^(?:\/landing)?\/releases\.json$/.exec(p);
|
|
475
|
+
if (mrel && (req.method === "GET" || req.method === "HEAD")) {
|
|
476
|
+
const f = path.join(PKG_ROOT, "site", "releases.json");
|
|
477
|
+
if (!fs.existsSync(f))
|
|
478
|
+
return json(res, 404, { error: "not found" });
|
|
479
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
480
|
+
return res.end(req.method === "HEAD" ? undefined : fs.readFileSync(f));
|
|
481
|
+
}
|
|
438
482
|
const msite = /^(?:\/landing)?\/(media|logos)\/([A-Za-z0-9._-]+)$/.exec(p);
|
|
439
483
|
if (msite && (req.method === "GET" || req.method === "HEAD")) {
|
|
440
484
|
const f = path.join(PKG_ROOT, "site", msite[1], msite[2]);
|
|
@@ -449,10 +493,6 @@ export function serve(port) {
|
|
|
449
493
|
res.writeHead(200, { "content-type": "text/html" });
|
|
450
494
|
return res.end(guidePage);
|
|
451
495
|
}
|
|
452
|
-
if (p === "/classic" && req.method === "GET") {
|
|
453
|
-
res.writeHead(200, { "content-type": "text/html" });
|
|
454
|
-
return res.end(page);
|
|
455
|
-
}
|
|
456
496
|
if (p === "/manifest.webmanifest") {
|
|
457
497
|
res.writeHead(200, { "content-type": "application/manifest+json" });
|
|
458
498
|
return res.end(JSON.stringify({ name: "Retake", short_name: "Retake", description: "Rerun the demo, don't re-record it.", start_url: "/", scope: "/", display: "standalone", background_color: "#edefe8", theme_color: "#edefe8", icons: [{ src: "/icon-192.png", sizes: "192x192", type: "image/png" }, { src: "/icon-512.png", sizes: "512x512", type: "image/png", purpose: "any maskable" }] }));
|
|
@@ -494,6 +534,19 @@ export function serve(port) {
|
|
|
494
534
|
}
|
|
495
535
|
if (p === "/api/provider" && req.method === "GET")
|
|
496
536
|
return json(res, 200, providerStatus());
|
|
537
|
+
if (p === "/api/releases" && req.method === "GET") {
|
|
538
|
+
// The same site/releases.json the landing page reads — one list, so
|
|
539
|
+
// the window and the page can never disagree about what shipped.
|
|
540
|
+
const f = path.join(PKG_ROOT, "site", "releases.json");
|
|
541
|
+
if (!fs.existsSync(f))
|
|
542
|
+
return json(res, 200, { releases: [] });
|
|
543
|
+
try {
|
|
544
|
+
return json(res, 200, JSON.parse(fs.readFileSync(f, "utf8")));
|
|
545
|
+
}
|
|
546
|
+
catch {
|
|
547
|
+
return json(res, 200, { releases: [] });
|
|
548
|
+
}
|
|
549
|
+
}
|
|
497
550
|
if (p === "/api/presets" && req.method === "GET")
|
|
498
551
|
return json(res, 200, Object.values(PRESETS));
|
|
499
552
|
if (p === "/api/drafts" && req.method === "GET")
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "retake-demos",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Demo-as-code for coding agents: a YAML manifest in, a product walkthrough video out. Rerun the demo instead of re-recording it.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"demo",
|
|
@@ -40,8 +40,7 @@
|
|
|
40
40
|
"scripts/patch-testreel.mjs",
|
|
41
41
|
"AGENTS.md",
|
|
42
42
|
"llms.txt",
|
|
43
|
-
"site/
|
|
44
|
-
"site/logos"
|
|
43
|
+
"site/releases.json"
|
|
45
44
|
],
|
|
46
45
|
"scripts": {
|
|
47
46
|
"build": "rm -rf dist && tsc -p tsconfig.build.json && mkdir -p dist/ui && cp src/ui/*.html dist/ui/ && chmod +x dist/cli.js",
|