repovid 0.1.0 → 0.2.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/bin/repovid.mjs +12 -6
- package/package.json +2 -2
- package/src/account.mjs +16 -2
- package/src/publish.mjs +48 -0
- package/src/skill.md +4 -0
package/README.md
CHANGED
|
@@ -9,8 +9,8 @@ npx repovid login
|
|
|
9
9
|
|
|
10
10
|
Then open Claude Code or Codex in your project and ask for a RepoVid walkthrough or demo.
|
|
11
11
|
|
|
12
|
-
Your first run is free.
|
|
12
|
+
Your first run is free. `npx repovid upgrade` unlocks unlimited local videos for $5, once. `npx repovid plans` adds a hosted link you can share.
|
|
13
13
|
|
|
14
14
|
RepoVid records and renders on your machine. It needs Node 20 or newer and a web app running locally with demo data.
|
|
15
15
|
|
|
16
|
-
More at https://repovid.
|
|
16
|
+
More at https://repovid.carles-mallafre.workers.dev. Support: repovid.support@gmail.com.
|
package/bin/repovid.mjs
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// repovid login | upgrade | doctor | init | schema | inspect <path> [--role r] | validate <plan.json> | render <plan.json> [--only a,b] [--out dir]
|
|
2
|
+
// repovid login | upgrade | plans | publish | doctor | init | schema | inspect <path> [--role r] | validate <plan.json> | render <plan.json> [--only a,b] [--out dir]
|
|
3
3
|
import { execFileSync, spawnSync } from "node:child_process";
|
|
4
4
|
import { appendFileSync, copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
5
5
|
import { homedir } from "node:os";
|
|
6
6
|
import { createRequire } from "node:module";
|
|
7
7
|
import { dirname, join, resolve } from "node:path";
|
|
8
8
|
import ffmpegPath from "ffmpeg-static";
|
|
9
|
-
import { completeExport, login, requestExport, upgrade } from "../src/account.mjs";
|
|
9
|
+
import { completeExport, login, plans, requestExport, upgrade } from "../src/account.mjs";
|
|
10
|
+
import { publish } from "../src/publish.mjs";
|
|
10
11
|
import { writeGuide } from "../src/guide.mjs";
|
|
11
12
|
import { validatePlan, validateProject } from "../src/plan.mjs";
|
|
12
13
|
import { inspectScreen, launch, recordVideo } from "../src/record.mjs";
|
|
@@ -85,9 +86,14 @@ async function render() {
|
|
|
85
86
|
for (const d of ["videos", "guide/media"]) mkdirSync(join(outDir, d), { recursive: true });
|
|
86
87
|
// The free selection is remembered, so retrying failed videos one by one keeps all of them eligible.
|
|
87
88
|
const freeFile = join(outDir, ".free.json");
|
|
88
|
-
const
|
|
89
|
+
const stateFile = join(outDir, ".render.json");
|
|
90
|
+
const previous = existsSync(stateFile) ? JSON.parse(readFileSync(stateFile, "utf8")) : [];
|
|
91
|
+
// If nothing has exported yet, the free run hasn't really started, so the choice can still change.
|
|
92
|
+
const remembered = previous.length && existsSync(freeFile) ? JSON.parse(readFileSync(freeFile, "utf8")) : null;
|
|
93
|
+
const freeSelection = remembered ?? selected.map(v => v.id);
|
|
89
94
|
|
|
90
95
|
const grant = await requestExport(plan.mode, plan.videos.length);
|
|
96
|
+
if (grant.plan === "refunded") { console.error("This licence was refunded. Contact repovid.support@gmail.com to buy again."); process.exit(1); }
|
|
91
97
|
if (grant.plan === "none") { console.error("Your free run is used. Run repovid upgrade to unlock unlimited videos for $5."); process.exit(1); }
|
|
92
98
|
const outsideFree = grant.plan === "free" && selected.some(v => !freeSelection.includes(v.id));
|
|
93
99
|
if (grant.plan === "free" && (outsideFree || freeSelection.length > grant.allowed)) {
|
|
@@ -102,8 +108,6 @@ async function render() {
|
|
|
102
108
|
const wavs = await synthesize([...new Set(videos.flatMap(v => v.scenes.map(s => s.say)))], join(homedir(), ".cache", "repovid", "voice"), console.log);
|
|
103
109
|
const browser = await launch(plan.testIdAttribute);
|
|
104
110
|
// Earlier runs count too: retrying one video with --only must not lock the others.
|
|
105
|
-
const stateFile = join(outDir, ".render.json");
|
|
106
|
-
const previous = existsSync(stateFile) ? JSON.parse(readFileSync(stateFile, "utf8")) : [];
|
|
107
111
|
const rendered = new Map(previous.filter(r => plan.videos.some(v => v.id === r.id) && existsSync(join(outDir, "videos", `${r.id}.mp4`))).map(r => [r.id, r]));
|
|
108
112
|
let failed = 0;
|
|
109
113
|
for (const video of videos) {
|
|
@@ -131,10 +135,12 @@ process.on("uncaughtException", e => { console.error(e.message); process.exit(1)
|
|
|
131
135
|
if (command === "doctor") await doctor();
|
|
132
136
|
else if (command === "login") await login();
|
|
133
137
|
else if (command === "upgrade") await upgrade();
|
|
138
|
+
else if (command === "plans") await plans(file);
|
|
139
|
+
else if (command === "publish" && file) await publish(file, flag("out") ?? "repovid-output", rest.includes("--public-link"));
|
|
134
140
|
else if (command === "init") init();
|
|
135
141
|
else if (command === "schema") process.stdout.write(readFileSync(asset("schema.md"), "utf8"));
|
|
136
142
|
else if (command === "inspect") await inspect();
|
|
137
143
|
else if (command === "validate" && file) { loadPlan(); console.log("plan is valid"); }
|
|
138
144
|
else if (command === "render" && file) await render();
|
|
139
|
-
else { console.error("usage: repovid login | upgrade | doctor | init | schema | inspect <path> [--role role] | validate <plan.json> | render <plan.json> [--only a,b] [--out dir]"); process.exit(2); }
|
|
145
|
+
else { console.error("usage: repovid login | upgrade | plans [name] | publish <plan.json> --public-link | doctor | init | schema | inspect <path> [--role role] | validate <plan.json> | render <plan.json> [--only a,b] [--out dir]"); process.exit(2); }
|
|
140
146
|
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "repovid",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Turn your web app into narrated demo videos and a guide page, using the coding agent you already have.",
|
|
5
|
-
"homepage": "https://repovid.
|
|
5
|
+
"homepage": "https://repovid.carles-mallafre.workers.dev",
|
|
6
6
|
"license": "UNLICENSED",
|
|
7
7
|
"keywords": [
|
|
8
8
|
"demo",
|
package/src/account.mjs
CHANGED
|
@@ -4,7 +4,7 @@ import { homedir } from "node:os";
|
|
|
4
4
|
import { dirname, join } from "node:path";
|
|
5
5
|
import { spawn } from "node:child_process";
|
|
6
6
|
|
|
7
|
-
const API = process.env.REPOVID_API ?? "https://repovid.
|
|
7
|
+
const API = process.env.REPOVID_API ?? "https://repovid.carles-mallafre.workers.dev";
|
|
8
8
|
const AUTH_FILE = join(homedir(), ".config", "repovid", "auth.json");
|
|
9
9
|
const POLL_MS = 2000, LOGIN_MS = 10 * 60 * 1000;
|
|
10
10
|
|
|
@@ -47,8 +47,22 @@ export async function login() {
|
|
|
47
47
|
throw new Error("Sign-in expired. Run repovid login again.");
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
export const authed = () => { if (!readToken()) throw new Error("Sign in first with repovid login."); };
|
|
51
|
+
export const api = call;
|
|
52
|
+
|
|
53
|
+
export async function plans(plan) {
|
|
54
|
+
authed();
|
|
55
|
+
if (!plan) {
|
|
56
|
+
console.log("Hosting plans, billed yearly:\n solo 3 projects, 30 videos each, 5 GB $12\n pro 10 projects, 50 videos each, 25 GB $29\n team 50 projects, 100 videos each, 100 GB $79\nChoose one: repovid plans <name>");
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
const { url } = await call("/api/hosting/checkout", { plan });
|
|
60
|
+
console.log(`Opening ${url}`);
|
|
61
|
+
openBrowser(url);
|
|
62
|
+
}
|
|
63
|
+
|
|
50
64
|
export async function upgrade() {
|
|
51
|
-
|
|
65
|
+
authed();
|
|
52
66
|
const { url } = await call("/api/checkout");
|
|
53
67
|
console.log(`Opening ${url}`);
|
|
54
68
|
openBrowser(url);
|
package/src/publish.mjs
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// Sends structured guide text and rendered files to RepoVid. The plan's actions never leave the machine.
|
|
2
|
+
import { existsSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join, resolve } from "node:path";
|
|
4
|
+
import { api, authed } from "./account.mjs";
|
|
5
|
+
|
|
6
|
+
const stateName = ".published.json";
|
|
7
|
+
const put = async (url, file, type) => {
|
|
8
|
+
const res = await fetch(url, { method: "PUT", body: readFileSync(file), headers: { "content-type": type } });
|
|
9
|
+
if (!res.ok) throw new Error(`upload failed (${res.status})`);
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export async function publish(planFile, out, confirmed) {
|
|
13
|
+
authed();
|
|
14
|
+
if (!confirmed) throw new Error("Anyone with the link will be able to watch these videos. Review them first, then add --public-link.");
|
|
15
|
+
const outDir = resolve(out);
|
|
16
|
+
const plan = JSON.parse(readFileSync(planFile, "utf8"));
|
|
17
|
+
const stateFile = join(outDir, ".render.json");
|
|
18
|
+
if (!existsSync(stateFile)) throw new Error("Render the videos first.");
|
|
19
|
+
const rendered = new Map(JSON.parse(readFileSync(stateFile, "utf8")).map(r => [r.id, r]));
|
|
20
|
+
const videos = plan.videos.filter(v => rendered.has(v.id) && existsSync(join(outDir, "videos", `${v.id}.mp4`)));
|
|
21
|
+
if (!videos.length) throw new Error("Nothing to publish yet.");
|
|
22
|
+
|
|
23
|
+
const guide = {
|
|
24
|
+
mode: plan.mode, name: plan.project.name, summary: plan.project.summary, sections: plan.project.sections ?? [],
|
|
25
|
+
videos: videos.map(v => { const r = rendered.get(v.id); return { id: v.id, title: v.title, summary: v.summary, role: v.role, steps: v.steps, length: r.length, cues: r.cues }; }),
|
|
26
|
+
};
|
|
27
|
+
const publishedFile = join(outDir, stateName);
|
|
28
|
+
const slug = existsSync(publishedFile) ? JSON.parse(readFileSync(publishedFile, "utf8")).slug : undefined;
|
|
29
|
+
const bytes = videos.reduce((n, v) => n + statSync(join(outDir, "videos", `${v.id}.mp4`)).size + statSync(join(outDir, "guide", "media", `${v.id}.jpg`)).size, 0);
|
|
30
|
+
const draft = await api("/api/guides", { guide, slug, bytes });
|
|
31
|
+
// Saved before commit: if the reply is lost, the guide can still be updated or removed from here.
|
|
32
|
+
writeFileSync(publishedFile, JSON.stringify({ slug: draft.slug, url: draft.url }));
|
|
33
|
+
for (const u of draft.uploads) {
|
|
34
|
+
const mp4 = join(outDir, "videos", `${u.id}.mp4`);
|
|
35
|
+
if (statSync(mp4).size > draft.maxVideoBytes) throw new Error(`${u.id} is too large to publish.`);
|
|
36
|
+
await put(u.video, mp4, "video/mp4");
|
|
37
|
+
await put(u.poster, join(outDir, "guide", "media", `${u.id}.jpg`), "image/jpeg");
|
|
38
|
+
console.log(`uploaded ${u.id}`);
|
|
39
|
+
}
|
|
40
|
+
// Large projects finish over several calls.
|
|
41
|
+
for (;;) {
|
|
42
|
+
const r = await api("/api/guides/commit", { guideId: draft.guideId, version: draft.version });
|
|
43
|
+
if (!r.remaining) break;
|
|
44
|
+
console.log(`processing, ${r.remaining} left`);
|
|
45
|
+
}
|
|
46
|
+
console.log(`Published: ${draft.url}\nAnyone with this link can watch. Manage it at ${new URL(draft.url).origin}/app.`);
|
|
47
|
+
}
|
|
48
|
+
|
package/src/skill.md
CHANGED
|
@@ -49,6 +49,7 @@ Run `npx repovid schema` for the format, then write `.repovid/plan.json`.
|
|
|
49
49
|
- Each scene's `say` is what the narrator says while that scene's actions run. Plain, friendly English for the people who use this app. Explain why, not only what.
|
|
50
50
|
- Fill `project.summary` and `project.sections` (title and text pairs). walkthrough: what it is, how it works, roles, high-level architecture. demo: what it is, who it's for, what problem it solves, how to get started.
|
|
51
51
|
- Give each walkthrough video a `summary` and short `steps`.
|
|
52
|
+
- Project text may be published, so keep local URLs, file names, demo users and internal details out of it. "How to get started" should describe the product, not this machine.
|
|
52
53
|
- Spell product names the way they sound if a voice would read them wrongly.
|
|
53
54
|
- Use only fake people and contact details. Don't submit forms that send real emails or messages, and never touch billing.
|
|
54
55
|
|
|
@@ -62,3 +63,6 @@ Run `npx repovid render .repovid/plan.json`. If a video fails, inspect that scre
|
|
|
62
63
|
Rendering really uses the app, so records a flow creates stay there. Before rendering a video again, tell the user which demo data to reset, because a second run can find duplicates or taken slots.
|
|
63
64
|
|
|
64
65
|
Then tell the user to open `repovid-output/guide/index.html`, and to review every video before sharing, in case something private is on screen.
|
|
66
|
+
|
|
67
|
+
## 7. Share (optional)
|
|
68
|
+
`npx repovid publish .repovid/plan.json --public-link` uploads the videos and guide text to RepoVid and returns a link. Anyone with the link can watch. Only run it after the user has watched every video and told you to publish. Publishing again from the same folder updates the same link and keeps earlier versions. The owner manages projects in the web dashboard. Publishing needs a hosting plan: `npx repovid plans`.
|