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,76 @@
|
|
|
1
|
+
import { InlineAnnotation, AnnotationHandler, InnerToken } from "codehike/code";
|
|
2
|
+
import { interpolate, useCurrentFrame } from "remotion";
|
|
3
|
+
import { useThemeColors } from "../calculate-metadata/theme";
|
|
4
|
+
import { mix, readableColor } from "polished";
|
|
5
|
+
|
|
6
|
+
export const errorInline: AnnotationHandler = {
|
|
7
|
+
name: "error",
|
|
8
|
+
transform: (annotation: InlineAnnotation) => {
|
|
9
|
+
const { query, lineNumber, data } = annotation;
|
|
10
|
+
return [
|
|
11
|
+
annotation,
|
|
12
|
+
{
|
|
13
|
+
name: "error-message",
|
|
14
|
+
query,
|
|
15
|
+
fromLineNumber: lineNumber,
|
|
16
|
+
toLineNumber: lineNumber,
|
|
17
|
+
data,
|
|
18
|
+
},
|
|
19
|
+
];
|
|
20
|
+
},
|
|
21
|
+
Inline: ({ children }) => (
|
|
22
|
+
<span
|
|
23
|
+
style={{
|
|
24
|
+
// @ts-expect-error - React types
|
|
25
|
+
"--decoration": "underline wavy red",
|
|
26
|
+
}}
|
|
27
|
+
>
|
|
28
|
+
{children}
|
|
29
|
+
</span>
|
|
30
|
+
),
|
|
31
|
+
Token: (props) => {
|
|
32
|
+
return (
|
|
33
|
+
<InnerToken
|
|
34
|
+
merge={props}
|
|
35
|
+
style={{
|
|
36
|
+
textDecoration: "var(--decoration)",
|
|
37
|
+
}}
|
|
38
|
+
/>
|
|
39
|
+
);
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export const errorMessage: AnnotationHandler = {
|
|
44
|
+
name: "error-message",
|
|
45
|
+
Block: ({ annotation, children }) => {
|
|
46
|
+
const frame = useCurrentFrame();
|
|
47
|
+
const opacity = interpolate(frame, [25, 35], [0, 1], {
|
|
48
|
+
extrapolateLeft: "clamp",
|
|
49
|
+
extrapolateRight: "clamp",
|
|
50
|
+
});
|
|
51
|
+
const themeColors = useThemeColors();
|
|
52
|
+
|
|
53
|
+
const color = readableColor(themeColors.background);
|
|
54
|
+
const calloutColor = mix(0.08, color, themeColors.background);
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
<>
|
|
58
|
+
{children}
|
|
59
|
+
<div
|
|
60
|
+
style={{
|
|
61
|
+
opacity,
|
|
62
|
+
borderLeft: "4px solid red",
|
|
63
|
+
marginLeft: "-1rem",
|
|
64
|
+
backgroundColor: calloutColor,
|
|
65
|
+
padding: "1rem 2rem",
|
|
66
|
+
marginTop: "0.5rem",
|
|
67
|
+
whiteSpace: "pre-wrap",
|
|
68
|
+
color: themeColors.editor.foreground,
|
|
69
|
+
}}
|
|
70
|
+
>
|
|
71
|
+
{annotation.data?.children || annotation.query}
|
|
72
|
+
</div>
|
|
73
|
+
</>
|
|
74
|
+
);
|
|
75
|
+
},
|
|
76
|
+
};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { useMemo } from "react";
|
|
2
|
+
import { AnnotationHandler, InnerLine, HighlightedCode } from "codehike/code";
|
|
3
|
+
import { interpolate, spring, useCurrentFrame, useVideoConfig } from "remotion";
|
|
4
|
+
|
|
5
|
+
function buildFocusedLines(
|
|
6
|
+
annotations: HighlightedCode["annotations"],
|
|
7
|
+
): Set<number> {
|
|
8
|
+
const lines = new Set<number>();
|
|
9
|
+
for (const ann of annotations) {
|
|
10
|
+
if (ann.name === "focus" && "fromLineNumber" in ann) {
|
|
11
|
+
const from = ann.fromLineNumber as number;
|
|
12
|
+
const to = (ann.toLineNumber as number) ?? from;
|
|
13
|
+
for (let i = from; i <= to; i++) lines.add(i);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
return lines;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function useFocusHandler(code: HighlightedCode): AnnotationHandler {
|
|
20
|
+
const frame = useCurrentFrame();
|
|
21
|
+
const { fps } = useVideoConfig();
|
|
22
|
+
|
|
23
|
+
const focusedLines = useMemo(
|
|
24
|
+
() => buildFocusedLines(code.annotations),
|
|
25
|
+
[code.annotations],
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
const hasFocus = focusedLines.size > 0;
|
|
29
|
+
|
|
30
|
+
const progress = hasFocus
|
|
31
|
+
? spring({ frame, fps, config: { damping: 20, stiffness: 100 } })
|
|
32
|
+
: 0;
|
|
33
|
+
const blur = interpolate(progress, [0, 1], [0, 3]);
|
|
34
|
+
const dimOpacity = interpolate(progress, [0, 1], [1, 0.4]);
|
|
35
|
+
|
|
36
|
+
return useMemo(
|
|
37
|
+
() => ({
|
|
38
|
+
name: "focus",
|
|
39
|
+
Line: hasFocus
|
|
40
|
+
? (props) => (
|
|
41
|
+
<InnerLine
|
|
42
|
+
merge={props}
|
|
43
|
+
style={{
|
|
44
|
+
filter: focusedLines.has(props.lineNumber)
|
|
45
|
+
? "none"
|
|
46
|
+
: `blur(${blur}px)`,
|
|
47
|
+
opacity: focusedLines.has(props.lineNumber) ? 1 : dimOpacity,
|
|
48
|
+
display: "block",
|
|
49
|
+
}}
|
|
50
|
+
/>
|
|
51
|
+
)
|
|
52
|
+
: undefined,
|
|
53
|
+
}),
|
|
54
|
+
[hasFocus, focusedLines, blur, dimOpacity],
|
|
55
|
+
);
|
|
56
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { useMemo } from "react";
|
|
2
|
+
import { AnnotationHandler, InnerLine } from "codehike/code";
|
|
3
|
+
import { interpolate, useCurrentFrame } from "remotion";
|
|
4
|
+
import { useThemeColors } from "../calculate-metadata/theme";
|
|
5
|
+
|
|
6
|
+
const DEFAULT_MARK_COLOR = "rgb(14, 165, 233)";
|
|
7
|
+
|
|
8
|
+
function withAlpha(color: string, percent: number): string {
|
|
9
|
+
return `color-mix(in srgb, ${color} ${percent}%, transparent)`;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function stripAlpha(color: string): string {
|
|
13
|
+
if (color.startsWith("#")) {
|
|
14
|
+
if (color.length === 9) return color.slice(0, 7);
|
|
15
|
+
if (color.length === 5) return color.slice(0, 4);
|
|
16
|
+
}
|
|
17
|
+
return color;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type GetLineProgress = (lineNumber: number) => number;
|
|
21
|
+
|
|
22
|
+
export function useMarkHandler(
|
|
23
|
+
getLineProgress: GetLineProgress,
|
|
24
|
+
): AnnotationHandler {
|
|
25
|
+
const themeColors = useThemeColors();
|
|
26
|
+
const themeSelectionBg = themeColors.editor.selectionBackground;
|
|
27
|
+
|
|
28
|
+
const defaultColors = useMemo(
|
|
29
|
+
() => ({
|
|
30
|
+
border: stripAlpha(themeSelectionBg) || DEFAULT_MARK_COLOR,
|
|
31
|
+
background: themeSelectionBg || withAlpha(DEFAULT_MARK_COLOR, 15),
|
|
32
|
+
inlineBackground: themeSelectionBg || withAlpha(DEFAULT_MARK_COLOR, 20),
|
|
33
|
+
}),
|
|
34
|
+
[themeSelectionBg],
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
return useMemo(
|
|
38
|
+
() => ({
|
|
39
|
+
name: "mark",
|
|
40
|
+
AnnotatedLine: ({ annotation, ...props }) => {
|
|
41
|
+
const progress = getLineProgress(props.lineNumber);
|
|
42
|
+
const queryColor = annotation.query;
|
|
43
|
+
|
|
44
|
+
const borderColor = queryColor || defaultColors.border;
|
|
45
|
+
const backgroundColor = queryColor
|
|
46
|
+
? withAlpha(queryColor, 15)
|
|
47
|
+
: defaultColors.background;
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<div
|
|
51
|
+
style={{
|
|
52
|
+
opacity: progress,
|
|
53
|
+
borderLeft: `solid 2px ${borderColor}`,
|
|
54
|
+
backgroundColor,
|
|
55
|
+
}}
|
|
56
|
+
>
|
|
57
|
+
<InnerLine merge={props} style={{ padding: "0 0.5ch" }} />
|
|
58
|
+
</div>
|
|
59
|
+
);
|
|
60
|
+
},
|
|
61
|
+
Inline: ({ annotation, children }) => {
|
|
62
|
+
const queryColor = annotation?.query;
|
|
63
|
+
const background = queryColor
|
|
64
|
+
? withAlpha(queryColor, 20)
|
|
65
|
+
: defaultColors.inlineBackground;
|
|
66
|
+
|
|
67
|
+
return (
|
|
68
|
+
<span style={{ background, borderRadius: "2px", padding: "1px 3px" }}>
|
|
69
|
+
{children}
|
|
70
|
+
</span>
|
|
71
|
+
);
|
|
72
|
+
},
|
|
73
|
+
}),
|
|
74
|
+
[getLineProgress, defaultColors],
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const MARK_FADE_START = 20;
|
|
79
|
+
const MARK_FADE_END = 30;
|
|
80
|
+
|
|
81
|
+
export function useMorphMarkHandler(): AnnotationHandler {
|
|
82
|
+
const frame = useCurrentFrame();
|
|
83
|
+
const progress = interpolate(
|
|
84
|
+
frame,
|
|
85
|
+
[MARK_FADE_START, MARK_FADE_END],
|
|
86
|
+
[0, 1],
|
|
87
|
+
{
|
|
88
|
+
extrapolateLeft: "clamp",
|
|
89
|
+
extrapolateRight: "clamp",
|
|
90
|
+
},
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
return useMarkHandler(() => progress);
|
|
94
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { CalculateMetadataFunction } from "remotion";
|
|
3
|
+
import { getThemeColors } from "./theme";
|
|
4
|
+
import { Props, CodeStep, Animation } from "../Main";
|
|
5
|
+
import { schema, presetDimensions, stepConfigSchema } from "./schema";
|
|
6
|
+
import { processSnippetSimple } from "./process-snippet";
|
|
7
|
+
import { getFilesFromStudio } from "./get-files";
|
|
8
|
+
import { tabSize, CHAR_WIDTH_RATIO } from "../font";
|
|
9
|
+
import { flattenCode } from "../TypewriterTransition";
|
|
10
|
+
import {
|
|
11
|
+
calculateStepFontSize,
|
|
12
|
+
calculateStepDuration,
|
|
13
|
+
} from "../shared/calculations";
|
|
14
|
+
|
|
15
|
+
type StepConfig = z.infer<typeof stepConfigSchema>;
|
|
16
|
+
|
|
17
|
+
export const calculateMetadata: CalculateMetadataFunction<
|
|
18
|
+
z.infer<typeof schema> & Props
|
|
19
|
+
> = async ({ props }) => {
|
|
20
|
+
const preset = presetDimensions[props.preset];
|
|
21
|
+
const finalWidth = preset.width;
|
|
22
|
+
const finalHeight = preset.height;
|
|
23
|
+
|
|
24
|
+
// If steps are already provided (from CLI processing), use them directly
|
|
25
|
+
if (props.steps && props.themeColors && props.codeWidth !== null) {
|
|
26
|
+
const durationInFrames = props.steps.reduce(
|
|
27
|
+
(sum, step) => sum + step.durationInFrames,
|
|
28
|
+
0,
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
durationInFrames,
|
|
33
|
+
width: finalWidth,
|
|
34
|
+
height: finalHeight,
|
|
35
|
+
props: {
|
|
36
|
+
theme: props.theme,
|
|
37
|
+
preset: props.preset,
|
|
38
|
+
animation: props.animation,
|
|
39
|
+
charsPerSecond: props.charsPerSecond,
|
|
40
|
+
brand: props.brand,
|
|
41
|
+
steps: props.steps,
|
|
42
|
+
themeColors: props.themeColors,
|
|
43
|
+
codeWidth: props.codeWidth,
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Studio dev mode: process files without twoslash (browser context)
|
|
49
|
+
const contents = props.files ?? (await getFilesFromStudio());
|
|
50
|
+
const themeColors = await getThemeColors(props.theme);
|
|
51
|
+
|
|
52
|
+
const stepConfigMap = new Map<string, StepConfig>();
|
|
53
|
+
if (props.stepConfigs) {
|
|
54
|
+
for (const config of props.stepConfigs) {
|
|
55
|
+
stepConfigMap.set(config.file, config);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const steps: CodeStep[] = [];
|
|
60
|
+
for (const snippet of contents) {
|
|
61
|
+
const code = await processSnippetSimple(snippet, props.theme);
|
|
62
|
+
const fontSize = calculateStepFontSize(
|
|
63
|
+
snippet.value,
|
|
64
|
+
finalWidth,
|
|
65
|
+
finalHeight,
|
|
66
|
+
);
|
|
67
|
+
const charCount = flattenCode(code).length;
|
|
68
|
+
const lineCount = snippet.value.split("\n").length;
|
|
69
|
+
|
|
70
|
+
const stepConfig = stepConfigMap.get(snippet.filename);
|
|
71
|
+
const stepAnimation: Animation = stepConfig?.animation ?? props.animation;
|
|
72
|
+
const stepCps = stepConfig?.charsPerSecond ?? props.charsPerSecond;
|
|
73
|
+
|
|
74
|
+
const durationInFrames = calculateStepDuration(
|
|
75
|
+
charCount,
|
|
76
|
+
lineCount,
|
|
77
|
+
stepAnimation,
|
|
78
|
+
stepCps,
|
|
79
|
+
);
|
|
80
|
+
steps.push({
|
|
81
|
+
code,
|
|
82
|
+
fontSize,
|
|
83
|
+
durationInFrames,
|
|
84
|
+
animation: stepAnimation,
|
|
85
|
+
charsPerSecond: stepCps,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const maxCharacters = Math.max(
|
|
90
|
+
...contents
|
|
91
|
+
.map(({ value }) => value.split("\n"))
|
|
92
|
+
.flat()
|
|
93
|
+
.map((value) => value.replaceAll("\t", " ".repeat(tabSize)).length),
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
const maxFontSize = Math.max(...steps.map((s) => s.fontSize));
|
|
97
|
+
const codeWidth = maxFontSize * CHAR_WIDTH_RATIO * maxCharacters;
|
|
98
|
+
|
|
99
|
+
const durationInFrames = steps.reduce(
|
|
100
|
+
(sum, step) => sum + step.durationInFrames,
|
|
101
|
+
0,
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
durationInFrames,
|
|
106
|
+
width: finalWidth,
|
|
107
|
+
height: finalHeight,
|
|
108
|
+
props: {
|
|
109
|
+
theme: props.theme,
|
|
110
|
+
preset: props.preset,
|
|
111
|
+
animation: props.animation,
|
|
112
|
+
charsPerSecond: props.charsPerSecond,
|
|
113
|
+
brand: props.brand,
|
|
114
|
+
steps,
|
|
115
|
+
themeColors,
|
|
116
|
+
codeWidth,
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { getStaticFiles } from "@remotion/studio";
|
|
2
|
+
|
|
3
|
+
export type StaticFile = {
|
|
4
|
+
filename: string;
|
|
5
|
+
value: string;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export const getFilesFromStudio = async (): Promise<StaticFile[]> => {
|
|
9
|
+
const files = getStaticFiles();
|
|
10
|
+
|
|
11
|
+
const contents = files.map(async (file): Promise<StaticFile> => {
|
|
12
|
+
const res = await fetch(file.src);
|
|
13
|
+
const text = await res.text();
|
|
14
|
+
return { filename: file.name, value: text };
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
return Promise.all(contents);
|
|
18
|
+
};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { highlight } from "codehike/code";
|
|
2
|
+
import { StaticFile } from "./get-files";
|
|
3
|
+
import { Theme, loadTheme } from "./theme";
|
|
4
|
+
|
|
5
|
+
export const processSnippetSimple = async (step: StaticFile, theme: Theme) => {
|
|
6
|
+
const splitted = step.filename.split(".");
|
|
7
|
+
const extension = splitted[splitted.length - 1];
|
|
8
|
+
|
|
9
|
+
const shikiTheme = await loadTheme(theme);
|
|
10
|
+
|
|
11
|
+
const highlighted = await highlight(
|
|
12
|
+
{
|
|
13
|
+
lang: extension,
|
|
14
|
+
meta: step.filename,
|
|
15
|
+
value: step.value,
|
|
16
|
+
},
|
|
17
|
+
shikiTheme,
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
return highlighted;
|
|
21
|
+
};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { themeSchema } from "./theme";
|
|
3
|
+
|
|
4
|
+
export { themeSchema };
|
|
5
|
+
|
|
6
|
+
export const presetSchema = z.enum(["post", "tutorial", "square"]);
|
|
7
|
+
|
|
8
|
+
export const presetDimensions = {
|
|
9
|
+
post: { width: 720, height: 1280 },
|
|
10
|
+
tutorial: { width: 1920, height: 1080 },
|
|
11
|
+
square: { width: 1080, height: 1080 },
|
|
12
|
+
} as const;
|
|
13
|
+
|
|
14
|
+
export const animationSchema = z.enum(["morph", "typewriter", "cascade"]);
|
|
15
|
+
|
|
16
|
+
export const fileSchema = z.object({
|
|
17
|
+
filename: z.string(),
|
|
18
|
+
value: z.string(),
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
export const brandSchema = z.object({
|
|
22
|
+
logo: z.string().min(1).optional(),
|
|
23
|
+
twitter: z.string().min(1).optional(),
|
|
24
|
+
website: z.string().min(1).optional(),
|
|
25
|
+
accent: z
|
|
26
|
+
.string()
|
|
27
|
+
.regex(/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/)
|
|
28
|
+
.optional(),
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
export type BrandConfig = z.infer<typeof brandSchema>;
|
|
32
|
+
|
|
33
|
+
export const stepConfigSchema = z.object({
|
|
34
|
+
file: z.string(),
|
|
35
|
+
animation: animationSchema.optional(),
|
|
36
|
+
charsPerSecond: z.number().int().positive().optional(),
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
export const schema = z.object({
|
|
40
|
+
theme: themeSchema,
|
|
41
|
+
preset: presetSchema.default("tutorial"),
|
|
42
|
+
animation: animationSchema.default("morph"),
|
|
43
|
+
charsPerSecond: z.number().int().positive().default(30),
|
|
44
|
+
brand: brandSchema.optional(),
|
|
45
|
+
files: z.array(fileSchema).optional(),
|
|
46
|
+
stepConfigs: z.array(stepConfigSchema).optional(),
|
|
47
|
+
});
|