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 +97 -0
- package/framecode.schema.json +154 -0
- package/package.json +63 -0
- package/src/BrandOverlay.tsx +129 -0
- package/src/CascadeTransition.tsx +88 -0
- package/src/CodeTransition.tsx +103 -0
- package/src/Main.tsx +192 -0
- package/src/ProgressBar.tsx +112 -0
- package/src/ReloadOnCodeChange.tsx +40 -0
- package/src/Root.tsx +89 -0
- package/src/TypewriterTransition.tsx +167 -0
- package/src/annotations/Callout.tsx +78 -0
- package/src/annotations/Error.tsx +76 -0
- package/src/annotations/Focus.tsx +56 -0
- package/src/annotations/InlineToken.tsx +8 -0
- package/src/annotations/Mark.tsx +94 -0
- package/src/calculate-metadata/calculate-metadata.tsx +119 -0
- package/src/calculate-metadata/get-files.ts +18 -0
- package/src/calculate-metadata/process-snippet.ts +21 -0
- package/src/calculate-metadata/schema.ts +47 -0
- package/src/calculate-metadata/theme.tsx +354 -0
- package/src/cli/commands/init.ts +28 -0
- package/src/cli/commands/render.ts +379 -0
- package/src/cli/commands/themes.ts +46 -0
- package/src/cli/index.ts +18 -0
- package/src/cli/utils/config.ts +83 -0
- package/src/cli/utils/files.ts +156 -0
- package/src/cli/utils/logger.ts +36 -0
- package/src/cli/utils/process-code.ts +196 -0
- package/src/cli/utils/prompts.ts +338 -0
- package/src/font.ts +13 -0
- package/src/index.css +26 -0
- package/src/index.ts +4 -0
- package/src/shared/calculations.ts +58 -0
- package/src/utils.ts +28 -0
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
|
|
3
|
+
export const logger = {
|
|
4
|
+
info: (msg: string) => console.log(chalk.blue("ℹ"), msg),
|
|
5
|
+
success: (msg: string) => console.log(chalk.green("✔"), msg),
|
|
6
|
+
error: (msg: string) => console.error(chalk.red("✖"), msg),
|
|
7
|
+
warn: (msg: string) => console.warn(chalk.yellow("⚠"), msg),
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export class ProgressBar {
|
|
11
|
+
private width = 30;
|
|
12
|
+
private label: string;
|
|
13
|
+
private lastPct = -1;
|
|
14
|
+
|
|
15
|
+
constructor(label: string) {
|
|
16
|
+
this.label = label;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
update(progress: number) {
|
|
20
|
+
const pct = Math.round(progress * 100);
|
|
21
|
+
if (pct === this.lastPct) return;
|
|
22
|
+
this.lastPct = pct;
|
|
23
|
+
|
|
24
|
+
const filled = Math.round(this.width * progress);
|
|
25
|
+
const empty = this.width - filled;
|
|
26
|
+
const bar = chalk.blue("█").repeat(filled) + chalk.gray("░").repeat(empty);
|
|
27
|
+
|
|
28
|
+
process.stdout.write(`\r${chalk.blue("ℹ")} ${this.label} ${bar} ${pct}%`);
|
|
29
|
+
|
|
30
|
+
if (pct === 100) process.stdout.write("\n");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
clear() {
|
|
34
|
+
process.stdout.write("\r" + " ".repeat(60) + "\r");
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { highlight, HighlightedCode } from "codehike/code";
|
|
2
|
+
import { createTwoslasher } from "twoslash";
|
|
3
|
+
import type { z } from "zod";
|
|
4
|
+
import type { StaticFile } from "../../calculate-metadata/get-files";
|
|
5
|
+
import type {
|
|
6
|
+
animationSchema,
|
|
7
|
+
stepConfigSchema,
|
|
8
|
+
} from "../../calculate-metadata/schema";
|
|
9
|
+
import {
|
|
10
|
+
Theme,
|
|
11
|
+
loadTheme,
|
|
12
|
+
getThemeColors,
|
|
13
|
+
ThemeColors,
|
|
14
|
+
} from "../../calculate-metadata/theme";
|
|
15
|
+
import { tabSize, CHAR_WIDTH_RATIO } from "../../font";
|
|
16
|
+
import {
|
|
17
|
+
calculateStepFontSize,
|
|
18
|
+
calculateStepDuration,
|
|
19
|
+
} from "../../shared/calculations";
|
|
20
|
+
import ts from "typescript";
|
|
21
|
+
|
|
22
|
+
export type Animation = z.infer<typeof animationSchema>;
|
|
23
|
+
export type StepConfig = z.infer<typeof stepConfigSchema>;
|
|
24
|
+
|
|
25
|
+
export type CodeStep = {
|
|
26
|
+
code: HighlightedCode;
|
|
27
|
+
fontSize: number;
|
|
28
|
+
durationInFrames: number;
|
|
29
|
+
animation: Animation;
|
|
30
|
+
charsPerSecond: number;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export type ProcessedCode = {
|
|
34
|
+
steps: CodeStep[];
|
|
35
|
+
themeColors: ThemeColors;
|
|
36
|
+
codeWidth: number;
|
|
37
|
+
durationInFrames: number;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const compilerOptions = {
|
|
41
|
+
lib: ["dom", "es2023"],
|
|
42
|
+
jsx: ts.JsxEmit.React,
|
|
43
|
+
target: ts.ScriptTarget.ES2022,
|
|
44
|
+
module: ts.ModuleKind.ESNext,
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
let twoslasher: ReturnType<typeof createTwoslasher> | null = null;
|
|
48
|
+
|
|
49
|
+
function getTwoslasher() {
|
|
50
|
+
if (!twoslasher) {
|
|
51
|
+
twoslasher = createTwoslasher({ compilerOptions });
|
|
52
|
+
}
|
|
53
|
+
return twoslasher;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function countChars(code: HighlightedCode): number {
|
|
57
|
+
let count = 0;
|
|
58
|
+
for (const token of code.tokens) {
|
|
59
|
+
if (typeof token === "string") {
|
|
60
|
+
count += token.length;
|
|
61
|
+
} else {
|
|
62
|
+
count += token[0].length;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return count;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function processSnippet(
|
|
69
|
+
step: StaticFile,
|
|
70
|
+
shikiTheme: Awaited<ReturnType<typeof loadTheme>>,
|
|
71
|
+
): Promise<HighlightedCode> {
|
|
72
|
+
const splitted = step.filename.split(".");
|
|
73
|
+
const extension = splitted[splitted.length - 1];
|
|
74
|
+
|
|
75
|
+
let code = step.value;
|
|
76
|
+
let twoslashResult = null;
|
|
77
|
+
|
|
78
|
+
if (extension === "ts" || extension === "tsx") {
|
|
79
|
+
try {
|
|
80
|
+
twoslashResult = getTwoslasher()(code, extension, { compilerOptions });
|
|
81
|
+
code = twoslashResult.code;
|
|
82
|
+
} catch {
|
|
83
|
+
// If twoslash fails, just use the original code
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const highlighted = await highlight(
|
|
88
|
+
{
|
|
89
|
+
lang: extension,
|
|
90
|
+
meta: step.filename,
|
|
91
|
+
value: code,
|
|
92
|
+
},
|
|
93
|
+
shikiTheme,
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
if (!twoslashResult) {
|
|
97
|
+
return highlighted;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
for (const { text, line, character, length } of twoslashResult.queries) {
|
|
101
|
+
const codeblock = await highlight(
|
|
102
|
+
{ value: text, lang: "ts", meta: "callout" },
|
|
103
|
+
shikiTheme,
|
|
104
|
+
);
|
|
105
|
+
highlighted.annotations.push({
|
|
106
|
+
name: "callout",
|
|
107
|
+
query: text,
|
|
108
|
+
lineNumber: line + 1,
|
|
109
|
+
data: { character, codeblock },
|
|
110
|
+
fromColumn: character,
|
|
111
|
+
toColumn: character + length,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
for (const { text, line, character, length } of twoslashResult.errors) {
|
|
116
|
+
highlighted.annotations.push({
|
|
117
|
+
name: "error",
|
|
118
|
+
query: text,
|
|
119
|
+
lineNumber: line + 1,
|
|
120
|
+
data: { character },
|
|
121
|
+
fromColumn: character,
|
|
122
|
+
toColumn: character + length,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return highlighted;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export async function processCode(
|
|
130
|
+
files: StaticFile[],
|
|
131
|
+
theme: Theme,
|
|
132
|
+
animation: Animation,
|
|
133
|
+
charsPerSecond: number,
|
|
134
|
+
width: number,
|
|
135
|
+
height: number,
|
|
136
|
+
stepConfigs?: StepConfig[],
|
|
137
|
+
): Promise<ProcessedCode> {
|
|
138
|
+
const shikiTheme = await loadTheme(theme);
|
|
139
|
+
const themeColors = await getThemeColors(theme);
|
|
140
|
+
|
|
141
|
+
const stepConfigMap = new Map<string, StepConfig>();
|
|
142
|
+
if (stepConfigs) {
|
|
143
|
+
for (const config of stepConfigs) {
|
|
144
|
+
stepConfigMap.set(config.file, config);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const steps = await Promise.all(
|
|
149
|
+
files.map(async (file) => {
|
|
150
|
+
const code = await processSnippet(file, shikiTheme);
|
|
151
|
+
const fontSize = calculateStepFontSize(file.value, width, height);
|
|
152
|
+
const charCount = countChars(code);
|
|
153
|
+
const lineCount = file.value.split("\n").length;
|
|
154
|
+
|
|
155
|
+
const stepConfig = stepConfigMap.get(file.filename);
|
|
156
|
+
const stepAnimation = stepConfig?.animation ?? animation;
|
|
157
|
+
const stepCps = stepConfig?.charsPerSecond ?? charsPerSecond;
|
|
158
|
+
|
|
159
|
+
const durationInFrames = calculateStepDuration(
|
|
160
|
+
charCount,
|
|
161
|
+
lineCount,
|
|
162
|
+
stepAnimation,
|
|
163
|
+
stepCps,
|
|
164
|
+
);
|
|
165
|
+
return {
|
|
166
|
+
code,
|
|
167
|
+
fontSize,
|
|
168
|
+
durationInFrames,
|
|
169
|
+
animation: stepAnimation,
|
|
170
|
+
charsPerSecond: stepCps,
|
|
171
|
+
};
|
|
172
|
+
}),
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
const maxCharacters = Math.max(
|
|
176
|
+
...files
|
|
177
|
+
.map(({ value }) => value.split("\n"))
|
|
178
|
+
.flat()
|
|
179
|
+
.map((value) => value.replaceAll("\t", " ".repeat(tabSize)).length),
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
const maxFontSize = Math.max(...steps.map((s) => s.fontSize));
|
|
183
|
+
const codeWidth = maxFontSize * CHAR_WIDTH_RATIO * maxCharacters;
|
|
184
|
+
|
|
185
|
+
const durationInFrames = steps.reduce(
|
|
186
|
+
(sum, step) => sum + step.durationInFrames,
|
|
187
|
+
0,
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
return {
|
|
191
|
+
steps,
|
|
192
|
+
themeColors,
|
|
193
|
+
codeWidth,
|
|
194
|
+
durationInFrames,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
import * as p from "@clack/prompts";
|
|
2
|
+
import chalk from "chalk";
|
|
3
|
+
import { basename } from "path";
|
|
4
|
+
import { themeSchema } from "../../calculate-metadata/theme";
|
|
5
|
+
import {
|
|
6
|
+
animationSchema,
|
|
7
|
+
presetSchema,
|
|
8
|
+
presetDimensions,
|
|
9
|
+
} from "../../calculate-metadata/schema";
|
|
10
|
+
import { saveConfig, type Config } from "./config";
|
|
11
|
+
import type { StepConfig } from "./process-code";
|
|
12
|
+
|
|
13
|
+
export interface InteractiveOptions {
|
|
14
|
+
files: string[];
|
|
15
|
+
theme?: string;
|
|
16
|
+
animation?: string;
|
|
17
|
+
preset?: string;
|
|
18
|
+
output?: string;
|
|
19
|
+
cps?: number;
|
|
20
|
+
fps?: number;
|
|
21
|
+
yes?: boolean;
|
|
22
|
+
config?: Config;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface InteractiveResult {
|
|
26
|
+
files: string[];
|
|
27
|
+
theme: string;
|
|
28
|
+
animation: string;
|
|
29
|
+
preset: string;
|
|
30
|
+
output: string;
|
|
31
|
+
cps: number;
|
|
32
|
+
fps: number;
|
|
33
|
+
stepConfigs?: StepConfig[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const THEMES = themeSchema.options;
|
|
37
|
+
const ANIMATIONS = animationSchema.options;
|
|
38
|
+
const PRESETS = presetSchema.options;
|
|
39
|
+
|
|
40
|
+
const POPULAR_THEMES = [
|
|
41
|
+
"vercel-dark",
|
|
42
|
+
"story-gradients",
|
|
43
|
+
"retro-terminal",
|
|
44
|
+
"github-dark",
|
|
45
|
+
"synthwave-84",
|
|
46
|
+
"dracula",
|
|
47
|
+
"tokyo-night",
|
|
48
|
+
"catppuccin-mocha",
|
|
49
|
+
] as const;
|
|
50
|
+
|
|
51
|
+
function getFileSize(filePath: string): string {
|
|
52
|
+
try {
|
|
53
|
+
const file = Bun.file(filePath);
|
|
54
|
+
const bytes = file.size;
|
|
55
|
+
if (bytes < 1024) return `${bytes}B`;
|
|
56
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
|
|
57
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
|
58
|
+
} catch {
|
|
59
|
+
return "?";
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function runInteractive(
|
|
64
|
+
opts: InteractiveOptions,
|
|
65
|
+
): Promise<InteractiveResult> {
|
|
66
|
+
p.intro(chalk.bgCyan.black(" framecode "));
|
|
67
|
+
|
|
68
|
+
const needsFilePrompt = opts.files.length > 1;
|
|
69
|
+
const needsOutputPrompt = opts.output === "output.mp4";
|
|
70
|
+
|
|
71
|
+
const defaultTheme = opts.theme ?? opts.config?.theme ?? "vercel-dark";
|
|
72
|
+
const defaultAnimation = opts.animation ?? opts.config?.animation ?? "morph";
|
|
73
|
+
const defaultPreset = opts.preset ?? opts.config?.preset ?? "tutorial";
|
|
74
|
+
|
|
75
|
+
const group = await p.group(
|
|
76
|
+
{
|
|
77
|
+
files: () =>
|
|
78
|
+
needsFilePrompt
|
|
79
|
+
? p.multiselect({
|
|
80
|
+
message: "Select files to render",
|
|
81
|
+
options: opts.files.map((file) => ({
|
|
82
|
+
value: file,
|
|
83
|
+
label: file,
|
|
84
|
+
hint: getFileSize(file),
|
|
85
|
+
})),
|
|
86
|
+
required: true,
|
|
87
|
+
})
|
|
88
|
+
: Promise.resolve(opts.files),
|
|
89
|
+
|
|
90
|
+
theme: () => promptThemeSelect(defaultTheme),
|
|
91
|
+
|
|
92
|
+
animation: () =>
|
|
93
|
+
p.select({
|
|
94
|
+
message: "Pick an animation",
|
|
95
|
+
initialValue: defaultAnimation,
|
|
96
|
+
options: ANIMATIONS.map((a) => ({
|
|
97
|
+
value: a as string,
|
|
98
|
+
label: a,
|
|
99
|
+
hint:
|
|
100
|
+
a === "morph"
|
|
101
|
+
? "smooth transitions"
|
|
102
|
+
: a === "typewriter"
|
|
103
|
+
? "character reveal"
|
|
104
|
+
: "line-by-line",
|
|
105
|
+
})),
|
|
106
|
+
}),
|
|
107
|
+
|
|
108
|
+
preset: () =>
|
|
109
|
+
p.select({
|
|
110
|
+
message: "Pick a preset",
|
|
111
|
+
initialValue: defaultPreset,
|
|
112
|
+
options: PRESETS.map((pr) => {
|
|
113
|
+
const dims = presetDimensions[pr];
|
|
114
|
+
return {
|
|
115
|
+
value: pr as string,
|
|
116
|
+
label: pr,
|
|
117
|
+
hint: `${dims.width}x${dims.height}`,
|
|
118
|
+
};
|
|
119
|
+
}),
|
|
120
|
+
}),
|
|
121
|
+
|
|
122
|
+
output: ({ results }) =>
|
|
123
|
+
needsOutputPrompt
|
|
124
|
+
? p.text({
|
|
125
|
+
message: "Output file",
|
|
126
|
+
initialValue: `${(results.files as string[])[0].replace(/\.[^/.]+$/, "")}.mp4`,
|
|
127
|
+
validate: (value) => {
|
|
128
|
+
if (!value) return "Output path required";
|
|
129
|
+
if (!value.endsWith(".mp4")) return "Must end with .mp4";
|
|
130
|
+
},
|
|
131
|
+
})
|
|
132
|
+
: Promise.resolve(null),
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
onCancel: () => {
|
|
136
|
+
p.cancel("Operation cancelled.");
|
|
137
|
+
process.exit(0);
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
const selectedFiles = group.files as string[];
|
|
143
|
+
const theme = group.theme as string;
|
|
144
|
+
const animation = group.animation as string;
|
|
145
|
+
const preset = group.preset as string;
|
|
146
|
+
const output = (group.output as string | null) ?? opts.output ?? "output.mp4";
|
|
147
|
+
const cps = opts.cps ?? opts.config?.charsPerSecond ?? 30;
|
|
148
|
+
const fps = opts.fps ?? opts.config?.fps ?? 30;
|
|
149
|
+
|
|
150
|
+
const result: InteractiveResult = {
|
|
151
|
+
files: selectedFiles,
|
|
152
|
+
theme,
|
|
153
|
+
animation,
|
|
154
|
+
preset,
|
|
155
|
+
output,
|
|
156
|
+
cps,
|
|
157
|
+
fps,
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
const dims = presetDimensions[preset as keyof typeof presetDimensions];
|
|
161
|
+
|
|
162
|
+
let stepConfigs: StepConfig[] | undefined;
|
|
163
|
+
|
|
164
|
+
if (selectedFiles.length > 1) {
|
|
165
|
+
const configurePerStep = await p.confirm({
|
|
166
|
+
message: "Configure animation per file?",
|
|
167
|
+
initialValue: false,
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
if (p.isCancel(configurePerStep)) {
|
|
171
|
+
p.cancel("Operation cancelled.");
|
|
172
|
+
process.exit(0);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (configurePerStep) {
|
|
176
|
+
stepConfigs = await promptPerStepConfig(selectedFiles, animation, cps);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (!opts.yes) {
|
|
181
|
+
const summaryLines = [
|
|
182
|
+
`${chalk.dim("Files:")} ${selectedFiles.join(", ")}`,
|
|
183
|
+
`${chalk.dim("Theme:")} ${theme}`,
|
|
184
|
+
`${chalk.dim("Animation:")} ${animation}`,
|
|
185
|
+
`${chalk.dim("Preset:")} ${preset} (${dims.width}x${dims.height})`,
|
|
186
|
+
`${chalk.dim("Output:")} ${output}`,
|
|
187
|
+
`${chalk.dim("CPS:")} ${cps}`,
|
|
188
|
+
`${chalk.dim("FPS:")} ${fps}`,
|
|
189
|
+
];
|
|
190
|
+
|
|
191
|
+
if (stepConfigs && stepConfigs.length > 0) {
|
|
192
|
+
summaryLines.push("");
|
|
193
|
+
summaryLines.push(chalk.dim("Per-file overrides:"));
|
|
194
|
+
for (const sc of stepConfigs) {
|
|
195
|
+
const parts = [` ${sc.file}:`];
|
|
196
|
+
if (sc.animation) parts.push(`animation=${sc.animation}`);
|
|
197
|
+
if (sc.charsPerSecond) parts.push(`cps=${sc.charsPerSecond}`);
|
|
198
|
+
summaryLines.push(parts.join(" "));
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
p.note(summaryLines.join("\n"), "Summary");
|
|
203
|
+
|
|
204
|
+
const confirmed = await p.confirm({
|
|
205
|
+
message: "Proceed with render?",
|
|
206
|
+
initialValue: true,
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
if (p.isCancel(confirmed) || !confirmed) {
|
|
210
|
+
p.cancel("Render cancelled");
|
|
211
|
+
process.exit(0);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const configChanged =
|
|
216
|
+
theme !== opts.config?.theme ||
|
|
217
|
+
animation !== opts.config?.animation ||
|
|
218
|
+
preset !== opts.config?.preset;
|
|
219
|
+
|
|
220
|
+
if (configChanged) {
|
|
221
|
+
const saved = await saveConfig({
|
|
222
|
+
theme: theme as Config["theme"],
|
|
223
|
+
animation: animation as Config["animation"],
|
|
224
|
+
preset: preset as Config["preset"],
|
|
225
|
+
});
|
|
226
|
+
if (saved) {
|
|
227
|
+
p.log.info(chalk.dim("Settings saved to config"));
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return { ...result, stepConfigs };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async function promptPerStepConfig(
|
|
235
|
+
files: string[],
|
|
236
|
+
defaultAnimation: string,
|
|
237
|
+
defaultCps: number,
|
|
238
|
+
): Promise<StepConfig[]> {
|
|
239
|
+
const stepConfigs: StepConfig[] = [];
|
|
240
|
+
|
|
241
|
+
for (const filePath of files) {
|
|
242
|
+
const filename = basename(filePath);
|
|
243
|
+
|
|
244
|
+
p.log.step(`Configure ${chalk.cyan(filename)}`);
|
|
245
|
+
|
|
246
|
+
const stepAnimation = await p.select({
|
|
247
|
+
message: `Animation for ${filename}`,
|
|
248
|
+
options: [
|
|
249
|
+
{ value: "__default__", label: `Use default (${defaultAnimation})` },
|
|
250
|
+
...ANIMATIONS.map((a) => ({
|
|
251
|
+
value: a as string,
|
|
252
|
+
label: a,
|
|
253
|
+
hint:
|
|
254
|
+
a === "morph"
|
|
255
|
+
? "smooth transitions"
|
|
256
|
+
: a === "typewriter"
|
|
257
|
+
? "character reveal"
|
|
258
|
+
: a === "cascade"
|
|
259
|
+
? "line-by-line"
|
|
260
|
+
: "spotlight effect",
|
|
261
|
+
})),
|
|
262
|
+
],
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
if (p.isCancel(stepAnimation)) {
|
|
266
|
+
p.cancel("Operation cancelled.");
|
|
267
|
+
process.exit(0);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const animationValue =
|
|
271
|
+
stepAnimation === "__default__" ? undefined : (stepAnimation as string);
|
|
272
|
+
|
|
273
|
+
let cpsValue: number | undefined;
|
|
274
|
+
|
|
275
|
+
if (animationValue === "typewriter") {
|
|
276
|
+
const stepCps = await p.text({
|
|
277
|
+
message: `Chars per second for ${filename}`,
|
|
278
|
+
initialValue: String(defaultCps),
|
|
279
|
+
validate: (value) => {
|
|
280
|
+
const num = Number(value);
|
|
281
|
+
if (isNaN(num) || num <= 0) return "Must be a positive number";
|
|
282
|
+
},
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
if (p.isCancel(stepCps)) {
|
|
286
|
+
p.cancel("Operation cancelled.");
|
|
287
|
+
process.exit(0);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
cpsValue = Number(stepCps);
|
|
291
|
+
if (cpsValue === defaultCps) cpsValue = undefined;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if (animationValue || cpsValue) {
|
|
295
|
+
stepConfigs.push({
|
|
296
|
+
file: filename,
|
|
297
|
+
animation: animationValue as StepConfig["animation"],
|
|
298
|
+
charsPerSecond: cpsValue,
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
return stepConfigs;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
async function promptThemeSelect(defaultTheme: string): Promise<string> {
|
|
307
|
+
const isPopular = POPULAR_THEMES.includes(
|
|
308
|
+
defaultTheme as (typeof POPULAR_THEMES)[number],
|
|
309
|
+
);
|
|
310
|
+
|
|
311
|
+
const theme = await p.select({
|
|
312
|
+
message: "Pick a theme",
|
|
313
|
+
initialValue: isPopular ? defaultTheme : "__more__",
|
|
314
|
+
options: [
|
|
315
|
+
...POPULAR_THEMES.map((t) => ({ value: t as string, label: t })),
|
|
316
|
+
{ value: "__more__", label: chalk.dim("More themes...") },
|
|
317
|
+
],
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
if (theme === "__more__") {
|
|
321
|
+
const allTheme = await p.autocomplete({
|
|
322
|
+
message: "Pick a theme (all)",
|
|
323
|
+
options: THEMES.map((t) => ({ value: t as string, label: t })),
|
|
324
|
+
initialValue: defaultTheme,
|
|
325
|
+
});
|
|
326
|
+
return allTheme as string;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
return theme as string;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
export function createRenderSpinner() {
|
|
333
|
+
return p.spinner();
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
export function renderOutro(output: string) {
|
|
337
|
+
p.outro(chalk.green(`Video saved to ${output}`));
|
|
338
|
+
}
|
package/src/font.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { loadFont } from "@remotion/google-fonts/RobotoMono";
|
|
2
|
+
|
|
3
|
+
export const { fontFamily, waitUntilDone } = loadFont("normal", {
|
|
4
|
+
subsets: ["latin"],
|
|
5
|
+
weights: ["400", "700"],
|
|
6
|
+
});
|
|
7
|
+
export const fontSize = 40;
|
|
8
|
+
export const minFontSize = 20;
|
|
9
|
+
export const tabSize = 3;
|
|
10
|
+
export const horizontalPadding = 60;
|
|
11
|
+
export const verticalPadding = 84;
|
|
12
|
+
export const CHAR_WIDTH_RATIO = 0.6;
|
|
13
|
+
export const LINE_HEIGHT = 1.5;
|
package/src/index.css
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
@import "tailwindcss";
|
|
2
|
+
|
|
3
|
+
@theme {
|
|
4
|
+
--color-brand-black: #000000;
|
|
5
|
+
--color-brand-indigo: #6366f1;
|
|
6
|
+
--color-brand-pink: #ec4899;
|
|
7
|
+
|
|
8
|
+
--color-primary: var(--color-brand-indigo);
|
|
9
|
+
--color-secondary: var(--color-brand-pink);
|
|
10
|
+
--color-background: var(--color-brand-black);
|
|
11
|
+
|
|
12
|
+
--font-sans: "Geist Variable", system-ui, sans-serif;
|
|
13
|
+
--font-mono: "JetBrains Mono", monospace;
|
|
14
|
+
|
|
15
|
+
--spacing-0: 0px;
|
|
16
|
+
--spacing-1: 4px;
|
|
17
|
+
--spacing-2: 8px;
|
|
18
|
+
--spacing-3: 12px;
|
|
19
|
+
--spacing-4: 16px;
|
|
20
|
+
--spacing-5: 20px;
|
|
21
|
+
--spacing-6: 24px;
|
|
22
|
+
--spacing-8: 32px;
|
|
23
|
+
--spacing-10: 40px;
|
|
24
|
+
--spacing-12: 48px;
|
|
25
|
+
--spacing-16: 64px;
|
|
26
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import {
|
|
2
|
+
fontSize as baseFontSize,
|
|
3
|
+
minFontSize,
|
|
4
|
+
horizontalPadding,
|
|
5
|
+
verticalPadding,
|
|
6
|
+
tabSize,
|
|
7
|
+
CHAR_WIDTH_RATIO,
|
|
8
|
+
LINE_HEIGHT,
|
|
9
|
+
} from "../font";
|
|
10
|
+
|
|
11
|
+
const FPS = 30;
|
|
12
|
+
const BASE_STEP_DURATION = 90;
|
|
13
|
+
const CASCADE_STAGGER_DELAY = 3;
|
|
14
|
+
|
|
15
|
+
export function calculateStepFontSize(
|
|
16
|
+
content: string,
|
|
17
|
+
width: number,
|
|
18
|
+
height: number,
|
|
19
|
+
): number {
|
|
20
|
+
const lines = content.split("\n");
|
|
21
|
+
const lineCount = lines.length;
|
|
22
|
+
const maxChars = Math.max(
|
|
23
|
+
...lines.map((line) => line.replaceAll("\t", " ".repeat(tabSize)).length),
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
const availableHeight = height - verticalPadding * 2;
|
|
27
|
+
const availableWidth = width - horizontalPadding * 2;
|
|
28
|
+
|
|
29
|
+
const maxFontSizeByLines = availableHeight / (lineCount * LINE_HEIGHT);
|
|
30
|
+
const maxFontSizeByWidth =
|
|
31
|
+
maxChars > 0
|
|
32
|
+
? availableWidth / (maxChars * CHAR_WIDTH_RATIO)
|
|
33
|
+
: baseFontSize;
|
|
34
|
+
|
|
35
|
+
return Math.max(
|
|
36
|
+
minFontSize,
|
|
37
|
+
Math.min(
|
|
38
|
+
baseFontSize,
|
|
39
|
+
Math.floor(Math.min(maxFontSizeByLines, maxFontSizeByWidth)),
|
|
40
|
+
),
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function calculateStepDuration(
|
|
45
|
+
charCount: number,
|
|
46
|
+
lineCount: number,
|
|
47
|
+
animation: string,
|
|
48
|
+
charsPerSecond: number,
|
|
49
|
+
): number {
|
|
50
|
+
switch (animation) {
|
|
51
|
+
case "typewriter":
|
|
52
|
+
return Math.ceil((charCount / charsPerSecond) * FPS) + FPS;
|
|
53
|
+
case "cascade":
|
|
54
|
+
return lineCount * CASCADE_STAGGER_DELAY + FPS;
|
|
55
|
+
default:
|
|
56
|
+
return BASE_STEP_DURATION;
|
|
57
|
+
}
|
|
58
|
+
}
|
package/src/utils.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { TokenTransition } from "codehike/utils/token-transitions";
|
|
2
|
+
import { interpolate, interpolateColors } from "remotion";
|
|
3
|
+
|
|
4
|
+
export function applyStyle({
|
|
5
|
+
element,
|
|
6
|
+
keyframes,
|
|
7
|
+
progress,
|
|
8
|
+
linearProgress,
|
|
9
|
+
}: {
|
|
10
|
+
element: HTMLElement;
|
|
11
|
+
keyframes: TokenTransition["keyframes"];
|
|
12
|
+
progress: number;
|
|
13
|
+
linearProgress: number;
|
|
14
|
+
}) {
|
|
15
|
+
const { translateX, translateY, color, opacity } = keyframes;
|
|
16
|
+
|
|
17
|
+
if (opacity) {
|
|
18
|
+
element.style.opacity = linearProgress.toString();
|
|
19
|
+
}
|
|
20
|
+
if (color && color[0].length && color[1].length) {
|
|
21
|
+
element.style.color = interpolateColors(progress, [0, 1], color);
|
|
22
|
+
}
|
|
23
|
+
const hasValidX = translateX && translateX.every((v) => Number.isFinite(v));
|
|
24
|
+
const hasValidY = translateY && translateY.every((v) => Number.isFinite(v));
|
|
25
|
+
const x = hasValidX ? interpolate(progress, [0, 1], translateX) : 0;
|
|
26
|
+
const y = hasValidY ? interpolate(progress, [0, 1], translateY) : 0;
|
|
27
|
+
element.style.translate = `${x}px ${y}px`;
|
|
28
|
+
}
|