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.
package/README.md ADDED
@@ -0,0 +1,97 @@
1
+ # FrameCode
2
+
3
+ [![npm version](https://img.shields.io/npm/v/framecode)](https://www.npmjs.com/package/framecode)
4
+ [![license](https://img.shields.io/badge/license-MIT-blue.svg)](./LICENSE)
5
+ [![stars](https://img.shields.io/github/stars/esau-morais/framecode?style=social)](https://github.com/esau-morais/framecode)
6
+
7
+ CLI-first video generator that turns code snippets into social-ready videos with Remotion + Shiki.
8
+
9
+ ## Features
10
+
11
+ - 5 built-in themes: `vercel-dark`, `story-gradients`, `retro-terminal`, `github-dark`, `synthwave-84`
12
+ - Animations: `morph`, `typewriter`, `cascade` (with per-file overrides)
13
+ - Presets: `post` (9:16), `tutorial` (16:9), `square` (1:1)
14
+ - Optional brand kit overlay: logo, Twitter/X handle, website, accent color
15
+ - Interactive render wizard or fully scripted CLI flow
16
+
17
+ ## Quick Start
18
+
19
+ ```bash
20
+ bunx framecode init --local
21
+ framecode render "~/projects/demo/src/**/*.ts" -t vercel-dark -a cascade -p tutorial
22
+ ```
23
+
24
+ ## Installation
25
+
26
+ ```bash
27
+ bun add -g framecode
28
+ framecode --help
29
+ ```
30
+
31
+ ## Usage
32
+
33
+ ```bash
34
+ # Example 1: Single file
35
+ framecode render "~/projects/demo/src/intro.ts" -t story-gradients -a typewriter
36
+
37
+ # Example 2: Multi-file with per-step overrides in config
38
+ framecode render "~/projects/demo/src/**/*.ts" "!**/*.test.ts" -c framecode.json
39
+
40
+ # Example 3: Add brand kit on render
41
+ framecode render "~/projects/demo/src/main.ts" \
42
+ --logo "~/projects/demo/public/logo.png" \
43
+ --twitter framecodehq \
44
+ --website framecode.dev \
45
+ --accent "#ff8800"
46
+ ```
47
+
48
+ ## Config
49
+
50
+ Create config with:
51
+
52
+ ```bash
53
+ framecode init --local
54
+ ```
55
+
56
+ Minimal `framecode.json`:
57
+
58
+ ```json
59
+ {
60
+ "$schema": "https://raw.githubusercontent.com/esau-morais/framecode/main/framecode.schema.json",
61
+ "theme": "vercel-dark",
62
+ "preset": "tutorial",
63
+ "animation": "cascade",
64
+ "stepConfigs": [{ "file": "intro.ts", "animation": "typewriter", "charsPerSecond": 42 }],
65
+ "brand": { "twitter": "@framecodehq", "accent": "#ff8800" }
66
+ }
67
+ ```
68
+
69
+ ## Themes
70
+
71
+ ```bash
72
+ framecode themes
73
+ ```
74
+
75
+ Shows built-in themes first, then the full Shiki theme catalog.
76
+
77
+ ## Animations
78
+
79
+ - `morph`: smooth transition between snapshots
80
+ - `typewriter`: character reveal with cursor
81
+ - `cascade`: line-by-line reveal with stagger
82
+
83
+ ## Roadmap
84
+
85
+ Current focus: Phase 1 MVP CLI completion (themes, brand kit, docs polish), then monorepo + web in Phase 2.
86
+
87
+ ## Contributing
88
+
89
+ ```bash
90
+ bun install
91
+ bun run lint
92
+ bun run typecheck
93
+ ```
94
+
95
+ ## License
96
+
97
+ MIT
@@ -0,0 +1,154 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft-07/schema",
3
+ "type": "object",
4
+ "definitions": {
5
+ "animation": {
6
+ "type": "string",
7
+ "enum": [
8
+ "morph",
9
+ "typewriter",
10
+ "cascade"
11
+ ],
12
+ "default": "morph"
13
+ },
14
+ "stepConfig": {
15
+ "type": "object",
16
+ "properties": {
17
+ "file": {
18
+ "type": "string",
19
+ "description": "Filename to match (exact match)"
20
+ },
21
+ "animation": {
22
+ "$ref": "#/definitions/animation",
23
+ "description": "Animation style override for this step"
24
+ },
25
+ "charsPerSecond": {
26
+ "type": "integer",
27
+ "minimum": 1,
28
+ "description": "Characters per second for typewriter animation"
29
+ }
30
+ },
31
+ "required": [
32
+ "file"
33
+ ],
34
+ "additionalProperties": false
35
+ },
36
+ "brand": {
37
+ "type": "object",
38
+ "properties": {
39
+ "logo": {
40
+ "type": "string",
41
+ "description": "Logo source URL, data URL, or local file path"
42
+ },
43
+ "twitter": {
44
+ "type": "string",
45
+ "description": "Twitter/X handle watermark"
46
+ },
47
+ "website": {
48
+ "type": "string",
49
+ "description": "Website watermark"
50
+ },
51
+ "accent": {
52
+ "type": "string",
53
+ "pattern": "^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$",
54
+ "description": "Brand accent color (#RGB or #RRGGBB)"
55
+ }
56
+ },
57
+ "additionalProperties": false
58
+ }
59
+ },
60
+ "properties": {
61
+ "theme": {
62
+ "type": "string",
63
+ "enum": [
64
+ "vercel-dark",
65
+ "story-gradients",
66
+ "retro-terminal",
67
+ "github-dark",
68
+ "github-light",
69
+ "nord",
70
+ "dracula",
71
+ "monokai",
72
+ "tokyo-night",
73
+ "catppuccin-mocha",
74
+ "rose-pine",
75
+ "synthwave-84",
76
+ "material-theme-darker",
77
+ "one-dark-pro",
78
+ "vitesse-dark",
79
+ "vitesse-light",
80
+ "min-dark",
81
+ "min-light",
82
+ "night-owl",
83
+ "ayu-dark",
84
+ "gruvbox-dark-medium",
85
+ "gruvbox-light-medium",
86
+ "everforest-dark",
87
+ "everforest-light",
88
+ "kanagawa-wave",
89
+ "rose-pine-moon",
90
+ "rose-pine-dawn",
91
+ "catppuccin-latte",
92
+ "catppuccin-frappe",
93
+ "catppuccin-macchiato",
94
+ "github-dark-dimmed",
95
+ "github-dark-high-contrast",
96
+ "github-light-high-contrast",
97
+ "dark-plus",
98
+ "light-plus",
99
+ "poimandres",
100
+ "slack-dark",
101
+ "one-light",
102
+ "material-theme-lighter",
103
+ "material-theme-ocean",
104
+ "material-theme-palenight",
105
+ "dracula-soft",
106
+ "houston"
107
+ ],
108
+ "default": "vercel-dark"
109
+ },
110
+ "preset": {
111
+ "type": "string",
112
+ "enum": [
113
+ "post",
114
+ "tutorial",
115
+ "square"
116
+ ],
117
+ "default": "tutorial"
118
+ },
119
+ "animation": {
120
+ "$ref": "#/definitions/animation"
121
+ },
122
+ "charsPerSecond": {
123
+ "type": "integer",
124
+ "minimum": 1,
125
+ "default": 30,
126
+ "description": "Global characters per second for typewriter animation"
127
+ },
128
+ "fps": {
129
+ "type": "integer",
130
+ "minimum": 1,
131
+ "maximum": 120,
132
+ "default": 30
133
+ },
134
+ "width": {
135
+ "type": "integer",
136
+ "minimum": 1
137
+ },
138
+ "height": {
139
+ "type": "integer",
140
+ "minimum": 1
141
+ },
142
+ "brand": {
143
+ "$ref": "#/definitions/brand"
144
+ },
145
+ "stepConfigs": {
146
+ "type": "array",
147
+ "items": {
148
+ "$ref": "#/definitions/stepConfig"
149
+ },
150
+ "description": "Per-step animation configuration overrides"
151
+ }
152
+ },
153
+ "additionalProperties": false
154
+ }
package/package.json ADDED
@@ -0,0 +1,63 @@
1
+ {
2
+ "name": "framecode",
3
+ "version": "1.0.0",
4
+ "private": false,
5
+ "description": "CLI-first video generator that turns code snippets into videos",
6
+ "license": "MIT",
7
+ "author": "Esau Morais <esaumorais7@gmail.com> (https://emots.dev)",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "https://github.com/esau-morais/framecode.git"
11
+ },
12
+ "bin": {
13
+ "framecode": "src/cli/index.ts"
14
+ },
15
+ "files": [
16
+ "framecode.schema.json",
17
+ "src"
18
+ ],
19
+ "type": "module",
20
+ "module": "src/index.ts",
21
+ "publishConfig": {
22
+ "access": "public"
23
+ },
24
+ "scripts": {
25
+ "dev": "remotion studio",
26
+ "build": "remotion bundle",
27
+ "upgrade": "remotion upgrade",
28
+ "typecheck": "tsc --noEmit",
29
+ "lint": "oxlint",
30
+ "lint:fix": "oxfmt",
31
+ "prepublishOnly": "bun run typecheck && bun run lint && bun run build"
32
+ },
33
+ "dependencies": {
34
+ "@clack/prompts": "^1.0.0-alpha.9",
35
+ "@remotion/bundler": "4.0.399",
36
+ "@remotion/cli": "4.0.399",
37
+ "@remotion/google-fonts": "4.0.399",
38
+ "@remotion/layout-utils": "4.0.399",
39
+ "@remotion/renderer": "4.0.399",
40
+ "@remotion/studio": "4.0.399",
41
+ "@remotion/tailwind-v4": "^4.0.399",
42
+ "chalk": "^5.6.2",
43
+ "codehike": "1.0.7",
44
+ "commander": "^14.0.2",
45
+ "polished": "4.3.1",
46
+ "react": "19.2.3",
47
+ "react-dom": "19.2.3",
48
+ "remotion": "4.0.399",
49
+ "shiki": "^3.20.0",
50
+ "tailwindcss": "^4.1.18",
51
+ "twoslash": "^0.3.6",
52
+ "zod": "3.22.3"
53
+ },
54
+ "devDependencies": {
55
+ "@remotion/eslint-plugin": "^4.0.399",
56
+ "@types/bun": "^1.3.5",
57
+ "@types/react": "19.2.7",
58
+ "@types/web": "0.0.312",
59
+ "oxfmt": "^0.22.0",
60
+ "oxlint": "^1.36.0",
61
+ "typescript": "5.9.3"
62
+ }
63
+ }
@@ -0,0 +1,129 @@
1
+ import React, { useMemo } from "react";
2
+ import { Img, staticFile } from "remotion";
3
+ import { horizontalPadding } from "./font";
4
+ import { useThemeColors } from "./calculate-metadata/theme";
5
+ import type { BrandConfig } from "./calculate-metadata/schema";
6
+
7
+ function normalizeHandle(handle?: string): string | null {
8
+ if (!handle) {
9
+ return null;
10
+ }
11
+
12
+ const trimmed = handle.trim();
13
+ if (!trimmed) {
14
+ return null;
15
+ }
16
+
17
+ return trimmed.startsWith("@") ? trimmed : `@${trimmed}`;
18
+ }
19
+
20
+ function normalizeWebsite(website?: string): string | null {
21
+ if (!website) {
22
+ return null;
23
+ }
24
+
25
+ const trimmed = website.trim();
26
+ if (!trimmed) {
27
+ return null;
28
+ }
29
+
30
+ return trimmed.replace(/^https?:\/\//i, "");
31
+ }
32
+
33
+ function resolveLogoSource(logo: string): string {
34
+ if (/^(https?:)?\/\//i.test(logo) || logo.startsWith("data:")) {
35
+ return logo;
36
+ }
37
+
38
+ const normalizedPath = logo.replaceAll("\\", "/").replace(/^\//, "");
39
+ return staticFile(normalizedPath);
40
+ }
41
+
42
+ export function BrandOverlay({ brand }: { readonly brand?: BrandConfig }) {
43
+ const themeColors = useThemeColors();
44
+
45
+ const handle = normalizeHandle(brand?.twitter);
46
+ const website = normalizeWebsite(brand?.website);
47
+ const logo = brand?.logo?.trim() ? resolveLogoSource(brand.logo) : null;
48
+
49
+ const hasTopLeft = Boolean(logo || website);
50
+ const hasBottomRight = Boolean(handle);
51
+
52
+ if (!hasTopLeft && !hasBottomRight) {
53
+ return null;
54
+ }
55
+
56
+ const accent = brand?.accent ?? themeColors.icon.foreground;
57
+
58
+ const chipStyle = useMemo<React.CSSProperties>(
59
+ () => ({
60
+ border: `1px solid ${accent}`,
61
+ backgroundColor: "rgba(0, 0, 0, 0.58)",
62
+ color: themeColors.foreground,
63
+ borderRadius: 999,
64
+ padding: "8px 14px",
65
+ fontSize: 26,
66
+ lineHeight: 1,
67
+ letterSpacing: 0.2,
68
+ fontWeight: 600,
69
+ textShadow: "0 2px 8px rgba(0, 0, 0, 0.5)",
70
+ }),
71
+ [accent, themeColors.foreground],
72
+ );
73
+
74
+ return (
75
+ <>
76
+ {hasTopLeft ? (
77
+ <div
78
+ style={{
79
+ position: "absolute",
80
+ top: 32,
81
+ left: horizontalPadding,
82
+ display: "flex",
83
+ alignItems: "center",
84
+ gap: 12,
85
+ pointerEvents: "none",
86
+ }}
87
+ >
88
+ {logo ? (
89
+ <div
90
+ style={{
91
+ width: 52,
92
+ height: 52,
93
+ borderRadius: 12,
94
+ border: `1px solid ${accent}`,
95
+ backgroundColor: "rgba(0, 0, 0, 0.58)",
96
+ display: "flex",
97
+ alignItems: "center",
98
+ justifyContent: "center",
99
+ overflow: "hidden",
100
+ }}
101
+ >
102
+ <Img
103
+ src={logo}
104
+ style={{
105
+ width: "80%",
106
+ height: "80%",
107
+ objectFit: "contain",
108
+ }}
109
+ />
110
+ </div>
111
+ ) : null}
112
+ {website ? <span style={chipStyle}>{website}</span> : null}
113
+ </div>
114
+ ) : null}
115
+ {hasBottomRight ? (
116
+ <div
117
+ style={{
118
+ position: "absolute",
119
+ right: horizontalPadding,
120
+ bottom: 32,
121
+ pointerEvents: "none",
122
+ }}
123
+ >
124
+ <span style={chipStyle}>{handle}</span>
125
+ </div>
126
+ ) : null}
127
+ </>
128
+ );
129
+ }
@@ -0,0 +1,88 @@
1
+ import { useMemo, useCallback } from "react";
2
+ import { interpolate, spring, useCurrentFrame, useVideoConfig } from "remotion";
3
+ import {
4
+ Pre,
5
+ HighlightedCode,
6
+ AnnotationHandler,
7
+ InnerLine,
8
+ } from "codehike/code";
9
+ import { fontFamily, fontSize as baseFontSize, tabSize } from "./font";
10
+ import { useMarkHandler } from "./annotations/Mark";
11
+ import { useFocusHandler } from "./annotations/Focus";
12
+
13
+ type TimingMode = "spring" | "linear";
14
+
15
+ export function CascadeTransition({
16
+ code,
17
+ staggerDelay = 3,
18
+ timing = "spring",
19
+ fontSize,
20
+ }: {
21
+ readonly code: HighlightedCode;
22
+ readonly staggerDelay?: number;
23
+ readonly timing?: TimingMode;
24
+ readonly fontSize?: number;
25
+ }) {
26
+ const frame = useCurrentFrame();
27
+ const { fps } = useVideoConfig();
28
+
29
+ const getLineProgress = useCallback(
30
+ (lineNum: number) => {
31
+ const delay = (lineNum - 1) * staggerDelay;
32
+ return timing === "spring"
33
+ ? spring({ frame, fps, delay, config: { damping: 20, stiffness: 100 } })
34
+ : interpolate(frame, [delay, delay + 15], [0, 1], {
35
+ extrapolateLeft: "clamp",
36
+ extrapolateRight: "clamp",
37
+ });
38
+ },
39
+ [frame, fps, staggerDelay, timing],
40
+ );
41
+
42
+ const cascadeHandler: AnnotationHandler = useMemo(
43
+ () => ({
44
+ name: "cascade",
45
+ Line: (props) => {
46
+ const progress = getLineProgress(props.lineNumber);
47
+ const translateY = interpolate(progress, [0, 1], [10, 0]);
48
+
49
+ return (
50
+ <InnerLine
51
+ merge={props}
52
+ style={{
53
+ opacity: progress,
54
+ transform: `translate3d(0, ${translateY}px, 0)`,
55
+ display: "block",
56
+ willChange: "transform, opacity",
57
+ backfaceVisibility: "hidden",
58
+ }}
59
+ />
60
+ );
61
+ },
62
+ }),
63
+ [getLineProgress],
64
+ );
65
+
66
+ const markHandler = useMarkHandler(getLineProgress);
67
+
68
+ const focusHandler = useFocusHandler(code);
69
+
70
+ const handlers = useMemo(
71
+ () => [cascadeHandler, markHandler, focusHandler],
72
+ [cascadeHandler, markHandler, focusHandler],
73
+ );
74
+
75
+ const style: React.CSSProperties = useMemo(
76
+ () => ({
77
+ fontSize: fontSize ?? baseFontSize,
78
+ lineHeight: 1.5,
79
+ fontFamily,
80
+ tabSize,
81
+ margin: 0,
82
+ whiteSpace: "pre",
83
+ }),
84
+ [fontSize],
85
+ );
86
+
87
+ return <Pre code={code} handlers={handlers} style={style} />;
88
+ }
@@ -0,0 +1,103 @@
1
+ import { Easing, interpolate, useDelayRender, useCurrentFrame } from "remotion";
2
+ import { Pre, HighlightedCode, AnnotationHandler } from "codehike/code";
3
+ import React, { useEffect, useLayoutEffect, useMemo, useState } from "react";
4
+ import {
5
+ calculateTransitions,
6
+ getStartingSnapshot,
7
+ TokenTransitionsSnapshot,
8
+ } from "codehike/utils/token-transitions";
9
+ import { applyStyle } from "./utils";
10
+ import { callout } from "./annotations/Callout";
11
+ import { useMorphMarkHandler } from "./annotations/Mark";
12
+ import { useFocusHandler } from "./annotations/Focus";
13
+ import { tokenTransitions } from "./annotations/InlineToken";
14
+ import { errorInline, errorMessage } from "./annotations/Error";
15
+ import { fontFamily, fontSize as baseFontSize, tabSize } from "./font";
16
+
17
+ export function CodeTransition({
18
+ oldCode,
19
+ newCode,
20
+ durationInFrames = 30,
21
+ fontSize,
22
+ }: {
23
+ readonly oldCode: HighlightedCode | null;
24
+ readonly newCode: HighlightedCode;
25
+ readonly durationInFrames?: number;
26
+ readonly fontSize?: number;
27
+ }) {
28
+ const frame = useCurrentFrame();
29
+
30
+ const ref = React.useRef<HTMLPreElement>(null);
31
+ const [oldSnapshot, setOldSnapshot] =
32
+ useState<TokenTransitionsSnapshot | null>(null);
33
+ const { delayRender, continueRender } = useDelayRender();
34
+ const [handle] = React.useState(() => delayRender());
35
+
36
+ const prevCode: HighlightedCode = useMemo(() => {
37
+ return oldCode || { ...newCode, tokens: [], annotations: [] };
38
+ }, [newCode, oldCode]);
39
+
40
+ const code = useMemo(() => {
41
+ return oldSnapshot ? newCode : prevCode;
42
+ }, [newCode, prevCode, oldSnapshot]);
43
+
44
+ useEffect(() => {
45
+ if (!oldSnapshot) {
46
+ setOldSnapshot(getStartingSnapshot(ref.current!));
47
+ }
48
+ }, [oldSnapshot]);
49
+
50
+ // eslint-disable-next-line react-hooks/exhaustive-deps
51
+ useLayoutEffect(() => {
52
+ if (!oldSnapshot) {
53
+ setOldSnapshot(getStartingSnapshot(ref.current!));
54
+ return;
55
+ }
56
+ const transitions = calculateTransitions(ref.current!, oldSnapshot);
57
+ transitions.forEach(({ element, keyframes, options }) => {
58
+ const delay = durationInFrames * options.delay;
59
+ const duration = durationInFrames * options.duration;
60
+ const linearProgress = interpolate(
61
+ frame,
62
+ [delay, delay + duration],
63
+ [0, 1],
64
+ { extrapolateLeft: "clamp", extrapolateRight: "clamp" },
65
+ );
66
+ const progress = interpolate(linearProgress, [0, 1], [0, 1], {
67
+ easing: Easing.bezier(0.17, 0.67, 0.76, 0.91),
68
+ });
69
+
70
+ applyStyle({ element, keyframes, progress, linearProgress });
71
+ });
72
+ continueRender(handle);
73
+ });
74
+
75
+ const markHandler = useMorphMarkHandler();
76
+
77
+ const focusHandler = useFocusHandler(newCode);
78
+
79
+ const handlers: AnnotationHandler[] = useMemo(
80
+ () => [
81
+ tokenTransitions,
82
+ markHandler,
83
+ focusHandler,
84
+ callout,
85
+ errorInline,
86
+ errorMessage,
87
+ ],
88
+ [markHandler, focusHandler],
89
+ );
90
+
91
+ const style: React.CSSProperties = useMemo(
92
+ () => ({
93
+ position: "relative",
94
+ fontSize: fontSize ?? baseFontSize,
95
+ lineHeight: 1.5,
96
+ fontFamily,
97
+ tabSize,
98
+ }),
99
+ [fontSize],
100
+ );
101
+
102
+ return <Pre ref={ref} code={code} handlers={handlers} style={style} />;
103
+ }