@praeko/studio-core 0.0.0-stage → 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 ADDED
@@ -0,0 +1,16 @@
1
+ # Changelog
2
+
3
+ Follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and [SemVer](https://semver.org/).
4
+
5
+ ## [0.2.0] - unreleased
6
+
7
+ Breaking: the whole public API is English. There are no deprecated aliases; move to the new names. Published types are generated by `tsc`, and their relative specifiers end in `.js`.
8
+
9
+ - Types and data: `Palavra` -> `Word`, `Tema` -> `Theme`, `TEMA_PADRAO` -> `DEFAULT_THEME`, `EpData` -> `EpisodeData` (`voz` -> `voice`, `palavras` -> `words`, `cenas` -> `scenes`, `fechamento` -> `outro`, `fundoLinhas` -> `backgroundLines`, `legendaPx` -> `captionPx`, `golpes` -> `hits`), `EpCena` -> `EpisodeScene` (`rotulo` -> `label`, `sons` -> `sounds`), `Som` -> `Sound` (`f` -> `sound`), `Golpe` -> `Hit` (`tipo` -> `type`, `tudo` -> `all`), `Grupo` -> `Group`, `LongoData` -> `LongData`, `CenaLongo` -> `LongScene`, `FechoLongo` -> `LongEndcard`.
10
+ - Functions and components: `Episodio` -> `Episode`, `Longo` -> `Long`, `Rotulo` -> `Label`, `Fundo` -> `Background`, `agrupar` -> `groupWords`, `TemaProvider` -> `ThemeProvider` (prop `tema` -> `theme`), `useTema` -> `useTheme`, `temaDaMarca` -> `themeFromBrand`, `surge` -> `appear`, `mkAt` -> `makeAt`, `mkAtG` -> `makeAtGlobal`, `janela` -> `fadeWindow`.
11
+ - Theme fields: `fundo` -> `background`, `fundoCapa` -> `coverBackground`, `destaque` -> `accent`, `texto` -> `text`, `suave` -> `muted`, `erro` -> `error`, `caixa` -> `box`, `caixaLongo` -> `longBox`, `codigo` -> `code`, `fonte` -> `font`. New `mascotPath` (default `mascots/{name}.png`). `themeFromBrand` reads the English `brand.json` shape (`colors`, `fonts.title`, `fonts.mono`).
12
+ - Error messages are English (`phrase not found: ...`).
13
+
14
+ ## [0.1.0] - 2026-11-07
15
+
16
+ First published version, extracted from the project where it was born. Publishes compiled `dist/` (ESM + `.d.ts`); `react` and `remotion` are peers.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Marcone Santos
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/LICENSES.md ADDED
@@ -0,0 +1,36 @@
1
+ # Praeko Studio licenses
2
+
3
+ Verified on 2026-11-07. This file is not legal advice; each vendor's own text prevails.
4
+
5
+ ## Praeko Studio's code
6
+ The packages `@praeko/studio`, `@praeko/studio-core` and `@praeko/studio-kit` are **MIT** (see `LICENSE`, Copyright (c) 2026 Marcone Santos), like `@praeko/sdk` and `@praeko/cli`. The sound effects (`pop`, `ding`, `thump`, `whoosh`) and the `avatar.png` of the project template were created for Studio (sounds synthesized by `scripts/generate-sfx.mjs`, with no third-party samples) and follow the same MIT license. The package ships no fonts, music, stock images or third-party art.
7
+
8
+ ## Notice you need to see (Remotion is NOT MIT)
9
+ > **Remotion.** Remotion is *source-available*, not open source. It is **free** for individuals, non-profits, evaluation and companies with **up to 3 employees** (commercial use included). Companies with **4 or more people** need the **Company License** (remotion.pro/license). You install and run Remotion, so the license is **yours**, not Praeko's. Remotion also sells plans for those who **automate renders by program or agent** (Creators, Automators); their FAQ does not expressly say whether a local agent running `remotion render` counts: **ask Remotion** before using at scale. Praeko Studio does not copy or modify Remotion code: it uses it as a dependency (peer) that you install.
10
+
11
+ This notice appears in the `README`, at install time (`praeko studio init`), in `praeko studio doctor` and in `praeko studio licenses` (`--accept` records that you read it in `~/.config/praeko/studio/licenses.json`).
12
+
13
+ **Engines.** Remotion is the default engine for videos **and** covers. With `--engine hyperframes` (or `"engine": "hyperframes"` in `brand.json`) the video **and** the cover are rendered by HyperFrames and nothing from Remotion is used, so a project that only uses HyperFrames does not need a Remotion license. Scenes or covers written as TSX (`component:`) only exist in Remotion.
14
+
15
+ **We do not promise "free forever":** Remotion's conditions may change (their own page warns that the license changes in Remotion 5.0). Review the license when you upgrade.
16
+
17
+ ## Third-party dependencies
18
+ | Component | Where | License | Source |
19
+ |---|---|---|---|
20
+ | `remotion`, `@remotion/cli` (4.0.539) | installed in the user's **project** (peer of core/kit) | Remotion License (see above), not MIT | remotion.pro/license; github.com/remotion-dev/remotion (LICENSE.md) |
21
+ | `react`, `react-dom` (19.2.3) | user's project (peer) | MIT | each package's package.json |
22
+ | `@praeko/studio-core`, `-kit` | user's project | MIT | LICENSE |
23
+ | `yaml` (2.9.1) | embedded in the `@praeko/studio` bundle | ISC | package.json |
24
+ | `@elevenlabs/elevenlabs-js` (2.x) | dependency of `@praeko/studio` | MIT | package.json |
25
+ | `hyperframes` (0.9.13, optional engine) | downloaded by `npx` the first time you use `--engine hyperframes` | Apache-2.0 | github.com/heygen-com/hyperframes |
26
+ | `typescript`, `esbuild`, `@types/*` | development/build only | Apache-2.0 / MIT | package.json |
27
+ | ffmpeg / ffprobe | you install | LGPL or GPL, depending on the build | ffmpeg.org/legal.html |
28
+ | Chrome Headless Shell / Chromium | Remotion downloads it on the first render | Chromium/Google licenses | remotion.dev/docs/miscellaneous/chrome-headless-shell |
29
+
30
+ ## Services and models (you sign up)
31
+ - **ElevenLabs**: its terms and your plan (voice cloning only with rights over the voice). The key is yours; Studio never prints it.
32
+ - **Local VoiceStudio** (optional): the OmniVoice model is **CC-BY-NC (non-commercial use)**. It is not the default in any Studio configuration and **must not be used in monetized content**. Check the license of any local model you use.
33
+ - **YouTube, Instagram, TikTok**: each network's terms and synthetic-content rules (see `praeko studio docs publish`).
34
+
35
+ ## Fonts and media
36
+ No font is distributed. The project template uses system fonts (`Arial Black`, `Menlo`) that `doctor` checks; swap them in `brand.json` for open fonts (OFL) if you want the video to look the same on every system. Music, images and clips you add are your responsibility (rights and licenses).
package/README.md CHANGED
@@ -1,3 +1,18 @@
1
- # Temporary Holding Version
1
+ # @praeko/studio-core
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The Praeko Studio engine (Remotion parts): voice-driven episodes with timed words, word-by-word captions, editing hits, outro and brand theme. A library for projects created with [`@praeko/studio`](https://www.npmjs.com/package/@praeko/studio) (`praeko studio init` installs and configures it).
4
+
5
+ > **Licenses.** This package is MIT and uses **Remotion** as a peer dependency that **you** install. Remotion is **not MIT**: free for individuals, non-profits and companies of up to 3 employees; companies of 4 or more people need the Company License ([remotion.pro/license](https://www.remotion.pro/license)). Details in [LICENSES.md](./LICENSES.md).
6
+
7
+ ## Install
8
+ ```sh
9
+ # npm
10
+ npm install @praeko/studio-core remotion react
11
+ # yarn
12
+ yarn add @praeko/studio-core remotion react
13
+ # pnpm
14
+ pnpm add @praeko/studio-core remotion react
15
+ # bun
16
+ bun add @praeko/studio-core remotion react
17
+ ```
18
+ Peers: `remotion` ^4.0.539 (all `@remotion/*` packages on the same version) and `react` ^19. Node 22 or newer. Usage: `praeko studio docs episode-yaml`.
@@ -0,0 +1,56 @@
1
+ import type { Word } from "./word.js";
2
+ /**
3
+ * PRAEKO STUDIO ENGINE.
4
+ * An episode is just data (EpisodeData): voice, timed words, scenes (card + label + sounds) and outro.
5
+ * The rest (background, captions, progress bar, outro, effects) is shared and uses the brand theme.
6
+ */
7
+ export type Sound = {
8
+ at: number;
9
+ sound: "pop" | "ding" | "thump" | "whoosh";
10
+ v?: number;
11
+ };
12
+ export type Hit = {
13
+ at: number;
14
+ type: "shake" | "zoom" | "flash" | "all";
15
+ };
16
+ export type EpisodeScene = {
17
+ a: number;
18
+ b: number;
19
+ C: React.FC<{
20
+ dur: number;
21
+ }>;
22
+ label?: string;
23
+ /** sounds relative to the start of the scene (s) */
24
+ sounds?: Sound[];
25
+ };
26
+ export type EpisodeData = {
27
+ id: string;
28
+ durS: number;
29
+ voice: string;
30
+ words: Word[];
31
+ scenes: EpisodeScene[];
32
+ outro: {
33
+ a: number;
34
+ cta: string;
35
+ };
36
+ /** blurred "terminal" lines in the background */
37
+ backgroundLines?: string[];
38
+ /** caption size (default 90) */
39
+ captionPx?: number;
40
+ /** editing hits: quick shake, flash and zoom at instants (s) of the narration */
41
+ hits?: Hit[];
42
+ };
43
+ export declare const Label: React.FC<{
44
+ text: string;
45
+ }>;
46
+ /** Clean background: dark, a pulsing amber glow and dots drifting up slowly. */
47
+ export declare const Background: React.FC;
48
+ export type Group = {
49
+ words: Word[];
50
+ a: number;
51
+ b: number;
52
+ };
53
+ export declare const groupWords: (ps: Word[]) => Group[];
54
+ export declare const Episode: React.FC<{
55
+ d: EpisodeData;
56
+ }>;
@@ -0,0 +1,6 @@
1
+ export * from "./engine.js";
2
+ export * from "./word.js";
3
+ export * from "./theme.js";
4
+ export * from "./theme-context.js";
5
+ export * from "./util.js";
6
+ export * from "./long.js";
package/dist/index.js ADDED
@@ -0,0 +1,465 @@
1
+ // src/engine.tsx
2
+ import { AbsoluteFill, Audio, Img, Sequence, interpolate as interpolate2, spring, staticFile, useCurrentFrame, useVideoConfig } from "remotion";
3
+
4
+ // src/theme.ts
5
+ var BASE = {
6
+ background: "#14110f",
7
+ coverBackground: "#0e0c14",
8
+ accent: "#f4b942",
9
+ text: "#f3ece0",
10
+ muted: "#a89c8a",
11
+ ok: "#7ee787",
12
+ error: "#ff6b5e",
13
+ info: "#7fd7ff",
14
+ extra: "#a98bff",
15
+ box: "#2a2118",
16
+ code: "#1b1610",
17
+ font: "'Arial Black', 'Helvetica Neue', system-ui, sans-serif",
18
+ mono: "Menlo, 'Courier New', monospace",
19
+ handle: "@yourchannel",
20
+ avatar: "cover/avatar.png",
21
+ mascotPath: "mascots/{name}.png"
22
+ };
23
+ var DEFAULT_THEME = BASE;
24
+ var themeFromBrand = (m) => ({
25
+ ...DEFAULT_THEME,
26
+ ...m.colors ?? {},
27
+ ...m.fonts?.title ? { font: m.fonts.title } : {},
28
+ ...m.fonts?.mono ? { mono: m.fonts.mono } : {},
29
+ ...m.handle ? { handle: m.handle } : {},
30
+ ...m.avatar ? { avatar: m.avatar } : {},
31
+ ...m.mascotPath ? { mascotPath: m.mascotPath } : {}
32
+ });
33
+ var rgba = (hex, a) => {
34
+ const n = parseInt(hex.slice(1), 16);
35
+ return `rgba(${n >> 16 & 255},${n >> 8 & 255},${n & 255},${a})`;
36
+ };
37
+
38
+ // src/theme-context.tsx
39
+ import { createContext, useContext } from "react";
40
+ import { jsx } from "react/jsx-runtime";
41
+ var Ctx = createContext(DEFAULT_THEME);
42
+ var ThemeProvider = ({ theme, children }) => /* @__PURE__ */ jsx(Ctx.Provider, { value: theme, children });
43
+ var useTheme = () => useContext(Ctx);
44
+
45
+ // src/util.ts
46
+ import { Easing, interpolate } from "remotion";
47
+ var FPS = 30;
48
+ var clamp = { extrapolateLeft: "clamp", extrapolateRight: "clamp" };
49
+ var EO = Easing.bezier(0.16, 1, 0.3, 1);
50
+ var appear = (t, q, d = 0.35) => interpolate(t, [q, q + d], [0, 1], { ...clamp, easing: EO });
51
+ var ramp = (t, a, b) => interpolate(t, [a, b], [0, 1], { ...clamp, easing: Easing.out(Easing.cubic) });
52
+ var pop = (t, q) => interpolate(t, [q, q + 0.18, q + 0.4], [0.6, 1.14, 1], { ...clamp, easing: Easing.out(Easing.cubic) });
53
+ var fmt = (n) => String(Math.round(n)).replace(/\B(?=(\d{3})+(?!\d))/g, ".");
54
+ var norm = (s) => s.toLowerCase().normalize("NFD").replace(/[̀-ͯ]/g, "").replace(/[^a-z0-9]/g, "");
55
+ var makeAt = (P, S) => {
56
+ const W = P.map((p) => norm(p.t));
57
+ return (i) => (phrase, nth = 0) => {
58
+ const f2 = phrase.split(/\s+/).map(norm).filter(Boolean);
59
+ let c = 0;
60
+ for (let k = 0; k <= W.length - f2.length; k++) {
61
+ if (f2.every((w, j) => W[k + j] === w)) {
62
+ if (c === nth) return P[k].a - S[i];
63
+ c++;
64
+ }
65
+ }
66
+ throw new Error("phrase not found: " + phrase);
67
+ };
68
+ };
69
+
70
+ // src/engine.tsx
71
+ import { Fragment, jsx as jsx2, jsxs } from "react/jsx-runtime";
72
+ var f = (s) => Math.round(s * FPS);
73
+ var DUR_SOM = { pop: 0.16, ding: 0.9, thump: 0.4, whoosh: 0.55 };
74
+ var VOL_SOM = { pop: 0.22, ding: 0.26, thump: 0.33, whoosh: 0.28 };
75
+ var Label = ({ text }) => {
76
+ const { accent: LAMP, background: BG, text: INK, font: FONT, ok: OK, box: BOX } = useTheme();
77
+ const frame = useCurrentFrame();
78
+ const { fps } = useVideoConfig();
79
+ const pop2 = spring({ frame: frame - 3, fps, config: { damping: 11, stiffness: 200 } });
80
+ return /* @__PURE__ */ jsx2(
81
+ "div",
82
+ {
83
+ style: {
84
+ position: "absolute",
85
+ top: 70,
86
+ left: 0,
87
+ right: 0,
88
+ display: "flex",
89
+ justifyContent: "center",
90
+ transform: `scale(${0.6 + 0.4 * pop2}) rotate(${(1 - pop2) * -4}deg)`,
91
+ opacity: Math.min(1, pop2 * 1.5)
92
+ },
93
+ children: /* @__PURE__ */ jsx2(
94
+ "div",
95
+ {
96
+ style: {
97
+ background: LAMP,
98
+ color: BG,
99
+ fontFamily: FONT,
100
+ fontSize: 62,
101
+ fontWeight: 900,
102
+ letterSpacing: 1,
103
+ padding: "14px 36px",
104
+ borderRadius: 22,
105
+ boxShadow: "0 10px 30px rgba(0,0,0,.55)",
106
+ whiteSpace: "nowrap"
107
+ },
108
+ children: text
109
+ }
110
+ )
111
+ }
112
+ );
113
+ };
114
+ var Background = () => {
115
+ const { accent: LAMP, background: BG, text: INK, font: FONT, ok: OK, box: BOX } = useTheme();
116
+ const frame = useCurrentFrame();
117
+ const pulse = 0.16 + 0.05 * Math.sin(frame / 22);
118
+ return /* @__PURE__ */ jsx2(
119
+ AbsoluteFill,
120
+ {
121
+ style: {
122
+ background: `radial-gradient(ellipse at 50% 20%, ${rgba(LAMP, pulse)} 0%, ${rgba(LAMP, 0)} 55%), radial-gradient(ellipse at 50% 100%, rgba(80,60,120,.22) 0%, rgba(0,0,0,0) 60%), ${BG}`
123
+ },
124
+ children: Array.from({ length: 26 }).map((_, i) => {
125
+ const x = i * 137 % 1080;
126
+ const vel = 0.35 + i % 5 * 0.18;
127
+ const y = (i * 251 % 1920 - frame * vel + 1920 * 4) % 1920;
128
+ const r = 3 + i % 4 * 2;
129
+ return /* @__PURE__ */ jsx2(
130
+ "div",
131
+ {
132
+ style: {
133
+ position: "absolute",
134
+ left: x,
135
+ top: y,
136
+ width: r,
137
+ height: r,
138
+ borderRadius: r,
139
+ background: LAMP,
140
+ opacity: 0.1 + i % 4 * 0.05
141
+ }
142
+ },
143
+ i
144
+ );
145
+ })
146
+ }
147
+ );
148
+ };
149
+ var groupWords = (ps) => {
150
+ const g = [];
151
+ let cur = [];
152
+ const close = () => {
153
+ if (cur.length) g.push({ words: cur, a: cur[0].a, b: cur[cur.length - 1].b });
154
+ cur = [];
155
+ };
156
+ ps.forEach((p, i) => {
157
+ cur.push(p);
158
+ const gap = i < ps.length - 1 ? ps[i + 1].a - p.b : 1;
159
+ const letters = cur.reduce((n, x) => n + x.t.length, 0);
160
+ const next = ps[i + 1]?.t.length ?? 0;
161
+ if (/[.,?!:]$/.test(p.t) || cur.length >= 3 || letters + next > 15 || gap > 0.3) close();
162
+ });
163
+ close();
164
+ return g;
165
+ };
166
+ var TerminalBackground = ({ lines }) => {
167
+ const { accent: LAMP, ok: OK } = useTheme();
168
+ const frame = useCurrentFrame();
169
+ const n = Math.max(1, lines.length);
170
+ const step = 64, cycle = 34 * step + 0;
171
+ return /* @__PURE__ */ jsxs(AbsoluteFill, { style: { overflow: "hidden" }, children: [
172
+ /* @__PURE__ */ jsx2(Background, {}),
173
+ /* @__PURE__ */ jsx2("div", { style: { position: "absolute", inset: 0, opacity: 0.09, filter: "blur(1.2px)" }, children: Array.from({ length: 34 }).map((_, i) => {
174
+ const y = ((i * step - frame * 0.8) % cycle + cycle) % cycle - 160;
175
+ const col = (i + Math.floor(i / n)) % 2;
176
+ return /* @__PURE__ */ jsx2(
177
+ "div",
178
+ {
179
+ style: {
180
+ position: "absolute",
181
+ left: 30 + col * 420,
182
+ top: y,
183
+ fontFamily: "Menlo, 'Courier New', monospace",
184
+ fontSize: 28,
185
+ color: i % 4 === 0 ? LAMP : OK,
186
+ whiteSpace: "nowrap"
187
+ },
188
+ children: lines[i % n]
189
+ },
190
+ i
191
+ );
192
+ }) })
193
+ ] });
194
+ };
195
+ var CaptionEp = ({ words, bottom, px = 90 }) => {
196
+ const { accent: LAMP, background: BG, text: INK, font: FONT, ok: OK, box: BOX } = useTheme();
197
+ const frame = useCurrentFrame();
198
+ const t = frame / FPS;
199
+ const groups = groupWords(words);
200
+ const idx = groups.findIndex((g2, i) => {
201
+ const next = groups[i + 1]?.a ?? g2.b + 0.4;
202
+ return t >= g2.a - 0.05 && t < Math.max(g2.b + 0.15, Math.min(next, g2.b + 0.5));
203
+ });
204
+ if (idx < 0) return null;
205
+ const g = groups[idx];
206
+ const inside = frame - f(g.a);
207
+ const enter = interpolate2(inside, [-2, 4], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
208
+ return /* @__PURE__ */ jsx2(
209
+ "div",
210
+ {
211
+ style: {
212
+ position: "absolute",
213
+ left: px < 90 ? 70 : 40,
214
+ right: px < 90 ? 70 : 40,
215
+ bottom,
216
+ display: "flex",
217
+ justifyContent: "center",
218
+ gap: px < 90 ? 64 : 44,
219
+ flexWrap: "wrap",
220
+ opacity: enter,
221
+ transform: `translateY(${(1 - enter) * 24}px)`
222
+ },
223
+ children: g.words.map((p, i) => {
224
+ const active = t >= p.a && t < p.b + 0.05;
225
+ const spoken = t >= p.a;
226
+ return /* @__PURE__ */ jsx2(
227
+ "span",
228
+ {
229
+ style: {
230
+ fontFamily: FONT,
231
+ fontWeight: 900,
232
+ fontSize: px,
233
+ lineHeight: 1.05,
234
+ textTransform: "uppercase",
235
+ color: active ? LAMP : spoken ? INK : rgba(INK, 0.55),
236
+ transform: `scale(${active ? 1.12 : 1})`,
237
+ textShadow: "0 6px 0 #000, 0 0 24px rgba(0,0,0,.85), 4px 0 0 #000, -4px 0 0 #000"
238
+ },
239
+ children: p.t
240
+ },
241
+ i
242
+ );
243
+ })
244
+ }
245
+ );
246
+ };
247
+ var OutroEp = ({ cta }) => {
248
+ const theme = useTheme();
249
+ const { accent: LAMP, background: BG, text: INK, font: FONT, ok: OK, box: BOX } = useTheme();
250
+ const frame = useCurrentFrame();
251
+ const { fps } = useVideoConfig();
252
+ const logo = spring({ frame, fps, config: { damping: 10, stiffness: 150 } });
253
+ const handleSpring = spring({ frame: frame - 8, fps, config: { damping: 200 } });
254
+ const pill = spring({ frame: frame - 20, fps, config: { damping: 12, stiffness: 160 } });
255
+ return /* @__PURE__ */ jsxs(
256
+ AbsoluteFill,
257
+ {
258
+ style: {
259
+ background: `radial-gradient(circle at 50% 40%, ${BOX} 0%, ${BG} 72%)`,
260
+ justifyContent: "center",
261
+ alignItems: "center",
262
+ fontFamily: FONT,
263
+ textAlign: "center"
264
+ },
265
+ children: [
266
+ /* @__PURE__ */ jsx2(
267
+ "div",
268
+ {
269
+ style: {
270
+ width: 420,
271
+ height: 420,
272
+ borderRadius: 210,
273
+ overflow: "hidden",
274
+ border: `12px solid ${LAMP}`,
275
+ boxShadow: `0 0 80px ${rgba(LAMP, 0.45)}`,
276
+ transform: `scale(${0.6 + 0.4 * logo})`,
277
+ opacity: Math.min(1, logo * 1.5)
278
+ },
279
+ children: /* @__PURE__ */ jsx2(Img, { src: staticFile(theme.avatar), style: { width: 420, height: 420, imageRendering: "pixelated" } })
280
+ }
281
+ ),
282
+ /* @__PURE__ */ jsx2(
283
+ "div",
284
+ {
285
+ style: {
286
+ marginTop: 46,
287
+ fontSize: 92,
288
+ fontWeight: 900,
289
+ color: LAMP,
290
+ opacity: Math.max(0, handleSpring),
291
+ transform: `translateY(${(1 - Math.max(0, handleSpring)) * 30}px)`
292
+ },
293
+ children: theme.handle
294
+ }
295
+ ),
296
+ /* @__PURE__ */ jsx2(
297
+ "div",
298
+ {
299
+ style: {
300
+ marginTop: 50,
301
+ background: LAMP,
302
+ color: BG,
303
+ fontSize: cta.length > 18 ? 54 : 64,
304
+ fontWeight: 900,
305
+ padding: "20px 44px",
306
+ borderRadius: 28,
307
+ opacity: Math.max(0, pill),
308
+ transform: `scale(${0.6 + 0.4 * Math.max(0, pill)})`
309
+ },
310
+ children: cta
311
+ }
312
+ )
313
+ ]
314
+ }
315
+ );
316
+ };
317
+ var ProgressBar = ({ durS }) => {
318
+ const { accent: LAMP, background: BG, text: INK, font: FONT, ok: OK, box: BOX } = useTheme();
319
+ const frame = useCurrentFrame();
320
+ const w = Math.min(100, frame / f(durS) * 100);
321
+ return /* @__PURE__ */ jsx2("div", { style: { position: "absolute", top: 0, left: 0, right: 0, height: 10, background: "rgba(0,0,0,.4)" }, children: /* @__PURE__ */ jsx2("div", { style: { width: `${w}%`, height: "100%", background: LAMP } }) });
322
+ };
323
+ var Hits = ({ hits, children }) => {
324
+ const t = useCurrentFrame() / FPS;
325
+ let sx = 0, sy = 0, zoom = 1, flash = 0;
326
+ for (const g of hits) {
327
+ const dt = t - g.at;
328
+ if (dt < 0 || dt > 0.45) continue;
329
+ const k = (1 - dt / 0.45) ** 2;
330
+ if (g.type === "shake" || g.type === "all") {
331
+ sx += Math.sin(dt * 95) * 16 * k;
332
+ sy += Math.cos(dt * 120) * 9 * k;
333
+ }
334
+ if (g.type === "zoom" || g.type === "all") zoom = Math.max(zoom, 1 + 0.055 * k);
335
+ if (g.type === "flash" || g.type === "all") flash = Math.max(flash, 0.33 * k);
336
+ }
337
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
338
+ /* @__PURE__ */ jsx2(AbsoluteFill, { style: { transform: `translate(${sx}px, ${sy}px) scale(${zoom})` }, children }),
339
+ flash > 0.01 ? /* @__PURE__ */ jsx2(AbsoluteFill, { style: { background: `rgba(255,244,214,${flash})`, pointerEvents: "none" } }) : null
340
+ ] });
341
+ };
342
+ var Episode = ({ d }) => {
343
+ const { background: BG } = useTheme();
344
+ return /* @__PURE__ */ jsxs(AbsoluteFill, { style: { backgroundColor: BG }, children: [
345
+ /* @__PURE__ */ jsx2(Audio, { src: staticFile(d.voice) }),
346
+ /* @__PURE__ */ jsxs(Hits, { hits: d.hits ?? [], children: [
347
+ /* @__PURE__ */ jsx2(TerminalBackground, { lines: d.backgroundLines ?? ["\u2713 ok"] }),
348
+ /* @__PURE__ */ jsx2(Sequence, { from: 0, durationInFrames: f(DUR_SOM.whoosh) + 2, children: /* @__PURE__ */ jsx2(Audio, { src: staticFile("sfx/whoosh.wav"), volume: VOL_SOM.whoosh }) }),
349
+ d.scenes.flatMap((c, i) => {
350
+ const sounds = [{ at: 0, sound: "pop" }, ...c.sounds ?? []];
351
+ return sounds.map((s, k) => /* @__PURE__ */ jsx2(Sequence, { from: f(c.a + s.at), durationInFrames: f(DUR_SOM[s.sound]) + 2, children: /* @__PURE__ */ jsx2(Audio, { src: staticFile(`sfx/${s.sound}.wav`), volume: s.v ?? VOL_SOM[s.sound] }) }, `s${i}-${k}`));
352
+ }),
353
+ d.scenes.map((c, i) => /* @__PURE__ */ jsx2(Sequence, { from: f(c.a), durationInFrames: f(c.b - c.a), premountFor: 10, children: /* @__PURE__ */ jsx2(c.C, { dur: c.b - c.a }) }, "c" + i)),
354
+ d.scenes.map(
355
+ (c, i) => c.label ? /* @__PURE__ */ jsx2(Sequence, { from: f(c.a), durationInFrames: f(c.b - c.a), children: /* @__PURE__ */ jsx2(Label, { text: c.label }) }, "r" + i) : null
356
+ )
357
+ ] }),
358
+ /* @__PURE__ */ jsx2(Sequence, { from: f(d.outro.a), durationInFrames: f(d.durS - d.outro.a), children: /* @__PURE__ */ jsx2(OutroEp, { cta: d.outro.cta }) }),
359
+ /* @__PURE__ */ jsx2(Sequence, { from: 0, durationInFrames: f(d.durS), children: /* @__PURE__ */ jsx2(CaptionEp, { words: d.words, bottom: 190, px: d.captionPx }) }),
360
+ /* @__PURE__ */ jsx2(Sequence, { from: f(d.outro.a), durationInFrames: 2, children: /* @__PURE__ */ jsx2(Audio, { src: staticFile("sfx/ding.wav"), volume: 0.22 }) }),
361
+ /* @__PURE__ */ jsx2(ProgressBar, { durS: d.durS })
362
+ ] });
363
+ };
364
+
365
+ // src/long.tsx
366
+ import { AbsoluteFill as AbsoluteFill2, Audio as Audio2, Img as Img2, interpolate as interpolate3, spring as spring2, staticFile as staticFile2, useCurrentFrame as useCurrentFrame2, useVideoConfig as useVideoConfig2 } from "remotion";
367
+ import { jsx as jsx3, jsxs as jsxs2 } from "react/jsx-runtime";
368
+ var clamp2 = { extrapolateLeft: "clamp", extrapolateRight: "clamp" };
369
+ var fadeWindow = (t, a, b, d = 0.4) => interpolate3(t, [a, a + d, b - d, b], [0, 1, 1, 0], clamp2);
370
+ var makeAtGlobal = (P) => {
371
+ const W = P.map((p) => norm(p.t));
372
+ return (phrase, nth = 0, from = 0) => {
373
+ const f2 = phrase.split(/\s+/).map(norm).filter(Boolean);
374
+ let c = 0;
375
+ for (let i = 0; i <= W.length - f2.length; i++) {
376
+ if (P[i].a < from) continue;
377
+ if (f2.every((w, k) => W[i + k] === w)) {
378
+ if (c === nth) return P[i].a;
379
+ c++;
380
+ }
381
+ }
382
+ throw new Error("phrase not found: " + phrase);
383
+ };
384
+ };
385
+ var SceneFrame = ({ t, a, b, n, title, children }) => {
386
+ const T = useTheme();
387
+ if (t < a - 0.1 || t > b + 0.1) return null;
388
+ return /* @__PURE__ */ jsxs2(AbsoluteFill2, { style: { opacity: fadeWindow(t, a, b) }, children: [
389
+ /* @__PURE__ */ jsxs2("div", { style: { position: "absolute", left: 100, top: 46, display: "flex", alignItems: "center", gap: 18, fontFamily: T.font, fontSize: 30, letterSpacing: 4, color: T.accent, whiteSpace: "nowrap" }, children: [
390
+ /* @__PURE__ */ jsx3("span", { style: { background: T.accent, color: T.background, borderRadius: 12, padding: "4px 16px" }, children: n }),
391
+ title
392
+ ] }),
393
+ children
394
+ ] });
395
+ };
396
+ var splitGroups = (P) => {
397
+ const g = [];
398
+ let cur = [];
399
+ P.forEach((p) => {
400
+ cur.push(p);
401
+ if (/[.!?:]$/.test(p.t) || cur.length >= 8 || /[,;]$/.test(p.t) && cur.length >= 5) {
402
+ g.push(cur);
403
+ cur = [];
404
+ }
405
+ });
406
+ if (cur.length) g.push(cur);
407
+ return g;
408
+ };
409
+ var Captions = ({ t, G }) => {
410
+ const T = useTheme();
411
+ let group = null;
412
+ for (const g of G) if (t >= g[0].a - 0.05 && t <= g[g.length - 1].b + 0.4) group = g;
413
+ if (!group) return null;
414
+ return /* @__PURE__ */ jsx3("div", { style: { position: "absolute", left: 160, right: 160, bottom: 56, textAlign: "center", fontFamily: T.font, fontSize: 46, lineHeight: 1.2, textShadow: "0 3px 12px rgba(0,0,0,.9)" }, children: group.map((p, i) => /* @__PURE__ */ jsxs2("span", { style: { color: t >= p.a && t < p.b + 0.05 ? T.accent : T.text }, children: [
415
+ p.t,
416
+ i < group.length - 1 ? " " : ""
417
+ ] }, i)) });
418
+ };
419
+ var EndcardScreen = ({ f: f2, t }) => {
420
+ const T = useTheme();
421
+ const { fps } = useVideoConfig2();
422
+ const k = (delay) => spring2({ frame: (t - f2.a - delay) * fps, fps, config: { damping: 14, stiffness: 140 } });
423
+ const op = interpolate3(t, [f2.a, f2.a + 0.5], [0, 1], clamp2);
424
+ return /* @__PURE__ */ jsxs2(AbsoluteFill2, { style: { opacity: op, background: `radial-gradient(circle at 50% 40%, ${T.box} 0%, ${T.background} 75%)`, alignItems: "center", justifyContent: "center", fontFamily: T.font, textAlign: "center", paddingBottom: 90 }, children: [
425
+ /* @__PURE__ */ jsx3("div", { style: { width: 230, height: 230, borderRadius: 44, overflow: "hidden", border: `8px solid ${f2.image ? "#3a3566" : T.accent}`, background: f2.image ? "#1c1a33" : void 0, boxShadow: `0 0 70px ${rgba(T.accent, 0.3)}`, transform: `scale(${0.6 + 0.4 * k(0)})`, opacity: Math.min(1, k(0) * 1.5) }, children: /* @__PURE__ */ jsx3(Img2, { src: staticFile2(f2.image ?? T.avatar), style: { width: 230, height: 230, imageRendering: "pixelated", objectFit: "contain" } }) }),
426
+ f2.text ? /* @__PURE__ */ jsx3("div", { style: { marginTop: 44, fontSize: 104, fontWeight: 900, color: T.accent, opacity: Math.max(0, k(0.15)), transform: `translateY(${(1 - Math.max(0, k(0.15))) * 24}px)` }, children: f2.text }) : null,
427
+ /* @__PURE__ */ jsx3("div", { style: { marginTop: 28, fontSize: 54, fontWeight: 900, color: T.text, opacity: Math.max(0, k(0.3)) }, children: T.handle }),
428
+ f2.link ? /* @__PURE__ */ jsx3("div", { style: { marginTop: 18, fontSize: 38, fontFamily: T.mono, color: T.muted, opacity: Math.max(0, k(0.45)) }, children: f2.link }) : null
429
+ ] });
430
+ };
431
+ var Long = ({ d }) => {
432
+ const T = useTheme();
433
+ const t = useCurrentFrame2() / FPS;
434
+ const G = splitGroups(d.words);
435
+ return /* @__PURE__ */ jsxs2(AbsoluteFill2, { style: { background: T.background, color: T.text, fontFamily: T.font }, children: [
436
+ /* @__PURE__ */ jsx3(AbsoluteFill2, { style: { background: `radial-gradient(1200px 700px at 70% 20%, ${rgba(T.accent, 0.07)}, transparent 70%)` } }),
437
+ /* @__PURE__ */ jsx3(Audio2, { src: staticFile2(d.voice) }),
438
+ d.scenes.map((c) => /* @__PURE__ */ jsx3(SceneFrame, { t, a: c.a, b: c.b, n: c.n, title: c.title, children: /* @__PURE__ */ jsx3(c.C, { t }) }, c.n)),
439
+ d.endcard && t >= d.endcard.a ? /* @__PURE__ */ jsx3(EndcardScreen, { f: d.endcard, t }) : null,
440
+ /* @__PURE__ */ jsx3(Captions, { t, G }),
441
+ /* @__PURE__ */ jsx3("div", { style: { position: "absolute", left: 0, top: 0, height: 8, width: `${t / d.durS * 100}%`, background: T.accent } })
442
+ ] });
443
+ };
444
+ export {
445
+ Background,
446
+ DEFAULT_THEME,
447
+ Episode,
448
+ FPS,
449
+ Label,
450
+ Long,
451
+ ThemeProvider,
452
+ appear,
453
+ clamp,
454
+ fadeWindow,
455
+ fmt,
456
+ groupWords,
457
+ makeAt,
458
+ makeAtGlobal,
459
+ norm,
460
+ pop,
461
+ ramp,
462
+ rgba,
463
+ themeFromBrand,
464
+ useTheme
465
+ };
package/dist/long.d.ts ADDED
@@ -0,0 +1,39 @@
1
+ import type { Word } from "./word.js";
2
+ /**
3
+ * LONG ENGINE (16:9, several minutes). A long video is a list of scenes in GLOBAL time: each scene receives `t` (s since the start of the video)
4
+ * and ties itself to the voice with `makeAtGlobal(WORDS)(phrase, nth, from)`. The engine handles the background, the voice, the scene header (number + title),
5
+ * grouped captions and the progress bar. Scenes are project TSX, built with the parts of `@praeko/studio-kit`.
6
+ */
7
+ export type LongScene = {
8
+ n: string;
9
+ title: string;
10
+ a: number;
11
+ b: number;
12
+ C: React.FC<{
13
+ t: number;
14
+ }>;
15
+ };
16
+ /** Optional end card: avatar, text (e.g. the signature), brand handle and link. Starts at `a` (s) and stays until the end. */
17
+ export type LongEndcard = {
18
+ a: number;
19
+ text?: string;
20
+ link?: string;
21
+ image?: string;
22
+ };
23
+ export type LongData = {
24
+ id: string;
25
+ durS: number;
26
+ voice: string;
27
+ words: Word[];
28
+ scenes: LongScene[];
29
+ width?: number;
30
+ height?: number;
31
+ endcard?: LongEndcard;
32
+ };
33
+ /** Fades in and out over `d` s at the ends of the window [a, b]. */
34
+ export declare const fadeWindow: (t: number, a: number, b: number, d?: number) => number;
35
+ /** Global anchor: `at("phrase", nth, from)` = instant (s) at which the phrase starts; `from` ignores occurrences before `from` s. Fails loudly if not found. */
36
+ export declare const makeAtGlobal: (P: Word[]) => (phrase: string, nth?: number, from?: number) => number;
37
+ export declare const Long: React.FC<{
38
+ d: LongData;
39
+ }>;
@@ -0,0 +1,6 @@
1
+ import type { Theme } from "./theme.js";
2
+ export declare const ThemeProvider: React.FC<{
3
+ theme: Theme;
4
+ children: React.ReactNode;
5
+ }>;
6
+ export declare const useTheme: () => Theme;
@@ -0,0 +1,37 @@
1
+ /** Brand tokens. Everything that is a color, font, name or avatar comes from here; the engine and the kit know no brand. */
2
+ export type Theme = {
3
+ background: string;
4
+ coverBackground: string;
5
+ accent: string;
6
+ text: string;
7
+ muted: string;
8
+ ok: string;
9
+ error: string;
10
+ info: string;
11
+ extra: string;
12
+ box: string;
13
+ longBox?: string;
14
+ code: string;
15
+ font: string;
16
+ mono: string;
17
+ handle: string;
18
+ avatar: string;
19
+ /** Mascot image path template under public/; `{name}` is replaced. Default `mascots/{name}.png`. */
20
+ mascotPath?: string;
21
+ };
22
+ export declare const DEFAULT_THEME: Theme;
23
+ type BrandLike = {
24
+ colors?: Partial<Theme>;
25
+ fonts?: {
26
+ title?: string;
27
+ mono?: string;
28
+ };
29
+ handle?: string;
30
+ avatar?: string;
31
+ mascotPath?: string;
32
+ };
33
+ /** Reads a project's `brand.json` (missing fields use the default theme). */
34
+ export declare const themeFromBrand: (m: BrandLike) => Theme;
35
+ /** "#f4b942" + 0.16 -> "rgba(244,185,66,0.16)". */
36
+ export declare const rgba: (hex: string, a: number) => string;
37
+ export {};
package/dist/util.d.ts ADDED
@@ -0,0 +1,18 @@
1
+ import type { Word } from "./word.js";
2
+ export declare const FPS = 30;
3
+ export declare const clamp: {
4
+ readonly extrapolateLeft: "clamp";
5
+ readonly extrapolateRight: "clamp";
6
+ };
7
+ /** 0 -> 1 from instant `q` (s), with a soft ease-out. */
8
+ export declare const appear: (t: number, q: number, d?: number) => number;
9
+ export declare const ramp: (t: number, a: number, b: number) => number;
10
+ export declare const pop: (t: number, q: number) => number;
11
+ /** Number with "." as thousands separator (pt-BR style): 13500 -> "13.500". */
12
+ export declare const fmt: (n: number) => string;
13
+ export declare const norm: (s: string) => string;
14
+ /**
15
+ * Phrase anchors: `makeAt(words, starts)(i)(phrase, n)` returns the LOCAL instant (s since the start of scene i) at which the phrase
16
+ * starts in the voice. Fails loudly if the phrase does not exist (the aligner sometimes writes numbers as digits).
17
+ */
18
+ export declare const makeAt: (P: Word[], S: number[]) => (i: number) => (phrase: string, nth?: number) => number;
package/dist/word.d.ts ADDED
@@ -0,0 +1,6 @@
1
+ /** A spoken word with its timing: `t` text, `a` start (s), `b` end (s). */
2
+ export type Word = {
3
+ t: string;
4
+ a: number;
5
+ b: number;
6
+ };
package/package.json CHANGED
@@ -1,6 +1,63 @@
1
1
  {
2
2
  "name": "@praeko/studio-core",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.0",
4
+ "type": "module",
5
+ "description": "The Praeko Studio engine: voice-driven episodes with timed words, word-by-word captions, editing hits, outro and brand theme (Remotion parts). A library for projects created with `praeko studio init`.",
6
+ "license": "MIT",
7
+ "keywords": [
8
+ "praeko",
9
+ "video",
10
+ "shorts",
11
+ "remotion",
12
+ "captions",
13
+ "engine"
14
+ ],
15
+ "homepage": "https://praeko.app/docs",
16
+ "bugs": {
17
+ "url": "https://praeko.app/docs"
18
+ },
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "git+https://github.com/Praeko/praeko-js.git",
22
+ "directory": "studio/packages/core"
23
+ },
24
+ "main": "./dist/index.js",
25
+ "types": "./dist/index.d.ts",
26
+ "exports": {
27
+ ".": {
28
+ "types": "./dist/index.d.ts",
29
+ "default": "./dist/index.js"
30
+ },
31
+ "./package.json": "./package.json"
32
+ },
33
+ "files": [
34
+ "dist",
35
+ "README.md",
36
+ "CHANGELOG.md",
37
+ "LICENSE",
38
+ "LICENSES.md"
39
+ ],
40
+ "sideEffects": false,
41
+ "publishConfig": {
42
+ "access": "public"
43
+ },
44
+ "scripts": {
45
+ "build": "node ../../../scripts/build-studio.mjs core",
46
+ "prepack": "npm run build",
47
+ "typecheck": "tsc --noEmit -p tsconfig.json",
48
+ "test": "node --test test/*.test.ts"
49
+ },
50
+ "peerDependencies": {
51
+ "react": "^19.0.0",
52
+ "remotion": "^4.0.539"
53
+ },
54
+ "engines": {
55
+ "node": ">=22"
56
+ },
57
+ "devDependencies": {
58
+ "@types/react": "19.2.7",
59
+ "react": "19.2.3",
60
+ "react-dom": "19.2.3",
61
+ "remotion": "4.0.539"
62
+ }
63
+ }