feat: add asset preferences, video research, and Remotion ad assets

- Add thumbs-down feedback modal and preference API endpoint
- Add AI UGC video platforms research doc
- Add ReflectAd Remotion composition with public flow assets
- Add gemini-ad-designer and poster-ad-designer pipeline skills
- Add research_reflect_v1.1 pipeline script

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-05-03 20:28:07 -05:00
parent b318798ca7
commit 807dfc539b
40 changed files with 3089 additions and 232 deletions
+41 -1
View File
@@ -8,7 +8,47 @@
"WebSearch",
"WebFetch(domain:github.com)",
"WebFetch(domain:developer.nextdoor.com)",
"WebFetch(domain:docs.postiz.com)"
"WebFetch(domain:docs.postiz.com)",
"Bash(npm install:*)",
"Bash(npx next:*)",
"Bash(curl -s http://localhost:3000/api/auth/session)",
"Bash(curl -s http://localhost:3000/login)",
"Bash(curl -s http://localhost:3000/ -L)",
"Bash(curl -s http://localhost:3000/api/campaigns)",
"Bash(curl -s -o /dev/null -w \"Status: %{http_code}\" http://localhost:3000/)",
"Bash(curl -s -o /dev/null -w \"%{http_code}\" -b /tmp/cookies3.txt \"http://localhost:3000/\")",
"Bash(curl -s -o /dev/null -w \"%{http_code}\" -b /tmp/cookies3.txt \"http://localhost:3000/campaigns\")",
"Bash(curl -s -o /dev/null -w \"%{http_code}\" -b /tmp/cookies3.txt \"http://localhost:3000/campaigns/new\")",
"Bash(curl -s -o /dev/null -w \"%{http_code}\" -b /tmp/cookies3.txt \"http://localhost:3000/assets\")",
"Bash(curl -s -o /dev/null -w \"%{http_code}\" -b /tmp/cookies3.txt \"http://localhost:3000/trends\")",
"WebFetch(domain:www.npmjs.com)",
"WebFetch(domain:raw.githubusercontent.com)",
"Bash(curl:*)",
"Bash(sqlite3:*)",
"Bash(ls:*)",
"Bash(OUTDIR=\"../outputs/task_management_feature_launch_20260323/video\" __NEW_LINE_74c5312ec027b42b__ npx remotion render src/index.ts Gemini-IG-Feed-Cost --output \"$OUTDIR/video_gemini_ig_feed_cost_1080x1920.mp4\")",
"Bash(npx remotion:*)",
"Bash(npx tsx -e \":*)",
"Bash(OUTDIR=\"../outputs/task_management_feature_launch_20260323/video\")",
"Bash(npx tsx:*)",
"Bash(open:*)",
"Bash(git add:*)",
"Bash(git commit:*)",
"Bash(git checkout:*)",
"Bash(npm run:*)",
"Bash(mkdir -p /Users/treyt/Desktop/code/claude_marketing/pipeline/apps/reflect/screenshots)",
"Bash(cp /Users/treyt/Downloads/flow_03_q1.png /Users/treyt/Downloads/flow_05_q3_scrolled.png /Users/treyt/Downloads/flow_06_q4.png /Users/treyt/Downloads/flow_07_detail_after.png /Users/treyt/Downloads/flow_q2_typing.png /Users/treyt/Desktop/code/claude_marketing/pipeline/apps/reflect/screenshots/)",
"Bash(grep -v \"^$\")",
"Bash(find /Users/treyt/Desktop/code/claude_marketing/pipeline/outputs/reflect_v1.1_—_guided_reflection_ _ai_reports_20260325/ -type f \\\\\\(-name *.png -o -name *.mp4 \\\\\\))",
"Read(//tmp/**)",
"Bash(ssh unraid:*)",
"Bash(scp:*)",
"Bash(rsync:*)",
"Bash(openssl rand:*)",
"Bash(security find-generic-password:*)",
"Bash(claude auth:*)",
"Bash(claude setup-token:*)",
"Bash(dig marketing.88oakapps.com +short)"
]
}
}
+762
View File
@@ -0,0 +1,762 @@
# AI UGC Video Generation Platforms Research 2025-2026
## Realistic "Person Using Phone" Lifestyle Video Analysis
**Research Date**: March 2026
**Focus**: Platforms for realistic video clips of people naturally interacting with phones/tablets (NOT talking-head testimonials)
---
## EXECUTIVE SUMMARY
For your specific use case—realistic lifestyle videos of people naturally using apps on phones (checking mood apps, couples looking at screens, tapping before bed, showing phones to family)—**the landscape is fragmented**:
- **Text-to-video models** (Runway, Kling, Google Veo, Sora) can generate general "person using phone" scenarios from text prompts but require careful prompt engineering
- **Avatar platforms** (HeyGen, Synthesia, D-ID) excel at talking-head presenters, NOT lifestyle interaction videos
- **Specialized UGC platforms** (MakeUGC, Creatify, Arcads) can make realistic people holding products but have limited "phone interaction" capabilities
- **Phone mockup tools** (Mockey, Rotato, FlexClip) handle app screen display but lack realistic human actors
**Best Match for Your Use Case**: A combination approach using Runway Gen-4.5 or Google Veo 3.1 for lifestyle generation + a phone mockup tool for screen display integration.
---
## DETAILED PLATFORM ANALYSIS
### 1. RUNWAY GEN-4 / GEN-4.5
**Phone Interaction Capability**: ⭐⭐⭐⭐⭐ (Excellent)
**API Access**: ⭐⭐⭐⭐⭐ (Yes, fully supported)
**Diverse Cast**: ⭐⭐⭐⭐ (Via detailed prompts)
**Overall Fit**: ⭐⭐⭐⭐⭐ (BEST OPTION for general "person using phone" videos)
**What It Does Well**:
- **Character & Scene Consistency**: Gen-4 maintains consistent characters across multiple shots
- **Physics Simulation**: Realistic weight, momentum, motion—crucial for natural phone interactions
- **Camera Control**: Advanced camera movements (zoom, arc, trucking)
- **Gen-4.5 Performance**: Released December 2025, now #1 on Artificial Analysis Text-to-Video benchmark with 1,247 Elo points
**Can It Do Your Use Cases?**
- ✅ Person checking phone at breakfast and smiling
- ✅ Couple looking at phone together on couch (with proper prompting)
- ✅ Someone tapping phone quickly before bed
- ✅ Parent showing teen something on phone
**API Details**:
- Native API with modern documentation
- Generation speed: 5-8 second videos in ~60 seconds (5x faster than Gen-4)
- Supports text-to-video and image-to-video
- Available via Runway's official API
**Pricing**:
- No official per-video pricing published
- Credit-based system through third-party APIs (CometAPI, AIML API, etc.)
- Estimated: $0.25-$0.50 per 8-second video through aggregator APIs
- Enterprise/volume discounts available
**Node.js/TypeScript Integration**:
- Native Node.js SDK available: `npm install @runwayml/sdk`
- REST API with standard authentication
- Can be integrated into automated pipelines
**Quality**: Extremely high—bleeding-edge photorealism, best for lifestyle sequences
---
### 2. GOOGLE VEO 3 / VEO 3.1
**Phone Interaction Capability**: ⭐⭐⭐⭐⭐ (Excellent)
**API Access**: ⭐⭐⭐⭐ (Yes, via Gemini API)
**Diverse Cast**: ⭐⭐⭐⭐ (Better with reference images)
**Overall Fit**: ⭐⭐⭐⭐⭐ (EXCELLENT, comparable to Runway)
**What It Does Well**:
- **Native Audio Generation**: Generates synchronized audio alongside video
- **Human Face Generation**: Veo 3.1 can generate realistic human faces when provided references (advantage over Sora)
- **Image-to-Video**: Enhanced capabilities for maintaining character consistency
- **October 2025 Release**: Latest production model with high-fidelity outputs
**Can It Do Your Use Cases?**
- ✅ All four use cases similar to Runway, with added audio sync
- ✅ Better for complex scenes with multiple people (family showing scenarios)
**API Details**:
- Available via Gemini API (Google's unified API)
- Pricing available on Vertex AI platform
- Can integrate with Google Cloud Platform workflows
**Pricing**:
- Vertex AI: $0.40 per second (standard), $0.15 per second (faster model)
- For 30-second video: ~$12 (standard) or ~$4.50 (faster)
- Gemini API: Different pricing tier (check latest)
- Free preview tier available for experimentation
**Node.js/TypeScript Integration**:
- Google Cloud Node.js client libraries available
- Standard REST API access
- Integrates with existing GCP infrastructure
**Quality**: Very high, with better audio sync than Runway. Strong for family/couple scenarios.
---
### 3. SORA (OPENAI)
**Phone Interaction Capability**: ⭐⭐⭐⭐ (Very Good)
**API Access**: ⭐⭐ (NOT AVAILABLE - major limitation)
**Diverse Cast**: ⭐⭐⭐ (Possible with prompts)
**Overall Fit**: ⭐⭐ (NOT SUITABLE for automation)
**Status**:
- Sora 2 released September 2025
- **No public API** as of January 2026
- WaveSpeedAI offers unofficial Sora 2 API access (not directly supported by OpenAI)
- January 2026 change: Free users can no longer generate—Plus ($20/mo) and Pro ($200/mo) only
**Capabilities**:
- Can generate professional-quality videos up to 25 seconds with synchronized dialogue
- More "physically accurate and realistic" than earlier models
- Can handle complex human interactions
**Why Not Suitable**:
- No direct API access from OpenAI
- Relies on web app or unofficial third-party APIs
- Can't be directly integrated into automated pipelines
- Subscription-locked (no free tier)
**Recommendation**: Skip for your automation needs.
---
### 4. KLING AI 3.0
**Phone Interaction Capability**: ⭐⭐⭐⭐ (Very Good)
**API Access**: ⭐⭐⭐⭐ (Yes, via multiple providers)
**Diverse Cast**: ⭐⭐⭐⭐ (Strong)
**Overall Fit**: ⭐⭐⭐⭐ (GOOD alternative to Runway/Veo)
**What It Does Well**:
- **Physics Accuracy**: Simulates gravity, balance, inertia for believable movement
- **Face Stability**: Characters remain consistent across frames (February 2026 launch solved this major pain point)
- **Element Library**: Upload reference images to ensure characters stay consistent across shots
- **Audio Sync**: Native audio with video for up to 5 minutes
**Can It Do Your Use Cases?**
- ✅ Person checking phone at breakfast
- ✅ Couple looking at phone together
- ✅ Tapping phone before bed
- ✅ Parent/teen scenarios (with reference images for consistency)
**Kling 3.0 Specifics** (Unified multimodal video engine):
- Cinema-grade visuals
- Physics-accurate motion
- Native audio sync
- Released February 2026
**API Access**:
- Multiple third-party providers: fal.ai, Runware, WaveSpeedAI, PiAPI
- Element Library feature available for character consistency
- Supports text-to-video and image-to-video
**Pricing**:
- Variable by provider, but generally affordable (cheaper than Runway/Veo)
- fal.ai: Pay-per-use model (check current rates)
- Estimated: $0.10-$0.30 per video through aggregators
**Node.js/TypeScript Integration**:
- Available through fal.ai SDK (`npm install @fal-ai/client`)
- REST API through aggregator platforms
- Straightforward integration
**Quality**: Very high, especially after 3.0 launch. Excellent value for cost.
---
### 5. HEYGEN (Avatar-Based)
**Phone Interaction Capability**: ⭐⭐ (Limited)
**API Access**: ⭐⭐⭐⭐⭐ (Excellent)
**Diverse Cast**: ⭐⭐⭐ (100+ avatars available)
**Overall Fit**: ⭐⭐ (NOT IDEAL - focused on talking heads)
**Problem**: HeyGen specializes in **avatar presenters speaking to camera**, NOT lifestyle interactions.
**Latest Features (February 2026)**:
- Avatar IV with motion-captured avatars
- Timing-aware hand gestures
- Micro-expressions (natural blinks, subtle smiles)
- Redesigned homepage
- ChatGPT integration
- Video Agent API (new)
**Avatar IV Performance**:
- Full-body avatars with realistic lip-sync
- Hand gesture timing
- Micro-expressions
- Digital Twin feature (create version of yourself)
**When It Might Work**:
- Could potentially show avatar using phone in script, but very artificial
- Better for product explainers where avatar talks about the app
**API Details**:
- Video Agent API: prompt-to-video workflows
- REST API with Node.js support
- Multiple video generation, translation, LiveAvatar streaming endpoints
**Pricing**:
- API starts at $99/month
- Credit-based: 1 credit = 1 minute avatar video (standard)
- Avatar IV uses 1 credit per 10 seconds (~6 credits/minute)
- Video Agent: ~2 credits per minute
- Translation: 3 credits per minute of source video
- Pro tier: $0.99/credit, Scale tier: $0.50/credit
**Recommendation**: Use only if you want talking-head explainer videos about the app, NOT lifestyle interaction videos.
---
### 6. SYNTHESIA (Avatar-Based)
**Phone Interaction Capability**: ⭐⭐ (Limited)
**API Access**: ⭐⭐⭐ (Yes, Creator plan+)
**Diverse Cast**: ⭐⭐⭐⭐ (160+ avatars, real actors)
**Overall Fit**: ⭐⭐ (NOT IDEAL - talking head focused)
**What It Does**:
- Express-2 engine: full-body avatars with gestures, pointing, waving
- All avatars based on real actors (paid consent model)
- Facial micro-expressions matching emotional tone
- 160+ languages supported
**API Access**:
- Creator plan: $64/month (billed yearly, $18/month equivalent)
- Includes API access with rate limits
- Webhook integration for automated workflows
**Pricing**:
- **Free**: 36 minutes/year
- **Starter**: $18/month (annual) = ~0.33 credits/minute
- **Creator**: $64/month (annual) - includes API
- **Enterprise**: Custom pricing
- Credit system: 1 minute = 1 credit
**Node.js/TypeScript Integration**:
- REST API with Node.js support
- Webhook integration for async workflows
- Standard authentication
**Why Not Ideal**:
- Designed for presenters/training videos, not lifestyle interaction
- Avatars still feel "presenter-like" rather than casual interaction
- Better for corporate than authentic UGC
**Recommendation**: Skip for this use case.
---
### 7. PIKA LABS 2.2
**Phone Interaction Capability**: ⭐⭐⭐⭐ (Good)
**API Access**: ⭐⭐⭐⭐ (Yes, via fal.ai)
**Diverse Cast**: ⭐⭐⭐ (Text-prompt based)
**Overall Fit**: ⭐⭐⭐ (Decent alternative)
**What It Does**:
- Text-to-video generation (Pika 2.2)
- Image-to-video (Pikascenes 2.2)
- Pikaframes 2.2: upload 5 keyframes, AI interpolates smooth motion
- Pikaformance: hyper-real expressions synced to audio (near real-time)
**API Access**:
- December 2025 announcement: Pika 2.2 now exposed via fal.ai
- API key through fal dashboard
- Text-to-video and image-to-video endpoints
**Use Cases**:
- ✅ Can generate "person using phone" via text prompts
- ✅ Pikaframes could help create consistent character across shots
- Less ideal than Runway/Veo for this specific use case
**Pricing**: Not clearly published; likely variable through fal.ai aggregator
**Quality**: Good, but less consistent character realism than Runway Gen-4.5
---
### 8. D-ID (Real-Time Avatar Video)
**Phone Interaction Capability**: ⭐⭐ (Limited)
**API Access**: ⭐⭐⭐⭐⭐ (Excellent - core product)
**Diverse Cast**: ⭐⭐ (Limited to avatar variations)
**Overall Fit**: ⭐⭐ (NOT suitable)
**New V4 Expressive Visual Agents (March 2026)**:
- Ultra-high-fidelity digital humans
- Real-time LLM-connected conversations
- Sub-0.5-second latency
- Up to 4K resolution
- Sentiment-aligned facial expressions
- Trained on real actor performances
**Best Use Case**:
- Customer support chatbots with realistic avatars
- Interactive training experiences
- NOT lifestyle video content
**Why Not Suitable**:
- Designed for talking-head interactions
- Real-time conversational focus
- Not for pre-recorded lifestyle scenarios
**Recommendation**: Skip for your use case.
---
### 9. TAVUS (Real-Time AI Humans)
**Phone Interaction Capability**: ⭐⭐⭐ (Moderate)
**API Access**: ⭐⭐⭐⭐ (Yes, with real-time capability)
**Diverse Cast**: ⭐⭐ (Requires custom avatar creation)
**Overall Fit**: ⭐⭐⭐ (Possible but expensive)
**What It Does**:
- Creates hyperrealistic AI replicas from 2-minute video sample
- Phoenix-4 model: first real-time model with emotional states + active listening
- Emotional states, facial expressions, head movements as unified system
- Millisecond-level latency
**Pricing**:
- Free plan: 25 min/month conversational, 5 min/month generation ($0 cost)
- Starter: ~$39-59/month
- Growth: 1,250 min/month conversational
- Overage: $0.37/min conversations, $0.32/min overage (Growth tier)
- Enterprise: Custom (resource-intensive, expensive)
**Use Cases**:
- ✅ Could generate video of person using phone if you create custom avatar
- ✅ Real-time interaction capability (not needed for your use case)
- ❌ Expensive for batch video generation
**Why Less Ideal**:
- Designed for real-time conversational avatars
- Creating custom avatars is expensive
- Better for interactive experiences than pre-recorded lifestyle videos
---
### 10. MAKEUGC (Specialized UGC Platform)
**Phone Interaction Capability**: ⭐⭐⭐ (Moderate)
**API Access**: ⭐⭐⭐⭐ (Yes, Platform API)
**Diverse Cast**: ⭐⭐⭐⭐ (100+ licensed AI avatars)
**Overall Fit**: ⭐⭐⭐ (GOOD for avatar-based content)
**What It Does**:
- 100+ unique licensed AI avatars
- Avatar can realistically hold/showcase/consume products
- Testimonial and lifestyle shot generation
- Text script → AI avatar video transformation
**Key Feature**:
- Proprietary hand-holding technology: avatars can realistically hold products
- Could potentially adapt for "holding phone" scenarios
**API Details**:
- Platform API for programmatic video generation
- Authentication via API key
- Specify avatar, voice, script
- Processing time: 2-10 minutes for talking head videos
- 29 languages supported
**Pricing**:
- Under $10 per video (mentioned as cost comparison to $100-200 traditional UGC)
- Subscription required (exact tiers unclear from search)
**Node.js/TypeScript Integration**:
- REST API should be straightforward to integrate
- Check documentation at app.makeugc.ai/api/platform/documentation
**Use Cases**:
- ✅ Person holding phone showing it to others (good fit)
- ✅ Product holding = could adapt for phone
- ❌ More formal/structured than casual lifestyle
- ❌ Feels more like testimonial than authentic interaction
**Quality**: Good for product-focused UGC, less natural for casual lifestyle scenarios
---
### 11. CREATIFY
**Phone Interaction Capability**: ⭐⭐⭐ (Moderate)
**API Access**: ⭐⭐⭐⭐ (Yes, Business plan+)
**Diverse Cast**: ⭐⭐⭐⭐ (1500+ hyper-realistic UGC avatars)
**Overall Fit**: ⭐⭐⭐ (GOOD for avatar-based UGC)
**What It Does**:
- 1500+ hyper-realistic UGC avatars
- Aurora avatar model (state-of-the-art)
- Text-to-video, URL-to-video, image-to-video
- Custom templates, product videos, AI Shorts
**API Capabilities**:
- URL-to-video conversion
- AI avatar lip-sync
- Aurora image-to-video
- Custom templates
- Text-to-Speech
**Pricing**:
- Free: 10 credits (≈2 videos)
- Creator: $39/month (annual) or $33/month annual = 50 credits/month
- Business: $99/month = 250 credits/month + API access + priority support
- Enterprise: Custom with volume discounts
- Credit cost: 2-20 per video depending on quality
**Estimated Cost**:
- At Business tier: $99/250 credits = ~$0.40/credit
- 10-credit video = ~$4, 20-credit video = ~$8
**Node.js/TypeScript Integration**:
- REST API on Business plan
- Check docs.creatify.ai for API details
**Use Cases**:
- ✅ Person holding/showing phone
- ✅ Family/couple scenarios with different avatars
- ✅ Good diversity in avatar library
- ❌ May feel more "production" than authentic UGC
---
### 12. ARCADS.AI (Specialized UGC)
**Phone Interaction Capability**: ⭐⭐ (Limited)
**API Access**: ⭐⭐⭐⭐ (Yes, Enterprise+)
**Diverse Cast**: ⭐⭐⭐⭐ (300+ actors from video footage)
**Overall Fit**: ⭐⭐⭐ (Possible but not ideal)
**What It Does**:
- 300+ AI "actors" from real video footage (better body language than synthetic)
- TikTok-style UGC video ads
- Avatars can hold products and show apps
- B-rolls, music, captions, transitions auto-added
**Can They Do Phone?**
- ✅ Can make avatar hold phone and show app
- ❌ Struggles with physical products, likely limited for realistic phone interaction
**API Details**:
- Enterprise plans include API access
- Trigger generation from briefs
- Auto-route to cloud storage
**Pricing**:
- Starter: $110/month = 10 videos/month = $11/video
- Creator: $220/month = 20 videos/month = $11/video
- Custom plans for volume + API access
**Why Less Ideal**:
- Platform struggles with physical product interactions
- More TikTok-ad focused than lifestyle
- Enterprise-only API (high minimum commitment)
---
## PHONE MOCKUP / APP SCREEN DISPLAY TOOLS
If you need to show actual phone screens, these complement AI video tools:
### Mockey.ai
- Phone mockup video generator
- Add your design, generate MP4 mockup
- Templates with realistic person holding phone
- Good for app screen display
### Rotato
- 3D device mockups
- Your own app/web designs on device screens
- High-quality visuals
### FlexClip
- Free phone mockup generator
- Display app screenshots on iPhone/Android backgrounds
- AI image tools (object remover, voice generator)
- Integrated with video editor
### Placeit (by Envato)
- App mockup templates
- Animated device displays
- Professional quality
**Strategy**: Use AI video generator for realistic people, combine with mockup tool for accurate phone screen display.
---
## RECOMMENDATION MATRIX
### For Your Specific Use Cases:
**Use Case: "Person checking phone at breakfast and smiling"**
- **Best**: Runway Gen-4.5 with detailed prompt
- **Alternative**: Google Veo 3.1
- **Budget**: Kling AI 3.0
**Use Case: "Couple looking at phone together on couch"**
- **Best**: Runway Gen-4.5 (multi-character consistency)
- **Alternative**: Google Veo 3.1
- **Budget**: Kling AI 3.0
**Use Case: "Someone tapping phone quickly before bed"**
- **Best**: Runway Gen-4.5 (motion capture precision)
- **Alternative**: Kling AI 3.0 (physics simulation)
**Use Case: "Parent showing teen something on phone"**
- **Best**: Runway Gen-4.5 or Google Veo 3.1 (multi-person interaction)
- **Alternative**: MakeUGC or Creatify (controlled avatar setup)
---
## IMPLEMENTATION ARCHITECTURE
### Option A: Text-to-Video Foundation (Recommended)
```typescript
// Runway Gen-4.5 approach
const prompt = `
A woman sits at her kitchen table with breakfast,
holding her phone. She glances at it, reads something
that makes her smile. Natural morning lighting. Shot
from medium distance, gentle camera movement.
`;
// Generate via Runway API
const video = await runwayClient.generateVideo({
prompt,
duration: 10,
quality: 'high'
});
```
**Pros**:
- Single source of truth
- High realism
- Character consistency
- Flexible scenarios
**Cons**:
- Phone screen not visible
- Prompt engineering required
- May need multiple generations for variations
### Option B: Composite Approach
```typescript
// Generate person using phone video
const personVideo = await runwayClient.generateVideo({
prompt: "Woman checking her phone at breakfast, smiling",
duration: 10
});
// Create phone mockup with your actual app UI
const phoneVideo = await mockeyClient.generateMockup({
appScreenshot: moodAppScreenshot,
template: 'hand_holding_phone'
});
// Composite them together (requires video editing)
const final = compositeVideos(personVideo, phoneVideo);
```
**Pros**:
- Shows actual app UI
- Customizable
- Control over phone screen content
**Cons**:
- Requires video compositing
- More complex pipeline
- Phone screen doesn't match hand/phone position perfectly
### Option C: UGC Avatar Platform
```typescript
// Creatify approach - controlled but less flexible
const video = await creatifyClient.generateVideo({
avatarId: 'avatar_diverse_female_30s',
script: 'Let me show you our mood tracking app',
voiceId: 'natural_female_voice',
backgroundTemplate: 'modern_bedroom',
productUrl: 'https://yourapp.com'
});
```
**Pros**:
- Controlled, consistent output
- Diverse avatars available
- Quick generation
**Cons**:
- Less natural/authentic
- Limited "lifestyle" feel
- Feels more like testimonial
---
## FINAL RECOMMENDATION FOR YOUR PIPELINE
### Best Solution: **Runway Gen-4.5 + Optional Compositing**
**Why**:
1. **Highest Quality**: #1 on AI video benchmarks
2. **API First**: Built for automation, excellent Node.js integration
3. **Handles All Use Cases**: Can generate realistic multi-person interactions, natural gestures, emotional micro-expressions
4. **Reasonable Pricing**: ~$0.25-$0.50 per 8-10 second video (through aggregators)
5. **Character Consistency**: Maintains same person across shots and variations
**Integration Path**:
```typescript
import Anthropic from "@anthropic-sdk/sdk";
import Runway from "@runwayml/sdk";
const runway = new Runway({
apiKey: process.env.RUNWAY_API_KEY
});
async function generateMoodAppUGC(scenario: string) {
const prompt = `
Realistic, natural lighting. Shot composition appropriate for the scenario.
${scenario}
Character: diverse, relatable person
Style: authentic UGC, not staged/commercial
Duration: 8-10 seconds
`;
const video = await runway.generateVideo({
prompt,
duration: 10,
aspectRatio: "9:16" // TikTok/Instagram vertical
});
return video;
}
// Generate variations
const scenarios = [
"Woman checking her phone at breakfast, sees notification, smiles",
"Couple sitting on couch, passing phone back and forth, both smiling",
"Teenager in bedroom, taps phone quickly before sleeping",
"Parent showing child phone screen, both looking engaged"
];
for (const scenario of scenarios) {
const video = await generateMoodAppUGC(scenario);
await saveVideo(video);
}
```
**Estimated Pipeline Costs**:
- 4 videos × $0.35 average = $1.40
- 100 videos/month = $35
- 1,000 videos/month = $350 (scale pricing may apply)
### Secondary Option: **Google Veo 3.1**
If you prefer:
- Native audio sync in videos
- More conservative, "safe" generation
- Integrated Google Cloud infrastructure
- Reference image consistency for characters
**Cost**: $0.40/second standard = ~$4 per 10-second video
### Budget Option: **Kling AI 3.0**
If you're price-sensitive:
- ~$0.10-$0.30 per video
- Still excellent quality (especially Kling 3.0)
- Good physics for natural gestures
- Element Library for character consistency
---
## NODE.JS IMPLEMENTATION CHECKLIST
- [ ] Install Runway SDK or use their REST API
- [ ] Set up authentication (API keys in environment)
- [ ] Create prompt templates for each UGC scenario
- [ ] Implement video generation with error handling/retries
- [ ] Set up webhook/polling for async generation
- [ ] Download and organize generated videos
- [ ] (Optional) Integrate video compositing library for phone screen mockups
- [ ] Create variation generator (prompt templates with parameters)
- [ ] Implement quality/consistency checks
- [ ] Log all API calls, costs, and video metadata
---
## PLATFORMS TO AVOID FOR THIS USE CASE
**HeyGen**: Talking-head avatars, not lifestyle
**Synthesia**: Corporate/training videos, not authentic UGC
**D-ID**: Real-time chatbot avatars, not pre-recorded lifestyle
**Tavus**: Expensive for batch generation, conversation-focused
**Sora**: No public API, can't automate
**Pika**: Good but less consistent character than Runway/Veo
---
## KEY METRICS COMPARISON TABLE
| Platform | Phone Interaction | API | Diverse Cast | API Cost/Video | Quality | Ease of Integration |
|----------|-------------------|-----|--------------|-----------------|---------|---------------------|
| **Runway Gen-4.5** | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ | $0.25-$0.50 | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
| **Google Veo 3.1** | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | $0.40/sec | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ |
| **Kling AI 3.0** | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | $0.10-$0.30 | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ |
| **Creatify** | ⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | $0.40-$8.00 | ⭐⭐⭐ | ⭐⭐⭐ |
| **MakeUGC** | ⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | <$10 | ⭐⭐⭐ | ⭐⭐⭐ |
| **Arcads** | ⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | $11 | ⭐⭐⭐ | ⭐⭐⭐ |
| **HeyGen** | ⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | $0.50-$0.99 | ⭐⭐⭐ | ⭐⭐⭐⭐ |
| **Synthesia** | ⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐ | $0.33-1.00 | ⭐⭐⭐ | ⭐⭐⭐ |
---
## SOURCES
### Primary Research Sources
- [Runway Gen-4 Research](https://runwayml.com/research/introducing-runway-gen-4)
- [Runway API Documentation](https://runwayml.com/api)
- [Google Veo 3.1 Announcement](https://developers.googleblog.com/introducing-veo-3-1-and-new-creative-capabilities-in-the-gemini-api/)
- [Google Veo API Docs](https://docs.cloud.google.com/vertex-ai/generative-ai/docs/model-reference/veo-video-generation)
- [Kling AI 3.0 Launch](https://higgsfield.ai/kling-o1-intro)
- [HeyGen API Pricing](https://www.heygen.com/api-pricing)
- [HeyGen February 2026 Release](https://www.heygen.com/blog/heygen-february-2026-release)
- [Synthesia API Docs](https://docs.synthesia.io/reference/introduction)
- [Synthesia Pricing 2026](https://www.synthesia.io/pricing)
- [D-ID V4 Announcement](https://www.d-id.com/news/v4-expressive-visual-agents-real-time-llm-connected-interaction/)
- [MakeUGC Platform API](https://app.makeugc.ai/api/platform/documentation)
- [Creatify API](https://creatify.ai/api)
- [Tavus Pricing](https://www.tavus.io/pricing)
- [Arcads AI Features](https://www.arcads.ai/features/)
- [Pika API via fal.ai](https://blog.fal.ai/pika-api-is-now-powered-by-fal)
- [AI Video Generation APIs 2025](https://www.tavus.io/post/high-quality-ai-video-api)
- [Best AI Video Generators 2026](https://zapier.com/blog/best-ai-video-generator/)
---
## NEXT STEPS
1. **Sign up for Runway API** with test credits
2. **Create prompt templates** for your 4 use cases
3. **Test generation** with various prompts and durations
4. **Measure quality** and iteration requirements
5. **Calculate actual costs** from real API usage
6. **Build Node.js pipeline** with error handling
7. **Implement variation system** (prompt parameters, style options)
8. **Monitor and optimize** prompts based on output quality
---
**Last Updated**: March 2026
**Research Methodology**: Comprehensive web search of 2025-2026 platform releases, API documentation, and pricing structures.
+102
View File
@@ -1 +1,103 @@
@AGENTS.md
# Marketing Command Center
Next.js 16 app that uses Claude Code CLI as a subprocess to run an 8-agent marketing pipeline. Generates ads, videos, copy, and research for mobile apps.
## Tech Stack
- Next.js 16.2.1, React 19, TypeScript 5, TailwindCSS 4, shadcn/ui
- Prisma 7.5 with SQLite (better-sqlite3 adapter)
- NextAuth 5 (credentials provider, `trustHost: true` required)
- Claude Code CLI spawned via `child_process.spawn()` (NOT the Anthropic SDK)
- Playwright for HTML-to-PNG rendering
- Remotion for video generation (in `pipeline/remotion-ad/`)
## How Claude Is Used
The app spawns `claude -p <prompt> --output-format stream-json` as a subprocess. Auth comes from the user's Claude Max subscription. In Docker, auth is via `CLAUDE_CODE_OAUTH_TOKEN` env var (an access token extracted from macOS Keychain `Claude Code-credentials` entry). See `lib/claude.ts` for all spawn logic.
## Local Development
```bash
npm install
npx prisma db push
npx prisma db seed # creates admin user + honeyDue app
npm run dev # http://localhost:3000
```
Default login: `admin@localhost` / `admin123` (set via ADMIN_EMAIL/ADMIN_PASSWORD env vars).
## Unraid Docker Deployment
The app runs on an Unraid server at `marketing.88oakapps.com` behind Nginx Proxy Manager.
### Architecture
- App source: `/mnt/user/appdata/marketing/` (disposable, rsync from dev machine)
- Persistent data: `/mnt/user/downloads/marketing/` (survives app updates)
- `db/` - SQLite database
- `outputs/` - generated campaign assets (ads, videos, copy)
- `knowledge/` - brand identity, platform guidelines
### Unraid docker-compose.yml (lives on Unraid, NOT the repo version)
The repo `docker-compose.yml` includes Postiz services for local dev. The Unraid version is app-only:
```yaml
services:
app:
build: .
ports:
- "3000:3000"
environment:
- NEXTAUTH_URL=http://localhost:3000
- NEXTAUTH_SECRET=${NEXTAUTH_SECRET}
- ADMIN_EMAIL=${ADMIN_EMAIL:-admin@localhost}
- ADMIN_PASSWORD=${ADMIN_PASSWORD:-admin123}
- DATABASE_URL=file:./prisma/data/marketing.db
- PIPELINE_ROOT=/app/pipeline
- CLAUDE_CODE_OAUTH_TOKEN=${CLAUDE_CODE_OAUTH_TOKEN}
volumes:
- /mnt/user/downloads/marketing/db:/app/prisma/data
- /mnt/user/downloads/marketing/outputs:/app/pipeline/outputs
- /mnt/user/downloads/marketing/knowledge:/app/pipeline/knowledge
```
### Deploying Updates to Unraid
```bash
# Sync code (excludes env/compose so Unraid config isn't overwritten)
rsync -avz \
--exclude='node_modules' --exclude='.next' --exclude='prisma/data' \
--exclude='pipeline/outputs' --exclude='pipeline/remotion-ad/.next' \
--exclude='pipeline/remotion-ad/node_modules' \
--exclude='.env' --exclude='.env.local' --exclude='docker-compose.yml' \
--delete \
./ unraid:/mnt/user/appdata/marketing/
# Rebuild and restart
ssh unraid "cd /mnt/user/appdata/marketing && docker compose down && docker compose build app && docker compose up -d"
```
### Claude Auth in Docker
Claude Max uses OAuth, tokens stored in macOS Keychain. For headless Docker:
1. Run `claude setup-token` locally, open the magic link in browser
2. Extract the access token: `security find-generic-password -s "Claude Code-credentials" -a "$(whoami)" -w`
3. The JSON has `claudeAiOauth.accessToken` — use just that value
4. Set `CLAUDE_CODE_OAUTH_TOKEN=<access-token>` in the Unraid `.env`
### Volume Permissions
Host directories must be owned by UID 1000 (node user in container):
```bash
ssh unraid "chown -R 1000:1000 /mnt/user/downloads/marketing/{db,outputs,knowledge}"
```
### Dockerfile Notes
- Base image: `node:20-slim` (NOT alpine — Playwright needs apt/Debian)
- Startup script (`start.sh`) runs `prisma db push` and `prisma db seed` before `node server.js`
- Seed is idempotent (uses upsert), safe to run on every restart
## Key Files
- `lib/claude.ts` - Claude CLI spawn logic, pipeline orchestration, chat sessions
- `lib/auth.ts` - NextAuth config (trustHost: true for reverse proxy)
- `lib/scanner.ts` - scans pipeline output directories for assets
- `prisma/schema.prisma` - 8 models: User, App, Campaign, AgentRun, Asset, ClaudeSession, TrendReport, Setting
- `prisma/seed.ts` - creates admin user + honeyDue app
- `pipeline/CLAUDE.md` - agent system docs (read this for pipeline details)
- `pipeline/skills/` - 8 agent skill definitions
- `pipeline/knowledge/` - brand assets & guidelines
## SSH
Unraid is accessible via `ssh unraid` (configured in ~/.ssh/config on dev machine).
+17 -2
View File
@@ -1,4 +1,4 @@
FROM node:20-alpine AS base
FROM node:20-slim AS base
# Install Claude Code CLI
RUN npm install -g @anthropic-ai/claude-code
@@ -21,15 +21,30 @@ RUN npm run build
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV HOME=/home/node
# Create Claude credentials directory with correct ownership
RUN mkdir -p /home/node/.claude && chown -R node:node /home/node
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
COPY --from=builder /app/prisma ./prisma
COPY --from=builder /app/lib/generated ./lib/generated
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./package.json
COPY --from=builder /app/prisma.config.ts ./prisma.config.ts
# Copy the marketing pipeline
COPY pipeline/ ./pipeline/
# Startup script: ensure DB exists, then start server
RUN printf '#!/bin/sh\nnpx prisma db push 2>&1 || true\nnpx prisma db seed 2>&1 || true\nnode server.js\n' > /app/start.sh && chmod +x /app/start.sh
# Ensure node user owns the app directory
RUN chown -R node:node /app
USER node
EXPOSE 3000
CMD ["node", "server.js"]
CMD ["/app/start.sh"]
+3 -9
View File
@@ -64,7 +64,7 @@ export default function AppsPage() {
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{apps.map((app) => (
<Link key={app.id} href={`/apps/${app.slug}`}>
<Card className="transition-shadow hover:shadow-md">
<Card className="h-full transition-shadow hover:shadow-md">
<CardHeader className="pb-3">
<div className="flex items-center gap-3">
<div
@@ -81,22 +81,16 @@ export default function AppsPage() {
<p className="mb-3 text-sm text-muted-foreground line-clamp-2">
{app.description || "No description"}
</p>
<div className="flex items-center gap-4 text-sm">
<div className="flex items-center gap-1.5">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<div
className="h-3 w-3 rounded"
style={{ backgroundColor: app.primaryColor }}
/>
<span>{app.primaryColor}</span>
</div>
<div className="flex items-center gap-1.5">
<div
className="h-3 w-3 rounded"
style={{ backgroundColor: app.accentColor }}
/>
<span>{app.accentColor}</span>
</div>
<span className="ml-auto text-muted-foreground">
<span className="ml-auto">
{app._count.campaigns} campaign{app._count.campaigns !== 1 ? "s" : ""}
</span>
</div>
+52 -4
View File
@@ -11,7 +11,13 @@ import {
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Plus } from "lucide-react";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Plus, Info } from "lucide-react";
interface Campaign {
id: string;
@@ -30,6 +36,25 @@ const statusColors: Record<string, "default" | "secondary" | "destructive" | "ou
published: "default",
};
const statusTooltips: Record<string, string> = {
draft: "Campaign is configured but hasn't been launched yet. Click to edit and launch.",
running: "The AI pipeline is actively generating assets for this campaign.",
review: "All agents finished. Assets are ready for your review before publishing.",
approved: "Assets have been approved and are ready to publish.",
published: "Campaign content has been pushed to social platforms.",
};
function Tip({ children, content }: { children: React.ReactNode; content: string }) {
return (
<Tooltip>
<TooltipTrigger className="inline-flex items-center">
{children}
</TooltipTrigger>
<TooltipContent>{content}</TooltipContent>
</Tooltip>
);
}
export default function CampaignsPage() {
const [campaigns, setCampaigns] = useState<Campaign[]>([]);
@@ -41,9 +66,15 @@ export default function CampaignsPage() {
}, []);
return (
<TooltipProvider>
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<h1 className="text-3xl font-bold">Campaigns</h1>
<Tip content="Campaigns run the AI pipeline to generate ads, videos, copy, and scripts for your app across platforms.">
<Info className="h-4 w-4 text-muted-foreground hover:text-foreground transition-colors" />
</Tip>
</div>
<Link href="/campaigns/new" className={buttonVariants()}>
<Plus className="mr-2 h-4 w-4" />
New Campaign
@@ -68,14 +99,30 @@ export default function CampaignsPage() {
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>{campaign.name}</CardTitle>
<Tip content={statusTooltips[campaign.status] || "Campaign status"}>
<Badge variant={statusColors[campaign.status] || "secondary"}>
{campaign.status}
</Badge>
</Tip>
</div>
<CardDescription>
{platforms.join(", ")} &middot;{" "}
{campaign._count.assets} assets &middot;{" "}
<CardDescription className="flex items-center gap-1">
<Tip content="Target platforms where ads and content will be generated and optimized.">
<span className="underline decoration-dotted underline-offset-4 cursor-help">
{platforms.join(", ")}
</span>
</Tip>
<span>&middot;</span>
<Tip content="Total generated files — images, videos, copy, and scripts produced by the AI pipeline.">
<span className="underline decoration-dotted underline-offset-4 cursor-help">
{campaign._count.assets} assets
</span>
</Tip>
<span>&middot;</span>
<Tip content="Date this campaign was created.">
<span className="underline decoration-dotted underline-offset-4 cursor-help">
{new Date(campaign.createdAt).toLocaleDateString()}
</span>
</Tip>
</CardDescription>
</CardHeader>
</Card>
@@ -85,5 +132,6 @@ export default function CampaignsPage() {
</div>
)}
</div>
</TooltipProvider>
);
}
+1
View File
@@ -43,6 +43,7 @@ export async function PATCH(
brandIdentity: body.brandIdentity ?? undefined,
productInfo: body.productInfo ?? undefined,
platformGuidelines: body.platformGuidelines ?? undefined,
stylePreferences: body.stylePreferences ?? undefined,
},
});
+156
View File
@@ -0,0 +1,156 @@
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
interface LikedEntry {
assetId: string;
filePath: string;
fileName: string;
dimensions: string | null;
platform: string | null;
style: string | null; // "with-people" | "without-people" | null
timestamp: string;
}
interface DislikedEntry {
assetId: string;
reason: string;
fileName: string;
dimensions: string | null;
platform: string | null;
style: string | null;
timestamp: string;
}
interface StylePreferences {
liked: LikedEntry[];
disliked: DislikedEntry[];
}
function parsePreferences(raw: string | null): StylePreferences {
if (!raw) return { liked: [], disliked: [] };
try {
const parsed = JSON.parse(raw);
return {
liked: Array.isArray(parsed.liked) ? parsed.liked : [],
disliked: Array.isArray(parsed.disliked) ? parsed.disliked : [],
};
} catch {
return { liked: [], disliked: [] };
}
}
export async function GET(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await auth();
if (!session) return new Response("Unauthorized", { status: 401 });
const { id } = await params;
const asset = await prisma.asset.findUnique({
where: { id },
include: { campaign: { include: { app: true } } },
});
if (!asset) return Response.json({ error: "Not found" }, { status: 404 });
const app = asset.campaign?.app;
if (!app) return Response.json({ vote: null });
const prefs = parsePreferences(app.stylePreferences);
const isLiked = prefs.liked.some((e) => e.assetId === id);
const isDisliked = prefs.disliked.some((e) => e.assetId === id);
return Response.json({ vote: isLiked ? "up" : isDisliked ? "down" : null });
}
export async function POST(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await auth();
if (!session) return new Response("Unauthorized", { status: 401 });
const { id } = await params;
const body = await request.json();
const { vote, reason } = body as { vote: "up" | "down"; reason?: string };
if (vote !== "up" && vote !== "down") {
return Response.json({ error: "vote must be 'up' or 'down'" }, { status: 400 });
}
const asset = await prisma.asset.findUnique({
where: { id },
include: { campaign: { include: { app: true } } },
});
if (!asset) return Response.json({ error: "Not found" }, { status: 404 });
const app = asset.campaign?.app;
if (!app) {
return Response.json({ error: "Asset has no associated app" }, { status: 400 });
}
// Extract style from asset metadata (set by ad_manifest.json during generation)
let assetStyle: string | null = null;
if (asset.metadata) {
try {
const meta = JSON.parse(asset.metadata);
if (meta.style === "with-people" || meta.style === "without-people") {
assetStyle = meta.style;
}
} catch { /* metadata isn't JSON or has no style */ }
}
const prefs = parsePreferences(app.stylePreferences);
// Remove from both lists first (clean slate for this asset)
prefs.liked = prefs.liked.filter((e) => e.assetId !== id);
prefs.disliked = prefs.disliked.filter((e) => e.assetId !== id);
// Check if this was already the active vote (toggle off)
const wasLiked = parsePreferences(app.stylePreferences).liked.some((e) => e.assetId === id);
const wasDisliked = parsePreferences(app.stylePreferences).disliked.some((e) => e.assetId === id);
const isToggleOff = (vote === "up" && wasLiked) || (vote === "down" && wasDisliked);
if (!isToggleOff) {
if (vote === "up") {
prefs.liked.push({
assetId: id,
filePath: asset.filePath,
fileName: asset.fileName,
dimensions: asset.dimensions || null,
platform: asset.platform || null,
style: assetStyle,
timestamp: new Date().toISOString(),
});
// Keep max ~10 liked
if (prefs.liked.length > 10) {
prefs.liked = prefs.liked.slice(-10);
}
} else {
prefs.disliked.push({
assetId: id,
reason: reason || "Not preferred",
fileName: asset.fileName,
dimensions: asset.dimensions || null,
platform: asset.platform || null,
style: assetStyle,
timestamp: new Date().toISOString(),
});
// Keep max ~20 disliked
if (prefs.disliked.length > 20) {
prefs.disliked = prefs.disliked.slice(-20);
}
}
}
await prisma.app.update({
where: { id: app.id },
data: { stylePreferences: JSON.stringify(prefs) },
});
const newVote = isToggleOff ? null : vote;
return Response.json({ vote: newVote });
}
+26 -9
View File
@@ -1,6 +1,10 @@
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { repurposeImage, retoneCaption, getAvailableFormats } from "@/lib/repurpose";
import { existsSync } from "fs";
import path from "path";
const PIPELINE_ROOT = process.env.PIPELINE_ROOT || path.join(process.cwd(), "pipeline");
export async function GET(
_request: Request,
@@ -36,14 +40,25 @@ export async function POST(
}
const outputDir = `outputs/repurposed_${id.slice(0, 8)}`;
const resized = await repurposeImage(asset.filePath, targetFormats, outputDir);
const results = [];
for (const file of resized) {
// Launch async — Gemini generation takes time
(async () => {
try {
const expectedFiles = await repurposeImage(
asset.filePath,
asset.dimensions,
targetFormats,
outputDir
);
// Create DB records for files that were actually generated
for (const file of expectedFiles) {
const fullPath = path.join(PIPELINE_ROOT, file.filePath);
if (!existsSync(fullPath)) continue;
const originalMeta = asset.metadata ? JSON.parse(asset.metadata) : {};
let newMeta = { ...originalMeta };
// Re-tone caption if platform changed and caption exists
if (originalMeta.caption && asset.platform && file.platform !== asset.platform) {
try {
const newCaption = await retoneCaption(
@@ -53,11 +68,11 @@ export async function POST(
);
newMeta = { ...newMeta, caption: newCaption, originalCaption: originalMeta.caption };
} catch {
// Keep original caption if re-toning fails
// Keep original caption
}
}
const newAsset = await prisma.asset.create({
await prisma.asset.create({
data: {
campaignId: asset.campaignId,
type: "image",
@@ -71,9 +86,11 @@ export async function POST(
parentAssetId: asset.id,
},
});
results.push(newAsset);
}
} catch (err) {
console.error(`Repurpose failed for asset ${id}:`, err);
}
})();
return Response.json({ created: results.length, assets: results });
return Response.json({ status: "repurposing", formats: targetFormats });
}
+1
View File
@@ -55,6 +55,7 @@ export async function POST(
brandIdentity: app.brandIdentity,
productInfo: app.productInfo,
platformGuidelines: app.platformGuidelines,
stylePreferences: app.stylePreferences,
};
}
+5 -1
View File
@@ -17,7 +17,11 @@ export async function GET(request: Request) {
const where: Record<string, unknown> = {};
if (campaignId) where.campaignId = campaignId;
if (type && type !== "all") where.type = type;
if (type === "media") {
where.type = { in: ["image", "video"] };
} else if (type && type !== "all") {
where.type = type;
}
if (platform && platform !== "all") where.platform = platform;
if (status && status !== "all") where.status = status;
if (search) {
+1
View File
@@ -42,6 +42,7 @@ export async function POST(
brandIdentity: app.brandIdentity,
productInfo: app.productInfo,
platformGuidelines: app.platformGuidelines,
stylePreferences: app.stylePreferences,
};
}
+108 -15
View File
@@ -1,11 +1,12 @@
"use client";
import { useState } from "react";
import { useState, useEffect } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Play, Copy, Sparkles, Send } from "lucide-react";
import { Play, Copy, Sparkles, Send, ThumbsUp, ThumbsDown } from "lucide-react";
import { RepurposeModal } from "./repurpose-modal";
import { VariationModal } from "./variation-modal";
import { ThumbsDownModal } from "./thumbs-down-modal";
interface Asset {
id: string;
@@ -27,6 +28,7 @@ interface AssetCardProps {
selected?: boolean;
onSelect?: (id: string) => void;
onPushToPostiz?: (assetIds: string[]) => void;
onRefresh?: () => void;
}
export function AssetCard({
@@ -34,11 +36,21 @@ export function AssetCard({
selected,
onSelect,
onPushToPostiz,
onRefresh,
}: AssetCardProps) {
const [repurposeOpen, setRepurposeOpen] = useState(false);
const [variationOpen, setVariationOpen] = useState(false);
const [thumbsDownOpen, setThumbsDownOpen] = useState(false);
const [vote, setVote] = useState<"up" | "down" | null>(null);
const metadata = asset.metadata ? JSON.parse(asset.metadata) : {};
let metadata: Record<string, unknown> = {};
if (asset.metadata) {
try {
metadata = JSON.parse(asset.metadata);
} catch {
// metadata may be truncated — ignore parse errors
}
}
const isImage = asset.type === "image" || asset.format === "png" || asset.format === "jpg";
const isVideo = asset.type === "video" || asset.format === "mp4";
const fileSrc = `/api/files/${asset.filePath}`;
@@ -54,6 +66,34 @@ export function AssetCard({
? "Playwright"
: null;
const isVisual = isImage || isVideo;
useEffect(() => {
if (!isVisual) return;
fetch(`/api/assets/${asset.id}/preference`)
.then((r) => r.json())
.then((data) => setVote(data.vote ?? null))
.catch(() => {});
}, [asset.id, isVisual]);
async function handleThumbsUp() {
const newVote = vote === "up" ? null : "up";
setVote(newVote); // Optimistic
try {
const res = await fetch(`/api/assets/${asset.id}/preference`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ vote: "up" }),
});
if (res.ok) {
const data = await res.json();
setVote(data.vote);
}
} catch (err) {
console.error("Preference API failed:", err);
}
}
const sourceColors: Record<string, string> = {
Gemini: "text-purple-600 border-purple-200 bg-purple-50",
"Canvas Design": "text-amber-600 border-amber-200 bg-amber-50",
@@ -146,7 +186,7 @@ export function AssetCard({
)}
</div>
{metadata.caption && (
{typeof metadata.caption === "string" && (
<p className="text-xs text-muted-foreground line-clamp-2">
{metadata.caption}
</p>
@@ -172,18 +212,61 @@ export function AssetCard({
)}
</div>
{/* Style Preference Buttons */}
{isVisual && (
<div className="flex items-center gap-1.5">
<button
onClick={handleThumbsUp}
className={`flex items-center gap-1 rounded-md border px-2 py-1 text-xs transition-colors ${
vote === "up"
? "border-green-300 bg-green-50 text-green-700"
: "border-border text-muted-foreground hover:bg-muted/50"
}`}
>
<ThumbsUp className="h-3 w-3" />
{vote === "up" ? "Liked" : "Like"}
</button>
<button
onClick={() => {
if (vote === "down") {
// Toggle off — optimistic
setVote(null);
fetch(`/api/assets/${asset.id}/preference`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ vote: "down" }),
})
.then((r) => r.json())
.then((data) => setVote(data.vote))
.catch(() => {});
} else {
setThumbsDownOpen(true);
}
}}
className={`flex items-center gap-1 rounded-md border px-2 py-1 text-xs transition-colors ${
vote === "down"
? "border-red-300 bg-red-50 text-red-700"
: "border-border text-muted-foreground hover:bg-muted/50"
}`}
>
<ThumbsDown className="h-3 w-3" />
{vote === "down" ? "Disliked" : "Dislike"}
</button>
</div>
)}
{/* Actions */}
{(isImage || isVideo) && (
<div className="flex gap-2 flex-wrap">
<div className="flex flex-col gap-1.5">
{onPushToPostiz && (
<Button
size="sm"
variant="outline"
className="flex-1 min-w-0 overflow-hidden"
className="w-full"
onClick={() => onPushToPostiz([asset.id])}
>
<Send className="h-3 w-3 shrink-0" />
<span className="truncate">Postiz</span>
<Send className="h-3 w-3" />
Push to Postiz
</Button>
)}
{isImage && (
@@ -191,20 +274,20 @@ export function AssetCard({
<Button
size="sm"
variant="outline"
className="flex-1 min-w-0 overflow-hidden"
className="w-full"
onClick={() => setRepurposeOpen(true)}
>
<Copy className="h-3 w-3 shrink-0" />
<span className="truncate">Repurpose</span>
<Copy className="h-3 w-3" />
Repurpose
</Button>
<Button
size="sm"
variant="outline"
className="flex-1 min-w-0 overflow-hidden"
className="w-full"
onClick={() => setVariationOpen(true)}
>
<Sparkles className="h-3 w-3 shrink-0" />
<span className="truncate">Variations</span>
<Sparkles className="h-3 w-3" />
Spawn Variations
</Button>
</>
)}
@@ -216,7 +299,10 @@ export function AssetCard({
{repurposeOpen && (
<RepurposeModal
assetId={asset.id}
onClose={() => setRepurposeOpen(false)}
onClose={() => {
setRepurposeOpen(false);
onRefresh?.();
}}
/>
)}
{variationOpen && (
@@ -226,6 +312,13 @@ export function AssetCard({
onClose={() => setVariationOpen(false)}
/>
)}
{thumbsDownOpen && (
<ThumbsDownModal
assetId={asset.id}
onClose={() => setThumbsDownOpen(false)}
onSubmitted={() => setVote("down")}
/>
)}
</div>
);
}
+5 -3
View File
@@ -30,7 +30,7 @@ export function AssetGallery({ campaignId, onPushToPostiz }: AssetGalleryProps)
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [filters, setFilters] = useState({
platform: "all",
type: "all",
type: "media",
});
const [search, setSearch] = useState("");
const [sort, setSort] = useState("newest");
@@ -103,9 +103,10 @@ export function AssetGallery({ campaignId, onPushToPostiz }: AssetGalleryProps)
setFilters((f) => ({ ...f, type: e.target.value }))
}
>
<option value="media">Images & Videos</option>
<option value="all">All Types</option>
<option value="image">Images</option>
<option value="video">Videos</option>
<option value="image">Images Only</option>
<option value="video">Videos Only</option>
<option value="copy">Copy</option>
<option value="script">Scripts</option>
</select>
@@ -156,6 +157,7 @@ export function AssetGallery({ campaignId, onPushToPostiz }: AssetGalleryProps)
selected={selectedIds.has(asset.id)}
onSelect={toggleSelect}
onPushToPostiz={onPushToPostiz}
onRefresh={fetchAssets}
/>
))}
</div>
+31 -11
View File
@@ -13,7 +13,24 @@ import {
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { ImagePlus, X, Loader2 } from "lucide-react";
import { ImagePlus, X, Loader2, Info } from "lucide-react";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
function InfoTip({ text }: { text: string }) {
return (
<Tooltip>
<TooltipTrigger className="inline-flex text-muted-foreground hover:text-foreground transition-colors ml-1 align-middle">
<Info className="h-3.5 w-3.5" />
</TooltipTrigger>
<TooltipContent>{text}</TooltipContent>
</Tooltip>
);
}
export const PLATFORMS = ["instagram", "tiktok", "nextdoor"] as const;
export const GOALS = [
@@ -221,6 +238,7 @@ export function CampaignForm({ initialData, mode = "create" }: CampaignFormProps
}
return (
<TooltipProvider>
<Card>
<CardHeader>
<CardTitle>{mode === "edit" ? "Edit Campaign" : "New Campaign"}</CardTitle>
@@ -233,7 +251,7 @@ export function CampaignForm({ initialData, mode = "create" }: CampaignFormProps
<CardContent>
<form onSubmit={handleSubmit} className="space-y-6">
<div className="space-y-2">
<Label htmlFor="name">Campaign Name</Label>
<Label htmlFor="name">Campaign Name <InfoTip text="A descriptive name for this campaign. Used to organize outputs and label generated assets." /></Label>
<Input
id="name"
name="name"
@@ -244,7 +262,7 @@ export function CampaignForm({ initialData, mode = "create" }: CampaignFormProps
</div>
<div className="space-y-2">
<Label>Platforms</Label>
<Label>Platforms <InfoTip text="Which social platforms to generate content for. Each platform gets platform-specific ad dimensions, captions, and video styles." /></Label>
<div className="flex gap-3">
{PLATFORMS.map((platform) => (
<button
@@ -264,7 +282,7 @@ export function CampaignForm({ initialData, mode = "create" }: CampaignFormProps
</div>
<div className="space-y-2">
<Label>Campaign Goal</Label>
<Label>Campaign Goal <InfoTip text="The primary objective shapes the tone, CTA, and creative direction of all generated content." /></Label>
<input type="hidden" name="goal" value={selectedGoal} />
<div className="grid gap-2">
{GOALS.map((goal) => (
@@ -286,7 +304,7 @@ export function CampaignForm({ initialData, mode = "create" }: CampaignFormProps
</div>
<div className="space-y-2">
<Label htmlFor="keyMessage">Key Message</Label>
<Label htmlFor="keyMessage">Key Message <InfoTip text="The core value proposition. This drives every headline, hook, and CTA the AI generates." /></Label>
<Textarea
id="keyMessage"
name="keyMessage"
@@ -298,7 +316,7 @@ export function CampaignForm({ initialData, mode = "create" }: CampaignFormProps
</div>
<div className="space-y-2">
<Label>App Screenshots (optional)</Label>
<Label>App Screenshots (optional) <InfoTip text="Real app screenshots become reference images for Gemini. The AI places them in phone mockups and uses them as the hero visual in ads." /></Label>
<p className="text-xs text-muted-foreground">
Upload screenshots of the feature you want to showcase. These will
be incorporated into generated ads.
@@ -362,7 +380,7 @@ export function CampaignForm({ initialData, mode = "create" }: CampaignFormProps
</div>
<div className="space-y-2">
<Label htmlFor="socialProof">Social Proof</Label>
<Label htmlFor="socialProof">Social Proof <InfoTip text="Stats, ratings, or testimonials that build trust. Appears in ad copy and video overlays." /></Label>
<Textarea
id="socialProof"
name="socialProof"
@@ -373,7 +391,7 @@ export function CampaignForm({ initialData, mode = "create" }: CampaignFormProps
</div>
<div className="space-y-2">
<Label htmlFor="targetAudience">Target Audience</Label>
<Label htmlFor="targetAudience">Target Audience <InfoTip text="Who are we talking to? Influences tone, pain points, and hooks. Be specific — age, interests, situation." /></Label>
<Textarea
id="targetAudience"
name="targetAudience"
@@ -384,7 +402,7 @@ export function CampaignForm({ initialData, mode = "create" }: CampaignFormProps
</div>
<div className="space-y-2">
<Label htmlFor="visualDirection">Visual Direction</Label>
<Label htmlFor="visualDirection">Visual Direction <InfoTip text="Sets the overall aesthetic for generated images and videos. Affects color treatment, layout style, and mood." /></Label>
<select
id="visualDirection"
name="visualDirection"
@@ -400,7 +418,7 @@ export function CampaignForm({ initialData, mode = "create" }: CampaignFormProps
</div>
<div className="space-y-2">
<Label htmlFor="competitorApps">Competitor Apps (optional)</Label>
<Label htmlFor="competitorApps">Competitor Apps (optional) <InfoTip text="Apps you're competing with. The research agent analyzes their messaging to differentiate yours." /></Label>
<Input
id="competitorApps"
name="competitorApps"
@@ -410,7 +428,7 @@ export function CampaignForm({ initialData, mode = "create" }: CampaignFormProps
</div>
<div className="space-y-2">
<Label htmlFor="variations">Variations Per Platform</Label>
<Label htmlFor="variations">Variations Per Platform <InfoTip text="Number of unique hook angles to generate per platform. More variations = more A/B testing options." /></Label>
<Input
id="variations"
name="variations"
@@ -431,6 +449,7 @@ export function CampaignForm({ initialData, mode = "create" }: CampaignFormProps
/>
<Label htmlFor="useTrendReport" className="font-normal">
Use latest trend report for hook inspiration
<InfoTip text="When checked, the trend scout agent runs first and feeds current social media trends into the script writer for timely hooks." />
</Label>
</div>
@@ -446,5 +465,6 @@ export function CampaignForm({ initialData, mode = "create" }: CampaignFormProps
</form>
</CardContent>
</Card>
</TooltipProvider>
);
}
+5 -3
View File
@@ -28,7 +28,7 @@ export function RepurposeModal({ assetId, onClose }: RepurposeModalProps) {
const [available, setAvailable] = useState<string[]>([]);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [loading, setLoading] = useState(false);
const [result, setResult] = useState<{ created: number } | null>(null);
const [result, setResult] = useState<{ formats?: string[] } | null>(null);
useEffect(() => {
fetch(`/api/assets/${assetId}/repurpose`)
@@ -73,8 +73,10 @@ export function RepurposeModal({ assetId, onClose }: RepurposeModalProps) {
{result ? (
<div className="py-4 text-center">
<p className="text-lg font-semibold">{result.created} asset{result.created !== 1 ? "s" : ""} created</p>
<p className="text-sm text-muted-foreground mt-1">Check the Asset Library to review them.</p>
<p className="text-lg font-semibold">Repurposing to {result.formats?.length || 0} format{(result.formats?.length || 0) !== 1 ? "s" : ""}</p>
<p className="text-sm text-muted-foreground mt-1">
Gemini is regenerating the ad at each new size. New assets will appear in the Asset Library when ready.
</p>
<DialogFooter>
<Button onClick={onClose}>Done</Button>
</DialogFooter>
+116
View File
@@ -0,0 +1,116 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
const REASON_TAGS = [
"Too dark",
"Bad composition",
"Stock photo feel",
"Wrong colors",
"Too busy",
"Off brand",
];
interface ThumbsDownModalProps {
assetId: string;
onClose: () => void;
onSubmitted: (vote: "down") => void;
}
export function ThumbsDownModal({ assetId, onClose, onSubmitted }: ThumbsDownModalProps) {
const [selectedTags, setSelectedTags] = useState<Set<string>>(new Set());
const [freeform, setFreeform] = useState("");
const [loading, setLoading] = useState(false);
function toggleTag(tag: string) {
setSelectedTags((prev) => {
const next = new Set(prev);
if (next.has(tag)) next.delete(tag);
else next.add(tag);
return next;
});
}
async function handleSubmit() {
setLoading(true);
const parts = [...selectedTags];
if (freeform.trim()) parts.push(freeform.trim());
const reason = parts.join("; ") || "Not preferred";
// Optimistic update — show disliked immediately
onSubmitted("down");
try {
const res = await fetch(`/api/assets/${assetId}/preference`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ vote: "down", reason }),
});
if (!res.ok) {
console.error("Preference API error:", res.status, await res.text());
}
} catch (err) {
console.error("Preference API failed:", err);
}
setLoading(false);
onClose();
}
return (
<Dialog open onOpenChange={onClose}>
<DialogContent>
<DialogHeader>
<DialogTitle>What didn&apos;t you like?</DialogTitle>
<DialogDescription>
Your feedback helps the AI avoid this style in future generations.
</DialogDescription>
</DialogHeader>
<div className="space-y-3 py-2">
<div className="flex flex-wrap gap-2">
{REASON_TAGS.map((tag) => (
<button
key={tag}
onClick={() => toggleTag(tag)}
className={`rounded-full border px-3 py-1.5 text-sm transition-colors ${
selectedTags.has(tag)
? "border-red-300 bg-red-50 text-red-700"
: "border-border bg-background text-muted-foreground hover:bg-muted/50"
}`}
>
{tag}
</button>
))}
</div>
<textarea
placeholder="Anything else? (optional)"
value={freeform}
onChange={(e) => setFreeform(e.target.value)}
className="w-full rounded-md border bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
rows={2}
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>Cancel</Button>
<Button
variant="destructive"
onClick={handleSubmit}
disabled={loading}
>
{loading ? "Saving..." : "Submit Feedback"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+4 -6
View File
@@ -17,10 +17,11 @@ services:
- NEXTDOOR_API_TOKEN=${NEXTDOOR_API_TOKEN}
- NEXTDOOR_ADVERTISER_ID=${NEXTDOOR_ADVERTISER_ID}
- PIPELINE_ROOT=/app/pipeline
- CLAUDE_CODE_OAUTH_TOKEN=${CLAUDE_CODE_OAUTH_TOKEN}
volumes:
- app-data:/app/prisma/data
- pipeline-outputs:/app/pipeline/outputs
- pipeline-knowledge:/app/pipeline/knowledge
- /mnt/user/downloads/marketing/db:/app/prisma/data
- /mnt/user/downloads/marketing/outputs:/app/pipeline/outputs
- /mnt/user/downloads/marketing/knowledge:/app/pipeline/knowledge
depends_on:
- postiz
@@ -55,9 +56,6 @@ services:
- redis-data:/data
volumes:
app-data:
pipeline-outputs:
pipeline-knowledge:
postiz-uploads:
postiz-config:
postiz-pgdata:
+1
View File
@@ -28,4 +28,5 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
signIn: "/login",
},
secret: process.env.NEXTAUTH_SECRET,
trustHost: true,
});
+132 -16
View File
@@ -13,7 +13,8 @@ const AGENT_STEPS = [
"trend-scout",
"marketing-research-agent",
"script-writer",
"ad-creative-designer",
"gemini-ad-designer",
"poster-ad-designer",
"video-ad-producer",
"copywriter-agent",
"distribution-agent",
@@ -31,6 +32,7 @@ export interface AppConfig {
brandIdentity: string | null;
productInfo: string | null;
platformGuidelines: string | null;
stylePreferences: string | null;
}
interface CampaignConfig {
@@ -122,6 +124,82 @@ Read CLAUDE.md first. Then execute each agent skill in order:
CRITICAL: Read each skill's SKILL.md before executing. Follow the skill instructions exactly.`;
}
/**
* Build a style preferences prompt section from app config.
* Groups liked references by style tag (with-people / without-people) set at
* generation time so the correct reference images are used for matching ads.
* Disliked reasons are text-only and universal.
*/
function buildStylePreferencesSection(appConfig?: AppConfig): string {
if (!appConfig?.stylePreferences) return "";
interface PrefEntry { filePath: string; fileName: string; style?: string | null }
interface DislikeEntry { reason: string; fileName: string; style?: string | null }
let prefs: { liked?: PrefEntry[]; disliked?: DislikeEntry[] };
try {
prefs = JSON.parse(appConfig.stylePreferences);
} catch {
return "";
}
const liked = prefs.liked || [];
const disliked = prefs.disliked || [];
if (liked.length === 0 && disliked.length === 0) return "";
const withPeople = liked.filter((e) => e.style === "with-people");
const withoutPeople = liked.filter((e) => e.style === "without-people");
const untagged = liked.filter((e) => !e.style);
const lines: string[] = ["## Style Preferences (learned from user feedback)", ""];
if (withPeople.length > 0) {
lines.push("### DO — liked WITH-PEOPLE ads (use as reference_images ONLY for ads with people):");
for (const entry of withPeople) {
lines.push(`- "${entry.fileName}"`);
}
const refs = withPeople.slice(-2);
lines.push("Reference image paths:");
for (const entry of refs) {
lines.push(`- ${entry.filePath}`);
}
lines.push("");
}
if (withoutPeople.length > 0) {
lines.push("### DO — liked WITHOUT-PEOPLE ads (use as reference_images ONLY for product-focused ads):");
for (const entry of withoutPeople) {
lines.push(`- "${entry.fileName}"`);
}
const refs = withoutPeople.slice(-2);
lines.push("Reference image paths:");
for (const entry of refs) {
lines.push(`- ${entry.filePath}`);
}
lines.push("");
}
if (untagged.length > 0) {
lines.push("### DO — liked ads (general inspiration, use for any ad type):");
for (const entry of untagged) {
lines.push(`- "${entry.fileName}"`);
}
const refs = untagged.slice(-2);
lines.push("Reference image paths:");
for (const entry of refs) {
lines.push(`- ${entry.filePath}`);
}
lines.push("");
}
if (disliked.length > 0) {
lines.push("### DON'T — styles the user dislikes (applies to ALL ad types):");
for (const entry of disliked) {
lines.push(`- Avoid: "${entry.reason}" (from "${entry.fileName}")`);
}
lines.push("");
}
return lines.join("\n");
}
/**
* Build a focused prompt for a single agent step.
*/
@@ -198,9 +276,9 @@ Save outputs to: ${outputDir}/scripts/
${campaignBrief}`,
"ad-creative-designer": `You are the Ad Creative Designer agent.
"gemini-ad-designer": `You are the Gemini Ad Designer agent.
Read and follow the skill instructions in skills/ad-creative-designer/SKILL.md exactly.
Read and follow the skill instructions in skills/gemini-ad-designer/SKILL.md exactly.
First, read these knowledge files:
- ${knowledgeDir}/brand_identity.md
@@ -211,11 +289,7 @@ Read the upstream outputs:
- ${outputDir}/scripts/scripts_all.json
- ${outputDir}/research_brief.md
You MUST produce TWO SETS of image assets:
---
## SET 1: Gemini AI-Generated Ads (NanoBanana MCP)
## Gemini AI-Generated Ads (NanoBanana MCP)
Use the NanoBanana MCP tools to create polished ad images.${screenshots?.length ? `
@@ -247,9 +321,31 @@ Generate exactly 4 Gemini ads with this mix:
IMPORTANT: For ads WITH people, show real-looking people naturally using the app — not stock photo poses. For ads WITHOUT people, focus on the phone/app in an environment (floating over house, on a counter, etc.)
---
Platform dimensions:
- Instagram Feed: 1080x1080
- Instagram Stories: 1080x1920
- Nextdoor Spotlight: 1200x1200
- Nextdoor Display: 1200x628
## SET 2: Canvas Design Posters (Museum-quality art)
Save ${outputDir}/ads/gemini/manifest.json listing all generated Gemini ads with fields: fileName, set ("gemini"), hook, platform, dimensions, headline, style ("with-people" or "without-people").
${buildStylePreferencesSection(appConfig)}
${campaignBrief}`,
"poster-ad-designer": `You are the Poster Ad Designer agent.
Read and follow the skill instructions in skills/poster-ad-designer/SKILL.md exactly.
First, read these knowledge files:
- ${knowledgeDir}/brand_identity.md
- ${knowledgeDir}/platform_guidelines.md
- ${knowledgeDir}/product_campaign.md
Read the upstream outputs:
- ${outputDir}/scripts/scripts_all.json
- ${outputDir}/research_brief.md
## Canvas Design Posters (Museum-quality art)
Create poster ads using the /skill canvas-design approach. This is a TWO-STEP process:
@@ -276,6 +372,16 @@ Using the philosophy, create each poster as a .png file. For each:
8. The result should look like it could hang in a gallery or appear in a design magazine
9. The ${appName} app icon (${assetsDir}/icon.png) MUST appear in every poster, placed near the branding or CTA area. Use it as an <img> element in the HTML.
### CRITICAL Layout Rule: Phone Must NOT Cover Text
The phone mockup and text MUST occupy separate vertical zones — NEVER overlapping.
Use a three-zone vertical layout:
- **Top zone (15-30%):** Headline text only. No phone.
- **Middle zone (40-55%):** Phone mockup, centered. No text overlapping.
- **Bottom zone (15-25%):** Subtext, CTA, branding. No phone.
For 9:16 (1080x1920): headline top ~380px, phone middle ~420-1400px, CTA bottom ~400px.
For 1:1 (1080x1080): headline top ~200px, phone center max 45% width/500px tall, CTA bottom ~250px.
Before rendering, verify bounding boxes don't overlap. If they do, shrink the phone.
### MANDATORY Typography & Sizing Rules (Social Media Readability)
These are viewed on phones at arm's length. Text that looks fine on a monitor is INVISIBLE in a feed.
@@ -302,16 +408,17 @@ Generate at least 4 posters:
- 2x Instagram (1 feed 1080x1080, 1 stories 1080x1920)
- 2x TikTok cover images (1080x1920)
---
Platform dimensions:
- Instagram Feed: 1080x1080
- Instagram Stories: 1080x1920
- Nextdoor Spotlight: 1200x1200
- Nextdoor Display: 1200x628
Save ${outputDir}/ads/ad_manifest.json listing ALL generated ads from BOTH sets, with fields: fileName, set ("gemini" or "poster"), hook, platform, dimensions, headline.
Save ${outputDir}/ads/posters/manifest.json listing all generated poster ads with fields: fileName, set ("poster"), hook, platform, dimensions, headline.
After generating posters, also create the combined ${outputDir}/ads/ad_manifest.json listing ALL ads from both sets (read ${outputDir}/ads/gemini/manifest.json for the Gemini ads). Fields: fileName, set ("gemini" or "poster"), hook, platform, dimensions, headline, style.
${buildStylePreferencesSection(appConfig)}
${campaignBrief}`,
"video-ad-producer": `You are the Video Ad Producer agent.
@@ -425,7 +532,8 @@ const AGENT_LABELS: Record<string, string> = {
"trend-scout": "Trend Scout",
"marketing-research-agent": "Research Agent",
"script-writer": "Script Writer",
"ad-creative-designer": "Ad Creative Designer",
"gemini-ad-designer": "Gemini Ad Designer",
"poster-ad-designer": "Poster Ad Designer",
"video-ad-producer": "Video Ad Producer",
"copywriter-agent": "Copywriter",
"distribution-agent": "Distribution Agent",
@@ -472,6 +580,13 @@ function humanizeAgentError(agentName: string, code: number | null, stderr: stri
return `${label} failed unexpectedly`;
}
const AGENT_TOOLS: Record<string, string> = {
"gemini-ad-designer":
"Read,Edit,Write,Bash,Grep,Glob,mcp__nanobanana__generate_image,mcp__nanobanana__set_aspect_ratio",
};
const DEFAULT_TOOLS =
"Read,Edit,Write,Bash,Grep,Glob";
export async function runAgentStep(
agentName: string,
prompt: string,
@@ -479,6 +594,7 @@ export async function runAgentStep(
env: Record<string, string>
): Promise<{ output: string }> {
return new Promise((resolve, reject) => {
const allowedTools = AGENT_TOOLS[agentName] || DEFAULT_TOOLS;
const args = [
"-p",
prompt,
@@ -486,7 +602,7 @@ export async function runAgentStep(
"stream-json",
"--verbose",
"--allowedTools",
"Read,Edit,Write,Bash,Grep,Glob,mcp__nanobanana__generate_image",
allowedTools,
];
const claude = spawn("claude", args, {
@@ -736,7 +852,7 @@ export async function sendChatMessage(
"stream-json",
"--verbose",
"--allowedTools",
"Read,Edit,Write,Bash,Grep,Glob,mcp__nanobanana__generate_image",
"Read,Edit,Write,Bash,Grep,Glob,mcp__nanobanana__generate_image,mcp__nanobanana__set_aspect_ratio",
];
if (sessionId) args.push("--resume", sessionId);
+74 -44
View File
@@ -1,16 +1,16 @@
import sharp from "sharp";
import path from "path";
import { mkdirSync } from "fs";
import { runAgentStep } from "./claude";
import { getAllSettings } from "./settings";
const PIPELINE_ROOT = process.env.PIPELINE_ROOT || path.join(process.cwd(), "pipeline");
const PLATFORM_FORMATS: Record<string, { width: number; height: number; label: string }> = {
"instagram-feed": { width: 1080, height: 1080, label: "Instagram Feed" },
"instagram-stories": { width: 1080, height: 1920, label: "Instagram Stories" },
tiktok: { width: 1080, height: 1920, label: "TikTok" },
"nextdoor-spotlight": { width: 1200, height: 1200, label: "Nextdoor Spotlight" },
"nextdoor-display": { width: 1200, height: 628, label: "Nextdoor Display" },
const PLATFORM_FORMATS: Record<string, { width: number; height: number; label: string; ratio: string }> = {
"instagram-feed": { width: 1080, height: 1080, label: "Instagram Feed", ratio: "1:1" },
"instagram-stories": { width: 1080, height: 1920, label: "Instagram Stories", ratio: "9:16" },
tiktok: { width: 1080, height: 1920, label: "TikTok", ratio: "9:16" },
"nextdoor-spotlight": { width: 1200, height: 1200, label: "Nextdoor Spotlight", ratio: "1:1" },
"nextdoor-display": { width: 1200, height: 628, label: "Nextdoor Display", ratio: "191:100" },
};
export function getAvailableFormats(currentDimensions: string | null): string[] {
@@ -24,34 +24,80 @@ export function getPlatformFormat(key: string) {
return PLATFORM_FORMATS[key] || null;
}
/**
* Resize an image to target platform dimensions using Sharp.
* Uses cover fit + center crop for aspect ratio changes.
*/
export async function resizeImage(
sourcePath: string,
targetFormat: string,
outputDir: string
): Promise<{ filePath: string; fileName: string; dimensions: string; platform: string }> {
const fmt = PLATFORM_FORMATS[targetFormat];
if (!fmt) throw new Error(`Unknown format: ${targetFormat}`);
async function loadPipelineEnv(): Promise<Record<string, string>> {
const settings = await getAllSettings();
const env: Record<string, string> = {};
if (settings.GEMINI_API_KEY) env.GEMINI_API_KEY = settings.GEMINI_API_KEY;
return env;
}
/**
* Repurpose an image to target platform formats using Gemini via Claude CLI.
* Claude analyzes the original ad and instructs Gemini to regenerate it
* at the new dimensions with proper layout adaptation.
*/
export async function repurposeImage(
sourcePath: string,
sourceDimensions: string | null,
targetFormats: string[],
outputDir: string
): Promise<Array<{ filePath: string; fileName: string; dimensions: string; platform: string }>> {
mkdirSync(path.join(PIPELINE_ROOT, outputDir), { recursive: true });
const sourceBase = path.basename(sourcePath, path.extname(sourcePath));
const fileName = `${sourceBase}_${targetFormat}_${fmt.width}x${fmt.height}.png`;
const outputPath = path.join(outputDir, fileName);
const fullOutputPath = path.join(PIPELINE_ROOT, outputPath);
const fullSourcePath = path.join(PIPELINE_ROOT, sourcePath);
const env = await loadPipelineEnv();
await sharp(fullSourcePath)
.resize(fmt.width, fmt.height, { fit: "cover", position: "centre" })
.png()
.toFile(fullOutputPath);
const formatInstructions = targetFormats.map((key) => {
const fmt = PLATFORM_FORMATS[key];
if (!fmt) return null;
const fileName = `${sourceBase}_${key}_${fmt.width}x${fmt.height}.png`;
return { key, fmt, fileName };
}).filter(Boolean) as Array<{ key: string; fmt: typeof PLATFORM_FORMATS[string]; fileName: string }>;
const platform = targetFormat.split("-")[0];
// Resolve absolute paths so the agent doesn't have to guess
const absPipelineRoot = path.resolve(PIPELINE_ROOT);
const absSourcePath = path.join(absPipelineRoot, sourcePath);
return { filePath: outputPath, fileName, dimensions: `${fmt.width}x${fmt.height}`, platform };
const prompt = `You are an ad creative reformatter. Your job is to take an existing ad image and recreate it at different dimensions, keeping ALL the same content — same text, same layout structure, same colors, same branding, same imagery — but properly recomposed for the new aspect ratio.
## Source Ad
- File: ${absSourcePath}
- Current dimensions: ${sourceDimensions || "unknown"}
First, use the Read tool to look at the source image so you can see exactly what it contains.
## What To Do
For EACH target format below, do this EXACT sequence:
1. Call mcp__nanobanana__set_aspect_ratio with the correct ratio string
2. Call mcp__nanobanana__gemini_generate_image with:
- reference_images: ["${absSourcePath}"]
- prompt: Describe in detail everything you see in the source ad — the exact headline text, body text, phone mockup, app screenshot, app icon, CTA button text and color, brand name, background color and style. Then say: "Recreate this ad with all these elements recomposed for the new aspect ratio. Keep all text word-for-word identical. Adapt the layout naturally for the new dimensions."
- output_path: the ABSOLUTE output path shown below
## Target Formats
${formatInstructions.map((f) => `### ${f.fmt.label} (${f.fmt.width}x${f.fmt.height})
- Aspect ratio for set_aspect_ratio: "${f.fmt.ratio}"
- output_path: "${path.join(absPipelineRoot, outputDir, f.fileName)}"`).join("\n\n")}
CRITICAL RULES:
- You MUST read the source image first to see what it contains
- Every piece of text from the original MUST appear in the output, word for word
- The visual style, colors, and branding must match exactly
- The layout should be ADAPTED for the new dimensions, not just cropped or padded
- Use the exact output_path values above — they are absolute paths
- The reference_images array must use the absolute source path above`;
await runAgentStep("repurpose-adapter", prompt, PIPELINE_ROOT, env);
// Return expected output info — the agent writes files to disk
return formatInstructions.map((f) => ({
filePath: path.join(outputDir, f.fileName),
fileName: f.fileName,
dimensions: `${f.fmt.width}x${f.fmt.height}`,
platform: f.key.split("-")[0],
}));
}
/**
@@ -77,19 +123,3 @@ Rules:
const { output } = await runAgentStep("caption-retone", prompt, PIPELINE_ROOT, {});
return output.trim();
}
/**
* Repurpose an image asset to specific platform formats.
*/
export async function repurposeImage(
sourcePath: string,
targetFormats: string[],
outputDir: string
): Promise<Array<{ filePath: string; fileName: string; dimensions: string; platform: string }>> {
const results = [];
for (const fmt of targetFormats) {
const result = await resizeImage(sourcePath, fmt, outputDir);
results.push(result);
}
return results;
}
+48 -3
View File
@@ -12,6 +12,29 @@ interface ScannedFile {
metadata: string | null;
}
/** Truncate a JSON string to maxLen while keeping it valid JSON. */
function safeJsonTruncate(json: string, maxLen: number): string {
if (json.length <= maxLen) return json;
try {
const parsed = JSON.parse(json);
// Try to produce a shorter version by re-stringifying with key limits
const trimmed = JSON.stringify(parsed, (_key, value) => {
if (typeof value === "string" && value.length > 200) {
return value.slice(0, 200) + "…";
}
if (Array.isArray(value) && value.length > 5) {
return value.slice(0, 5);
}
return value;
});
if (trimmed.length <= maxLen) return trimmed;
// Still too long — return a minimal summary
return JSON.stringify({ _truncated: true, _originalLength: json.length });
} catch {
return JSON.stringify({ _truncated: true, _originalLength: json.length });
}
}
const FORMAT_TO_TYPE: Record<string, string> = {
png: "image",
jpg: "image",
@@ -65,13 +88,13 @@ function loadMetadata(fullPath: string, format: string): string | null {
if (Array.isArray(parsed)) {
return JSON.stringify({ captions: parsed.slice(0, 3), totalVariations: parsed.length });
}
return content.slice(0, 2000);
return safeJsonTruncate(content, 2000);
}
// For media files, look for adjacent JSON with same name
const jsonPath = fullPath.replace(/\.[^.]+$/, ".json");
if (existsSync(jsonPath)) {
return readFileSync(jsonPath, "utf-8").slice(0, 2000);
return safeJsonTruncate(readFileSync(jsonPath, "utf-8"), 2000);
}
// Look for manifest in same directory
@@ -85,7 +108,10 @@ function loadMetadata(fullPath: string, format: string): string | null {
const entry = manifest.find((e: { fileName?: string; file?: string }) =>
e.fileName === fileName || e.file === fileName
);
if (entry) return JSON.stringify(entry);
if (entry) {
// Ensure style field is preserved in metadata
return JSON.stringify(entry);
}
}
}
@@ -122,6 +148,25 @@ function scanDirectory(dir: string, baseDir: string): ScannedFile[] {
const ext = path.extname(entry).toLowerCase().slice(1);
if (!ext || ext === "gitkeep") continue;
// Only ingest deliverable files — skip source/build artifacts
const ASSET_EXTENSIONS = new Set([
"png", "jpg", "jpeg", "webp", "gif", // images
"mp4", "webm", // videos
]);
const CONTENT_EXTENSIONS = new Set([
"json", "md", "txt", // copy/scripts/research
]);
// Skip HTML source files, render scripts, and build tools
if (!ASSET_EXTENSIONS.has(ext) && !CONTENT_EXTENSIONS.has(ext)) continue;
// Skip known build/tool artifacts
const SKIP_FILES = new Set([
"tavily_search.mjs", "render_posters.mjs", "design_philosophy.md",
]);
if (SKIP_FILES.has(entry)) continue;
// Skip HTML source files in ads/ (they're build artifacts, not deliverables)
const relativePath0 = path.relative(baseDir, fullPath);
if (ext === "html" && relativePath0.includes("/ads/")) continue;
const relativePath = path.relative(baseDir, fullPath);
const type = inferTypeFromPath(relativePath, ext);
const metadata = loadMetadata(fullPath, ext);
+14
View File
@@ -0,0 +1,14 @@
{
"permissions": {
"allow": [
"mcp__nanobanana__generate_image",
"mcp__nanobanana__gemini_generate_image",
"mcp__nanobanana__(.*)",
"Bash(*)",
"Read(*)",
"Write(*)",
"Grep(*)",
"Glob(*)"
]
}
}
+9 -8
View File
@@ -1,22 +1,23 @@
# Marketing Content Pipeline
This project implements an AI-powered Social Media Content Automation System.
Seven specialized agents research, generate, render, and distribute marketing content.
Eight specialized agents research, generate, render, and distribute marketing content.
# System Architecture
Seven agents running in sequence:
Eight agents running in sequence:
1. **Trend Scout** — trending content monitoring via Tavily
2. **Marketing Research Agent** — deep market research via Tavily
3. **Script Writer** — ad scripts from research output
4. **Ad Creative Designer**static ads via NanoBanana MCP + Playwright
5. **Video Ad Producer** — video ads via Remotion
6. **Copywriter Agent** — platform-specific copy
7. **Distribution Agent** — publish manifest creation (gate-protected)
4. **Gemini Ad Designer**AI-generated image ads via NanoBanana MCP (Google Gemini)
5. **Poster Ad Designer** — museum-quality poster ads via Playwright HTML-to-PNG
6. **Video Ad Producer** — video ads via Remotion
7. **Copywriter Agent** — platform-specific copy
8. **Distribution Agent** — publish manifest creation (gate-protected)
# Folder Structure
- `assets/` — brand images, logos, product shots (mood board)
- `knowledge/` — brand identity, platform guidelines, product/campaign info
- `skills/` — all 7 agent skills (each has SKILL.md)
- `skills/` — all 8 agent skills (each has SKILL.md)
- `outputs/` — generated content per campaign
- `remotion-ad/` — Remotion video project with compositions
@@ -65,7 +66,7 @@ cd remotion-ad && npx remotion render src/index.ts CompositionId --output ../out
You can modify or create new compositions in `remotion-ad/src/` before rendering.
# Pipeline Execution Order
trend-scout → research → script-writer → ad-creative → video-producer → copywriter → distribution
trend-scout → research → script-writer → gemini-ad-designer → poster-ad-designer → video-producer → copywriter → distribution
Each agent reads its SKILL.md from `skills/{agent-name}/SKILL.md` and follows it exactly.
+1
View File
@@ -0,0 +1 @@
[{"name":"generate-buildid","duration":118,"timestamp":60500788710,"id":4,"parentId":1,"tags":{},"startTime":1774315255995,"traceId":"9f11bde72f6a7676"},{"name":"load-custom-routes","duration":220,"timestamp":60500788882,"id":5,"parentId":1,"tags":{},"startTime":1774315255996,"traceId":"9f11bde72f6a7676"},{"name":"create-dist-dir","duration":1140,"timestamp":60500789119,"id":6,"parentId":1,"tags":{},"startTime":1774315255996,"traceId":"9f11bde72f6a7676"},{"name":"clean","duration":278,"timestamp":60500790703,"id":7,"parentId":1,"tags":{},"startTime":1774315255997,"traceId":"9f11bde72f6a7676"},{"name":"next-build","duration":27478,"timestamp":60500763592,"id":1,"tags":{"buildMode":"default","version":"16.2.1","bundler":"turbopack","failed":true},"startTime":1774315255970,"traceId":"9f11bde72f6a7676"}]
+1
View File
@@ -0,0 +1 @@
[{"name":"next-build","duration":27478,"timestamp":60500763592,"id":1,"tags":{"buildMode":"default","version":"16.2.1","bundler":"turbopack","failed":true},"startTime":1774315255970,"traceId":"9f11bde72f6a7676"}]
Binary file not shown.

After

Width:  |  Height:  |  Size: 377 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 444 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 394 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 225 KiB

+581
View File
@@ -0,0 +1,581 @@
import {
AbsoluteFill,
Img,
interpolate,
spring,
useCurrentFrame,
useVideoConfig,
staticFile,
} from "remotion";
export interface ReflectAdProps {
platform: "instagram" | "tiktok";
hookText: string;
bodyText: string;
ctaText: string;
proofText: string;
screenshotSrc: string;
}
const COLORS = {
sage: "#3d5a4c",
bronze: "#c4956b",
cream: "#f5f1eb",
dark: "#141210",
white: "#ffffff",
sageLight: "#5a7d6a",
bronzeLight: "#d4ad85",
};
export const ReflectAd: React.FC<ReflectAdProps> = ({
platform,
hookText,
bodyText,
ctaText,
proofText,
screenshotSrc,
}) => {
const frame = useCurrentFrame();
const { fps, durationInFrames, width, height } = useVideoConfig();
const isPolished = platform === "instagram";
// Scene boundaries (4 scenes per the brief)
// Hook: 0 → ~20% | Phone reveal: ~20% → ~60% | Proof: ~60% → ~78% | CTA: ~78% → end
const hookEnd = Math.floor(durationInFrames * 0.2);
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 hookFadeIn = interpolate(frame, [0, 12], [0, 1], {
extrapolateRight: "clamp",
});
const hookFadeOut = interpolate(
frame,
[hookEnd - 10, hookEnd],
[1, 0],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
);
const hookOpacity = Math.min(hookFadeIn, hookFadeOut);
const hookY = isPolished
? interpolate(frame, [0, 18], [30, 0], { extrapolateRight: "clamp" })
: 0;
// TikTok: pop-in scale
const hookScale = isPolished
? 1
: spring({
frame,
fps,
config: { damping: 14, stiffness: 120 },
});
// === PHONE REVEAL SCENE ===
const phoneSpring = spring({
frame: Math.max(0, frame - phoneStart),
fps,
config: { damping: 16, stiffness: 60 },
});
const phoneOpacity = interpolate(
frame,
[phoneStart, phoneStart + 12, phoneEnd - 10, phoneEnd],
[0, 1, 1, 0],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
);
const bodyTextOpacity = interpolate(
frame,
[phoneStart + 18, phoneStart + 30],
[0, 1],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
);
const bodyTextFadeOut = interpolate(
frame,
[phoneEnd - 10, phoneEnd],
[1, 0],
{ 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: 14 },
});
// === CTA SCENE ===
const ctaOpacity = interpolate(frame, [ctaStart, ctaStart + 14], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
const ctaScale = spring({
frame: Math.max(0, frame - ctaStart),
fps,
config: { damping: 12, stiffness: 80 },
});
const ctaPulse =
frame > ctaStart + 24
? 1 + 0.025 * Math.sin((frame - ctaStart - 24) * 0.12)
: 1;
const phoneWidth = width * 0.52;
const phoneHeight = phoneWidth * 2.05;
// Subtle background grain animation
const grainOpacity = isPolished ? 0.03 : 0.06;
if (isPolished) {
// ============================
// POLISHED — Instagram Reels
// Warm cream or dark bg, elegant serif, smooth fades
// ============================
return (
<AbsoluteFill
style={{
backgroundColor: COLORS.dark,
fontFamily: '"Cormorant Garamond", "Georgia", serif',
}}
>
{/* Warm radial gradient */}
<div
style={{
position: "absolute",
inset: 0,
background: `radial-gradient(ellipse at 50% 25%, ${COLORS.sage}20 0%, transparent 55%), radial-gradient(ellipse at 50% 75%, ${COLORS.bronze}15 0%, transparent 50%)`,
}}
/>
{/* Subtle grain texture */}
<div
style={{
position: "absolute",
inset: 0,
opacity: grainOpacity,
backgroundImage: `url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)' opacity='1'/%3E%3C/svg%3E")`,
}}
/>
{/* === HOOK === */}
<div
style={{
position: "absolute",
top: "38%",
left: "50%",
transform: `translate(-50%, -50%) translateY(${hookY}px)`,
opacity: hookOpacity,
width: "82%",
textAlign: "center",
}}
>
<div
style={{
fontSize: 62,
fontWeight: 400,
fontStyle: "italic",
color: COLORS.cream,
lineHeight: 1.25,
letterSpacing: 0.5,
}}
>
{hookText}
</div>
{/* Decorative line */}
<div
style={{
width: 60,
height: 2,
backgroundColor: COLORS.bronze,
margin: "28px auto 0",
opacity: hookFadeIn,
}}
/>
</div>
{/* === PHONE REVEAL + BODY TEXT === */}
<div
style={{
position: "absolute",
top: "6%",
left: "50%",
transform: `translateX(-50%) scale(${phoneSpring})`,
opacity: phoneOpacity,
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 24,
}}
>
{/* Body text above phone */}
<div
style={{
opacity: Math.min(bodyTextOpacity, bodyTextFadeOut),
fontSize: 32,
fontWeight: 300,
color: COLORS.cream,
textAlign: "center",
maxWidth: width * 0.78,
lineHeight: 1.4,
fontFamily: '"Outfit", "Inter", sans-serif',
letterSpacing: 0.3,
}}
>
{bodyText}
</div>
{/* Phone mockup */}
<div
style={{
width: phoneWidth,
height: phoneHeight,
position: "relative",
filter: "drop-shadow(0 24px 48px rgba(0,0,0,0.45))",
}}
>
{/* Screenshot behind the frame */}
<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",
width: "80%",
}}
>
<div
style={{
fontSize: 40,
fontWeight: 400,
fontStyle: "italic",
color: COLORS.bronze,
lineHeight: 1.3,
}}
>
{proofText}
</div>
<div
style={{
fontSize: 26,
fontWeight: 300,
color: "rgba(245, 241, 235, 0.6)",
marginTop: 16,
fontFamily: '"Outfit", "Inter", sans-serif',
}}
>
Loved by thousands of mindful users
</div>
</div>
{/* === CTA === */}
<div
style={{
position: "absolute",
inset: 0,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
opacity: ctaOpacity,
transform: `scale(${ctaScale})`,
gap: 36,
}}
>
{/* App name */}
<div
style={{
fontSize: 52,
fontWeight: 400,
fontStyle: "italic",
color: COLORS.cream,
letterSpacing: 2,
}}
>
Reflect
</div>
{/* Tagline */}
<div
style={{
fontSize: 26,
fontWeight: 300,
color: "rgba(245, 241, 235, 0.7)",
fontFamily: '"Outfit", "Inter", sans-serif',
marginTop: -12,
}}
>
A quiet space for your inner world
</div>
{/* CTA button */}
<div
style={{
backgroundColor: COLORS.sage,
color: COLORS.cream,
fontSize: 32,
fontWeight: 500,
padding: "22px 64px",
borderRadius: 28,
textAlign: "center",
boxShadow: `0 8px 32px ${COLORS.sage}60`,
transform: `scale(${ctaPulse})`,
fontFamily: '"Outfit", "Inter", sans-serif',
}}
>
{ctaText}
</div>
</div>
{/* Small logo watermark bottom-right */}
<div
style={{
position: "absolute",
bottom: 40,
right: 40,
opacity: interpolate(frame, [0, durationInFrames * 0.15], [0, 0.4], {
extrapolateRight: "clamp",
}),
fontSize: 18,
fontWeight: 300,
color: COLORS.cream,
fontFamily: '"Outfit", "Inter", sans-serif',
letterSpacing: 1,
}}
>
reflect
</div>
</AbsoluteFill>
);
}
// ============================
// AUTHENTIC — TikTok
// Dark bg, bold text, quick cuts, max 6 words per frame, native feel
// ============================
return (
<AbsoluteFill
style={{
backgroundColor: "#0a0a0a",
fontFamily: '"Inter", "SF Pro Display", system-ui, sans-serif',
}}
>
{/* Subtle warm vignette */}
<div
style={{
position: "absolute",
inset: 0,
background: `radial-gradient(ellipse at 50% 50%, transparent 40%, rgba(0,0,0,0.4) 100%)`,
}}
/>
{/* === HOOK === */}
<div
style={{
position: "absolute",
top: "40%",
left: "50%",
transform: `translate(-50%, -50%) scale(${hookScale})`,
opacity: hookOpacity,
width: "88%",
textAlign: "center",
}}
>
<div
style={{
fontSize: 72,
fontWeight: 800,
color: COLORS.white,
lineHeight: 1.15,
letterSpacing: -1,
textShadow: "0 4px 20px rgba(0,0,0,0.5)",
}}
>
{hookText}
</div>
</div>
{/* === PHONE REVEAL + BODY TEXT === */}
<div
style={{
position: "absolute",
top: "5%",
left: "50%",
transform: `translateX(-50%) scale(${phoneSpring})`,
opacity: phoneOpacity,
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 20,
}}
>
{/* Body text — bold, short */}
<div
style={{
opacity: Math.min(bodyTextOpacity, bodyTextFadeOut),
fontSize: 38,
fontWeight: 700,
color: COLORS.white,
textAlign: "center",
maxWidth: width * 0.85,
lineHeight: 1.25,
textShadow: "0 2px 12px rgba(0,0,0,0.4)",
}}
>
{bodyText}
</div>
{/* Phone mockup */}
<div
style={{
width: phoneWidth,
height: phoneHeight,
position: "relative",
filter: "drop-shadow(0 16px 40px rgba(0,0,0,0.6))",
}}
>
<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>
<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",
width: "85%",
}}
>
<div
style={{
fontSize: 48,
fontWeight: 800,
color: COLORS.bronze,
lineHeight: 1.2,
textShadow: "0 2px 16px rgba(0,0,0,0.4)",
}}
>
{proofText}
</div>
</div>
{/* === CTA === */}
<div
style={{
position: "absolute",
inset: 0,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
opacity: ctaOpacity,
transform: `scale(${ctaScale})`,
gap: 28,
}}
>
<div
style={{
fontSize: 58,
fontWeight: 800,
color: COLORS.white,
letterSpacing: -1,
}}
>
Reflect
</div>
{/* CTA text (no button — native TikTok feel) */}
<div
style={{
fontSize: 36,
fontWeight: 700,
color: COLORS.bronze,
textAlign: "center",
textShadow: "0 2px 12px rgba(0,0,0,0.3)",
transform: `scale(${ctaPulse})`,
}}
>
{ctaText}
</div>
</div>
</AbsoluteFill>
);
};
+177 -23
View File
@@ -1,20 +1,176 @@
import { Composition, staticFile } from "remotion";
import { HoneyDueAd } from "./HoneyDueAd";
import { ReflectAd } from "./ReflectAd";
const SCREENSHOT = staticFile("tasks_overdue.png");
const HONEYDUESCREEN = staticFile("tasks_overdue.png");
// 15s @ 30fps for Instagram, 12s for TikTok
const IG_FRAMES = 450;
const TT_FRAMES = 360;
// 15s @ 30fps for Instagram, various for TikTok
const IG_FRAMES = 450; // 15s
const TT_13_FRAMES = 390; // 13s
const TT_12_FRAMES = 360; // 12s
export const RemotionRoot: React.FC = () => {
return (
<>
{/* === GEMINI AD VIDEOS === */}
{/* ============================================ */}
{/* === REFLECT v1.1 — GEMINI AD VIDEOS ======= */}
{/* ============================================ */}
{/* Gemini IG Hook 2 — "My therapist asked me to track my moods" */}
<Composition
id="Reflect-Gemini-IG-Hook2"
component={ReflectAd}
durationInFrames={IG_FRAMES}
fps={30}
width={1080}
height={1920}
defaultProps={{
platform: "instagram" as const,
hookText: "My therapist asked me\nto track my moods.",
bodyText: "She didn't expect an AI-powered mood report.",
ctaText: "Begin reflecting",
proofText: "Your check-ins become patterns\nyou can share with your therapist",
screenshotSrc: staticFile("flow_07_detail_after.png"),
}}
/>
{/* Gemini IG Hook 4 — "Two minutes of reflection" */}
<Composition
id="Reflect-Gemini-IG-Hook4"
component={ReflectAd}
durationInFrames={420}
fps={30}
width={1080}
height={1920}
defaultProps={{
platform: "instagram" as const,
hookText: "Two minutes of reflection.\nZero blank pages.",
bodyText: "Guided questions that adapt to your mood.",
ctaText: "Start your journal",
proofText: "AI-powered reports turn daily moments\ninto real understanding",
screenshotSrc: staticFile("flow_03_q1.png"),
}}
/>
{/* Gemini TT Hook 1 — "I stopped journaling" */}
<Composition
id="Reflect-Gemini-TT-Hook1"
component={ReflectAd}
durationInFrames={TT_13_FRAMES}
fps={30}
width={1080}
height={1920}
defaultProps={{
platform: "tiktok" as const,
hookText: "I stopped journaling.",
bodyText: "Then I found one that\nasks the questions for you.",
ctaText: "Try Reflect free",
proofText: "No blank pages.\nJust the right question.",
screenshotSrc: staticFile("flow_q2_typing.png"),
}}
/>
{/* Gemini TT Hook 3 — "POV: the app asks exactly what you needed" */}
<Composition
id="Reflect-Gemini-TT-Hook3"
component={ReflectAd}
durationInFrames={TT_12_FRAMES}
fps={30}
width={1080}
height={1920}
defaultProps={{
platform: "tiktok" as const,
hookText: "POV: it asks exactly\nwhat you needed.",
bodyText: "Questions based on how you feel.\nNot random. Not generic.",
ctaText: "Download Reflect free",
proofText: "The one that makes\nyou pause and think.",
screenshotSrc: staticFile("flow_05_q3_scrolled.png"),
}}
/>
{/* ============================================ */}
{/* === REFLECT v1.1 — POSTER AD VIDEOS ======= */}
{/* ============================================ */}
{/* Poster IG Hook 2 — "My therapist asked me to track my moods" */}
<Composition
id="Reflect-Poster-IG-Hook2"
component={ReflectAd}
durationInFrames={IG_FRAMES}
fps={30}
width={1080}
height={1920}
defaultProps={{
platform: "instagram" as const,
hookText: "My therapist asked me\nto track my moods.",
bodyText: "She didn't expect an AI-powered mood report.",
ctaText: "Begin reflecting",
proofText: "Patterns you can understand\nand share with your therapist",
screenshotSrc: staticFile("flow_06_q4.png"),
}}
/>
{/* Poster IG Hook 5 — "What if your phone helped you feel" */}
<Composition
id="Reflect-Poster-IG-Hook5"
component={ReflectAd}
durationInFrames={IG_FRAMES}
fps={30}
width={1080}
height={1920}
defaultProps={{
platform: "instagram" as const,
hookText: "What if your phone\nhelped you feel\ninstead of just scroll?",
bodyText: "Guided reflection that adapts to your mood.",
ctaText: "Begin reflecting",
proofText: "AI-powered insights reveal\npatterns you can't see alone",
screenshotSrc: staticFile("flow_07_detail_after.png"),
}}
/>
{/* Poster TT Hook 1 — "I stopped journaling" */}
<Composition
id="Reflect-Poster-TT-Hook1"
component={ReflectAd}
durationInFrames={TT_13_FRAMES}
fps={30}
width={1080}
height={1920}
defaultProps={{
platform: "tiktok" as const,
hookText: "I stopped journaling.",
bodyText: "Tap your mood.\nIt asks the right question.",
ctaText: "Try Reflect free",
proofText: "No pressure.\nJust the right question.",
screenshotSrc: staticFile("flow_03_q1.png"),
}}
/>
{/* Poster TT Hook 3 — "POV: the app asks exactly what you needed" */}
<Composition
id="Reflect-Poster-TT-Hook3"
component={ReflectAd}
durationInFrames={TT_12_FRAMES}
fps={30}
width={1080}
height={1920}
defaultProps={{
platform: "tiktok" as const,
hookText: "POV: it asks exactly\nwhat you needed.",
bodyText: "Log your mood.\nGet a guided question.",
ctaText: "Download Reflect free",
proofText: "Not random.\nThe one you needed.",
screenshotSrc: staticFile("flow_06_q4.png"),
}}
/>
{/* ============================================ */}
{/* === HONEYDUE (LEGACY COMPOSITIONS) ========= */}
{/* ============================================ */}
<Composition
id="Gemini-IG-Feed-Cost"
component={HoneyDueAd}
durationInFrames={IG_FRAMES}
durationInFrames={450}
fps={30}
width={1080}
height={1920}
@@ -24,13 +180,13 @@ export const RemotionRoot: React.FC = () => {
bodyText: "honeyDue tracks every task so you never skip the small stuff.",
ctaText: "Download Free",
proofText: "Trusted by thousands of homeowners",
screenshotSrc: SCREENSHOT,
screenshotSrc: HONEYDUESCREEN,
}}
/>
<Composition
id="Gemini-IG-Stories-FirstTimer"
component={HoneyDueAd}
durationInFrames={IG_FRAMES}
durationInFrames={450}
fps={30}
width={1080}
height={1920}
@@ -40,13 +196,13 @@ export const RemotionRoot: React.FC = () => {
bodyText: "This app tells you what to fix and when.",
ctaText: "Try honeyDue",
proofText: "First-time homeowners love this",
screenshotSrc: SCREENSHOT,
screenshotSrc: HONEYDUESCREEN,
}}
/>
<Composition
id="Gemini-TT-SilentTodo"
component={HoneyDueAd}
durationInFrames={TT_FRAMES}
durationInFrames={360}
fps={30}
width={1080}
height={1920}
@@ -56,13 +212,13 @@ export const RemotionRoot: React.FC = () => {
bodyText: "HVAC filters. Gutters. Water heater. honeyDue sees it all.",
ctaText: "Get Started Free",
proofText: "Never miss maintenance again",
screenshotSrc: SCREENSHOT,
screenshotSrc: HONEYDUESCREEN,
}}
/>
<Composition
id="Gemini-TT-Forgetter"
component={HoneyDueAd}
durationInFrames={TT_FRAMES}
durationInFrames={360}
fps={30}
width={1080}
height={1920}
@@ -72,15 +228,13 @@ export const RemotionRoot: React.FC = () => {
bodyText: "honeyDue would have reminded me 8 times by now.",
ctaText: "Download Free",
proofText: "Your home maintenance safety net",
screenshotSrc: SCREENSHOT,
screenshotSrc: HONEYDUESCREEN,
}}
/>
{/* === CANVAS POSTER VIDEOS === */}
<Composition
id="Poster-IG-Feed-Cost"
component={HoneyDueAd}
durationInFrames={IG_FRAMES}
durationInFrames={450}
fps={30}
width={1080}
height={1920}
@@ -90,13 +244,13 @@ export const RemotionRoot: React.FC = () => {
bodyText: "The difference between a reminder and a disaster.",
ctaText: "Download Free",
proofText: "Join thousands of organized homeowners",
screenshotSrc: SCREENSHOT,
screenshotSrc: HONEYDUESCREEN,
}}
/>
<Composition
id="Poster-IG-Stories-FirstHouse"
component={HoneyDueAd}
durationInFrames={IG_FRAMES}
durationInFrames={450}
fps={30}
width={1080}
height={1920}
@@ -106,13 +260,13 @@ export const RemotionRoot: React.FC = () => {
bodyText: "100+ maintenance tasks. One app to track them all.",
ctaText: "Try honeyDue",
proofText: "Built for first-time homeowners",
screenshotSrc: SCREENSHOT,
screenshotSrc: HONEYDUESCREEN,
}}
/>
<Composition
id="Poster-TT-HiddenTodo"
component={HoneyDueAd}
durationInFrames={TT_FRAMES}
durationInFrames={360}
fps={30}
width={1080}
height={1920}
@@ -122,13 +276,13 @@ export const RemotionRoot: React.FC = () => {
bodyText: "Filters. Gutters. Drains. Vents. honeyDue tracks all of it.",
ctaText: "Get Started Free",
proofText: "See what you've been missing",
screenshotSrc: SCREENSHOT,
screenshotSrc: HONEYDUESCREEN,
}}
/>
<Composition
id="Poster-TT-HVAC2Years"
component={HoneyDueAd}
durationInFrames={TT_FRAMES}
durationInFrames={360}
fps={30}
width={1080}
height={1920}
@@ -138,7 +292,7 @@ export const RemotionRoot: React.FC = () => {
bodyText: "That's 8 missed reminders honeyDue would have sent.",
ctaText: "Download Free",
proofText: "Never forget again",
screenshotSrc: SCREENSHOT,
screenshotSrc: HONEYDUESCREEN,
}}
/>
</>
@@ -0,0 +1,95 @@
import { tavily } from "@tavily/core";
import { writeFileSync } from "fs";
const client = tavily({ apiKey: process.env.TAVILY_API_KEY });
async function runQueries() {
const queries = [
{
query_id: 1,
query_name: "Industry Trends & Market Landscape",
search_terms: "mood tracking app market 2026 mental health app trends CBT guided journaling AI insights",
options: {
searchDepth: "advanced",
topic: "news",
days: 30,
maxResults: 10,
excludeDomains: ["pinterest.com", "etsy.com"]
}
},
{
query_id: 2,
query_name: "Competitor Analysis",
search_terms: "Daylio vs Finch vs Bearable vs How We Feel mood tracker 2026 marketing campaigns features comparison",
options: {
searchDepth: "advanced",
topic: "general",
maxResults: 10,
includeDomains: ["reddit.com", "producthunt.com", "techcrunch.com", "theverge.com", "cnet.com", "choosingtherapy.com"]
}
},
{
query_id: 3,
query_name: "Audience Pain Points & Conversations",
search_terms: "mood tracking app frustrations blank journal overwhelm therapist homework mood diary share with therapist 2025 2026",
options: {
searchDepth: "advanced",
topic: "general",
maxResults: 10,
includeDomains: ["reddit.com", "twitter.com", "quora.com"]
}
},
{
query_id: 4,
query_name: "High-Performing Hooks & Ad Copy",
search_terms: "best mental health app ad copy hooks 2026 wellness app Instagram TikTok ad examples high engagement",
options: {
searchDepth: "advanced",
topic: "general",
maxResults: 10
}
},
{
query_id: 5,
query_name: "Viral Content & Cultural Moments",
search_terms: "mental health app viral TikTok 2026 spring self-care trend therapy journal check-in cultural moment",
options: {
searchDepth: "advanced",
topic: "news",
days: 14,
maxResults: 10
}
}
];
const results = [];
for (const q of queries) {
console.log(`\n🔍 Query ${q.query_id}: ${q.query_name}`);
console.log(` Search: "${q.search_terms}"`);
try {
const res = await client.search(q.search_terms, q.options);
console.log(` ✅ Got ${res.results?.length || 0} results`);
results.push({
...q,
raw_results: res.results || [],
answer: res.answer || null
});
} catch (err) {
console.error(` ❌ Error: ${err.message}`);
results.push({
...q,
raw_results: [],
answer: null,
error: err.message
});
}
}
// Write raw results for processing
const outputPath = "outputs/reflect_v1.1_—_guided_reflection_&_ai_reports_20260325/raw_research.json";
writeFileSync(outputPath, JSON.stringify(results, null, 2));
console.log(`\n✅ Raw results saved to ${outputPath}`);
}
runQueries().catch(console.error);
@@ -0,0 +1,73 @@
---
name: gemini-ad-designer
description: >
AI image ad designer agent. Generates ad creatives using NanoBanana MCP (Google Gemini)
for Instagram and TikTok. Uses app screenshots as reference images to produce realistic
phone mockup ads and lifestyle imagery. Outputs production-ready PNG files.
---
# Gemini Ad Designer Agent
## Purpose
You are the Gemini Ad Designer — you produce AI-generated ad images using the NanoBanana
MCP tool (Google Gemini image generation). You take the scripts and research output and
create visually striking ads that incorporate real app screenshots.
## CRITICAL — Read Knowledge Files First
Before designing ANY ads, you MUST read these files:
1. `knowledge/brand_identity.md` — tone, voice, CTA patterns, brand personality
2. `knowledge/platform_guidelines.md` — exact dimensions, aspect ratios, platform rules
3. `knowledge/product_campaign.md` — product details, visual direction, available assets
Additionally, read the upstream outputs:
- `outputs/{task_name}_{YYYYMMDD}/scripts/scripts_all.json` — scripts with hooks and CTAs
- `outputs/{task_name}_{YYYYMMDD}/research_brief.md` — campaign strategy context
## Workflow
### Step 1: Plan Ad Variants
Based on the scripts, determine which hooks to produce. Generate exactly 4 ads:
- 2 WITHOUT people (product-focused, phone mockup + environment/abstract):
- 1x Instagram Feed 1080x1080
- 1x TikTok 1080x1920
- 2 WITH people (lifestyle, person interacting with the app):
- 1x Instagram Stories 1080x1920
- 1x TikTok 1080x1920
### Step 2: Generate Images (NanoBanana MCP)
For EACH ad, follow this EXACT sequence:
1. Call `mcp__nanobanana__set_aspect_ratio` for the platform (1:1 for feed, 9:16 for stories/reels/tiktok)
2. Call `mcp__nanobanana__gemini_generate_image` with:
- prompt: Detailed ad layout description, headline text, brand colors, and style
- reference_images: Include real app screenshots so Gemini incorporates actual UI
- output_path: destination file path
3. Save to `outputs/{task_name}_{date}/ads/gemini/`
4. Name files: `gemini_{platform}_{hook}_{dimensions}.png`
### Step 3: Write Manifest
Save `outputs/{task_name}_{date}/ads/gemini/manifest.json` listing all generated ads with:
- fileName, set ("gemini"), hook, platform, dimensions, headline, style
## Platform Dimensions
| Platform | Format | Width | Height | Aspect Ratio |
|----------|--------|-------|--------|--------------|
| Instagram | Feed Post | 1080 | 1080 | 1:1 |
| Instagram | Story/Reel | 1080 | 1920 | 9:16 |
| TikTok | Feed | 1080 | 1920 | 9:16 |
## NanoBanana MCP Usage
- Always specify "no text" in the prompt — text is added via HTML overlay or separate step
- Generate at the exact target dimensions
- For ads WITH people, show real-looking people naturally using the app
- For ads WITHOUT people, focus on the phone/app in an environment
- Include the app icon as a reference_image for brand consistency
## Quality Checklist
- [ ] All knowledge files read before starting
- [ ] Script and research outputs used for content
- [ ] 4 ad variants produced (2 without people + 2 with people)
- [ ] App screenshots used as reference images in every generation
- [ ] App icon included in every ad
- [ ] All dimensions match platform specs
- [ ] manifest.json is valid JSON with all required fields
+127
View File
@@ -0,0 +1,127 @@
---
name: poster-ad-designer
description: >
Canvas design poster agent. Creates museum-quality poster ads using HTML/CSS rendered
to PNG via Playwright. Develops a design philosophy per campaign, then expresses it
as art-object posters with phone mockups and minimal typography.
---
# Poster Ad Designer Agent
## Purpose
You are the Poster Ad Designer — you create museum-quality poster ads using HTML/CSS
rendered to pixel-perfect PNGs via Playwright. Each poster is an art object: 90% visual
design, 10% essential text. You develop a design philosophy for the campaign and express
it through systematic visual language.
## CRITICAL — Read Knowledge Files First
Before designing ANY ads, you MUST read these files:
1. `knowledge/brand_identity.md` — tone, voice, CTA patterns, brand personality
2. `knowledge/platform_guidelines.md` — exact dimensions, aspect ratios, platform rules
3. `knowledge/product_campaign.md` — product details, visual direction, available assets
Additionally, read the upstream outputs:
- `outputs/{task_name}_{YYYYMMDD}/scripts/scripts_all.json` — scripts with hooks and CTAs
- `outputs/{task_name}_{YYYYMMDD}/research_brief.md` — campaign strategy context
## Workflow
### Step 1: Design Philosophy
Create a visual design philosophy (.md file) for this campaign's poster aesthetic.
Save it to `outputs/{task_name}_{date}/ads/posters/design_philosophy.md`
The philosophy should:
- Name the aesthetic movement (1-2 words, e.g. "Domestic Geometry" or "Maintenance Modernism")
- Be 4-6 paragraphs articulating how the philosophy manifests through space, form, color, scale, rhythm, composition
- Emphasize: visual expression over text, spatial communication, artistic interpretation
- Stress meticulous craftsmanship
- Brand palette as foundation
### Step 2: Express as Poster Art
Using the philosophy, create each poster as a .png file. For each:
1. Write a Node.js script that builds HTML and screenshots with Playwright
2. Treat each poster as an ART OBJECT — 90% visual design, 10% essential text
3. Use repeating patterns, perfect geometric shapes, systematic visual language
4. Typography is minimal and design-forward — sparse labels, bold single phrases, never paragraphs
5. The campaign's hook text appears as a visual accent, not a headline block
6. Incorporate app screenshots inside the phone frame PNG (transparent screen area)
7. Every element contained within canvas boundaries with proper margins
8. The result should look like it could hang in a gallery or appear in a design magazine
9. App icon MUST appear in every poster near the branding or CTA area
### CRITICAL Layout Rule: Phone Must NOT Cover Text
The phone mockup and text must occupy separate zones — NEVER overlapping.
Use a **three-zone vertical layout:**
- **Top zone (15-30% of canvas):** Headline text. No phone here.
- **Middle zone (40-55% of canvas):** Phone mockup, centered. No text overlapping this zone.
- **Bottom zone (15-25% of canvas):** Subtext, CTA, branding. No phone here.
For 1080x1920 (9:16) posters:
- Headline: top 290-380px (y: 60 to ~380)
- Phone: centered vertically in middle band (y: ~420 to ~1400), max width 55% of canvas
- Subtext + CTA: bottom 400px (y: ~1520 to ~1860)
For 1080x1080 (1:1) posters:
- Headline: top 200px
- Phone: center, max width 45%, max height 500px
- Subtext + CTA: bottom 250px
**Before rendering, calculate bounding boxes for all elements and assert no overlap between
the phone rect and any text rect. If they overlap, shrink the phone or increase spacing.**
### Step 3: Write Manifests
Save `outputs/{task_name}_{date}/ads/posters/manifest.json` listing all poster ads.
Also create the combined `outputs/{task_name}_{date}/ads/ad_manifest.json` merging both
the Gemini manifest (`ads/gemini/manifest.json`) and the poster manifest.
## Output
Generate at least 4 posters:
- 2x Instagram (1 feed 1080x1080, 1 stories 1080x1920)
- 2x TikTok cover images (1080x1920)
Save to `outputs/{task_name}_{date}/ads/posters/`
Name files: `poster_{platform}_{hook}_{dimensions}.png`
## MANDATORY Typography & Sizing Rules (Social Media Readability)
These are viewed on phones at arm's length. Text that looks fine on a monitor is INVISIBLE in a feed.
- **Minimum font size: 44px** — NO text below 44px, ever.
- **Text scale multiplier: 1.13x** — apply 113% to all font sizes before rendering.
- **Hero headline: 75-100px effective** (66-88px raw × 1.13)
- **Body/subheadline: 44px minimum**
- **Minimum gap between stacked text:**
- Giant (100-140px) above + Large below: 50px gap
- Large above + Medium below: 40px gap
- Medium + Medium: 30px gap
- Absolute minimum gap: 20px
- **2x internal render recommended:** Render at 2160×3840 (9:16) or 2160×2160 (1:1), downsample to 1080.
- **CTA block at fixed bottom position:** CTA_BOTTOM_MARGIN = 60px from bottom edge.
- **Overflow check:** Calculate total vertical extent before rendering. If content exceeds canvas, reduce phone width first.
## Platform Dimensions
| Platform | Format | Width | Height | Aspect Ratio |
|----------|--------|-------|--------|--------------|
| Instagram | Feed Post | 1080 | 1080 | 1:1 |
| Instagram | Story/Reel | 1080 | 1920 | 9:16 |
| TikTok | Feed | 1080 | 1920 | 9:16 |
## Playwright Usage
- Set device scale factor to 1 (exact pixel dimensions)
- Use `waitForLoadState('networkidle')` before screenshots
- Disable animations for consistent renders
- If fonts fail to load, use system fonts as fallback
- Set viewport to match ad dimensions exactly
## Quality Checklist
- [ ] All knowledge files read before starting
- [ ] Design philosophy created and saved
- [ ] At least 4 poster variants produced
- [ ] All dimensions match platform specs
- [ ] Typography follows minimum size rules
- [ ] Phone mockups use real screenshots + phone frame
- [ ] App icon visible in every poster
- [ ] HTML source files saved alongside PNGs
- [ ] posters/manifest.json and ads/ad_manifest.json are valid JSON
+1
View File
@@ -27,6 +27,7 @@ model App {
brandIdentity String?
productInfo String?
platformGuidelines String?
stylePreferences String? // JSON: { liked: [...], disliked: [...] }
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
campaigns Campaign[]
+35
View File
@@ -0,0 +1,35 @@
import Database from 'better-sqlite3';
import { randomBytes } from 'crypto';
const db = new Database('prisma/data/marketing.db');
const id = randomBytes(12).toString('hex').slice(0, 25);
const config = {
goal: "app_downloads",
keyMessage: "Reflect v1.1 goes beyond mood tracking. Guided reflections powered by CBT and ACT therapy frameworks help you understand why you feel the way you do — not just log it. AI-generated reports turn weeks of check-ins into actionable insights you can share with your therapist. Weather tracking reveals how your environment shapes your emotions. All private, all on-device.",
socialProof: "Loved by thousands of mindful users on iOS. Featured for beautiful design with 12 curated themes.",
targetAudience: "Adults 18-45 interested in mental wellness, self-reflection, journaling, and therapy. Secondary: therapists and counselors looking for client homework tools. People who've tried mood trackers before but found them too shallow.",
visualDirection: "warm",
competitorApps: "Daylio, Pixels Year in Mood, Moodfit, Bearable",
variations: 5,
useTrendReport: true,
screenshots: [],
};
const stmt = db.prepare(`
INSERT INTO Campaign (id, name, status, platforms, config, appId, createdAt, updatedAt)
VALUES (?, ?, ?, ?, ?, ?, datetime('now'), datetime('now'))
`);
stmt.run(
id,
"Reflect v1.1 — Guided Therapy & AI Insights Launch",
"draft",
JSON.stringify(["instagram", "tiktok"]),
JSON.stringify(config),
"reflect001"
);
console.log("Created campaign:", id);
db.close();
+179
View File
@@ -0,0 +1,179 @@
import Database from 'better-sqlite3';
const db = new Database('prisma/data/marketing.db');
const brandIdentity = `# Brand Identity: Reflect
## 1. Brand Personality
Reflect is a quiet, meditative companion for your inner world. We create space for self-awareness without judgment — helping users notice their emotional patterns and grow through gentle observation, not productivity pressure.
Core traits:
- Meditative — calm, unhurried, intentional
- Elegant — beautiful design is the product, not decoration
- Introspective — curious about feelings, never prescriptive
- Non-judgmental — every mood is valid, no "bad" days
- Private — your feelings are yours alone
## 2. Tone & Voice
| Attribute | Guidance |
|-----------|----------|
| Register | Soft, poetic, like a journal entry |
| Energy | Calm and still, never urgent |
| Humor | Warm and gentle, never sarcastic |
| Confidence| Reassuring, inviting |
| Length | Short, breathing sentences with whitespace |
Write like this: "A quiet space for your inner world."
Not like this: "Track your moods with our powerful analytics dashboard!"
## 3. CTA Patterns
- Soft, inviting language — never pushy
- Approved CTAs: "Begin reflecting", "Try Reflect free", "Start your journal", "Download Reflect"
- Never use: "Buy now", "Don't miss out", "Limited time", "Act fast"
## 4. Emoji Usage
- Approved: 🌿 ✨ 🌙 💫 🪷 🫧
- Max per post: 2
- Never start a caption with an emoji
- Prefer no emoji over forced emoji
## 5. Hashtag Strategy
- Primary (always include): #Reflect #MoodTracking
- Secondary (rotate): #MentalWellness #DailyCheckIn #EmotionalHealth #Mindfulness #SelfAwareness #MoodJournal #InnerPeace
- Never use: #Follow4Follow #Grind #Hustle
## 6. Brand Colors
- Primary: #3d5a4c (sage green — calm, grounded)
- Accent: #c4956b (warm bronze — human warmth)
- Background: #f5f1eb (warm cream — soft, inviting)
- Dark mode: #141210 (deep charcoal — restful)
- Mood palette: Red (horrible) → Orange (bad) → Blue (average) → Yellow (good) → Green (great)
## 7. Typography
- Headlines: Cormorant Garamond (serif, italic, weight 300-500) — poetic, editorial
- Body: Outfit (sans-serif, weight 300-600) — clean, modern
- Style: generous whitespace, breathing room between elements
## 8. Visual Aesthetic
- Japanese minimalism meets modern iOS
- Soft rounded corners (24-28px radius)
- Subtle grain texture overlays
- Floating, breathing animations
- Light/dark mode with warm tones in both
- 12 curated themes (Zen Garden, Synthwave, Celestial, Editorial, Mixtape, Bloom, Heartfelt, Minimal, Luxe, Forecast, Playful, Journal)`;
const productInfo = `# Product & Campaign Knowledge: Reflect
## 1. Product Overview
| Attribute | Details |
|-----------|---------|
| Product Name | Reflect |
| Category | Mental Wellness & Mood Tracking |
| Target Audience | Introspective adults 18-45 who value mental wellness, self-reflection, and beautiful design |
| Brand Positioning | A quiet space for your inner world — the most beautiful mood journal on iOS |
| Platforms | iOS, watchOS, widgets, Live Activities |
| Company | 88 Oak Apps |
| Bundle ID | com.88oakapps.reflect |
## 2. Key Features
| Feature | Benefit | Proof Point |
|---------|---------|-------------|
| Daily Mood Check-In | One tap to log how you feel on a 5-point scale | Horrible → Bad → Average → Good → Great with custom color coding |
| AI-Powered Insights | Understand emotional patterns you can't see yourself | Uses on-device Apple Intelligence — private, no data leaves your phone |
| 12 Curated Themes | Every check-in feels intentional and personal | Zen Garden, Synthwave, Celestial, Editorial, Mixtape, Bloom, Heartfelt, Minimal, Luxe, Forecast, Playful, Journal |
| Multiple Visualizations | See your mood from every angle | Day view (chronological), Month view (calendar grid), Year view (aggregate stats) |
| Apple Watch Companion | Log moods from your wrist | Complications and standalone app |
| Widgets & Lock Screen | See your mood at a glance | Home screen widgets, Lock Screen widgets, Live Activity mood streaks |
| iCloud Sync | Your journal everywhere | Automatic CloudKit syncing across all Apple devices |
| HealthKit Integration | Connect mood to physical health | Optional sync to Apple Health |
| Biometric Security | Private by default | Face ID / Touch ID lock |
| Full Customization | Make it yours | 4 color schemes, 7 icon packs, multiple view styles |
## 3. Campaign Direction
- Goal: Drive app downloads on iOS
- Key message: A quiet space for your inner world. Track your mood daily, see patterns emerge, and understand yourself better — all in the most beautiful journal on iOS.
- Social proof: Loved by thousands of mindful users
- Visual direction: Warm, meditative, editorial — cream backgrounds with sage green and bronze accents. Showcase the stunning theme system.
- Hero screenshots: Day view with mood entries, month calendar grid, theme showcase
- Target: Adults 18-45 interested in mindfulness, journaling, mental health, self-improvement, and beautiful app design
## 4. Competitive Advantages
- Stunning design with 12 cohesive themes (not just color swaps — each theme changes icons, layouts, animations, and mood entry styles)
- AI insights powered by on-device Apple Intelligence (100% private)
- Full Apple ecosystem: iPhone, Watch, widgets, Lock Screen, Live Activities, Siri, Shortcuts, Control Center
- Privacy-first: data stays on your devices and iCloud, developers never see mood data
- One-tap simplicity: no friction, no overthinking
## 5. Competitors
- Daylio — functional but dated design, no AI insights, no Apple Watch
- Pixels Year — year-only view, no daily detail, no themes
- Moodfit — clinical feel, too many features, overwhelming
- Bearable — symptom tracker disguised as mood app, complex
## 6. Subscription Model
- 30-day free trial (full feature access)
- Monthly subscription
- Yearly subscription (best value)`;
const platformGuidelines = `# Platform Guidelines
## 1. Platform Overview
| Platform | Content Type | Primary Tone | Hashtags |
|----------|-------------|--------------|----------|
| Instagram | Feed posts, Stories, Reels | Meditative, editorial, beautiful | Required (3-5) |
| TikTok | Short video ads | Authentic, gentle, relatable | Required (3-5 trending) |
| Nextdoor | Neighborhood posts, display ads | Warm, wellness-focused | None |
## 2. Instagram
### Specs
| Format | Dimensions | Aspect Ratio |
|--------|-----------|--------------|
| Feed Post | 1080x1080 px | 1:1 |
| Story/Reel | 1080x1920 px | 9:16 |
### Caption Guidelines
- Poetic hook in first line (before "more" truncation)
- Structure: Reflective hook → Feature value → Soft CTA → line break → Hashtags
- Max 2200 chars, aim for 100-200
- Tone: like a journal entry, not a sales pitch
### Visual Style
- Warm cream backgrounds (#f5f1eb) or deep dark (#141210)
- Sage green (#3d5a4c) and bronze (#c4956b) accents
- Cormorant Garamond serif for headlines
- Generous whitespace — let the design breathe
- Showcase app themes and mood visualizations
- Phone mockups in clean, minimal settings
## 3. TikTok
### Specs
| Format | Dimensions | Length |
|--------|-----------|--------|
| Video Ad | 1080x1920 px (9:16) | 9-15s sweet spot |
### Style Rules
- Soft, contemplative feel (not aggressive trend-chasing)
- Gentle text overlays with serif typography
- Hook: relatable emotional moment in first 2 seconds
- Show the beauty of the app — theme switching, mood logging, calendar views
- Ambient music, not trending audio (unless it fits the meditative brand)
## 4. Nextdoor
### Specs
| Format | Dimensions |
|--------|-----------|
| Spotlight Ad | 1200x1200 px |
| Display Ad | 1200x628 px |
### Style Rules
- Warm, wellness-focused tone
- No hashtags
- Frame as self-care / daily ritual
- CTA: "Try Reflect free" or "Start your mood journal"
- Connect to seasonal wellness ("As the seasons change, check in with yourself")`;
const stmt = db.prepare('UPDATE App SET brandIdentity = ?, productInfo = ?, platformGuidelines = ? WHERE slug = ?');
stmt.run(brandIdentity, productInfo, platformGuidelines, 'reflect');
console.log('Updated Reflect with all details');
db.close();
+31
View File
@@ -0,0 +1,31 @@
#!/bin/bash
set -e
echo "Starting Marketing Command Center..."
echo ""
# Start all services
docker compose up -d
echo "Waiting for services to start..."
sleep 5
# Set up database
docker compose exec app npx prisma db push
docker compose exec app npx prisma db seed
echo ""
echo "========================================="
echo " Marketing Command Center is ready!"
echo "========================================="
echo ""
echo " Dashboard: http://localhost:3000"
echo " Postiz: http://localhost:5000"
echo ""
echo " Login with your ADMIN_EMAIL / ADMIN_PASSWORD"
echo " from your .env file."
echo ""
echo " Next steps:"
echo " 1. Connect your Instagram + TikTok accounts in Postiz"
echo " 2. Create your first campaign in the dashboard"
echo ""