Files
ClaudeMarketing/pipeline/remotion-ad/src/HoneyDueAd.tsx
T
Trey t 80a1ffbe4d feat: add multi-app support with app switcher, per-app branding, and filtered queries
Apps share the same backend, API keys, and publishing flow but each gets its own
branding (name, colors, icon, app URL), knowledge files (brand identity, product
info, platform guidelines), and campaigns. The pipeline dynamically writes
_knowledge/ files and copies app assets before each run.

- Add App model with slug, colors, appUrl, and knowledge markdown fields
- Add appId FK to Campaign, seed honeyDue as first app with existing knowledge
- App switcher dropdown in sidebar with icon previews
- Filter campaigns, stats, and assets by active app (cookie-based)
- De-hardcode lib/claude.ts: AppConfig interface, templated prompts, dynamic
  _knowledge/ and Remotion asset copying
- App management pages (list, create, edit) with icon upload and color pickers
- Asset library sort options (newest, oldest, name, platform, type)
- Asset cards show creation date
- Remotion HoneyDueAd accepts colors/appName props

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 22:21:45 -05:00

333 lines
7.8 KiB
TypeScript

import {
AbsoluteFill,
Img,
interpolate,
spring,
useCurrentFrame,
useVideoConfig,
staticFile,
} from "remotion";
export interface AdColors {
primary?: string;
accent?: string;
dark?: string;
light?: string;
white?: string;
red?: string;
}
export interface HoneyDueAdProps {
platform: "instagram" | "tiktok";
hookText: string;
bodyText: string;
ctaText: string;
proofText: string;
screenshotSrc: string;
colors?: AdColors;
appName?: string;
}
const DEFAULT_COLORS = {
primary: "#0079FF",
accent: "#FF9400",
dark: "#1a1a2e",
light: "#f8f6f2",
white: "#ffffff",
red: "#FF3B30",
};
export const HoneyDueAd: React.FC<HoneyDueAdProps> = ({
platform,
hookText,
bodyText,
ctaText,
proofText,
screenshotSrc,
colors,
appName = "honeyDue",
}) => {
const COLORS = { ...DEFAULT_COLORS, ...colors };
const frame = useCurrentFrame();
const { fps, durationInFrames, width, height } = useVideoConfig();
const isPolished = platform === "instagram";
const bg = isPolished ? COLORS.dark : "#0d0d0d";
// Scene boundaries
const hookEnd = Math.floor(durationInFrames * 0.22);
const phoneStart = hookEnd;
const phoneEnd = Math.floor(durationInFrames * 0.6);
const proofStart = phoneEnd;
const proofEnd = Math.floor(durationInFrames * 0.78);
const ctaStart = proofEnd;
// === HOOK SCENE ===
const hookOpacity = interpolate(
frame,
[0, 10, hookEnd - 8, hookEnd],
[0, 1, 1, 0],
{ extrapolateRight: "clamp" }
);
const hookY = interpolate(frame, [0, 15], [40, 0], {
extrapolateRight: "clamp",
});
// === PHONE SCENE ===
const phoneScale = spring({
frame: Math.max(0, frame - phoneStart),
fps,
config: { damping: 14, stiffness: 80 },
});
const phoneOpacity = interpolate(
frame,
[phoneStart, phoneStart + 10, phoneEnd - 8, phoneEnd],
[0, 1, 1, 0],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
);
const bodyTextOpacity = interpolate(
frame,
[phoneStart + 20, phoneStart + 35],
[0, 1],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
);
// === PROOF SCENE ===
const proofOpacity = interpolate(
frame,
[proofStart, proofStart + 12, proofEnd - 8, proofEnd],
[0, 1, 1, 0],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
);
const proofScale = spring({
frame: Math.max(0, frame - proofStart),
fps,
config: { damping: 12 },
});
// === CTA SCENE ===
const ctaOpacity = interpolate(frame, [ctaStart, ctaStart + 12], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
const ctaScale = spring({
frame: Math.max(0, frame - ctaStart),
fps,
config: { damping: 10, stiffness: 100 },
});
// Pulse the CTA button
const ctaPulse =
frame > ctaStart + 20
? 1 + 0.03 * Math.sin((frame - ctaStart - 20) * 0.15)
: 1;
const phoneWidth = width * 0.55;
const phoneHeight = phoneWidth * 2.05;
return (
<AbsoluteFill
style={{
backgroundColor: bg,
fontFamily:
'-apple-system, BlinkMacSystemFont, "SF Pro Display", "Inter", sans-serif',
}}
>
{/* Subtle gradient overlay */}
<div
style={{
position: "absolute",
inset: 0,
background: `radial-gradient(ellipse at 50% 30%, ${COLORS.primary}15 0%, transparent 60%)`,
}}
/>
{/* === HOOK === */}
<div
style={{
position: "absolute",
top: "38%",
left: "50%",
transform: `translate(-50%, -50%) translateY(${hookY}px)`,
opacity: hookOpacity,
width: "85%",
textAlign: "center",
}}
>
<div
style={{
fontSize: 68,
fontWeight: 800,
color: COLORS.white,
lineHeight: 1.15,
letterSpacing: -1,
}}
>
{hookText}
</div>
</div>
{/* === PHONE + BODY TEXT === */}
<div
style={{
position: "absolute",
top: "8%",
left: "50%",
transform: `translateX(-50%) scale(${phoneScale})`,
opacity: phoneOpacity,
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 30,
}}
>
{/* Body text above phone */}
<div
style={{
opacity: bodyTextOpacity,
fontSize: 36,
fontWeight: 600,
color: COLORS.white,
textAlign: "center",
maxWidth: width * 0.8,
lineHeight: 1.3,
}}
>
{bodyText}
</div>
{/* Phone mockup — real phone.png frame over screenshot */}
<div
style={{
width: phoneWidth,
height: phoneHeight,
position: "relative",
filter: "drop-shadow(0 30px 60px rgba(0,0,0,0.5))",
}}
>
{/* Screenshot behind the frame — clipped to screen area */}
<div
style={{
position: "absolute",
top: "3.2%",
left: "4.2%",
width: "91.6%",
height: "93.6%",
borderRadius: phoneWidth * 0.065,
overflow: "hidden",
}}
>
<Img
src={screenshotSrc}
style={{
width: "100%",
height: "100%",
objectFit: "cover",
objectPosition: "top center",
}}
/>
</div>
{/* Phone frame on top */}
<Img
src={staticFile("phone.png")}
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
height: "100%",
objectFit: "contain",
}}
/>
</div>
</div>
{/* === PROOF === */}
<div
style={{
position: "absolute",
top: "42%",
left: "50%",
transform: `translate(-50%, -50%) scale(${proofScale})`,
opacity: proofOpacity,
textAlign: "center",
}}
>
<div
style={{
fontSize: 44,
fontWeight: 700,
color: COLORS.accent,
marginBottom: 16,
}}
>
{proofText}
</div>
<div
style={{
fontSize: 28,
fontWeight: 500,
color: "rgba(255,255,255,0.7)",
}}
>
Join thousands of homeowners
</div>
</div>
{/* === CTA === */}
<div
style={{
position: "absolute",
inset: 0,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
opacity: ctaOpacity,
transform: `scale(${ctaScale})`,
gap: 40,
}}
>
{/* Brand name header */}
<div
style={{
fontSize: 56,
fontWeight: 800,
color: COLORS.white,
letterSpacing: -1,
textAlign: "center",
}}
>
{appName}
</div>
{/* Icon — 50% of canvas width, centered */}
<Img
src={staticFile("icon.png")}
style={{
width: width * 0.5,
height: "auto",
borderRadius: 32,
}}
/>
{/* CTA button */}
<div
style={{
backgroundColor: COLORS.primary,
color: COLORS.white,
fontSize: 36,
fontWeight: 700,
padding: "24px 72px",
borderRadius: 20,
textAlign: "center",
boxShadow: `0 8px 32px ${COLORS.primary}80`,
transform: `scale(${ctaPulse})`,
}}
>
{ctaText}
</div>
</div>
</AbsoluteFill>
);
};