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/src/Main.tsx ADDED
@@ -0,0 +1,192 @@
1
+ import { AbsoluteFill, Series } from "remotion";
2
+ import { ProgressBar } from "./ProgressBar";
3
+ import { CodeTransition } from "./CodeTransition";
4
+ import { TypewriterTransition } from "./TypewriterTransition";
5
+ import { CascadeTransition } from "./CascadeTransition";
6
+ import { HighlightedCode } from "codehike/code";
7
+ import {
8
+ ThemeColors,
9
+ ThemeProvider,
10
+ getThemeVisuals,
11
+ } from "./calculate-metadata/theme";
12
+ import { useMemo } from "react";
13
+ import { RefreshOnCodeChange } from "./ReloadOnCodeChange";
14
+ import { verticalPadding, horizontalPadding } from "./font";
15
+ import { BrandOverlay } from "./BrandOverlay";
16
+
17
+ import type { animationSchema, schema } from "./calculate-metadata/schema";
18
+ import type { z } from "zod";
19
+
20
+ export type Animation = z.infer<typeof animationSchema>;
21
+
22
+ export type CodeStep = {
23
+ code: HighlightedCode;
24
+ fontSize: number;
25
+ durationInFrames: number;
26
+ animation: Animation;
27
+ charsPerSecond: number;
28
+ };
29
+
30
+ export type Props = {
31
+ steps: CodeStep[] | null;
32
+ themeColors: ThemeColors | null;
33
+ codeWidth: number | null;
34
+ } & Omit<z.infer<typeof schema>, "steps">;
35
+
36
+ export const Main: React.FC<Props> = ({
37
+ steps,
38
+ theme,
39
+ themeColors,
40
+ codeWidth,
41
+ brand,
42
+ }) => {
43
+ if (!steps) {
44
+ throw new Error("Steps are not defined");
45
+ }
46
+
47
+ const transitionDuration = 30;
48
+
49
+ if (!themeColors) {
50
+ throw new Error("Theme colors are not defined");
51
+ }
52
+
53
+ const themeVisuals = useMemo(() => {
54
+ return getThemeVisuals(theme, themeColors);
55
+ }, [theme, themeColors]);
56
+
57
+ const backgroundStyle: React.CSSProperties = useMemo(() => {
58
+ return {
59
+ backgroundColor: themeVisuals.canvasBackground,
60
+ backgroundImage: themeVisuals.canvasBackgroundImage,
61
+ overflow: "hidden",
62
+ };
63
+ }, [themeVisuals]);
64
+
65
+ const overlayStyle: React.CSSProperties | null = useMemo(() => {
66
+ if (themeVisuals.overlayKind === "none") {
67
+ return null;
68
+ }
69
+
70
+ if (themeVisuals.overlayKind === "grid") {
71
+ return {
72
+ pointerEvents: "none",
73
+ opacity: themeVisuals.overlayOpacity,
74
+ backgroundImage:
75
+ "linear-gradient(to right, rgba(255, 255, 255, 0.35) 1px, transparent 1px), linear-gradient(to bottom, rgba(255, 255, 255, 0.35) 1px, transparent 1px)",
76
+ backgroundSize: "28px 28px",
77
+ };
78
+ }
79
+
80
+ if (themeVisuals.overlayKind === "guides") {
81
+ return {
82
+ pointerEvents: "none",
83
+ opacity: themeVisuals.overlayOpacity,
84
+ backgroundImage:
85
+ "linear-gradient(to right, transparent 11.9%, rgba(255, 255, 255, 0.14) 11.9%, rgba(255, 255, 255, 0.14) 12.1%, transparent 12.1%, transparent 49.9%, rgba(255, 255, 255, 0.18) 49.9%, rgba(255, 255, 255, 0.18) 50.1%, transparent 50.1%, transparent 87.9%, rgba(255, 255, 255, 0.14) 87.9%, rgba(255, 255, 255, 0.14) 88.1%, transparent 88.1%), linear-gradient(to bottom, transparent 17.9%, rgba(255, 255, 255, 0.12) 17.9%, rgba(255, 255, 255, 0.12) 18.1%, transparent 18.1%, transparent 49.9%, rgba(255, 255, 255, 0.15) 49.9%, rgba(255, 255, 255, 0.15) 50.1%, transparent 50.1%, transparent 81.9%, rgba(255, 255, 255, 0.12) 81.9%, rgba(255, 255, 255, 0.12) 82.1%, transparent 82.1%), radial-gradient(circle at 0% 50%, rgba(255, 255, 255, 0.95) 0 3px, transparent 3.5px), radial-gradient(circle at 100% 50%, rgba(255, 255, 255, 0.95) 0 3px, transparent 3.5px)",
86
+ };
87
+ }
88
+
89
+ return {
90
+ pointerEvents: "none",
91
+ opacity: themeVisuals.overlayOpacity,
92
+ backgroundImage:
93
+ "repeating-linear-gradient(to bottom, rgba(0, 0, 0, 0) 0, rgba(0, 0, 0, 0) 2px, rgba(158, 255, 182, 0.25) 3px, rgba(0, 0, 0, 0) 4px)",
94
+ };
95
+ }, [themeVisuals]);
96
+
97
+ const codeWindowStyle: React.CSSProperties = useMemo(() => {
98
+ return {
99
+ width: codeWidth || "100%",
100
+ maxWidth: "100%",
101
+ padding: `${verticalPadding}px ${horizontalPadding}px`,
102
+ backgroundColor: themeVisuals.codeSurface,
103
+ border: `1px solid ${themeVisuals.codeBorder}`,
104
+ borderRadius: themeVisuals.codeRadius,
105
+ boxShadow: themeVisuals.codeShadow,
106
+ overflow: "hidden",
107
+ };
108
+ }, [codeWidth, themeVisuals]);
109
+
110
+ const sequenceStyle: React.CSSProperties = useMemo(() => {
111
+ return {
112
+ display: "flex",
113
+ flexDirection: "column",
114
+ gap: themeVisuals.showFilename ? 24 : 0,
115
+ height: "100%",
116
+ };
117
+ }, [themeVisuals.showFilename]);
118
+
119
+ const filenameStyle: React.CSSProperties = useMemo(() => {
120
+ return {
121
+ color: themeVisuals.filenameColor,
122
+ fontSize: 22,
123
+ fontWeight: 600,
124
+ letterSpacing: 0.4,
125
+ textTransform: "lowercase",
126
+ opacity: 0.9,
127
+ };
128
+ }, [themeVisuals.filenameColor]);
129
+
130
+ return (
131
+ <ThemeProvider themeColors={themeColors}>
132
+ <AbsoluteFill style={backgroundStyle}>
133
+ {overlayStyle ? <AbsoluteFill style={overlayStyle} /> : null}
134
+ <ProgressBar
135
+ steps={steps}
136
+ trackColor={themeVisuals.progressTrack}
137
+ fillColor={brand?.accent ?? themeVisuals.accent}
138
+ top={themeVisuals.progressTop}
139
+ height={themeVisuals.progressHeight}
140
+ gap={themeVisuals.progressGap}
141
+ />
142
+ <AbsoluteFill
143
+ style={{
144
+ justifyContent: "center",
145
+ alignItems: "center",
146
+ padding: `${verticalPadding}px ${horizontalPadding}px`,
147
+ }}
148
+ >
149
+ <div style={codeWindowStyle}>
150
+ <Series>
151
+ {steps.map((step, index) => (
152
+ <Series.Sequence
153
+ key={index}
154
+ layout="none"
155
+ durationInFrames={step.durationInFrames}
156
+ name={step.code.meta}
157
+ >
158
+ <div style={sequenceStyle}>
159
+ {themeVisuals.showFilename ? (
160
+ <div style={filenameStyle}>{step.code.meta}</div>
161
+ ) : null}
162
+ {step.animation === "typewriter" ? (
163
+ <TypewriterTransition
164
+ code={step.code}
165
+ charsPerSecond={step.charsPerSecond}
166
+ fontSize={step.fontSize}
167
+ />
168
+ ) : step.animation === "cascade" ? (
169
+ <CascadeTransition
170
+ code={step.code}
171
+ fontSize={step.fontSize}
172
+ />
173
+ ) : (
174
+ <CodeTransition
175
+ oldCode={steps[index - 1]?.code ?? null}
176
+ newCode={step.code}
177
+ durationInFrames={transitionDuration}
178
+ fontSize={step.fontSize}
179
+ />
180
+ )}
181
+ </div>
182
+ </Series.Sequence>
183
+ ))}
184
+ </Series>
185
+ </div>
186
+ </AbsoluteFill>
187
+ <BrandOverlay brand={brand} />
188
+ </AbsoluteFill>
189
+ <RefreshOnCodeChange />
190
+ </ThemeProvider>
191
+ );
192
+ };
@@ -0,0 +1,112 @@
1
+ import { useMemo } from "react";
2
+ import { useCurrentFrame } from "remotion";
3
+ import { useThemeColors } from "./calculate-metadata/theme";
4
+ import { horizontalPadding } from "./font";
5
+ import { CodeStep } from "./Main";
6
+ import React from "react";
7
+
8
+ const Step: React.FC<{
9
+ readonly index: number;
10
+ readonly currentStep: number;
11
+ readonly currentStepProgress: number;
12
+ readonly trackColor?: string;
13
+ readonly fillColor?: string;
14
+ }> = ({
15
+ index,
16
+ currentStep,
17
+ currentStepProgress,
18
+ trackColor,
19
+ fillColor,
20
+ }) => {
21
+ const themeColors = useThemeColors();
22
+
23
+ const outer: React.CSSProperties = useMemo(() => {
24
+ return {
25
+ backgroundColor:
26
+ trackColor ??
27
+ themeColors.editor.lineHighlightBackground ??
28
+ themeColors.editor.rangeHighlightBackground,
29
+ borderRadius: 6,
30
+ overflow: "hidden",
31
+ height: "100%",
32
+ flex: 1,
33
+ };
34
+ }, [themeColors, trackColor]);
35
+
36
+ const inner: React.CSSProperties = useMemo(() => {
37
+ return {
38
+ height: "100%",
39
+ backgroundColor: fillColor ?? themeColors.icon.foreground,
40
+ width:
41
+ index > currentStep
42
+ ? 0
43
+ : index === currentStep
44
+ ? currentStepProgress * 100 + "%"
45
+ : "100%",
46
+ };
47
+ }, [fillColor, themeColors.icon.foreground, index, currentStep, currentStepProgress]);
48
+
49
+ return (
50
+ <div style={outer}>
51
+ <div style={inner} />
52
+ </div>
53
+ );
54
+ };
55
+
56
+ export function ProgressBar({
57
+ steps,
58
+ trackColor,
59
+ fillColor,
60
+ top = 48,
61
+ height = 6,
62
+ gap = 12,
63
+ }: {
64
+ readonly steps: CodeStep[];
65
+ readonly trackColor?: string;
66
+ readonly fillColor?: string;
67
+ readonly top?: number;
68
+ readonly height?: number;
69
+ readonly gap?: number;
70
+ }) {
71
+ const frame = useCurrentFrame();
72
+
73
+ const { currentStep, currentStepProgress } = useMemo(() => {
74
+ let accumulated = 0;
75
+ for (let i = 0; i < steps.length; i++) {
76
+ const stepEnd = accumulated + steps[i].durationInFrames;
77
+ if (frame < stepEnd) {
78
+ const progress = (frame - accumulated) / steps[i].durationInFrames;
79
+ return { currentStep: i, currentStepProgress: progress };
80
+ }
81
+ accumulated = stepEnd;
82
+ }
83
+ return { currentStep: steps.length - 1, currentStepProgress: 1 };
84
+ }, [frame, steps]);
85
+
86
+ const container: React.CSSProperties = useMemo(() => {
87
+ return {
88
+ position: "absolute",
89
+ top,
90
+ height,
91
+ left: horizontalPadding,
92
+ right: horizontalPadding,
93
+ display: "flex",
94
+ gap,
95
+ };
96
+ }, [gap, height, top]);
97
+
98
+ return (
99
+ <div style={container}>
100
+ {steps.map((_, index) => (
101
+ <Step
102
+ key={index}
103
+ trackColor={trackColor}
104
+ fillColor={fillColor}
105
+ currentStep={currentStep}
106
+ currentStepProgress={currentStepProgress}
107
+ index={index}
108
+ />
109
+ ))}
110
+ </div>
111
+ );
112
+ }
@@ -0,0 +1,40 @@
1
+ import { getStaticFiles, reevaluateComposition } from "@remotion/studio";
2
+ import { useState } from "react";
3
+ import React, { useEffect } from "react";
4
+ import { watchPublicFolder } from "@remotion/studio";
5
+ import { useRemotionEnvironment } from "remotion";
6
+
7
+ const getCurrentHash = () => {
8
+ const files = getStaticFiles();
9
+ const codeFiles = files.filter((file) => file.name.startsWith("code"));
10
+ const contents = codeFiles.map((file) => file.src + file.lastModified);
11
+ return contents.join("");
12
+ };
13
+
14
+ export const RefreshOnCodeChange: React.FC = () => {
15
+ const [files, setFiles] = useState(getCurrentHash());
16
+ const env = useRemotionEnvironment();
17
+
18
+ useEffect(() => {
19
+ if (env.isReadOnlyStudio) {
20
+ return;
21
+ }
22
+ if (!env.isStudio) {
23
+ return;
24
+ }
25
+ const { cancel } = watchPublicFolder(() => {
26
+ const hash = getCurrentHash();
27
+ if (hash !== files) {
28
+ setFiles(hash);
29
+ reevaluateComposition();
30
+ }
31
+ });
32
+
33
+ return () => {
34
+ cancel();
35
+ };
36
+ // oxlint-disable-next-line
37
+ }, [files, env.isReadOnlyStudio]);
38
+
39
+ return null;
40
+ };
package/src/Root.tsx ADDED
@@ -0,0 +1,89 @@
1
+ import { Composition, Folder } from "remotion";
2
+ import { Main } from "./Main";
3
+ import "./index.css";
4
+
5
+ import { calculateMetadata } from "./calculate-metadata/calculate-metadata";
6
+ import { schema } from "./calculate-metadata/schema";
7
+
8
+ export const RemotionRoot = () => {
9
+ return (
10
+ <>
11
+ <Composition
12
+ id="Main"
13
+ component={Main}
14
+ defaultProps={{
15
+ steps: null,
16
+ themeColors: null,
17
+ codeWidth: null,
18
+ theme: "vercel-dark" as const,
19
+ preset: "tutorial" as const,
20
+ animation: "morph" as const,
21
+ charsPerSecond: 30,
22
+ }}
23
+ fps={30}
24
+ height={1080}
25
+ calculateMetadata={calculateMetadata}
26
+ schema={schema}
27
+ />
28
+ <Folder name="Presets">
29
+ <Composition
30
+ id="Preset-Post"
31
+ component={Main}
32
+ width={720}
33
+ height={1280}
34
+ fps={30}
35
+ durationInFrames={150}
36
+ defaultProps={{
37
+ steps: null,
38
+ themeColors: null,
39
+ codeWidth: null,
40
+ theme: "vercel-dark" as const,
41
+ preset: "post" as const,
42
+ animation: "morph" as const,
43
+ charsPerSecond: 30,
44
+ }}
45
+ calculateMetadata={calculateMetadata}
46
+ schema={schema}
47
+ />
48
+ <Composition
49
+ id="Preset-Tutorial"
50
+ component={Main}
51
+ width={1920}
52
+ height={1080}
53
+ fps={30}
54
+ durationInFrames={300}
55
+ defaultProps={{
56
+ steps: null,
57
+ themeColors: null,
58
+ codeWidth: null,
59
+ theme: "vercel-dark" as const,
60
+ preset: "tutorial" as const,
61
+ animation: "morph" as const,
62
+ charsPerSecond: 30,
63
+ }}
64
+ calculateMetadata={calculateMetadata}
65
+ schema={schema}
66
+ />
67
+ <Composition
68
+ id="Preset-Square"
69
+ component={Main}
70
+ width={1080}
71
+ height={1080}
72
+ fps={30}
73
+ durationInFrames={150}
74
+ defaultProps={{
75
+ steps: null,
76
+ themeColors: null,
77
+ codeWidth: null,
78
+ theme: "vercel-dark" as const,
79
+ preset: "square" as const,
80
+ animation: "morph" as const,
81
+ charsPerSecond: 30,
82
+ }}
83
+ calculateMetadata={calculateMetadata}
84
+ schema={schema}
85
+ />
86
+ </Folder>
87
+ </>
88
+ );
89
+ };
@@ -0,0 +1,167 @@
1
+ import { useMemo } from "react";
2
+ import { interpolate, useCurrentFrame, useVideoConfig } from "remotion";
3
+ import { HighlightedCode } from "codehike/code";
4
+ import { fontFamily, fontSize as baseFontSize, tabSize } from "./font";
5
+ import { useThemeColors } from "./calculate-metadata/theme";
6
+
7
+ interface CharInfo {
8
+ char: string;
9
+ style: React.CSSProperties;
10
+ }
11
+
12
+ export function flattenCode(code: HighlightedCode): CharInfo[] {
13
+ const chars: CharInfo[] = [];
14
+
15
+ for (const token of code.tokens) {
16
+ if (typeof token === "string") {
17
+ for (const char of token) {
18
+ chars.push({ char, style: {} });
19
+ }
20
+ } else {
21
+ const [content, color, style] = token;
22
+ const tokenStyle: React.CSSProperties = {
23
+ ...style,
24
+ color: color ?? style?.color,
25
+ };
26
+ for (const char of content) {
27
+ chars.push({ char, style: tokenStyle });
28
+ }
29
+ }
30
+ }
31
+
32
+ while (chars.length > 0 && chars[chars.length - 1].char === "\n") {
33
+ chars.pop();
34
+ }
35
+
36
+ return chars;
37
+ }
38
+
39
+ interface Segment {
40
+ text: string;
41
+ style: React.CSSProperties;
42
+ startIndex: number;
43
+ isNewline: boolean;
44
+ }
45
+
46
+ function groupCharsIntoSegments(chars: CharInfo[]): Segment[] {
47
+ const segments: Segment[] = [];
48
+ let i = 0;
49
+
50
+ while (i < chars.length) {
51
+ const { char, style } = chars[i];
52
+
53
+ if (char === "\n") {
54
+ segments.push({ text: "\n", style: {}, startIndex: i, isNewline: true });
55
+ i++;
56
+ continue;
57
+ }
58
+
59
+ let text = char;
60
+ const startIndex = i;
61
+ i++;
62
+
63
+ while (
64
+ i < chars.length &&
65
+ chars[i].char !== "\n" &&
66
+ chars[i].style.color === style.color
67
+ ) {
68
+ text += chars[i].char;
69
+ i++;
70
+ }
71
+
72
+ segments.push({ text, style, startIndex, isNewline: false });
73
+ }
74
+
75
+ return segments;
76
+ }
77
+
78
+ export function TypewriterTransition({
79
+ code,
80
+ charsPerSecond = 30,
81
+ fontSize,
82
+ }: {
83
+ readonly code: HighlightedCode;
84
+ readonly charsPerSecond?: number;
85
+ readonly fontSize?: number;
86
+ }) {
87
+ const frame = useCurrentFrame();
88
+ const { fps } = useVideoConfig();
89
+ const themeColors = useThemeColors();
90
+
91
+ const chars = useMemo(() => flattenCode(code), [code]);
92
+ const segments = useMemo(() => groupCharsIntoSegments(chars), [chars]);
93
+ const totalChars = chars.length;
94
+
95
+ const typingDuration = (totalChars / charsPerSecond) * fps;
96
+
97
+ const charsShown = Math.floor(
98
+ interpolate(frame, [0, typingDuration], [0, totalChars], {
99
+ extrapolateRight: "clamp",
100
+ extrapolateLeft: "clamp",
101
+ }),
102
+ );
103
+
104
+ const isComplete = charsShown >= totalChars;
105
+ const blinkInterval = Math.round(fps / 2);
106
+ const cursorVisible = isComplete
107
+ ? Math.floor(frame / blinkInterval) % 2 === 0
108
+ : true;
109
+
110
+ return (
111
+ <pre
112
+ style={{
113
+ fontSize: fontSize ?? baseFontSize,
114
+ lineHeight: 1.5,
115
+ fontFamily,
116
+ tabSize,
117
+ margin: 0,
118
+ whiteSpace: "pre",
119
+ }}
120
+ >
121
+ <code>
122
+ {segments.map((segment, idx) => {
123
+ const segmentEnd = segment.startIndex + segment.text.length;
124
+
125
+ if (segment.startIndex >= charsShown) {
126
+ return null;
127
+ }
128
+
129
+ if (segment.isNewline) {
130
+ return <br key={idx} />;
131
+ }
132
+
133
+ const visibleLength = Math.min(
134
+ segment.text.length,
135
+ charsShown - segment.startIndex,
136
+ );
137
+
138
+ if (segmentEnd <= charsShown) {
139
+ return (
140
+ <span key={idx} style={segment.style}>
141
+ {segment.text}
142
+ </span>
143
+ );
144
+ }
145
+
146
+ return (
147
+ <span key={idx} style={segment.style}>
148
+ {segment.text.slice(0, visibleLength)}
149
+ </span>
150
+ );
151
+ })}
152
+ <span
153
+ key="cursor"
154
+ style={{
155
+ display: "inline-block",
156
+ width: "0.6em",
157
+ height: "1.2em",
158
+ backgroundColor: themeColors?.foreground ?? "currentColor",
159
+ opacity: cursorVisible ? 0.8 : 0,
160
+ verticalAlign: "text-bottom",
161
+ marginLeft: 2,
162
+ }}
163
+ />
164
+ </code>
165
+ </pre>
166
+ );
167
+ }
@@ -0,0 +1,78 @@
1
+ import {
2
+ InlineAnnotation,
3
+ AnnotationHandler,
4
+ InnerLine,
5
+ Pre,
6
+ } from "codehike/code";
7
+ import { interpolate, useCurrentFrame } from "remotion";
8
+ import { useThemeColors } from "../calculate-metadata/theme";
9
+ import { mix, readableColor } from "polished";
10
+
11
+ export const callout: AnnotationHandler = {
12
+ name: "callout",
13
+ transform: (annotation: InlineAnnotation) => {
14
+ const { name, query, lineNumber, fromColumn, toColumn, data } = annotation;
15
+ return {
16
+ name,
17
+ query,
18
+ fromLineNumber: lineNumber,
19
+ toLineNumber: lineNumber,
20
+ data: { ...data, column: (fromColumn + toColumn) / 2 },
21
+ };
22
+ },
23
+ AnnotatedLine: ({ annotation, ...props }) => {
24
+ const { column, codeblock } = annotation.data;
25
+ const { indentation } = props;
26
+ const frame = useCurrentFrame();
27
+
28
+ const opacity = interpolate(frame, [25, 35], [0, 1], {
29
+ extrapolateLeft: "clamp",
30
+ extrapolateRight: "clamp",
31
+ });
32
+
33
+ const themeColors = useThemeColors();
34
+
35
+ const color = readableColor(themeColors.background);
36
+ const calloutColor = mix(0.08, color, themeColors.background);
37
+
38
+ return (
39
+ <>
40
+ <InnerLine merge={props} />
41
+ <div
42
+ style={{
43
+ opacity,
44
+ minWidth: `${column + 4}ch`,
45
+ marginLeft: `${indentation}ch`,
46
+ width: "fit-content",
47
+ backgroundColor: calloutColor,
48
+ padding: "1rem 2rem",
49
+ position: "relative",
50
+ marginTop: "0.25rem",
51
+ whiteSpace: "pre-wrap",
52
+ color: themeColors.editor.foreground,
53
+ }}
54
+ >
55
+ <div
56
+ style={{
57
+ left: `${column - indentation - 1}ch`,
58
+ position: "absolute",
59
+ width: "1rem",
60
+ height: "1rem",
61
+ transform: "rotate(45deg) translateY(-50%)",
62
+ top: "-2px",
63
+ backgroundColor: calloutColor,
64
+ }}
65
+ />
66
+ {codeblock ? (
67
+ <Pre
68
+ code={codeblock}
69
+ style={{ margin: 0, fontFamily: "inherit" }}
70
+ />
71
+ ) : (
72
+ annotation.data.children || annotation.query
73
+ )}
74
+ </div>
75
+ </>
76
+ );
77
+ },
78
+ };