reelkit-cli 0.1.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/LICENSE +21 -0
- package/README.md +91 -0
- package/bin/reelkit.mjs +5 -0
- package/package.json +89 -0
- package/skill/SKILL.md +101 -0
- package/skill/THIRD_PARTY.md +20 -0
- package/skill/command.md +5 -0
- package/skill/reference/asset-reuse.md +31 -0
- package/skill/reference/captions.md +57 -0
- package/skill/reference/component-authoring.md +26 -0
- package/skill/reference/kit.md +135 -0
- package/skill/reference/motion-design.md +62 -0
- package/skill/reference/remotion-composition.md +58 -0
- package/skill/reference/scene-treatments.md +35 -0
- package/skill/reference/scriptwriting.md +44 -0
- package/skill/reference/sound-design.md +43 -0
- package/src/agents.ts +103 -0
- package/src/api/client.ts +58 -0
- package/src/cli.ts +91 -0
- package/src/commands/assets.ts +196 -0
- package/src/commands/auth.ts +118 -0
- package/src/commands/build.ts +109 -0
- package/src/commands/init.ts +32 -0
- package/src/commands/install.ts +24 -0
- package/src/commands/plan.ts +45 -0
- package/src/context.ts +21 -0
- package/src/contract/index.ts +157 -0
- package/src/credentials.ts +55 -0
- package/src/open.ts +48 -0
- package/src/pipeline/review.ts +30 -0
- package/src/pipeline/schema.ts +110 -0
- package/src/pipeline/timing.ts +30 -0
- package/src/project/manifest.ts +71 -0
- package/src/project/probe.ts +54 -0
- package/src/project/project.ts +58 -0
- package/src/project/serve.ts +81 -0
- package/src/remotion/Root.tsx +29 -0
- package/src/remotion/kit/Captions.tsx +77 -0
- package/src/remotion/kit/Counter.tsx +18 -0
- package/src/remotion/kit/Entrance.tsx +53 -0
- package/src/remotion/kit/FootageLayer.tsx +10 -0
- package/src/remotion/kit/Icon.tsx +35 -0
- package/src/remotion/kit/KenBurnsImage.tsx +18 -0
- package/src/remotion/kit/Layers.tsx +46 -0
- package/src/remotion/kit/LowerThird.tsx +17 -0
- package/src/remotion/kit/SceneFrame.tsx +19 -0
- package/src/remotion/kit/ScreenOverlay.tsx +15 -0
- package/src/remotion/kit/Sfx.tsx +11 -0
- package/src/remotion/kit/TitleCard.tsx +23 -0
- package/src/remotion/kit/Voiceover.tsx +5 -0
- package/src/remotion/kit/brand-icons.ts +37 -0
- package/src/remotion/kit/docs.ts +98 -0
- package/src/remotion/kit/index.ts +20 -0
- package/src/remotion/kit/media.ts +10 -0
- package/src/remotion/kit/theme.ts +131 -0
- package/src/remotion/types.ts +2 -0
- package/src/render/component-preview.ts +65 -0
- package/src/render/deps.ts +8 -0
- package/src/render/render.ts +67 -0
- package/src/render/validate.ts +191 -0
- package/src/testing/conformance.ts +396 -0
- package/src/testing/fake-api.ts +228 -0
- package/src/testing/fixtures.ts +44 -0
- package/src/ui/CommandView.tsx +18 -0
- package/src/ui/run.tsx +20 -0
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
export const KIT_DOCS = `
|
|
2
|
+
Import from "reelkit/kit". Media props (src) take urls[key]; the kit components also accept the bare project path. For your own <Img>, <Audio> or <OffthreadVideo>, always pass urls[key]. Inside a SceneFrame, useCurrentFrame() starts at 0 for that scene.
|
|
3
|
+
|
|
4
|
+
SceneFrame { from: number; durationInFrames: number; children }
|
|
5
|
+
Wraps one scene. Places children on the timeline and fades them in and out.
|
|
6
|
+
<SceneFrame from={s.startFrame} durationInFrames={s.durationFrames}>...</SceneFrame>
|
|
7
|
+
|
|
8
|
+
TitleCard { text: string; subtitle?: string; color?: string; background?: string }
|
|
9
|
+
Large centred title that springs in.
|
|
10
|
+
<TitleCard text="Ship faster" subtitle="in three steps" background="#101020" />
|
|
11
|
+
|
|
12
|
+
LowerThird { title: string; subtitle?: string; accent?: string }
|
|
13
|
+
Name/label strip that slides in from the left, low on the screen.
|
|
14
|
+
<LowerThird title="Step 1" subtitle="Connect your account" />
|
|
15
|
+
|
|
16
|
+
Captions { words: WordTiming[]; mode?: "highlight" | "pop" | "karaoke"; highlight?: string; color?: string; perLine?: number; uppercase?: boolean; bottom?: number; face?: string; rtl?: boolean }
|
|
17
|
+
Word-timed captions near the bottom. Pass the scene's words from the manifest. Pick one mode for the whole video:
|
|
18
|
+
"highlight" (a line, spoken word coloured), "pop" (1-3 words popping in as spoken), "karaoke" (a line filling with colour).
|
|
19
|
+
bottom is the distance from the bottom edge as a fraction of the height (default 0.16).
|
|
20
|
+
For Hebrew narration pass face={font("heebo")} and rtl.
|
|
21
|
+
<Captions words={s.words} mode="pop" highlight={palette.hero} uppercase />
|
|
22
|
+
|
|
23
|
+
Counter { from?: number; to: number; durationFrames?: number; prefix?: string; suffix?: string; color?: string }
|
|
24
|
+
Centred number that counts up.
|
|
25
|
+
<Counter to={10000} suffix="+" durationFrames={45} />
|
|
26
|
+
|
|
27
|
+
KenBurnsImage { src: string; zoom?: number; direction?: "in" | "out" }
|
|
28
|
+
Full-frame image with a slow zoom and pan. src must come from urls[...]. Alternate direction between consecutive image scenes.
|
|
29
|
+
<KenBurnsImage src={urls[s.imageKey]} />
|
|
30
|
+
|
|
31
|
+
FootageLayer { src: string; muted?: boolean; dim?: number }
|
|
32
|
+
Full-frame user footage. Place it once, outside the scenes, as the bottom layer. dim (0-1) darkens it for legibility.
|
|
33
|
+
<FootageLayer src={urls[manifest.footageKey]} dim={0.25} />
|
|
34
|
+
|
|
35
|
+
BgMesh { bg: string; hero: string; accent?: string }
|
|
36
|
+
The bottom layer of the video: the base colour with two slow-drifting soft colour fields. Use it instead of a flat background.
|
|
37
|
+
<BgMesh bg={palette.bg} hero={palette.hero} accent={palette.accent} />
|
|
38
|
+
|
|
39
|
+
Grade { color: string; strength?: number } Grain { opacity?: number; blend?: "multiply" | "overlay" } Vignette { strength?: number }
|
|
40
|
+
Finishing layers, placed once at the very top of the video in this order: Grade, Grain, Vignette.
|
|
41
|
+
Grade tints everything toward the hero colour so images and graphics read as one look (strength 0.10-0.15 on light themes, 0.18-0.25 on dark).
|
|
42
|
+
Grain uses blend "multiply" on light themes and "overlay" on dark ones.
|
|
43
|
+
<Grade color={palette.hero} /><Grain blend="overlay" /><Vignette />
|
|
44
|
+
|
|
45
|
+
Entrance { delay?: number; exitAt?: number; rise?: number; breathe?: boolean; style?; children }
|
|
46
|
+
The standard way to bring anything in: fade + rise + scale on a spring, then a slow idle breathe. delay and exitAt are frames from the scene start; exitAt adds a faster exit.
|
|
47
|
+
<Entrance delay={8} exitAt={s.durationFrames - 14}><Card /></Entrance>
|
|
48
|
+
|
|
49
|
+
WordReveal { text: string; delay?: number; per?: number; highlight?: string; highlightColor?: string; style? }
|
|
50
|
+
Headline that rises in word by word from behind a mask. highlight colours one word.
|
|
51
|
+
<WordReveal text="Stretch first, phone second" highlight="first" highlightColor={palette.hero} style={{ fontFamily: fonts.display, fontWeight: 800, fontSize: width * 0.09, color: palette.ink }} />
|
|
52
|
+
|
|
53
|
+
fonts { display: string; body: string }
|
|
54
|
+
Loaded font families. Use fonts.display (weights 600-800) for headlines and numbers, fonts.body for supporting text. Never leave hero text on a default font.
|
|
55
|
+
|
|
56
|
+
Icon { name: IconName; size: number; color?: string } brandColor(name): string
|
|
57
|
+
A platform's real logo mark, or a plain interface glyph. size is in pixels. color "brand" uses the platform's own colour.
|
|
58
|
+
Brands: instagram, tiktok, youtube, x, facebook, whatsapp, telegram, twitch, discord, spotify, snapchat, pinterest, messenger, threads,
|
|
59
|
+
reddit, github, gmail, soundcloud, behance, vimeo, dribbble, imessage, apple, googlemessages, signal, googlecalendar, stripe, paypal,
|
|
60
|
+
applepay, shopify, notion, zoom. Glyphs: message, mail, bell, phone, calendar, cart, heart, star, user, play.
|
|
61
|
+
Use Icon for any platform logo. Never draw a logo yourself, never use a letter or an emoji in its place, and only show a
|
|
62
|
+
platform's mark when the video really refers to that platform.
|
|
63
|
+
<Icon name="instagram" size={width * 0.06} color="brand" />
|
|
64
|
+
|
|
65
|
+
font(name): string
|
|
66
|
+
Loads one extra typeface and returns its family name. Call it once at the top of a file: const heavy = font("montserrat").
|
|
67
|
+
montserrat (300, 800, 900), montserratItalic (800, 900), kanit (600), roboto (300, 500, 700, 900), robotoSlab (700),
|
|
68
|
+
bebasNeue (tall condensed caps), archivoBlack (very heavy), unbounded (wide heavy display, 800, 900), modak (chunky rounded),
|
|
69
|
+
greatVibes (formal script), sacramento (monoline script), outfit (geometric sans 100, 300, 500, 900), syne (wide heavy 800),
|
|
70
|
+
lilitaOne (heavy rounded), kaushanScript (bold brush script), yellowtail (flowing brush script), mrsSaintDelafield (signature script),
|
|
71
|
+
permanentMarker (marker lettering), bungeeShade (outlined sign capitals), cinzel (Roman capitals 700, 900: epic),
|
|
72
|
+
playfairDisplay (elegant serif 700, 900), orbitron (sci-fi 700, 900), rye (western), creepster (horror).
|
|
73
|
+
Hebrew text must use a Hebrew face; every face above is Latin only and Hebrew would fall back to a default font. Hebrew faces
|
|
74
|
+
(all also cover Latin): heebo, rubik, notoSansHebrew (400, 700, 900: neutral sans), assistant (400, 700, 800), alef (400, 700),
|
|
75
|
+
secularOne (strong headline), varelaRound (soft rounded), fredoka (playful rounded 500, 700), karantina (tall condensed 400, 700),
|
|
76
|
+
suezOne (heavy serif headline), frankRuhlLibre (book serif 400, 700, 900), davidLibre (traditional serif 400, 700),
|
|
77
|
+
amaticSC (hand-lettered 400, 700).
|
|
78
|
+
Use at most two typefaces in one video.
|
|
79
|
+
|
|
80
|
+
springs { snappy, smooth, heavy } ease { out, in, inOut }
|
|
81
|
+
Spring configs for spring({ frame, fps, config: springs.smooth }) and easings for interpolate(..., { easing: ease.out, extrapolateLeft: "clamp", extrapolateRight: "clamp" }).
|
|
82
|
+
|
|
83
|
+
palettes { darkTech, warmEditorial, warmPremium, cleanLight } each is a Palette { bg, ink, hero, accent, dim }
|
|
84
|
+
Proven starting palettes. Pick one that suits the topic, or define your own Palette object. Declare it once at the top of Video.tsx and take every colour from it.
|
|
85
|
+
|
|
86
|
+
ScreenOverlay { src: string; durationSec?: number; opacity?: number }
|
|
87
|
+
Lays a shared overlay clip (film marks, HUD frames) over the video with a screen blend. Pull one with: reelkit assets search "<description>" --kind overlay, then reelkit assets pull <id>; the clip's durationSec is in its entry in assets/library.json.
|
|
88
|
+
Place it once, above the scenes and below Grade. Works best on dark themes.
|
|
89
|
+
<ScreenOverlay src={urls["assets/lib/<id>/clip.mp4"]} durationSec={5} opacity={0.5} />
|
|
90
|
+
|
|
91
|
+
Sfx { src: string; at?: number; volume?: number }
|
|
92
|
+
Plays one sound effect from the shared library, starting at the frame given by "at", counted from the start of the enclosing scene. Pull one with: reelkit assets search "<description>" --kind sfx, then reelkit assets pull <id>.
|
|
93
|
+
<Sfx src={urls["assets/lib/<id>/clip.mp3"]} at={10} volume={0.35} />
|
|
94
|
+
|
|
95
|
+
Voiceover { src: string; volume?: number }
|
|
96
|
+
The scene's narration audio. One per narrated scene, inside its SceneFrame. Guard it with s.voiceoverKey so a scene without a recording still renders.
|
|
97
|
+
{s.voiceoverKey ? <Voiceover src={urls[s.voiceoverKey]} /> : null}
|
|
98
|
+
`.trim();
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export { Captions } from "./Captions";
|
|
2
|
+
export { Counter } from "./Counter";
|
|
3
|
+
export { Entrance, WordReveal } from "./Entrance";
|
|
4
|
+
export { FootageLayer } from "./FootageLayer";
|
|
5
|
+
export { brandColor, Icon } from "./Icon";
|
|
6
|
+
export type { GlyphName, IconName } from "./Icon";
|
|
7
|
+
export type { BrandName } from "./brand-icons";
|
|
8
|
+
export { KenBurnsImage } from "./KenBurnsImage";
|
|
9
|
+
export { BgMesh, Grade, Grain, Vignette } from "./Layers";
|
|
10
|
+
export { LowerThird } from "./LowerThird";
|
|
11
|
+
export { SceneFrame } from "./SceneFrame";
|
|
12
|
+
export { ScreenOverlay } from "./ScreenOverlay";
|
|
13
|
+
export { useMedia } from "./media";
|
|
14
|
+
export { Sfx } from "./Sfx";
|
|
15
|
+
export { TitleCard } from "./TitleCard";
|
|
16
|
+
export { Voiceover } from "./Voiceover";
|
|
17
|
+
export type { VideoProps } from "../types";
|
|
18
|
+
export { ease, font, fonts, palettes, springs } from "./theme";
|
|
19
|
+
export type { CaptionMode } from "./Captions";
|
|
20
|
+
export type { KitFont, Palette } from "./theme";
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { createContext, useContext } from "react";
|
|
2
|
+
|
|
3
|
+
// The project's media links, keyed by project path (assets/...). Root provides them, so a kit component works whether it is
|
|
4
|
+
// given a link (urls[key]) or just the project path (key).
|
|
5
|
+
export const UrlsContext = createContext<Record<string, string>>({});
|
|
6
|
+
|
|
7
|
+
export function useMedia(src: string): string {
|
|
8
|
+
const urls = useContext(UrlsContext);
|
|
9
|
+
return urls[src] ?? src;
|
|
10
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { loadFont as loadBody } from "@remotion/google-fonts/Inter";
|
|
2
|
+
import { loadFont as loadDisplay } from "@remotion/google-fonts/Sora";
|
|
3
|
+
import { loadFont as loadArchivoBlack } from "@remotion/google-fonts/ArchivoBlack";
|
|
4
|
+
import { loadFont as loadBebasNeue } from "@remotion/google-fonts/BebasNeue";
|
|
5
|
+
import { loadFont as loadGreatVibes } from "@remotion/google-fonts/GreatVibes";
|
|
6
|
+
import { loadFont as loadKanit } from "@remotion/google-fonts/Kanit";
|
|
7
|
+
import { loadFont as loadModak } from "@remotion/google-fonts/Modak";
|
|
8
|
+
import { loadFont as loadMontserrat } from "@remotion/google-fonts/Montserrat";
|
|
9
|
+
import { loadFont as loadRoboto } from "@remotion/google-fonts/Roboto";
|
|
10
|
+
import { loadFont as loadRobotoSlab } from "@remotion/google-fonts/RobotoSlab";
|
|
11
|
+
import { loadFont as loadSacramento } from "@remotion/google-fonts/Sacramento";
|
|
12
|
+
import { loadFont as loadUnbounded } from "@remotion/google-fonts/Unbounded";
|
|
13
|
+
import { loadFont as loadBungeeShade } from "@remotion/google-fonts/BungeeShade";
|
|
14
|
+
import { loadFont as loadCinzel } from "@remotion/google-fonts/Cinzel";
|
|
15
|
+
import { loadFont as loadCreepster } from "@remotion/google-fonts/Creepster";
|
|
16
|
+
import { loadFont as loadHeebo } from "@remotion/google-fonts/Heebo";
|
|
17
|
+
import { loadFont as loadKaushanScript } from "@remotion/google-fonts/KaushanScript";
|
|
18
|
+
import { loadFont as loadLilitaOne } from "@remotion/google-fonts/LilitaOne";
|
|
19
|
+
import { loadFont as loadMrsSaintDelafield } from "@remotion/google-fonts/MrsSaintDelafield";
|
|
20
|
+
import { loadFont as loadOrbitron } from "@remotion/google-fonts/Orbitron";
|
|
21
|
+
import { loadFont as loadOutfit } from "@remotion/google-fonts/Outfit";
|
|
22
|
+
import { loadFont as loadPermanentMarker } from "@remotion/google-fonts/PermanentMarker";
|
|
23
|
+
import { loadFont as loadPlayfairDisplay } from "@remotion/google-fonts/PlayfairDisplay";
|
|
24
|
+
import { loadFont as loadRubik } from "@remotion/google-fonts/Rubik";
|
|
25
|
+
import { loadFont as loadRye } from "@remotion/google-fonts/Rye";
|
|
26
|
+
import { loadFont as loadSyne } from "@remotion/google-fonts/Syne";
|
|
27
|
+
import { loadFont as loadYellowtail } from "@remotion/google-fonts/Yellowtail";
|
|
28
|
+
import { loadFont as loadAlef } from "@remotion/google-fonts/Alef";
|
|
29
|
+
import { loadFont as loadAmaticSC } from "@remotion/google-fonts/AmaticSC";
|
|
30
|
+
import { loadFont as loadAssistant } from "@remotion/google-fonts/Assistant";
|
|
31
|
+
import { loadFont as loadDavidLibre } from "@remotion/google-fonts/DavidLibre";
|
|
32
|
+
import { loadFont as loadFrankRuhlLibre } from "@remotion/google-fonts/FrankRuhlLibre";
|
|
33
|
+
import { loadFont as loadFredoka } from "@remotion/google-fonts/Fredoka";
|
|
34
|
+
import { loadFont as loadKarantina } from "@remotion/google-fonts/Karantina";
|
|
35
|
+
import { loadFont as loadNotoSansHebrew } from "@remotion/google-fonts/NotoSansHebrew";
|
|
36
|
+
import { loadFont as loadSecularOne } from "@remotion/google-fonts/SecularOne";
|
|
37
|
+
import { loadFont as loadSuezOne } from "@remotion/google-fonts/SuezOne";
|
|
38
|
+
import { loadFont as loadVarelaRound } from "@remotion/google-fonts/VarelaRound";
|
|
39
|
+
import { Easing } from "remotion";
|
|
40
|
+
|
|
41
|
+
// Loaded once for every video. Hero text must never fall back to a system default font.
|
|
42
|
+
const display = loadDisplay("normal", { weights: ["600", "700", "800"], subsets: ["latin"] });
|
|
43
|
+
const body = loadBody("normal", { weights: ["400", "500", "700"], subsets: ["latin"] });
|
|
44
|
+
|
|
45
|
+
// Heebo is loaded for every video and appended to every family as a fallback, so Hebrew text set in a Latin-only
|
|
46
|
+
// face still renders in a proper Hebrew typeface instead of a system default.
|
|
47
|
+
const hebrewFallback = loadHeebo("normal", { weights: ["400", "700", "900"], subsets: ["hebrew"] }).fontFamily;
|
|
48
|
+
const withHebrew = (family: string) => (family === hebrewFallback ? family : `${family}, ${hebrewFallback}`);
|
|
49
|
+
|
|
50
|
+
export const fonts = { display: withHebrew(display.fontFamily), body: withHebrew(body.fontFamily) };
|
|
51
|
+
|
|
52
|
+
// Extra typefaces, loaded only by the videos that ask for them. Call font("montserrat") at the top of a file
|
|
53
|
+
// and use the returned family name. Each loads just the weights listed here.
|
|
54
|
+
const latin = { subsets: ["latin" as const] };
|
|
55
|
+
const hebrew = { subsets: ["hebrew" as const, "latin" as const] };
|
|
56
|
+
const loaders = {
|
|
57
|
+
montserrat: () => loadMontserrat("normal", { weights: ["300", "800", "900"], ...latin }), // 300 light, 800/900 heavy titles
|
|
58
|
+
montserratItalic: () => loadMontserrat("italic", { weights: ["800", "900"], ...latin }),
|
|
59
|
+
kanit: () => loadKanit("normal", { weights: ["600"], ...latin }), // semi-bold, social handles
|
|
60
|
+
roboto: () => loadRoboto("normal", { weights: ["300", "500", "700", "900"], ...latin }), // interface text
|
|
61
|
+
robotoSlab: () => loadRobotoSlab("normal", { weights: ["700"], ...latin }), // slab serif titles
|
|
62
|
+
bebasNeue: () => loadBebasNeue("normal", { weights: ["400"], ...latin }), // tall condensed caps
|
|
63
|
+
archivoBlack: () => loadArchivoBlack("normal", { weights: ["400"], ...latin }), // very heavy grotesque
|
|
64
|
+
unbounded: () => loadUnbounded("normal", { weights: ["800", "900"], ...latin }), // wide heavy display
|
|
65
|
+
modak: () => loadModak("normal", { weights: ["400"], ...latin }), // chunky rounded display
|
|
66
|
+
greatVibes: () => loadGreatVibes("normal", { weights: ["400"], ...latin }), // formal script
|
|
67
|
+
sacramento: () => loadSacramento("normal", { weights: ["400"], ...latin }), // monoline script, neon signs
|
|
68
|
+
// Free stand-ins for commercial or demo faces that cannot be bundled:
|
|
69
|
+
outfit: () => loadOutfit("normal", { weights: ["100", "300", "500", "900"], ...latin }), // geometric sans, thin to black (for Objective)
|
|
70
|
+
syne: () => loadSyne("normal", { weights: ["800"], ...latin }), // wide, characterful heavy sans (for Hanson, with unbounded)
|
|
71
|
+
lilitaOne: () => loadLilitaOne("normal", { weights: ["400"], ...latin }), // heavy rounded sans (for Peace Sans)
|
|
72
|
+
kaushanScript: () => loadKaushanScript("normal", { weights: ["400"], ...latin }), // bold brush script (for Blacksword)
|
|
73
|
+
yellowtail: () => loadYellowtail("normal", { weights: ["400"], ...latin }), // flowing brush script (for Brushwell)
|
|
74
|
+
mrsSaintDelafield: () => loadMrsSaintDelafield("normal", { weights: ["400"], ...latin }), // fine signature script (for Antro Vectra)
|
|
75
|
+
permanentMarker: () => loadPermanentMarker("normal", { weights: ["400"], ...latin }), // dry marker lettering
|
|
76
|
+
bungeeShade: () => loadBungeeShade("normal", { weights: ["400"], ...latin }), // outlined, shadowed sign capitals
|
|
77
|
+
// Genre faces for film-style titles (original designs, not imitations of any film's logo):
|
|
78
|
+
cinzel: () => loadCinzel("normal", { weights: ["700", "900"], ...latin }), // Roman inscription capitals: epic, historical
|
|
79
|
+
playfairDisplay: () => loadPlayfairDisplay("normal", { weights: ["700", "900"], ...latin }), // high-contrast serif: elegant, editorial
|
|
80
|
+
orbitron: () => loadOrbitron("normal", { weights: ["700", "900"], ...latin }), // geometric sci-fi
|
|
81
|
+
rye: () => loadRye("normal", { weights: ["400"], ...latin }), // western poster
|
|
82
|
+
creepster: () => loadCreepster("normal", { weights: ["400"], ...latin }), // horror
|
|
83
|
+
// Hebrew (these also cover Latin):
|
|
84
|
+
heebo: () => loadHeebo("normal", { weights: ["400", "700", "900"], subsets: ["hebrew", "latin"] }),
|
|
85
|
+
rubik: () => loadRubik("normal", { weights: ["400", "700", "900"], subsets: ["hebrew", "latin"] }),
|
|
86
|
+
alef: () => loadAlef("normal", { weights: ["400", "700"], ...hebrew }), // clean, friendly sans
|
|
87
|
+
assistant: () => loadAssistant("normal", { weights: ["400", "700", "800"], ...hebrew }), // modern sans for interface and body
|
|
88
|
+
notoSansHebrew: () => loadNotoSansHebrew("normal", { weights: ["400", "700", "900"], ...hebrew }), // neutral sans, full weight range
|
|
89
|
+
secularOne: () => loadSecularOne("normal", { weights: ["400"], ...hebrew }), // strong headline sans
|
|
90
|
+
varelaRound: () => loadVarelaRound("normal", { weights: ["400"], ...hebrew }), // soft rounded sans
|
|
91
|
+
fredoka: () => loadFredoka("normal", { weights: ["500", "700"], ...hebrew }), // playful rounded
|
|
92
|
+
karantina: () => loadKarantina("normal", { weights: ["400", "700"], ...hebrew }), // tall condensed display
|
|
93
|
+
suezOne: () => loadSuezOne("normal", { weights: ["400"], ...hebrew }), // heavy serif headline
|
|
94
|
+
frankRuhlLibre: () => loadFrankRuhlLibre("normal", { weights: ["400", "700", "900"], ...hebrew }), // classic book serif
|
|
95
|
+
davidLibre: () => loadDavidLibre("normal", { weights: ["400", "700"], ...hebrew }), // traditional serif
|
|
96
|
+
amaticSC: () => loadAmaticSC("normal", { weights: ["400", "700"], ...hebrew }), // hand-lettered capitals
|
|
97
|
+
};
|
|
98
|
+
export type KitFont = keyof typeof loaders;
|
|
99
|
+
const loaded = new Map<KitFont, string>();
|
|
100
|
+
export function font(name: KitFont): string {
|
|
101
|
+
let family = loaded.get(name);
|
|
102
|
+
if (!family) {
|
|
103
|
+
family = withHebrew(loaders[name]().fontFamily);
|
|
104
|
+
loaded.set(name, family);
|
|
105
|
+
}
|
|
106
|
+
return family;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Spring presets (stiffness / damping). A tiny overshoot at most: never a cartoon bounce.
|
|
110
|
+
export const springs = {
|
|
111
|
+
snappy: { stiffness: 320, damping: 30 },
|
|
112
|
+
smooth: { stiffness: 170, damping: 26 },
|
|
113
|
+
heavy: { stiffness: 120, damping: 24 },
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
// Use one of these on every interpolate(). Linear motion looks cheap.
|
|
117
|
+
export const ease = {
|
|
118
|
+
out: Easing.bezier(0.16, 1, 0.3, 1),
|
|
119
|
+
in: Easing.bezier(0.7, 0, 0.84, 0),
|
|
120
|
+
inOut: Easing.bezier(0.65, 0, 0.35, 1),
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
export type Palette = { bg: string; ink: string; hero: string; accent: string; dim: string };
|
|
124
|
+
|
|
125
|
+
// One base, one hero colour, one accent. The hero colour goes on at most one element per frame.
|
|
126
|
+
export const palettes: Record<"darkTech" | "warmEditorial" | "warmPremium" | "cleanLight", Palette> = {
|
|
127
|
+
darkTech: { bg: "#0A0A0F", ink: "#F4F4F5", hero: "#7C3AED", accent: "#22D3EE", dim: "#A1A1AA" },
|
|
128
|
+
warmEditorial: { bg: "#FAF7F2", ink: "#1F1E1B", hero: "#D97757", accent: "#8A6A4F", dim: "#6B6660" },
|
|
129
|
+
warmPremium: { bg: "#1A120B", ink: "#F4E9DA", hero: "#E8A33D", accent: "#C9B79C", dim: "#A8977F" },
|
|
130
|
+
cleanLight: { bg: "#F7F5F2", ink: "#1A1A1A", hero: "#2563EB", accent: "#0EA5E9", dim: "#6B7280" },
|
|
131
|
+
};
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { mkdir, mkdtemp, rename, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { promisify } from "node:util";
|
|
6
|
+
import { renderMedia, selectComposition } from "@remotion/renderer";
|
|
7
|
+
import { bundleProject, disposeBundle } from "./render";
|
|
8
|
+
|
|
9
|
+
const run = promisify(execFile);
|
|
10
|
+
const NAME = /^[A-Z][A-Za-z0-9]*$/;
|
|
11
|
+
|
|
12
|
+
export const PREVIEW = { width: 960, height: 600, fps: 30, frames: 105 } as const;
|
|
13
|
+
|
|
14
|
+
// The generated entry: the kit's default dark background with the example filling the frame. Importing the kit also loads
|
|
15
|
+
// its fonts, which is what makes text render in the kit fonts. `name` is checked against NAME and `example` starts with `<name`,
|
|
16
|
+
// because both are interpolated into this code; the example is trusted seed text from the owner's own repository.
|
|
17
|
+
const entrySource = (name: string, example: string) => `import React from "react";
|
|
18
|
+
import { AbsoluteFill, Composition, registerRoot } from "remotion";
|
|
19
|
+
import { palettes } from "reelkit/kit";
|
|
20
|
+
import { ${name} } from "./${name}";
|
|
21
|
+
|
|
22
|
+
void ${name};
|
|
23
|
+
const Preview: React.FC = () => (
|
|
24
|
+
<AbsoluteFill style={{ background: palettes.darkTech.bg }}>
|
|
25
|
+
${example}
|
|
26
|
+
</AbsoluteFill>
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
registerRoot(() => (
|
|
30
|
+
<Composition id="Preview" component={Preview} durationInFrames={${PREVIEW.frames}} fps={${PREVIEW.fps}} width={${PREVIEW.width}} height={${PREVIEW.height}} />
|
|
31
|
+
));
|
|
32
|
+
`;
|
|
33
|
+
|
|
34
|
+
// Renders one component with its example to a short silent MP4 (960x600, 30 fps, 3.5 s, H.264, yuv420p, CRF 28).
|
|
35
|
+
// Everything it creates besides outPath (the temp folder and the bundle) is removed again, also when it fails.
|
|
36
|
+
export async function renderComponentPreview(input: { name: string; source: string; example: string }, outPath: string): Promise<void> {
|
|
37
|
+
const { name, source, example } = input;
|
|
38
|
+
if (!NAME.test(name)) throw new Error(`Component name "${name}" must be PascalCase letters and digits.`);
|
|
39
|
+
if (!example.trimStart().startsWith(`<${name}`)) throw new Error(`The example for ${name} must start with <${name}.`);
|
|
40
|
+
|
|
41
|
+
const work = await mkdtemp(join(tmpdir(), "rk-cmp-preview-"));
|
|
42
|
+
let serveUrl: string | undefined;
|
|
43
|
+
try {
|
|
44
|
+
await writeFile(join(work, `${name}.tsx`), source);
|
|
45
|
+
const entry = join(work, "index.tsx");
|
|
46
|
+
await writeFile(entry, entrySource(name, example.trim()));
|
|
47
|
+
serveUrl = await bundleProject(entry);
|
|
48
|
+
const composition = await selectComposition({ serveUrl, id: "Preview" });
|
|
49
|
+
const rendered = join(work, "rendered.mp4");
|
|
50
|
+
await renderMedia({
|
|
51
|
+
composition, serveUrl, codec: "h264", outputLocation: rendered, crf: 28, pixelFormat: "yuv420p", colorSpace: "bt709", muted: true, logLevel: "error",
|
|
52
|
+
});
|
|
53
|
+
// The last pass guarantees no audio stream and the index at the front, whatever Remotion wrote; the video is copied, not re-encoded.
|
|
54
|
+
const finished = join(work, "final.mp4");
|
|
55
|
+
await run("ffmpeg", ["-y", "-v", "error", "-i", rendered, "-an", "-movflags", "+faststart", "-c:v", "copy", finished]);
|
|
56
|
+
await mkdir(dirname(outPath), { recursive: true });
|
|
57
|
+
await rename(finished, outPath).catch(async () => {
|
|
58
|
+
// A different file system than the temp folder: copy instead.
|
|
59
|
+
await run("ffmpeg", ["-y", "-v", "error", "-i", finished, "-c", "copy", "-movflags", "+faststart", outPath]);
|
|
60
|
+
});
|
|
61
|
+
} finally {
|
|
62
|
+
if (serveUrl) await disposeBundle(serveUrl).catch(() => {});
|
|
63
|
+
await rm(work, { recursive: true, force: true });
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
|
|
4
|
+
const require = createRequire(import.meta.url);
|
|
5
|
+
|
|
6
|
+
// The directory of an installed dependency of this package, found through its package.json so it does not depend on
|
|
7
|
+
// where the package keeps its main file or on how the package manager lays out node_modules.
|
|
8
|
+
export const packageDir = (name: string): string => dirname(require.resolve(`${name}/package.json`));
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { mkdir, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { bundle } from "@remotion/bundler";
|
|
4
|
+
import { renderMedia, renderStill, selectComposition } from "@remotion/renderer";
|
|
5
|
+
import type { VideoProps } from "../remotion/types";
|
|
6
|
+
import { packageDir } from "./deps";
|
|
7
|
+
import { KIT_INDEX, ROOT_FILE } from "./validate";
|
|
8
|
+
|
|
9
|
+
const ENTRY_SOURCE = `import { registerRoot } from "remotion";
|
|
10
|
+
import { makeRoot } from "reelkit/root";
|
|
11
|
+
import { Video } from "../../src/Video";
|
|
12
|
+
|
|
13
|
+
registerRoot(makeRoot(Video));
|
|
14
|
+
`;
|
|
15
|
+
|
|
16
|
+
// The project folder has no node_modules. Each dependency is pinned to the one copy installed with this package, so the
|
|
17
|
+
// kit, Root and the user's composition all share a single react and remotion (a second react breaks hooks).
|
|
18
|
+
export function dependencyAliases(): Record<string, string> {
|
|
19
|
+
return Object.fromEntries(["react", "react-dom", "remotion"].map((n) => [n, packageDir(n)]));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Writes the bundler's entry file into the project. The composition itself stays where the user's Claude wrote it.
|
|
23
|
+
export async function writeEntry(projectDir: string): Promise<string> {
|
|
24
|
+
const dir = join(projectDir, ".reelkit", "build");
|
|
25
|
+
await rm(dir, { recursive: true, force: true });
|
|
26
|
+
await mkdir(dir, { recursive: true });
|
|
27
|
+
const entry = join(dir, "index.ts");
|
|
28
|
+
await writeFile(entry, ENTRY_SOURCE);
|
|
29
|
+
return entry;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Webpack's on-disk cache would only grow, by tens of megabytes per render. It is switched off, and each bundle
|
|
33
|
+
// is deleted by disposeBundle once it has been used.
|
|
34
|
+
export async function bundleProject(entry: string): Promise<string> {
|
|
35
|
+
return bundle({
|
|
36
|
+
entryPoint: entry,
|
|
37
|
+
enableCaching: false,
|
|
38
|
+
webpackOverride: (config) => ({
|
|
39
|
+
...config,
|
|
40
|
+
resolve: {
|
|
41
|
+
...config.resolve,
|
|
42
|
+
alias: { ...(config.resolve?.alias as Record<string, string> | undefined), ...dependencyAliases(), "reelkit/kit": KIT_INDEX, "reelkit/root": ROOT_FILE },
|
|
43
|
+
},
|
|
44
|
+
}),
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function disposeBundle(serveUrl: string): Promise<void> {
|
|
49
|
+
// Only ever remove a folder that is one of Remotion's own temporary bundles.
|
|
50
|
+
if (/remotion-webpack-bundle-/.test(serveUrl)) await rm(serveUrl, { recursive: true, force: true });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const asInput = (props: VideoProps) => props as unknown as Record<string, unknown>;
|
|
54
|
+
|
|
55
|
+
export async function renderStills(serveUrl: string, props: VideoProps, frames: number[], outDir: string) {
|
|
56
|
+
await mkdir(outDir, { recursive: true });
|
|
57
|
+
const composition = await selectComposition({ serveUrl, id: "Video", inputProps: asInput(props) });
|
|
58
|
+
for (const frame of frames) {
|
|
59
|
+
await renderStill({ composition, serveUrl, frame, output: join(outDir, `still-${frame}.png`), inputProps: asInput(props) });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function renderVideo(serveUrl: string, props: VideoProps, outPath: string) {
|
|
64
|
+
await mkdir(dirname(outPath), { recursive: true });
|
|
65
|
+
const composition = await selectComposition({ serveUrl, id: "Video", inputProps: asInput(props) });
|
|
66
|
+
await renderMedia({ composition, serveUrl, codec: "h264", outputLocation: outPath, inputProps: asInput(props) });
|
|
67
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { basename, join } from "node:path";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import ts from "typescript";
|
|
4
|
+
import { packageDir } from "./deps";
|
|
5
|
+
|
|
6
|
+
export const PKG_ROOT = fileURLToPath(new URL("../..", import.meta.url));
|
|
7
|
+
export const KIT_INDEX = join(PKG_ROOT, "src", "remotion", "kit", "index.ts");
|
|
8
|
+
export const ROOT_FILE = join(PKG_ROOT, "src", "remotion", "Root.tsx");
|
|
9
|
+
|
|
10
|
+
export const MAIN_FILE = "Video.tsx";
|
|
11
|
+
export const FILE_NAME = /^[A-Z][A-Za-z0-9]*\.tsx$/;
|
|
12
|
+
export const componentName = (file: string) => file.replace(/\.tsx$/, "");
|
|
13
|
+
|
|
14
|
+
const KIT = "reelkit/kit";
|
|
15
|
+
const BASE_IMPORTS = new Set(["react", "remotion", KIT]);
|
|
16
|
+
|
|
17
|
+
// This check keeps honest compositions deterministic and self-contained. It is NOT a security boundary:
|
|
18
|
+
// code in src/ runs on the user's machine when it is bundled and rendered, and anyone determined can get around a list of names.
|
|
19
|
+
// It looks at the code (the TypeScript syntax tree), never at text, so words in on-screen copy, strings and comments are not flagged.
|
|
20
|
+
// Only a reference to the global counts: `style.process`, `{ fetch: 1 }`, and a name the file declares itself (a component called
|
|
21
|
+
// `Worker`, a parameter called `window`) are fine. Scope comes from the TypeScript binder, so a declaration only covers where it is in scope.
|
|
22
|
+
const FORBIDDEN_NAMES: Record<string, string> = {
|
|
23
|
+
require: "require()", eval: "eval()", fetch: "fetch()",
|
|
24
|
+
process: "process", globalThis: "globalThis", window: "window", self: "self",
|
|
25
|
+
XMLHttpRequest: "XMLHttpRequest", WebSocket: "WebSocket", EventSource: "EventSource",
|
|
26
|
+
navigator: "navigator", localStorage: "localStorage", sessionStorage: "sessionStorage", indexedDB: "indexedDB",
|
|
27
|
+
Function: "Function", Worker: "Worker", SharedWorker: "SharedWorker", importScripts: "importScripts", child_process: "child_process",
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const sourceFile = (source: string, file = "Video.tsx") => ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
|
31
|
+
|
|
32
|
+
// True when this identifier names a property, a label or a JSX attribute rather than referring to a variable.
|
|
33
|
+
function isNamePosition(node: ts.Identifier): boolean {
|
|
34
|
+
const p = node.parent;
|
|
35
|
+
if (!p) return false;
|
|
36
|
+
if ((ts.isPropertyAccessExpression(p) || ts.isQualifiedName(p)) && (ts.isQualifiedName(p) ? p.right : p.name) === node) return true;
|
|
37
|
+
if ((ts.isPropertyAssignment(p) || ts.isPropertyDeclaration(p) || ts.isPropertySignature(p) || ts.isMethodDeclaration(p) || ts.isMethodSignature(p)
|
|
38
|
+
|| ts.isGetAccessorDeclaration(p) || ts.isSetAccessorDeclaration(p) || ts.isEnumMember(p) || ts.isJsxAttribute(p)) && p.name === node) return true;
|
|
39
|
+
if (ts.isBindingElement(p) && p.propertyName === node) return true;
|
|
40
|
+
if ((ts.isImportSpecifier(p) || ts.isExportSpecifier(p)) && p.propertyName === node) return true;
|
|
41
|
+
if ((ts.isLabeledStatement(p) || ts.isBreakOrContinueStatement(p)) && p.label === node) return true;
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Looks through the wrappers that do not change what an expression is: (x), x as T, x!, x satisfies T, <T>x.
|
|
46
|
+
function unwrap(e: ts.Expression): ts.Expression {
|
|
47
|
+
while (ts.isParenthesizedExpression(e) || ts.isAsExpression(e) || ts.isNonNullExpression(e) || ts.isSatisfiesExpression(e) || ts.isTypeAssertionExpression(e)) e = e.expression;
|
|
48
|
+
return e;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// A one-file program with no libraries: a name the file declares has a symbol with a declaration in it; a global has none.
|
|
52
|
+
function declaredHere(sf: ts.SourceFile): (id: ts.Identifier) => boolean {
|
|
53
|
+
const host: ts.CompilerHost = {
|
|
54
|
+
getSourceFile: (name) => (name === sf.fileName ? sf : undefined),
|
|
55
|
+
getDefaultLibFileName: () => "lib.d.ts", writeFile: () => {}, getCurrentDirectory: () => "", getCanonicalFileName: (f) => f,
|
|
56
|
+
useCaseSensitiveFileNames: () => true, getNewLine: () => "\n", fileExists: (f) => f === sf.fileName, readFile: () => undefined, getDirectories: () => [],
|
|
57
|
+
};
|
|
58
|
+
const program = ts.createProgram([sf.fileName], { noLib: true, noResolve: true, jsx: ts.JsxEmit.Preserve, target: ts.ScriptTarget.Latest }, host);
|
|
59
|
+
const checker = program.getTypeChecker();
|
|
60
|
+
return (id) => {
|
|
61
|
+
const p = id.parent;
|
|
62
|
+
const symbol = p && ts.isShorthandPropertyAssignment(p) && p.name === id ? checker.getShorthandAssignmentValueSymbol(p) : checker.getSymbolAtLocation(id);
|
|
63
|
+
return Boolean(symbol?.declarations?.some((d) => d.getSourceFile() === sf));
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const textOf = (n: ts.Node | undefined) => (n && (ts.isStringLiteralLike(n)) ? n.text : undefined);
|
|
68
|
+
|
|
69
|
+
type Finding = { specifiers: string[]; forbidden: string[] };
|
|
70
|
+
|
|
71
|
+
function inspectSource(source: string, file?: string): Finding {
|
|
72
|
+
const specifiers: string[] = [];
|
|
73
|
+
const forbidden = new Set<string>();
|
|
74
|
+
const add = (s: string | undefined) => { if (s !== undefined) specifiers.push(s); };
|
|
75
|
+
|
|
76
|
+
const sf = sourceFile(source, file);
|
|
77
|
+
const declared = declaredHere(sf);
|
|
78
|
+
// True when the expression is the global with this name, not a variable the file declares.
|
|
79
|
+
const isGlobal = (e: ts.Expression | undefined, name: string) => {
|
|
80
|
+
const x = e && unwrap(e);
|
|
81
|
+
return Boolean(x && ts.isIdentifier(x) && x.text === name && !declared(x));
|
|
82
|
+
};
|
|
83
|
+
const flagMember = (object: ts.Expression | undefined, member: string | undefined) => {
|
|
84
|
+
if (isGlobal(object, "Math") && member === "random") forbidden.add("Math.random (use random(seed) from remotion)");
|
|
85
|
+
if (isGlobal(object, "Date") && member === "now") forbidden.add("the clock (Date)");
|
|
86
|
+
};
|
|
87
|
+
// The key a destructuring element reads: `random` in `{ random }` and in `{ random: r }`, "random" in `{ ["random"]: r }`.
|
|
88
|
+
const keyOf = (name: ts.PropertyName | undefined) =>
|
|
89
|
+
name && (ts.isIdentifier(name) || ts.isStringLiteralLike(name) ? name.text : ts.isComputedPropertyName(name) ? textOf(name.expression) : undefined);
|
|
90
|
+
|
|
91
|
+
const visit = (node: ts.Node, inType: boolean) => {
|
|
92
|
+
if (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) add(textOf(node.moduleSpecifier));
|
|
93
|
+
else if (ts.isImportEqualsDeclaration(node) && ts.isExternalModuleReference(node.moduleReference)) add(textOf(node.moduleReference.expression));
|
|
94
|
+
else if (ts.isImportTypeNode(node) && ts.isLiteralTypeNode(node.argument)) add(textOf(node.argument.literal));
|
|
95
|
+
else if (ts.isCallExpression(node)) {
|
|
96
|
+
const callee = node.expression;
|
|
97
|
+
if (callee.kind === ts.SyntaxKind.ImportKeyword) { forbidden.add("dynamic import()"); add(textOf(node.arguments[0])); }
|
|
98
|
+
else if (isGlobal(callee, "require")) add(textOf(node.arguments[0]));
|
|
99
|
+
else if (ts.isPropertyAccessExpression(callee) && isGlobal(callee.expression, "require")) add(textOf(node.arguments[0]));
|
|
100
|
+
if (isGlobal(callee, "Function")) forbidden.add("Function()");
|
|
101
|
+
if (isGlobal(callee, "Date")) forbidden.add("the clock (Date)");
|
|
102
|
+
} else if (ts.isNewExpression(node)) {
|
|
103
|
+
if (isGlobal(node.expression, "Function")) forbidden.add("new Function");
|
|
104
|
+
if (isGlobal(node.expression, "Date")) forbidden.add("the clock (Date)");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Math.random and Date.now, also written Math["random"], (Math).random, or taken out by destructuring.
|
|
108
|
+
if (ts.isPropertyAccessExpression(node) || ts.isElementAccessExpression(node)) {
|
|
109
|
+
flagMember(node.expression, ts.isPropertyAccessExpression(node) ? node.name.text : textOf(node.argumentExpression));
|
|
110
|
+
} else if (ts.isVariableDeclaration(node) && ts.isObjectBindingPattern(node.name) && node.initializer) {
|
|
111
|
+
for (const el of node.name.elements) flagMember(node.initializer, keyOf(el.propertyName) ?? (ts.isIdentifier(el.name) ? el.name.text : undefined));
|
|
112
|
+
} else if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.EqualsToken && ts.isObjectLiteralExpression(unwrap(node.left))) {
|
|
113
|
+
for (const prop of (unwrap(node.left) as ts.ObjectLiteralExpression).properties) {
|
|
114
|
+
if (ts.isShorthandPropertyAssignment(prop)) flagMember(node.right, prop.name.text);
|
|
115
|
+
else if (ts.isPropertyAssignment(prop)) flagMember(node.right, keyOf(prop.name));
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
if (!inType && ts.isIdentifier(node) && !isNamePosition(node) && Object.hasOwn(FORBIDDEN_NAMES, node.text) && !declared(node)) {
|
|
119
|
+
// A plain call or `new` of Function is already reported as Function() or new Function.
|
|
120
|
+
const p = node.parent;
|
|
121
|
+
const reported = node.text === "Function" && (ts.isCallExpression(p) || ts.isNewExpression(p)) && p.expression === node;
|
|
122
|
+
if (!reported) forbidden.add(FORBIDDEN_NAMES[node.text]);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const nextInType = inType || (ts.isTypeNode(node) && !ts.isExpressionWithTypeArguments(node));
|
|
126
|
+
ts.forEachChild(node, (c) => visit(c, nextInType));
|
|
127
|
+
};
|
|
128
|
+
visit(sf, false);
|
|
129
|
+
return { specifiers, forbidden: [...forbidden] };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Every module a file pulls in: import and export declarations, import(), require() and require.x(), however they are spaced or commented.
|
|
133
|
+
export function importSpecifiers(source: string): string[] {
|
|
134
|
+
return inspectSource(source).specifiers;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Video.tsx may import sibling component files; a component must be self-contained so it can be reused on its own.
|
|
138
|
+
export function staticCheck(source: string, file: string, siblings: string[]): string[] {
|
|
139
|
+
const errors: string[] = [];
|
|
140
|
+
const isMain = file === MAIN_FILE;
|
|
141
|
+
const allowedSiblings = new Set(isMain ? siblings.filter((s) => s !== MAIN_FILE).map((s) => `./${componentName(s)}`) : []);
|
|
142
|
+
const found = inspectSource(source, file);
|
|
143
|
+
for (const s of found.specifiers) {
|
|
144
|
+
if (BASE_IMPORTS.has(s) || allowedSiblings.has(s)) continue;
|
|
145
|
+
errors.push(
|
|
146
|
+
isMain
|
|
147
|
+
? `${file}: import not allowed: "${s}". Use react, remotion, ${KIT}, or a component file that exists ("./Name").`
|
|
148
|
+
: `${file}: import not allowed: "${s}". A component may only import react, remotion and ${KIT}.`,
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
for (const name of found.forbidden) errors.push(`${file}: not allowed: ${name}.`);
|
|
152
|
+
const exported = isMain ? "Video" : componentName(file);
|
|
153
|
+
const exportsIt = sourceFile(source, file).statements.some((s) =>
|
|
154
|
+
ts.isVariableStatement(s) && s.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword)
|
|
155
|
+
&& s.declarationList.flags & ts.NodeFlags.Const && s.declarationList.declarations.some((d) => ts.isIdentifier(d.name) && d.name.text === exported));
|
|
156
|
+
if (!exportsIt) errors.push(`${file} must contain \`export const ${exported}\`.`);
|
|
157
|
+
return errors;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// The project folder has no node_modules. These map its imports to the copies installed with this package.
|
|
161
|
+
const typesReact = packageDir("@types/react");
|
|
162
|
+
export const remotionDir = packageDir("remotion");
|
|
163
|
+
|
|
164
|
+
export function typecheck(files: string[]): string[] {
|
|
165
|
+
const program = ts.createProgram(files, {
|
|
166
|
+
target: ts.ScriptTarget.ES2022,
|
|
167
|
+
module: ts.ModuleKind.ES2022,
|
|
168
|
+
moduleResolution: ts.ModuleResolutionKind.Bundler,
|
|
169
|
+
jsx: ts.JsxEmit.ReactJSX,
|
|
170
|
+
strict: true,
|
|
171
|
+
noEmit: true,
|
|
172
|
+
skipLibCheck: true,
|
|
173
|
+
esModuleInterop: true,
|
|
174
|
+
allowImportingTsExtensions: true,
|
|
175
|
+
baseUrl: PKG_ROOT,
|
|
176
|
+
paths: {
|
|
177
|
+
[KIT]: [KIT_INDEX],
|
|
178
|
+
react: [typesReact],
|
|
179
|
+
"react/*": [join(typesReact, "*")],
|
|
180
|
+
remotion: [remotionDir],
|
|
181
|
+
},
|
|
182
|
+
});
|
|
183
|
+
const wanted = new Set(files);
|
|
184
|
+
return ts
|
|
185
|
+
.getPreEmitDiagnostics(program)
|
|
186
|
+
.filter((d) => d.file && wanted.has(d.file.fileName))
|
|
187
|
+
.map((d) => {
|
|
188
|
+
const pos = d.file!.getLineAndCharacterOfPosition(d.start ?? 0);
|
|
189
|
+
return `${basename(d.file!.fileName)} line ${pos.line + 1}: ${ts.flattenDiagnosticMessageText(d.messageText, "\n")}`;
|
|
190
|
+
});
|
|
191
|
+
}
|