@praeko/studio 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/CHANGELOG.md +24 -9
- package/LICENSES.md +28 -30
- package/README.md +23 -22
- package/dist/praeko-studio.mjs +4751 -3621
- package/dist/voice-account.mjs +295 -0
- package/dist/voice-balance.mjs +152 -0
- package/dist/{voz-config.mjs → voice-config.mjs} +38 -35
- package/dist/voice-voices.mjs +220 -0
- package/docs/episode-yaml.md +57 -0
- package/docs/hyperframes.md +49 -49
- package/docs/long-yaml.md +78 -0
- package/docs/publish.md +22 -0
- package/docs/voice.md +22 -0
- package/package.json +8 -7
- package/dist/voz-conta.mjs +0 -319
- package/dist/voz-saldo.mjs +0 -178
- package/dist/voz-vozes.mjs +0 -247
- package/docs/episodio-yaml.md +0 -57
- package/docs/longo-yaml.md +0 -78
- package/docs/publicar.md +0 -22
- package/docs/voz.md +0 -22
- /package/{modelos → templates}/avatar.png +0 -0
- /package/{modelos → templates}/sfx/ding.wav +0 -0
- /package/{modelos → templates}/sfx/pop.wav +0 -0
- /package/{modelos → templates}/sfx/thump.wav +0 -0
- /package/{modelos → templates}/sfx/whoosh.wav +0 -0
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { createRequire as __cr } from 'node:module'; const require = __cr(import.meta.url);
|
|
3
|
+
|
|
4
|
+
// ../voice/src/index.ts
|
|
5
|
+
import { ElevenLabsClient as ElevenLabsClient2 } from "@elevenlabs/elevenlabs-js";
|
|
6
|
+
|
|
7
|
+
// ../voice/src/config.ts
|
|
8
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { execFileSync } from "node:child_process";
|
|
10
|
+
import { homedir } from "node:os";
|
|
11
|
+
import { join, dirname, resolve } from "node:path";
|
|
12
|
+
|
|
13
|
+
// ../voice/src/defaults.ts
|
|
14
|
+
var DEFAULT_VOICES = {
|
|
15
|
+
accounts: { elevenlabs: ["principal"] },
|
|
16
|
+
voices: {
|
|
17
|
+
"draft-silent": { provider: "estimated", wordsPerMin: 190 },
|
|
18
|
+
"my-voice": { provider: "elevenlabs", model: "eleven_v4", ids: {}, aligner: "scribe" },
|
|
19
|
+
"local-voice": { provider: "voicestudio", model: "", id: "", language: "pt-BR", settings: {}, aligner: "voicestudio" }
|
|
20
|
+
},
|
|
21
|
+
strategy: { elevenlabs: "failover-balance" },
|
|
22
|
+
quality: { draft: "draft-silent", local: "local-voice", final: "my-voice" },
|
|
23
|
+
voicestudio: { base: "http://127.0.0.1:3900" }
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
// ../voice/src/config.ts
|
|
27
|
+
var userConfigDir = () => process.env.PRAEKO_STUDIO_CONFIG || join(homedir(), ".config", "praeko", "studio");
|
|
28
|
+
var userVoicesFile = () => join(userConfigDir(), "voices.json");
|
|
29
|
+
var secretsFile = () => process.env.PRAEKO_SECRETS || join(homedir(), ".config", "praeko", "secrets.json");
|
|
30
|
+
var KEYCHAIN_SERVICE = "praeko";
|
|
31
|
+
var keychainActive = () => process.platform === "darwin" && !process.env.PRAEKO_NO_KEYCHAIN;
|
|
32
|
+
function projectRoot(from = process.cwd()) {
|
|
33
|
+
let d = resolve(from);
|
|
34
|
+
for (; ; ) {
|
|
35
|
+
if (existsSync(join(d, "brand.json"))) return d;
|
|
36
|
+
const p = dirname(d);
|
|
37
|
+
if (p === d) return null;
|
|
38
|
+
d = p;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
var projectVoicesFile = (from) => {
|
|
42
|
+
const r = projectRoot(from);
|
|
43
|
+
return r ? join(r, "voices.json") : null;
|
|
44
|
+
};
|
|
45
|
+
var readJson = (file) => {
|
|
46
|
+
try {
|
|
47
|
+
return JSON.parse(readFileSync(file, "utf8"));
|
|
48
|
+
} catch (e) {
|
|
49
|
+
throw new Error(`${file}: invalid JSON (${e.message})`);
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
var merge = (base, extra) => ({
|
|
53
|
+
...base,
|
|
54
|
+
...extra,
|
|
55
|
+
accounts: { ...base.accounts, ...extra.accounts },
|
|
56
|
+
voices: { ...base.voices, ...extra.voices },
|
|
57
|
+
strategy: { ...base.strategy, ...extra.strategy },
|
|
58
|
+
quality: { ...base.quality, ...extra.quality },
|
|
59
|
+
voicestudio: { ...base.voicestudio, ...extra.voicestudio }
|
|
60
|
+
});
|
|
61
|
+
function loadConfig(file) {
|
|
62
|
+
if (file) return merge({ accounts: {}, voices: {}, strategy: {} }, readJson(file));
|
|
63
|
+
let c = structuredClone(DEFAULT_VOICES);
|
|
64
|
+
for (const f of [userVoicesFile(), projectVoicesFile()]) if (f && existsSync(f)) c = merge(c, readJson(f));
|
|
65
|
+
return c;
|
|
66
|
+
}
|
|
67
|
+
var keyEnvNames = (ref) => [`PRAEKO_STUDIO_KEY_${ref.toUpperCase().replace(/[^A-Z0-9]/g, "_")}`, ...ref === "elevenlabs/principal" ? ["ELEVENLABS_API_KEY"] : [], ...ref === "praeko/main" ? ["PRAEKO_KEY"] : []];
|
|
68
|
+
var canonical = (ref) => ref === "praeko/main" ? "praeko/chave" : ref;
|
|
69
|
+
var readSecrets = () => {
|
|
70
|
+
try {
|
|
71
|
+
return existsSync(secretsFile()) ? JSON.parse(readFileSync(secretsFile(), "utf8")) : {};
|
|
72
|
+
} catch {
|
|
73
|
+
return {};
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
function getKey(ref) {
|
|
77
|
+
for (const n of keyEnvNames(ref)) if (process.env[n]) return process.env[n];
|
|
78
|
+
const refs = [.../* @__PURE__ */ new Set([canonical(ref), ref])];
|
|
79
|
+
if (keychainActive()) for (const r of refs) {
|
|
80
|
+
try {
|
|
81
|
+
const k = execFileSync("security", ["find-generic-password", "-s", KEYCHAIN_SERVICE, "-a", r, "-w"], { stdio: ["ignore", "pipe", "ignore"] }).toString().trim();
|
|
82
|
+
if (k) return k;
|
|
83
|
+
} catch {
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
const secrets = readSecrets();
|
|
87
|
+
for (const r of refs) if (secrets[r]) return secrets[r];
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ../voice/src/providers/elevenlabs.ts
|
|
92
|
+
import { ElevenLabsClient } from "@elevenlabs/elevenlabs-js";
|
|
93
|
+
function createElevenLabs({ key }) {
|
|
94
|
+
const client = new ElevenLabsClient({ apiKey: key });
|
|
95
|
+
return {
|
|
96
|
+
name: "elevenlabs",
|
|
97
|
+
/** `{ text, id, model, settings }` → mp3 Buffer */
|
|
98
|
+
async generate({ text, id, model, settings = {} }) {
|
|
99
|
+
const audio = await client.textToSpeech.convert(id, {
|
|
100
|
+
text,
|
|
101
|
+
modelId: model || "eleven_v4",
|
|
102
|
+
languageCode: "pt",
|
|
103
|
+
outputFormat: "mp3_44100_128",
|
|
104
|
+
voiceSettings: { stability: settings.stability ?? 0.5, similarityBoost: settings.similarity ?? 0.75, style: settings.style ?? 0, useSpeakerBoost: true, speed: settings.speed ?? 1 }
|
|
105
|
+
});
|
|
106
|
+
const chunks = [];
|
|
107
|
+
for await (const c of audio) chunks.push(c);
|
|
108
|
+
return Buffer.concat(chunks);
|
|
109
|
+
},
|
|
110
|
+
/** Characters used, limit, remaining, plan and reset date. */
|
|
111
|
+
async balance() {
|
|
112
|
+
const r = await fetch("https://api.elevenlabs.io/v1/user/subscription", { headers: { "xi-api-key": key } });
|
|
113
|
+
if (!r.ok) {
|
|
114
|
+
const e = new Error(`balance: HTTP ${r.status}`);
|
|
115
|
+
e.statusCode = r.status;
|
|
116
|
+
throw e;
|
|
117
|
+
}
|
|
118
|
+
const d = await r.json();
|
|
119
|
+
return { used: d.character_count, limit: d.character_limit, remaining: d.character_limit - d.character_count, plan: d.tier, resets: d.next_character_count_reset_unix ? new Date(d.next_character_count_reset_unix * 1e3) : null };
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ../voice/src/router.ts
|
|
125
|
+
async function balances({ config = loadConfig(), create } = {}) {
|
|
126
|
+
const factory = create || ((_a, key) => createElevenLabs({ key }));
|
|
127
|
+
const out = [];
|
|
128
|
+
const el = config.accounts?.elevenlabs;
|
|
129
|
+
for (const account of Array.isArray(el) ? el : []) {
|
|
130
|
+
const key = getKey(`elevenlabs/${account}`);
|
|
131
|
+
if (!key) {
|
|
132
|
+
out.push({ account, error: "no key" });
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
try {
|
|
136
|
+
out.push({ account, ...await factory(account, key).balance() });
|
|
137
|
+
} catch (e) {
|
|
138
|
+
out.push({ account, error: e.message });
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return out;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ../voice/src/providers/voicestudio.ts
|
|
145
|
+
var DEFAULT_BASE = "http://127.0.0.1:3900";
|
|
146
|
+
var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
147
|
+
function prepareText(text) {
|
|
148
|
+
return text.replace(/\[[^\]]*\]/g, "").replace(/\bIA\b/g, "intelig\xEAncia artificial").replace(/\s*\n\s*/g, " ").replace(/ {2,}/g, " ").trim();
|
|
149
|
+
}
|
|
150
|
+
function createVoiceStudio({ base = DEFAULT_BASE, wait = sleep } = {}) {
|
|
151
|
+
return {
|
|
152
|
+
name: "voicestudio",
|
|
153
|
+
base,
|
|
154
|
+
async available() {
|
|
155
|
+
try {
|
|
156
|
+
const r = await fetch(`${base}/health`, { signal: AbortSignal.timeout(3e3) });
|
|
157
|
+
if (!r.ok) return { ok: false, reason: `HTTP ${r.status}` };
|
|
158
|
+
const d = await r.json();
|
|
159
|
+
return { ok: d.status === "ok", device: d.device, version: d.version };
|
|
160
|
+
} catch {
|
|
161
|
+
return { ok: false, reason: `no response at ${base} (open VoiceStudio)` };
|
|
162
|
+
}
|
|
163
|
+
},
|
|
164
|
+
/** Voice profiles. */
|
|
165
|
+
async profiles() {
|
|
166
|
+
const r = await fetch(`${base}/profiles`, { signal: AbortSignal.timeout(1e4) });
|
|
167
|
+
const d = await r.json();
|
|
168
|
+
return (Array.isArray(d) ? d : d.profiles || d.data || []).map((p) => ({ id: p.id, name: p.name, language: p.language }));
|
|
169
|
+
},
|
|
170
|
+
/** Returns a wav Buffer. 429 = busy: waits and retries. */
|
|
171
|
+
async generate({ text, id, model = "omnivoice", language = "pt-BR", settings = {}, retries = 15, interval = 6e4, limit = 18e5 }) {
|
|
172
|
+
const body = JSON.stringify({ model, input: prepareText(text), voice: id, language, response_format: "wav", ...settings });
|
|
173
|
+
for (let i = 0; i < retries; i++) {
|
|
174
|
+
const r = await fetch(`${base}/v1/audio/speech`, { method: "POST", headers: { "Content-Type": "application/json" }, body, signal: AbortSignal.timeout(limit) });
|
|
175
|
+
if (r.status === 429) {
|
|
176
|
+
await wait(interval);
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
if (!r.ok) {
|
|
180
|
+
const e = new Error(`VoiceStudio: HTTP ${r.status} ${(await r.text()).slice(0, 200)}`);
|
|
181
|
+
e.statusCode = r.status;
|
|
182
|
+
throw e;
|
|
183
|
+
}
|
|
184
|
+
return Buffer.from(await r.arrayBuffer());
|
|
185
|
+
}
|
|
186
|
+
throw new Error("VoiceStudio is busy (429) after several attempts");
|
|
187
|
+
},
|
|
188
|
+
/** Transcription with per-word timing (VoiceStudio's own faster-whisper). */
|
|
189
|
+
async transcribe({ audio, bytes, language = "pt" }) {
|
|
190
|
+
const f = new FormData();
|
|
191
|
+
f.append("file", new Blob([bytes]), audio.split("/").pop());
|
|
192
|
+
f.append("model", "whisper-1");
|
|
193
|
+
f.append("language", language);
|
|
194
|
+
f.append("response_format", "verbose_json");
|
|
195
|
+
f.append("timestamp_granularities[]", "word");
|
|
196
|
+
const r = await fetch(`${base}/v1/audio/transcriptions`, { method: "POST", body: f, signal: AbortSignal.timeout(6e5) });
|
|
197
|
+
if (!r.ok) throw new Error(`VoiceStudio (transcription): HTTP ${r.status} ${(await r.text()).slice(0, 200)}`);
|
|
198
|
+
const d = await r.json();
|
|
199
|
+
return { words: (d.words || []).map((w) => ({ t: String(w.word).trim(), a: +w.start.toFixed(2), b: +w.end.toFixed(2) })), text: d.text };
|
|
200
|
+
}
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// ../voice/src/alignment.ts
|
|
205
|
+
var NUM = new Set("zero um uma dois duas tr\xEAs tres quatro cinco seis sete oito nove dez onze doze treze catorze quatorze quinze dezesseis dezessete dezoito dezenove vinte trinta quarenta cinquenta sessenta setenta oitenta noventa cem cento duzentos duzentas trezentos trezentas quatrocentos quatrocentas quinhentos quinhentas seiscentos seiscentas setecentos setecentas oitocentos oitocentas novecentos novecentas mil milh\xE3o milh\xF5es bilh\xE3o bilh\xF5es meio meia primeiro primeira".split(" "));
|
|
206
|
+
|
|
207
|
+
// ../voice/bin/voices.ts
|
|
208
|
+
var cfg = loadConfig();
|
|
209
|
+
var vs = createVoiceStudio({ base: cfg.voicestudio?.base });
|
|
210
|
+
var st = await vs.available();
|
|
211
|
+
var profiles = st.ok ? await vs.profiles() : [];
|
|
212
|
+
console.log(`VoiceStudio: ${st.ok ? `up (${st.version}, ${st.device})` : st.reason}`);
|
|
213
|
+
for (const [name, v] of Object.entries(cfg.voices)) {
|
|
214
|
+
let note = "";
|
|
215
|
+
if (v.provider === "voicestudio") note = !st.ok ? "unavailable" : profiles.find((p) => p.id === v.id) ? `profile "${profiles.find((p) => p.id === v.id).name}" ok` : "PROFILE NOT FOUND";
|
|
216
|
+
else note = `accounts: ${Object.keys(v.ids || {}).join(", ")}`;
|
|
217
|
+
console.log(` ${name.padEnd(14)} ${v.provider.padEnd(12)} ${v.model || ""} \xB7 aligner ${v.aligner || "scribe"} \xB7 ${note}`);
|
|
218
|
+
}
|
|
219
|
+
console.log("Balances (ElevenLabs):");
|
|
220
|
+
for (const s of await balances({ config: cfg })) console.log(` ${s.account.padEnd(10)} ${"error" in s ? s.error : `${s.remaining} left of ${s.limit}`}`);
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# Episode in YAML
|
|
2
|
+
|
|
3
|
+
`episodes/<slug>/episode.yaml` describes the Short; `praeko studio compile <slug>` (or `render`/`cover`, which compile on their own) checks it and writes `episode.spec.json`, which the `EpisodeDecl` of `@praeko/studio-kit` reads. `episode.tsx` is fixed (created by `praeko studio new`). What the YAML cannot express goes in `scenes.tsx` and is called with `component:`.
|
|
4
|
+
|
|
5
|
+
## Top level
|
|
6
|
+
```yaml
|
|
7
|
+
id: CommentTimestamp # Composition name (default: the one in episode.json)
|
|
8
|
+
duration: 60.2 # s, whole video
|
|
9
|
+
caption: 80 # px of the word-by-word caption
|
|
10
|
+
voice: audio/comment-timestamp.mp3
|
|
11
|
+
background: ["lines that float in the background", "…"]
|
|
12
|
+
hits: [{ at: 31.5, type: flash }, { at: 44.9, type: zoom }] # shake | zoom | flash | all
|
|
13
|
+
outro: { start: 52.4, cta: ARTICLE ON THE DEVLOG }
|
|
14
|
+
cover: { id: CoverComment, component: CoverComment } # TSX in scenes.tsx; or title/color/glow/size/items
|
|
15
|
+
scenes: [ … ]
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Times by phrase (they follow the voice)
|
|
19
|
+
In `duration`, `outro.start`, `hits[].at` and in the `start`/`end` of scenes you can use: a number (s) · `"phrase"` (start of the phrase in the voice) · `{ when: "phrase", nth: 1, after: 20, plus: -0.1 }` · `{ voice: 2.5 }` (2.5 s after the end of the voice; negative = before). Resolved by `praeko studio compile` with the current `words.json`, so just recompile (`render` already does) after a new voice or after `praeko studio cut --redo`. Example: `duration: { voice: 2.5 }`, `outro: { start: { voice: -2 }, cta: … }`, `start: { when: "Now a real bug", plus: -0.1 }`.
|
|
20
|
+
|
|
21
|
+
## Scene
|
|
22
|
+
```yaml
|
|
23
|
+
- start: 7.05 # s; the scene runs until the start of the next one (or `end:`, or the outro)
|
|
24
|
+
end: 52.35 # optional; only changes when the panel leaves
|
|
25
|
+
title: I WENT TO CHECK # strip of the kit's Panel (default layout: panel)
|
|
26
|
+
layout: panel | free # free = loose items on the screen (absolute position, videos)
|
|
27
|
+
label: I WENT TO CHECK # short tag of the scene
|
|
28
|
+
sounds: [{ at: 0.2, sound: pop }] # local s; pop | ding | thump | whoosh
|
|
29
|
+
items: [ … ] # or `component: NameInScenes` (one of the two)
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Items
|
|
33
|
+
Every item has a `type`. **When it appears:** `when: "phrase"` (or `[phrase, n]` for the n-th occurrence, from 0) ties it to the instant of the voice where the phrase starts; `offset: 0.4` adds seconds; `at: 1.2` is in seconds since the start of the scene. A phrase not found in the voice is an error. Absolute position: `pos: { left, right, top, bottom, width, height }`. Colors: theme names (`accent text muted ok error info extra box`) or `#hex`. **Inside `{ … }`, text with a comma goes in quotes.**
|
|
34
|
+
|
|
35
|
+
| type | fields |
|
|
36
|
+
|---|---|
|
|
37
|
+
| `text` | `text` (`\n` breaks), `size`, `color`, `font` (`default`, `mono`; omitted inherits), `align: left`, `lineHeight`, `weight`, `pop`, `noFade`, `noWrap` |
|
|
38
|
+
| `big` | `text`, `size`, `color`, `mono` |
|
|
39
|
+
| `box` | `text`, `size`, `color`, `mono`, `dim`, `dashed` |
|
|
40
|
+
| `line` | `parts: [{text, mono, color, weight}]`, `size`, `color` |
|
|
41
|
+
| `bubble` | `text`, `color`, `author` |
|
|
42
|
+
| `code` | `title`, `color`, `lines: [{s, color, mark}]` |
|
|
43
|
+
| `card` | `lines: [[{text, color, weight}]]`, `size`, `color` (slides in) |
|
|
44
|
+
| `bar` | `height`, `radius`, `gradient: [color, "color 60%", …]`, `border: false`, `fill: {from, to, start, duration}`, `glowAtEnd`, `noFade` |
|
|
45
|
+
| `bars` | `lines: [{label, value, text}]`, `max`, `maxWidth`, `labelWidth`, `step`, `gap`, `color` |
|
|
46
|
+
| `window` | `src`, `clipDuration`, `from`, `reg: [x, y, w, h]`, `tag`, `pos` (left, top, width) |
|
|
47
|
+
| `phone` | `src`, `clipDuration`, `from`, `pos` |
|
|
48
|
+
| `stack` / `row` | `items`, `gap`, `align` (`center`, `start`, `end`, `stretch`); row: `center: false`, `spread: true` |
|
|
49
|
+
|
|
50
|
+
## What the compiler checks
|
|
51
|
+
Errors (the spec is not written): missing required field, unknown `type` or color, unknown key (this includes the comma without quotes), scenes out of order, phrase that is not in `words.json`. Warnings (an estimate, not a proof): anchor outside the scene window, items that add up to more height than the panel (920 px) or more width than 844 px, text that should wrap, absolutely positioned items that cross, a duration with less than 1 s after the end of the voice. Always look at `praeko studio contact-sheet <slug>` too.
|
|
52
|
+
|
|
53
|
+
## Equivalence with TSX
|
|
54
|
+
`comment-timestamp` (7 scenes) and `raid-ep1` (6 scenes, videos and animated bars) were ported from TSX to this format: the first one renders **identically** (PSNR inf), the second within the noise of `OffthreadVideo` (mean 54.2 dB, minimum 47.4). The check found a bug in the raid original: the `"damage"` anchor of scene 5 hit the first occurrence (18.98 s) and the phrase showed up at the start of the scene.
|
|
55
|
+
|
|
56
|
+
## Render engine
|
|
57
|
+
`engine: hyperframes` (or `remotion`) at the top of the YAML chooses who renders this episode; `praeko studio render --engine …` has the final word. Scenes with `component:` only render in Remotion. See [`hyperframes.md`](hyperframes.md).
|
package/docs/hyperframes.md
CHANGED
|
@@ -1,79 +1,79 @@
|
|
|
1
|
-
# HyperFrames
|
|
1
|
+
# HyperFrames as a second render engine
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Praeko Studio renders with **Remotion** (default) or with **HyperFrames** (HTML + GSAP, frame-capture rendering). `episode.yaml`, the voice, the captions, the cover and the publication package are the same; only who draws the frames changes.
|
|
4
4
|
|
|
5
5
|
```
|
|
6
|
-
praeko studio render <slug> --
|
|
7
|
-
praeko studio
|
|
8
|
-
praeko studio
|
|
6
|
+
praeko studio render <slug> --engine hyperframes # out/<slug>.hf.mp4 (does not overwrite the Remotion one)
|
|
7
|
+
praeko studio preview <slug> --engine hyperframes # opens the HyperFrames preview of the generated project
|
|
8
|
+
praeko studio compile <slug> --engine hyperframes # warns about scenes that use `component:` (TSX)
|
|
9
9
|
```
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
Which engine an episode uses (the first one that exists): `--engine` on the command line → `engine: hyperframes` at the top of `episode.yaml` → `"engine": "hyperframes"` in `brand.json` → `remotion`. A new channel can use `"engine": "hyperframes"` in the brand and never touch Remotion (with `--engine hyperframes` the cover is rendered by HyperFrames too: `praeko studio cover <slug> --engine hyperframes`; nothing from Remotion is used).
|
|
12
12
|
|
|
13
|
-
##
|
|
13
|
+
## What happens
|
|
14
14
|
|
|
15
|
-
1.
|
|
16
|
-
2. `@praeko/studio-hyperframes`
|
|
17
|
-
3. `npx hyperframes@0.9.13 check --json`:
|
|
15
|
+
1. Compiles `episode.yaml` (`episode.spec.json`), as always.
|
|
16
|
+
2. `@praeko/studio-hyperframes` generates a project in `<project>/.hf/<slug>/` (already in the `.gitignore` of `praeko studio init`): `index.html`, one sub-composition per scene in `compositions/`, and `assets/` with **only** what the episode uses (copied from the brand's `publicDir`).
|
|
17
|
+
3. `npx hyperframes@0.9.13 check --json`: errors stop the render; warnings become one summary line (code × count).
|
|
18
18
|
4. `npx hyperframes@0.9.13 render --output out/<slug>.hf.mp4`.
|
|
19
19
|
|
|
20
|
-
|
|
20
|
+
The generated project is disposable: do not edit `.hf/`, edit the YAML and run again. To edit visually, open `.hf/<slug>` in the HyperFrames app; whatever you change there disappears at the next generation.
|
|
21
21
|
|
|
22
22
|
## Short (1080×1920)
|
|
23
23
|
|
|
24
|
-
|
|
24
|
+
The YAML items (`text`, `big`, `box`, `line`, `bubble`, `code`, `window`, `phone`, `bar`, `bars`, `stack`, `row`, `card`) become HTML with the same measures as the Remotion kit: a panel with 70 px margins, label, word-by-word captions (same 3-word grouping), progress bar, hits (shake, zoom, flash), a two-column terminal background, sounds (whoosh, pop per scene, ding at the outro) and an outro with avatar, handle and CTA. `when` resolves to the local time of the scene as in Remotion.
|
|
25
25
|
|
|
26
|
-
|
|
27
|
-
- **
|
|
28
|
-
- **
|
|
29
|
-
- **
|
|
30
|
-
-
|
|
31
|
-
- **
|
|
26
|
+
Known differences:
|
|
27
|
+
- **Fonts:** only the fonts bundled with HyperFrames resolve by name. Arial Black becomes Montserrat 900 and Menlo becomes JetBrains Mono (slightly different width; text that wraps in one may wrap in the other). With `"hyperframes": { "localFonts": true }` in `brand.json` the brand font is loaded from the system (`local()`), with the bundled one as a fallback: exact parity, but only on this machine.
|
|
28
|
+
- **Game videos** (`window`, `phone`): the segment `[from, clipDuration]` repeats until the end of the scene (several `<video>` elements in sequence, instead of `Loop`).
|
|
29
|
+
- **Red dot** of the window label is fixed (it blinks in Remotion).
|
|
30
|
+
- **Audio** comes out about 3 dB louder (mean −16.8 dB against −19.8 dB in the same Short): check with `praeko studio check` before publishing.
|
|
31
|
+
- **Vertical safe zone:** the YAML positions are used as they are (the label sits at y=70, the caption 190 px from the bottom). HyperFrames' `check` does not complain, but the apps' borders cover the top.
|
|
32
32
|
|
|
33
|
-
##
|
|
33
|
+
## Long form (1920×1080)
|
|
34
34
|
|
|
35
|
-
|
|
36
|
-
-
|
|
37
|
-
-
|
|
38
|
-
-
|
|
39
|
-
-
|
|
40
|
-
-
|
|
35
|
+
Reuses the plan of `episode.yaml` (`format: long`: `code`, `card`, `chip`, `freetext`, `video`, `phone`, `bar`, `counter`, `flow`) and adds an edit of the engine's own:
|
|
36
|
+
- a chapter bar at the top, with the title of the current chapter;
|
|
37
|
+
- a transition between chapters (horizontal sweep, vertical, iris and fade, in rotation) with the chapter number and name, in the silence between blocks (`blocks.json`) and with a whoosh;
|
|
38
|
+
- chapter number in the background, a background glow that changes color per chapter;
|
|
39
|
+
- word-by-word captions (the spoken word turns gold);
|
|
40
|
+
- an end screen with `endcard:` (text, link, image).
|
|
41
41
|
|
|
42
|
-
|
|
42
|
+
Optional mascot, only in this engine: in the long form's `episode.yaml`,
|
|
43
43
|
|
|
44
44
|
```yaml
|
|
45
|
-
|
|
46
|
-
- {
|
|
47
|
-
- {
|
|
45
|
+
mascot:
|
|
46
|
+
- { when: "five radiants came back", name: shocked } # mascotPath: mascots/{name}.png
|
|
47
|
+
- { when: "the lock only did", name: coffee, plus: 0.5 }
|
|
48
48
|
```
|
|
49
49
|
|
|
50
|
-
|
|
50
|
+
the mascot appears in the bottom right corner for ~3 s in the scene where the phrase is spoken.
|
|
51
51
|
|
|
52
|
-
|
|
52
|
+
Code lines: `hl: [start-phrase, end-phrase]` lights the line between the two phrases (as in Remotion).
|
|
53
53
|
|
|
54
|
-
##
|
|
54
|
+
## Limits
|
|
55
55
|
|
|
56
|
-
- **`
|
|
57
|
-
-
|
|
58
|
-
-
|
|
59
|
-
-
|
|
56
|
+
- **`component:` (TSX) does not exist in HyperFrames.** A scene (or cover) made in `scenes.tsx` has to become YAML items or the episode stays in Remotion. The generator fails with the slug, the number, the title and the component of the scene; `praeko studio compile --engine hyperframes` warns before. A cover written as a TSX component (`cover.component`) only exists in Remotion; a declarative cover (title/items) renders in HyperFrames too.
|
|
57
|
+
- The `check` times are those of a sampled instant: contrast warnings on still-empty bars and empty-screen warnings on frame 0 are expected (frame 0 of a Short is only the background; the cover is the thumbnail).
|
|
58
|
+
- HyperFrames fetches GSAP from the CDN (jsdelivr) and the bundled fonts the first time: it needs internet on the first render.
|
|
59
|
+
- Pinned version: `hyperframes@0.9.13` (`HF_VERSION` in `packages/hyperframes/src/common.ts`). Raising the version = running the tests and comparing frames.
|
|
60
60
|
|
|
61
|
-
## Remotion × HyperFrames (
|
|
61
|
+
## Remotion × HyperFrames (measured on 2026-11-04, 10-core M4)
|
|
62
62
|
|
|
63
|
-
|
|
|
63
|
+
| Episode | Remotion (GL angle) | HyperFrames (render) | HyperFrames (with generation and check) |
|
|
64
64
|
|---|---|---|---|
|
|
65
|
-
| Short
|
|
66
|
-
| Short
|
|
67
|
-
|
|
|
65
|
+
| Short (53 s) | 26 s | 32 s | 38 s |
|
|
66
|
+
| Short (42 s, windows, phone, bars) | 43 s | 40 s | 54 s |
|
|
67
|
+
| Long (7:47; output 38 MB against Remotion's 56 MB) | 7:12 (default GL) · 7:53 (GL angle) | 3:29 | 3:54 |
|
|
68
68
|
|
|
69
|
-
|
|
69
|
+
Notes: `angle` only helps the Shorts (blur, shadows at 1080×1920); on the long form it gained nothing (7:12 with the default, 7:53 with `angle`; on 1200 frames, 46 s against 44 s), so it is worth measuring before switching. Remotion only got fast on the Shorts after `Config.setChromiumOpenGlRenderer("angle")` in `remotion.config.ts` (before: 102 s for the same Short; PSNR of 48 dB between the two renderers). HyperFrames pays ~6 s of compilation per render and ~6 s of `check`. Remotion concurrency (5 or 10) changed nothing.
|
|
70
70
|
|
|
71
|
-
##
|
|
71
|
+
## When to choose each
|
|
72
72
|
|
|
73
|
-
|
|
|
73
|
+
| Situation | Engine |
|
|
74
74
|
|---|---|
|
|
75
|
-
|
|
|
76
|
-
| Short
|
|
77
|
-
|
|
|
78
|
-
|
|
|
79
|
-
|
|
|
75
|
+
| Episode with a TSX scene (`component:`) or a cover of its own | Remotion |
|
|
76
|
+
| Routine Short of an existing channel | Remotion (same time, same look as always) |
|
|
77
|
+
| Long form, or any video where the edit (transitions, chapters, mascot) matters | HyperFrames |
|
|
78
|
+
| New channel, no legacy TSX | HyperFrames as the default (`"engine": "hyperframes"` in the brand) |
|
|
79
|
+
| You want to tweak the result in a visual editor | HyperFrames (open `.hf/<slug>` in the app) |
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# Long form in YAML (16:9, 1920×1080)
|
|
2
|
+
|
|
3
|
+
`episode.yaml` with `format: long`. Time is **global** (seconds since the start of the video). `praeko studio compile <slug>` validates and writes `episode.spec.json`; `praeko studio render <slug>` renders (compiling first).
|
|
4
|
+
|
|
5
|
+
## Script in blocks
|
|
6
|
+
```
|
|
7
|
+
## 1 · THE HOOK
|
|
8
|
+
[curious] spoken text…
|
|
9
|
+
|
|
10
|
+
## 2 · THE PROBLEM
|
|
11
|
+
…
|
|
12
|
+
```
|
|
13
|
+
`praeko studio voice <slug> [--quality draft|final|local]` generates one audio per block in `voice-blocks/` (the name carries a hash of the text, voice and pronunciation: only the edited block is regenerated, which saves ElevenLabs credit), joins them with a 0.6 s pause into `voice.mp3` and writes `words.json`, `words.ts` and `blocks.json` (`[{n, title, a, b}]`). Old blocks stay in the cache.
|
|
14
|
+
|
|
15
|
+
## Top level and scenes
|
|
16
|
+
```yaml
|
|
17
|
+
format: long
|
|
18
|
+
id: StallLong
|
|
19
|
+
duration: 334
|
|
20
|
+
voice: audio/stall-long-voice.mp3
|
|
21
|
+
scenes:
|
|
22
|
+
- { title: THE HOOK, start: 0, component: S1 } # TSX scene (scenes.tsx exports S1: React.FC<{t:number}>)
|
|
23
|
+
- title: THE PROBLEM
|
|
24
|
+
start: 31.0 # s; or { block: 2 } (uses blocks.json); `n` optional (default: position)
|
|
25
|
+
items: [ … ]
|
|
26
|
+
```
|
|
27
|
+
Each scene runs from its `start` to the next one's (the last one, until `duration`); it fades in and out and shows "n · TITLE" in the corner.
|
|
28
|
+
|
|
29
|
+
## Time (`when`, `hl`)
|
|
30
|
+
`number` (s) · `"phrase"` (start of the phrase in the voice) · `{ when: "phrase", nth: 1, after: 83, plus: 0.6 }` (`nth` = occurrence, `after` = ignore before N s, `plus` = add seconds). A phrase that is not in `words.json` is an error.
|
|
31
|
+
|
|
32
|
+
## Items (all with `pos: { left, top, width, height }` in px, except `text`)
|
|
33
|
+
| type | fields |
|
|
34
|
+
|---|---|
|
|
35
|
+
| `code` | `when`, `title`, `size`, `lines: [{ s, hl: [time, time], red }]` (the line lights up between the two times) |
|
|
36
|
+
| `card` | `when`, `color`, `items: [text…]` (slides in) |
|
|
37
|
+
| `text` | only inside `card`: `size`, `color`, `spacing`, `when` (fade), `text` or `parts` |
|
|
38
|
+
| `chip` | `when`, `color`, `size`, `text` |
|
|
39
|
+
| `freetext` | `when`, `size`, `color`, `align: center`, `mono`, `text` or `parts: [{ text, color, mono }]` |
|
|
40
|
+
|
|
41
|
+
Colors: theme names (`accent text muted ok error info extra box`) or `#hex`. **Text with a comma inside `{ … }` goes in quotes** (the compiler reports an unknown key).
|
|
42
|
+
|
|
43
|
+
## Compiler warnings (estimate)
|
|
44
|
+
Item that goes past the right edge or covers the captions (below ~960 px), absolutely positioned items that overlap, item that enters outside the scene window, a duration with less than 1 s after the end of the voice.
|
|
45
|
+
|
|
46
|
+
## End screen (optional)
|
|
47
|
+
```yaml
|
|
48
|
+
duration: { voice: 6 } # the end screen needs about 5 s after the voice
|
|
49
|
+
endcard: { start: { when: "productive idle", after: 480 }, text: "Productive idle", link: example.com }
|
|
50
|
+
```
|
|
51
|
+
Shows the avatar, `text` (the signature), the theme's `handle` and the `link`, over the scenes, from `start` to the end (the captions go on). `start` accepts a number, "phrase" or `{ voice: N }`; `text`, `link` and `image` (a file in `public/`; default: the theme avatar) are optional.
|
|
52
|
+
|
|
53
|
+
## Cover (YouTube 16:9 and Instagram vertical)
|
|
54
|
+
```yaml
|
|
55
|
+
cover:
|
|
56
|
+
id: CoverStallLong # default: Cover<id>; the vertical one is <id>Vertical
|
|
57
|
+
title: ["TWO CLICKS", "ONE ITEM"] # 2 lines; the 2nd one is highlighted (≤ ~16 letters each on 16:9)
|
|
58
|
+
mascot: shocked # mascot image by name (optional; brand.json mascotPath)
|
|
59
|
+
code: { title: social.mjs · marketBuy, lines: [{ s: "UPDATE market ..." }, { s: "AND sold_to IS NULL", hl: true }] } # up to 3 lines
|
|
60
|
+
chips: [{ text: "ok ✔", color: ok }, { text: sold, color: error }]
|
|
61
|
+
```
|
|
62
|
+
`praeko studio cover <slug>` writes `out/<slug>-cover-1280x720.png` and `out/<slug>-cover-vertical-1080x1920.png` (the vertical one uses the Short covers' `Frame`, inside Instagram's 3:4 grid).
|
|
63
|
+
|
|
64
|
+
## Publication package of the long form
|
|
65
|
+
`publication.json` with `"format": "long"` (fields as in the Short, plus `yt.pinnedComment` and `files.verticalCover`). `praeko studio package <slug>` builds `texts.md`: title and alternatives, description **with chapters**, pinned comment, Instagram caption and the check. Chapters come from the scenes (`start` + title in lowercase) and the YouTube rules apply: start at 0:00, 3 or more, 10 s each (otherwise it is an ERROR). For other texts, write `chapters: [{ t, title }]` in `publication.json`. The YouTube UTM uses `utm_medium=long`.
|
|
66
|
+
|
|
67
|
+
## Media and animation items (long form)
|
|
68
|
+
All with `when` and `pos`:
|
|
69
|
+
| type | fields |
|
|
70
|
+
|---|---|
|
|
71
|
+
| `video` | `src` (in `public/`, 16:9), `clipDuration`, `from` (s of the clip), `reg: [x, y, w, h]` (crop in 1920×1080), `tag`, `pos: { left, top, width }` (height comes from the crop), `motionDuration` |
|
|
72
|
+
| `phone` | `src` (vertical 1080×1920), `clipDuration`, `from`, `pos: { left, top, width }`, `motionDuration` |
|
|
73
|
+
| `bar` | `value` (0–1), `duration`, `color`, `label`, `text` (inside the bar), `height`, `pos` |
|
|
74
|
+
| `counter` | `from`, `to`, `duration`, `decimals`, `prefix`, `suffix`, `size`, `color`, `mono`, `align: center`, `pos` |
|
|
75
|
+
| `flow` | `nodes: [{ text (\n breaks), color, when }]`, `size`, `color`, `pos: { left, top, width }`: boxes joined by arrows that enter one by one |
|
|
76
|
+
|
|
77
|
+
## Engine and mascot (HyperFrames)
|
|
78
|
+
`engine: hyperframes` at the top chooses the render engine of this long video (default: the brand's, then `remotion`). `mascot: [{ when: "phrase", name: shocked, plus: 0.5 }]` makes the mascot image (the `brand.json` `mascotPath` template, default `mascots/{name}.png`) appear in the corner for ~3 s; only HyperFrames draws the mascot (Remotion ignores it). See [`hyperframes.md`](hyperframes.md).
|
package/docs/publish.md
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# Publishing through Praeko
|
|
2
|
+
|
|
3
|
+
Studio **publishes nothing on its own**. `praeko studio package <slug>` writes `texts.md` and checks limits, hashtags and the AI disclosure; `praeko studio schedule` hands the video, cover and texts to Praeko, which schedules them on the networks connected to your workspace.
|
|
4
|
+
|
|
5
|
+
## From the CLI (`--via praeko`)
|
|
6
|
+
```sh
|
|
7
|
+
echo $KEY | praeko studio schedule --via praeko --key # praeko_… key (write scope); the same one as the `praeko` CLI
|
|
8
|
+
praeko studio schedule --channels --via praeko # lists the connected channels
|
|
9
|
+
praeko studio schedule <slug> --when "06/11 17:30" --via praeko # SIMULATES (only reads the channels)
|
|
10
|
+
praeko studio schedule <slug> --when "06/11 17:30" --via praeko --send # uploads the video and really schedules
|
|
11
|
+
praeko studio schedule <slug> --status | --cancel | --clear
|
|
12
|
+
```
|
|
13
|
+
`brand.json` → `publishing.provider: "praeko"` makes Praeko the default (`init` already writes it that way). `publishing.praeko.channels` pins the account when there is more than one per network. YouTube is uploaded as private and scheduled; TikTok goes as a draft (`publishing.praeko.tiktok.mode`); the minimum time is 5 minutes ahead.
|
|
14
|
+
|
|
15
|
+
## Through the Praeko MCP (agent with a shell)
|
|
16
|
+
The video file never goes through the MCP. In one step: `create_media_upload { type: "video", name, mime, size }` (`size` in bytes: `wc -c < file`) returns a ready `curl`; upload the file; then `complete_media_upload { mediaId }` until the state is `ready`. Then `create_post`/`schedule_post` in **two steps**: the 1st call returns only the **preview** and a `confirmation_code`; **show the whole preview to the person**, wait for the yes and repeat the call with the same arguments plus `confirm`. The agent never confirms on its own. See the MCP documentation at praeko.app/docs.
|
|
17
|
+
|
|
18
|
+
## AI disclosure (checked by `check` and `package`)
|
|
19
|
+
Fill `"ai": { "synthetic": true|false }` in `publication.json`. A realistic synthetic voice, or a generated image/video that looks real, needs the altered/synthetic content label: YouTube (the `containsSyntheticMedia` field, sent when Studio publishes directly), "AI info" on Instagram and the AI label on TikTok. Through Praeko, **set the label in each network's app before publishing** (Praeko's public API does not carry this field yet). Drafting a script with AI or cloning **your own** voice to narrate does not require the label on YouTube, but the rules change: check each network's current policy.
|
|
20
|
+
|
|
21
|
+
## Advanced
|
|
22
|
+
`post` (direct publishing with your own credentials for each network), `queue` and `account <network>` stay in the package for those who need them; `queue install` uses launchd and only works on macOS. The Buffer provider (`--via buffer`) is legacy.
|
package/docs/voice.md
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# Voice in Praeko Studio
|
|
2
|
+
|
|
3
|
+
`praeko studio voice <slug> --quality draft|final|local` writes `voice.mp3` and the per-word timings (`words.json`/`words.ts`).
|
|
4
|
+
|
|
5
|
+
| Quality | What it does | Cost |
|
|
6
|
+
|---|---|---|
|
|
7
|
+
| `draft` | **estimated, silent** voice (~190 words/min), no network | free |
|
|
8
|
+
| `final` | ElevenLabs (TTS) + Scribe (per-word timings), **your** account and key | characters of your plan |
|
|
9
|
+
| `local` | VoiceStudio (compatible local server, `http://127.0.0.1:3900` by default) | free, must be running |
|
|
10
|
+
|
|
11
|
+
## Set up your voice
|
|
12
|
+
Layers of `voices.json` (the top one wins): **project** (next to `brand.json`) > **user** (`~/.config/praeko/studio/voices.json`, or `PRAEKO_STUDIO_CONFIG`) > package default (no ids).
|
|
13
|
+
1. Store the key: `echo $KEY | praeko studio voice config key elevenlabs/principal` (system keychain on macOS; file `~/.config/praeko/secrets.json` with 600 permission on Linux; on Windows prefer the `ELEVENLABS_API_KEY` variable).
|
|
14
|
+
2. Register the voice: `praeko studio voice account add principal --voice my-voice --voice-id <voice id>` (writes the id into the project's `voices.json`) or set `ELEVENLABS_VOICE_ID`.
|
|
15
|
+
3. In `brand.json`, `"voice": "my-voice"` applies to `voice` without `--quality`; with `--quality final` the voice in `quality.final` applies. The `init` default is `draft-silent`, so it never spends by accident.
|
|
16
|
+
More than one account (failover by balance): `account add <other>`; the router skips an account with no balance or with an invalid key.
|
|
17
|
+
|
|
18
|
+
## Rules
|
|
19
|
+
- Use **only your own voice** or one you are authorized to use (ElevenLabs terms).
|
|
20
|
+
- A realistic synthetic voice requires the AI label on the platforms: fill `"ai": { "synthetic": true }` in `publication.json`.
|
|
21
|
+
- A key never goes in a project file, in a log or in a chat with an agent. Reading order: environment variable, macOS keychain, secrets file.
|
|
22
|
+
- The VoiceStudio OmniVoice model is CC-BY-NC (non-commercial): do not use it in monetized content.
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@praeko/studio",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"description": "Praeko Studio:
|
|
5
|
+
"description": "Praeko Studio: short (9:16) and long (16:9) videos from a script, for you to run with your own coding agent (Claude Code, Codex, Gemini CLI, Cursor, opencode). Voice, word-by-word captions, scenes, cover, texts and publishing through Praeko.",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"keywords": [
|
|
8
8
|
"praeko",
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
},
|
|
30
30
|
"files": [
|
|
31
31
|
"dist",
|
|
32
|
-
"
|
|
32
|
+
"templates",
|
|
33
33
|
"docs",
|
|
34
34
|
"README.md",
|
|
35
35
|
"CHANGELOG.md",
|
|
@@ -43,16 +43,17 @@
|
|
|
43
43
|
"scripts": {
|
|
44
44
|
"build": "node ../../../scripts/build-studio.mjs cli",
|
|
45
45
|
"prepack": "npm run build",
|
|
46
|
-
"test": "node --test test/*.test.
|
|
46
|
+
"test": "node --test test/*.test.ts",
|
|
47
|
+
"typecheck": "tsc --noEmit -p tsconfig.json"
|
|
47
48
|
},
|
|
48
49
|
"dependencies": {
|
|
49
50
|
"@elevenlabs/elevenlabs-js": "^2.75.0"
|
|
50
51
|
},
|
|
51
52
|
"devDependencies": {
|
|
52
53
|
"@praeko/studio-hyperframes": "*",
|
|
53
|
-
"@praeko/studio-
|
|
54
|
-
"@praeko/studio-
|
|
55
|
-
"@praeko/studio-
|
|
54
|
+
"@praeko/studio-publish": "*",
|
|
55
|
+
"@praeko/studio-review": "*",
|
|
56
|
+
"@praeko/studio-voice": "*",
|
|
56
57
|
"yaml": "^2.9.1"
|
|
57
58
|
},
|
|
58
59
|
"engines": {
|