ContentForge is a full-stack application for topic-driven social content: it generates quotes (via a local LLM), vertical images (Stable Diffusion or Unsplash), composites quote text onto the image, optionally renders a short video (Ken Burns–style), and can post to configured platforms through a plugin layer.
This README is written for full-stack developers who need to run, extend, or debug the system—especially the async generation pipeline (API → DB → Celery → files on disk).
flowchart LR
subgraph client [Browser]
UI[React + Vite]
end
subgraph compose [Docker Compose]
FE[frontend :5173]
API[backend FastAPI :8000]
W[Celery worker]
R[Redis]
DB[(MySQL 8)]
OL[Ollama]
end
UI --> FE
FE -->|"/api proxy"| API
API --> DB
API -->|enqueue| R
W --> R
W --> DB
W --> OL
W -->|read/write| DATA[./data volume]
W -->|optional SD weights| SD[/models mount/]
| Service | Role |
|---|---|
| frontend | React SPA; Vite dev server proxies /api and /health to the backend (in Compose: http://backend:8000). |
| backend | FastAPI: CRUD, settings, generation triggers, static file routes for content images/videos. Does not run heavy image generation. |
| worker | Same Python image as backend; runs Celery with concurrency=1 so only one generation task loads SD/RAM at a time. |
| redis | Celery broker and result backend; also Redis pub/sub so the API can broadcast generation events to browsers over WebSockets. |
| db | MySQL: topics, content items, generation jobs, app settings, platform accounts, post history. |
| ollama | Local LLM HTTP API (/api/generate) for quotes, SD prompt enrichment, stock-photo search phrases, and social captions. |
- Backend: Python 3.11, FastAPI, SQLAlchemy 2, Alembic, Celery 5, httpx, Pillow, diffusers/torch (in worker), MoviePy (video).
- Frontend: React, React Router, Vite, Tailwind-style utility classes (
cf-*), axios. - Infra: Docker Compose; optional
docker-compose.gpu.ymlfor NVIDIA hosts; Redis pub/sub for live UI updates.
| Path | Purpose |
|---|---|
contentforge/ |
Backend + worker code (single package: main.py, api/, models/, tasks/, services/, alembic/). |
frontend/ |
Vite + React UI. |
docker-compose.yml |
Default stack (CPU-friendly SD; Ollama CPU). |
docker-compose.gpu.yml |
Optional overlay for GPU (Linux + NVIDIA). |
data/ |
Runtime user data (mounted to /app/data in containers): images/, backgrounds/, videos/, topic_refs/. |
models/sd15/ |
Optional host mount for Stable Diffusion 1.5 diffusers weights (read-only in worker). |
.env |
Secrets and URLs (not committed). See .env.example. |
-
Copy environment file
cp .env.example .env
Edit MySQL passwords,
SECRET_KEY, and any optional keys (e.g.UNSPLASH_ACCESS_KEY). -
Stable Diffusion weights (optional, for topics that use Stable Diffusion backgrounds)
The worker expects a diffusers layout at the path stored in Settings → Diffusers model path (default in DB is often
/models/stable-diffusion). The compose file mounts./models/sd15at/models/sd15; point Settings to that path or adjust the mount. -
Pull an Ollama model (e.g. on first run)
docker compose exec ollama ollama pull llama3.2In Settings, pick the model by name (the UI lists installed models from Ollama’s
/api/tags; pull new ones withollama pull …then refresh). -
Start stack
docker compose up -d --build
-
Run migrations (idempotent)
docker compose exec backend alembic upgrade head -
Open the app
- UI:
http://localhost:5173 - API:
http://localhost:8000 - Health:
http://localhost:8000/health
- UI:
Loaded from .env into backend and worker (env_file in Compose). Names map to contentforge/config.py (pydantic-settings).
| Variable | Purpose |
|---|---|
DATABASE_URL |
SQLAlchemy URL (MySQL in Docker: host db). |
SECRET_KEY |
App secret (e.g. credential encryption helpers). |
DATA_DIR |
Filesystem root for media; default /app/data in containers. |
OLLAMA_BASE_URL |
e.g. http://ollama:11434 in Compose. |
CELERY_BROKER_URL / CELERY_RESULT_BACKEND |
Redis URLs. |
PUBLIC_BASE_URL |
Optional fixed public HTTPS origin for media URLs (Instagram, TikTok). |
NGROK_LOCAL_API_URL |
Optional; if PUBLIC_BASE_URL is empty, the app queries this ngrok agent URL (…/api/tunnels) when building media URLs. Compose: http://ngrok:4040. Host ngrok: http://host.docker.internal:4040. |
NGROK_AUTHTOKEN |
Required for the optional ngrok Compose service (--profile ngrok). |
NGROK_DOMAIN |
Your reserved ngrok hostname (e.g. myapp.ngrok-free.app). Passed to ngrok http --domain=… so the public URL is stable. |
UNSPLASH_ACCESS_KEY |
Required if any topic uses Unsplash for backgrounds. |
SD_INFERENCE_STEPS_GPU |
More steps on CUDA (worker). |
FORCE_SD_CPU |
Force CPU even if GPU visible (debug). |
MySQL variables (MYSQL_*) are for the db service image; DATABASE_URL must align with them.
- Alembic lives under
contentforge/alembic/. Revisions include initial schema, job progress, topic style reference, generation retry limit, and background source. - Always run
alembic upgrade headafter pulling migrations. - Singleton
app_settingsrow (id = 1) holds Ollama model name, diffusers path, default image style, caption CTA, and generation retry limit.background_source(diffusers|unsplash) is stored per topic (not onapp_settings).
Generation is asynchronous: the API creates DB rows and enqueues Celery tasks. The UI polls GET /api/jobs/{id} for status, progress_percent, stage, and errors while jobs run. When a generation task succeeds or fails, the worker publishes an event to Redis; the API WebSocket at /api/ws forwards it to connected clients so the Content Library, Dashboard, and job panels refetch without waiting for the next poll.
ContentItem— One piece of content:quote_text,quote_author, paths underdata_dirforbackground_path,image_path(composed), optionalvideo_path,status(draft|approved|rejected|posted),generation_model(Ollama),image_model(diffusers path or"unsplash").GenerationJob— Tracks one run:job_type,status(queued→running→done|failed),progress_percent,stage,error_message, links totopic_idandcontent_item_id.
| Endpoint | Celery task | job_type |
What it does |
|---|---|---|---|
POST /api/generate |
run_full_generation |
full |
Quote → background → composite → optional video. |
POST /api/generate/quote |
run_quote_only |
quote |
Quote (and mood) only; no image. |
POST /api/generate/image |
run_image_only |
image |
Image pipeline for an existing item that already has quote_text. |
POST /api/generate/blog |
run_blog_generation |
blog |
Long-form Markdown blog (see below). |
Batch generate creates N content items and N jobs in one request (count in body).
- Plan —
llm_service.classify_blog_topic_sync()asks Ollama for JSON:topic_kind(technical|functional|general),mermaid_max(0–2), and a one-sentencecontent_focus. Technical leans toward systems and depth; functional toward workflows and outcomes; general is balanced. On parse errors, a safe default plan is used. - Write —
generate_blog_post_sync(topic, model, plan=…)produces Markdown whose structure and optional Mermaid usage follow that plan (no fenced Mermaid blocks whenmermaid_maxis 0; otherwise up to one or two optional diagrams). - Render —
blog_service.process_blog_markdown()still turns any Mermaid blocks into PNGs via Kroki when present.
High-level flow:
sequenceDiagram
participant API as FastAPI
participant DB as MySQL
participant Q as Redis/Celery
participant W as Worker
participant O as Ollama
participant SD as SD or Unsplash
participant FS as data/
API->>DB: ContentItem + GenerationJob (queued)
API->>Q: run_full_generation.delay(job_id)
W->>DB: job running, stages
W->>O: generate_quote_sync
W->>DB: save quote, mood
W->>O: enrich_sd_prompt_sync
alt background_source diffusers
W->>SD: generate_background (txt2img or img2img)
else background_source unsplash
W->>O: stock_photo_search_query_sync
W->>SD: Unsplash HTTP + crop JPEG
end
W->>FS: backgrounds/{id}_background.jpg
W->>FS: composite_quote → images/{id}_composed.jpg
opt include_video
W->>FS: videos/{id}.mp4
end
W->>DB: job done, paths on item
Step-by-step:
-
Job lifecycle — Job marked
running;stagestrings update throughout (e.g. “Writing quote”, “Refining image prompt”, “Generating background”, “Compositing text”, “Rendering video”, “Complete”). -
Quote —
llm_service.generate_quote_sync(topic, ollama_model)calls Ollama with JSON output: quote, author, mood. Stored onContentItem;generation_modelset to the Ollama model name. -
Prompt package —
_prepare_background_prompts()callsenrich_sd_prompt_sync()so Ollama returns a structuredvisualfragment (and optionalnegative_extra) for abstract, no-people backgrounds. If enrichment fails, the worker falls back totopic.image_style+ mood template (still SD-oriented rules). -
Background file —
_produce_background()branches on the topic’sbackground_source:diffusers—image_service.generate_background(): loads StableDiffusionPipeline or StableDiffusionImg2ImgPipeline if the topic has a style reference (topic_refs/...underdata_dir). Progress callbacks map diffusion steps intoprogress_percent(roughly 26–86% for full job). Output:backgrounds/{content_id}_background.jpg(default dimensions 1080×1920 portrait). On failure (missing model, etc.), a gradient placeholder may be written (seeimage_service).unsplash— RequiresUNSPLASH_ACCESS_KEY. Ollama produces a short stock search query (stock_photo_search_query_sync). The worker searches Unsplash (portrait), picks a result, triggers download tracking, fetches the image, cover-crops to target size, saves the same relativebackgrounds/...path.ContentItem.image_modelis set to"unsplash".
-
Composite —
image_service.composite_quote()draws a center-weighted scrim and vertically centered quote + author text (DejaVu fonts in container), writesimages/{id}_composed.jpg. -
Video (optional) — If
include_videois true,video_service.make_ken_burns_video()buildsvideos/{id}.mp4from the composed image. -
Completion — Job
status = done,progress_percent = 100,completed_atset.
- Quote-only — Same quote generation; updates item; no SD/Unsplash/composite/video.
- Image-only — Assumes
quote_textexists; uses a placeholder mood (contemplative) in code for prompt path; otherwise same background + composite flow as full (no new quote).
generation_retry_limit(Settings, 0–10): on normal Python exceptions inside the task, the worker can retry the same logical job up tolimit + 1total attempts, withstagelike “Retry 2/3”.- Worker process death (e.g. SIGKILL from OOM during SD) is not a clean retry: Celery’s
task_failurehandler (tasks/celery_app.py) marks theGenerationJobfailedif it was still non-terminal, with a message that often mentions memory.task_acks_lateandtask_reject_on_worker_lostreduce silent loss of tasks; the job row still reflects failure for the UI.
tasks.post_content.post_to_platform builds a caption via generate_caption_sync (Ollama + topic + quote + CTA), then calls a platform plugin with the video or image path. Not part of the “generation pipeline” above but uses the same data_dir files.
All JSON routers are mounted under /api (see main.py):
| Prefix | Concern |
|---|---|
/api/topics |
Topics CRUD, optional per-topic style reference image upload. |
/api/content |
List/get/patch/delete content; serve binary image/video; batch zip. |
/api/generate |
Trigger full / quote / image generation (see above). |
/api/jobs |
Job status polling. |
/api/settings |
App settings get/patch. |
/api/llm |
List installed Ollama models (/api/tags proxy) for Settings. |
/api/ws |
WebSocket — optional client ping / server pong; server pushes job_done after generation Celery tasks finish (success or handled failure). |
/api/platforms, /api/accounts, /api/post, /api/post-history |
Social integrations. |
Unauthenticated in default dev layout; tighten before production.
Meta and TikTok fetch your media from a public HTTPS URL. Use a reserved ngrok domain so that URL stays the same across restarts (simpler TikTok URL-prefix verification and fewer moving parts).
-
In the ngrok dashboard, create a static domain (free tier includes a
*.ngrok-free.appname) and copy your authtoken. -
In
.envsetNGROK_AUTHTOKEN,NGROK_DOMAIN(e.g.myapp.ngrok-free.app), andNGROK_LOCAL_API_URL=http://ngrok:4040. -
Start the tunnel with the Compose profile (the service runs
ngrok http --domain=$NGROK_DOMAIN backend:8000):docker compose --profile ngrok up -d
Resolving the URL in the app
- Recommended: Leave
PUBLIC_BASE_URLempty and setNGROK_LOCAL_API_URL. On each post, the API/worker callsGET …/api/tunnelsand uses your statichttps://…tunnel URL (always the same hostname whileNGROK_DOMAINis unchanged). - Alternative: Set
PUBLIC_BASE_URL=https://myapp.ngrok-free.appto match your reserved domain and skip tunnel discovery (noNGROK_LOCAL_API_URLneeded for URL building).
If ngrok runs on the host instead of Compose, point NGROK_LOCAL_API_URL at http://host.docker.internal:4040 and start ngrok with the same --domain flag toward port 8000.
If you run npm run dev on the host, point the Vite proxy at a reachable backend (e.g. change vite.config.js target to http://127.0.0.1:8000 when the API is exposed from Docker on port 8000). The /api proxy enables WebSockets (ws: true) so /api/ws works for live job events during dev.
Production / reverse proxy: If you terminate TLS or proxy in front of the API, enable WebSocket pass-through (e.g. Upgrade / Connection headers) for paths used by the SPA.
- Settings — Choose the Ollama model by name (free text + suggestions from installed models, with size / modified time when Ollama returns them). Pick a Stable Diffusion diffusers path via common presets or a custom container path. Save persists to
app_settings. - Topics — Content style is a preset list (voice/tone for quotes, captions, blog) with optional custom value. Image mood / visual style uses presets or custom text for SD/Unsplash hints. Background source (Stable Diffusion vs Unsplash) is per topic; optional style reference image applies to SD.
- Memory — SD img2img + VAE decode is heavy on CPU Docker; the worker uses reduced inference resolution on CPU and
shm_size: 4gbto mitigate OOM. Prefer GPU compose on Linux when possible. - Concurrency — Worker
--concurrency=1: raising it without enough RAM can spawn multiple SD loads and trigger SIGKILL. - Unsplash — Respect Unsplash API guidelines and photographer attribution for public posts.
- Plugins —
contentforge/plugins/is loaded at startup (load_plugins()); posting behavior is extensible per platform.
- Plugin —
plugins/tiktok/: Direct Post withPULL_FROM_URL(TikTok fetches your MP4 from a public URL). - Video only — Image-only items cannot be posted to TikTok; generate with Include video or extend the plugin for photo posting later.
- OAuth — Create a TikTok developer app, implement login to obtain a user access token with scopes such as
video.publish,user.info.basic(validation), and follow TikTok’s product/audit rules (unaudited clients may be limited, e.g. private-only posting). PUBLIC_BASE_URL— Must be HTTPS and reachable by TikTok’s servers. Register and verify the URL prefix / domain in the developer portal (media transfer guide); otherwisePULL_FROM_URLreturnsurl_ownership_unverified.- Privacy — The Platforms form asks for a privacy level; saving an account checks it against
/v2/post/publish/creator_info/query/so it matches the creator’s allowed options.
On a suitable Linux host with NVIDIA drivers:
docker compose -f docker-compose.yml -f docker-compose.gpu.yml up -d --build(Adjust paths and device requests per your docker-compose.gpu.yml.)
This project is licensed under the MIT License.