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,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,8 @@
1
+ import { AnnotationHandler, InnerToken } from "codehike/code";
2
+
3
+ export const tokenTransitions: AnnotationHandler = {
4
+ name: "token-transitions",
5
+ Token: ({ ...props }) => (
6
+ <InnerToken merge={props} style={{ display: "inline-block" }} />
7
+ ),
8
+ };
@@ -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
+ });