Skip to content

Commit 6533b9f

Browse files
feat(droid-control): render callout effects in Showcase composition (MB-17)
Schema-valid callout effects were silently dropped by the Showcase composition. Adds CalloutOverlay (palette-aware text pill at percent anchors, content-local timing) and a render-time warning for any remaining schema-valid-but-unrendered effect types (fade-in/fade-out). Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
1 parent e8801fa commit 6533b9f

5 files changed

Lines changed: 130 additions & 3 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
.factory/
2+
node_modules/

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, configurable transitions (`motion-blur`, `flash`, `whip-pan`, `light-leak`, `glitch-lite`), zooms, spotlights, keystroke overlays, section headers, and syntax-highlighted code annotations.
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, callout annotations, 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

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import { useCurrentFrame, useVideoConfig, interpolate, Easing } from 'remotion';
2+
import type { Effect } from '../lib/schema';
3+
import type { Palette } from '../lib/palettes';
4+
5+
type Callout = Extract<Effect, { fx: 'callout' }>;
6+
7+
const CalloutPill: React.FC<{
8+
callout: Callout;
9+
palette: Palette;
10+
enterFrame: number;
11+
exitFrame: number;
12+
}> = ({ callout, palette, enterFrame, exitFrame }) => {
13+
const frame = useCurrentFrame();
14+
const { fps } = useVideoConfig();
15+
16+
const isVisible = frame >= enterFrame && frame < exitFrame;
17+
if (!isVisible) return null;
18+
19+
const localFrame = frame - enterFrame;
20+
21+
// Pop-in animation over 0.25s (same overshoot bezier as KeystrokePill)
22+
const enterProgress = interpolate(localFrame, [0, 0.25 * fps], [0, 1], {
23+
easing: Easing.bezier(0.34, 1.56, 0.64, 1),
24+
extrapolateLeft: 'clamp',
25+
extrapolateRight: 'clamp',
26+
});
27+
28+
// Fade out over last 0.2s
29+
const totalFrames = exitFrame - enterFrame;
30+
const fadeOutStart = totalFrames - 0.2 * fps;
31+
const exitOpacity = interpolate(
32+
localFrame,
33+
[fadeOutStart, totalFrames],
34+
[1, 0],
35+
{
36+
extrapolateLeft: 'clamp',
37+
extrapolateRight: 'clamp',
38+
}
39+
);
40+
41+
const scale = interpolate(enterProgress, [0, 1], [0.85, 1]);
42+
43+
return (
44+
<div
45+
style={{
46+
position: 'absolute',
47+
left: callout.at.x,
48+
top: callout.at.y,
49+
transform: `translate(-50%, -50%) scale(${scale})`,
50+
opacity: Math.min(enterProgress, exitOpacity),
51+
backgroundColor: `${palette.surface}E6`,
52+
color: palette.text,
53+
fontSize: 24,
54+
fontFamily: "'Geist', 'Inter', sans-serif",
55+
fontWeight: 500,
56+
lineHeight: 1.4,
57+
padding: '12px 24px',
58+
borderRadius: 12,
59+
border: `1px solid ${palette.border}`,
60+
borderLeft: `3px solid ${palette.accent}`,
61+
backdropFilter: 'blur(12px)',
62+
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.35)',
63+
maxWidth: '40%',
64+
textAlign: 'center',
65+
zIndex: 90,
66+
pointerEvents: 'none',
67+
}}
68+
>
69+
{callout.text}
70+
</div>
71+
);
72+
};
73+
74+
export const CalloutOverlay: React.FC<{
75+
callouts: Callout[];
76+
palette: Palette;
77+
}> = ({ callouts, palette }) => {
78+
const { fps } = useVideoConfig();
79+
80+
return (
81+
<>
82+
{callouts.map((callout) => (
83+
<CalloutPill
84+
key={`${callout.t}-${callout.text}`}
85+
callout={callout}
86+
palette={palette}
87+
enterFrame={Math.round(callout.t * fps)}
88+
exitFrame={Math.round((callout.t + callout.dur) * fps)}
89+
/>
90+
))}
91+
</>
92+
);
93+
};

plugins/droid-control/remotion/src/compositions/Showcase.tsx

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import { ZoomEffect } from '../components/ZoomEffect';
3131
import { SectionTransitionOverlay } from '../components/SectionTransition';
3232
import { DroidOutro } from '../components/DroidOutro';
3333
import { CodeAnnotationOverlay } from '../components/CodeAnnotationOverlay';
34+
import { CalloutOverlay } from '../components/CalloutOverlay';
3435

3536
export const showcaseSchema = z.object({
3637
clips: z.array(z.string()),
@@ -68,6 +69,23 @@ export const showcaseSchema = z.object({
6869
const TITLE_DURATION_S = 4;
6970
const TRANSITION_FRAMES = 15;
7071

72+
// Effect types this composition actually renders. Anything schema-valid but
73+
// absent from this set is a silent no-op — warn instead of dropping quietly.
74+
const RENDERED_FX = new Set(['zoom', 'spotlight', 'callout']);
75+
const warnedUnrenderedFx = new Set<string>();
76+
77+
const warnUnrenderedEffects = (effects: z.infer<typeof effectSchema>[]) => {
78+
for (const effect of effects) {
79+
if (RENDERED_FX.has(effect.fx) || warnedUnrenderedFx.has(effect.fx)) {
80+
continue;
81+
}
82+
warnedUnrenderedFx.add(effect.fx);
83+
console.warn(
84+
`Showcase: effect fx='${effect.fx}' is schema-valid but not rendered by this composition`
85+
);
86+
}
87+
};
88+
7189
const resolveFidelity = (
7290
props: z.infer<typeof showcaseSchema>
7391
): 'compact' | 'standard' | 'inspect' =>
@@ -211,6 +229,16 @@ export const ShowcaseComposition: React.FC<z.infer<typeof showcaseSchema>> = (
211229
[props.effects]
212230
);
213231

232+
const callouts = useMemo(
233+
() =>
234+
props.effects.filter(
235+
(e): e is Extract<typeof e, { fx: 'callout' }> => e.fx === 'callout'
236+
),
237+
[props.effects]
238+
);
239+
240+
warnUnrenderedEffects(props.effects);
241+
214242
return (
215243
<AbsoluteFill>
216244
<Background palette={palette} config={config} />
@@ -289,6 +317,11 @@ export const ShowcaseComposition: React.FC<z.infer<typeof showcaseSchema>> = (
289317
/>
290318
))}
291319

320+
{/* Callout annotations: timed text pills at percent positions */}
321+
{callouts.length > 0 && (
322+
<CalloutOverlay callouts={callouts} palette={palette} />
323+
)}
324+
292325
{/* Frosted sweep at section boundaries */}
293326
{props.sections && props.sections.length > 1 && (
294327
<SectionTransitionOverlay sections={props.sections} />

plugins/droid-control/skills/compose/SKILL.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -245,8 +245,8 @@ Use a run-scoped props path like `$PROPS`; do not reuse a global `/tmp/showcase-
245245

246246
| Effect | Props | Description |
247247
|---|---|---|
248-
| `fade-in` | `t`, `dur` | Fade from black |
249-
| `fade-out` | `t`, `dur` | Fade to black |
248+
| `fade-in` | `t`, `dur` | Fade from black (schema-valid but not yet rendered by Showcase — emits a render-time warning) |
249+
| `fade-out` | `t`, `dur` | Fade to black (schema-valid but not yet rendered by Showcase — emits a render-time warning) |
250250
| `zoom` | `t`, `dur`, `to: {x,y,w,h}` | Directed zoom to a target region (30% in, 40% hold, 30% out) |
251251
| `spotlight` | `t`, `dur`, `on: {x,y,w,h}`, `dim?` | Dim everything except a region (`dim`: 0–1, default 0.6) |
252252
| `callout` | `t`, `dur`, `text`, `at: {x,y}` | Text overlay anchored to a point |

0 commit comments

Comments
 (0)