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:
@@ -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)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
@@ -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
@@ -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"]
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(", ")} ·{" "}
|
||||
{campaign._count.assets} assets ·{" "}
|
||||
<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>·</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>·</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ export async function PATCH(
|
||||
brandIdentity: body.brandIdentity ?? undefined,
|
||||
productInfo: body.productInfo ?? undefined,
|
||||
platformGuidelines: body.platformGuidelines ?? undefined,
|
||||
stylePreferences: body.stylePreferences ?? undefined,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -55,6 +55,7 @@ export async function POST(
|
||||
brandIdentity: app.brandIdentity,
|
||||
productInfo: app.productInfo,
|
||||
platformGuidelines: app.platformGuidelines,
|
||||
stylePreferences: app.stylePreferences,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -42,6 +42,7 @@ export async function POST(
|
||||
brandIdentity: app.brandIdentity,
|
||||
productInfo: app.productInfo,
|
||||
platformGuidelines: app.platformGuidelines,
|
||||
stylePreferences: app.stylePreferences,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+108
-15
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'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
@@ -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:
|
||||
|
||||
@@ -28,4 +28,5 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
|
||||
signIn: "/login",
|
||||
},
|
||||
secret: process.env.NEXTAUTH_SECRET,
|
||||
trustHost: true,
|
||||
});
|
||||
|
||||
+132
-16
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"mcp__nanobanana__generate_image",
|
||||
"mcp__nanobanana__gemini_generate_image",
|
||||
"mcp__nanobanana__(.*)",
|
||||
"Bash(*)",
|
||||
"Read(*)",
|
||||
"Write(*)",
|
||||
"Grep(*)",
|
||||
"Glob(*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
+9
-8
@@ -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.
|
||||
|
||||
|
||||
@@ -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"}]
|
||||
@@ -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 |
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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[]
|
||||
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
Executable
+31
@@ -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 ""
|
||||
Reference in New Issue
Block a user