framecode 1.0.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.
@@ -0,0 +1,379 @@
1
+ import { Command } from "commander";
2
+ import { bundle } from "@remotion/bundler";
3
+ import { renderMedia, selectComposition } from "@remotion/renderer";
4
+ import { loadConfig } from "../utils/config";
5
+ import { logger } from "../utils/logger";
6
+ import {
7
+ themeSchema,
8
+ animationSchema,
9
+ presetSchema,
10
+ presetDimensions,
11
+ brandSchema,
12
+ } from "../../calculate-metadata/schema";
13
+ import type { Theme } from "../../calculate-metadata/theme";
14
+ import type { BrandConfig } from "../../calculate-metadata/schema";
15
+ import { basename, join } from "path";
16
+ import type { StaticFile } from "../../calculate-metadata/get-files";
17
+ import {
18
+ expandGlobPatterns,
19
+ isInteractiveTerminal,
20
+ scanCurrentDirectory,
21
+ } from "../utils/files";
22
+ import {
23
+ processCode,
24
+ type Animation,
25
+ type StepConfig,
26
+ } from "../utils/process-code";
27
+ import {
28
+ runInteractive,
29
+ createRenderSpinner,
30
+ renderOutro,
31
+ } from "../utils/prompts";
32
+
33
+ const entryPoint = join(import.meta.dir, "../../index.ts");
34
+
35
+ async function readFiles(files: string[]): Promise<StaticFile[]> {
36
+ return Promise.all(
37
+ files.map(async (filePath) => {
38
+ const file = Bun.file(filePath);
39
+ if (!(await file.exists())) {
40
+ throw new Error(`File not found: ${filePath}`);
41
+ }
42
+ return {
43
+ filename: basename(filePath),
44
+ value: await file.text(),
45
+ };
46
+ }),
47
+ );
48
+ }
49
+
50
+ function getImageMimeType(path: string): string {
51
+ const extension = path.toLowerCase().split(".").pop();
52
+
53
+ switch (extension) {
54
+ case "png":
55
+ return "image/png";
56
+ case "jpg":
57
+ case "jpeg":
58
+ return "image/jpeg";
59
+ case "webp":
60
+ return "image/webp";
61
+ case "svg":
62
+ return "image/svg+xml";
63
+ case "gif":
64
+ return "image/gif";
65
+ default:
66
+ return "application/octet-stream";
67
+ }
68
+ }
69
+
70
+ async function normalizeLogoSource(logo: string): Promise<string> {
71
+ if (/^(https?:)?\/\//i.test(logo) || logo.startsWith("data:")) {
72
+ return logo;
73
+ }
74
+
75
+ const file = Bun.file(logo);
76
+ if (!(await file.exists())) {
77
+ logger.warn(`Brand logo not found: ${logo}`);
78
+ return logo;
79
+ }
80
+
81
+ const bytes = await file.arrayBuffer();
82
+ const encoded = Buffer.from(bytes).toString("base64");
83
+ const mimeType = getImageMimeType(logo);
84
+ return `data:${mimeType};base64,${encoded}`;
85
+ }
86
+
87
+ async function normalizeBrand(
88
+ brand?: Partial<BrandConfig>,
89
+ ): Promise<BrandConfig | undefined> {
90
+ if (!brand) {
91
+ return undefined;
92
+ }
93
+
94
+ const logo = brand.logo?.trim();
95
+ const twitter = brand.twitter?.trim();
96
+ const website = brand.website?.trim();
97
+ const accent = brand.accent?.trim();
98
+
99
+ const normalized: BrandConfig = {};
100
+
101
+ if (logo) {
102
+ normalized.logo = await normalizeLogoSource(logo);
103
+ }
104
+
105
+ if (twitter) {
106
+ normalized.twitter = twitter.startsWith("@") ? twitter : `@${twitter}`;
107
+ }
108
+
109
+ if (website) {
110
+ normalized.website = /^(https?:)?\/\//i.test(website)
111
+ ? website
112
+ : `https://${website}`;
113
+ }
114
+
115
+ if (accent) {
116
+ normalized.accent = accent;
117
+ }
118
+
119
+ if (Object.keys(normalized).length === 0) {
120
+ return undefined;
121
+ }
122
+
123
+ return normalized;
124
+ }
125
+
126
+ export const renderCommand = new Command("render")
127
+ .description("Render code files to video")
128
+ .argument("[files...]", "code files or glob patterns")
129
+ .option("-o, --output <path>", "output file path", "output.mp4")
130
+ .option("-t, --theme <name>", "syntax theme")
131
+ .option("-p, --preset <name>", "post|tutorial|square")
132
+ .option("-a, --animation <name>", "morph|typewriter|cascade")
133
+ .option("--cps <number>", "chars per second (typewriter)", "30")
134
+ .option("-f, --fps <number>", "frames per second", "30")
135
+ .option("-c, --config <path>", "config file path")
136
+ .option("-i, --interactive", "launch interactive mode")
137
+ .option("-y, --yes", "skip confirmation prompt")
138
+ .option("--logo <pathOrUrl>", "brand logo image")
139
+ .option("--twitter <handle>", "Twitter/X handle watermark")
140
+ .option("--website <url>", "website watermark")
141
+ .option("--accent <color>", "brand accent color override")
142
+ .option("--no-interactive", "disable interactive mode (for CI)")
143
+ .addHelpText(
144
+ "after",
145
+ `
146
+ Examples:
147
+ $ framecode render code.ts
148
+ $ framecode render "src/**/*.ts"
149
+ $ framecode render "src/*.ts" "!src/*.test.ts"
150
+ $ framecode render src/ --interactive
151
+ $ framecode render -i
152
+ $ framecode render code.ts -y`,
153
+ )
154
+ .action(async (filesArg: string[], options) => {
155
+ const config = await loadConfig(options.config);
156
+ const isCI = !isInteractiveTerminal();
157
+
158
+ let files: string[];
159
+ if (filesArg.length === 0) {
160
+ files = await scanCurrentDirectory();
161
+ if (files.length === 0) {
162
+ logger.error("No code files found in current directory");
163
+ process.exit(1);
164
+ }
165
+ } else {
166
+ files = await expandGlobPatterns(filesArg);
167
+ if (files.length === 0) {
168
+ logger.error("No files matched the provided patterns");
169
+ process.exit(1);
170
+ }
171
+ }
172
+
173
+ const shouldInteractive =
174
+ options.interactive === true ||
175
+ (filesArg.length === 0 && !isCI && options.interactive !== false);
176
+
177
+ let theme: string;
178
+ let preset: string;
179
+ let animation: string;
180
+ let charsPerSecond: number;
181
+ let fps: number;
182
+ let output: string;
183
+ let selectedFiles: string[];
184
+ let useInteractiveProgress = false;
185
+ let interactiveStepConfigs: StepConfig[] | undefined;
186
+ let brand: BrandConfig | undefined;
187
+
188
+ if (shouldInteractive) {
189
+ const result = await runInteractive({
190
+ files,
191
+ theme: options.theme,
192
+ animation: options.animation,
193
+ preset: options.preset,
194
+ output: options.output,
195
+ cps: options.cps ? Number(options.cps) : undefined,
196
+ fps: options.fps ? Number(options.fps) : undefined,
197
+ yes: options.yes,
198
+ config,
199
+ });
200
+
201
+ selectedFiles = result.files;
202
+ theme = result.theme;
203
+ animation = result.animation;
204
+ preset = result.preset;
205
+ output = result.output;
206
+ charsPerSecond = result.cps;
207
+ fps = result.fps;
208
+ interactiveStepConfigs = result.stepConfigs;
209
+ useInteractiveProgress = true;
210
+
211
+ brand = await normalizeBrand({
212
+ logo: options.logo ?? config.brand?.logo,
213
+ twitter: options.twitter ?? config.brand?.twitter,
214
+ website: options.website ?? config.brand?.website,
215
+ accent: options.accent ?? config.brand?.accent,
216
+ });
217
+ } else {
218
+ selectedFiles = files;
219
+ theme = options.theme ?? config.theme ?? "vercel-dark";
220
+ preset = options.preset ?? config.preset ?? "tutorial";
221
+ animation = options.animation ?? config.animation ?? "morph";
222
+ charsPerSecond = Number(options.cps ?? config.charsPerSecond ?? 30);
223
+ fps = Number(options.fps ?? config.fps ?? 30);
224
+ output = options.output;
225
+
226
+ brand = await normalizeBrand({
227
+ logo: options.logo ?? config.brand?.logo,
228
+ twitter: options.twitter ?? config.brand?.twitter,
229
+ website: options.website ?? config.brand?.website,
230
+ accent: options.accent ?? config.brand?.accent,
231
+ });
232
+ }
233
+
234
+ if (!themeSchema.safeParse(theme).success) {
235
+ logger.error(`Invalid theme: ${theme}`);
236
+ logger.info("Run 'framecode themes' to see available themes");
237
+ process.exit(1);
238
+ }
239
+
240
+ if (!animationSchema.safeParse(animation).success) {
241
+ logger.error(`Invalid animation: ${animation}`);
242
+ logger.info("Valid: morph, typewriter, cascade");
243
+ process.exit(1);
244
+ }
245
+
246
+ if (!presetSchema.safeParse(preset).success) {
247
+ logger.error(`Invalid preset: ${preset}`);
248
+ logger.info("Valid: post (9:16), tutorial (16:9), square (1:1)");
249
+ process.exit(1);
250
+ }
251
+
252
+ if (isNaN(charsPerSecond) || charsPerSecond <= 0) {
253
+ logger.error(`Invalid chars-per-second: ${options.cps}`);
254
+ process.exit(1);
255
+ }
256
+
257
+ if (isNaN(fps) || fps <= 0 || fps > 120) {
258
+ logger.error(`Invalid fps: ${options.fps} (must be 1-120)`);
259
+ process.exit(1);
260
+ }
261
+
262
+ if (brand && !brandSchema.safeParse(brand).success) {
263
+ logger.error("Invalid brand configuration");
264
+ logger.info(
265
+ "Accent color must be hex format (#RGB or #RRGGBB) when provided",
266
+ );
267
+ process.exit(1);
268
+ }
269
+
270
+ const filenames = new Set(selectedFiles.map((f) => basename(f)));
271
+ const stepConfigsToValidate = interactiveStepConfigs ?? config.stepConfigs;
272
+
273
+ if (stepConfigsToValidate) {
274
+ for (const stepConfig of stepConfigsToValidate) {
275
+ if (!filenames.has(stepConfig.file)) {
276
+ logger.warn(
277
+ `Step config for "${stepConfig.file}" doesn't match any provided file`,
278
+ );
279
+ }
280
+
281
+ const effectiveAnimation = stepConfig.animation ?? animation;
282
+ if (
283
+ stepConfig.charsPerSecond !== undefined &&
284
+ effectiveAnimation !== "typewriter"
285
+ ) {
286
+ logger.warn(
287
+ `charsPerSecond for "${stepConfig.file}" is only used with typewriter animation`,
288
+ );
289
+ }
290
+ }
291
+ }
292
+
293
+ const staticFiles = await readFiles(selectedFiles);
294
+ const presetDims =
295
+ presetDimensions[preset as keyof typeof presetDimensions];
296
+
297
+ const spinner = useInteractiveProgress ? createRenderSpinner() : null;
298
+
299
+ if (!useInteractiveProgress) {
300
+ logger.info(
301
+ `Rendering ${selectedFiles.length} file(s) with ${theme} theme`,
302
+ );
303
+ logger.info(
304
+ `Preset: ${preset} (${presetDims.width}x${presetDims.height})`,
305
+ );
306
+ }
307
+
308
+ const finalStepConfigs = interactiveStepConfigs ?? config.stepConfigs;
309
+
310
+ spinner?.start("Processing code...");
311
+ const processed = await processCode(
312
+ staticFiles,
313
+ theme as Theme,
314
+ animation as Animation,
315
+ charsPerSecond,
316
+ presetDims.width,
317
+ presetDims.height,
318
+ finalStepConfigs,
319
+ );
320
+
321
+ const inputProps = {
322
+ theme,
323
+ preset,
324
+ animation,
325
+ charsPerSecond,
326
+ brand,
327
+ steps: processed.steps,
328
+ themeColors: processed.themeColors,
329
+ codeWidth: processed.codeWidth,
330
+ };
331
+
332
+ try {
333
+ spinner?.message("Bundling...");
334
+ const bundleLocation = await bundle({
335
+ entryPoint,
336
+ onProgress: () => {},
337
+ });
338
+
339
+ const composition = await selectComposition({
340
+ serveUrl: bundleLocation,
341
+ id: "Main",
342
+ inputProps,
343
+ logLevel: "error",
344
+ onBrowserLog: () => {},
345
+ });
346
+
347
+ spinner?.message("Rendering video...");
348
+ await renderMedia({
349
+ composition: {
350
+ ...composition,
351
+ width: presetDims.width,
352
+ height: presetDims.height,
353
+ fps,
354
+ },
355
+ serveUrl: bundleLocation,
356
+ codec: "h264",
357
+ logLevel: "error",
358
+ onBrowserLog: () => {},
359
+ outputLocation: output,
360
+ inputProps,
361
+ onProgress: () => {},
362
+ });
363
+
364
+ spinner?.stop("Done!");
365
+
366
+ if (useInteractiveProgress) {
367
+ renderOutro(output);
368
+ } else {
369
+ logger.success(`Video saved to ${output}`);
370
+ }
371
+ } catch (error) {
372
+ spinner?.stop("Failed");
373
+ logger.error("Render failed");
374
+ if (error instanceof Error) {
375
+ logger.error(error.message);
376
+ }
377
+ process.exit(1);
378
+ }
379
+ });
@@ -0,0 +1,46 @@
1
+ import { Command } from "commander";
2
+ import chalk from "chalk";
3
+ import {
4
+ themeSchema,
5
+ getThemeColors,
6
+ curatedThemeNames,
7
+ } from "../../calculate-metadata/theme";
8
+
9
+ export const themesCommand = new Command("themes")
10
+ .description("List available themes")
11
+ .action(async () => {
12
+ const themes = themeSchema.options;
13
+ const custom = new Set<string>(curatedThemeNames);
14
+ const builtIns = themes.filter((theme) => custom.has(theme));
15
+ const extra = themes.filter((theme) => !custom.has(theme));
16
+
17
+ console.log(chalk.bold("\nBuilt-in Themes:\n"));
18
+
19
+ for (const theme of builtIns) {
20
+ try {
21
+ const colors = await getThemeColors(theme);
22
+ const preview = chalk.bgHex(colors.background).hex(colors.foreground)(
23
+ ` ${theme} `,
24
+ );
25
+ console.log(` ${preview}`);
26
+ } catch {
27
+ console.log(` ${theme}`);
28
+ }
29
+ }
30
+
31
+ console.log(chalk.bold("\nAll Themes:\n"));
32
+
33
+ for (const theme of extra) {
34
+ try {
35
+ const colors = await getThemeColors(theme);
36
+ const preview = chalk.bgHex(colors.background).hex(colors.foreground)(
37
+ ` ${theme} `,
38
+ );
39
+ console.log(` ${preview}`);
40
+ } catch {
41
+ console.log(` ${theme}`);
42
+ }
43
+ }
44
+
45
+ console.log(chalk.dim(`\nTotal: ${themes.length} themes\n`));
46
+ });
@@ -0,0 +1,18 @@
1
+ #!/usr/bin/env bun
2
+ import { Command } from "commander";
3
+ import { renderCommand } from "./commands/render";
4
+ import { themesCommand } from "./commands/themes";
5
+ import { initCommand } from "./commands/init";
6
+
7
+ const program = new Command();
8
+
9
+ program
10
+ .name("framecode")
11
+ .description("CLI-first video generator that turns code into videos")
12
+ .version("0.1.0");
13
+
14
+ program.addCommand(renderCommand);
15
+ program.addCommand(themesCommand);
16
+ program.addCommand(initCommand);
17
+
18
+ program.parseAsync();
@@ -0,0 +1,83 @@
1
+ import { z } from "zod";
2
+ import { homedir } from "os";
3
+ import { join, dirname } from "path";
4
+ import { mkdir } from "fs/promises";
5
+ import {
6
+ themeSchema,
7
+ animationSchema,
8
+ presetSchema,
9
+ stepConfigSchema,
10
+ brandSchema,
11
+ } from "../../calculate-metadata/schema";
12
+
13
+ const configSchema = z.object({
14
+ theme: themeSchema.optional(),
15
+ preset: presetSchema.optional(),
16
+ animation: animationSchema.optional(),
17
+ charsPerSecond: z.number().int().positive().optional(),
18
+ fps: z.number().int().min(1).max(120).optional(),
19
+ brand: brandSchema.optional(),
20
+ stepConfigs: z.array(stepConfigSchema).optional(),
21
+ });
22
+
23
+ export type Config = z.infer<typeof configSchema>;
24
+
25
+ export async function loadConfig(configPath?: string): Promise<Config> {
26
+ if (configPath) {
27
+ const file = Bun.file(configPath);
28
+ if (!(await file.exists())) {
29
+ console.warn(`Warning: Config file not found: ${configPath}`);
30
+ return {};
31
+ }
32
+ try {
33
+ const json = await file.json();
34
+ return configSchema.parse(json);
35
+ } catch {
36
+ console.warn(`Warning: Failed to parse config file: ${configPath}`);
37
+ return {};
38
+ }
39
+ }
40
+
41
+ const paths = [
42
+ "./framecode.json",
43
+ join(homedir(), ".config", "framecode", "config.json"),
44
+ ];
45
+
46
+ for (const path of paths) {
47
+ try {
48
+ const file = Bun.file(path);
49
+ const json = await file.json();
50
+ return configSchema.parse(json);
51
+ } catch {
52
+ continue;
53
+ }
54
+ }
55
+
56
+ return {};
57
+ }
58
+
59
+ export function getConfigPath(local: boolean): string {
60
+ if (local) {
61
+ return "./framecode.json";
62
+ }
63
+ return join(homedir(), ".config", "framecode", "config.json");
64
+ }
65
+
66
+ export async function saveConfig(
67
+ config: Partial<Config>,
68
+ local: boolean = false,
69
+ ): Promise<boolean> {
70
+ const configPath = getConfigPath(local);
71
+
72
+ try {
73
+ await mkdir(dirname(configPath), { recursive: true });
74
+
75
+ const existingConfig = await loadConfig(configPath);
76
+ const merged = { ...existingConfig, ...config };
77
+
78
+ await Bun.write(configPath, JSON.stringify(merged, null, 2));
79
+ return true;
80
+ } catch {
81
+ return false;
82
+ }
83
+ }
@@ -0,0 +1,156 @@
1
+ import { Glob } from "bun";
2
+
3
+ const CODE_EXTENSIONS =
4
+ "*.{ts,tsx,js,jsx,mjs,cjs,py,rb,go,rs,java,kt,swift,c,cpp,h,hpp,cs,php,vue,svelte,sh,bash,zsh,json,yaml,yml,toml,md,mdx,sql,graphql,prisma,dockerfile}";
5
+
6
+ const DEFAULT_IGNORES = [
7
+ "node_modules",
8
+ "dist",
9
+ "build",
10
+ "out",
11
+ ".git",
12
+ ".next",
13
+ ".turbo",
14
+ ".cache",
15
+ "coverage",
16
+ ".remotion",
17
+ ];
18
+
19
+ async function loadGitignorePatterns(): Promise<string[]> {
20
+ const gitignorePath = ".gitignore";
21
+ const file = Bun.file(gitignorePath);
22
+
23
+ if (!(await file.exists())) {
24
+ return [];
25
+ }
26
+
27
+ const content = await file.text();
28
+ return content
29
+ .split("\n")
30
+ .map((line) => line.trim())
31
+ .filter((line) => line && !line.startsWith("#"));
32
+ }
33
+
34
+ function shouldIgnore(filePath: string, ignorePatterns: string[]): boolean {
35
+ for (const pattern of ignorePatterns) {
36
+ if (pattern.endsWith("/")) {
37
+ const dir = pattern.slice(0, -1);
38
+ if (
39
+ filePath.startsWith(dir + "/") ||
40
+ filePath.includes("/" + dir + "/")
41
+ ) {
42
+ return true;
43
+ }
44
+ } else if (pattern.includes("/")) {
45
+ const glob = new Glob(pattern);
46
+ if (glob.match(filePath)) return true;
47
+ } else {
48
+ if (
49
+ filePath === pattern ||
50
+ filePath.startsWith(pattern + "/") ||
51
+ filePath.includes("/" + pattern + "/") ||
52
+ filePath.includes("/" + pattern)
53
+ ) {
54
+ return true;
55
+ }
56
+ const glob = new Glob(`**/${pattern}`);
57
+ if (glob.match(filePath)) return true;
58
+ }
59
+ }
60
+ return false;
61
+ }
62
+
63
+ async function getIgnorePatterns(): Promise<string[]> {
64
+ const gitignore = await loadGitignorePatterns();
65
+ return [...DEFAULT_IGNORES, ...gitignore];
66
+ }
67
+
68
+ export async function expandGlobPatterns(
69
+ patterns: string[],
70
+ ): Promise<string[]> {
71
+ const files: string[] = [];
72
+ const userExcludes: string[] = [];
73
+ const ignorePatterns = await getIgnorePatterns();
74
+
75
+ for (const pattern of patterns) {
76
+ if (pattern.startsWith("!")) {
77
+ userExcludes.push(pattern.slice(1));
78
+ continue;
79
+ }
80
+
81
+ const isDirectory = await isDir(pattern);
82
+ if (isDirectory) {
83
+ const dirGlob = new Glob(
84
+ `${pattern.replace(/\/$/, "")}/**/${CODE_EXTENSIONS}`,
85
+ );
86
+ for await (const file of dirGlob.scan({ cwd: ".", onlyFiles: true })) {
87
+ if (!shouldIgnore(file, ignorePatterns)) {
88
+ files.push(file);
89
+ }
90
+ }
91
+ continue;
92
+ }
93
+
94
+ const hasGlobChars = /[*?[\]{}]/.test(pattern);
95
+ if (hasGlobChars) {
96
+ const glob = new Glob(pattern);
97
+ for await (const file of glob.scan({ cwd: ".", onlyFiles: true })) {
98
+ if (!shouldIgnore(file, ignorePatterns)) {
99
+ files.push(file);
100
+ }
101
+ }
102
+ } else {
103
+ files.push(pattern);
104
+ }
105
+ }
106
+
107
+ const uniqueFiles = [...new Set(files)];
108
+
109
+ if (userExcludes.length === 0) {
110
+ return uniqueFiles;
111
+ }
112
+
113
+ return uniqueFiles.filter((file) => {
114
+ return !userExcludes.some((excludePattern) => {
115
+ const glob = new Glob(excludePattern);
116
+ return glob.match(file);
117
+ });
118
+ });
119
+ }
120
+
121
+ async function isDir(path: string): Promise<boolean> {
122
+ try {
123
+ return (await Bun.file(path).stat()).isDirectory();
124
+ } catch {
125
+ return false;
126
+ }
127
+ }
128
+
129
+ export function isInteractiveTerminal(): boolean {
130
+ return process.stdout.isTTY === true && process.stdin.isTTY === true;
131
+ }
132
+
133
+ const MAX_SCAN_DEPTH = 5;
134
+
135
+ function getDepth(filePath: string): number {
136
+ return filePath.split("/").length - 1;
137
+ }
138
+
139
+ export async function scanCurrentDirectory(): Promise<string[]> {
140
+ const glob = new Glob(`**/${CODE_EXTENSIONS}`);
141
+ const files: string[] = [];
142
+ const ignorePatterns = await getIgnorePatterns();
143
+
144
+ for await (const file of glob.scan({
145
+ cwd: ".",
146
+ onlyFiles: true,
147
+ dot: false,
148
+ })) {
149
+ if (getDepth(file) > MAX_SCAN_DEPTH) continue;
150
+ if (!shouldIgnore(file, ignorePatterns)) {
151
+ files.push(file);
152
+ }
153
+ }
154
+
155
+ return files.sort();
156
+ }