@writepanda/mcp 1.251.0 → 1.262.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/bin/apps/clips.mjs +401 -0
- package/bin/apps/editPreview.mjs +389 -0
- package/bin/apps/index.mjs +242 -0
- package/bin/apps/viewKit.mjs +506 -0
- package/bin/server.mjs +854 -28
- package/package.json +1 -1
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
// Clips picker view (ui://pandastudio/clips.html): a clip set from clips_make
|
|
2
|
+
// / clips_get as a grid of cards (still, title, hook, score, length) with
|
|
3
|
+
// Open, Export and Remove on each. Follows a set that is still being made.
|
|
4
|
+
|
|
5
|
+
import { viewPage } from "./viewKit.mjs";
|
|
6
|
+
|
|
7
|
+
export const CLIPS_URI = "ui://pandastudio/clips.html";
|
|
8
|
+
|
|
9
|
+
const CSS = `
|
|
10
|
+
.head { margin-bottom: 12px; }
|
|
11
|
+
.status { margin-top: 8px; }
|
|
12
|
+
.status .bar { margin-top: 6px; }
|
|
13
|
+
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(var(--card, 168px), 1fr)); gap: 12px; }
|
|
14
|
+
.card { border: 1px solid var(--line2); border-radius: var(--radius-lg); overflow: hidden; background: var(--bg);
|
|
15
|
+
display: flex; flex-direction: column; min-width: 0; }
|
|
16
|
+
.thumb { position: relative; background: #000; aspect-ratio: var(--ar, 9 / 16); overflow: hidden; }
|
|
17
|
+
.thumb img, .thumb .ph { display: block; width: 100%; height: 100%; object-fit: cover; }
|
|
18
|
+
.thumb .badge { position: absolute; top: 6px; font-size: 11px; font-weight: 600; padding: 2px 7px; border-radius: 999px;
|
|
19
|
+
background: rgba(0, 0, 0, 0.62); color: #fff; font-variant-numeric: tabular-nums; }
|
|
20
|
+
.thumb .score { left: 6px; }
|
|
21
|
+
.thumb .score.hi { background: #16a34a; }
|
|
22
|
+
.thumb .score.mid { background: #ca8a04; }
|
|
23
|
+
.thumb .len { right: 6px; }
|
|
24
|
+
.thumb .rank { position: absolute; left: 6px; bottom: 6px; font-size: 11px; color: #fff; opacity: 0.85; }
|
|
25
|
+
.thumb .over { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
|
|
26
|
+
background: rgba(0, 0, 0, 0.5); color: #fff; font-size: 12px; text-align: center; padding: 10px; }
|
|
27
|
+
.thumb .done { position: absolute; right: 6px; bottom: 6px; font-size: 11px; font-weight: 600; padding: 2px 7px;
|
|
28
|
+
border-radius: 999px; background: rgba(22, 163, 74, 0.92); color: #fff; }
|
|
29
|
+
.body { padding: 9px 10px 10px; display: flex; flex-direction: column; gap: 4px; flex: 1; }
|
|
30
|
+
.title { font-weight: 600; font-size: 13.5px; line-height: 1.3; }
|
|
31
|
+
.hook { font-size: 12.5px; color: var(--fg2); display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical;
|
|
32
|
+
overflow: hidden; }
|
|
33
|
+
.acts { display: flex; gap: 4px; margin-top: auto; padding-top: 6px; flex-wrap: wrap; }
|
|
34
|
+
.acts .btn { padding: 4px 8px; font-size: 12px; }
|
|
35
|
+
.confirm { display: flex; gap: 4px; align-items: center; font-size: 12px; flex-wrap: wrap; }
|
|
36
|
+
.empty { padding: 26px 8px; text-align: center; color: var(--fg2); }
|
|
37
|
+
`;
|
|
38
|
+
|
|
39
|
+
/** Runs in the view. Self-contained: serialized into the page. */
|
|
40
|
+
function main(kit, ui) {
|
|
41
|
+
const { h, duration, actionButton, toast } = ui;
|
|
42
|
+
const app = document.getElementById("app");
|
|
43
|
+
const RUNNING = ["importing", "transcribing", "finding", "building"];
|
|
44
|
+
const view = {
|
|
45
|
+
setId: null,
|
|
46
|
+
set: null,
|
|
47
|
+
images: new Map(), // `${path}#${mediaVersion}` -> data URL | null
|
|
48
|
+
exporting: new Map(), // clipId -> "running" | "done" | error text
|
|
49
|
+
confirming: null, // clipId awaiting a Remove confirmation
|
|
50
|
+
pollTimer: 0,
|
|
51
|
+
pollStarted: 0,
|
|
52
|
+
loadError: null,
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const imageKey = (c) => `${c.thumbPath}#${c.mediaVersion ?? 0}`;
|
|
56
|
+
const isRunning = (set) =>
|
|
57
|
+
RUNNING.includes(set?.status) ||
|
|
58
|
+
(set?.clips ?? []).some((c) => c.status === "building" || c.status === "pending");
|
|
59
|
+
const exported = (c) => (c.exports?.length ?? 0) > 0 || !!c.exportedPath;
|
|
60
|
+
|
|
61
|
+
async function refresh() {
|
|
62
|
+
if (!view.setId) return;
|
|
63
|
+
try {
|
|
64
|
+
const { data } = await kit.callTool("clips_get", { id: view.setId });
|
|
65
|
+
view.set = data;
|
|
66
|
+
view.loadError = null;
|
|
67
|
+
} catch (err) {
|
|
68
|
+
view.loadError = err?.message ?? String(err);
|
|
69
|
+
}
|
|
70
|
+
render();
|
|
71
|
+
await loadThumbs();
|
|
72
|
+
schedulePoll();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function loadThumbs() {
|
|
76
|
+
const missing = (view.set?.clips ?? []).filter(
|
|
77
|
+
(c) => c.status === "ready" && c.thumbPath && !view.images.has(imageKey(c)),
|
|
78
|
+
);
|
|
79
|
+
if (!missing.length) return;
|
|
80
|
+
try {
|
|
81
|
+
const loaded = await kit.loadImages(missing.map((c) => c.thumbPath));
|
|
82
|
+
for (const c of missing) view.images.set(imageKey(c), loaded.get(c.thumbPath) ?? null);
|
|
83
|
+
} catch {
|
|
84
|
+
for (const c of missing) view.images.set(imageKey(c), null);
|
|
85
|
+
}
|
|
86
|
+
render();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function schedulePoll() {
|
|
90
|
+
clearTimeout(view.pollTimer);
|
|
91
|
+
if (!isRunning(view.set)) return;
|
|
92
|
+
if (!view.pollStarted) view.pollStarted = Date.now();
|
|
93
|
+
// Follow a running set for up to three hours, slower once it's long.
|
|
94
|
+
const age = Date.now() - view.pollStarted;
|
|
95
|
+
if (age > 3 * 3600_000) return;
|
|
96
|
+
view.pollTimer = setTimeout(
|
|
97
|
+
() => {
|
|
98
|
+
if (document.hidden) schedulePoll();
|
|
99
|
+
else refresh();
|
|
100
|
+
},
|
|
101
|
+
age > 10 * 60_000 ? 10_000 : 3_000,
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function waitForJob(jobId) {
|
|
106
|
+
for (let i = 0; i < 2400; i++) {
|
|
107
|
+
const { data } = await kit.callTool("job_get", { id: jobId });
|
|
108
|
+
const job = data?.job ?? data;
|
|
109
|
+
if (job?.status === "succeeded") return job.result;
|
|
110
|
+
if (job?.status === "failed" || job?.status === "canceled")
|
|
111
|
+
throw new Error(job.error ?? `Export ${job.status}`);
|
|
112
|
+
await new Promise((r) => setTimeout(r, 1500));
|
|
113
|
+
}
|
|
114
|
+
throw new Error("The export is still running; check My Exports in PandaStudio.");
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function exportClips(clipIds) {
|
|
118
|
+
for (const id of clipIds) view.exporting.set(id, "running");
|
|
119
|
+
render();
|
|
120
|
+
try {
|
|
121
|
+
const { data } = await kit.callTool("clips_export", { id: view.setId, clipIds });
|
|
122
|
+
await waitForJob(data.jobId);
|
|
123
|
+
for (const id of clipIds) view.exporting.set(id, "done");
|
|
124
|
+
const titles = clipIds
|
|
125
|
+
.map((id) => view.set?.clips?.find((c) => c.id === id)?.title)
|
|
126
|
+
.filter(Boolean);
|
|
127
|
+
toast(clipIds.length === 1 ? "Exported" : `Exported ${clipIds.length} clips`);
|
|
128
|
+
kit.updateModelContext(
|
|
129
|
+
`In the PandaStudio clips view the user exported ${titles.map((t) => `"${t}"`).join(", ")} from clip set ${view.setId} to ${data.outputDir}.`,
|
|
130
|
+
);
|
|
131
|
+
} catch (err) {
|
|
132
|
+
for (const id of clipIds) view.exporting.set(id, err?.message ?? String(err));
|
|
133
|
+
throw err;
|
|
134
|
+
} finally {
|
|
135
|
+
await refresh();
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function removeClip(clip) {
|
|
140
|
+
await kit.callTool("clips_remove_clip", { id: view.setId, clipId: clip.id });
|
|
141
|
+
view.confirming = null;
|
|
142
|
+
toast(`Removed "${clip.title}"`);
|
|
143
|
+
kit.updateModelContext(
|
|
144
|
+
`In the PandaStudio clips view the user removed the clip "${clip.title}" (${clip.id}) from clip set ${view.setId}; its project went to the Trash.`,
|
|
145
|
+
);
|
|
146
|
+
await refresh();
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function statusBlock(set) {
|
|
150
|
+
if (set.status === "error" || set.status === "cancelled") {
|
|
151
|
+
return h(
|
|
152
|
+
"div",
|
|
153
|
+
{ class: "status" },
|
|
154
|
+
h(
|
|
155
|
+
"div",
|
|
156
|
+
{ class: "error small" },
|
|
157
|
+
set.error ?? (set.status === "cancelled" ? "Stopped." : "Clip making failed."),
|
|
158
|
+
),
|
|
159
|
+
h(
|
|
160
|
+
"div",
|
|
161
|
+
{ class: "row", style: { marginTop: "8px" } },
|
|
162
|
+
actionButton(
|
|
163
|
+
"Try again",
|
|
164
|
+
async () => {
|
|
165
|
+
await kit.callTool("clips_retry", { id: view.setId });
|
|
166
|
+
view.pollStarted = 0;
|
|
167
|
+
await refresh();
|
|
168
|
+
},
|
|
169
|
+
{ primary: true, busy: "Starting…" },
|
|
170
|
+
),
|
|
171
|
+
),
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
if (RUNNING.includes(set.status)) {
|
|
175
|
+
const pct = Math.max(2, Math.min(100, Math.round(set.stage?.percent ?? 0)));
|
|
176
|
+
const eta = set.transcription?.etaMs
|
|
177
|
+
? ` · about ${duration(set.transcription.etaMs)} left`
|
|
178
|
+
: "";
|
|
179
|
+
return h(
|
|
180
|
+
"div",
|
|
181
|
+
{ class: "status" },
|
|
182
|
+
h("div", { class: "small muted" }, `${set.stage?.message ?? set.status}${eta}`),
|
|
183
|
+
h("div", { class: "bar" }, h("div", { style: { width: `${pct}%` } })),
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function card(c, rank) {
|
|
190
|
+
const src = view.images.get(imageKey(c));
|
|
191
|
+
const exp = view.exporting.get(c.id);
|
|
192
|
+
const ready = c.status === "ready";
|
|
193
|
+
const scoreClass = c.score >= 80 ? "score hi" : c.score >= 60 ? "score mid" : "score";
|
|
194
|
+
let over = null;
|
|
195
|
+
if (c.status === "building" || c.status === "pending")
|
|
196
|
+
over = h("div", { class: "over" }, c.status === "building" ? "Building…" : "Waiting…");
|
|
197
|
+
else if (c.status === "error")
|
|
198
|
+
over = h("div", { class: "over" }, c.error ?? "Couldn't build this clip");
|
|
199
|
+
else if (exp === "running") over = h("div", { class: "over" }, "Exporting…");
|
|
200
|
+
|
|
201
|
+
const acts =
|
|
202
|
+
view.confirming === c.id
|
|
203
|
+
? h(
|
|
204
|
+
"div",
|
|
205
|
+
{ class: "confirm" },
|
|
206
|
+
h("span", null, "Remove this clip?"),
|
|
207
|
+
actionButton("Remove", () => removeClip(c), { busy: "Removing…" }),
|
|
208
|
+
h(
|
|
209
|
+
"button",
|
|
210
|
+
{
|
|
211
|
+
class: "btn ghost",
|
|
212
|
+
type: "button",
|
|
213
|
+
onclick: () => {
|
|
214
|
+
view.confirming = null;
|
|
215
|
+
render();
|
|
216
|
+
},
|
|
217
|
+
},
|
|
218
|
+
"Cancel",
|
|
219
|
+
),
|
|
220
|
+
)
|
|
221
|
+
: h(
|
|
222
|
+
"div",
|
|
223
|
+
{ class: "acts" },
|
|
224
|
+
ready && c.projectPath
|
|
225
|
+
? actionButton(
|
|
226
|
+
"Open",
|
|
227
|
+
async () => {
|
|
228
|
+
await kit.callTool("preview_show", { path: c.projectPath });
|
|
229
|
+
toast("Opened in PandaStudio");
|
|
230
|
+
},
|
|
231
|
+
{ busy: "Opening…", title: "Open this clip in the PandaStudio editor" },
|
|
232
|
+
)
|
|
233
|
+
: null,
|
|
234
|
+
ready && exp !== "running"
|
|
235
|
+
? actionButton(exported(c) ? "Export again" : "Export", () => exportClips([c.id]), {
|
|
236
|
+
busy: "Exporting…",
|
|
237
|
+
})
|
|
238
|
+
: null,
|
|
239
|
+
h(
|
|
240
|
+
"button",
|
|
241
|
+
{
|
|
242
|
+
class: "btn ghost danger",
|
|
243
|
+
type: "button",
|
|
244
|
+
title: "Remove this clip from the set",
|
|
245
|
+
onclick: () => {
|
|
246
|
+
view.confirming = c.id;
|
|
247
|
+
render();
|
|
248
|
+
},
|
|
249
|
+
},
|
|
250
|
+
"Remove",
|
|
251
|
+
),
|
|
252
|
+
);
|
|
253
|
+
|
|
254
|
+
return h(
|
|
255
|
+
"div",
|
|
256
|
+
{ class: "card" },
|
|
257
|
+
h(
|
|
258
|
+
"div",
|
|
259
|
+
{ class: "thumb" },
|
|
260
|
+
src
|
|
261
|
+
? h("img", { src, alt: c.title })
|
|
262
|
+
: h("div", { class: ready && !view.images.has(imageKey(c)) ? "ph skeleton" : "ph" }),
|
|
263
|
+
typeof c.score === "number"
|
|
264
|
+
? h("span", { class: `badge ${scoreClass}`, title: "Hook score" }, c.score)
|
|
265
|
+
: null,
|
|
266
|
+
h("span", { class: "badge len" }, duration(c.endMs - c.startMs)),
|
|
267
|
+
h("span", { class: "rank" }, `#${rank}`),
|
|
268
|
+
exported(c) && exp !== "running" ? h("span", { class: "done" }, "Exported") : null,
|
|
269
|
+
over,
|
|
270
|
+
),
|
|
271
|
+
h(
|
|
272
|
+
"div",
|
|
273
|
+
{ class: "body" },
|
|
274
|
+
h("div", { class: "title" }, c.title || "Untitled clip"),
|
|
275
|
+
c.hook ? h("div", { class: "hook" }, `"${c.hook}"`) : null,
|
|
276
|
+
typeof exp === "string" && exp !== "running" && exp !== "done"
|
|
277
|
+
? h("div", { class: "small error" }, exp)
|
|
278
|
+
: null,
|
|
279
|
+
acts,
|
|
280
|
+
),
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function render() {
|
|
285
|
+
const set = view.set;
|
|
286
|
+
if (!set) {
|
|
287
|
+
app.replaceChildren(
|
|
288
|
+
h("div", { class: "brand" }, h("i"), "PandaStudio clips"),
|
|
289
|
+
view.loadError
|
|
290
|
+
? h("p", { class: "error" }, view.loadError)
|
|
291
|
+
: h("p", { class: "muted" }, "Loading clips…"),
|
|
292
|
+
);
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
const fmt = set.options?.format ?? "9:16";
|
|
296
|
+
const [w, hh] = fmt.split(":");
|
|
297
|
+
document.documentElement.style.setProperty("--ar", `${w} / ${hh}`);
|
|
298
|
+
document.documentElement.style.setProperty(
|
|
299
|
+
"--card",
|
|
300
|
+
fmt === "16:9" ? "240px" : fmt === "1:1" ? "190px" : "160px",
|
|
301
|
+
);
|
|
302
|
+
const clips = set.clips ?? [];
|
|
303
|
+
const readyIds = clips.filter((c) => c.status === "ready").map((c) => c.id);
|
|
304
|
+
const notExported = clips
|
|
305
|
+
.filter((c) => c.status === "ready" && !exported(c) && view.exporting.get(c.id) !== "running")
|
|
306
|
+
.map((c) => c.id);
|
|
307
|
+
const sub = [
|
|
308
|
+
`${readyIds.length} of ${clips.length} clip${clips.length === 1 ? "" : "s"} ready`,
|
|
309
|
+
fmt,
|
|
310
|
+
set.durationMs ? `from a ${duration(set.durationMs)} video` : null,
|
|
311
|
+
set.usedModel === false ? "picked from the transcript" : null,
|
|
312
|
+
].filter(Boolean);
|
|
313
|
+
app.replaceChildren(
|
|
314
|
+
h(
|
|
315
|
+
"div",
|
|
316
|
+
{ class: "head" },
|
|
317
|
+
h(
|
|
318
|
+
"div",
|
|
319
|
+
{ class: "row spread wrap" },
|
|
320
|
+
h("div", { class: "brand" }, h("i"), "PandaStudio clips"),
|
|
321
|
+
readyIds.length
|
|
322
|
+
? h(
|
|
323
|
+
"div",
|
|
324
|
+
{ class: "row" },
|
|
325
|
+
notExported.length
|
|
326
|
+
? actionButton(
|
|
327
|
+
notExported.length === readyIds.length
|
|
328
|
+
? `Export all ${readyIds.length}`
|
|
329
|
+
: `Export ${notExported.length} new`,
|
|
330
|
+
() => exportClips(notExported),
|
|
331
|
+
{ primary: true, busy: "Exporting…" },
|
|
332
|
+
)
|
|
333
|
+
: h("span", { class: "chip" }, "All exported"),
|
|
334
|
+
kit.canFullscreen()
|
|
335
|
+
? actionButton(
|
|
336
|
+
kit.state.hostContext.displayMode === "fullscreen"
|
|
337
|
+
? "Exit full screen"
|
|
338
|
+
: "Full screen",
|
|
339
|
+
() =>
|
|
340
|
+
kit.requestDisplayMode(
|
|
341
|
+
kit.state.hostContext.displayMode === "fullscreen"
|
|
342
|
+
? "inline"
|
|
343
|
+
: "fullscreen",
|
|
344
|
+
),
|
|
345
|
+
)
|
|
346
|
+
: null,
|
|
347
|
+
)
|
|
348
|
+
: null,
|
|
349
|
+
),
|
|
350
|
+
h("h1", { style: { marginTop: "4px" } }, set.title || set.name || "Clips"),
|
|
351
|
+
h("div", { class: "small muted" }, sub.join(" · ")),
|
|
352
|
+
statusBlock(set),
|
|
353
|
+
view.loadError ? h("div", { class: "small error" }, view.loadError) : null,
|
|
354
|
+
),
|
|
355
|
+
clips.length
|
|
356
|
+
? h(
|
|
357
|
+
"div",
|
|
358
|
+
{ class: "grid" },
|
|
359
|
+
clips.map((c, i) => card(c, i + 1)),
|
|
360
|
+
)
|
|
361
|
+
: h(
|
|
362
|
+
"div",
|
|
363
|
+
{ class: "empty" },
|
|
364
|
+
RUNNING.includes(set.status)
|
|
365
|
+
? "Clips appear here as they're found."
|
|
366
|
+
: "No clips in this set.",
|
|
367
|
+
),
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
render();
|
|
372
|
+
kit.on("input", (args) => {
|
|
373
|
+
if (!view.setId && typeof args?.id === "string") view.setId = args.id;
|
|
374
|
+
});
|
|
375
|
+
kit.on("result", (result) => {
|
|
376
|
+
const error = kit.resultError(result);
|
|
377
|
+
if (error) {
|
|
378
|
+
view.loadError = error;
|
|
379
|
+
render();
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
const data = kit.resultData(result) ?? {};
|
|
383
|
+
view.setId = data.clipSetId ?? data.id ?? view.setId;
|
|
384
|
+
if (Array.isArray(data.clips)) {
|
|
385
|
+
view.set = data;
|
|
386
|
+
render();
|
|
387
|
+
}
|
|
388
|
+
// Always read it fresh: the set in the chat may be older than the app's.
|
|
389
|
+
refresh();
|
|
390
|
+
});
|
|
391
|
+
kit.on("context", () => render());
|
|
392
|
+
kit.on("teardown", () => clearTimeout(view.pollTimer));
|
|
393
|
+
kit.connect().catch((err) => {
|
|
394
|
+
view.loadError = `Couldn't connect to the chat: ${err?.message ?? err}`;
|
|
395
|
+
render();
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
export function clipsHtml(version) {
|
|
400
|
+
return viewPage({ title: "PandaStudio clips", version, css: CSS, main });
|
|
401
|
+
}
|