kaizen-agent 1.48.0 → 1.49.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/README.md +2 -2
- package/cli/build-web.ts +4 -2
- package/cli/hermes.ts +65 -0
- package/cli/install.ts +18 -5
- package/cli/localsend.test.ts +84 -0
- package/cli/localsend.ts +131 -0
- package/cli/notes.test.ts +6 -4
- package/cli/settings.ts +1 -0
- package/cli/state.test.ts +37 -0
- package/cli/state.ts +35 -20
- package/cli/video.test.ts +479 -0
- package/cli/video.ts +507 -0
- package/cli/web.ts +127 -1
- package/package.json +1 -1
- package/skills/kaizen/config.default.yml +4 -0
- package/skills/media/kaizen-book/SKILL.md +107 -0
- package/skills/media/kaizen-book/assets/reader.html +440 -0
- package/skills/media/kaizen-book/assets/styles.json +45 -0
- package/skills/media/kaizen-book/examples/book.json +14 -0
- package/skills/media/kaizen-book/examples/images/reach.svg +1 -0
- package/skills/media/kaizen-book/examples/manuscript.md +127 -0
- package/skills/media/kaizen-book/references/interview.md +85 -0
- package/skills/media/kaizen-book/references/proofread.md +47 -0
- package/skills/media/kaizen-book/references/writing.md +53 -0
- package/skills/media/kaizen-book/scripts/build_book.py +279 -0
- package/skills/media/kaizen-book/scripts/check.sh +10 -0
- package/skills/media/kaizen-book/scripts/check_book.py +128 -0
- package/skills/media/kaizen-book/scripts/deploy.py +58 -0
- package/skills/media/kaizen-book/scripts/pdf.mjs +99 -0
- package/skills/media/kaizen-book/scripts/setup.sh +9 -0
- package/skills/media/kaizen-book/scripts/verify.mjs +95 -0
- package/skills/{story-deck → media/kaizen-deck}/SKILL.md +1 -1
- package/skills/{story-deck → media/kaizen-deck}/scripts/check.sh +1 -1
- package/skills/{story-deck → media/kaizen-deck}/scripts/install_fonts.py +2 -2
- package/skills/media/kaizen-video/SKILL.md +112 -0
- package/skills/{kaizen-video → media/kaizen-video}/assets/CREDITS.txt +5 -0
- package/skills/media/kaizen-video/assets/film-fonts/Fraunces.ttf +0 -0
- package/skills/media/kaizen-video/assets/film-fonts/Inter.ttf +0 -0
- package/skills/media/kaizen-video/assets/film-fonts/PlayfairDisplay-Italic.ttf +0 -0
- package/skills/media/kaizen-video/assets/film-fonts/PlayfairDisplay.ttf +0 -0
- package/skills/media/kaizen-video/examples/sample-film/README.md +10 -0
- package/skills/media/kaizen-video/examples/sample-film/film.json +63 -0
- package/skills/media/kaizen-video/film.html +246 -0
- package/skills/media/kaizen-video/presets/film-cinematic-drama/CREDITS.txt +1 -0
- package/skills/media/kaizen-video/presets/film-cinematic-drama/README.md +2 -0
- package/skills/media/kaizen-video/presets/film-cinematic-drama/style.json +39 -0
- package/skills/media/kaizen-video/presets/film-fun-energetic/CREDITS.txt +1 -0
- package/skills/media/kaizen-video/presets/film-fun-energetic/README.md +2 -0
- package/skills/media/kaizen-video/presets/film-fun-energetic/style.json +39 -0
- package/skills/media/kaizen-video/presets/film-promo-booth/CREDITS.txt +1 -0
- package/skills/media/kaizen-video/presets/film-promo-booth/README.md +2 -0
- package/skills/media/kaizen-video/presets/film-promo-booth/style.json +39 -0
- package/skills/media/kaizen-video/presets/film-viral-social/CREDITS.txt +1 -0
- package/skills/media/kaizen-video/presets/film-viral-social/README.md +2 -0
- package/skills/media/kaizen-video/presets/film-viral-social/style.json +39 -0
- package/skills/media/kaizen-video/presets/tiktok-explainer/CREDITS.txt +3 -0
- package/skills/media/kaizen-video/presets/tiktok-explainer/README.md +52 -0
- package/skills/media/kaizen-video/presets/tiktok-explainer/assets/fonts/InstrumentSerif.ttf +0 -0
- package/skills/media/kaizen-video/presets/tiktok-explainer/assets/fonts/SpaceMono.ttf +0 -0
- package/skills/media/kaizen-video/presets/tiktok-explainer/assets/sfx/boing.wav +0 -0
- package/skills/media/kaizen-video/presets/tiktok-explainer/assets/sfx/ding.wav +0 -0
- package/skills/media/kaizen-video/presets/tiktok-explainer/assets/sfx/ending.wav +0 -0
- package/skills/media/kaizen-video/presets/tiktok-explainer/assets/sfx/pop.wav +0 -0
- package/skills/media/kaizen-video/presets/tiktok-explainer/assets/sfx/reveal.wav +0 -0
- package/skills/media/kaizen-video/presets/tiktok-explainer/assets/sfx/shutter.wav +0 -0
- package/skills/media/kaizen-video/presets/tiktok-explainer/assets/sfx/stamp.wav +0 -0
- package/skills/media/kaizen-video/presets/tiktok-explainer/assets/sfx/tick.wav +0 -0
- package/skills/media/kaizen-video/presets/tiktok-explainer/assets/sfx/whoosh.wav +0 -0
- package/skills/media/kaizen-video/presets/tiktok-explainer/index.html +272 -0
- package/skills/media/kaizen-video/presets/tiktok-explainer/package.json +10 -0
- package/skills/{kaizen-video → media/kaizen-video}/references/presets.md +22 -0
- package/skills/{story-video → media/kaizen-video}/references/production.md +2 -2
- package/skills/{story-video → media/kaizen-video}/references/revisions.md +9 -9
- package/skills/media/kaizen-video/scripts/__pycache__/intake.cpython-314.pyc +0 -0
- package/skills/media/kaizen-video/scripts/__pycache__/make_film.cpython-314.pyc +0 -0
- package/skills/media/kaizen-video/scripts/__pycache__/make_tiktok.cpython-314.pyc +0 -0
- package/skills/media/kaizen-video/scripts/__pycache__/render_cut.cpython-314.pyc +0 -0
- package/skills/media/kaizen-video/scripts/__pycache__/sfx.cpython-314.pyc +0 -0
- package/skills/media/kaizen-video/scripts/__pycache__/srt.cpython-314.pyc +0 -0
- package/skills/media/kaizen-video/scripts/__pycache__/transcribe.cpython-314.pyc +0 -0
- package/skills/media/kaizen-video/scripts/__pycache__/vo.cpython-314.pyc +0 -0
- package/skills/{kaizen-video → media/kaizen-video}/scripts/check.sh +8 -0
- package/skills/{story-video → media/kaizen-video}/scripts/intake.py +2 -2
- package/skills/media/kaizen-video/scripts/make_film.py +263 -0
- package/skills/{kaizen-video → media/kaizen-video}/scripts/make_tiktok.py +8 -3
- package/skills/{story-video → media/kaizen-video}/scripts/master.sh +12 -11
- package/skills/{story-video → media/kaizen-video}/scripts/sfx.py +3 -3
- package/skills/media/kaizen-video/scripts/srt.py +9 -0
- package/skills/media/kaizen-video/scripts/still.sh +7 -0
- package/web/board/views.ts +1 -1
- package/web/board.html +2 -0
- package/web/board.js +2 -2
- package/web/notebook.js +1 -1
- package/web/notes.html +2 -0
- package/web/term.js +1 -1
- package/web/video.html +298 -0
- package/web/video.js +81 -0
- package/web/video.ts +610 -0
- package/skills/kaizen-video/SKILL.md +0 -73
- package/skills/story-video/SKILL.md +0 -108
- package/skills/story-video/engine/package.json +0 -12
- package/skills/story-video/engine/src/Root.tsx +0 -24
- package/skills/story-video/engine/src/engine/Film.tsx +0 -405
- package/skills/story-video/engine/src/engine/presets.json +0 -58
- package/skills/story-video/engine/src/index.ts +0 -3
- package/skills/story-video/engine/tsconfig.json +0 -1
- package/skills/story-video/examples/sample-film/README.md +0 -12
- package/skills/story-video/examples/sample-film/film.json +0 -16
- package/skills/story-video/references/presets.md +0 -21
- package/skills/story-video/scripts/check.sh +0 -19
- package/skills/story-video/scripts/setup.sh +0 -25
- package/skills/story-video/scripts/srt.py +0 -30
- package/skills/story-video/scripts/still.sh +0 -10
- /package/skills/{story-deck → media/kaizen-deck}/examples/build-example.js +0 -0
- /package/skills/{story-deck → media/kaizen-deck}/references/rules.md +0 -0
- /package/skills/{story-deck → media/kaizen-deck}/references/story-arc.md +0 -0
- /package/skills/{story-deck → media/kaizen-deck}/scripts/kit.js +0 -0
- /package/skills/{story-deck → media/kaizen-deck}/scripts/prep_images.py +0 -0
- /package/skills/{story-deck → media/kaizen-deck}/scripts/qa.sh +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/assets/fonts/Fredoka.ttf +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/assets/fonts/Montserrat.ttf +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/assets/fonts/SpecialElite.ttf +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/assets/music/investigations.mp3 +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/assets/music/lights.mp3 +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/assets/music/sunny.mp3 +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/examples/config.json +0 -0
- /package/skills/{story-video → media/kaizen-video}/examples/sample-film/lines.json +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/hyperframes.json +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/presets/educational-warning/CREDITS.txt +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/presets/educational-warning/README.md +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/presets/educational-warning/assets/sfx/ending.wav +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/presets/educational-warning/assets/sfx/reveal.wav +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/presets/educational-warning/assets/sfx/shutter.wav +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/presets/educational-warning/assets/sfx/stamp.wav +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/presets/educational-warning/index.html +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/presets/educational-warning/package.json +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-cheerful/CREDITS.txt +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-cheerful/README.md +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-cheerful/assets/sfx/boing.wav +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-cheerful/assets/sfx/chime.wav +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-cheerful/assets/sfx/ding.wav +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-cheerful/assets/sfx/ending.wav +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-cheerful/assets/sfx/pop.wav +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-cheerful/assets/sfx/reveal.wav +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-cheerful/assets/sfx/shutter.wav +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-cheerful/assets/sfx/stamp.wav +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-cheerful/assets/sfx/whoosh.wav +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-cheerful/index.html +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-cheerful/package.json +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-mystery/CREDITS.txt +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-mystery/README.md +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-mystery/assets/sfx/clock.wav +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-mystery/assets/sfx/ding.wav +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-mystery/assets/sfx/ending.wav +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-mystery/assets/sfx/heart.wav +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-mystery/assets/sfx/projector.wav +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-mystery/assets/sfx/reveal.wav +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-mystery/assets/sfx/shutter.wav +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-mystery/assets/sfx/stamp.wav +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-mystery/assets/sfx/whoosh.wav +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-mystery/index.html +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-mystery/package.json +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-scam-alert/CREDITS.txt +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-scam-alert/README.md +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-scam-alert/assets/sfx/clock.wav +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-scam-alert/assets/sfx/ding.wav +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-scam-alert/assets/sfx/ending.wav +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-scam-alert/assets/sfx/heart.wav +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-scam-alert/assets/sfx/reveal.wav +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-scam-alert/assets/sfx/shutter.wav +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-scam-alert/assets/sfx/stamp.wav +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-scam-alert/assets/sfx/whoosh.wav +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-scam-alert/index.html +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-scam-alert/package.json +0 -0
- /package/skills/{story-video → media/kaizen-video}/references/licensing.md +0 -0
- /package/skills/{story-video → media/kaizen-video}/references/script.md +0 -0
- /package/skills/{story-video → media/kaizen-video}/scripts/music.sh +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/scripts/render_cut.py +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/scripts/setup.sh +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/scripts/transcribe.py +0 -0
- /package/skills/{story-video → media/kaizen-video}/scripts/vo.py +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/tools/carve.mjs +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/tools/lib/main-module.mjs +0 -0
package/web/video.ts
ADDED
|
@@ -0,0 +1,610 @@
|
|
|
1
|
+
// The Video page: kaizen-video's videos as a library (one tile per rendered shape, at
|
|
2
|
+
// that shape), and an editor for a film's on-screen words with its built preview.
|
|
3
|
+
// Bundled into web/video.js by `bun run build:web`. The server keeps every path; the
|
|
4
|
+
// page names a video by its place number and folder name only.
|
|
5
|
+
|
|
6
|
+
type Format = "9x16" | "16x9" | "4x5";
|
|
7
|
+
type Job = { id: number; kind: string; state: string; step: number; total: number; label: string[]; error: string | null; place: number; name: string };
|
|
8
|
+
type Output = { format: Format | null; file: string; thumb: string | null; srt: string | null; seconds: number | null };
|
|
9
|
+
type Video = {
|
|
10
|
+
place: number; where: string; name: string; mode: "film" | "footage"; preset: string; title: string; formats: Format[];
|
|
11
|
+
platforms: string[]; seconds: number | null; text: string; poster: string | null; built: boolean; size: [number, number] | null; outputs: Output[];
|
|
12
|
+
job: Job | null; status: "draft" | "queued" | "building" | "rendering" | "ready" | "failed"; send: Send | null; sends: Send[];
|
|
13
|
+
};
|
|
14
|
+
type Send = { at: number; via: "localsend" | "whatsapp"; to: string; file: string; state: "waiting" | "uploading" | "sent" | "failed"; pct: number; error: string | null };
|
|
15
|
+
type Beat = { i: number; kind: string; vo: string | null; line: string; start: number; len: number | null; title?: string; sub?: string; kick?: string; items?: string[] };
|
|
16
|
+
type Film = { name: string; preset: string; format: Format; platforms: string[]; beats: Beat[]; stills: string[]; job: Job | null; log: string };
|
|
17
|
+
|
|
18
|
+
const PLATFORMS: Record<string, Format> = {
|
|
19
|
+
"TikTok": "9x16", "Instagram Reels": "9x16", "YouTube Shorts": "9x16", "WhatsApp Status": "9x16", "YouTube": "16x9", "Instagram feed": "4x5",
|
|
20
|
+
};
|
|
21
|
+
const SIZE: Record<Format, [number, number]> = { "9x16": [1080, 1920], "16x9": [1920, 1080], "4x5": [1080, 1350] };
|
|
22
|
+
const COLORS = ["#b4462f", "#a8720a", "#2d50a5", "#0f8fa3", "#1f9d55", "#7a4fb5", "#c2416b", "#5b6b8c"];
|
|
23
|
+
|
|
24
|
+
const $ = <T extends HTMLElement = HTMLElement>(id: string) => document.getElementById(id) as T;
|
|
25
|
+
const esc = (s: unknown) => String(s ?? "").replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
|
|
26
|
+
const mmss = (s: number | null) => s == null ? "" : `${Math.floor(s / 60)}:${String(Math.round(s % 60)).padStart(2, "0")}`;
|
|
27
|
+
const ratio = (f: Format | null) => (f ?? "9x16").replace("x", ":");
|
|
28
|
+
const fileUrl = (v: { place: number; name: string }, rel: string) => `/video/f/${v.place}/${encodeURIComponent(v.name)}/${rel.split("/").map(encodeURIComponent).join("/")}`;
|
|
29
|
+
const LABEL: Record<Video["status"], string> = { draft: "Draft", queued: "Queued", building: "Building preview", rendering: "Rendering", ready: "Ready", failed: "Failed" };
|
|
30
|
+
|
|
31
|
+
let videos: Video[] = [];
|
|
32
|
+
type Agent = { place: number; name: string; pty: string; alive: boolean; asking: boolean; at: number };
|
|
33
|
+
let agents: Agent[] = [];
|
|
34
|
+
let places: { i: number; label: string; dir: string }[] = [];
|
|
35
|
+
// The newest agent the page started on a video, while the board still holds its terminal.
|
|
36
|
+
const agentOn = (place: number, name: string) => agents.filter((a) => a.place === place && a.name === name).sort((a, b) => b.at - a.at)[0];
|
|
37
|
+
let filter = "";
|
|
38
|
+
let toastT: ReturnType<typeof setTimeout> | undefined;
|
|
39
|
+
function toast(msg: string) {
|
|
40
|
+
const t = $("toast");
|
|
41
|
+
t.textContent = msg;
|
|
42
|
+
t.classList.add("on");
|
|
43
|
+
clearTimeout(toastT);
|
|
44
|
+
toastT = setTimeout(() => t.classList.remove("on"), 3200);
|
|
45
|
+
}
|
|
46
|
+
async function post(path: string, body: unknown) {
|
|
47
|
+
const r = await fetch(path, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
|
|
48
|
+
const j = await r.json().catch(() => ({}));
|
|
49
|
+
if (!r.ok) throw new Error(j.error || j.why || `${r.status}`);
|
|
50
|
+
return j;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ---- library
|
|
54
|
+
|
|
55
|
+
async function load() {
|
|
56
|
+
let r: Response;
|
|
57
|
+
try { r = await fetch("/video/list"); } catch { toast("The board is not running"); return; }
|
|
58
|
+
const d = await r.json();
|
|
59
|
+
videos = d.videos;
|
|
60
|
+
agents = d.agents ?? [];
|
|
61
|
+
places = d.places;
|
|
62
|
+
$("emptyhow").innerHTML = `Videos are folders under <code>videos/</code> in ${d.places.map((p: { dir: string }) => `<code>${esc(p.dir)}</code>`).join(" or ")}. Ask the kaizen-video skill to make one, and it shows up here.`;
|
|
63
|
+
drawGrid();
|
|
64
|
+
if (open) { refreshEditor(); drawSendState(); }
|
|
65
|
+
drawDock();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// One tile per rendered shape; a video with nothing rendered yet shows once, at its own shape.
|
|
69
|
+
function tiles() {
|
|
70
|
+
return videos.flatMap((v) => {
|
|
71
|
+
const outs = v.outputs.length ? v.outputs : [null];
|
|
72
|
+
const shapes = new Map<string, Output | null>();
|
|
73
|
+
for (const o of outs) if (!shapes.has(o?.format ?? v.formats[0]!)) shapes.set(o?.format ?? v.formats[0]!, o);
|
|
74
|
+
return [...shapes].map(([f, o]) => ({ v, f: f as Format, o }));
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function drawGrid() {
|
|
79
|
+
const words = $<HTMLInputElement>("q").value.trim().toLowerCase().split(/\s+/).filter(Boolean);
|
|
80
|
+
const all = tiles();
|
|
81
|
+
const shown = all.filter(({ v }) => {
|
|
82
|
+
const hay = [v.title, v.name, v.text, v.preset, v.where, v.mode, ...v.platforms].join(" ").toLowerCase();
|
|
83
|
+
const st = filter === "busy" ? ["queued", "building", "rendering"].includes(v.status) : !filter || v.status === filter;
|
|
84
|
+
return st && words.every((w) => hay.includes(w));
|
|
85
|
+
});
|
|
86
|
+
// Videos an agent is still setting up: no film.json yet, so only the agent knows them.
|
|
87
|
+
const making = agents.filter((a) => a.alive && !videos.some((v) => v.place === a.place && v.name === a.name) && words.every((w) => a.name.includes(w)) && (!filter || filter === "busy" || filter === "draft"));
|
|
88
|
+
$("grid").innerHTML = making.map((a) => `<article class="vid" tabindex="0" data-p="${a.place}" data-n="${esc(a.name)}" aria-label="${esc(a.name)}, being made">
|
|
89
|
+
<div class="thumb making" style="--ar:9/16"><span class="t">${a.asking ? "Claude is asking you something" : "Claude is making this"}</span></div>
|
|
90
|
+
<div class="meta"><b>${esc(a.name)}</b><div class="plats"><i class="where">${esc(places[a.place]?.label ?? "")}</i></div>
|
|
91
|
+
<span class="pill ${a.asking ? "draft" : "building"}">${a.asking ? "Needs your answer" : "Being made"}</span></div></article>`).join("") + shown.map(({ v, f, o }) => {
|
|
92
|
+
const [w, h] = SIZE[f];
|
|
93
|
+
const img = o?.thumb ? fileUrl(v, o.thumb) : v.poster ? fileUrl(v, v.poster) : "";
|
|
94
|
+
const plats = v.platforms.filter((p) => PLATFORMS[p] === f);
|
|
95
|
+
const busy = v.job && (v.job.state === "running" || v.job.state === "queued");
|
|
96
|
+
const pct = busy && v.job!.total ? Math.round((v.job!.step / v.job!.total) * 100) : 0;
|
|
97
|
+
return `<article class="vid" tabindex="0" data-p="${v.place}" data-n="${esc(v.name)}" aria-label="${esc(v.name)}, ${ratio(f)}">
|
|
98
|
+
<div class="thumb" style="--ar:${w}/${h}">${img ? `<img src="${esc(img)}" alt="" loading="lazy">` : ""}
|
|
99
|
+
<span class="tag sh">${ratio(f)}</span><span class="t">${img ? "" : esc(v.title)}</span>
|
|
100
|
+
${(o?.seconds ?? v.seconds) != null ? `<span class="tag du">${mmss(o?.seconds ?? v.seconds)}</span>` : ""}
|
|
101
|
+
${busy ? `<span class="bar" style="width:${Math.max(6, pct)}%"></span>` : ""}</div>
|
|
102
|
+
<div class="meta"><b>${esc(v.name)}</b>
|
|
103
|
+
<div class="plats">${plats.map((p) => `<i>${esc(p)}</i>`).join("")}<i class="where" title="Where it lives">${esc(v.where)}</i></div>
|
|
104
|
+
<span class="pill ${v.status}">${LABEL[v.status]}${v.preset ? ` · ${esc(v.preset)}` : ""}</span></div></article>`;
|
|
105
|
+
}).join("");
|
|
106
|
+
$("nores").hidden = !all.length || !!shown.length || !!making.length;
|
|
107
|
+
$("empty").hidden = !!all.length || !!making.length;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
$("grid").addEventListener("click", (e) => {
|
|
111
|
+
const t = (e.target as HTMLElement).closest<HTMLElement>(".vid");
|
|
112
|
+
if (t) go(Number(t.dataset.p), t.dataset.n!);
|
|
113
|
+
});
|
|
114
|
+
$("grid").addEventListener("keydown", (e) => {
|
|
115
|
+
const t = (e.target as HTMLElement).closest<HTMLElement>(".vid");
|
|
116
|
+
if (t && (e.key === "Enter" || e.key === " ")) { e.preventDefault(); go(Number(t.dataset.p), t.dataset.n!); }
|
|
117
|
+
});
|
|
118
|
+
$("q").addEventListener("input", drawGrid);
|
|
119
|
+
$("fstat").addEventListener("click", (e) => {
|
|
120
|
+
const c = (e.target as HTMLElement).closest<HTMLElement>(".chip");
|
|
121
|
+
if (!c) return;
|
|
122
|
+
filter = c.dataset.f!;
|
|
123
|
+
for (const x of $("fstat").querySelectorAll(".chip")) x.classList.toggle("on", x === c);
|
|
124
|
+
drawGrid();
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
// ---- editor: #<place>/<name> in the address, so a reload or a shared tab opens the same video
|
|
128
|
+
|
|
129
|
+
let open: { place: number; name: string } | null = null;
|
|
130
|
+
let film: Film | null = null;
|
|
131
|
+
let edits = new Map<number, Partial<Beat>>();
|
|
132
|
+
let picked = new Set<string>();
|
|
133
|
+
let dur = 0;
|
|
134
|
+
let playing = false;
|
|
135
|
+
let lastJob = "";
|
|
136
|
+
|
|
137
|
+
function go(place: number | null, name?: string) {
|
|
138
|
+
const want = place == null ? "" : `#${place}/${encodeURIComponent(name!)}`;
|
|
139
|
+
if (location.hash !== want) history.pushState(null, "", want || location.pathname);
|
|
140
|
+
route();
|
|
141
|
+
}
|
|
142
|
+
function route() {
|
|
143
|
+
const m = location.hash.match(/^#(\d+)\/(.+)$/);
|
|
144
|
+
open = m ? { place: Number(m[1]), name: decodeURIComponent(m[2]!) } : null;
|
|
145
|
+
const wiz = location.hash === "#new";
|
|
146
|
+
$("lib").hidden = !!open || wiz;
|
|
147
|
+
$("edit").hidden = !open;
|
|
148
|
+
$("wiz").hidden = !wiz;
|
|
149
|
+
film = null;
|
|
150
|
+
edits = new Map();
|
|
151
|
+
sp = null;
|
|
152
|
+
$("sendp").innerHTML = "";
|
|
153
|
+
if (open) refreshEditor(true);
|
|
154
|
+
else $("editbody").innerHTML = "";
|
|
155
|
+
if (wiz) drawWizard();
|
|
156
|
+
drawDock();
|
|
157
|
+
}
|
|
158
|
+
addEventListener("popstate", route);
|
|
159
|
+
|
|
160
|
+
const cur = () => open && videos.find((v) => v.place === open!.place && v.name === open!.name);
|
|
161
|
+
|
|
162
|
+
async function refreshEditor(first = false) {
|
|
163
|
+
const v = cur();
|
|
164
|
+
if (!v) {
|
|
165
|
+
const a = open && agentOn(open.place, open.name);
|
|
166
|
+
if (a) $("editbody").innerHTML = `<div class="crumb"><button data-back>Videos</button> / ${esc(places[a.place]?.label ?? "")}</div>
|
|
167
|
+
<div class="ehead"><h2>${esc(a.name)}</h2></div><p class="note">Claude is setting this video up. Answer its questions above; the editor opens here once the video has its storyboard.</p>`;
|
|
168
|
+
else if (videos.length) $("editbody").innerHTML = `<p class="blank">That video is gone. <button class="btn" data-back>Back to videos</button></p>`;
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
const jobKey = JSON.stringify(v.job && [v.job.id, v.job.state, v.job.step]);
|
|
172
|
+
if (!first && film && jobKey === lastJob) return;
|
|
173
|
+
const builtNow = lastJob && v.job?.state === "done" && jobKey !== lastJob;
|
|
174
|
+
lastJob = jobKey;
|
|
175
|
+
// Nothing drawn for this video yet: the first answer after opening it, however it arrives.
|
|
176
|
+
const fresh = first || !$("edit").querySelector(".ed");
|
|
177
|
+
if (v.mode === "film") {
|
|
178
|
+
const r = await fetch(`/video/film?p=${v.place}&name=${encodeURIComponent(v.name)}`);
|
|
179
|
+
if (!r.ok) { $("editbody").innerHTML = `<p class="blank">Could not read this film's film.json.</p>`; return; }
|
|
180
|
+
film = await r.json();
|
|
181
|
+
}
|
|
182
|
+
if (fresh) picked = new Set(film?.platforms.length ? film.platforms : v.platforms);
|
|
183
|
+
if (fresh || v.mode !== "film") drawEditor(v);
|
|
184
|
+
else { drawJob(v); drawFiles(v); if (builtNow) reloadPreview(v); }
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function drawEditor(v: Video) {
|
|
188
|
+
picked0 = false;
|
|
189
|
+
const f = (film?.format ?? v.formats[0] ?? "9x16") as Format;
|
|
190
|
+
const [w, h] = v.size ?? SIZE[f];
|
|
191
|
+
const beats = film?.beats ?? [];
|
|
192
|
+
dur = beats.reduce((a, b) => Math.max(a, b.start + (b.len ?? 0)), 0) || v.seconds || 0;
|
|
193
|
+
$("editbody").innerHTML = `
|
|
194
|
+
<div class="crumb"><button data-back>Videos</button> / ${esc(v.where)}</div>
|
|
195
|
+
<div class="ehead"><h2>${esc(v.name)}</h2><span class="pill ${v.status}" id="stat">${LABEL[v.status]}</span>
|
|
196
|
+
<div class="acts">${v.mode === "film" ? `<button class="btn" id="stillsBtn" title="Build the film and take three still frames">Preview stills</button>` : ""}
|
|
197
|
+
<button class="btn pri" id="render">Render final</button></div></div>
|
|
198
|
+
<div class="ed">
|
|
199
|
+
<div class="player">
|
|
200
|
+
<div class="screen" style="--ar:${w}/${h}" id="screen">${v.built
|
|
201
|
+
? `<iframe id="frame" title="Preview" src="${esc(fileUrl(v, "v/film/index.html"))}" width="${w}" height="${h}"></iframe>`
|
|
202
|
+
: `<div class="none">No preview yet. Press <b>Preview stills</b> or save a change to build it.</div>`}</div>
|
|
203
|
+
<div class="ctrl"><button class="play" id="play" aria-label="Play" ${v.built ? "" : "disabled"}><svg><use href="#i-play"/></svg></button><span id="time">0:00 / ${mmss(dur)}</span><span class="sp"></span><span id="shape">${ratio(f)} · ${esc(v.preset)}</span></div>
|
|
204
|
+
${beats.length ? `<div class="strip" id="strip">${beats.map((b, i) => `<button style="flex:${Math.max(1, b.len ?? 1)};--c:${COLORS[i % COLORS.length]}" data-b="${i}" title="${esc(b.kind)} ${mmss(b.start)}">${i + 1}</button>`).join("")}<span class="head" id="head" style="left:0"></span></div>` : ""}
|
|
205
|
+
<div class="stills" id="stills">${(film?.stills ?? []).map((s) => `<a href="${esc(fileUrl(v, s))}" target="_blank" rel="noopener"><img src="${esc(fileUrl(v, s))}?t=${Date.now()}" alt="Still frame"></a>`).join("")}</div>
|
|
206
|
+
</div>
|
|
207
|
+
<div class="beats">
|
|
208
|
+
<div id="job"></div>
|
|
209
|
+
${v.mode === "film" ? beats.map((b, i) => beatRow(b, i)).join("") : `<p class="note">A video made from your footage. Its words come from what you said; change them by asking the kaizen-video skill. Render it again here.</p>`}
|
|
210
|
+
<div class="savebar"><div class="chips" id="plats" role="group" aria-label="Where it goes">${Object.keys(PLATFORMS).map((p) => `<button class="chip${picked.has(p) ? " on" : ""}" data-p="${esc(p)}">${esc(p)}</button>`).join("")}</div>
|
|
211
|
+
${v.mode === "film" ? `<button class="btn pri" id="save">Save changes</button>` : ""}</div>
|
|
212
|
+
<p class="note" id="platnote"></p>
|
|
213
|
+
<div class="ask"><input id="askin" maxlength="2000" placeholder="Ask Claude for a bigger change, like a new voice line or a punchier hook" aria-label="Ask Claude to change this video"><button class="btn pri" id="askgo">Send</button></div>
|
|
214
|
+
<p class="note">Claude works in a terminal at the top of this page; answer it there.</p>
|
|
215
|
+
<div class="files" id="files"></div>
|
|
216
|
+
</div>
|
|
217
|
+
</div>`;
|
|
218
|
+
fit();
|
|
219
|
+
drawPlats();
|
|
220
|
+
drawJob(v);
|
|
221
|
+
drawFiles(v);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function beatRow(b: Beat, i: number) {
|
|
225
|
+
const e = { ...b, ...edits.get(i) };
|
|
226
|
+
const field = (k: "kick" | "title" | "sub", label: string) => typeof b[k] === "string"
|
|
227
|
+
? `<label>${label}<input data-i="${i}" data-k="${k}" value="${esc(e[k])}" maxlength="400"></label>` : "";
|
|
228
|
+
const items = Array.isArray(b.items) ? `<label>List, one per line<textarea data-i="${i}" data-k="items">${esc((e.items ?? []).join("\n"))}</textarea></label>` : "";
|
|
229
|
+
const fields = field("kick", "Small line above") + field("title", "Words on screen") + field("sub", "Line below") + items;
|
|
230
|
+
return `<div class="beat" data-b="${i}"><button><span class="sw" style="--c:${COLORS[i % COLORS.length]}"></span><span class="k">${esc(b.kind)}</span>
|
|
231
|
+
<span class="tm">${mmss(b.start)}–${mmss(b.start + (b.len ?? 0))}</span><span class="tx">${esc(e.title ?? e.kick ?? b.line.replace(/\[[^\]]*\]\s*/g, ""))}</span></button>
|
|
232
|
+
<div class="form">${fields || `<p class="note">No words on screen in this beat.</p>`}
|
|
233
|
+
${b.line ? `<div class="said">Said: ${esc(b.line.replace(/\[[^\]]*\]\s*/g, ""))}<br><span class="note">Changing what is said records the voice again; ask Claude for that.</span></div>` : ""}</div></div>`;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// The iframe is the film at full size (1080×1920…), scaled down to the box.
|
|
237
|
+
function fit() {
|
|
238
|
+
const fr = document.getElementById("frame") as HTMLIFrameElement | null, box = document.getElementById("screen");
|
|
239
|
+
if (fr && box) fr.style.transform = `scale(${box.clientWidth / Number(fr.width)})`;
|
|
240
|
+
}
|
|
241
|
+
addEventListener("resize", fit);
|
|
242
|
+
|
|
243
|
+
function reloadPreview(v: Video) {
|
|
244
|
+
const box = document.getElementById("screen");
|
|
245
|
+
if (!box) return;
|
|
246
|
+
const [w, h] = v.size ?? SIZE[(film?.format ?? "9x16") as Format];
|
|
247
|
+
box.style.setProperty("--ar", `${w}/${h}`);
|
|
248
|
+
box.innerHTML = `<iframe id="frame" title="Preview" src="${esc(fileUrl(v, "v/film/index.html"))}?t=${Date.now()}" width="${w}" height="${h}"></iframe>`;
|
|
249
|
+
($("play") as HTMLButtonElement).disabled = false;
|
|
250
|
+
fit();
|
|
251
|
+
const st = document.getElementById("stills");
|
|
252
|
+
if (st && film) st.innerHTML = film.stills.map((s) => `<a href="${esc(fileUrl(v, s))}" target="_blank" rel="noopener"><img src="${esc(fileUrl(v, s))}?t=${Date.now()}" alt="Still frame"></a>`).join("");
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function drawPlats() {
|
|
256
|
+
const fs = [...new Set([...picked].map((p) => PLATFORMS[p]))];
|
|
257
|
+
const n = document.getElementById("platnote");
|
|
258
|
+
if (n) n.textContent = picked.size ? `Render makes ${fs.length} file${fs.length > 1 ? "s" : ""}: ${fs.map((f) => ratio(f!)).join(", ")}` : "Pick where it goes, then Render.";
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function drawJob(v: Video) {
|
|
262
|
+
const el = document.getElementById("job"), stat = document.getElementById("stat");
|
|
263
|
+
if (stat) { stat.className = `pill ${v.status}`; stat.textContent = LABEL[v.status]; }
|
|
264
|
+
const busy = !!v.job && (v.job.state === "running" || v.job.state === "queued");
|
|
265
|
+
for (const id of ["render", "stillsBtn", "save"]) { const b = document.getElementById(id) as HTMLButtonElement | null; if (b) b.disabled = busy; }
|
|
266
|
+
if (!el) return;
|
|
267
|
+
const j = v.job;
|
|
268
|
+
if (!j || j.state === "done" || j.state === "cancelled") { el.innerHTML = ""; return; }
|
|
269
|
+
const pct = j.total ? Math.round((j.step / j.total) * 100) : 0;
|
|
270
|
+
const what = j.state === "queued" ? "Waiting for another video to finish" : j.state === "failed" ? j.error ?? "Failed" : `${j.label[j.step] ?? "Working"} (step ${j.step + 1} of ${j.total})`;
|
|
271
|
+
el.innerHTML = `<div class="job"><div class="row"><b>${esc(what)}</b><span class="sp"></span>${busy ? `<button class="btn" data-cancel="${j.id}">Cancel</button>` : ""}</div>
|
|
272
|
+
${busy ? `<div class="prog"><i style="width:${Math.max(4, pct)}%"></i></div>` : ""}
|
|
273
|
+
${j.state === "failed" && film?.log ? `<pre>${esc(film.log.split("\n").slice(-25).join("\n"))}</pre>` : ""}</div>`;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function drawFiles(v: Video) {
|
|
277
|
+
const el = document.getElementById("files");
|
|
278
|
+
if (!el) return;
|
|
279
|
+
el.innerHTML = v.outputs.length ? `<p class="note" style="margin:0">Finished files</p>` + v.outputs.map((o) => `<div class="file"><span class="pill ready">${ratio(o.format)}</span><b>${esc(o.file.split("/").pop())}</b>
|
|
280
|
+
${o.seconds != null ? `<span class="note" style="margin:0">${mmss(o.seconds)}</span>` : ""}
|
|
281
|
+
<a href="${esc(fileUrl(v, o.file))}" target="_blank" rel="noopener">Open</a>${o.srt ? `<a href="${esc(fileUrl(v, o.srt))}" target="_blank" rel="noopener">Captions</a>` : ""}
|
|
282
|
+
<button class="btn" data-sendfile="${esc(o.file)}">Send</button></div>`).join("") : "";
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// ---- Send: the panel under the editor. Drawn on the person's own actions only, so a
|
|
286
|
+
// server change (a render step, a send's progress) never wipes a message being typed;
|
|
287
|
+
// the status and recent sends below it are redrawn on every change.
|
|
288
|
+
type Dev = { id: string; alias: string; deviceModel: string | null; deviceType: string | null };
|
|
289
|
+
type Chat = { id: string; name: string; type: string };
|
|
290
|
+
let sp: { file: string; via: "localsend" | "whatsapp"; devices: Dev[] | null; devNote: string | null; dev: string; chats: Chat[] | null; chatErr: string | null; chat: string; text: string; searching: boolean } | null = null;
|
|
291
|
+
|
|
292
|
+
function openSend(file: string) {
|
|
293
|
+
const v = cur();
|
|
294
|
+
if (!v) return;
|
|
295
|
+
sp = { file, via: sp?.via ?? "localsend", devices: sp?.devices ?? null, devNote: sp?.devNote ?? null, dev: sp?.dev ?? "", chats: sp?.chats ?? null, chatErr: null, chat: sp?.chat ?? "", text: sp?.text || v.title || v.name, searching: false };
|
|
296
|
+
drawSend();
|
|
297
|
+
$("sendp").scrollIntoView({ behavior: "smooth", block: "nearest" });
|
|
298
|
+
if (sp.via === "localsend" && !sp.devices) search(false); else if (sp.via === "whatsapp" && !sp.chats) loadChats();
|
|
299
|
+
}
|
|
300
|
+
async function search(fresh: boolean) {
|
|
301
|
+
if (!sp) return;
|
|
302
|
+
sp.searching = true; drawSend();
|
|
303
|
+
try { const d = await (await fetch(`/video/devices${fresh ? "?fresh=1" : ""}`)).json(); if (sp) { sp.devices = d.devices; sp.devNote = d.note; if (!d.devices.some((x: Dev) => x.id === sp.dev)) sp.dev = d.devices[0]?.id ?? ""; } }
|
|
304
|
+
catch { toast("The board is not running"); }
|
|
305
|
+
if (sp) { sp.searching = false; drawSend(); }
|
|
306
|
+
}
|
|
307
|
+
async function loadChats() {
|
|
308
|
+
if (!sp) return;
|
|
309
|
+
try { const d = await (await fetch("/video/chats")).json(); if (sp) { sp.chats = d.chats; sp.chatErr = d.error ?? null; if (!d.chats.some((c: Chat) => c.id === sp.chat)) sp.chat = d.chats[0]?.id ?? ""; } }
|
|
310
|
+
catch { toast("The board is not running"); }
|
|
311
|
+
drawSend();
|
|
312
|
+
}
|
|
313
|
+
function drawSend() {
|
|
314
|
+
const el = $("sendp"), v = cur();
|
|
315
|
+
if (!sp || !v) { el.innerHTML = ""; return; }
|
|
316
|
+
const kind = (d: Dev) => d.deviceModel || d.deviceType || "";
|
|
317
|
+
const local = sp.via === "localsend";
|
|
318
|
+
el.innerHTML = `<div class="sendp"><div class="row"><h3>Send</h3>
|
|
319
|
+
<label class="sr" for="sfile">File</label><select id="sfile">${v.outputs.flatMap((o) => [o.file, o.srt, o.thumb]).filter(Boolean).map((f) => `<option value="${esc(f)}"${f === sp!.file ? " selected" : ""}>${esc(f!.split("/").pop())}</option>`).join("")}</select>
|
|
320
|
+
<span class="sp"></span><button class="ib" data-sclose aria-label="Close send">✕</button></div>
|
|
321
|
+
<div class="chips" role="tablist"><button class="chip${local ? " on" : ""}" role="tab" aria-selected="${local}" data-via="localsend">LocalSend</button><button class="chip${local ? "" : " on"}" role="tab" aria-selected="${!local}" data-via="whatsapp">WhatsApp <small>via Hermes</small></button></div>
|
|
322
|
+
${local ? `<div class="sendbox">
|
|
323
|
+
<div class="chips" role="group" aria-label="Device">${sp.searching ? `<span class="note">Looking for devices…</span>` : (sp.devices ?? []).map((d) => `<button class="chip${d.id === sp!.dev ? " on" : ""}" data-dev="${esc(d.id)}">${esc(d.alias)}${kind(d) ? ` <small>${esc(kind(d))}</small>` : ""}</button>`).join("") || `<span class="note">No devices found. Open LocalSend on the phone, on the same Wi‑Fi, then search again.</span>`}</div>
|
|
324
|
+
${sp.devNote ? `<span class="note">${esc(sp.devNote)}</span>` : ""}
|
|
325
|
+
<div class="row"><button class="btn" data-search ${sp.searching ? "disabled" : ""}>Search again</button><span class="sp"></span><button class="btn pri" data-sgo ${sp.dev && !sp.searching ? "" : "disabled"}>Send to device</button></div>
|
|
326
|
+
<span class="note">The device asks to accept; it has 2 minutes.</span></div>`
|
|
327
|
+
: `<div class="sendbox">
|
|
328
|
+
${sp.chatErr ? `<span class="note">${esc(sp.chatErr)}</span>` : sp.chats ? `<label>Chat<select id="schat">${sp.chats.map((c) => `<option value="${esc(c.id)}"${c.id === sp!.chat ? " selected" : ""}>${esc(c.name)}${c.type ? ` (${esc(c.type)})` : ""}</option>`).join("")}</select></label>` : `<span class="note">Asking Hermes for its chats…</span>`}
|
|
329
|
+
<label>Message<textarea id="stext" maxlength="1000">${esc(sp.text)}</textarea></label>
|
|
330
|
+
<div class="row"><span class="sp"></span><button class="btn pri" data-sgo ${sp.chat ? "" : "disabled"}>Send on WhatsApp</button></div></div>`}
|
|
331
|
+
<div id="sendst"></div></div>`;
|
|
332
|
+
drawSendState();
|
|
333
|
+
}
|
|
334
|
+
const when = (t: number) => new Date(t).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
|
335
|
+
function drawSendState() {
|
|
336
|
+
const el = document.getElementById("sendst"), v = cur();
|
|
337
|
+
if (!el || !v) return;
|
|
338
|
+
const s = v.send;
|
|
339
|
+
const line = (x: Send) => x.state === "sent" ? `Sent to ${x.to}` : x.state === "failed" ? (x.error ?? "Failed") : x.state === "waiting" ? (x.via === "localsend" ? `Waiting for ${x.to} to accept…` : `Hermes is sending to ${x.to}…`) : `Sending to ${x.to}… ${x.pct}%`;
|
|
340
|
+
const busy = !!s && (s.state === "waiting" || s.state === "uploading");
|
|
341
|
+
el.innerHTML = (s ? `<div class="job sendres ${s.state}" role="status"><div class="row"><b>${esc(line(s))}</b></div>${busy && s.via === "localsend" ? `<div class="prog"><i style="width:${Math.max(4, s.pct)}%"></i></div>` : ""}</div>` : "")
|
|
342
|
+
+ (v.sends.length ? `<p class="note" style="margin:0">Recent sends</p><ul class="sends">${v.sends.map((x) => `<li><span class="pill ${x.state === "sent" ? "ready" : "failed"}">${x.state === "sent" ? "Sent" : "Failed"}</span> ${esc(x.file.split("/").pop())} → ${esc(x.to)}${x.via === "whatsapp" ? " (WhatsApp)" : ""}, ${when(x.at)}${x.state === "failed" && x.error ? ` <span class="note">${esc(x.error)}</span>` : ""}</li>`).join("")}</ul>` : "");
|
|
343
|
+
for (const b of document.querySelectorAll<HTMLButtonElement>("#sendp [data-sgo]")) if (busy) b.disabled = true;
|
|
344
|
+
}
|
|
345
|
+
$("sendp").addEventListener("click", async (e) => {
|
|
346
|
+
const t = e.target as HTMLElement, v = cur();
|
|
347
|
+
if (!sp || !v) return;
|
|
348
|
+
if (t.closest("[data-sclose]")) { sp = null; return drawSend(); }
|
|
349
|
+
const via = t.closest<HTMLElement>("[data-via]");
|
|
350
|
+
if (via) { sp.via = via.dataset.via as "localsend" | "whatsapp"; drawSend(); return sp.via === "localsend" ? (sp.devices ? undefined : search(false)) : (sp.chats ? undefined : loadChats()); }
|
|
351
|
+
const dev = t.closest<HTMLElement>("[data-dev]");
|
|
352
|
+
if (dev) { sp.dev = dev.dataset.dev!; return drawSend(); }
|
|
353
|
+
if (t.closest("[data-search]")) return search(true);
|
|
354
|
+
if (!t.closest("[data-sgo]")) return;
|
|
355
|
+
try {
|
|
356
|
+
await post("/video/send", { p: v.place, name: v.name, file: sp.file, via: sp.via, to: sp.via === "localsend" ? sp.dev : sp.chat, text: sp.text });
|
|
357
|
+
toast(sp.via === "localsend" ? "Accept it on the device" : "Hermes is sending it");
|
|
358
|
+
} catch (err) { toast(err instanceof Error ? err.message : String(err)); }
|
|
359
|
+
load();
|
|
360
|
+
});
|
|
361
|
+
$("sendp").addEventListener("change", (e) => {
|
|
362
|
+
const t = e.target as HTMLSelectElement;
|
|
363
|
+
if (!sp) return;
|
|
364
|
+
if (t.id === "sfile") sp.file = t.value;
|
|
365
|
+
if (t.id === "schat") sp.chat = t.value;
|
|
366
|
+
});
|
|
367
|
+
$("sendp").addEventListener("input", (e) => {
|
|
368
|
+
const t = e.target as HTMLTextAreaElement;
|
|
369
|
+
if (sp && t.id === "stext") sp.text = t.value;
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
function selectBeat(i: number, seek = true) {
|
|
373
|
+
document.querySelectorAll(".beat").forEach((b) => b.classList.toggle("open", Number((b as HTMLElement).dataset.b) === i));
|
|
374
|
+
document.querySelectorAll("#strip button").forEach((b) => b.classList.toggle("on", Number((b as HTMLElement).dataset.b) === i));
|
|
375
|
+
const b = film?.beats[i];
|
|
376
|
+
picked0 = true;
|
|
377
|
+
if (seek && b) send({ kz: "seek", t: b.start + Math.min(1.2, (b.len ?? 0) / 2) });
|
|
378
|
+
}
|
|
379
|
+
const send = (m: unknown) => (document.getElementById("frame") as HTMLIFrameElement | null)?.contentWindow?.postMessage(m, location.origin);
|
|
380
|
+
|
|
381
|
+
addEventListener("message", (e) => {
|
|
382
|
+
if (e.origin !== location.origin || e.data?.kz !== "time") return;
|
|
383
|
+
const { t, dur: d, on } = e.data as { t: number; dur: number; on: boolean };
|
|
384
|
+
dur = d || dur;
|
|
385
|
+
const tm = document.getElementById("time"), hd = document.getElementById("head");
|
|
386
|
+
if (tm) tm.textContent = `${mmss(t)} / ${mmss(dur)}`;
|
|
387
|
+
if (hd && dur) hd.style.left = `${(t / dur) * 100}%`;
|
|
388
|
+
if (on !== playing) {
|
|
389
|
+
playing = on;
|
|
390
|
+
const p = document.getElementById("play");
|
|
391
|
+
if (p) { p.innerHTML = `<svg><use href="#i-${on ? "pause" : "play"}"/></svg>`; p.setAttribute("aria-label", on ? "Pause" : "Play"); }
|
|
392
|
+
}
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
$("edit").addEventListener("click", async (e) => {
|
|
396
|
+
const t = e.target as HTMLElement;
|
|
397
|
+
const v = cur();
|
|
398
|
+
if (t.closest("[data-back]")) return go(null);
|
|
399
|
+
if (!v) return;
|
|
400
|
+
const beat = t.closest<HTMLElement>(".beat>button");
|
|
401
|
+
if (beat) return selectBeat(Number(beat.parentElement!.dataset.b));
|
|
402
|
+
const seg = t.closest<HTMLElement>("#strip button");
|
|
403
|
+
if (seg) return selectBeat(Number(seg.dataset.b));
|
|
404
|
+
if (t.closest("#play")) return send({ kz: playing ? "pause" : "play" });
|
|
405
|
+
const chip = t.closest<HTMLElement>("#plats .chip");
|
|
406
|
+
if (chip) { const p = chip.dataset.p!; picked.has(p) ? picked.delete(p) : picked.add(p); chip.classList.toggle("on", picked.has(p)); return drawPlats(); }
|
|
407
|
+
const sf = t.closest<HTMLElement>("[data-sendfile]");
|
|
408
|
+
if (sf) return openSend(sf.dataset.sendfile!);
|
|
409
|
+
const cancel = t.closest<HTMLElement>("[data-cancel]");
|
|
410
|
+
try {
|
|
411
|
+
if (cancel) { await post("/video/cancel", { id: Number(cancel.dataset.cancel) }); toast("Cancelled"); }
|
|
412
|
+
else if (t.closest("#save")) {
|
|
413
|
+
const list = [...edits].map(([i, x]) => ({ i, ...x }));
|
|
414
|
+
await post("/video/save", { p: v.place, name: v.name, edits: list, platforms: [...picked] });
|
|
415
|
+
edits = new Map();
|
|
416
|
+
toast(list.length ? "Saved. Building the preview…" : "Saved");
|
|
417
|
+
} else if (t.closest("#stillsBtn")) {
|
|
418
|
+
await post("/video/job", { p: v.place, name: v.name, kind: "preview" });
|
|
419
|
+
toast("Building the film and taking stills…");
|
|
420
|
+
} else if (t.closest("#askgo")) {
|
|
421
|
+
const words = ($("askin") as HTMLInputElement).value.trim();
|
|
422
|
+
if (!words) return toast("Write what to change first");
|
|
423
|
+
const r = await post("/video/ask", { p: v.place, name: v.name, text: words });
|
|
424
|
+
($("askin") as HTMLInputElement).value = "";
|
|
425
|
+
toast(r.pty ? "Claude is on it. Its terminal is at the top of the page." : "Claude opened in a terminal window.");
|
|
426
|
+
scrollTo({ top: 0, behavior: "smooth" });
|
|
427
|
+
} else if (t.closest("#render")) {
|
|
428
|
+
if (!picked.size) return toast("Pick where it goes first");
|
|
429
|
+
if (v.mode === "film") await post("/video/save", { p: v.place, name: v.name, edits: [], platforms: [...picked], build: false });
|
|
430
|
+
const r = await post("/video/job", { p: v.place, name: v.name, kind: "render", platforms: [...picked] });
|
|
431
|
+
toast(`Rendering ${r.job.total} file${r.job.total > 1 ? "s" : ""}. You can leave this page.`);
|
|
432
|
+
} else return;
|
|
433
|
+
} catch (err) { toast(err instanceof Error ? err.message : String(err)); }
|
|
434
|
+
load();
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
$("edit").addEventListener("input", (e) => {
|
|
438
|
+
const t = e.target as HTMLInputElement;
|
|
439
|
+
const i = Number(t.dataset.i), k = t.dataset.k;
|
|
440
|
+
if (!k || !film) return;
|
|
441
|
+
const x = edits.get(i) ?? {};
|
|
442
|
+
(x as any)[k] = k === "items" ? t.value.split("\n").map((s) => s.trim()).filter(Boolean) : t.value;
|
|
443
|
+
edits.set(i, x);
|
|
444
|
+
if (k === "title") { const tx = t.closest(".beat")?.querySelector(".tx"); if (tx) tx.textContent = t.value; }
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
// A film opens on its first frame, which is usually a fade from black: show a moment in
|
|
448
|
+
// instead, unless the person already picked a beat while it loaded.
|
|
449
|
+
let picked0 = false;
|
|
450
|
+
document.addEventListener("load", (e) => {
|
|
451
|
+
if ((e.target as HTMLElement).id === "frame" && !picked0) send({ kz: "seek", t: Math.min(1.5, dur / 2) });
|
|
452
|
+
}, true);
|
|
453
|
+
|
|
454
|
+
// ---- the agent on this video: its board terminal, mounted here so questions are answered without leaving the page
|
|
455
|
+
|
|
456
|
+
let termLib: Promise<(el: HTMLElement, key: string, onEnd?: () => void) => () => void> | null = null;
|
|
457
|
+
const loadTerm = () => termLib ??= new Promise((ok, no) => {
|
|
458
|
+
const l = document.createElement("link");
|
|
459
|
+
l.rel = "stylesheet"; l.href = "/term.css";
|
|
460
|
+
document.head.append(l);
|
|
461
|
+
const sc = document.createElement("script");
|
|
462
|
+
sc.src = "/term.js";
|
|
463
|
+
sc.onload = () => ok((window as any).kzTerm);
|
|
464
|
+
sc.onerror = () => { termLib = null; no(new Error("terminal failed to load")); };
|
|
465
|
+
document.head.append(sc);
|
|
466
|
+
});
|
|
467
|
+
let mounted: { key: string; off: () => void } | null = null;
|
|
468
|
+
|
|
469
|
+
async function drawDock() {
|
|
470
|
+
const dock = $("dock");
|
|
471
|
+
const a = open && agentOn(open.place, open.name);
|
|
472
|
+
if (!a) {
|
|
473
|
+
mounted?.off(); mounted = null;
|
|
474
|
+
dock.innerHTML = "";
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
if (mounted?.key !== a.pty) {
|
|
478
|
+
mounted?.off(); mounted = null;
|
|
479
|
+
dock.innerHTML = `<div class="agentbar" id="abar"></div><div class="term" id="term"></div>`;
|
|
480
|
+
try {
|
|
481
|
+
const mount = await loadTerm();
|
|
482
|
+
const el = document.getElementById("term");
|
|
483
|
+
if (el && !mounted) mounted = { key: a.pty, off: mount(el, a.pty, () => load()) };
|
|
484
|
+
} catch {
|
|
485
|
+
// Keep the message: the next poll would rebuild the dock and try again, every few seconds.
|
|
486
|
+
mounted = { key: a.pty, off: () => {} };
|
|
487
|
+
const el = document.getElementById("term");
|
|
488
|
+
if (el) el.textContent = "The terminal could not load. Open it on the board instead.";
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
const bar = document.getElementById("abar");
|
|
492
|
+
if (!bar) return;
|
|
493
|
+
bar.className = "agentbar" + (a.alive && a.asking ? " asking" : "");
|
|
494
|
+
bar.innerHTML = `<b>${!a.alive ? "Claude finished. Its terminal stays here for a few minutes." : a.asking ? "Claude is asking you something: answer in the terminal below." : "Claude is working on this video."}</b>
|
|
495
|
+
<a class="btn" href="/?view=terminal" target="_blank" rel="noopener">Open on the board</a>`;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// ---- New video: one form, the mockup's. Every choice goes into the agent's request.
|
|
499
|
+
|
|
500
|
+
const LANG_NAMES: Record<string, string> = { id: "Indonesian", en: "English" };
|
|
501
|
+
const wiz = { source: "brief" as "brief" | "footage", brief: "", folder: "", preset: "", platforms: new Set(["TikTok"]), seconds: 60, language: "id", place: 0 };
|
|
502
|
+
let wizPresets: { name: string; kind: "film" | "footage"; label: string }[] = [];
|
|
503
|
+
let limits: Record<string, number> = {};
|
|
504
|
+
const LEN_MIN = 15, LEN_MAX = 300;
|
|
505
|
+
const tick = (s: number) => `${((s - LEN_MIN) / (LEN_MAX - LEN_MIN)) * 100}%`;
|
|
506
|
+
|
|
507
|
+
async function drawWizard() {
|
|
508
|
+
try {
|
|
509
|
+
const d = await (await fetch(`/video/presets?p=${wiz.place}`)).json();
|
|
510
|
+
wizPresets = d.presets; limits = d.limits;
|
|
511
|
+
} catch { toast("The board is not running"); return; }
|
|
512
|
+
const kind = wiz.source === "brief" ? "film" : "footage";
|
|
513
|
+
const list = wizPresets.filter((p) => p.kind === kind);
|
|
514
|
+
if (!list.some((p) => p.name === wiz.preset)) wiz.preset = list[0]?.name ?? "";
|
|
515
|
+
$("wiz").innerHTML = `
|
|
516
|
+
<div class="crumb"><button data-back>Videos</button> /</div>
|
|
517
|
+
<div class="ehead"><h2>New video</h2></div>
|
|
518
|
+
<form class="card" id="wform" novalidate>
|
|
519
|
+
<div><h3>Start from</h3><div class="src">
|
|
520
|
+
<button type="button" class="opt${wiz.source === "brief" ? " on" : ""}" data-src="brief"><b>A brief</b><small>Describe it. Claude writes the script, the narration, the cards and picks music.</small></button>
|
|
521
|
+
<button type="button" class="opt${wiz.source === "footage" ? " on" : ""}" data-src="footage"><b>My footage</b><small>A talking-head video already on this computer. Claude cuts dead air and retakes, then captions it.</small></button>
|
|
522
|
+
</div></div>
|
|
523
|
+
${wiz.source === "brief"
|
|
524
|
+
? `<label style="display:grid;gap:6px"><h3 style="margin:0">Brief</h3><textarea id="wbrief" maxlength="4000" placeholder="60-second promo for … in Indonesian, friendly, ends with …">${esc(wiz.brief)}</textarea></label>`
|
|
525
|
+
: `<div><h3>Footage folder</h3><div class="pickrow"><input type="text" id="wfolder" value="${esc(wiz.folder)}" placeholder="/home/you/Videos/recording" aria-label="Footage folder"><button type="button" class="btn" id="wpick">Pick…</button></div>
|
|
526
|
+
<p class="note">Claude copies the video from this folder into the video's own folder; the original stays where it is.</p></div>`}
|
|
527
|
+
<div><h3>Style preset</h3><div class="styles">${list.map((p) => `<button type="button" class="sty${p.name === wiz.preset ? " on" : ""}" data-preset="${esc(p.name)}"><b>${esc(p.label)}</b><small>${esc(p.name)}</small></button>`).join("") || `<p class="note">No ${kind} presets found.</p>`}</div></div>
|
|
528
|
+
<div><h3>Where it goes <small>(pick one or more)</small></h3><div class="chips" id="wplats">${Object.keys(PLATFORMS).map((p) => `<button type="button" class="chip${wiz.platforms.has(p) ? " on" : ""}" data-plat="${esc(p)}">${esc(p)}</button>`).join("")}</div><p class="note" id="wplatnote"></p></div>
|
|
529
|
+
<div><h3>Length</h3>
|
|
530
|
+
<div class="lenrow"><input type="range" id="wlen" min="${LEN_MIN}" max="${LEN_MAX}" step="5" value="${Math.min(LEN_MAX, wiz.seconds)}" aria-label="Length in seconds"><input type="text" id="wlentxt" value="${mmss(wiz.seconds)}" inputmode="numeric" aria-label="Length as minutes:seconds"></div>
|
|
531
|
+
<div class="ticks"><span style="left:0">0:15</span><span class="m-hide" style="left:${tick(60)}">1:00</span><span style="left:${tick(120)}">2:00</span><span style="left:${tick(180)}">3:00</span><span style="left:100%">5:00</span></div>
|
|
532
|
+
<p class="note" id="wlennote"></p></div>
|
|
533
|
+
<div class="two">
|
|
534
|
+
<label>Language<select id="wlang">${Object.entries(LANG_NAMES).map(([k, n]) => `<option value="${k}"${k === wiz.language ? " selected" : ""}>${n}</option>`).join("")}</select></label>
|
|
535
|
+
<label>Save in<select id="wplace">${places.map((p) => `<option value="${p.i}"${p.i === wiz.place ? " selected" : ""}>${esc(p.label === "library" ? `Video library (${p.dir})` : `${p.label} (${p.dir})`)}</option>`).join("")}</select></label>
|
|
536
|
+
</div>
|
|
537
|
+
<div class="foot"><button type="button" class="btn" data-back>Cancel</button><button type="submit" class="btn pri" id="wgo">Generate draft</button></div>
|
|
538
|
+
</form>`;
|
|
539
|
+
wizNotes();
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
function wizNotes() {
|
|
543
|
+
const shapes = [...new Set([...wiz.platforms].map((p) => PLATFORMS[p]))];
|
|
544
|
+
const pn = document.getElementById("wplatnote");
|
|
545
|
+
if (pn) pn.textContent = wiz.platforms.size ? `Makes ${shapes.length} file${shapes.length > 1 ? "s" : ""}: ${shapes.map((f) => ratio(f!)).join(", ")}` : "Pick at least one.";
|
|
546
|
+
const over = [...wiz.platforms].filter((p) => limits[p] && wiz.seconds > limits[p]!).map((p) => `${p} takes at most ${mmss(limits[p]!)}`);
|
|
547
|
+
const ln = document.getElementById("wlennote");
|
|
548
|
+
if (ln) { ln.textContent = over.length ? `Too long: ${over.join(", ")}` : `About ${Math.round(wiz.seconds * 2.3)} words of narration`; ln.className = "note" + (over.length ? " warn-t" : ""); }
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
$("new").addEventListener("click", () => { if (location.hash !== "#new") history.pushState(null, "", "#new"); route(); });
|
|
552
|
+
$("wiz").addEventListener("click", async (e) => {
|
|
553
|
+
const t = e.target as HTMLElement;
|
|
554
|
+
if (t.closest("[data-back]")) return go(null);
|
|
555
|
+
const src = t.closest<HTMLElement>("[data-src]");
|
|
556
|
+
if (src) { keepText(); wiz.source = src.dataset.src as "brief" | "footage"; return drawWizard(); }
|
|
557
|
+
const pr = t.closest<HTMLElement>("[data-preset]");
|
|
558
|
+
if (pr) { wiz.preset = pr.dataset.preset!; for (const b of $("wiz").querySelectorAll("[data-preset]")) b.classList.toggle("on", b === pr); return; }
|
|
559
|
+
const pl = t.closest<HTMLElement>("[data-plat]");
|
|
560
|
+
if (pl) { const p = pl.dataset.plat!; wiz.platforms.has(p) ? wiz.platforms.delete(p) : wiz.platforms.add(p); pl.classList.toggle("on", wiz.platforms.has(p)); return wizNotes(); }
|
|
561
|
+
if (t.closest("#wpick")) {
|
|
562
|
+
try { const r = await post("/pick-folder", {}); if (r.path) { wiz.folder = r.path; ($("wfolder") as HTMLInputElement).value = r.path; } }
|
|
563
|
+
catch (err) { toast(err instanceof Error ? err.message : String(err)); }
|
|
564
|
+
}
|
|
565
|
+
});
|
|
566
|
+
const keepText = () => {
|
|
567
|
+
const b = document.getElementById("wbrief") as HTMLTextAreaElement | null, f = document.getElementById("wfolder") as HTMLInputElement | null;
|
|
568
|
+
if (b) wiz.brief = b.value;
|
|
569
|
+
if (f) wiz.folder = f.value;
|
|
570
|
+
};
|
|
571
|
+
$("wiz").addEventListener("input", (e) => {
|
|
572
|
+
const t = e.target as HTMLInputElement;
|
|
573
|
+
if (t.id === "wlen") { wiz.seconds = Number(t.value); ($("wlentxt") as HTMLInputElement).value = mmss(wiz.seconds); wizNotes(); }
|
|
574
|
+
else if (t.id === "wbrief" || t.id === "wfolder") keepText();
|
|
575
|
+
});
|
|
576
|
+
$("wiz").addEventListener("change", (e) => {
|
|
577
|
+
const t = e.target as HTMLInputElement;
|
|
578
|
+
if (t.id === "wlentxt") {
|
|
579
|
+
const [m, s] = t.value.includes(":") ? t.value.split(":").map(Number) : [0, Number(t.value)];
|
|
580
|
+
wiz.seconds = Math.min(600, Math.max(LEN_MIN, Math.round(((m! * 60 + s!) || 60) / 5) * 5));
|
|
581
|
+
t.value = mmss(wiz.seconds);
|
|
582
|
+
($("wlen") as HTMLInputElement).value = String(Math.min(LEN_MAX, wiz.seconds));
|
|
583
|
+
wizNotes();
|
|
584
|
+
} else if (t.id === "wlang") wiz.language = t.value;
|
|
585
|
+
else if (t.id === "wplace") { keepText(); wiz.place = Number(t.value); drawWizard(); }
|
|
586
|
+
});
|
|
587
|
+
$("wiz").addEventListener("submit", async (e) => {
|
|
588
|
+
e.preventDefault();
|
|
589
|
+
keepText();
|
|
590
|
+
const btn = $("wgo") as HTMLButtonElement;
|
|
591
|
+
btn.disabled = true;
|
|
592
|
+
try {
|
|
593
|
+
const r = await post("/video/new", { p: wiz.place, source: wiz.source, brief: wiz.brief, folder: wiz.folder, preset: wiz.preset, platforms: [...wiz.platforms], seconds: wiz.seconds, language: wiz.language });
|
|
594
|
+
wiz.brief = ""; wiz.folder = "";
|
|
595
|
+
await load();
|
|
596
|
+
go(wiz.place, r.name);
|
|
597
|
+
toast(r.pty ? "Claude started. Answer its questions in the terminal at the top." : "Claude opened in a terminal window.");
|
|
598
|
+
} catch (err) { toast(err instanceof Error ? err.message : String(err)); }
|
|
599
|
+
finally { btn.disabled = false; }
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
// The board's change stream: a job moving or a file landing redraws what is on screen.
|
|
603
|
+
function listen() {
|
|
604
|
+
const es = new EventSource("/events");
|
|
605
|
+
es.onmessage = () => load();
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
route();
|
|
609
|
+
load();
|
|
610
|
+
listen();
|