= {
+ resize: ResizeIcon,
+ compress: CompressIcon,
+ watermark: WatermarkIcon,
+};
+
+/* ------------------------------------------------------------------ */
+/* Station component */
+/* ------------------------------------------------------------------ */
+
+const Station: React.FC<{
+ station: (typeof STATIONS)[number];
+ index: number;
+ glowing: boolean;
+ pulseGlow: boolean;
+}> = ({ station, index, glowing, pulseGlow }) => {
+ const frame = useCurrentFrame();
+ const { fps } = useVideoConfig();
+
+ const scale = spring({
+ frame,
+ fps,
+ delay: index * 8,
+ config: SPRING.snappy,
+ });
+
+ const Icon = STATION_ICONS[station.id];
+ const glowOpacity = pulseGlow ? 0.3 + Math.sin(frame * 0.3) * 0.2 : glowing ? 0.5 : 0;
+
+ return (
+ 0
+ ? `0 0 20px ${station.color}${Math.round(glowOpacity * 255)
+ .toString(16)
+ .padStart(2, "0")}`
+ : "none",
+ transform: `scale(${scale})`,
+ display: "flex",
+ flexDirection: "column",
+ alignItems: "center",
+ justifyContent: "center",
+ gap: 6,
+ }}
+ >
+ {Icon && }
+
+ {station.label}
+
+
+ );
+};
+
+/* ------------------------------------------------------------------ */
+/* Connector lines between stations */
+/* ------------------------------------------------------------------ */
+
+const ConnectorLine: React.FC<{
+ fromX: number;
+ toX: number;
+ delay: number;
+}> = ({ fromX, toX, delay }) => {
+ const frame = useCurrentFrame();
+ const progress = interpolate(frame, [delay, delay + 20], [0, 1], {
+ extrapolateLeft: "clamp",
+ extrapolateRight: "clamp",
+ easing: EASE.smooth,
+ });
+
+ const x1 = fromX + STATION_W / 2 + 4;
+ const x2 = toX - STATION_W / 2 - 4;
+ const pathD = `M ${x1} ${STATION_Y} L ${x2} ${STATION_Y}`;
+ const evolved = evolvePath(progress, pathD);
+
+ return (
+
+ );
+};
+
+/* ------------------------------------------------------------------ */
+/* Folder icon */
+/* ------------------------------------------------------------------ */
+
+const FolderIcon: React.FC<{ pulse: boolean }> = ({ pulse }) => {
+ const frame = useCurrentFrame();
+ const { fps } = useVideoConfig();
+ const scale = spring({ frame, fps, delay: 24, config: SPRING.snappy });
+ const pulseScale = pulse ? 1 + Math.sin(frame * 0.2) * 0.05 : 1;
+
+ return (
+
+ );
+};
+
+/* ------------------------------------------------------------------ */
+/* Progress ring (SVG circle with stroke animation) */
+/* ------------------------------------------------------------------ */
+
+const ProgressRing: React.FC<{
+ progress: number;
+ color: string;
+ x: number;
+ y: number;
+}> = ({ progress, color, x, y }) => {
+ const r = 14;
+ const circumference = 2 * Math.PI * r;
+ const offset = circumference * (1 - Math.min(progress, 1));
+
+ return (
+
+ );
+};
+
+/* ------------------------------------------------------------------ */
+/* Checkmark */
+/* ------------------------------------------------------------------ */
+
+const Checkmark: React.FC<{ visible: boolean }> = ({ visible }) => {
+ const frame = useCurrentFrame();
+ const { fps } = useVideoConfig();
+
+ if (!visible) return null;
+
+ const scale = spring({
+ frame,
+ fps,
+ config: SPRING.popIn,
+ });
+
+ return (
+
+ );
+};
+
+/* ------------------------------------------------------------------ */
+/* Single thumbnail traversal (Act 2) */
+/* ------------------------------------------------------------------ */
+
+const SingleFlow: React.FC = () => {
+ const frame = useCurrentFrame();
+
+ // Spec timing (global -> local, Sequence from=40):
+ // Frame 40-70 (local 0-30): Travel to Station 1 + resize effect
+ // Frame 70-100 (local 30-60): Travel to Station 2 + compress effect
+ // Frame 100-130(local 60-90): Travel to Station 3 + watermark effect
+ // Frame 130-155(local 90-115): Travel to folder + checkmark
+
+ // Thumbnail X position: moves through each station to folder
+ // Input/output arrays must be same length (12 entries each)
+ const thumbX = interpolate(
+ frame,
+ [0, 18, 26, 30, 48, 56, 60, 78, 86, 90, 110, 115],
+ [
+ THUMB_START_X, // 0: start off-screen
+ STATIONS[0].x, // 18: arrive at Resize
+ STATIONS[0].x, // 26: pause for resize effect
+ STATIONS[0].x, // 30: leave Resize
+ STATIONS[1].x, // 48: arrive at Compress
+ STATIONS[1].x, // 56: pause for compress effect
+ STATIONS[1].x, // 60: leave Compress
+ STATIONS[2].x, // 78: arrive at Watermark
+ STATIONS[2].x, // 86: pause for watermark effect
+ STATIONS[2].x, // 90: leave Watermark
+ FOLDER_X, // 110: arrive at folder
+ FOLDER_X, // 115: hold at folder
+ ],
+ { extrapolateLeft: "clamp", extrapolateRight: "clamp", easing: EASE.smooth },
+ );
+
+ // Thumbnail size: shrinks at resize station (local 18-26)
+ const thumbSize = interpolate(frame, [18, 26], [48, 36], {
+ extrapolateLeft: "clamp",
+ extrapolateRight: "clamp",
+ easing: EASE.smooth,
+ });
+
+ // Resize label morph
+ const showResizeLabel = frame >= 16 && frame <= 34;
+ const resizeLabelProgress = interpolate(frame, [18, 26], [0, 1], {
+ extrapolateLeft: "clamp",
+ extrapolateRight: "clamp",
+ });
+
+ // Compress ring progress (local 48-60)
+ const compressRingProgress = interpolate(frame, [48, 60], [0, 1], {
+ extrapolateLeft: "clamp",
+ extrapolateRight: "clamp",
+ easing: EASE.smooth,
+ });
+
+ // Watermark stamp (local 78-86)
+ const stampOpacity = interpolate(frame, [78, 84], [0, 0.4], {
+ extrapolateLeft: "clamp",
+ extrapolateRight: "clamp",
+ });
+
+ // Slide behind folder (local 105-115)
+ const folderScale = interpolate(frame, [105, 115], [1, 0.8], {
+ extrapolateLeft: "clamp",
+ extrapolateRight: "clamp",
+ });
+ const thumbOpacity = interpolate(frame, [108, 115], [1, 0], {
+ extrapolateLeft: "clamp",
+ extrapolateRight: "clamp",
+ });
+
+ // Station glow states (6 frames each centered on arrival)
+ const resizeGlow = frame >= 16 && frame < 30;
+ const compressGlow = frame >= 46 && frame < 62;
+ const watermarkGlow = frame >= 76 && frame < 90;
+
+ // Checkmark appears at local frame 115
+ const showCheck = frame >= 115;
+
+ return (
+ <>
+ {/* Thumbnail */}
+ = 108 ? 0 : 5,
+ }}
+ >
+
+ {/* Watermark stamp */}
+ {stampOpacity > 0 && (
+
+ SnapOtter
+
+ )}
+
+
+ {/* Resize label */}
+ {showResizeLabel && (
+
+ {resizeLabelProgress < 0.5 ? "1200px" : "800px"}
+
+ )}
+
+ {/* Compress ring + file size */}
+
+ {frame >= 46 && frame <= 68 && (
+
+ (n >= 1000 ? `${(n / 1000).toFixed(1)} MB` : `${n} KB`)}
+ />
+
+ )}
+
+ {/* Station glows passed as props */}
+ {STATIONS.map((s, i) => (
+
+ ))}
+
+
+ >
+ );
+};
+
+/* ------------------------------------------------------------------ */
+/* Batch thumbnail (Act 3) */
+/* ------------------------------------------------------------------ */
+
+const BatchThumb: React.FC<{
+ index: number;
+}> = ({ index }) => {
+ const frame = useCurrentFrame();
+ const staggerDelay = index * 3;
+ const localFrame = frame - staggerDelay;
+
+ if (localFrame < 0) return null;
+
+ const hue = random(`thumb-hue-${index}`) * 360;
+ const size = 24 + random(`thumb-size-${index}`) * 8;
+ const yOffset = (random(`thumb-y-${index}`) - 0.5) * 30;
+
+ // Fast traversal: cover pipeline in ~28 frames per thumb
+ const thumbX = interpolate(localFrame, [0, 28], [THUMB_START_X, FOLDER_X + 10], {
+ extrapolateLeft: "clamp",
+ extrapolateRight: "clamp",
+ easing: EASE.smooth,
+ });
+
+ const opacity = interpolate(localFrame, [0, 3, 25, 28], [0, 1, 1, 0], {
+ extrapolateLeft: "clamp",
+ extrapolateRight: "clamp",
+ });
+
+ if (localFrame > 32) return null;
+
+ return (
+
+ );
+};
+
+/* ------------------------------------------------------------------ */
+/* Batch stream wrapper with Trail */
+/* ------------------------------------------------------------------ */
+
+const BatchStream: React.FC = () => {
+ const frame = useCurrentFrame();
+ const thumbs = Array.from({ length: BATCH_COUNT }, (_, i) => i);
+
+ // How many have arrived at folder
+ const arrivedCount = thumbs.filter((i) => {
+ const localFrame = frame - i * 3;
+ return localFrame >= 28;
+ }).length;
+
+ return (
+ <>
+ {/* Stations with continuous pulse glow during batch */}
+ {STATIONS.map((s, i) => (
+ = 10} />
+ ))}
+
+
+
+ {thumbs.map((i) => (
+
+ ))}
+
+
+
+ {/* Batch counter above folder */}
+ {arrivedCount > 0 && (
+
+
+
+ )}
+ >
+ );
+};
+
+/* ------------------------------------------------------------------ */
+/* Main composition */
+/* ------------------------------------------------------------------ */
+
+export const PipelineFlow: React.FC = () => {
+ const frame = useCurrentFrame();
+
+ return (
+
+ {/* Act 1: Stations Appear (frame 0-40) */}
+
+ {/* Connector lines (drawn during Act 1) */}
+
+
+
+
+ {/* Folder (appears last in Act 1) */}
+ = 340 && frame < 380} />
+
+
+ {/* Act 1 stations (visible throughout until Act 2 takes over) */}
+ {frame < 40 && (
+ <>
+ {STATIONS.map((s, i) => (
+
+ ))}
+ >
+ )}
+
+ {/* Act 2: Single Image Flow (frame 40-170) */}
+
+
+
+
+ {/* "Chain any tools." text (frame 155-350) */}
+ {frame >= 155 && frame < 350 && (
+
+
+ Chain any tools.
+
+
+ )}
+
+ {/* Act 3: Batch Mode (frame 170-340) */}
+
+
+
+
+ {/* Act 3 stations when not covered by SingleFlow or BatchStream */}
+ {frame >= 170 && frame < 180 && (
+ <>
+ {STATIONS.map((s, i) => (
+
+ ))}
+ >
+ )}
+
+ {/* Act 4: Result (frame 340-390) */}
+ {frame >= 340 && (
+ <>
+ {STATIONS.map((s, i) => (
+
+ ))}
+
+ {/* Final counter at 50 */}
+
+
+ 50
+
+
+ >
+ )}
+
+ {/* "Batch everything." text (frame 360-390) */}
+
+
+
+ Batch everything.
+
+
+
+
+
+
+ );
+};