diff --git a/plugins/droid-control/ARCHITECTURE.md b/plugins/droid-control/ARCHITECTURE.md index 3452ea2..ddc85e3 100644 --- a/plugins/droid-control/ARCHITECTURE.md +++ b/plugins/droid-control/ARCHITECTURE.md @@ -134,6 +134,22 @@ The compose stage uses the Remotion project in `remotion/` as a single video eng 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. +### Composition surface + +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: + +| Layer | Purpose | Controlled by | +|---|---|---| +| Background + FloatingParticles | Preset-driven warmth or coolness | `preset` | +| TitleCard / FanningRotorOutro | Opening and closing cards | `title`, `subtitle`, `speedNote` | +| Window chrome + layouts | `SingleLayout` or `SideBySideLayout` | `layout`, `labels`, `objectFit` | +| ZoomEffect / SpotlightOverlay / KeystrokeOverlay / SectionHeader | Timed in-scene overlays | `effects`, `keys`, `sections` | +| CodeAnnotationOverlay | Timed syntax-highlighted code cards | `codeAnnotations` | +| Transition presentation | Title→content and content→outro crossfade | `transitionStyle` (default `motion-blur`) | +| NoiseOverlay + ColorGradeOverlay + Watermark | Topmost polish pass | `fidelity`, `preset` | + +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. + ## Platform isolation Platform-specific mechanics live below the atom that needs them: diff --git a/plugins/droid-control/NOTICES.md b/plugins/droid-control/NOTICES.md index a99d593..5db6f8b 100644 --- a/plugins/droid-control/NOTICES.md +++ b/plugins/droid-control/NOTICES.md @@ -7,6 +7,7 @@ This plugin depends on several third-party tools and libraries. They are not bun - **[Remotion](https://www.remotion.dev/)** -- React-based video renderer used by the compose/showcase pipeline. Remotion is free for individuals, small teams (<=3 employees), and non-profits. Larger companies require a [company license](https://www.remotion.pro/). See the [full license terms](https://github.com/remotion-dev/remotion/blob/main/LICENSE.md). - **[React](https://react.dev/)** -- MIT License - **[Zod](https://zod.dev/)** -- MIT License +- **[prism-react-renderer](https://github.com/FormidableLabs/prism-react-renderer)** -- MIT License. Powers the syntax highlighting in the `CodeAnnotationOverlay` component. ## Terminal automation @@ -23,3 +24,7 @@ This plugin depends on several third-party tools and libraries. They are not bun - **[ffmpeg](https://ffmpeg.org/)** -- multimedia framework (LGPL-2.1+ / GPL-2.0+, depending on build configuration) - **[cage](https://github.com/cage-kiosk/cage)** -- Wayland kiosk compositor (MIT) - **[wtype](https://github.com/atx/wtype)** -- Wayland keystroke injection (MIT) + +## Design influences + +- **[@hyperframes/shader-transitions](https://github.com/heygen-com/hyperframes/tree/main/packages/shader-transitions)** (Apache-2.0) -- the `transitionStyle` prop's naming and taxonomy (`whip-pan`, `light-leak`, `flash`, `glitch-lite`) was shaped by Hyperframes' shader-transitions catalog. Implementations in `ShowcaseTransition.tsx` are original Remotion-native CSS/SVG overlays, not GLSL ports. diff --git a/plugins/droid-control/README.md b/plugins/droid-control/README.md index e9ed556..c33cbb9 100644 --- a/plugins/droid-control/README.md +++ b/plugins/droid-control/README.md @@ -76,7 +76,7 @@ For the full rationale and runtime pipeline, see [`ARCHITECTURE.md`](ARCHITECTUR ## Video rendering -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. +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. The `render-showcase.sh` helper owns the full pipeline: `.cast` conversion via `agg`, clip staging, duration detection, Remotion rendering, and cleanup. diff --git a/plugins/droid-control/remotion/package-lock.json b/plugins/droid-control/remotion/package-lock.json index 1d15e15..acb1704 100644 --- a/plugins/droid-control/remotion/package-lock.json +++ b/plugins/droid-control/remotion/package-lock.json @@ -14,6 +14,7 @@ "@remotion/media": "4.0.445", "@remotion/tailwind": "4.0.445", "@remotion/transitions": "4.0.445", + "prism-react-renderer": "^2.4.1", "react": "^19", "react-dom": "^19", "remotion": "4.0.445", @@ -2151,6 +2152,12 @@ "undici-types": "~7.18.0" } }, + "node_modules/@types/prismjs": { + "version": "1.26.6", + "resolved": "https://registry.npmjs.org/@types/prismjs/-/prismjs-1.26.6.tgz", + "integrity": "sha512-vqlvI7qlMvcCBbVe0AKAb4f97//Hy0EBTaiW8AalRnG/xAN5zOiWWyrNqNXeq8+KAuvRewjCVY1+IPxk4RdNYw==", + "license": "MIT" + }, "node_modules/@types/react": { "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", @@ -2654,6 +2661,15 @@ "node": ">=6.0" } }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/commander": { "version": "2.20.3", "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", @@ -4684,6 +4700,19 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, + "node_modules/prism-react-renderer": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/prism-react-renderer/-/prism-react-renderer-2.4.1.tgz", + "integrity": "sha512-ey8Ls/+Di31eqzUxC46h8MksNuGx/n0AAC8uKpwFau4RPDYLuE3EXTp8N8G2vX2N7UC/+IXeNUnlWBGGcAG+Ig==", + "license": "MIT", + "dependencies": { + "@types/prismjs": "^1.26.0", + "clsx": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.0.0" + } + }, "node_modules/prompts": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", diff --git a/plugins/droid-control/remotion/package.json b/plugins/droid-control/remotion/package.json index a9d1d39..30732ec 100644 --- a/plugins/droid-control/remotion/package.json +++ b/plugins/droid-control/remotion/package.json @@ -14,6 +14,7 @@ "@remotion/media": "4.0.445", "@remotion/tailwind": "4.0.445", "@remotion/transitions": "4.0.445", + "prism-react-renderer": "^2.4.1", "react": "^19", "react-dom": "^19", "remotion": "4.0.445", diff --git a/plugins/droid-control/remotion/src/components/CodeAnnotationOverlay.tsx b/plugins/droid-control/remotion/src/components/CodeAnnotationOverlay.tsx new file mode 100644 index 0000000..327a2a0 --- /dev/null +++ b/plugins/droid-control/remotion/src/components/CodeAnnotationOverlay.tsx @@ -0,0 +1,243 @@ +import React, { useMemo } from 'react'; +import { useCurrentFrame, useVideoConfig, interpolate, Easing } from 'remotion'; +import { Highlight, themes, type PrismTheme } from 'prism-react-renderer'; +import type { CodeAnnotation, CodeRange } from '../lib/schema'; +import type { Palette } from '../lib/palettes'; +import type { PresetConfig } from '../lib/presets'; + +const MONO = + "'Geist Mono', 'Berkeley Mono', 'SF Mono', 'Cascadia Code', 'Fira Code', monospace"; + +const inRange = (line: number, ranges: CodeRange[]): boolean => + ranges.some((r) => line >= r.start && line <= r.end); + +const buildPrismTheme = (palette: Palette): PrismTheme => { + const base = themes.vsDark; + return { + ...base, + plain: { + ...base.plain, + color: palette.text, + backgroundColor: 'transparent', + }, + }; +}; + +const positionToAnchor = ( + position: CodeAnnotation['position'], + margin: number, +): React.CSSProperties => { + const edge = Math.max(40, margin + 24); + switch (position) { + case 'center': + return { + left: '50%', + top: '50%', + transform: 'translate(-50%, -50%)', + }; + case 'bottom-left': + return { left: edge, bottom: edge }; + case 'top-right': + default: + return { right: edge, top: edge }; + } +}; + +const CodeCard: React.FC<{ + annotation: CodeAnnotation; + palette: Palette; + config: PresetConfig; + enterFrame: number; + exitFrame: number; +}> = ({ annotation, palette, config, enterFrame, exitFrame }) => { + const frame = useCurrentFrame(); + const { fps } = useVideoConfig(); + const lines = useMemo( + () => annotation.code.replace(/\n$/, '').split('\n'), + [annotation.code], + ); + const prismTheme = useMemo(() => buildPrismTheme(palette), [palette]); + + if (frame < enterFrame || frame >= exitFrame) return null; + + const localFrame = frame - enterFrame; + const totalFrames = exitFrame - enterFrame; + + const enterProgress = interpolate(localFrame, [0, 0.35 * fps], [0, 1], { + easing: Easing.bezier(0.16, 1, 0.3, 1), + extrapolateLeft: 'clamp', + extrapolateRight: 'clamp', + }); + + const exitStart = Math.max(0, totalFrames - 0.35 * fps); + const exitProgress = interpolate( + localFrame, + [exitStart, totalFrames], + [0, 1], + { extrapolateLeft: 'clamp', extrapolateRight: 'clamp' }, + ); + + const opacity = enterProgress * (1 - exitProgress); + const scale = interpolate(enterProgress, [0, 1], [0.96, 1]); + const blur = interpolate(enterProgress, [0, 1], [6, 0]); + + const highlightRanges = annotation.highlight ?? []; + const focusRanges = annotation.focus ?? []; + const hasFocus = focusRanges.length > 0; + const anchor = positionToAnchor( + annotation.position ?? 'top-right', + config.margin, + ); + const maxLine = lines.length; + const gutterWidth = `${String(maxLine).length + 1}ch`; + + return ( +
+ {annotation.title && ( +
+ {annotation.title} +
+ )} + + {({ tokens, getLineProps, getTokenProps }) => ( +
+            {tokens.map((line, i) => {
+              const lineNumber = i + 1;
+              const isHighlighted = inRange(lineNumber, highlightRanges);
+              const isFocused = !hasFocus || inRange(lineNumber, focusRanges);
+              const lineOpacity = isFocused ? 1 : 0.3;
+              const lineFilter = isFocused ? 'none' : 'blur(1.2px)';
+              const bg = isHighlighted ? `${palette.accent}22` : 'transparent';
+              const lineBorder = isHighlighted
+                ? `2px solid ${palette.accent}`
+                : `2px solid transparent`;
+              const { key: _lineKey, ...lineRest } = getLineProps({
+                line,
+                key: i,
+              });
+
+              return (
+                
+ + {lineNumber} + + + {line.map((token, tokenIndex) => { + const { key: _tokenKey, ...tokenRest } = getTokenProps({ + token, + key: tokenIndex, + }); + return ( + + ); + })} + +
+ ); + })} +
+ )} +
+
+ ); +}; + +export const CodeAnnotationOverlay: React.FC<{ + annotations: CodeAnnotation[]; + palette: Palette; + config: PresetConfig; +}> = ({ annotations, palette, config }) => { + const { fps } = useVideoConfig(); + + if (!annotations.length) return null; + + return ( + <> + {annotations.map((annotation, i) => { + const enterFrame = Math.round(annotation.t * fps); + const exitFrame = Math.round((annotation.t + annotation.dur) * fps); + return ( + + ); + })} + + ); +}; diff --git a/plugins/droid-control/remotion/src/components/MotionBlurTransition.tsx b/plugins/droid-control/remotion/src/components/MotionBlurTransition.tsx deleted file mode 100644 index 8b89b22..0000000 --- a/plugins/droid-control/remotion/src/components/MotionBlurTransition.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import React from 'react'; -import { AbsoluteFill, interpolate } from 'remotion'; -import type { - TransitionPresentation, - TransitionPresentationComponentProps, -} from '@remotion/transitions'; - -export type MotionBlurProps = { - /** Peak blur radius in pixels (default 6). */ - maxBlur?: number; - /** Initial scale of the entering scene (default 1.03). */ - enterScale?: number; -}; - -/** - * Custom presentation component for @remotion/transitions. - * - * Combines three effects during the crossfade: - * (a) CSS filter: blur() — 6 px at the edges, 0 px at the center - * (b) scale 1.03 → 1.0 on the entering scene (camera-dolly feel) - * (c) opacity crossfade - */ -const MotionBlurPresentation: React.FC< - TransitionPresentationComponentProps -> = ({ - children, - presentationDirection, - presentationProgress, - passedProps, -}) => { - const maxBlur = passedProps.maxBlur ?? 6; - const enterScale = passedProps.enterScale ?? 1.03; - const isEntering = presentationDirection === 'entering'; - - let style: React.CSSProperties; - if (isEntering) { - const blur = interpolate(presentationProgress, [0, 1], [maxBlur, 0], { - extrapolateLeft: 'clamp', - extrapolateRight: 'clamp', - }); - const scale = interpolate(presentationProgress, [0, 1], [enterScale, 1], { - extrapolateLeft: 'clamp', - extrapolateRight: 'clamp', - }); - style = { - opacity: presentationProgress, - filter: `blur(${blur}px)`, - transform: `scale(${scale})`, - }; - } else { - const blur = interpolate(presentationProgress, [0, 1], [0, maxBlur], { - extrapolateLeft: 'clamp', - extrapolateRight: 'clamp', - }); - style = { - opacity: 1 - presentationProgress, - filter: `blur(${blur}px)`, - }; - } - - return {children}; -}; - -/** - * Factory function returning a TransitionPresentation compatible with - * ``. - */ -export const motionBlurTransition = ( - props?: MotionBlurProps -): TransitionPresentation => { - return { - component: MotionBlurPresentation, - props: props ?? {}, - }; -}; diff --git a/plugins/droid-control/remotion/src/components/ShowcaseTransition.tsx b/plugins/droid-control/remotion/src/components/ShowcaseTransition.tsx new file mode 100644 index 0000000..303091e --- /dev/null +++ b/plugins/droid-control/remotion/src/components/ShowcaseTransition.tsx @@ -0,0 +1,335 @@ +// Transition style names (`whip-pan`, `light-leak`, `flash`, `glitch-lite`) and +// the "selectable enum of named transitions" API shape are inspired by +// @hyperframes/shader-transitions (Apache-2.0). Implementations below are +// original Remotion-native CSS/SVG overlays — no GLSL/WebGL involved. +// https://github.com/heygen-com/hyperframes/tree/main/packages/shader-transitions + +import React from 'react'; +import { AbsoluteFill, interpolate, Easing, random } from 'remotion'; +import type { + TransitionPresentation, + TransitionPresentationComponentProps, +} from '@remotion/transitions'; +import type { TransitionStyle } from '../lib/schema'; +import type { Palette } from '../lib/palettes'; + +type BaseProps = { + palette: Palette; +}; + +const clamp01 = (v: number): number => Math.min(1, Math.max(0, v)); + +// --------------------------------------------------------------------------- +// Motion blur: blur() + scale + opacity crossfade. (Existing aesthetic.) +// --------------------------------------------------------------------------- +const MotionBlurPresentation: React.FC< + TransitionPresentationComponentProps +> = ({ children, presentationDirection, presentationProgress }) => { + const maxBlur = 6; + const enterScale = 1.03; + const isEntering = presentationDirection === 'entering'; + + if (isEntering) { + const blur = interpolate(presentationProgress, [0, 1], [maxBlur, 0], { + extrapolateLeft: 'clamp', + extrapolateRight: 'clamp', + }); + const scale = interpolate(presentationProgress, [0, 1], [enterScale, 1], { + extrapolateLeft: 'clamp', + extrapolateRight: 'clamp', + }); + return ( + + {children} + + ); + } + + const blur = interpolate(presentationProgress, [0, 1], [0, maxBlur], { + extrapolateLeft: 'clamp', + extrapolateRight: 'clamp', + }); + return ( + + {children} + + ); +}; + +// --------------------------------------------------------------------------- +// Flash: hard cut emphasised by a warm/white flash overlay at the midpoint. +// --------------------------------------------------------------------------- +const FlashPresentation: React.FC< + TransitionPresentationComponentProps +> = ({ children, presentationDirection, presentationProgress, passedProps }) => { + const isEntering = presentationDirection === 'entering'; + const palette = passedProps.palette; + const isWarm = palette.accent === '#EE6018'; + + const sceneOpacity = isEntering + ? clamp01((presentationProgress - 0.45) / 0.35) + : clamp01(1 - (presentationProgress - 0.2) / 0.35); + + // Flash is rendered only on the entering side so the peak is single-source. + const flashOpacity = isEntering + ? interpolate( + presentationProgress, + [0, 0.35, 0.55, 0.85, 1], + [0, 0.25, 1, 0.2, 0], + { extrapolateLeft: 'clamp', extrapolateRight: 'clamp' }, + ) + : 0; + + const flashColor = isWarm + ? `rgba(255, 220, 180, ${flashOpacity})` + : `rgba(235, 240, 255, ${flashOpacity})`; + + return ( + + {children} + {flashOpacity > 0 && ( + + )} + + ); +}; + +// --------------------------------------------------------------------------- +// Whip-pan: horizontal pan + motion blur for energetic scene changes. +// --------------------------------------------------------------------------- +const WhipPanPresentation: React.FC< + TransitionPresentationComponentProps +> = ({ children, presentationDirection, presentationProgress }) => { + const isEntering = presentationDirection === 'entering'; + const easing = Easing.bezier(0.6, 0, 0.1, 1); + const eased = easing(presentationProgress); + const maxBlur = 14; + + if (isEntering) { + const translateX = interpolate(eased, [0, 1], [30, 0]); + const blur = interpolate(presentationProgress, [0, 0.6, 1], [maxBlur, 4, 0], { + extrapolateLeft: 'clamp', + extrapolateRight: 'clamp', + }); + const opacity = interpolate(presentationProgress, [0, 0.2, 1], [0, 1, 1], { + extrapolateLeft: 'clamp', + extrapolateRight: 'clamp', + }); + return ( + + {children} + + ); + } + + const translateX = interpolate(eased, [0, 1], [0, -30]); + const blur = interpolate(presentationProgress, [0, 0.4, 1], [0, 10, maxBlur], { + extrapolateLeft: 'clamp', + extrapolateRight: 'clamp', + }); + const opacity = interpolate(presentationProgress, [0, 0.8, 1], [1, 1, 0], { + extrapolateLeft: 'clamp', + extrapolateRight: 'clamp', + }); + return ( + + {children} + + ); +}; + +// --------------------------------------------------------------------------- +// Light leak: warm moving gradient sweeps across while scenes crossfade. +// --------------------------------------------------------------------------- +const LightLeakPresentation: React.FC< + TransitionPresentationComponentProps +> = ({ children, presentationDirection, presentationProgress, passedProps }) => { + const isEntering = presentationDirection === 'entering'; + const palette = passedProps.palette; + const isWarm = palette.accent === '#EE6018'; + + const sceneOpacity = isEntering + ? interpolate(presentationProgress, [0, 0.5, 1], [0, 0.4, 1], { + extrapolateLeft: 'clamp', + extrapolateRight: 'clamp', + }) + : interpolate(presentationProgress, [0, 0.5, 1], [1, 0.6, 0], { + extrapolateLeft: 'clamp', + extrapolateRight: 'clamp', + }); + + const sweepOpacity = isEntering + ? interpolate( + presentationProgress, + [0, 0.3, 0.6, 1], + [0, 1, 0.6, 0], + { extrapolateLeft: 'clamp', extrapolateRight: 'clamp' }, + ) + : 0; + + const sweepPosition = interpolate(presentationProgress, [0, 1], [-40, 140]); + + const leakColor = isWarm ? '238, 96, 24' : '137, 180, 250'; + + return ( + + {children} + {sweepOpacity > 0 && ( + + )} + + ); +}; + +// --------------------------------------------------------------------------- +// Glitch-lite: brief RGB offset + clipped horizontal bands near midpoint. +// --------------------------------------------------------------------------- +const GlitchLitePresentation: React.FC< + TransitionPresentationComponentProps +> = ({ children, presentationDirection, presentationProgress }) => { + const isEntering = presentationDirection === 'entering'; + + const sceneOpacity = isEntering + ? interpolate(presentationProgress, [0, 0.4, 1], [0, 1, 1], { + extrapolateLeft: 'clamp', + extrapolateRight: 'clamp', + }) + : interpolate(presentationProgress, [0, 0.6, 1], [1, 1, 0], { + extrapolateLeft: 'clamp', + extrapolateRight: 'clamp', + }); + + const glitchIntensity = interpolate( + presentationProgress, + [0, 0.35, 0.55, 0.75, 1], + [0, 1, 1, 0.3, 0], + { extrapolateLeft: 'clamp', extrapolateRight: 'clamp' }, + ); + + const seedBase = isEntering ? 1000 : 2000; + const jitterX = (random(`glitch-x-${Math.round(presentationProgress * 60) + seedBase}`) - 0.5) * 16 * glitchIntensity; + const rgbSplit = 6 * glitchIntensity; + + const bandTop = 20 + 40 * random(`glitch-band-top-${Math.round(presentationProgress * 30) + seedBase}`); + const bandHeight = 6 + 14 * random(`glitch-band-h-${Math.round(presentationProgress * 30) + seedBase}`); + const showBand = glitchIntensity > 0.6; + + return ( + + {/* Red ghost */} + 0 ? 0.45 : 0, + }} + > + + {children} + + + {/* Blue ghost */} + 0 ? 0.45 : 0, + }} + > + + {children} + + + {/* Main scene */} + + {children} + + {/* Displaced band */} + {showBand && ( + + {children} + + )} + + ); +}; + +const presentationMap: Record< + TransitionStyle, + React.FC> +> = { + 'motion-blur': MotionBlurPresentation, + flash: FlashPresentation, + 'whip-pan': WhipPanPresentation, + 'light-leak': LightLeakPresentation, + 'glitch-lite': GlitchLitePresentation, +}; + +export const getTransitionPresentation = ( + style: TransitionStyle, + palette: Palette, +): TransitionPresentation => { + const component = presentationMap[style]; + return { + component, + props: { palette }, + }; +}; diff --git a/plugins/droid-control/remotion/src/compositions/Showcase.tsx b/plugins/droid-control/remotion/src/compositions/Showcase.tsx index a142759..ec88b08 100644 --- a/plugins/droid-control/remotion/src/compositions/Showcase.tsx +++ b/plugins/droid-control/remotion/src/compositions/Showcase.tsx @@ -3,7 +3,7 @@ import { z } from 'zod'; import { AbsoluteFill, staticFile, useVideoConfig } from 'remotion'; import { Video } from '@remotion/media'; import { TransitionSeries, linearTiming } from '@remotion/transitions'; -import { motionBlurTransition } from '../components/MotionBlurTransition'; +import { getTransitionPresentation } from '../components/ShowcaseTransition'; import { fidelitySchema, presetSchema, @@ -11,6 +11,8 @@ import { keystrokeSchema, effectSchema, sectionSchema, + codeAnnotationSchema, + transitionStyleSchema, } from '../lib/schema'; import { getPalette } from '../lib/palettes'; import { getPresetConfig } from '../lib/presets'; @@ -28,6 +30,7 @@ import { Watermark } from '../components/Watermark'; import { ZoomEffect } from '../components/ZoomEffect'; import { SectionTransitionOverlay } from '../components/SectionTransition'; import { FanningRotorOutro } from '../components/FanningRotorOutro'; +import { CodeAnnotationOverlay } from '../components/CodeAnnotationOverlay'; export const showcaseSchema = z.object({ clips: z.array(z.string()), @@ -53,11 +56,17 @@ export const showcaseSchema = z.object({ // and you'd rather crop edges than see giant black bars. // - "fill": stretch to fill the panel (distorts aspect). Rarely what you want. objectFit: z.enum(['contain', 'cover', 'fill']).optional(), + // Timed syntax-highlighted code overlays rendered during the main content + // sequence (above content, below noise/color grade). Timings are relative + // to the start of the main content clip. + codeAnnotations: z.array(codeAnnotationSchema).optional(), + // Presentation used for title->content and content->outro transitions. + // Defaults to 'motion-blur', which preserves existing aesthetic. + transitionStyle: transitionStyleSchema.optional(), }); const TITLE_DURATION_S = 4; const TRANSITION_FRAMES = 15; -const MOTION_BLUR = motionBlurTransition(); const resolveFidelity = ( props: z.infer @@ -177,6 +186,14 @@ export const ShowcaseComposition: React.FC> = ( const titleFrames = TITLE_DURATION_S * fps; const clipFrames = Math.ceil((props.clipDuration ?? 60) * fps); const objectFit = props.objectFit ?? 'contain'; + const transition = useMemo( + () => + getTransitionPresentation( + props.transitionStyle ?? 'motion-blur', + palette + ), + [props.transitionStyle, palette] + ); const spotlights = useMemo( () => @@ -212,7 +229,7 @@ export const ShowcaseComposition: React.FC> = ( {/* Crossfade from title to content */} @@ -294,12 +311,21 @@ export const ShowcaseComposition: React.FC> = ( config={config} /> )} + + {/* Code annotations: timed syntax-highlighted overlays */} + {props.codeAnnotations && props.codeAnnotations.length > 0 && ( + + )} {/* Crossfade to outro */} diff --git a/plugins/droid-control/remotion/src/lib/schema.ts b/plugins/droid-control/remotion/src/lib/schema.ts index 3301038..9115919 100644 --- a/plugins/droid-control/remotion/src/lib/schema.ts +++ b/plugins/droid-control/remotion/src/lib/schema.ts @@ -73,3 +73,32 @@ export const effectSchema = z.discriminatedUnion('fx', [ }), ]); export type Effect = z.infer; + +export const codeRangeSchema = z.object({ + start: z.number().int().positive(), + end: z.number().int().positive(), +}); +export type CodeRange = z.infer; + +export const codeAnnotationSchema = z.object({ + t: z.number(), + dur: z.number(), + code: z.string(), + language: z.string().default('tsx'), + title: z.string().optional(), + highlight: z.array(codeRangeSchema).default([]), + focus: z.array(codeRangeSchema).default([]), + position: z + .enum(['center', 'top-right', 'bottom-left']) + .default('top-right'), +}); +export type CodeAnnotation = z.infer; + +export const transitionStyleSchema = z.enum([ + 'motion-blur', + 'flash', + 'whip-pan', + 'light-leak', + 'glitch-lite', +]); +export type TransitionStyle = z.infer; diff --git a/plugins/droid-control/skills/compose/SKILL.md b/plugins/droid-control/skills/compose/SKILL.md index 9187d11..2460c8e 100644 --- a/plugins/droid-control/skills/compose/SKILL.md +++ b/plugins/droid-control/skills/compose/SKILL.md @@ -55,7 +55,7 @@ Both use the same Remotion pipeline but target different visual registers. | | Showcase | Demo | |---|---|---| -| **Goal** | Cinematic, high-polish marketing material | Clear, utilitarian before/after comparison | +| **Goal** | Cinematic, high-polish marketing material | Clear, utilitarian demonstration — single or comparison, whichever the story calls for | | **Preset** | `factory`, `factory-hero`, or `hero` | `macos`, `minimal`, or `presentation` | | **Effects tier** | **Full** -- spotlight, zoom, callout, keystroke overlay. Go all out. | **Utilitarian** -- zoom for readability, keystroke overlay for user actions | | **Audience** | External — landing pages, social, marketing | Internal — PR reviews, docs, QA | @@ -146,6 +146,12 @@ This checkpoint is not optional. A video that lands outside the target range fai ## Step 2: Build props +### Choose layout + +**Default: `single`.** One clip of the target/final state. New features, bug-fix proofs, walkthroughs, and README heroes all belong here. + +Use `side-by-side` only when the story is fundamentally a comparison: regression (broken vs fixed), behavior-preserving refactor, or an explicit user request. Never fabricate a "before" clip to justify the side-by-side shape. + Save the `showcaseSchema` JSON to a temp file: ```bash @@ -154,10 +160,10 @@ PROPS="${DEMO_TMP}/showcase-props.json" cat > "$PROPS" << 'EOF' { - "clips": ["before.cast", "after.cast"], - "layout": "side-by-side", + "clips": ["demo.cast"], + "layout": "single", "fidelity": "auto", - "labels": ["BEFORE (dev)", "AFTER (PR #11621)"], + "labels": [], "speed": 3, "title": "PR #11621 — Prevent session freezes", "subtitle": "Bash Mode blocks interactive commands and supports ESC cancellation", @@ -167,10 +173,7 @@ cat > "$PROPS" << 'EOF' {"t": 5.5, "label": "sleep 100"}, {"t": 8.0, "label": "Esc"} ], - "sections": [ - {"t": 2.0, "title": "Testing basic echo"}, - {"t": 10.0, "title": "Testing long loop"} - ], + "sections": [], "effects": [], "speedNote": "3x speed", "windowTitle": "droid demo" @@ -178,6 +181,8 @@ cat > "$PROPS" << 'EOF' EOF ``` +For a comparison flow, swap `"clips"` to two paths, `"layout"` to `"side-by-side"`, and populate `"labels"` (e.g., `["BEFORE (main)", "AFTER (PR #11621)"]`). + Use a run-scoped props path like `$PROPS`; do not reuse a global `/tmp/showcase-props.json` across rerenders or concurrent demos. **CRITICAL: `clipDuration` handling.** The render script auto-detects clip duration via ffprobe when `clipDuration` is omitted from the props. If you set it manually, it **must** match the actual clip duration or you get blank frames (too long) or truncation (too short). When in doubt, omit it and let the script auto-detect. @@ -203,6 +208,8 @@ Use a run-scoped props path like `$PROPS`; do not reuse a global `/tmp/showcase- | `width` | `number` | no | Output width (default: 2560 for inspect, else 1920) | | `height` | `number` | no | Output height (default: 1440 for inspect, else 1080) | | `objectFit` | `"contain" \| "cover" \| "fill"` | no | How each clip fits its panel. Default `"contain"` (letterbox to preserve aspect). Use `"cover"` when clip aspect doesn't match panel aspect and you'd rather crop than see black bars. See "Clip aspect ratio" below. | +| `codeAnnotations` | `CodeAnnotation[]` | no | Timed syntax-highlighted code overlays shown during the main content sequence. See "Code annotations" below. | +| `transitionStyle` | `"motion-blur" \| "flash" \| "whip-pan" \| "light-leak" \| "glitch-lite"` | no | Presentation used for title→content and content→outro transitions. Default `"motion-blur"` preserves existing aesthetic. See "Transition styles" below. | ### Preset quick reference @@ -257,6 +264,35 @@ Regions use percentage strings (e.g., `"25%"`) relative to the video dimensions. **Less is more.** One well-timed spotlight has more impact than five overlapping effects. +### Code annotations + +Timed syntax-highlighted code card laid over the captured video. Use for PR demos where the decisive source change needs to sit next to the runtime proof. + +| Field | Type | Required | Description | +|---|---|---|---| +| `t` | `number` | yes | Start time in seconds, relative to clip start. Adjust for `speed` factor (same rule as `keys[].t`). | +| `dur` | `number` | yes | How long the card stays visible, in seconds. | +| `code` | `string` | yes | Source text; `\n` for multiline; no trailing newline. | +| `language` | `string` | no | Prism language id (`tsx`, `ts`, `py`, `rust`, `bash`, ...). Default `tsx`. | +| `title` | `string` | no | Small caption above the code (usually a file path). | +| `highlight` | `[{start,end}]` | no | 1-based inclusive line ranges with accent background + left border. | +| `focus` | `[{start,end}]` | no | 1-based inclusive line ranges kept at full opacity; others are dimmed/blurred. | +| `position` | `"top-right" \| "center" \| "bottom-left"` | no | Default `"top-right"`. Move to `"bottom-left"` if the captured top-right is load-bearing. | + +Keep it short — aim for ≤ 15 lines per card, hold for 3–6 seconds. + +### Transition styles + +`transitionStyle` selects the title→content and content→outro crossfade presentation. Both transitions in one render share the same style. `flash` and `light-leak` derive their tint from the preset palette. Default `motion-blur` is always safe; preset-tier guidance lives in `showcase/SKILL.md`. + +| Style | Feel | Use when… | +|---|---|---| +| `motion-blur` | Subtle dolly, blur + opacity crossfade | Default for PR demos, Factory content, most showcase work | +| `flash` | Quick palette-tinted flash at midpoint | Bug-fix proofs where the "after" state should feel sudden | +| `whip-pan` | Horizontal pan + motion blur | Energetic showcase / marketing when pacing is fast | +| `light-leak` | Warm gradient sweep | Factory-branded landing/social clips | +| `glitch-lite` | RGB channel offset + horizontal band | Security/vulnerability proof, terminal aesthetic; never default, never twice | + ## Step 3: Render Use the render script — it handles clip staging, duration detection, rendering, and cleanup: diff --git a/plugins/droid-control/skills/droid-control/SKILL.md b/plugins/droid-control/skills/droid-control/SKILL.md index cb0be05..dabc9e1 100644 --- a/plugins/droid-control/skills/droid-control/SKILL.md +++ b/plugins/droid-control/skills/droid-control/SKILL.md @@ -71,6 +71,21 @@ Command (intent + commitments) Commands declare **what** to produce. Atoms own **how**. +### Layout default + +**Default: `single`.** One clip showing the target/final state. Pick this unless the deliverable is fundamentally a comparison. + +| Case | Layout | +|---|---| +| Brand-new feature (no meaningful prior state) | `single` | +| Bug fix, single-clip proof of the working path | `single` | +| Walkthrough / tutorial / readme hero | `single` | +| Regression proof (broken vs fixed) | `side-by-side` | +| Behavior-preserving refactor (visual parity is the point) | `side-by-side` | +| User explicitly asks for a comparison | `side-by-side` | + +Do not synthesize a "before" state to justify `side-by-side`. If there is no real baseline, use `single`. + ## Delegation The parent agent plans and orchestrates. Mechanical work runs in **worker subagents** via the Task tool. This keeps the parent's context clean and enables parallelism. @@ -79,8 +94,8 @@ The parent agent plans and orchestrates. Mechanical work runs in **worker subage | Task | Delegate? | Why | |---|---|---| -| **Capture before branch** | YES — `run_in_background=true` | Independent from after branch; run both in parallel | -| **Capture after branch** | YES — `run_in_background=true` | Independent from before branch | +| **Capture clip** (single layout) | YES | Worker runs the interaction script end-to-end and returns the `.cast` path | +| **Capture both clips** (comparison layout) | YES — `run_in_background=true` for each | Branches are independent; run in parallel | | **Remotion render** | YES | Needs only props JSON, clip paths, output path. Runs `render-showcase.sh` (handles .cast conversion, fidelity profiles, duration detection, cleanup) | | Planning, interaction scripting | NO — parent | Requires PR context and editorial judgment | | Layout and prop construction | NO — parent | Requires editorial decisions about effects, timing, labels | @@ -125,7 +140,9 @@ Task prompt for a Remotion render worker: /tmp/droid-run-1712345678-42-xxxx/before.cast /tmp/droid-run-1712345678-42-xxxx/after.cast" ``` -### Parallel capture pattern +### Parallel capture pattern (comparison flows only) + +Only applicable when the Layout default table above selects `side-by-side`. For a `single` layout, launch one capture worker and skip this section. For before/after comparison demos, launch both capture workers simultaneously: diff --git a/plugins/droid-control/skills/showcase/SKILL.md b/plugins/droid-control/skills/showcase/SKILL.md index d454ecb..a13e6f4 100644 --- a/plugins/droid-control/skills/showcase/SKILL.md +++ b/plugins/droid-control/skills/showcase/SKILL.md @@ -39,7 +39,7 @@ Each preset configures window chrome, spacing, background style, and palette sel - Subtle cool color grade overlay - Floating particles in accent blue -**All presets** include: floating particles, noise texture overlay, color grade, motion blur title→content transition, animated window entrance, staggered panel entrance (side-by-side). +**All presets** include: floating particles, noise texture overlay, color grade, motion blur title→content transition (configurable via `transitionStyle`), animated window entrance, staggered panel entrance (side-by-side), and optional `codeAnnotations` syntax-highlighted overlays during the main content sequence. ## Visual palettes @@ -65,6 +65,18 @@ Palette is auto-selected based on preset. Factory/factory-hero use the warm pale | text | `#cdd6f4` | Cool white | | muted | `#6c7086` | De-emphasized text | +## Transition styles + +`transitionStyle` selects the crossfade presentation. Schema lives in `compose/SKILL.md`; preset-tier matching: + +| Preset | Recommended (default first) | Avoid | +|---|---|---| +| `factory`, `factory-hero` | `motion-blur`, `light-leak`, `whip-pan`, `flash` | `glitch-lite` (clashes with warm tone) | +| `hero`, `presentation` | `motion-blur`, `whip-pan`, `flash` | `light-leak` (warm sweep clashes with cool palette) | +| `macos`, `minimal` | `motion-blur` | `glitch-lite`, `light-leak` (too much personality for utilitarian frames) | + +`codeAnnotations` is preset-agnostic — palette and font stack are auto-derived. See `compose/SKILL.md` for schema and authoring rules. + ## Operational notes **Render time**: ~1-3 minutes for a 30-60s video at 1920x1080. Set worker timeouts to 5 minutes.