feat: complete marketing command center with pipeline, UI, and asset generation
- Dashboard with campaign management, asset gallery, and publishing queue - 7-agent pipeline: trend scout, research, scripts, ad creative, video, copy, distribution - Campaign form with screenshot upload, goal picker, platform selection - Campaign detail view with Details/Pipeline/Assets/Chat tabs - Two-set image generation: Gemini AI (NanoBanana MCP) + Canvas Design posters - Remotion video rendering with phone.png frame and real screenshot alignment - honeyDue branding: blue #0079FF, orange #FF9400, Inter font, warm off-white - Asset cards with source badges (Gemini/Canvas/Remotion/Playwright) - Markdown/JSON render endpoint for viewing pipeline outputs as HTML - Settings page with Tavily, Gemini, Postiz, Nextdoor integration management - Claude Chat for campaign feedback loop with streaming SSE - Postiz publishing modal with scheduling - Auth with NextAuth credentials + JWT sessions - SQLite via Prisma with better-sqlite3 adapter Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,175 @@
|
||||
import {
|
||||
AbsoluteFill,
|
||||
interpolate,
|
||||
spring,
|
||||
useCurrentFrame,
|
||||
useVideoConfig,
|
||||
} from "remotion";
|
||||
|
||||
interface AdProps {
|
||||
style: "polished" | "authentic" | "local";
|
||||
hookText: string;
|
||||
bodyText: string;
|
||||
ctaText: string;
|
||||
proofText: string;
|
||||
}
|
||||
|
||||
const STYLE_CONFIG = {
|
||||
polished: {
|
||||
bg: "#0f0f0f",
|
||||
text: "#ffffff",
|
||||
accent: "#6366f1",
|
||||
fontFamily: "Inter, sans-serif",
|
||||
},
|
||||
authentic: {
|
||||
bg: "#1a1a2e",
|
||||
text: "#eee",
|
||||
accent: "#e94560",
|
||||
fontFamily: "system-ui, sans-serif",
|
||||
},
|
||||
local: {
|
||||
bg: "#fef9ef",
|
||||
text: "#2d3436",
|
||||
accent: "#00b894",
|
||||
fontFamily: "Georgia, serif",
|
||||
},
|
||||
};
|
||||
|
||||
export const AdComposition: React.FC<AdProps> = ({
|
||||
style,
|
||||
hookText,
|
||||
bodyText,
|
||||
ctaText,
|
||||
proofText,
|
||||
}) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps, durationInFrames } = useVideoConfig();
|
||||
const config = STYLE_CONFIG[style];
|
||||
|
||||
// Scene timing (in frames)
|
||||
const hookEnd = Math.floor(durationInFrames * 0.2);
|
||||
const bodyStart = hookEnd;
|
||||
const bodyEnd = Math.floor(durationInFrames * 0.6);
|
||||
const proofStart = bodyEnd;
|
||||
const proofEnd = Math.floor(durationInFrames * 0.8);
|
||||
const ctaStart = proofEnd;
|
||||
|
||||
// Animations
|
||||
const hookOpacity = interpolate(frame, [0, 15, hookEnd - 10, hookEnd], [0, 1, 1, 0], {
|
||||
extrapolateRight: "clamp",
|
||||
});
|
||||
const hookScale = spring({ frame, fps, config: { damping: 12 } });
|
||||
|
||||
const bodyOpacity = interpolate(
|
||||
frame,
|
||||
[bodyStart, bodyStart + 15, bodyEnd - 10, bodyEnd],
|
||||
[0, 1, 1, 0],
|
||||
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
|
||||
);
|
||||
|
||||
const proofOpacity = interpolate(
|
||||
frame,
|
||||
[proofStart, proofStart + 15, proofEnd - 10, proofEnd],
|
||||
[0, 1, 1, 0],
|
||||
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
|
||||
);
|
||||
|
||||
const ctaOpacity = interpolate(frame, [ctaStart, ctaStart + 15], [0, 1], {
|
||||
extrapolateLeft: "clamp",
|
||||
extrapolateRight: "clamp",
|
||||
});
|
||||
const ctaScale = spring({
|
||||
frame: Math.max(0, frame - ctaStart),
|
||||
fps,
|
||||
config: { damping: 10, stiffness: 100 },
|
||||
});
|
||||
|
||||
return (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
backgroundColor: config.bg,
|
||||
fontFamily: config.fontFamily,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: 80,
|
||||
}}
|
||||
>
|
||||
{/* Hook */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "50%",
|
||||
left: "50%",
|
||||
transform: `translate(-50%, -50%) scale(${hookScale})`,
|
||||
opacity: hookOpacity,
|
||||
fontSize: 72,
|
||||
fontWeight: 800,
|
||||
color: config.text,
|
||||
textAlign: "center",
|
||||
lineHeight: 1.2,
|
||||
maxWidth: "80%",
|
||||
}}
|
||||
>
|
||||
{hookText}
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "50%",
|
||||
left: "50%",
|
||||
transform: "translate(-50%, -50%)",
|
||||
opacity: bodyOpacity,
|
||||
fontSize: 48,
|
||||
fontWeight: 600,
|
||||
color: config.text,
|
||||
textAlign: "center",
|
||||
lineHeight: 1.3,
|
||||
maxWidth: "80%",
|
||||
}}
|
||||
>
|
||||
{bodyText}
|
||||
</div>
|
||||
|
||||
{/* Proof */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "50%",
|
||||
left: "50%",
|
||||
transform: "translate(-50%, -50%)",
|
||||
opacity: proofOpacity,
|
||||
fontSize: 36,
|
||||
fontWeight: 500,
|
||||
color: config.accent,
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
{proofText}
|
||||
</div>
|
||||
|
||||
{/* CTA */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: "15%",
|
||||
left: "50%",
|
||||
transform: `translateX(-50%) scale(${ctaScale})`,
|
||||
opacity: ctaOpacity,
|
||||
backgroundColor: config.accent,
|
||||
color: "#fff",
|
||||
fontSize: 32,
|
||||
fontWeight: 700,
|
||||
padding: "20px 60px",
|
||||
borderRadius: 16,
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
{ctaText}
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,318 @@
|
||||
import {
|
||||
AbsoluteFill,
|
||||
Img,
|
||||
interpolate,
|
||||
spring,
|
||||
useCurrentFrame,
|
||||
useVideoConfig,
|
||||
staticFile,
|
||||
} from "remotion";
|
||||
|
||||
export interface HoneyDueAdProps {
|
||||
platform: "instagram" | "tiktok";
|
||||
hookText: string;
|
||||
bodyText: string;
|
||||
ctaText: string;
|
||||
proofText: string;
|
||||
screenshotSrc: string;
|
||||
}
|
||||
|
||||
const 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,
|
||||
}) => {
|
||||
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",
|
||||
}}
|
||||
>
|
||||
honeyDue
|
||||
</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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,146 @@
|
||||
import { Composition, staticFile } from "remotion";
|
||||
import { HoneyDueAd } from "./HoneyDueAd";
|
||||
|
||||
const SCREENSHOT = staticFile("tasks_overdue.png");
|
||||
|
||||
// 15s @ 30fps for Instagram, 12s for TikTok
|
||||
const IG_FRAMES = 450;
|
||||
const TT_FRAMES = 360;
|
||||
|
||||
export const RemotionRoot: React.FC = () => {
|
||||
return (
|
||||
<>
|
||||
{/* === GEMINI AD VIDEOS === */}
|
||||
<Composition
|
||||
id="Gemini-IG-Feed-Cost"
|
||||
component={HoneyDueAd}
|
||||
durationInFrames={IG_FRAMES}
|
||||
fps={30}
|
||||
width={1080}
|
||||
height={1920}
|
||||
defaultProps={{
|
||||
platform: "instagram" as const,
|
||||
hookText: "A $10 filter.\nA $4,200 repair.",
|
||||
bodyText: "honeyDue tracks every task so you never skip the small stuff.",
|
||||
ctaText: "Download Free",
|
||||
proofText: "Trusted by thousands of homeowners",
|
||||
screenshotSrc: SCREENSHOT,
|
||||
}}
|
||||
/>
|
||||
<Composition
|
||||
id="Gemini-IG-Stories-FirstTimer"
|
||||
component={HoneyDueAd}
|
||||
durationInFrames={IG_FRAMES}
|
||||
fps={30}
|
||||
width={1080}
|
||||
height={1920}
|
||||
defaultProps={{
|
||||
platform: "instagram" as const,
|
||||
hookText: "Just bought my first house.\nNobody told me about all this.",
|
||||
bodyText: "This app tells you what to fix and when.",
|
||||
ctaText: "Try honeyDue",
|
||||
proofText: "First-time homeowners love this",
|
||||
screenshotSrc: SCREENSHOT,
|
||||
}}
|
||||
/>
|
||||
<Composition
|
||||
id="Gemini-TT-SilentTodo"
|
||||
component={HoneyDueAd}
|
||||
durationInFrames={TT_FRAMES}
|
||||
fps={30}
|
||||
width={1080}
|
||||
height={1920}
|
||||
defaultProps={{
|
||||
platform: "tiktok" as const,
|
||||
hookText: "Your house has a\nhidden to-do list.",
|
||||
bodyText: "HVAC filters. Gutters. Water heater. honeyDue sees it all.",
|
||||
ctaText: "Get Started Free",
|
||||
proofText: "Never miss maintenance again",
|
||||
screenshotSrc: SCREENSHOT,
|
||||
}}
|
||||
/>
|
||||
<Composition
|
||||
id="Gemini-TT-Forgetter"
|
||||
component={HoneyDueAd}
|
||||
durationInFrames={TT_FRAMES}
|
||||
fps={30}
|
||||
width={1080}
|
||||
height={1920}
|
||||
defaultProps={{
|
||||
platform: "tiktok" as const,
|
||||
hookText: "I forgot for 2 years.",
|
||||
bodyText: "honeyDue would have reminded me 8 times by now.",
|
||||
ctaText: "Download Free",
|
||||
proofText: "Your home maintenance safety net",
|
||||
screenshotSrc: SCREENSHOT,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* === CANVAS POSTER VIDEOS === */}
|
||||
<Composition
|
||||
id="Poster-IG-Feed-Cost"
|
||||
component={HoneyDueAd}
|
||||
durationInFrames={IG_FRAMES}
|
||||
fps={30}
|
||||
width={1080}
|
||||
height={1920}
|
||||
defaultProps={{
|
||||
platform: "instagram" as const,
|
||||
hookText: "A $10 filter.\nA $4,200 repair.",
|
||||
bodyText: "The difference between a reminder and a disaster.",
|
||||
ctaText: "Download Free",
|
||||
proofText: "Join thousands of organized homeowners",
|
||||
screenshotSrc: SCREENSHOT,
|
||||
}}
|
||||
/>
|
||||
<Composition
|
||||
id="Poster-IG-Stories-FirstHouse"
|
||||
component={HoneyDueAd}
|
||||
durationInFrames={IG_FRAMES}
|
||||
fps={30}
|
||||
width={1080}
|
||||
height={1920}
|
||||
defaultProps={{
|
||||
platform: "instagram" as const,
|
||||
hookText: "Just bought my first house.",
|
||||
bodyText: "100+ maintenance tasks. One app to track them all.",
|
||||
ctaText: "Try honeyDue",
|
||||
proofText: "Built for first-time homeowners",
|
||||
screenshotSrc: SCREENSHOT,
|
||||
}}
|
||||
/>
|
||||
<Composition
|
||||
id="Poster-TT-HiddenTodo"
|
||||
component={HoneyDueAd}
|
||||
durationInFrames={TT_FRAMES}
|
||||
fps={30}
|
||||
width={1080}
|
||||
height={1920}
|
||||
defaultProps={{
|
||||
platform: "tiktok" as const,
|
||||
hookText: "Your house has a\nhidden to-do list.",
|
||||
bodyText: "Filters. Gutters. Drains. Vents. honeyDue tracks all of it.",
|
||||
ctaText: "Get Started Free",
|
||||
proofText: "See what you've been missing",
|
||||
screenshotSrc: SCREENSHOT,
|
||||
}}
|
||||
/>
|
||||
<Composition
|
||||
id="Poster-TT-HVAC2Years"
|
||||
component={HoneyDueAd}
|
||||
durationInFrames={TT_FRAMES}
|
||||
fps={30}
|
||||
width={1080}
|
||||
height={1920}
|
||||
defaultProps={{
|
||||
platform: "tiktok" as const,
|
||||
hookText: "I forgot my HVAC filter\nfor 2 years.",
|
||||
bodyText: "That's 8 missed reminders honeyDue would have sent.",
|
||||
ctaText: "Download Free",
|
||||
proofText: "Never forget again",
|
||||
screenshotSrc: SCREENSHOT,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
import { registerRoot } from "remotion";
|
||||
import { RemotionRoot } from "./Root";
|
||||
|
||||
registerRoot(RemotionRoot);
|
||||
Reference in New Issue
Block a user