Skip to content

Commit 4530ed9

Browse files
feat(droid-control): code annotations + transition styles
Add an opt-in `codeAnnotations` layer and a `transitionStyle` resolver to the Remotion Showcase composition. Defaults are unchanged so existing props render identically; the new behavior only activates when those props are set. - Schema: `codeAnnotationSchema` (t, dur, code, language, title, highlight, focus, position) and `transitionStyleSchema` (motion-blur, flash, whip-pan, light-leak, glitch-lite). - CodeAnnotationOverlay: syntax-highlighted code cards via prism-react-renderer with palette-aware theming, line highlights, focus dimming, and enter/exit animation. Anchored top-right by default. - ShowcaseTransition: five Remotion-native transition presentations wired via `getTransitionPresentation(style, palette)`. Motion-blur preserves the existing look; the others are palette-aware (warm on Factory presets). - Docs: compose/SKILL.md gains authoring guidance and a full props reference for both features; showcase/SKILL.md adds preset-level style guidance. README and ARCHITECTURE.md updated to reflect the new composition surface. Validated: `tsc --noEmit` clean; three fixture renders (default, whip-pan + code annotation, flash) produce 9.5s 1920x1080 H.264 mp4s with expected content. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
1 parent 5e938b7 commit 4530ed9

11 files changed

Lines changed: 722 additions & 81 deletions

File tree

plugins/droid-control/ARCHITECTURE.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,22 @@ The compose stage uses the Remotion project in `remotion/` as a single video eng
134134

135135
This keeps droids out of the common failure modes: stale files in `public/`, mismatched `clipDuration`, wrong `agg` theme, invalid pixel formats, and hand-written Remotion commands with missing encode flags.
136136

137+
### Composition surface
138+
139+
The `Showcase` composition in `remotion/src/compositions/Showcase.tsx` is the only video entry point. Everything else lives in `remotion/src/components/` and is composed by props:
140+
141+
| Layer | Purpose | Controlled by |
142+
|---|---|---|
143+
| Background + FloatingParticles | Preset-driven warmth or coolness | `preset` |
144+
| TitleCard / FanningRotorOutro | Opening and closing cards | `title`, `subtitle`, `speedNote` |
145+
| Window chrome + layouts | `SingleLayout` or `SideBySideLayout` | `layout`, `labels`, `objectFit` |
146+
| ZoomEffect / SpotlightOverlay / KeystrokeOverlay / SectionHeader | Timed in-scene overlays | `effects`, `keys`, `sections` |
147+
| CodeAnnotationOverlay | Timed syntax-highlighted code cards | `codeAnnotations` |
148+
| Transition presentation | Title→content and content→outro crossfade | `transitionStyle` (default `motion-blur`) |
149+
| NoiseOverlay + ColorGradeOverlay + Watermark | Topmost polish pass | `fidelity`, `preset` |
150+
151+
The key property is that the main composition is data-driven: the droid never writes Remotion JSX. Adding a new overlay or transition style is a new component plus a schema field, not a new composition.
152+
137153
## Platform isolation
138154

139155
Platform-specific mechanics live below the atom that needs them:

plugins/droid-control/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ For the full rationale and runtime pipeline, see [`ARCHITECTURE.md`](ARCHITECTUR
7676

7777
## Video rendering
7878

79-
The compose stage uses [Remotion](https://www.remotion.dev/) for video compositing. Presets provide window chrome, spacing, palettes, backgrounds, particles, noise, color grading, motion-blur transitions, zooms, spotlights, keystroke overlays, and section headers.
79+
The compose stage uses [Remotion](https://www.remotion.dev/) for video compositing. Presets provide window chrome, spacing, palettes, backgrounds, particles, noise, color grading, configurable transitions (`motion-blur`, `flash`, `whip-pan`, `light-leak`, `glitch-lite`), zooms, spotlights, keystroke overlays, section headers, and syntax-highlighted code annotations.
8080

8181
The `render-showcase.sh` helper owns the full pipeline: `.cast` conversion via `agg`, clip staging, duration detection, Remotion rendering, and cleanup.
8282

plugins/droid-control/remotion/package-lock.json

Lines changed: 29 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

plugins/droid-control/remotion/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
"@remotion/media": "4.0.445",
1515
"@remotion/tailwind": "4.0.445",
1616
"@remotion/transitions": "4.0.445",
17+
"prism-react-renderer": "^2.4.1",
1718
"react": "^19",
1819
"react-dom": "^19",
1920
"remotion": "4.0.445",
Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
1+
import React, { useMemo } from 'react';
2+
import { useCurrentFrame, useVideoConfig, interpolate, Easing } from 'remotion';
3+
import { Highlight, themes, type PrismTheme } from 'prism-react-renderer';
4+
import type { CodeAnnotation, CodeRange } from '../lib/schema';
5+
import type { Palette } from '../lib/palettes';
6+
import type { PresetConfig } from '../lib/presets';
7+
8+
const MONO =
9+
"'Geist Mono', 'Berkeley Mono', 'SF Mono', 'Cascadia Code', 'Fira Code', monospace";
10+
11+
const inRange = (line: number, ranges: CodeRange[]): boolean =>
12+
ranges.some((r) => line >= r.start && line <= r.end);
13+
14+
const buildPrismTheme = (palette: Palette): PrismTheme => {
15+
const base = themes.vsDark;
16+
return {
17+
...base,
18+
plain: {
19+
...base.plain,
20+
color: palette.text,
21+
backgroundColor: 'transparent',
22+
},
23+
};
24+
};
25+
26+
const positionToAnchor = (
27+
position: CodeAnnotation['position'],
28+
margin: number,
29+
): React.CSSProperties => {
30+
const edge = Math.max(40, margin + 24);
31+
switch (position) {
32+
case 'center':
33+
return {
34+
left: '50%',
35+
top: '50%',
36+
transform: 'translate(-50%, -50%)',
37+
};
38+
case 'bottom-left':
39+
return { left: edge, bottom: edge };
40+
case 'top-right':
41+
default:
42+
return { right: edge, top: edge };
43+
}
44+
};
45+
46+
const CodeCard: React.FC<{
47+
annotation: CodeAnnotation;
48+
palette: Palette;
49+
config: PresetConfig;
50+
enterFrame: number;
51+
exitFrame: number;
52+
}> = ({ annotation, palette, config, enterFrame, exitFrame }) => {
53+
const frame = useCurrentFrame();
54+
const { fps } = useVideoConfig();
55+
const lines = useMemo(
56+
() => annotation.code.replace(/\n$/, '').split('\n'),
57+
[annotation.code],
58+
);
59+
const prismTheme = useMemo(() => buildPrismTheme(palette), [palette]);
60+
61+
if (frame < enterFrame || frame >= exitFrame) return null;
62+
63+
const localFrame = frame - enterFrame;
64+
const totalFrames = exitFrame - enterFrame;
65+
66+
const enterProgress = interpolate(localFrame, [0, 0.35 * fps], [0, 1], {
67+
easing: Easing.bezier(0.16, 1, 0.3, 1),
68+
extrapolateLeft: 'clamp',
69+
extrapolateRight: 'clamp',
70+
});
71+
72+
const exitStart = Math.max(0, totalFrames - 0.35 * fps);
73+
const exitProgress = interpolate(
74+
localFrame,
75+
[exitStart, totalFrames],
76+
[0, 1],
77+
{ extrapolateLeft: 'clamp', extrapolateRight: 'clamp' },
78+
);
79+
80+
const opacity = enterProgress * (1 - exitProgress);
81+
const scale = interpolate(enterProgress, [0, 1], [0.96, 1]);
82+
const blur = interpolate(enterProgress, [0, 1], [6, 0]);
83+
84+
const highlightRanges = annotation.highlight ?? [];
85+
const focusRanges = annotation.focus ?? [];
86+
const hasFocus = focusRanges.length > 0;
87+
const anchor = positionToAnchor(
88+
annotation.position ?? 'top-right',
89+
config.margin,
90+
);
91+
const maxLine = lines.length;
92+
const gutterWidth = `${String(maxLine).length + 1}ch`;
93+
94+
return (
95+
<div
96+
style={{
97+
position: 'absolute',
98+
...anchor,
99+
opacity,
100+
transform: `${anchor.transform ?? ''} scale(${scale})`.trim(),
101+
filter: `blur(${blur}px)`,
102+
zIndex: 90,
103+
maxWidth: '42%',
104+
minWidth: 320,
105+
backgroundColor: `${palette.surface}F2`,
106+
border: `1px solid ${palette.border}`,
107+
borderBottom: `2px solid ${palette.accent}AA`,
108+
borderRadius: 12,
109+
padding: 16,
110+
boxShadow: `0 18px 48px rgba(0,0,0,0.45), 0 0 0 1px rgba(255,255,255,0.04)`,
111+
backdropFilter: 'blur(10px)',
112+
WebkitBackdropFilter: 'blur(10px)',
113+
fontFamily: MONO,
114+
fontSize: 14,
115+
lineHeight: 1.5,
116+
color: palette.text,
117+
}}
118+
>
119+
{annotation.title && (
120+
<div
121+
style={{
122+
color: palette.muted,
123+
fontSize: 11,
124+
textTransform: 'uppercase',
125+
letterSpacing: '0.1em',
126+
marginBottom: 10,
127+
fontFamily: "'Geist', system-ui, sans-serif",
128+
fontWeight: 500,
129+
}}
130+
>
131+
{annotation.title}
132+
</div>
133+
)}
134+
<Highlight
135+
code={annotation.code.replace(/\n$/, '')}
136+
language={annotation.language ?? 'tsx'}
137+
theme={prismTheme}
138+
>
139+
{({ tokens, getLineProps, getTokenProps }) => (
140+
<pre
141+
style={{
142+
margin: 0,
143+
padding: 0,
144+
backgroundColor: 'transparent',
145+
fontFamily: MONO,
146+
overflow: 'hidden',
147+
}}
148+
>
149+
{tokens.map((line, i) => {
150+
const lineNumber = i + 1;
151+
const isHighlighted = inRange(lineNumber, highlightRanges);
152+
const isFocused = !hasFocus || inRange(lineNumber, focusRanges);
153+
const lineOpacity = isFocused ? 1 : 0.3;
154+
const lineFilter = isFocused ? 'none' : 'blur(1.2px)';
155+
const bg = isHighlighted ? `${palette.accent}22` : 'transparent';
156+
const lineBorder = isHighlighted
157+
? `2px solid ${palette.accent}`
158+
: `2px solid transparent`;
159+
const { key: _lineKey, ...lineRest } = getLineProps({
160+
line,
161+
key: i,
162+
});
163+
164+
return (
165+
<div
166+
key={`line-${i}`}
167+
{...lineRest}
168+
style={{
169+
display: 'flex',
170+
opacity: lineOpacity,
171+
filter: lineFilter,
172+
backgroundColor: bg,
173+
borderLeft: lineBorder,
174+
paddingLeft: 8,
175+
marginLeft: -8,
176+
marginRight: -8,
177+
paddingRight: 8,
178+
}}
179+
>
180+
<span
181+
style={{
182+
width: gutterWidth,
183+
flex: '0 0 auto',
184+
color: palette.muted,
185+
userSelect: 'none',
186+
textAlign: 'right',
187+
paddingRight: 12,
188+
}}
189+
>
190+
{lineNumber}
191+
</span>
192+
<span style={{ flex: 1 }}>
193+
{line.map((token, tokenIndex) => {
194+
const { key: _tokenKey, ...tokenRest } = getTokenProps({
195+
token,
196+
key: tokenIndex,
197+
});
198+
return (
199+
<span
200+
key={`tok-${i}-${tokenIndex}`}
201+
{...tokenRest}
202+
/>
203+
);
204+
})}
205+
</span>
206+
</div>
207+
);
208+
})}
209+
</pre>
210+
)}
211+
</Highlight>
212+
</div>
213+
);
214+
};
215+
216+
export const CodeAnnotationOverlay: React.FC<{
217+
annotations: CodeAnnotation[];
218+
palette: Palette;
219+
config: PresetConfig;
220+
}> = ({ annotations, palette, config }) => {
221+
const { fps } = useVideoConfig();
222+
223+
if (!annotations.length) return null;
224+
225+
return (
226+
<>
227+
{annotations.map((annotation, i) => {
228+
const enterFrame = Math.round(annotation.t * fps);
229+
const exitFrame = Math.round((annotation.t + annotation.dur) * fps);
230+
return (
231+
<CodeCard
232+
key={`code-${i}-${annotation.t}`}
233+
annotation={annotation}
234+
palette={palette}
235+
config={config}
236+
enterFrame={enterFrame}
237+
exitFrame={exitFrame}
238+
/>
239+
);
240+
})}
241+
</>
242+
);
243+
};

0 commit comments

Comments
 (0)