diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..fa09098 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,16 @@ +.git +.gitignore +node_modules +dist +src/ui/dist +.env +.env.dev +.env.test +.env.age +facebook_posts +reels +shorts +tts_previews +post_media +tmp_voice.wav +*.wav diff --git a/.env.age b/.env.age new file mode 100644 index 0000000..e216a98 Binary files /dev/null and b/.env.age differ diff --git a/.env.example b/.env.example index 0a07402..3f4f3fd 100644 --- a/.env.example +++ b/.env.example @@ -1,12 +1,15 @@ OPENAI_API_KEY=your_openai_api_key NODE_ENV=production +UI_PORT=3333 # Use .env.dev for development and .env.test for tests (see package.json scripts) +# Scheduler cron expression (default every 4 hours) +CRON=0 */4 * * * -# Postgres connection string +# Postgres connection string (host-based dev default; prod compose overrides to postgres service) DATABASE_URL=postgres://ai_farm_brain:ai_farm_brain_password@localhost:5432/funrang_dev -# Redis (BullMQ) +# Redis (host-based dev default; prod compose overrides to redis service) REDIS_URL=redis://localhost:6379 # RSS ingest concurrency diff --git a/.gitignore b/.gitignore index bb7fc7c..a62c59a 100644 --- a/.gitignore +++ b/.gitignore @@ -4,7 +4,7 @@ post_media .env .env.dev .env.test -reels +/reels/ dist shorts *.wav diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..0d3c0c9 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,34 @@ +FROM node:20-bookworm-slim AS builder + +WORKDIR /app + +COPY package.json package-lock.json ./ +RUN npm ci + +COPY . . +RUN npm run build + +FROM node:20-bookworm-slim AS runtime + +RUN apt-get update \ + && apt-get install -y --no-install-recommends ffmpeg python3 python3-pip ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +# Local STT runtime for REEL_STT_PROVIDER=local. +RUN python3 -m pip install --no-cache-dir faster-whisper + +WORKDIR /app + +COPY package.json package-lock.json ./ +RUN npm ci --omit=dev + +COPY --from=builder /app/dist ./dist +COPY --from=builder /app/migrations ./migrations +COPY --from=builder /app/src/ui ./src/ui +COPY --from=builder /app/scripts/faster_whisper_words.py ./scripts/faster_whisper_words.py + +RUN mkdir -p facebook_posts reels shorts tts_previews post_media + +EXPOSE 3333 + +CMD ["sh", "-lc", "NODE_ENV=production node dist/scripts/migrate.js && NODE_ENV=production node dist/src/server.js"] diff --git a/README.md b/README.md index b7cc649..92dc65a 100644 --- a/README.md +++ b/README.md @@ -22,10 +22,63 @@ Funrang is a modern social content engine for social media managers. It discover ## Status Local MVP with working UI, job system, post review, and Facebook publishing. +## One-command setup (Docker, production-style) +### Prereqs +- Docker + Docker Compose plugin (`docker compose`) + +### 1) Create env +```bash +cp .env.example .env +``` +- Set at least `OPENAI_API_KEY` in `.env`. +- Keep `DATABASE_URL`/`REDIS_URL` as-is for Compose; `docker-compose.prod.yml` overrides them to internal service URLs. + +### 2) Start everything +```bash +docker compose -f docker-compose.prod.yml up --build -d +``` +- App: `http://localhost:3333` +- The production image compiles TypeScript during build and runs emitted JavaScript (`node`), not `tsx`. + +### 3) Stop everything +```bash +docker compose -f docker-compose.prod.yml down +``` + +### Optional ops tooling +- Start pgAdmin too: +```bash +docker compose -f docker-compose.prod.yml --profile ops up -d +``` +- pgAdmin: `http://localhost:5050` + ## Get up and running (dev) ### Prereqs -- Node 18+ -- Docker (for Postgres + pgAdmin + Redis) +- Node.js 18+ (Node 20+ recommended) +- Docker + Docker Compose plugin (`docker compose`) +- `ffmpeg` and `ffprobe` on your PATH (required for reels/shorts generation) +- OpenAI API key + +### OS-level dependencies +- Ubuntu/Debian: +```bash +sudo apt-get update +sudo apt-get install -y ffmpeg python3 python3-pip +``` +- macOS (Homebrew): +```bash +brew install ffmpeg python +``` +- Windows: +1. Install FFmpeg (make sure `ffmpeg`/`ffprobe` are on PATH). +2. Install Python 3 (with `pip` and PATH enabled). +3. Install Docker Desktop. + +### Optional (only if `REEL_STT_PROVIDER=local`) +- Install local Whisper runtime: +```bash +python3 -m pip install --user faster-whisper +``` ### 1) Install + env ```bash @@ -34,6 +87,7 @@ cp .env.example .env cp .env.example .env.dev cp .env.example .env.test ``` +- Set at least `OPENAI_API_KEY` in `.env.dev`. ### 2) Start local services ```bash @@ -49,13 +103,46 @@ npm run migrate:dev ```bash npm run ui ``` +- Open `http://localhost:3333` ### 5) Tests ```bash npm test ``` +### Quick checks (recommended) +```bash +ffmpeg -version +ffprobe -version +docker compose ps +``` + ### Notes - `.env.dev` is used by `npm run ui` - `.env.test` is used by `npm test` -- `docker-compose.yml` brings up Postgres (`funrang_dev`), pgAdmin, and Redis +- `.env` is used by `docker-compose.prod.yml` for app secrets/config. +- `docker-compose.yml` is the development infra stack (Postgres + pgAdmin + Redis, DB: `funrang_dev`). +- `docker-compose.prod.yml` is the production-style stack (app + Postgres + Redis, DB: `funrang_prod`, and pgAdmin when `--profile ops` is used). +- In prod compose, `DATABASE_URL`/`REDIS_URL` are set to `postgres`/`redis` service hosts automatically. +- If OAuth callbacks are needed from real providers, use a public callback URL (for example via ngrok) and set `FACEBOOK_REDIRECT_URI` / `YOUTUBE_REDIRECT_URI`. + +### Share `.env` securely with `age` +- Install `age`: +```bash +# Ubuntu/Debian +sudo apt-get install -y age + +# macOS +brew install age +``` +- Encrypt `.env` before sending: +```bash +age -p -o .env.age .env +``` +- Decrypt on receiver side: +```bash +age -d -o .env .env.age +``` +- Safety: send `.env.age` and passphrase in different channels. +- Safety: use a one-time strong passphrase. +- Safety: never commit `.env` or decrypted secrets to git. diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..8f77514 --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,94 @@ +services: + app: + build: + context: . + dockerfile: Dockerfile + container_name: ai-farm-brain-app + restart: unless-stopped + ports: + - "3333:3333" + env_file: + - .env + environment: + NODE_ENV: production + UI_PORT: 3333 + DATABASE_URL: postgres://ai_farm_brain:ai_farm_brain_password@postgres:5432/funrang_prod + REDIS_URL: redis://redis:6379 + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + healthcheck: + test: + [ + "CMD-SHELL", + "node -e \"fetch('http://127.0.0.1:3333').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))\"" + ] + interval: 10s + timeout: 5s + retries: 20 + volumes: + - facebook_posts_data:/app/facebook_posts + - reels_data:/app/reels + - shorts_data:/app/shorts + - tts_previews_data:/app/tts_previews + - post_media_data:/app/post_media + + postgres: + image: postgres:16 + container_name: ai-farm-brain-postgres + restart: unless-stopped + environment: + POSTGRES_USER: ai_farm_brain + POSTGRES_PASSWORD: ai_farm_brain_password + POSTGRES_DB: funrang_prod + volumes: + - pgdata:/var/lib/postgresql/data + - ./pg-init.sql:/docker-entrypoint-initdb.d/00-init.sql:ro + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ai_farm_brain -d funrang_prod"] + interval: 5s + timeout: 5s + retries: 10 + + redis: + image: redis:7-alpine + container_name: ai-farm-brain-redis + restart: unless-stopped + command: ["redis-server", "--save", "60", "1"] + volumes: + - redis_data:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 3s + retries: 20 + + pgadmin: + image: dpage/pgadmin4:8 + container_name: ai-farm-brain-pgadmin + profiles: ["ops"] + restart: unless-stopped + ports: + - "5050:80" + environment: + PGADMIN_DEFAULT_EMAIL: admin@local.dev + PGADMIN_DEFAULT_PASSWORD: admin123 + PGADMIN_CONFIG_SERVER_MODE: "False" + depends_on: + postgres: + condition: service_healthy + volumes: + - pgadmin_data:/var/lib/pgadmin + - ./pgadmin/servers.prod.json:/pgadmin4/servers.json:ro + +volumes: + pgdata: + pgadmin_data: + redis_data: + facebook_posts_data: + reels_data: + shorts_data: + tts_previews_data: + post_media_data: diff --git a/docs/CNAME b/docs/CNAME new file mode 100644 index 0000000..f758282 --- /dev/null +++ b/docs/CNAME @@ -0,0 +1 @@ +docs.funrang.com \ No newline at end of file diff --git a/docs/index.html b/docs/index.html index c33b7f4..5ad0525 100644 --- a/docs/index.html +++ b/docs/index.html @@ -24,7 +24,7 @@

Funrang

Contact

Support: fardin4work@gmail.com
- Website: https://funrang.app + Website: https://funrang.com

Last updated: February 13, 2026

diff --git a/migrations/0048_posts_job_id/down.sql b/migrations/0048_posts_job_id/down.sql new file mode 100644 index 0000000..f964b92 --- /dev/null +++ b/migrations/0048_posts_job_id/down.sql @@ -0,0 +1,5 @@ +DROP INDEX IF EXISTS idx_posts_org_job_id_created_at; + +ALTER TABLE posts + DROP CONSTRAINT IF EXISTS posts_job_id_fkey, + DROP COLUMN IF EXISTS job_id; diff --git a/migrations/0048_posts_job_id/up.sql b/migrations/0048_posts_job_id/up.sql new file mode 100644 index 0000000..8f46b5d --- /dev/null +++ b/migrations/0048_posts_job_id/up.sql @@ -0,0 +1,19 @@ +ALTER TABLE posts + ADD COLUMN IF NOT EXISTS job_id UUID; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'posts_job_id_fkey' + ) THEN + ALTER TABLE posts + ADD CONSTRAINT posts_job_id_fkey + FOREIGN KEY (job_id) REFERENCES jobs(id) + ON DELETE SET NULL; + END IF; +END $$; + +CREATE INDEX IF NOT EXISTS idx_posts_org_job_id_created_at + ON posts (organization_id, job_id, created_at DESC); diff --git a/migrations/0049_post_ai_generated_flag/down.sql b/migrations/0049_post_ai_generated_flag/down.sql new file mode 100644 index 0000000..1c14603 --- /dev/null +++ b/migrations/0049_post_ai_generated_flag/down.sql @@ -0,0 +1,2 @@ +ALTER TABLE posts + DROP COLUMN IF EXISTS is_ai_generated; diff --git a/migrations/0049_post_ai_generated_flag/up.sql b/migrations/0049_post_ai_generated_flag/up.sql new file mode 100644 index 0000000..ee13455 --- /dev/null +++ b/migrations/0049_post_ai_generated_flag/up.sql @@ -0,0 +1,6 @@ +ALTER TABLE posts + ADD COLUMN IF NOT EXISTS is_ai_generated BOOLEAN NOT NULL DEFAULT TRUE; + +UPDATE posts +SET is_ai_generated = TRUE +WHERE is_ai_generated IS NULL; diff --git a/package.json b/package.json index 1823993..e003420 100644 --- a/package.json +++ b/package.json @@ -6,11 +6,15 @@ "scripts": { "ui": "npm run ui:styles:watch & npm run ui:js:watch & DOTENV_CONFIG_PATH=.env.dev NODE_ENV=development tsx watch src/server.ts", "prod": "DOTENV_CONFIG_PATH=.env NODE_ENV=production tsx src/runner.ts", + "build:server": "tsc -p tsconfig.json --noCheck", + "build": "npm run ui:styles && npm run ui:js:build && npm run build:server", "reset-db:dev": "DOTENV_CONFIG_PATH=.env.dev NODE_ENV=development tsx scripts/reset-db.ts", "reset-db:prod": "DOTENV_CONFIG_PATH=.env NODE_ENV=production tsx scripts/reset-db.ts", "migrate": "tsx scripts/migrate.ts", "migrate:dev": "DOTENV_CONFIG_PATH=.env.dev NODE_ENV=development tsx scripts/migrate.ts", "migrate:prod": "DOTENV_CONFIG_PATH=.env NODE_ENV=production tsx scripts/migrate.ts", + "migrate:compiled": "NODE_ENV=production node dist/scripts/migrate.js", + "start:compiled": "NODE_ENV=production node dist/src/server.js", "ui:js:build": "vite build", "ui:js:watch": "vite build --watch", "ui:styles": "tailwindcss -i src/ui/styles/input.css -o src/ui/styles/tailwind.css", diff --git a/pgadmin/servers.prod.json b/pgadmin/servers.prod.json new file mode 100644 index 0000000..e4d730f --- /dev/null +++ b/pgadmin/servers.prod.json @@ -0,0 +1,13 @@ +{ + "Servers": { + "1": { + "Name": "funrang-prod", + "Group": "Servers", + "Host": "postgres", + "Port": 5432, + "MaintenanceDB": "funrang_prod", + "Username": "ai_farm_brain", + "SSLMode": "prefer" + } + } +} diff --git a/planning/p0-issues-draft.md b/planning/p0-issues-draft.md new file mode 100644 index 0000000..d2698a8 --- /dev/null +++ b/planning/p0-issues-draft.md @@ -0,0 +1,130 @@ +# P0 GitHub Issues Draft + +Use these to create issues quickly in GitHub. +Source board: `planning/tech-audit-board.md` + +--- + +## 1) P0: Protect private media routes behind auth/signed URLs + +**Why** +Generated media appears publicly reachable if URL is known. + +**Scope** +- Review static mounts and middleware order. +- Move private media behind auth and/or signed URLs. +- Preserve public assets that must stay public. + +**Files** +- `src/server.ts` + +**Acceptance criteria** +- Unauthenticated requests to private media return denied. +- Authenticated (or valid signed URL) access works. +- Automated test coverage added. + +--- + +## 2) P0: Harden sessions + CSRF + login rate limiting + +**Why** +Cookie/session posture is incomplete and mutating routes lack CSRF/rate-limit protections. + +**Scope** +- Ensure production cookies are `HttpOnly`, `Secure`, explicit expiry. +- Add CSRF protection for cookie-auth mutating endpoints. +- Add brute-force/login rate limiting. + +**Files** +- `src/auth/session.ts` +- auth routes/middleware + +**Acceptance criteria** +- Secure cookie policy enabled in production. +- CSRF required for mutating cookie-auth routes. +- Login attempts throttled with safe defaults. +- Tests added. + +--- + +## 3) P0: Replace OAuth state RNG with crypto-safe tokens + +**Why** +OAuth state currently uses non-cryptographic randomness. + +**Scope** +- Replace `Math.random` state generation with crypto-safe randomness. + +**Files** +- `src/routes/auth/facebook.ts` +- `src/routes/auth/youtube.ts` + +**Acceptance criteria** +- OAuth state generated from crypto RNG. +- Existing auth flow remains functional. +- Tests added/updated. + +--- + +## 4) P0: Patch invite-template XSS and media-fetch SSRF risks + +**Why** +- Invite template interpolation can allow HTML/script injection. +- Media downloader accepts arbitrary remote URLs. + +**Scope** +- Escape user-controlled invite variables before HTML interpolation. +- Add URL validation policy for media fetches. +- Block private/reserved IP ranges and unsafe targets. + +**Files** +- `src/routes/auth/local.ts` +- `src/api/posts.ts` + +**Acceptance criteria** +- XSS payloads are escaped. +- SSRF attempts to private/reserved ranges are rejected. +- Tests cover both protections. + +--- + +## 5) P0: Reinstate TypeScript safety gate and remove --noCheck release path + +**Why** +Build currently bypasses TypeScript checking in release path. + +**Scope** +- Add `npm run typecheck` (`tsc --noEmit`). +- Wire typecheck as required CI gate. +- Remove/phase out `--noCheck` for release CI path. +- Fix highest-priority TS errors in core paths. + +**Files** +- `package.json` +- CI workflow files +- core API/auth/publish modules as needed + +**Acceptance criteria** +- CI fails on TS errors. +- Release path no longer relies on `--noCheck`. +- Baseline TS error count reduced to agreed threshold. + +--- + +## 6) P0: Make migrations latest-by-default and remove risky rollback behavior + +**Why** +Migration script behavior can rollback based on stale target assumptions. + +**Scope** +- Default migrate command should apply forward to latest. +- Remove implicit rollback in normal deploy flow. +- Document explicit rollback procedure separately. + +**Files** +- `scripts/migrate.ts` + +**Acceptance criteria** +- Standard deploy path is forward-only to latest. +- No surprise rollback from stale target. +- Behavior documented and tested. diff --git a/planning/tech-audit-board.md b/planning/tech-audit-board.md new file mode 100644 index 0000000..b6f60ff --- /dev/null +++ b/planning/tech-audit-board.md @@ -0,0 +1,181 @@ +# Funrang Technical Audit Board + +_Last updated: 2026-02-16_ + +## How to use this board +- Keep this file internal (not deployed docs). +- Move fast with **P0 first**. +- Every task should end with: PR link + test evidence. + +--- + +## Status legend +- [ ] Todo +- [~] In progress +- [x] Done +- [!] Blocked + +--- + +## P0 — Must fix before scale (this week) + +### Security + auth hardening +- [ ] **Protect private media routes** + - **Why:** generated media appears publicly reachable if URL is known. + - **Files:** `src/server.ts` + - **DoD:** private media requires auth or signed URL; verified unauth access denied. + +- [ ] **Session cookie hardening** + - **Why:** missing strong production cookie posture. + - **Files:** `src/auth/session.ts` + - **DoD:** `HttpOnly`, `Secure` (prod), explicit expiry, rotation policy documented. + +- [ ] **Add CSRF protection for mutating cookie-auth routes** + - **Files:** auth/API middleware + mutating endpoints + - **DoD:** state-changing requests require valid CSRF token; tests added. + +- [ ] **Add login rate limiting / brute-force guard** + - **Files:** auth routes/middleware + - **DoD:** repeated failed attempts throttled; safe defaults configured. + +- [ ] **Use crypto-safe OAuth state tokens** + - **Files:** `src/routes/auth/facebook.ts`, `src/routes/auth/youtube.ts` + - **DoD:** replace `Math.random` with `crypto.randomBytes` (or equivalent). + +- [ ] **Patch invite template XSS risk** + - **Files:** `src/routes/auth/local.ts` + - **DoD:** user-controlled values HTML-escaped before interpolation. + +- [ ] **Mitigate SSRF in remote media fetch flows** + - **Files:** `src/api/posts.ts` (download helpers) + - **DoD:** deny private/reserved IP ranges + add allowlist policy and tests. + +### Type safety + correctness +- [ ] **Restore typecheck gate** + - **Files:** `package.json`, CI workflow + - **DoD:** `npm run typecheck` exists and is required in CI. + +- [ ] **Stop relying on `--noCheck` for CI/release builds** + - **Files:** `package.json` + - **DoD:** release path fails on TS errors. + +- [ ] **Fix immediate TS correctness breaks in core paths** + - **Files:** prioritize auth/api/publish/quality modules + - **DoD:** baseline type errors reduced to agreed threshold; no critical path TS errors. + +### Migrations + ops safety +- [ ] **Fix migration strategy to latest-by-default** + - **Files:** `scripts/migrate.ts` + - **DoD:** no implicit rollback by stale target in normal runs; behavior documented. + +--- + +## P1 — Stabilize core platform (next 2–3 weeks) + +### Architecture / maintainability +- [ ] **Split `src/api/posts.ts` into focused modules** + - Suggested split: uploads, timeline, media generation, publish/repurpose. + - **DoD:** each module has clear responsibility + tests. + +- [ ] **Resolve publish implementation drift (`fbMulti.js` vs `fbMulti.ts`)** + - **Files:** `src/publish/index.ts`, `src/publish/fbMulti.*` + - **DoD:** one canonical implementation path only. + +- [ ] **Separate web and worker process roles** + - **Files:** `src/server.ts`, queue bootstrap + - **DoD:** web replicas can scale independently from workers. + +### Performance / scalability +- [ ] **Fix outbox fairness to per-org+lang** + - **Files:** `src/publish/outboxProcessor.ts` + - **DoD:** one tenant cannot starve others in same language. + +- [ ] **Add composite indexes for due scans** + - **Targets:** + - `post_schedules(status, scheduled_at, id)` + - `outbox(status, created_at, id)` + - **DoD:** explain plans improve; p95 due-query latency reduced. + +- [ ] **Move heavy media ops off request path where possible** + - **Files:** media generation routes/queues + - **DoD:** API latency no longer dominated by synchronous processing. + +### Delivery quality +- [ ] **Add CI pipeline (blocking gates)** + - Required: typecheck, tests, build, security scan, migration safety check. + - **DoD:** PR merge blocked unless all required checks pass. + +--- + +## P2 — Hardening + scale readiness (after stabilization) + +- [ ] **Expand request validation coverage (Zod) for mutating endpoints** +- [ ] **Introduce clearer retry/DLQ taxonomy for publish pipeline** +- [ ] **Add observability dashboard + alert thresholds (queue depth, failure rate, p95 latencies)** +- [ ] **Strengthen secrets/token at-rest handling strategy** + +--- + +## Quick wins (1–2 days) + +- [ ] Add missing type packages: `@types/express`, `@types/jsdom`, `@types/node-cron` +- [ ] Add `npm run typecheck` +- [ ] Switch OAuth state RNG to crypto-safe +- [ ] Escape invite template vars +- [ ] Add due-query composite indexes +- [ ] Make pgAdmin/dev creds safer in prod guidance and defaults + +--- + +## Suggested CI gates (minimum) + +- [ ] `npm run typecheck` +- [ ] `npm test` +- [ ] `npm run build` +- [ ] Migration safety check script +- [ ] Security scan (`npm audit --production` + secret scan) + +--- + +## 7-day execution plan + +### Day 1 +- [ ] Create CI skeleton + add `typecheck` script + missing `@types/*` + +### Day 2 +- [ ] Remove `--noCheck` from release path and fix highest-impact TS errors + +### Day 3 +- [ ] Cookie hardening + CSRF + login rate limiting + +### Day 4 +- [ ] XSS + SSRF patches + tests + +### Day 5 +- [ ] Outbox fairness + DB index migration + +### Day 6 +- [ ] `posts.ts` phase-1 extraction + integration tests + +### Day 7 +- [ ] Migration script safety changes + deploy topology doc update + +--- + +## Metrics to track + +- Outbox success rate by org/channel/lang +- Publish latency p50/p95 (enqueue → sent) +- Queue depth by queue +- API p95 for heavy media endpoints +- Failed login attempts (rate-limited) +- CI pass rate for typecheck/test/build/security + +--- + +## Ownership (fill in) + +- Security owner: _TBD_ +- API/platform owner: _TBD_ +- Data/migrations owner: _TBD_ +- CI/devx owner: _TBD_ diff --git a/scripts/migrate.ts b/scripts/migrate.ts index 7ef511c..331eadb 100644 --- a/scripts/migrate.ts +++ b/scripts/migrate.ts @@ -1,13 +1,11 @@ import "dotenv/config"; import fs from "node:fs"; import path from "node:path"; -import { fileURLToPath } from "node:url"; import { Pool } from "pg"; -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const migrationsDir = path.resolve(__dirname, "../migrations"); +const migrationsDir = path.resolve(process.cwd(), "migrations"); -const TARGET_VERSION = "0047_post_media_settings"; +const TARGET_VERSION = "0049_post_ai_generated_flag"; const databaseUrl = process.env.DATABASE_URL; if (!databaseUrl) { diff --git a/src/api/jobs.ts b/src/api/jobs.ts index d53af2d..2cc975f 100644 --- a/src/api/jobs.ts +++ b/src/api/jobs.ts @@ -2,6 +2,12 @@ import { Router } from "express"; import { countJobs, createJob, getJobCounts, listJobs } from "../db/jobs.js"; import { listJobDefinitions } from "../db/jobDefinitions.js"; +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +function isUuid(value: string): boolean { + return UUID_RE.test(String(value || "").trim()); +} + export function jobsApiRouter() { const router = Router(); @@ -18,11 +24,27 @@ export function jobsApiRouter() { const page = Math.max(1, Number(pageRaw) || 1); const pageSize = Math.min(50, Math.max(1, Number(pageSizeRaw) || 10)); const offset = (page - 1) * pageSize; - const jobIds = jobIdsRaw + + const rawJobIds = jobIdsRaw ? jobIdsRaw.split(",").map((id) => id.trim()).filter(Boolean) : jobIdRaw && jobIdRaw !== "manual" ? [jobIdRaw] : []; + const hasNoneSentinel = rawJobIds.includes("__none__"); + const jobIds = rawJobIds.filter((id) => isUuid(id)); + + if (hasNoneSentinel && jobIds.length === 0 && !includeManual) { + res.json({ + counts: await getJobCounts(orgId), + recent: [], + total: 0, + page, + pageSize, + definitions: await listJobDefinitions(orgId) + }); + return; + } + const filter = jobIds.length > 0 || includeManual ? { jobDefinitionIds: jobIds, includeManual } diff --git a/src/api/posts.ts b/src/api/posts.ts index 2ae9071..bbe40ff 100644 --- a/src/api/posts.ts +++ b/src/api/posts.ts @@ -3,19 +3,21 @@ import { query, queryOne } from "../db/index.js"; import { insertRejection } from "../db/rejections.js"; import { listSocialAccounts } from "../db/socialAccounts.js"; import { insertPostSchedule, listPostSchedules } from "../db/postSchedules.js"; -import { listPostImages } from "../db/postImages.js"; +import { insertPostImage, listPostImages } from "../db/postImages.js"; import { deletePostReelById, getPostReelById, insertPostReel } from "../db/postReels.js"; import { insertPostReelPublish } from "../db/postReelPublishes.js"; import { deletePostShortById, getPostShortById, insertPostShort } from "../db/postShorts.js"; import { insertPostShortPublish } from "../db/postShortPublishes.js"; import { buildKenBurnsReel, buildKenBurnsSlideshow, buildVideoTemplateMontage } from "../reels/reelBuilder.js"; import { generateCoverImage } from "../ai/imageGen.js"; -import { saveFacebookDraft } from "../runner/helpers.js"; import fs from "node:fs/promises"; import path from "node:path"; import os from "node:os"; +import { lookup } from "node:dns/promises"; +import { isIP } from "node:net"; import { deleteFbPostById, getFbPostById, updatePostStatus } from "../db/posts.js"; import { postReelToPage } from "../publish/index.js"; +import { appendAiDisclosure } from "../publish/disclosure.js"; import { uploadYoutubeShort } from "../publish/youtube.js"; import { enqueueOutbox } from "../state/outbox.js"; import { getPostVideoTimeline, upsertPostVideoTimeline } from "../db/postVideoTimelines.js"; @@ -25,6 +27,12 @@ import { spawn } from "node:child_process"; type TemplateImageInput = { url: string; durationSeconds?: number }; type TemplateVideoInput = { url: string; durationSeconds: number }; type PostMediaAsset = { url?: string; preview?: string; filePath?: string; label?: string; source?: string }; +type PostVideoSettingsRow = { + image_prompt: string | null; + post_media_mode: string | null; + post_media_source: string | null; + post_media_count: number | null; +}; function stripHashtags(input: string) { return input @@ -72,6 +80,68 @@ function normalizeTemplateVideos(raw: unknown): TemplateVideoInput[] { } const POST_MEDIA_MAX_COUNT = 6; +const TEMPLATE_FETCH_HOST_ALLOWLIST = (process.env.TEMPLATE_FETCH_HOST_ALLOWLIST || "") + .split(/[,\s]+/) + .map((v) => v.trim().toLowerCase()) + .filter(Boolean); + +function isHostnameAllowed(hostname: string): boolean { + if (!TEMPLATE_FETCH_HOST_ALLOWLIST.length) return true; + const host = hostname.toLowerCase(); + return TEMPLATE_FETCH_HOST_ALLOWLIST.some( + (allowed) => host === allowed || host.endsWith(`.${allowed}`) + ); +} + +function isPrivateIpAddress(ip: string): boolean { + if (ip === "::1") return true; + if (ip.startsWith("fc") || ip.startsWith("fd")) return true; // IPv6 ULA + if (ip.startsWith("fe80:")) return true; // IPv6 link-local + + if (isIP(ip) !== 4) return false; + const [a, b] = ip.split(".").map((n) => Number(n)); + if (a === 10) return true; + if (a === 127) return true; + if (a === 169 && b === 254) return true; + if (a === 172 && b >= 16 && b <= 31) return true; + if (a === 192 && b === 168) return true; + if (a === 0) return true; + return false; +} + +async function assertSafeTemplateUrl(rawUrl: string, kind: "image" | "video") { + let parsed: URL; + try { + parsed = new URL(rawUrl); + } catch { + throw new Error(`Invalid template ${kind} URL`); + } + + if (!["http:", "https:"].includes(parsed.protocol)) { + throw new Error(`Invalid template ${kind} URL protocol`); + } + + const hostname = parsed.hostname.toLowerCase(); + if (!hostname) throw new Error(`Invalid template ${kind} URL`); + if (["localhost", "localhost.localdomain"].includes(hostname) || hostname.endsWith(".local")) { + throw new Error(`Blocked template ${kind} host`); + } + + if (!isHostnameAllowed(hostname)) { + throw new Error(`Template ${kind} host is not allowlisted`); + } + + const records = await lookup(hostname, { all: true, verbatim: true }); + if (!records.length) { + throw new Error(`Failed to resolve template ${kind} host`); + } + + for (const record of records) { + if (isPrivateIpAddress(record.address)) { + throw new Error(`Blocked template ${kind} host`); + } + } +} function normalizeMediaMode(raw: unknown) { const val = typeof raw === "string" ? raw.trim().toLowerCase() : ""; @@ -156,7 +226,7 @@ async function savePostMediaUploads(input: { } async function downloadToTempFile(url: string, prefix: string): Promise { - if (!/^https?:\/\//i.test(url)) throw new Error("Invalid template image URL"); + await assertSafeTemplateUrl(url, "image"); const res = await fetch(url); if (!res.ok) { const text = await res.text(); @@ -174,7 +244,7 @@ async function downloadToTempFile(url: string, prefix: string): Promise } async function downloadVideoToTempFile(url: string, prefix: string): Promise { - if (!/^https?:\/\//i.test(url)) throw new Error("Invalid template video URL"); + await assertSafeTemplateUrl(url, "video"); const res = await fetch(url); if (!res.ok) { const text = await res.text(); @@ -229,6 +299,123 @@ async function getAudioDurationSeconds(audioPath: string): Promise { }); } +async function getPostVideoSettings(orgId: string, postId: string): Promise<{ + imagePrompt: string; + mediaMode: "text" | "single" | "carousel"; + mediaSource: "ai" | "uploads" | "pexels"; + mediaCount: number; +}> { + const row = await queryOne( + ` + SELECT image_prompt, post_media_mode, post_media_source, post_media_count + FROM posts + WHERE id = $1 AND organization_id = $2 + `, + [postId, orgId] + ); + if (!row) throw new Error("Post not found"); + return { + imagePrompt: (row.image_prompt ?? "").trim(), + mediaMode: normalizeMediaMode(row.post_media_mode), + mediaSource: normalizeMediaSource(row.post_media_source), + mediaCount: normalizeMediaCount(row.post_media_count) + }; +} + +function buildImagePromptVariant(basePrompt: string, index: number, total: number): string { + const hints = [ + "wide establishing composition", + "medium composition with clear subject focus", + "close-up detail shot", + "alternate angle of the same subject", + "contextual scene with foreground depth", + "cinematic texture detail shot" + ]; + const shot = hints[index % hints.length] ?? "alternate composition"; + return `${basePrompt}\n\nFrame ${index + 1}/${Math.max(total, 1)}: ${shot}. Keep consistent subject, style, and lighting with prior frames.`; +} + +async function persistGeneratedImage(args: { + orgId: string; + postId: string; + image: Buffer; + prompt: string; +}): Promise { + const baseDir = process.env.FB_ARCHIVE_DIR ?? "facebook_posts"; + const dateDir = new Date().toISOString().slice(0, 10); + const outDir = path.resolve(process.cwd(), baseDir, dateDir); + await fs.mkdir(outDir, { recursive: true }); + const fileName = `${args.postId}-${Date.now()}-${Math.random().toString(36).slice(2)}.png`; + const absPath = path.join(outDir, fileName); + await fs.writeFile(absPath, args.image); + const relPath = path.join(baseDir, dateDir, fileName).split(path.sep).join("/"); + await insertPostImage(args.orgId, { + post_id: args.postId, + file_path: relPath, + prompt: args.prompt + }); +} + +async function ensurePostImagesForVideo(args: { + orgId: string; + postId: string; + desiredCount: number; + imagePrompt: string; + allowAiGeneration: boolean; +}): Promise> { + let images = await listPostImages(args.orgId, args.postId); + if (images.length >= args.desiredCount) return images.slice(-args.desiredCount); + if (!args.allowAiGeneration) return images.slice(-args.desiredCount); + if (!args.imagePrompt.trim()) throw new Error("Missing image prompt for this post."); + + const missing = args.desiredCount - images.length; + const startingIndex = images.length; + for (let i = 0; i < missing; i += 1) { + const prompt = buildImagePromptVariant(args.imagePrompt, startingIndex + i, args.desiredCount); + const image = await generateCoverImage(prompt); + await persistGeneratedImage({ + orgId: args.orgId, + postId: args.postId, + image, + prompt + }); + } + images = await listPostImages(args.orgId, args.postId); + return images.slice(-args.desiredCount); +} + +function estimateVoiceDurationSeconds(caption: string): number { + const cleaned = stripHashtags(caption || ""); + const words = cleaned ? cleaned.split(/\s+/).filter(Boolean).length : 0; + const sentencePauses = (cleaned.match(/[.!?]+/g) ?? []).length; + const shortPauses = (cleaned.match(/[,;:]/g) ?? []).length; + const base = words > 0 ? words / 2.7 : 6; + return Math.max(6, Math.min(180, base + sentencePauses * 0.35 + shortPauses * 0.12 + 0.6)); +} + +function buildPingPongIndices(length: number, count: number): number[] { + if (length <= 0 || count <= 0) return []; + if (length === 1) return Array.from({ length: count }, () => 0); + const forward = Array.from({ length }, (_, i) => i); + const backward = Array.from({ length: Math.max(0, length - 2) }, (_, i) => length - 2 - i); + const cycle = [...forward, ...backward]; + return Array.from({ length: count }, (_, i) => cycle[i % cycle.length] ?? 0); +} + +function buildCarouselTimeline(imagePaths: string[], caption: string): Array<{ path: string; durationSeconds: number }> { + if (!imagePaths.length) return []; + const estimatedDuration = estimateVoiceDurationSeconds(caption); + const targetSceneSeconds = 3.8; + const desiredScenes = Math.round(estimatedDuration / targetSceneSeconds); + const maxScenes = Math.max(imagePaths.length, Math.min(16, imagePaths.length * 3)); + const sceneCount = Math.max(imagePaths.length, Math.min(maxScenes, desiredScenes)); + const order = buildPingPongIndices(imagePaths.length, sceneCount); + const steadyDuration = Math.max(3.6, Math.min(4.2, estimatedDuration / Math.max(1, sceneCount))); + return order.map((idx) => { + return { path: imagePaths[idx] ?? imagePaths[0], durationSeconds: steadyDuration }; + }); +} + async function copyVideoToDir(sourcePath: string, dirName: string, prefix: string): Promise<{ relPath: string; absPath: string }> { const dateDir = new Date().toISOString().slice(0, 10); const outDir = path.resolve(process.cwd(), dirName, dateDir); @@ -241,6 +428,15 @@ async function copyVideoToDir(sourcePath: string, dirName: string, prefix: strin return { relPath, absPath }; } +async function getPostAiGeneratedSetting(orgId: string, postId: string): Promise { + const row = await queryOne<{ is_ai_generated: boolean | null }>( + "SELECT is_ai_generated FROM posts WHERE id = $1 AND organization_id = $2", + [postId, orgId] + ); + if (!row) throw new Error("Post not found"); + return row.is_ai_generated !== false; +} + export function postsApiRouter() { const router = Router(); @@ -290,6 +486,31 @@ export function postsApiRouter() { } }); + router.post("/:id/ai-generated", async (req, res) => { + const orgId = (req as any).org.id as string; + const postId = String(req.params.id ?? "").trim(); + if (!postId) return res.status(400).json({ ok: false, error: "Invalid id" }); + const value = req.body?.isAiGenerated; + if (typeof value !== "boolean") { + return res.status(400).json({ ok: false, error: "isAiGenerated must be a boolean" }); + } + try { + const updated = await queryOne<{ id: string }>( + ` + UPDATE posts + SET is_ai_generated = $3 + WHERE id = $1 AND organization_id = $2 + RETURNING id + `, + [postId, orgId, value] + ); + if (!updated) return res.status(404).json({ ok: false, error: "Post not found" }); + return res.json({ ok: true }); + } catch (err: any) { + return res.status(500).json({ ok: false, error: err?.message ?? String(err) }); + } + }); + router.get("/:id/timeline", async (req, res) => { const orgId = (req as any).org.id as string; const postId = String(req.params.id ?? "").trim(); @@ -530,35 +751,29 @@ export function postsApiRouter() { try { const templateList = normalizeTemplateImages(templateImages); const videoList = normalizeTemplateVideos(templateVideos ?? templateVideo); - let images: Array<{ file_path: string }> = []; + let imagePath = ""; + let autoCarouselTimeline: Array<{ path: string; durationSeconds: number }> = []; if (videoList.length === 0 && templateList.length === 0) { - images = await listPostImages(orgId, postId); - if (!images.length) { - const row = await queryOne<{ image_prompt?: string | null; story_key?: string | null }>( - "SELECT image_prompt, story_key FROM posts WHERE id = $1 AND organization_id = $2", - [postId, orgId] - ); - const imagePrompt = row?.image_prompt ?? ""; - if (!imagePrompt.trim()) { - return res.status(400).json({ ok: false, error: "Missing image prompt for this post." }); - } - const img = await generateCoverImage(imagePrompt); - await saveFacebookDraft({ - orgId, - postId, - storyKey: row?.story_key ?? postId, - posts: { [lang]: text }, - image: img, - imagePrompt - }); - images = await listPostImages(orgId, postId); - } - if (!images.length) { + const settings = await getPostVideoSettings(orgId, postId); + const desiredCount = settings.mediaMode === "carousel" ? settings.mediaCount : 1; + const images = await ensurePostImagesForVideo({ + orgId, + postId, + desiredCount, + imagePrompt: settings.imagePrompt, + allowAiGeneration: settings.mediaSource === "ai" + }); + if (images.length === 0) { return res.status(400).json({ ok: false, error: "No image saved for this post yet." }); } + if (settings.mediaMode === "carousel" && images.length > 1) { + const imagePaths = images.map((img) => path.resolve(process.cwd(), img.file_path)); + autoCarouselTimeline = buildCarouselTimeline(imagePaths, text); + } else { + const latest = images[images.length - 1]; + imagePath = latest ? path.resolve(process.cwd(), latest.file_path) : ""; + } } - const latest = images[images.length - 1]; - const imagePath = latest ? path.resolve(process.cwd(), latest.file_path) : ""; const reelsDir = process.env.REELS_DIR ?? "reels"; const dateDir = new Date().toISOString().slice(0, 10); const outDir = path.resolve(process.cwd(), reelsDir, dateDir); @@ -608,6 +823,18 @@ export function postsApiRouter() { audioLanguage: typeof audioLang === "string" && audioLang.trim() ? audioLang.trim() : undefined, ttsProvider: typeof ttsProvider === "string" && ttsProvider.trim() ? ttsProvider.trim() : undefined }); + } else if (autoCarouselTimeline.length > 0) { + await buildKenBurnsSlideshow({ + images: autoCarouselTimeline, + caption: text, + voiceText: typeof audioText === "string" && audioText.trim() ? audioText : undefined, + outputPath: outPath, + audioPath, + voice: typeof voice === "string" && voice.trim() ? voice.trim() : process.env.REEL_VOICE ?? undefined, + language: lang, + audioLanguage: typeof audioLang === "string" && audioLang.trim() ? audioLang.trim() : undefined, + ttsProvider: typeof ttsProvider === "string" && ttsProvider.trim() ? ttsProvider.trim() : undefined + }); } else { await buildKenBurnsReel({ imagePath, @@ -634,7 +861,9 @@ export function postsApiRouter() { reel: { id, url: `/${relPath}`, lang, createdAt: new Date().toISOString() } }); } catch (err: any) { - return res.status(500).json({ ok: false, error: err?.message ?? String(err) }); + const message = err?.message ?? String(err); + const status = message === "Post not found" ? 404 : 500; + return res.status(status).json({ ok: false, error: message }); } }); @@ -648,35 +877,29 @@ export function postsApiRouter() { try { const templateList = normalizeTemplateImages(templateImages); const videoList = normalizeTemplateVideos(templateVideos ?? templateVideo); - let images: Array<{ file_path: string }> = []; + let imagePath = ""; + let autoCarouselTimeline: Array<{ path: string; durationSeconds: number }> = []; if (videoList.length === 0 && templateList.length === 0) { - images = await listPostImages(orgId, postId); - if (!images.length) { - const row = await queryOne<{ image_prompt?: string | null; story_key?: string | null }>( - "SELECT image_prompt, story_key FROM posts WHERE id = $1 AND organization_id = $2", - [postId, orgId] - ); - const imagePrompt = row?.image_prompt ?? ""; - if (!imagePrompt.trim()) { - return res.status(400).json({ ok: false, error: "Missing image prompt for this post." }); - } - const img = await generateCoverImage(imagePrompt); - await saveFacebookDraft({ - orgId, - postId, - storyKey: row?.story_key ?? postId, - posts: { [lang]: text }, - image: img, - imagePrompt - }); - images = await listPostImages(orgId, postId); - } - if (!images.length) { + const settings = await getPostVideoSettings(orgId, postId); + const desiredCount = settings.mediaMode === "carousel" ? settings.mediaCount : 1; + const images = await ensurePostImagesForVideo({ + orgId, + postId, + desiredCount, + imagePrompt: settings.imagePrompt, + allowAiGeneration: settings.mediaSource === "ai" + }); + if (images.length === 0) { return res.status(400).json({ ok: false, error: "No image saved for this post yet." }); } + if (settings.mediaMode === "carousel" && images.length > 1) { + const imagePaths = images.map((img) => path.resolve(process.cwd(), img.file_path)); + autoCarouselTimeline = buildCarouselTimeline(imagePaths, text); + } else { + const latest = images[images.length - 1]; + imagePath = latest ? path.resolve(process.cwd(), latest.file_path) : ""; + } } - const latest = images[images.length - 1]; - const imagePath = latest ? path.resolve(process.cwd(), latest.file_path) : ""; const shortsDir = process.env.SHORTS_DIR ?? "shorts"; const dateDir = new Date().toISOString().slice(0, 10); const outDir = path.resolve(process.cwd(), shortsDir, dateDir); @@ -728,6 +951,19 @@ export function postsApiRouter() { ttsProvider: typeof ttsProvider === "string" && ttsProvider.trim() ? ttsProvider.trim() : undefined, maxDurationSeconds: 180 }); + } else if (autoCarouselTimeline.length > 0) { + await buildKenBurnsSlideshow({ + images: autoCarouselTimeline, + caption: text, + voiceText: typeof audioText === "string" && audioText.trim() ? audioText : undefined, + outputPath: outPath, + audioPath, + voice: typeof voice === "string" && voice.trim() ? voice.trim() : process.env.REEL_VOICE ?? undefined, + language: lang, + audioLanguage: typeof audioLang === "string" && audioLang.trim() ? audioLang.trim() : undefined, + ttsProvider: typeof ttsProvider === "string" && ttsProvider.trim() ? ttsProvider.trim() : undefined, + maxDurationSeconds: 180 + }); } else { await buildKenBurnsReel({ imagePath, @@ -756,7 +992,7 @@ export function postsApiRouter() { }); } catch (err: any) { const message = err?.message ?? String(err); - const status = message.includes("exceeds") ? 400 : 500; + const status = message === "Post not found" ? 404 : message.includes("exceeds") ? 400 : 500; return res.status(status).json({ ok: false, error: message }); } }); @@ -824,6 +1060,7 @@ export function postsApiRouter() { if (!reel || reel.post_id !== postId) { return res.status(404).json({ ok: false, error: "Reel not found" }); } + const isAiGenerated = await getPostAiGeneratedSetting(orgId, postId); const videoPath = path.resolve(process.cwd(), reel.file_path); const accounts = (await listSocialAccounts(orgId)).filter( (acc) => acc.enabled && acc.channel === "facebook" @@ -849,11 +1086,15 @@ export function postsApiRouter() { continue; } try { + const description = appendAiDisclosure( + typeof text === "string" ? text : "", + isAiGenerated + ); const resp = await postReelToPage({ pageId, pageToken: token, videoPath, - description: typeof text === "string" ? text : undefined + description: description || undefined }); await insertPostReelPublish(orgId, { post_id: postId, @@ -865,12 +1106,18 @@ export function postsApiRouter() { }); results.push({ accountId: acc.id, pageId, id: resp.id }); } catch (err: any) { - results.push({ accountId: acc.id, pageId, error: err?.message ?? String(err) }); + console.error( + `[posts-api] reel_publish_failed post=${postId} reel=${reelId} account=${acc.id} page=${pageId} error=${err?.message ?? String(err)}` + ); + results.push({ accountId: acc.id, pageId, error: "Publish failed" }); } } res.json({ ok: true, results }); } catch (err: any) { - res.status(500).json({ ok: false, error: err?.message ?? String(err) }); + console.error( + `[posts-api] reel_publish_route_failed post=${postId} reel=${reelId} error=${err?.message ?? String(err)}` + ); + res.status(500).json({ ok: false, error: "Publish failed" }); } }); @@ -896,10 +1143,10 @@ export function postsApiRouter() { .map((s: any) => ({ accountId: String(s?.accountId ?? "") })) .filter((s) => s.accountId) : []; - const selected = - selectionList.length > 0 - ? accounts.filter((acc) => selectionList.some((s) => s.accountId === acc.id)) - : accounts; + if (!selectionList.length) { + return res.status(400).json({ ok: false, error: "Select at least one YouTube channel" }); + } + const selected = accounts.filter((acc) => selectionList.some((s) => s.accountId === acc.id)); if (!selected.length) { return res.status(400).json({ ok: false, error: "No enabled YouTube channels selected" }); } @@ -972,6 +1219,7 @@ export function postsApiRouter() { if (!short || short.post_id !== postId) { return res.status(404).json({ ok: false, error: "Short not found" }); } + const isAiGenerated = await getPostAiGeneratedSetting(orgId, postId); const sourcePath = path.resolve(process.cwd(), short.file_path); const accounts = (await listSocialAccounts(orgId)).filter( (acc) => acc.enabled && acc.channel === "facebook" @@ -981,10 +1229,10 @@ export function postsApiRouter() { .map((s: any) => ({ accountId: String(s?.accountId ?? "") })) .filter((s) => s.accountId) : []; - const selected = - selectionList.length > 0 - ? accounts.filter((acc) => selectionList.some((s) => s.accountId === acc.id)) - : accounts; + if (!selectionList.length) { + return res.status(400).json({ ok: false, error: "Select at least one Facebook page" }); + } + const selected = accounts.filter((acc) => selectionList.some((s) => s.accountId === acc.id)); if (!selected.length) { return res.status(400).json({ ok: false, error: "No enabled Facebook pages selected" }); } @@ -1008,11 +1256,15 @@ export function postsApiRouter() { continue; } try { + const description = appendAiDisclosure( + typeof text === "string" ? text : "", + isAiGenerated + ); const resp = await postReelToPage({ pageId, pageToken: token, videoPath: absPath, - description: typeof text === "string" ? text : undefined + description: description || undefined }); await insertPostReelPublish(orgId, { post_id: postId, @@ -1024,7 +1276,10 @@ export function postsApiRouter() { }); results.push({ accountId: acc.id, pageId, id: resp.id }); } catch (err: any) { - results.push({ accountId: acc.id, pageId, error: err?.message ?? String(err) }); + console.error( + `[posts-api] short_repurpose_facebook_failed post=${postId} short=${shortId} account=${acc.id} page=${pageId} error=${err?.message ?? String(err)}` + ); + results.push({ accountId: acc.id, pageId, error: "Publish failed" }); } } res.json({ @@ -1033,7 +1288,10 @@ export function postsApiRouter() { results }); } catch (err: any) { - return res.status(500).json({ ok: false, error: err?.message ?? String(err) }); + console.error( + `[posts-api] short_repurpose_facebook_route_failed post=${postId} short=${shortId} error=${err?.message ?? String(err)}` + ); + return res.status(500).json({ ok: false, error: "Publish failed" }); } }); diff --git a/src/db/posts.ts b/src/db/posts.ts index 7052a68..e603c78 100644 --- a/src/db/posts.ts +++ b/src/db/posts.ts @@ -6,6 +6,7 @@ export { hashContent, insertFbPost, insertPost, + countDraftPosts, listDraftPosts, listFbPostsForPost, updatePostStatus, diff --git a/src/db/postsRepo.ts b/src/db/postsRepo.ts index 72cfad4..707dd4f 100644 --- a/src/db/postsRepo.ts +++ b/src/db/postsRepo.ts @@ -12,6 +12,7 @@ export type DraftPostRow = { translations_json?: string | null; fact_pack?: string | null; created_at: string; + job_id?: string | null; job_definition_id?: string | null; source_url?: string | null; story_key?: string | null; @@ -47,6 +48,7 @@ export async function insertPost( story_key?: string; fact_pack?: string; translations_json?: string; + job_id?: string; job_definition_id?: string; content_hash: string; created_at: string; @@ -57,9 +59,9 @@ export async function insertPost( INSERT INTO posts ( organization_id, kind, status, title, message_en, message_en_json, image_prompt, source_id, source_url, category, category_id, story_key, fact_pack, translations_json, - job_definition_id, content_hash, created_at + job_definition_id, job_id, content_hash, created_at ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18) RETURNING id `, [ @@ -78,6 +80,7 @@ export async function insertPost( p.fact_pack ?? null, p.translations_json ?? null, p.job_definition_id ?? null, + p.job_id ?? null, p.content_hash, p.created_at ] @@ -179,6 +182,8 @@ export async function listDraftPosts( filters?: { status?: "all" | "draft" | "approved" | "published" | "rejected"; jobDefinitionId?: string | null; + jobId?: string; + published?: "all" | "any" | "facebook" | "youtube" | "none"; limit?: number; offset?: number; } @@ -199,6 +204,40 @@ export async function listDraftPosts( params.push(filters.jobDefinitionId); } } + if (filters?.jobId) { + where.push(`p.job_id = $${idx++}`); + params.push(filters.jobId); + } + const fbPublishedExists = ` + EXISTS ( + SELECT 1 FROM fb_posts f + WHERE f.organization_id = p.organization_id AND f.post_id = p.id + ) + `; + const reelPublishedExists = ` + EXISTS ( + SELECT 1 FROM post_reel_publishes r + WHERE r.organization_id = p.organization_id AND r.post_id = p.id AND r.channel = 'facebook' + ) + `; + const ytPublishedExists = ` + EXISTS ( + SELECT 1 FROM post_short_publishes y + WHERE y.organization_id = p.organization_id AND y.post_id = p.id AND y.channel = 'youtube' + ) + `; + const anyPublished = `(${fbPublishedExists} OR ${reelPublishedExists} OR ${ytPublishedExists})`; + const fbAnyPublished = `(${fbPublishedExists} OR ${reelPublishedExists})`; + const publishedMode = filters?.published ?? "all"; + if (publishedMode === "any") { + where.push(anyPublished); + } else if (publishedMode === "facebook") { + where.push(fbAnyPublished); + } else if (publishedMode === "youtube") { + where.push(ytPublishedExists); + } else if (publishedMode === "none") { + where.push(`NOT ${anyPublished}`); + } const clause = where.length ? `WHERE ${where.join(" AND ")}` : ""; const limit = Number(filters?.limit ?? 200); const offset = Number(filters?.offset ?? 0); @@ -215,6 +254,7 @@ export async function listDraftPosts( p.translations_json, p.fact_pack, p.created_at, + p.job_id, p.job_definition_id, p.source_url, p.story_key, @@ -232,3 +272,76 @@ export async function listDraftPosts( [...params, limit, offset] ); } + +export async function countDraftPosts( + orgId: string, + filters?: { + status?: "all" | "draft" | "approved" | "published" | "rejected"; + jobDefinitionId?: string | null; + jobId?: string; + published?: "all" | "any" | "facebook" | "youtube" | "none"; + } +): Promise { + const where: string[] = ["p.organization_id = $1"]; + const params: unknown[] = [orgId]; + let idx = 2; + + if (filters?.status && filters.status !== "all") { + where.push(`p.status = $${idx++}`); + params.push(filters.status); + } + if (filters?.jobDefinitionId !== undefined) { + if (filters.jobDefinitionId === null) { + where.push("p.job_definition_id IS NULL"); + } else { + where.push(`p.job_definition_id = $${idx++}`); + params.push(filters.jobDefinitionId); + } + } + if (filters?.jobId) { + where.push(`p.job_id = $${idx++}`); + params.push(filters.jobId); + } + + const fbPublishedExists = ` + EXISTS ( + SELECT 1 FROM fb_posts f + WHERE f.organization_id = p.organization_id AND f.post_id = p.id + ) + `; + const reelPublishedExists = ` + EXISTS ( + SELECT 1 FROM post_reel_publishes r + WHERE r.organization_id = p.organization_id AND r.post_id = p.id AND r.channel = 'facebook' + ) + `; + const ytPublishedExists = ` + EXISTS ( + SELECT 1 FROM post_short_publishes y + WHERE y.organization_id = p.organization_id AND y.post_id = p.id AND y.channel = 'youtube' + ) + `; + const anyPublished = `(${fbPublishedExists} OR ${reelPublishedExists} OR ${ytPublishedExists})`; + const fbAnyPublished = `(${fbPublishedExists} OR ${reelPublishedExists})`; + const publishedMode = filters?.published ?? "all"; + if (publishedMode === "any") { + where.push(anyPublished); + } else if (publishedMode === "facebook") { + where.push(fbAnyPublished); + } else if (publishedMode === "youtube") { + where.push(ytPublishedExists); + } else if (publishedMode === "none") { + where.push(`NOT ${anyPublished}`); + } + + const clause = where.length ? `WHERE ${where.join(" AND ")}` : ""; + const row = await queryOne<{ count: string }>( + ` + SELECT COUNT(*)::text AS count + FROM posts p + ${clause} + `, + params + ); + return Number(row?.count ?? 0); +} diff --git a/src/domain/types.ts b/src/domain/types.ts index ba1d6fa..395643b 100644 --- a/src/domain/types.ts +++ b/src/domain/types.ts @@ -112,6 +112,7 @@ export type ChannelPost = { images?: Buffer[]; link?: string; lang?: Lang; + isAiGenerated?: boolean; }; export type ChannelResult = { diff --git a/src/jobs/worker.ts b/src/jobs/worker.ts index b6f78cf..e59bd27 100644 --- a/src/jobs/worker.ts +++ b/src/jobs/worker.ts @@ -45,7 +45,7 @@ export async function processNextJob(): Promise { }; } } - const summary = await runOnce({ ...options, orgId: job.organization_id }); + const summary = await runOnce({ ...options, orgId: job.organization_id, jobId: job.id }); await markJobCompleted(job.organization_id, job.id, JSON.stringify(summary)); if (jobDefinitionId) await markJobDefinitionRun(job.organization_id, jobDefinitionId); } catch (err: any) { diff --git a/src/publish/disclosure.ts b/src/publish/disclosure.ts new file mode 100644 index 0000000..7d3a918 --- /dev/null +++ b/src/publish/disclosure.ts @@ -0,0 +1,16 @@ +export const AI_DISCLOSURE_LINE = "Disclosure: Created with AI assistance"; + +export function appendAiDisclosure(message: string, isAiGenerated: boolean): string { + const base = typeof message === "string" ? message.trimEnd() : ""; + if (!isAiGenerated) return base; + + const disclosureNormalized = AI_DISCLOSURE_LINE.toLowerCase(); + const hasDisclosure = base + .split(/\r?\n/) + .map((line) => line.trim().toLowerCase()) + .includes(disclosureNormalized); + + if (hasDisclosure) return base; + if (!base) return AI_DISCLOSURE_LINE; + return `${base}\n${AI_DISCLOSURE_LINE}`; +} diff --git a/src/publish/fbMulti.ts b/src/publish/fbMulti.ts index 752e6af..f84102f 100644 --- a/src/publish/fbMulti.ts +++ b/src/publish/fbMulti.ts @@ -165,7 +165,11 @@ export async function postCommentToPost(args: { return json as { id: string }; } -export async function postTextToPage(args: { pageId: string; pageToken: string; message: string }) { +export async function postTextToPage(args: { + pageId: string; + pageToken: string; + message: string; +}) { const endpoint = `/${encodeURIComponent(args.pageId)}/feed`; const url = new URL(`https://graph.facebook.com/${apiVersion}${endpoint}`); url.searchParams.set("access_token", args.pageToken); diff --git a/src/publish/index.ts b/src/publish/index.ts index 6441bb5..06a0c52 100644 --- a/src/publish/index.ts +++ b/src/publish/index.ts @@ -1,4 +1,5 @@ import { postPhotoToPage, postReelToPage, postTextToPage, postMultiPhotoToPage } from "./fbMulti.js"; +import { appendAiDisclosure } from "./disclosure.js"; export { postPhotoToPage, postCommentToPost, postReelToPage, postTextToPage, postMultiPhotoToPage } from "./fbMulti.js"; export type { ChannelPost, ChannelResult, FbPageCfg, TwitterConfig, InstagramConfig } from "../domain/types.js"; export { postToTwitter } from "./twitter.js"; @@ -11,32 +12,33 @@ export async function publishToChannels(config: PublishConfig, post: ChannelPost if (config.facebookPages?.length) { for (const page of config.facebookPages) { + const message = appendAiDisclosure(post.message, post.isAiGenerated === true); try { const resp = post.images && post.images.length > 1 ? await postMultiPhotoToPage({ pageId: page.id, pageToken: page.token, - message: post.message, + message, images: post.images }) : post.images && post.images.length === 1 ? await postPhotoToPage({ pageId: page.id, pageToken: page.token, - message: post.message, + message, image: post.images[0] }) : post.image ? await postPhotoToPage({ pageId: page.id, pageToken: page.token, - message: post.message, + message, image: post.image }) : await postTextToPage({ pageId: page.id, pageToken: page.token, - message: post.message + message }); results.push({ channel: "facebook", id: resp.id }); } catch (e: any) { diff --git a/src/publish/outboxProcessor.ts b/src/publish/outboxProcessor.ts index ffa48ef..2a28ac6 100644 --- a/src/publish/outboxProcessor.ts +++ b/src/publish/outboxProcessor.ts @@ -254,12 +254,12 @@ export async function processOutbox(perLangLimit: number) { const results = await publishToChannels( { facebookPages: [page] }, mediaMode === "text" - ? { message, lang: item.lang } + ? { message, lang: item.lang, isAiGenerated: item.is_ai_generated !== false } : images.length > 1 - ? { message, images, lang: item.lang } + ? { message, images, lang: item.lang, isAiGenerated: item.is_ai_generated !== false } : images.length === 1 - ? { message, image: images[0], lang: item.lang } - : { message, lang: item.lang } + ? { message, image: images[0], lang: item.lang, isAiGenerated: item.is_ai_generated !== false } + : { message, lang: item.lang, isAiGenerated: item.is_ai_generated !== false } ); const fbResult = results.find((r) => r.channel === "facebook"); if (!fbResult?.id) { diff --git a/src/publish/youtube.ts b/src/publish/youtube.ts index 4f72e33..3ea2b34 100644 --- a/src/publish/youtube.ts +++ b/src/publish/youtube.ts @@ -111,7 +111,7 @@ async function startResumableUpload(input: { description: normalizeDescription(input.description) }, status: { - privacyStatus: input.privacyStatus || "unlisted" + privacyStatus: input.privacyStatus || "public" } }; const url = new URL("https://www.googleapis.com/upload/youtube/v3/videos"); diff --git a/src/reels/reelBuilder.ts b/src/reels/reelBuilder.ts new file mode 100644 index 0000000..6d39242 --- /dev/null +++ b/src/reels/reelBuilder.ts @@ -0,0 +1,852 @@ +import { spawn } from "node:child_process"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { generateVoiceover } from "./tts.js"; +import { getWordTimings } from "./wordTimings.js"; + +type ReelOptions = { + imagePath: string; + caption: string; + voiceText?: string; + outputPath: string; + audioPath?: string; + fps?: number; + zoomEnd?: number; + fontPath?: string; + fontSize?: number; + textBoxPadding?: number; + safeBottomRatio?: number; + width?: number; + height?: number; + voice?: string; + language?: string; + audioLanguage?: string; + ttsProvider?: string; + maxDurationSeconds?: number; +}; + +type KenBurnsPreset = { + startX: number; + startY: number; + endX: number; + endY: number; +}; + +const KEN_BURNS_PRESETS: KenBurnsPreset[] = [ + { startX: 0.32, startY: 0.32, endX: 0.4, endY: 0.34 }, + { startX: 0.62, startY: 0.34, endX: 0.54, endY: 0.36 }, + { startX: 0.44, startY: 0.24, endX: 0.46, endY: 0.34 }, + { startX: 0.42, startY: 0.56, endX: 0.38, endY: 0.46 }, + { startX: 0.28, startY: 0.46, endX: 0.34, endY: 0.4 }, + { startX: 0.58, startY: 0.52, endX: 0.52, endY: 0.44 } +]; + +function clamp(value: number, min: number, max: number): number { + return Math.max(min, Math.min(max, value)); +} + +function pickKenBurnsPreset(index: number): KenBurnsPreset { + const safeIndex = Number.isFinite(index) ? Math.abs(Math.floor(index)) : 0; + return KEN_BURNS_PRESETS[safeIndex % KEN_BURNS_PRESETS.length] ?? KEN_BURNS_PRESETS[0]; +} + +function buildKenBurnsZoompanFilter(args: { + fps: number; + totalFrames: number; + zoomTarget: number; + presetIndex: number; + width: number; + height: number; + allowPan?: boolean; +}): string { + const preset = pickKenBurnsPreset(args.presetIndex); + const denom = Math.max(1, args.totalFrames - 1); + const progress = `(on/${denom})`; + const ease = `(0.5-0.5*cos(PI*${progress}))`; + const zoomStart = 1.0; + const zoomEnd = clamp(args.zoomTarget, 1.03, 1.14); + const xStart = clamp(preset.startX, 0, 1); + const xEnd = clamp(preset.endX, 0, 1); + const yStart = clamp(preset.startY, 0, 1); + const yEnd = clamp(preset.endY, 0, 1); + const xExpr = + args.allowPan === false + ? "0.500" + : `${xStart.toFixed(3)}+${(xEnd - xStart).toFixed(3)}*${ease}`; + const yExpr = + args.allowPan === false + ? "0.500" + : `${yStart.toFixed(3)}+${(yEnd - yStart).toFixed(3)}*${ease}`; + return `zoompan=z='${zoomStart.toFixed(3)}+${(zoomEnd - zoomStart).toFixed(6)}*${ease}':d=${args.totalFrames}:x='(iw-iw/zoom)*(${xExpr})':y='(ih-ih/zoom)*(${yExpr})':s=${args.width}x${args.height}:fps=${args.fps}`; +} + +function hashString(value: string): number { + let hash = 0; + for (let i = 0; i < value.length; i += 1) { + hash = (hash * 31 + value.charCodeAt(i)) | 0; + } + return Math.abs(hash); +} + +function runCmd(cmd: string, args: string[]): Promise { + return new Promise((resolve, reject) => { + const child = spawn(cmd, args, { stdio: "inherit" }); + child.on("error", reject); + child.on("close", (code) => { + if (code === 0) resolve(); + else reject(new Error(`${cmd} exited with code ${code}`)); + }); + }); +} + +async function getDurationSeconds(audioPath: string): Promise { + const args = [ + "-v", + "error", + "-show_entries", + "format=duration", + "-of", + "default=nk=1:nw=1", + audioPath + ]; + return new Promise((resolve, reject) => { + let output = ""; + const child = spawn("ffprobe", args); + child.stdout.on("data", (d) => (output += d.toString())); + child.stderr.on("data", (d) => (output += d.toString())); + child.on("error", reject); + child.on("close", (code) => { + if (code !== 0) return reject(new Error(`ffprobe failed: ${output}`)); + const duration = Number.parseFloat(output.trim()); + if (!Number.isFinite(duration)) { + return reject(new Error(`Invalid duration from ffprobe: ${output}`)); + } + resolve(duration); + }); + }); +} + +function wrapText(text: string, maxLineLength: number): string { + const words = text.replace(/\s+/g, " ").trim().split(" "); + const lines: string[] = []; + let line = ""; + for (const word of words) { + const next = line ? `${line} ${word}` : word; + if (next.length > maxLineLength) { + if (line) lines.push(line); + line = word; + } else { + line = next; + } + } + if (line) lines.push(line); + return lines.join("\n"); +} + +function computeFontSize(text: string, base: number): number { + const len = text.length; + if (len > 420) return Math.max(36, base - 16); + if (len > 300) return Math.max(40, base - 10); + if (len > 220) return Math.max(44, base - 6); + return base; +} + +function stripHashtags(text: string): string { + return text.replace(/#[^\s#]+/g, "").replace(/\s+/g, " ").trim(); +} + +function splitForSubtitles(text: string, maxChars: number): string[] { + const cleaned = text.replace(/\s+/g, " ").trim(); + if (!cleaned) return []; + if (cleaned.length <= maxChars) return [cleaned]; + const sentences = cleaned.split(/(?<=[.!?])\s+/); + const chunks: string[] = []; + let current = ""; + for (const sentence of sentences) { + if (!sentence) continue; + const next = current ? `${current} ${sentence}` : sentence; + if (next.length > maxChars) { + if (current) chunks.push(current); + if (sentence.length > maxChars) { + const words = sentence.split(" "); + let line = ""; + for (const word of words) { + const candidate = line ? `${line} ${word}` : word; + if (candidate.length > maxChars) { + if (line) chunks.push(line); + line = word; + } else { + line = candidate; + } + } + if (line) chunks.push(line); + current = ""; + } else { + current = sentence; + } + } else { + current = next; + } + } + if (current) chunks.push(current); + return chunks.length ? chunks : [cleaned]; +} + +function escapeAssText(text: string): string { + return text + .replace(/\\/g, "\\\\") + .replace(/\r?\n/g, "\\N") + .replace(/{/g, "\\{") + .replace(/}/g, "\\}"); +} + +function formatAssTime(seconds: number): string { + const total = Math.max(0, seconds); + const h = Math.floor(total / 3600); + const m = Math.floor((total % 3600) / 60); + const s = Math.floor(total % 60); + const cs = Math.floor((total - Math.floor(total)) * 100); + const pad = (n: number, len = 2) => String(n).padStart(len, "0"); + return `${h}:${pad(m)}:${pad(s)}.${pad(cs)}`; +} + +async function buildAssSubtitle( + caption: string, + duration: number, + width: number, + height: number, + fontSize: number, + safeBottomRatio: number +): Promise { + const segments = splitForSubtitles(caption, 140); + const weights = segments.map((s) => Math.max(1, s.length)); + const totalWeight = weights.reduce((sum, w) => sum + w, 0); + let minPerSegment = 1.4; + if (segments.length > 0 && minPerSegment * segments.length > duration) { + minPerSegment = Math.max(0.6, duration / segments.length); + } + const durations = weights.map((w) => + Math.max(minPerSegment, (duration * w) / Math.max(1, totalWeight)) + ); + const totalDur = durations.reduce((sum, d) => sum + d, 0); + const scale = totalDur > 0 ? duration / totalDur : 1; + const marginV = Math.round(height * safeBottomRatio); + const content = `[Script Info] +ScriptType: v4.00+ +PlayResX: ${width} +PlayResY: ${height} + +[V4+ Styles] +Format: Name, Fontname, Fontsize, PrimaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding +Style: Default, ${process.env.REEL_FONT_ASS ?? "DejaVu Sans"}, ${fontSize}, &H0000D1FF, &H00000000, &H60000000, 1,0,0,0, 100,100,0,0, 1, 4, 1.5, 2, 120, 120, ${marginV}, 1 + +[Events] +Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text +`; + let cursor = 0; + const events = segments + .map((segment, idx) => { + const segDur = durations[idx] * scale; + const start = cursor; + const end = Math.min(duration, cursor + segDur); + cursor = end; + const wrapped = wrapText(segment, 34); + const escaped = escapeAssText(wrapped); + return `Dialogue: 0,${formatAssTime(start)},${formatAssTime(end)},Default,,0,0,0,,${escaped}`; + }) + .join("\n"); + const fullContent = `${content}\n${events}\n`; + const filePath = path.join(os.tmpdir(), `reel-${Date.now()}.ass`); + await fs.writeFile(filePath, fullContent, "utf8"); + return filePath; +} + +async function buildAssSubtitleFromWords( + words: Array<{ word: string; start: number; end: number }>, + duration: number, + width: number, + height: number, + fontSize: number, + safeBottomRatio: number +): Promise { + const marginV = Math.round(height * safeBottomRatio); + const content = `[Script Info] +ScriptType: v4.00+ +PlayResX: ${width} +PlayResY: ${height} + +[V4+ Styles] +Format: Name, Fontname, Fontsize, PrimaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding +Style: Default, ${process.env.REEL_FONT_ASS ?? "DejaVu Sans"}, ${fontSize}, &H0000D1FF, &H00000000, &H60000000, 1,0,0,0, 100,100,0,0, 1, 4, 1.5, 2, 120, 120, ${marginV}, 1 + +[Events] +Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text +`; + + const maxLineLength = 32; + const maxWordsPerLine = 8; + const gapThreshold = 0.7; + const safeWords = words.filter( + (w) => Number.isFinite(w.start) && Number.isFinite(w.end) && w.word?.trim() + ); + const lines: Array<{ start: number; end: number; text: string }> = []; + let lineWords: string[] = []; + let lineStart = 0; + let lineEnd = 0; + const flushLine = (fallbackEnd?: number) => { + if (!lineWords.length) return; + lines.push({ + start: lineStart, + end: Math.min(duration, fallbackEnd ?? lineEnd), + text: lineWords.join(" ").replace(/\s+/g, " ").trim() + }); + lineWords = []; + }; + safeWords.forEach((word, idx) => { + const clean = word.word.trim(); + if (!lineWords.length) lineStart = Math.max(0, word.start); + const candidate = lineWords.length ? `${lineWords.join(" ")} ${clean}` : clean; + const wouldExceedWords = lineWords.length + 1 > maxWordsPerLine; + if ((candidate.length > maxLineLength || wouldExceedWords) && lineWords.length) { + flushLine(word.start); + lineStart = Math.max(0, word.start); + lineWords = [clean]; + } else { + lineWords.push(clean); + } + lineEnd = Math.min(duration, word.end); + const next = safeWords[idx + 1]; + const gap = next ? Math.max(0, next.start - word.end) : 0; + const endsSentence = /[.!?]["')\]]?$/.test(clean); + if (endsSentence || gap >= gapThreshold || !next) { + flushLine(next?.start ?? lineEnd); + } + }); + const holdRatio = 0.1; + const hookHoldRatio = 0.35; + const hookFontSize = Math.round(fontSize * 1.2); + const events = lines + .map((line, idx) => { + const start = Math.max(0, line.start); + const nextStart = lines[idx + 1]?.start ?? duration; + const baseEnd = Math.max(start, Math.min(duration, Math.min(line.end, nextStart))); + const gap = Math.max(0, nextStart - baseEnd); + const ratio = idx === 0 ? hookHoldRatio : holdRatio; + const hold = Math.min(gap, (baseEnd - start) * ratio); + const end = Math.min(duration, baseEnd + hold); + const wrapped = wrapText(line.text, maxLineLength); + const escaped = escapeAssText(wrapped); + const prefix = idx === 0 ? `{\\fs${hookFontSize}}` : ""; + return `Dialogue: 0,${formatAssTime(start)},${formatAssTime(end)},Default,,0,0,0,,${prefix}${escaped}`; + }) + .join("\n"); + + const fullContent = `${content}\n${events}\n`; + const filePath = path.join(os.tmpdir(), `reel-${Date.now()}-words.ass`); + await fs.writeFile(filePath, fullContent, "utf8"); + return filePath; +} + +function splitCaptionWords(text: string): string[] { + return text + .replace(/\s+/g, " ") + .trim() + .split(" ") + .filter(Boolean); +} + +function alignTimingsToCaption( + caption: string, + timings: Array<{ word: string; start: number; end: number }>, + duration: number +): Array<{ word: string; start: number; end: number }> { + const words = splitCaptionWords(caption); + if (!words.length || !timings.length) return []; + + const start0 = Number.isFinite(timings[0].start) ? timings[0].start : 0; + const end0 = Number.isFinite(timings[timings.length - 1].end) + ? timings[timings.length - 1].end + : duration; + const total = Math.max(0.01, end0 - start0); + + if (timings.length < words.length) { + return words.map((word, idx) => { + const start = start0 + (idx / words.length) * total; + const end = start0 + ((idx + 1) / words.length) * total; + return { word, start, end }; + }); + } + + return words.map((word, idx) => { + const tIdx = Math.floor((idx / words.length) * timings.length); + const t = timings[tIdx] ?? timings[timings.length - 1]; + const next = timings[Math.min(tIdx + 1, timings.length - 1)]; + const start = Number.isFinite(t.start) ? t.start : start0 + (idx / words.length) * total; + const end = + Number.isFinite(next?.start) && next.start > start + ? next.start + : Number.isFinite(t.end) + ? Math.max(start, t.end) + : start0 + ((idx + 1) / words.length) * total; + return { word, start, end }; + }); +} + +export async function buildKenBurnsReel(options: ReelOptions): Promise<{ duration: number }> { + const width = options.width ?? 1080; + const height = options.height ?? 1920; + const fps = options.fps ?? 30; + const safeBottomRatio = options.safeBottomRatio ?? 0.22; + const baseFontSize = options.fontSize ?? 62; + const fontPath = options.fontPath ?? process.env.REEL_FONT_PATH ?? ""; + const audioPath = options.audioPath ?? "tmp_voice.wav"; + + const voiceText = stripHashtags(options.voiceText?.trim() || options.caption); + await generateVoiceover(voiceText, audioPath, { voice: options.voice, provider: options.ttsProvider }); + const duration = await getDurationSeconds(audioPath); + if (options.maxDurationSeconds && duration > options.maxDurationSeconds) { + throw new Error(`Audio duration ${duration.toFixed(2)}s exceeds ${options.maxDurationSeconds}s limit.`); + } + + const subtitleText = stripHashtags(options.caption); + const fontSize = computeFontSize(subtitleText, baseFontSize); + const subtitlesMode = (process.env.REEL_SUBTITLES_MODE ?? "line").toLowerCase(); + let assPath = ""; + if (subtitlesMode === "word") { + try { + const timingLang = options.audioLanguage ?? options.language; + const words = await getWordTimings(audioPath, { language: timingLang }); + if (words.length > 0) { + const shouldAlign = + options.audioLanguage && + options.language && + options.audioLanguage.toLowerCase() !== options.language.toLowerCase(); + const aligned = shouldAlign + ? alignTimingsToCaption(subtitleText, words, duration) + : words; + assPath = await buildAssSubtitleFromWords(aligned, duration, width, height, fontSize, safeBottomRatio); + } + } catch (err) { + console.warn("[reel] word-level subtitles failed, falling back to line subtitles.", err); + } + } + if (!assPath) { + assPath = await buildAssSubtitle( + subtitleText, + duration, + width, + height, + fontSize, + safeBottomRatio + ); + } + const assEscaped = assPath.replace(/\\/g, "/").replace(/:/g, "\\:"); + + const filter = [ + `scale=${width}:${height}:force_original_aspect_ratio=increase`, + `crop=${width}:${height}`, + `subtitles='${assEscaped}'` + ].join(","); + + const args = [ + "-y", + "-loop", + "1", + "-i", + options.imagePath, + "-i", + audioPath, + "-t", + duration.toFixed(2), + "-vf", + filter, + "-r", + String(fps), + "-c:v", + "libx264", + "-pix_fmt", + "yuv420p", + "-c:a", + "aac", + "-shortest", + options.outputPath + ]; + + try { + await runCmd("ffmpeg", args); + } finally { + await fs.unlink(assPath).catch(() => undefined); + } + return { duration }; +} + +export async function buildVideoTemplateMontage( + options: ReelOptions & { videos: Array<{ path: string; durationSeconds: number }> } +): Promise<{ duration: number }> { + const width = options.width ?? 1080; + const height = options.height ?? 1920; + const fps = options.fps ?? 30; + const safeBottomRatio = options.safeBottomRatio ?? 0.22; + const baseFontSize = options.fontSize ?? 62; + const audioPath = options.audioPath ?? "tmp_voice.wav"; + + const videos = Array.isArray(options.videos) ? options.videos.filter((v) => v?.path) : []; + if (videos.length === 0) throw new Error("No template videos provided."); + + const voiceText = stripHashtags(options.voiceText?.trim() || options.caption); + await generateVoiceover(voiceText, audioPath, { voice: options.voice, provider: options.ttsProvider }); + const duration = await getDurationSeconds(audioPath); + if (options.maxDurationSeconds && duration > options.maxDurationSeconds) { + throw new Error(`Audio duration ${duration.toFixed(2)}s exceeds ${options.maxDurationSeconds}s limit.`); + } + + const totalRequested = videos.reduce((sum, vid) => sum + Math.max(0, vid.durationSeconds || 0), 0); + if (totalRequested <= 0) throw new Error("Template video durations must be greater than 0."); + const scale = duration / totalRequested; + const scaled = videos.map((vid) => ({ + path: vid.path, + durationSeconds: Math.max(0.5, (vid.durationSeconds || 0) * scale) + })); + + const segmentPaths: string[] = []; + const listPath = path.join(os.tmpdir(), `reel-vconcat-${Date.now()}-${Math.random().toString(36).slice(2)}.txt`); + const concatPath = path.join(os.tmpdir(), `reel-vconcat-${Date.now()}-${Math.random().toString(36).slice(2)}.mp4`); + + try { + for (const [idx, vid] of scaled.entries()) { + const segmentPath = path.join(os.tmpdir(), `reel-vseg-${Date.now()}-${idx}.mp4`); + const segDuration = vid.durationSeconds; + const filter = [ + `scale=${width}:${height}:force_original_aspect_ratio=increase`, + `crop=${width}:${height}` + ].join(","); + const args = [ + "-y", + "-stream_loop", + "-1", + "-i", + vid.path, + "-t", + segDuration.toFixed(2), + "-vf", + filter, + "-r", + String(fps), + "-c:v", + "libx264", + "-pix_fmt", + "yuv420p", + "-an", + segmentPath + ]; + await runCmd("ffmpeg", args); + segmentPaths.push(segmentPath); + } + + const listContent = segmentPaths.map((p) => `file '${p.replace(/'/g, "'\\\\''")}'`).join("\n"); + await fs.writeFile(listPath, listContent, "utf8"); + await runCmd("ffmpeg", [ + "-y", + "-f", + "concat", + "-safe", + "0", + "-i", + listPath, + "-c:v", + "libx264", + "-pix_fmt", + "yuv420p", + "-r", + String(fps), + concatPath + ]); + + const subtitleText = stripHashtags(options.caption); + const fontSize = computeFontSize(subtitleText, baseFontSize); + const subtitlesMode = (process.env.REEL_SUBTITLES_MODE ?? "line").toLowerCase(); + let assPath = ""; + if (subtitlesMode === "word") { + try { + const timingLang = options.audioLanguage ?? options.language; + const words = await getWordTimings(audioPath, { language: timingLang }); + if (words.length > 0) { + const shouldAlign = + options.audioLanguage && + options.language && + options.audioLanguage.toLowerCase() !== options.language.toLowerCase(); + const aligned = shouldAlign + ? alignTimingsToCaption(subtitleText, words, duration) + : words; + assPath = await buildAssSubtitleFromWords(aligned, duration, width, height, fontSize, safeBottomRatio); + } + } catch (err) { + console.warn("[reel] word-level subtitles failed, falling back to line subtitles.", err); + } + } + if (!assPath) { + assPath = await buildAssSubtitle( + subtitleText, + duration, + width, + height, + fontSize, + safeBottomRatio + ); + } + const assEscaped = assPath.replace(/\\/g, "/").replace(/:/g, "\\:"); + + const filter = [`subtitles='${assEscaped}'`].join(","); + const args = [ + "-y", + "-i", + concatPath, + "-i", + audioPath, + "-t", + duration.toFixed(2), + "-vf", + filter, + "-r", + String(fps), + "-c:v", + "libx264", + "-pix_fmt", + "yuv420p", + "-c:a", + "aac", + "-shortest", + options.outputPath + ]; + try { + await runCmd("ffmpeg", args); + } finally { + await fs.unlink(assPath).catch(() => undefined); + } + } finally { + await fs.unlink(listPath).catch(() => undefined); + await fs.unlink(concatPath).catch(() => undefined); + await Promise.all(segmentPaths.map((p) => fs.unlink(p).catch(() => undefined))); + } + + return { duration }; +} + +export async function buildKenBurnsSlideshow( + options: ReelOptions & { images: Array<{ path: string; durationSeconds: number }> } +): Promise<{ duration: number }> { + const width = options.width ?? 1080; + const height = options.height ?? 1920; + const fps = options.fps ?? 30; + const zoomTarget = clamp(options.zoomEnd ?? 1.08, 1.03, 1.14); + const safeBottomRatio = options.safeBottomRatio ?? 0.22; + const baseFontSize = options.fontSize ?? 62; + const audioPath = options.audioPath ?? "tmp_voice.wav"; + + const images = Array.isArray(options.images) ? options.images.filter((i) => i?.path) : []; + if (images.length === 0) throw new Error("No template images provided."); + + const voiceText = stripHashtags(options.voiceText?.trim() || options.caption); + await generateVoiceover(voiceText, audioPath, { voice: options.voice, provider: options.ttsProvider }); + const duration = await getDurationSeconds(audioPath); + if (options.maxDurationSeconds && duration > options.maxDurationSeconds) { + throw new Error(`Audio duration ${duration.toFixed(2)}s exceeds ${options.maxDurationSeconds}s limit.`); + } + + const totalRequested = images.reduce((sum, img) => sum + Math.max(0, img.durationSeconds || 0), 0); + if (totalRequested <= 0) throw new Error("Template image durations must be greater than 0."); + const scale = duration / totalRequested; + const scaled = images.map((img) => ({ + path: img.path, + durationSeconds: Math.max(0.5, (img.durationSeconds || 0) * scale) + })); + const crossfadeRaw = Number(process.env.REEL_XFADE_SECONDS ?? 0.18); + const minSceneDuration = scaled.reduce((min, item) => Math.min(min, item.durationSeconds), Number.POSITIVE_INFINITY); + const motionEnabled = images.length > 1; + let crossfadeSeconds = + scaled.length > 1 && Number.isFinite(crossfadeRaw) + ? clamp(crossfadeRaw, 0.10, 0.22) + : 0; + crossfadeSeconds = Math.min(crossfadeSeconds, Math.max(0.08, minSceneDuration * 0.2)); + if (crossfadeSeconds < 0.1) crossfadeSeconds = 0; + const segmentDurations = scaled.map((img, idx) => + crossfadeSeconds > 0 && idx < scaled.length - 1 ? img.durationSeconds + crossfadeSeconds : img.durationSeconds + ); + + const segmentPaths: string[] = []; + const concatPath = path.join(os.tmpdir(), `reel-concat-${Date.now()}-${Math.random().toString(36).slice(2)}.mp4`); + + try { + const basePresetIndex = hashString(`${options.caption}|${scaled[0]?.path ?? ""}`); + for (const [idx, img] of scaled.entries()) { + const segmentPath = path.join(os.tmpdir(), `reel-seg-${Date.now()}-${idx}.mp4`); + const segDuration = segmentDurations[idx] ?? img.durationSeconds; + const totalFrames = Math.max(1, Math.ceil(segDuration * fps)); + const filterParts = [ + `scale=${width}:${height}:force_original_aspect_ratio=increase`, + `crop=${width}:${height}` + ]; + if (motionEnabled) { + filterParts.push( + buildKenBurnsZoompanFilter({ + fps, + totalFrames, + zoomTarget, + presetIndex: basePresetIndex, + width, + height, + allowPan: false + }) + ); + } + const filter = filterParts.join(","); + const args = [ + "-y", + "-loop", + "1", + "-i", + img.path, + "-t", + segDuration.toFixed(2), + "-vf", + filter, + "-r", + String(fps), + "-c:v", + "libx264", + "-pix_fmt", + "yuv420p", + "-an", + segmentPath + ]; + await runCmd("ffmpeg", args); + segmentPaths.push(segmentPath); + } + + if (segmentPaths.length === 1) { + await fs.copyFile(segmentPaths[0], concatPath); + } else if (crossfadeSeconds <= 0) { + const listPath = path.join(os.tmpdir(), `reel-concat-${Date.now()}-${Math.random().toString(36).slice(2)}.txt`); + try { + const listContent = segmentPaths.map((p) => `file '${p.replace(/'/g, "'\\\\''")}'`).join("\n"); + await fs.writeFile(listPath, listContent, "utf8"); + await runCmd("ffmpeg", [ + "-y", + "-f", + "concat", + "-safe", + "0", + "-i", + listPath, + "-c:v", + "libx264", + "-pix_fmt", + "yuv420p", + "-r", + String(fps), + concatPath + ]); + } finally { + await fs.unlink(listPath).catch(() => undefined); + } + } else { + const inputs = segmentPaths.flatMap((p) => ["-i", p]); + const chains: string[] = []; + let current = "[0:v]"; + let offset = (segmentDurations[0] ?? 0) - crossfadeSeconds; + for (let i = 1; i < segmentPaths.length; i += 1) { + const output = `[v${i}]`; + chains.push( + `${current}[${i}:v]xfade=transition=fade:duration=${crossfadeSeconds.toFixed(3)}:offset=${Math.max(0, offset).toFixed(3)}${output}` + ); + current = output; + if (i < segmentPaths.length - 1) { + offset += (segmentDurations[i] ?? 0) - crossfadeSeconds; + } + } + await runCmd("ffmpeg", [ + "-y", + ...inputs, + "-filter_complex", + chains.join(";"), + "-map", + current, + "-r", + String(fps), + "-c:v", + "libx264", + "-pix_fmt", + "yuv420p", + concatPath + ]); + } + + const subtitleText = stripHashtags(options.caption); + const fontSize = computeFontSize(subtitleText, baseFontSize); + const subtitlesMode = (process.env.REEL_SUBTITLES_MODE ?? "line").toLowerCase(); + let assPath = ""; + if (subtitlesMode === "word") { + try { + const timingLang = options.audioLanguage ?? options.language; + const words = await getWordTimings(audioPath, { language: timingLang }); + if (words.length > 0) { + const shouldAlign = + options.audioLanguage && + options.language && + options.audioLanguage.toLowerCase() !== options.language.toLowerCase(); + const aligned = shouldAlign + ? alignTimingsToCaption(subtitleText, words, duration) + : words; + assPath = await buildAssSubtitleFromWords(aligned, duration, width, height, fontSize, safeBottomRatio); + } + } catch (err) { + console.warn("[reel] word-level subtitles failed, falling back to line subtitles.", err); + } + } + if (!assPath) { + assPath = await buildAssSubtitle( + subtitleText, + duration, + width, + height, + fontSize, + safeBottomRatio + ); + } + const assEscaped = assPath.replace(/\\/g, "/").replace(/:/g, "\\:"); + + const filter = [`subtitles='${assEscaped}'`].join(","); + const args = [ + "-y", + "-i", + concatPath, + "-i", + audioPath, + "-t", + duration.toFixed(2), + "-vf", + filter, + "-r", + String(fps), + "-c:v", + "libx264", + "-pix_fmt", + "yuv420p", + "-c:a", + "aac", + "-shortest", + options.outputPath + ]; + try { + await runCmd("ffmpeg", args); + } finally { + await fs.unlink(assPath).catch(() => undefined); + } + } finally { + await fs.unlink(concatPath).catch(() => undefined); + await Promise.all(segmentPaths.map((p) => fs.unlink(p).catch(() => undefined))); + } + + return { duration }; +} diff --git a/src/reels/tts.ts b/src/reels/tts.ts new file mode 100644 index 0000000..846bd08 --- /dev/null +++ b/src/reels/tts.ts @@ -0,0 +1,176 @@ +import { spawn } from "node:child_process"; +import { KokoroTTS } from "kokoro-js"; +import { openai } from "../ai/openai.js"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +type TtsOptions = { + voice?: string; + provider?: string; +}; + +function runCmd(cmd: string, args: string[]): Promise { + return new Promise((resolve, reject) => { + const child = spawn(cmd, args, { stdio: "inherit" }); + child.on("error", reject); + child.on("close", (code) => { + if (code === 0) resolve(); + else reject(new Error(`${cmd} exited with code ${code}`)); + }); + }); +} + +function normalizeText(text: string): string { + return text.replace(/\s+/g, " ").trim(); +} + +function splitText(text: string, maxChars: number): string[] { + const cleaned = normalizeText(text); + if (cleaned.length <= maxChars) return [cleaned]; + const sentences = cleaned.split(/(?<=[.!?])\s+/); + const chunks: string[] = []; + let current = ""; + for (const sentence of sentences) { + if (!sentence) continue; + const next = current ? `${current} ${sentence}` : sentence; + if (next.length > maxChars) { + if (current) chunks.push(current); + if (sentence.length > maxChars) { + const words = sentence.split(" "); + let line = ""; + for (const word of words) { + const candidate = line ? `${line} ${word}` : word; + if (candidate.length > maxChars) { + if (line) chunks.push(line); + line = word; + } else { + line = candidate; + } + } + if (line) chunks.push(line); + current = ""; + } else { + current = sentence; + } + } else { + current = next; + } + } + if (current) chunks.push(current); + return chunks.length ? chunks : [cleaned]; +} + +let kokoroPromise: Promise | null = null; + +async function getKokoro(): Promise { + if (!kokoroPromise) { + const model = + process.env.REEL_TTS_KOKORO_MODEL ?? + "onnx-community/Kokoro-82M-v1.0-ONNX"; + const dtype = process.env.REEL_TTS_KOKORO_DTYPE ?? "q8"; + kokoroPromise = KokoroTTS.from_pretrained(model, { dtype }); + } + return kokoroPromise; +} + +export async function generateVoiceover( + text: string, + outPath: string, + options: TtsOptions = {} +): Promise { + const cleaned = normalizeText(text); + if (!cleaned) throw new Error("Empty text for TTS"); + + const customCmd = process.env.REEL_TTS_CMD; + if (customCmd) { + const cmd = customCmd + .replace(/\{\{\s*text\s*\}\}/g, cleaned) + .replace(/\{\{\s*out\s*\}\}/g, outPath); + await runCmd(process.env.SHELL ?? "bash", ["-lc", cmd]); + return; + } + + const provider = (options.provider || process.env.REEL_TTS_PROVIDER || "local").toLowerCase(); + const maxChars = Number.parseInt(process.env.REEL_TTS_MAX_CHARS ?? "350", 10); + const chunks = splitText(cleaned, Number.isFinite(maxChars) ? maxChars : 350); + + if (provider === "openai") { + const allowedVoices = new Set([ + "nova", + "shimmer", + "echo", + "onyx", + "fable", + "alloy", + "ash", + "sage", + "coral" + ]); + const requested = options.voice?.trim() || ""; + const fallback = process.env.REEL_TTS_OPENAI_VOICE || "alloy"; + const voice = allowedVoices.has(requested) ? requested : fallback; + const model = process.env.REEL_TTS_OPENAI_MODEL || "tts-1"; + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "reel-tts-")); + const chunkPaths: string[] = []; + try { + for (let i = 0; i < chunks.length; i++) { + const chunkPath = path.join(tempDir, `chunk-${i}.wav`); + const resp = await openai.audio.speech.create({ + model, + voice, + input: chunks[i], + response_format: "wav" + }); + const buffer = Buffer.from(await resp.arrayBuffer()); + await fs.writeFile(chunkPath, buffer); + chunkPaths.push(chunkPath); + } + if (chunkPaths.length === 1) { + await fs.copyFile(chunkPaths[0], outPath); + return; + } + const listPath = path.join(tempDir, "concat.txt"); + const listContent = chunkPaths.map((p) => `file '${p.replace(/'/g, "'\\''")}'`).join("\n"); + await fs.writeFile(listPath, listContent, "utf8"); + try { + await runCmd("ffmpeg", ["-y", "-f", "concat", "-safe", "0", "-i", listPath, "-c", "copy", outPath]); + } catch { + await runCmd("ffmpeg", ["-y", "-f", "concat", "-safe", "0", "-i", listPath, "-c:a", "pcm_s16le", outPath]); + } + return; + } finally { + await Promise.allSettled(chunkPaths.map((p) => fs.unlink(p))); + await fs.rm(tempDir, { recursive: true, force: true }); + } + } + + const voice = options.voice?.trim() || process.env.REEL_TTS_KOKORO_VOICE || "af_heart"; + const tts = await getKokoro(); + if (chunks.length === 1) { + const audio = await tts.generate(chunks[0], { voice }); + await audio.save(outPath); + return; + } + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "reel-tts-")); + const chunkPaths: string[] = []; + try { + for (let i = 0; i < chunks.length; i++) { + const chunkPath = path.join(tempDir, `chunk-${i}.wav`); + const audio = await tts.generate(chunks[i], { voice }); + await audio.save(chunkPath); + chunkPaths.push(chunkPath); + } + const listPath = path.join(tempDir, "concat.txt"); + const listContent = chunkPaths.map((p) => `file '${p.replace(/'/g, "'\\''")}'`).join("\n"); + await fs.writeFile(listPath, listContent, "utf8"); + try { + await runCmd("ffmpeg", ["-y", "-f", "concat", "-safe", "0", "-i", listPath, "-c", "copy", outPath]); + } catch { + await runCmd("ffmpeg", ["-y", "-f", "concat", "-safe", "0", "-i", listPath, "-c:a", "pcm_s16le", outPath]); + } + } finally { + await Promise.allSettled(chunkPaths.map((p) => fs.unlink(p))); + await fs.rm(tempDir, { recursive: true, force: true }); + } +} diff --git a/src/reels/wordTimings.ts b/src/reels/wordTimings.ts new file mode 100644 index 0000000..c3a7273 --- /dev/null +++ b/src/reels/wordTimings.ts @@ -0,0 +1,102 @@ +import fs from "node:fs"; +import fsPromises from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { spawn } from "node:child_process"; +import { openai } from "../ai/openai.js"; + +export type WordTiming = { + word: string; + start: number; + end: number; +}; + +function normalizeLanguage(lang?: string | null): string | undefined { + const raw = (lang ?? "").trim().toUpperCase(); + if (!raw) return undefined; + if (raw === "HINDI") return "hi"; + if (raw === "URDU") return "ur"; + if (raw === "HINGLISH") return "en"; + if (raw === "EN") return "en"; + return undefined; +} + +export async function getWordTimings( + audioPath: string, + options: { language?: string | null } = {} +): Promise { + const provider = (process.env.REEL_STT_PROVIDER ?? "openai").toLowerCase(); + if (provider === "local") { + return getWordTimingsLocal(audioPath, options); + } + const language = normalizeLanguage(options.language); + const model = process.env.REEL_STT_MODEL ?? "whisper-1"; + const resp = await openai.audio.transcriptions.create({ + model, + file: fs.createReadStream(audioPath), + response_format: "verbose_json", + timestamp_granularities: ["word"], + language + } as any); + + const words = (resp as any)?.words ?? []; + if (!Array.isArray(words)) return []; + return words + .map((w: any) => ({ + word: String(w.word ?? "").trim(), + start: Number(w.start ?? 0), + end: Number(w.end ?? 0) + })) + .filter((w: WordTiming) => w.word && Number.isFinite(w.start) && Number.isFinite(w.end)); +} + +function runCmd(cmd: string, args: string[]): Promise { + return new Promise((resolve, reject) => { + const child = spawn(cmd, args, { stdio: "inherit" }); + child.on("error", reject); + child.on("close", (code) => { + if (code === 0) resolve(); + else reject(new Error(`${cmd} exited with code ${code}`)); + }); + }); +} + +async function getWordTimingsLocal( + audioPath: string, + options: { language?: string | null } = {} +): Promise { + const language = normalizeLanguage(options.language); + const model = process.env.REEL_STT_LOCAL_MODEL ?? "small"; + const tempDir = await fsPromises.mkdtemp(path.join(os.tmpdir(), "whisper-")); + try { + const scriptPath = path.resolve(process.cwd(), "scripts/faster_whisper_words.py"); + const args = [scriptPath, audioPath, model, language ?? "auto"]; + const output = await runCmdCapture("python3", args); + const data = JSON.parse(output); + const words = Array.isArray(data?.words) ? data.words : []; + return words + .map((w: any) => ({ + word: String(w?.word ?? "").trim(), + start: Number(w?.start ?? 0), + end: Number(w?.end ?? 0) + })) + .filter((w: WordTiming) => w.word && Number.isFinite(w.start) && Number.isFinite(w.end)); + } finally { + await fsPromises.rm(tempDir, { recursive: true, force: true }); + } +} + +function runCmdCapture(cmd: string, args: string[]): Promise { + return new Promise((resolve, reject) => { + const child = spawn(cmd, args); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (d) => (stdout += d.toString())); + child.stderr.on("data", (d) => (stderr += d.toString())); + child.on("error", reject); + child.on("close", (code) => { + if (code === 0) return resolve(stdout.trim()); + reject(new Error(`${cmd} exited with code ${code}: ${stderr || stdout}`)); + }); + }); +} diff --git a/src/routes/auth/local.ts b/src/routes/auth/local.ts index 09618ba..efeff43 100644 --- a/src/routes/auth/local.ts +++ b/src/routes/auth/local.ts @@ -17,6 +17,15 @@ async function renderHtml(filePath: string) { return fs.readFile(filePath, "utf8"); } +function escapeHtml(value: string): string { + return String(value) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/\"/g, """) + .replace(/'/g, "'"); +} + export function localAuthRouter() { const router = Router(); @@ -59,7 +68,9 @@ export function localAuthRouter() { const orgName = org?.name || "your organization"; const filePath = path.resolve(process.cwd(), "src/ui/invite.html"); let html = await renderHtml(filePath); - html = html.replaceAll("{{inviteToken}}", token).replaceAll("{{orgName}}", orgName); + html = html + .replaceAll("{{inviteToken}}", encodeURIComponent(token)) + .replaceAll("{{orgName}}", escapeHtml(orgName)); res.setHeader("content-type", "text/html; charset=utf-8"); res.end(html); }); diff --git a/src/routes/pages.ts b/src/routes/pages.ts index 1771aca..4b0b3a3 100644 --- a/src/routes/pages.ts +++ b/src/routes/pages.ts @@ -1,7 +1,7 @@ import { Router } from "express"; import path from "node:path"; import { query, queryOne } from "../db/index.js"; -import { listDraftPosts, listFbPostsForPost } from "../db/posts.js"; +import { countDraftPosts, listDraftPosts, listFbPostsForPost } from "../db/posts.js"; import { listPostSchedules } from "../db/postSchedules.js"; import { listPostImagesForPosts } from "../db/postImages.js"; import { listPostReelsForPost } from "../db/postReels.js"; @@ -19,6 +19,7 @@ type Draft = { date: string; postId: string; storyKey: string; + jobId?: string | null; jobDefinitionId?: string | null; status?: string; kind?: string; @@ -48,8 +49,11 @@ async function listPosts( orgId: string, statusFilter?: string, jobDefinitionFilter?: string, - publishedFilter?: string -): Promise { + jobIdFilter?: string, + publishedFilter?: string, + page = 1, + pageSize = 20 +): Promise<{ posts: Draft[]; total: number; page: number; pageSize: number }> { const status = statusFilter && ["draft", "approved", "published", "rejected", "all"].includes(statusFilter) ? (statusFilter as "draft" | "approved" | "published" | "rejected" | "all") @@ -61,7 +65,28 @@ async function listPosts( if (jobDefinitionFilter) jobDefinitionId = jobDefinitionFilter; } - const rows = await listDraftPosts(orgId, { status, jobDefinitionId, limit: 500 }); + const jobId = jobIdFilter ? jobIdFilter.trim() : ""; + const publishedMode = + publishedFilter && ["all", "any", "facebook", "youtube", "none"].includes(publishedFilter) + ? publishedFilter + : "all"; + const safePage = Math.max(1, Number(page) || 1); + const safePageSize = Math.min(100, Math.max(1, Number(pageSize) || 20)); + const offset = (safePage - 1) * safePageSize; + const total = await countDraftPosts(orgId, { + status, + jobDefinitionId, + jobId: jobId || undefined, + published: publishedMode as "all" | "any" | "facebook" | "youtube" | "none" + }); + const rows = await listDraftPosts(orgId, { + status, + jobDefinitionId, + jobId: jobId || undefined, + published: publishedMode as "all" | "any" | "facebook" | "youtube" | "none", + limit: safePageSize, + offset + }); const postIds = rows.map((r) => r.id); const fbPublished = new Set(); const ytPublished = new Set(); @@ -103,26 +128,10 @@ async function listPosts( } const drafts: Draft[] = []; - const publishedMode = publishedFilter && typeof publishedFilter === "string" ? publishedFilter : "all"; - const matchesPublished = (fb: boolean, yt: boolean) => { - switch (publishedMode) { - case "any": - return fb || yt; - case "facebook": - return fb; - case "youtube": - return yt; - case "none": - return !fb && !yt; - default: - return true; - } - }; for (const row of rows) { const publishedFacebook = fbPublished.has(row.id); const publishedYoutube = ytPublished.has(row.id); const publishedAny = publishedFacebook || publishedYoutube; - if (!matchesPublished(publishedFacebook, publishedYoutube)) continue; const schedules = await listPostSchedules(orgId, row.id); const pending = schedules.filter((s) => s.status === "pending"); const nextAt = pending @@ -167,6 +176,7 @@ async function listPosts( date: String(created).slice(0, 10), postId: row.id, storyKey: row.story_key ?? String(row.id), + jobId: row.job_id ?? null, jobDefinitionId: row.job_definition_id ?? null, status: row.status, kind: row.kind, @@ -200,7 +210,7 @@ async function listPosts( texts }); } - return drafts; + return { posts: drafts, total, page: safePage, pageSize: safePageSize }; } export function pagesRouter() { @@ -214,8 +224,13 @@ export function pagesRouter() { const publishedAllowed = new Set(["all", "any", "facebook", "youtube", "none"]); const published = publishedAllowed.has(publishedRaw) ? publishedRaw : "all"; const jobRaw = typeof req.query.jobDefinitionId === "string" ? req.query.jobDefinitionId : "all"; + const jobIdRaw = typeof req.query.jobId === "string" ? req.query.jobId.trim() : ""; + const pageRaw = typeof req.query.page === "string" ? req.query.page.trim() : "1"; + const page = Math.max(1, Number(pageRaw) || 1); + const pageSize = 20; const orgId = req.org.id; - const posts = await listPosts(orgId, status, jobRaw, published); + const postsResult = await listPosts(orgId, status, jobRaw, jobIdRaw, published, page, pageSize); + const posts = postsResult.posts; const socialAccounts = (await listSocialAccounts(orgId)).filter((acc) => acc.enabled); const jobDefinitions = (await listJobDefinitions(orgId)).map((def) => ({ ...def, @@ -223,19 +238,21 @@ export function pagesRouter() { })); const jobCounts = await getJobCounts(orgId); const jobs = await listJobs(orgId, 10); - const renderedPosts = posts.map((post) => { - const langs = Object.keys(post.texts).sort(); - return { - ...post, - texts: langs.map((lang) => ({ lang, text: post.texts[lang] })) - }; - }); - const filePath = path.resolve(process.cwd(), "src/ui/index.html"); const html = await renderTemplate(filePath, { title: "Funrang · Posts", - posts: renderedPosts, postsJson: JSON.stringify(posts), + indexPageDataJson: JSON.stringify({ + posts, + status, + published, + jobDefinitionId: jobRaw, + jobId: jobIdRaw, + jobDefinitions, + page: postsResult.page, + pageSize: postsResult.pageSize, + total: postsResult.total + }), socialAccounts, socialAccountsJson: JSON.stringify(socialAccounts), status, @@ -253,6 +270,7 @@ export function pagesRouter() { jobDefinitionId: jobRaw, jobDefinitionIsAll: jobRaw === "all", jobDefinitionIsNone: jobRaw === "none", + jobId: jobIdRaw, jobs, jobCounts, jobsJson: JSON.stringify(jobs), @@ -286,10 +304,11 @@ export function pagesRouter() { post_media_source?: string | null; post_media_count?: number | null; post_media_assets?: any | null; + is_ai_generated?: boolean | null; }>( ` SELECT id, status, title, message_en, message_en_json, translations_json, fact_pack, created_at, job_definition_id, source_url, story_key, - post_media_mode, post_media_source, post_media_count, post_media_assets + post_media_mode, post_media_source, post_media_count, post_media_assets, is_ai_generated FROM posts WHERE id = $1 AND organization_id = $2 `, @@ -435,7 +454,8 @@ export function pagesRouter() { mediaMode: row.post_media_mode ?? "single", mediaSource: row.post_media_source ?? "ai", mediaCount: row.post_media_count ?? 1, - mediaAssets: mediaAssets.length ? mediaAssets : null + mediaAssets: mediaAssets.length ? mediaAssets : null, + isAiGenerated: row.is_ai_generated !== false }; const html = await renderTemplate(filePath, { title: `Funrang · Post ${row.id}`, diff --git a/src/routes/render.ts b/src/routes/render.ts index e35aeff..0d39a18 100644 --- a/src/routes/render.ts +++ b/src/routes/render.ts @@ -11,6 +11,15 @@ const headerPath = path.resolve(process.cwd(), "src/ui/partials/header.html"); const sidebarPath = path.resolve(process.cwd(), "src/ui/partials/sidebar.html"); const settingsSidebarPath = path.resolve(process.cwd(), "src/ui/partials/settings-sidebar.html"); const viteAssetsPath = path.resolve(process.cwd(), "src/ui/dist/assets"); +const viteManifestPath = path.resolve(process.cwd(), "src/ui/dist/manifest.json"); + +type ViteManifestEntry = { + file: string; + css?: string[]; + imports?: string[]; +}; + +type ViteManifest = Record; async function getLayout(): Promise { if (layoutCache) return layoutCache; @@ -47,23 +56,90 @@ function getEntryNamesFromBody(bodyTemplate: string): string[] { } async function getViteCssLinks(entryNames: string[]): Promise { + async function getCssFromManifest(entries: string[]): Promise { + try { + const raw = await fs.readFile(viteManifestPath, "utf8"); + const manifest = JSON.parse(raw) as ViteManifest; + const byFile = new Map(); + for (const key of Object.keys(manifest)) { + const file = manifest[key]?.file; + if (file) byFile.set(file, key); + } + + const entryKeys = entries + .map((entry) => byFile.get(`${entry}.js`) || "") + .filter(Boolean); + if (!entryKeys.length) return []; + + const visited = new Set(); + const selected = new Set(); + const stack = [...entryKeys]; + + while (stack.length) { + const key = stack.pop(); + if (!key || visited.has(key)) continue; + visited.add(key); + const node = manifest[key]; + if (!node) continue; + for (const cssFile of node.css || []) { + if (cssFile && cssFile.endsWith(".css")) selected.add(cssFile); + } + for (const dep of node.imports || []) { + if (dep && !visited.has(dep)) stack.push(dep); + } + } + + return [...selected]; + } catch { + return null; + } + } + try { + const manifestCss = await getCssFromManifest(entryNames); + if (manifestCss && manifestCss.length) { + return manifestCss + .sort() + .map((file) => ``) + .join("\n"); + } + const files = await fs.readdir(viteAssetsPath); const cssFiles = files.filter((name) => name.endsWith(".css")); if (!cssFiles.length) return ""; const selected = new Set(); + const mtimeByFile = new Map(); - // Shared PrimeVue/PrimeIcons stylesheet emitted by Vite. - for (const file of cssFiles) { - if (file.startsWith("primevue-")) selected.add(file); + async function pickLatestByPrefix(prefix: string): Promise { + const matches = cssFiles.filter((file) => file.startsWith(prefix)); + if (!matches.length) return null; + + let best: string | null = null; + let bestMtime = -1; + for (const file of matches) { + let mtime = mtimeByFile.get(file); + if (mtime == null) { + const stat = await fs.stat(path.join(viteAssetsPath, file)); + mtime = stat.mtimeMs; + mtimeByFile.set(file, mtime); + } + if (mtime > bestMtime) { + best = file; + bestMtime = mtime; + } + } + return best; } + // Shared PrimeVue/PrimeIcons stylesheet emitted by Vite. + const primeVueCss = await pickLatestByPrefix("primevue-"); + if (primeVueCss) selected.add(primeVueCss); + // Entry-local styles (e.g. schedulesApp-*.css). for (const entry of entryNames) { - for (const file of cssFiles) { - if (file.startsWith(`${entry}-`)) selected.add(file); - } + const entryCss = await pickLatestByPrefix(`${entry}-`); + if (entryCss) selected.add(entryCss); } return [...selected] diff --git a/src/runner/aiRunner.ts b/src/runner/aiRunner.ts index b88547e..ffa0b20 100644 --- a/src/runner/aiRunner.ts +++ b/src/runner/aiRunner.ts @@ -102,6 +102,7 @@ export async function runAiOnly(input: { fact_pack: undefined, translations_json: Object.keys(translatedWithTags).length ? JSON.stringify(finalPostsByLang) : undefined, job_definition_id: summary.jobDefinitionId ?? undefined, + job_id: options?.jobId ?? undefined, content_hash: contentHash, created_at: now }); diff --git a/src/runner/realRunner.ts b/src/runner/realRunner.ts index c03ca1c..1811e8c 100644 --- a/src/runner/realRunner.ts +++ b/src/runner/realRunner.ts @@ -219,6 +219,7 @@ export async function runRealRunner(input: { fact_pack: JSON.stringify(factPack), translations_json: JSON.stringify(finalPostsByLang), job_definition_id: summary.jobDefinitionId ?? undefined, + job_id: options?.jobId ?? undefined, content_hash: contentHash, created_at: now }); diff --git a/src/runner/types.ts b/src/runner/types.ts index 1eb7d9f..da8cef2 100644 --- a/src/runner/types.ts +++ b/src/runner/types.ts @@ -2,6 +2,7 @@ import type { Lang } from "../domain/types.js"; export type RunnerOptions = { orgId?: string; + jobId?: string; sourceIds?: string[]; categoryIds?: string[]; sourceTypes?: string[]; diff --git a/src/server.ts b/src/server.ts index a27499c..dd1b940 100644 --- a/src/server.ts +++ b/src/server.ts @@ -31,11 +31,15 @@ const shortsDir = process.env.SHORTS_DIR ?? "shorts"; const ttsPreviewDir = process.env.TTS_PREVIEW_DIR ?? "tts_previews"; const postMediaDir = process.env.POST_MEDIA_DIR ?? "post_media"; -app.use(`/${archiveDir}`, express.static(path.resolve(process.cwd(), archiveDir))); -app.use(`/${reelsDir}`, express.static(path.resolve(process.cwd(), reelsDir))); -app.use(`/${shortsDir}`, express.static(path.resolve(process.cwd(), shortsDir))); -app.use(`/${ttsPreviewDir}`, express.static(path.resolve(process.cwd(), ttsPreviewDir))); -app.use(`/${postMediaDir}`, express.static(path.resolve(process.cwd(), postMediaDir))); +function mountProtectedStatic(mountPath: string, dirName: string) { + app.use(mountPath, requireAuth, express.static(path.resolve(process.cwd(), dirName))); +} + +mountProtectedStatic(`/${archiveDir}`, archiveDir); +mountProtectedStatic(`/${reelsDir}`, reelsDir); +mountProtectedStatic(`/${shortsDir}`, shortsDir); +mountProtectedStatic(`/${ttsPreviewDir}`, ttsPreviewDir); +mountProtectedStatic(`/${postMediaDir}`, postMediaDir); app.use("/assets", express.static(path.resolve(process.cwd(), "src/ui/dist/assets"))); app.use("/ui", express.static(path.resolve(process.cwd(), "src/ui"))); app.use(express.json({ limit: "25mb" })); diff --git a/src/state/outbox.ts b/src/state/outbox.ts index a614f7b..d452d4b 100644 --- a/src/state/outbox.ts +++ b/src/state/outbox.ts @@ -24,6 +24,7 @@ export type PendingOutboxRow = { post_media_source?: string | null; post_media_count?: number | null; post_media_assets?: any | null; + is_ai_generated?: boolean | null; }; export function enqueueOutbox( @@ -88,7 +89,8 @@ export async function claimPendingOutbox(limit: number, leaseMs: number): Promis p.post_media_mode, p.post_media_source, p.post_media_count, - p.post_media_assets + p.post_media_assets, + p.is_ai_generated FROM outbox o JOIN posts p ON p.id = o.post_id AND p.organization_id = o.organization_id LEFT JOIN social_accounts sa ON sa.id = o.account_id AND sa.organization_id = o.organization_id diff --git a/src/ui/ai.html b/src/ui/ai.html index 7825014..6c75327 100644 --- a/src/ui/ai.html +++ b/src/ui/ai.html @@ -1,112 +1,2 @@ -
- -
- - -
- - -
- -
-
-
Classification
-
-
- - -
-
- - -
After the limit, we fall back to rule-based keyword filters.
-
-
-
-
- - -
-
- - -
-
-
- - -
-
-
- - -
- -
- +
+ diff --git a/src/ui/index.html b/src/ui/index.html index 0347bb0..7bbf877 100644 --- a/src/ui/index.html +++ b/src/ui/index.html @@ -1,104 +1,3 @@ -
- -
-
-
Filter posts
-
- - - -
-
- {{#posts}} -
-
-
date {{date}}{{#jobDefinitionId}} · job {{jobDefinitionId}}{{/jobDefinitionId}}
-
- {{#originLabel}}{{originLabel}}{{/originLabel}} - {{#similarStory}}🔁 Similar{{/similarStory}} - {{#scheduleCount}} - Scheduled · {{scheduleCount}} - {{/scheduleCount}} - {{^scheduleCount}} - - {{/scheduleCount}} - {{#publishedFacebook}}Facebook published{{/publishedFacebook}} - {{#publishedYoutube}}YouTube published{{/publishedYoutube}} - {{#status}}{{status}}{{/status}} -
-
-
-
- {{#imageUrl}}post image{{/imageUrl}} - {{^imageUrl}}
No image
{{/imageUrl}} -
-
- {{#texts}} -
-
{{lang}}
-
{{text}}
-
- {{/texts}} - {{#sources}}
{{sources}}
{{/sources}} - {{#factPack}} -
- Fact pack -
{{factPack}}
-
- {{/factPack}} -
- Manage -
-
-
-
- {{/posts}} - {{^posts}} -

No posts yet.

- {{/posts}} -
- -
- +
+ + diff --git a/src/ui/js/aiPage.js b/src/ui/js/aiPage.js deleted file mode 100644 index 1fd02ea..0000000 --- a/src/ui/js/aiPage.js +++ /dev/null @@ -1,242 +0,0 @@ -import { escapeHtml } from "./utils.js"; - -export function initAiPage() { - const llmEnabledEl = document.getElementById("aiLlmEnabled"); - const dailyLimitEl = document.getElementById("aiDailyLimit"); - const ruleIncludeEl = document.getElementById("aiRuleInclude"); - const ruleExcludeEl = document.getElementById("aiRuleExclude"); - const saveBtn = document.getElementById("aiSaveBtn"); - const saveStatus = document.getElementById("aiSaveStatus"); - - const tableBody = document.querySelector("#aiTemplatesTable tbody"); - const tplName = document.getElementById("tplName"); - const tplChannel = document.getElementById("tplChannel"); - const tplDescription = document.getElementById("tplDescription"); - const tplPrompt = document.getElementById("tplPrompt"); - const tplTone = document.getElementById("tplTone"); - const tplSaveBtn = document.getElementById("tplSaveBtn"); - const tplResetBtn = document.getElementById("tplResetBtn"); - const tplStatus = document.getElementById("tplStatus"); - const tplNewBtn = document.getElementById("tplNewBtn"); - const tplFormPanel = document.getElementById("tplFormPanel"); - const tabButtons = Array.from(document.querySelectorAll(".tab-btn")); - const tabPanels = Array.from(document.querySelectorAll("[data-tab-panel]")); - - let templates = []; - let editingId = null; - - function setTab(name) { - tabButtons.forEach((btn) => { - const isActive = btn.getAttribute("data-tab") === name; - btn.classList.toggle("active", isActive); - }); - tabPanels.forEach((panel) => { - const isActive = panel.getAttribute("data-tab-panel") === name; - panel.classList.toggle("hidden", !isActive); - }); - } - - const splitList = (input) => - String(input || "") - .split(/[\n,]/g) - .map((v) => v.trim()) - .filter(Boolean); - - const joinList = (items) => (Array.isArray(items) ? items.join(", ") : ""); - - async function loadSettings() { - try { - const res = await fetch("/api/ai/settings"); - const data = await res.json(); - const settings = data?.settings || {}; - if (llmEnabledEl) llmEnabledEl.checked = Boolean(settings.llm_enabled); - if (dailyLimitEl) dailyLimitEl.value = String(settings.llm_daily_limit ?? 1000); - if (ruleIncludeEl) ruleIncludeEl.value = joinList(settings.rule_include); - if (ruleExcludeEl) ruleExcludeEl.value = joinList(settings.rule_exclude); - } catch (err) { - if (saveStatus) saveStatus.textContent = "Failed to load settings."; - } - } - - async function saveSettings() { - if (!saveBtn) return; - saveBtn.disabled = true; - if (saveStatus) saveStatus.textContent = "Saving..."; - try { - const payload = { - llm_enabled: Boolean(llmEnabledEl?.checked), - llm_daily_limit: Number(dailyLimitEl?.value || 0), - fallback_mode: "rules", - rule_include: splitList(ruleIncludeEl?.value), - rule_exclude: splitList(ruleExcludeEl?.value) - }; - const res = await fetch("/api/ai/settings", { - method: "PUT", - headers: { "content-type": "application/json" }, - body: JSON.stringify(payload) - }); - const data = await res.json(); - if (!data?.ok) throw new Error(data?.error || "Save failed"); - if (saveStatus) saveStatus.textContent = "Saved."; - } catch (err) { - if (saveStatus) saveStatus.textContent = "Failed to save."; - } finally { - saveBtn.disabled = false; - } - } - - function renderTemplates() { - if (!tableBody) return; - if (!Array.isArray(templates) || templates.length === 0) { - tableBody.innerHTML = "No templates yet."; - return; - } - tableBody.innerHTML = templates - .map((t) => { - const scope = t.organization_id ? "Org" : "Global"; - const actions = t.organization_id - ? ` - ` - : ``; - return ` - - ${escapeHtml(t.name)} - ${escapeHtml(scope)} - ${escapeHtml(t.channel)} - ${actions} - - `; - }) - .join(""); - - tableBody.querySelectorAll("button[data-action]").forEach((btn) => { - btn.addEventListener("click", async () => { - const action = btn.getAttribute("data-action"); - const id = btn.getAttribute("data-id"); - const tpl = templates.find((t) => t.id === id); - if (!tpl) return; - if (action === "delete") { - if (!confirm(`Delete template "${tpl.name}"?`)) return; - await deleteTemplate(id); - return; - } - loadTemplateIntoForm(tpl, action === "edit"); - }); - }); - } - - function loadTemplateIntoForm(tpl, isEdit) { - editingId = isEdit ? tpl.id : null; - if (tplName) tplName.value = tpl.name || ""; - if (tplChannel) tplChannel.value = tpl.channel || "facebook"; - if (tplDescription) tplDescription.value = tpl.description || ""; - if (tplPrompt) tplPrompt.value = tpl.prompt_text || ""; - if (tplTone) tplTone.value = tpl.tone_config ? JSON.stringify(tpl.tone_config, null, 2) : ""; - if (tplFormPanel) tplFormPanel.classList.remove("hidden"); - setTab("templates"); - if (tplStatus) { - tplStatus.textContent = isEdit - ? `Editing "${tpl.name}"` - : `Using "${tpl.name}" as a starting point`; - } - } - - function resetTemplateForm() { - editingId = null; - if (tplName) tplName.value = ""; - if (tplChannel) tplChannel.value = "facebook"; - if (tplDescription) tplDescription.value = ""; - if (tplPrompt) tplPrompt.value = ""; - if (tplTone) tplTone.value = ""; - if (tplStatus) tplStatus.textContent = ""; - if (tplFormPanel) tplFormPanel.classList.add("hidden"); - } - - async function loadTemplates() { - try { - const res = await fetch("/api/prompt-templates"); - const data = await res.json(); - templates = Array.isArray(data.templates) ? data.templates : []; - renderTemplates(); - } catch (err) { - if (tplStatus) tplStatus.textContent = "Failed to load templates."; - } - } - - async function deleteTemplate(id) { - if (!id) return; - if (tplStatus) tplStatus.textContent = "Deleting..."; - try { - const res = await fetch(`/api/prompt-templates/${id}`, { method: "DELETE" }); - const data = await res.json(); - if (!data?.ok) throw new Error(data?.error || "Delete failed"); - if (tplStatus) tplStatus.textContent = "Deleted."; - resetTemplateForm(); - await loadTemplates(); - } catch (err) { - if (tplStatus) tplStatus.textContent = "Delete failed."; - } - } - - async function saveTemplate() { - if (!tplSaveBtn) return; - tplSaveBtn.disabled = true; - if (tplStatus) tplStatus.textContent = "Saving..."; - let toneConfig = null; - try { - if (tplTone?.value?.trim()) toneConfig = JSON.parse(tplTone.value.trim()); - } catch (err) { - if (tplStatus) tplStatus.textContent = "Tone must be valid JSON."; - tplSaveBtn.disabled = false; - return; - } - try { - const payload = { - name: tplName?.value?.trim() || "", - description: tplDescription?.value?.trim() || "", - channel: tplChannel?.value || "facebook", - prompt_text: tplPrompt?.value?.trim() || "", - tone_config: toneConfig - }; - if (!payload.name || !payload.prompt_text) { - if (tplStatus) tplStatus.textContent = "Name and prompt are required."; - tplSaveBtn.disabled = false; - return; - } - const res = await fetch(editingId ? `/api/prompt-templates/${editingId}` : "/api/prompt-templates", { - method: editingId ? "PATCH" : "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify(payload) - }); - const data = await res.json(); - if (!data?.ok) throw new Error(data?.error || "Save failed"); - if (tplStatus) tplStatus.textContent = "Saved."; - resetTemplateForm(); - await loadTemplates(); - } catch (err) { - if (tplStatus) tplStatus.textContent = "Save failed."; - } finally { - tplSaveBtn.disabled = false; - } - } - - if (saveBtn) saveBtn.addEventListener("click", saveSettings); - if (tplSaveBtn) tplSaveBtn.addEventListener("click", saveTemplate); - if (tplResetBtn) tplResetBtn.addEventListener("click", resetTemplateForm); - if (tplNewBtn) { - tplNewBtn.addEventListener("click", () => { - resetTemplateForm(); - if (tplFormPanel) tplFormPanel.classList.remove("hidden"); - setTab("templates"); - }); - } - tabButtons.forEach((btn) => { - btn.addEventListener("click", () => setTab(btn.getAttribute("data-tab"))); - }); - - loadSettings(); - loadTemplates(); - setTab("classification"); -} - -initAiPage(); diff --git a/src/ui/js/app.js b/src/ui/js/app.js deleted file mode 100644 index c139c6c..0000000 --- a/src/ui/js/app.js +++ /dev/null @@ -1,11 +0,0 @@ -import { parseJsonScript } from "./utils.js"; -import { initModal } from "./modal.js"; -import { initPosts } from "./posts.js"; - -const postsData = parseJsonScript("posts-data", []); - -const postById = new Map(postsData.map((d) => [d.id, d])); - -const modal = initModal({ draftById: postById }); - -initPosts({ postById, modal }); diff --git a/src/ui/js/jobs.js b/src/ui/js/jobs.js deleted file mode 100644 index 4cce14a..0000000 --- a/src/ui/js/jobs.js +++ /dev/null @@ -1,106 +0,0 @@ -import { escapeHtml } from "./utils.js"; - -export function initJobs({ - jobCountsEl, - jobsListEl, - refreshBtn, - initialCounts, - initialJobs, - pollMs = 5000 -}) { - let refreshing = false; - - function renderJobCounts(counts) { - if (!counts || !jobCountsEl) return; - jobCountsEl.innerHTML = ` - created ${counts.created} - in_progress ${counts.in_progress} - completed ${counts.completed} - failed ${counts.failed} - `; - } - - function formatSkips(skips) { - const entries = Object.entries(skips ?? {}); - if (entries.length === 0) return ""; - const top = entries - .sort((a, b) => b[1] - a[1]) - .slice(0, 4) - .map(([key, val]) => `${key}(${val})`) - .join(", "); - return `Skips: ${top}`; - } - - function formatJobMeta(meta) { - if (!meta) return ""; - let data = null; - try { - data = typeof meta === "string" ? JSON.parse(meta) : meta; - } catch { - return ""; - } - if (!data) return ""; - const rss = data.rss - ? `RSS: ${data.rss.itemsInserted}/${data.rss.itemsSeen} new, ${data.rss.sourcesOk}/${data.rss.sourcesTotal} ok` - : ""; - const scrape = data.scrape - ? `Scrape: ${data.scrape.itemsInserted ?? 0} items, ${data.scrape.pagesFetched ?? 0}/${data.scrape.pagesSeen ?? 0} pages` - : ""; - const posts = `Posts: ${data.createdPosts ?? 0} created, ${data.draftsSaved ?? 0} saved`; - const clusters = `Clusters: ${data.clusters ?? 0} total, ${data.selected ?? 0} selected`; - const reason = data.noPostsReason ? `Reason: ${data.noPostsReason}` : ""; - const skips = data.skips ? formatSkips(data.skips) : ""; - const parts = [rss, scrape, clusters, posts, skips, reason].filter(Boolean); - if (parts.length === 0) return ""; - return `
${parts.map((p) => `
${escapeHtml(p)}
`).join("")}
`; - } - - function renderJobsList(jobs) { - if (!jobsListEl) return; - if (!jobs || jobs.length === 0) { - jobsListEl.innerHTML = '
No jobs yet.
'; - return; - } - jobsListEl.innerHTML = jobs - .map((job) => { - const finished = job.finished_at ? `
finished ${job.finished_at}
` : ""; - const error = job.error ? `
${escapeHtml(job.error)}
` : ""; - const meta = formatJobMeta(job.meta); - return ` -
-
#${job.id}
-
${job.status}
-
created ${job.created_at}
- ${finished} - ${meta} - ${error} -
- `; - }) - .join(""); - } - - async function refreshJobs() { - if (refreshing) return; - refreshing = true; - if (refreshBtn) refreshBtn.classList.add("loading"); - try { - const res = await fetch("/api/jobs"); - const data = await res.json(); - renderJobCounts(data.counts); - renderJobsList(data.recent); - } finally { - if (refreshBtn) refreshBtn.classList.remove("loading"); - refreshing = false; - } - } - - if (refreshBtn) refreshBtn.addEventListener("click", refreshJobs); - renderJobCounts(initialCounts); - renderJobsList(initialJobs); - if (pollMs > 0) { - setInterval(refreshJobs, pollMs); - } - - return { refreshJobs }; -} diff --git a/src/ui/js/modal.js b/src/ui/js/modal.js deleted file mode 100644 index 127c6a9..0000000 --- a/src/ui/js/modal.js +++ /dev/null @@ -1,272 +0,0 @@ -export function initModal({ draftById }) { - const modal = document.getElementById("modal"); - const modalTitle = document.getElementById("modalTitle"); - const modalMeta = document.getElementById("modalMeta"); - const modalImage = document.getElementById("modalImage"); - const modalImagePlaceholder = document.getElementById("modalImagePlaceholder"); - const langSelect = document.getElementById("langSelect"); - const captionInput = document.getElementById("captionInput"); - const modalStatus = document.getElementById("modalStatus"); - const saveBtn = document.getElementById("saveBtn"); - const approveBtn = document.getElementById("approveBtn"); - const publishBtn = document.getElementById("publishBtn"); - const scheduleAt = document.getElementById("scheduleAt"); - const scheduleAccounts = document.getElementById("scheduleAccounts"); - const scheduleBtn = document.getElementById("scheduleBtn"); - const scheduleStatus = document.getElementById("scheduleStatus"); - const scheduleList = document.getElementById("scheduleList"); - const modalClose = document.getElementById("modalClose"); - const backdrop = modal?.querySelector(".modal-backdrop"); - const rejectReason = document.getElementById("rejectReason"); - const rejectNotes = document.getElementById("rejectNotes"); - const rejectBtn = document.getElementById("rejectBtn"); - const rejectStatus = document.getElementById("rejectStatus"); - - let currentDraft = null; - let currentLang = null; - let socialAccounts = []; - - try { - const raw = document.getElementById("social-accounts-data")?.textContent || "[]"; - socialAccounts = JSON.parse(raw); - } catch { - socialAccounts = []; - } - - function renderScheduleList(items) { - if (!scheduleList) return; - if (!Array.isArray(items) || items.length === 0) { - scheduleList.innerHTML = "
No schedules yet.
"; - return; - } - scheduleList.innerHTML = items - .map((s) => { - const when = new Date(s.scheduledAt); - const timeLabel = Number.isNaN(when.getTime()) ? s.scheduledAt : when.toLocaleString(); - return ` -
- ${timeLabel} - ${s.status} - ${s.channel} - ${s.accountId ? `#${s.accountId}` : ""} - -
- `; - }) - .join(""); - scheduleList.querySelectorAll(".delete-schedule").forEach((btn) => { - btn.addEventListener("click", async () => { - const row = btn.closest(".schedule-item"); - if (!row) return; - const id = Number(row.getAttribute("data-id")); - if (!Number.isFinite(id)) return; - btn.disabled = true; - try { - await fetch(`/api/schedules/${id}`, { method: "DELETE" }); - await refreshSchedules(); - } finally { - btn.disabled = false; - } - }); - }); - } - - function updateSchedulePill() { - if (!currentDraft) return; - const card = document.querySelector(`.card[data-id="${currentDraft.id}"]`); - const pill = card?.querySelector("[data-schedule-pill]"); - if (!pill) return; - const count = currentDraft.schedules?.length ?? 0; - if (count > 0) { - pill.textContent = `Scheduled · ${count}`; - pill.classList.remove("hidden"); - } else { - pill.textContent = ""; - pill.classList.add("hidden"); - } - } - - async function refreshSchedules() { - if (!currentDraft) return; - try { - const res = await fetch(`/posts/${currentDraft.id}/schedules`); - const data = await res.json(); - if (data?.ok && Array.isArray(data.schedules)) { - currentDraft.schedules = data.schedules; - renderScheduleList(currentDraft.schedules); - updateSchedulePill(); - } - } catch { - // ignore - } - } - - function openModal(draft) { - if (!modal) return; - currentDraft = draft; - const langs = Object.keys(draft.texts); - currentLang = langs.includes("en") ? "en" : langs[0]; - langSelect.innerHTML = langs - .sort() - .map((lang) => ``) - .join(""); - langSelect.value = currentLang; - captionInput.value = draft.texts[currentLang] || ""; - modalTitle.textContent = "Manage"; - modalMeta.textContent = `date ${draft.date} · id ${draft.postId}`; - modalStatus.textContent = ""; - if (scheduleStatus) scheduleStatus.textContent = ""; - if (scheduleAt) { - const now = new Date(); - const local = new Date(now.getTime() - now.getTimezoneOffset() * 60000).toISOString().slice(0, 16); - scheduleAt.value = local; - } - if (scheduleAccounts) { - const items = (socialAccounts || []).filter((acc) => acc.enabled); - scheduleAccounts.innerHTML = items.length - ? items - .map( - (acc) => - `` - ) - .join("") - : "
No enabled social accounts.
"; - } - if (rejectReason) rejectReason.value = ""; - if (rejectNotes) rejectNotes.value = ""; - if (rejectStatus) rejectStatus.textContent = ""; - if (draft.imageUrl) { - modalImage.src = draft.imageUrl; - modalImage.style.display = "block"; - modalImagePlaceholder.style.display = "none"; - } else { - modalImage.style.display = "none"; - modalImagePlaceholder.style.display = "block"; - } - renderScheduleList(draft.schedules || []); - updateSchedulePill(); - modal.classList.remove("hidden"); - modal.setAttribute("aria-hidden", "false"); - } - - function closeModal() { - if (!modal) return; - modal.classList.add("hidden"); - modal.setAttribute("aria-hidden", "true"); - currentDraft = null; - currentLang = null; - } - - async function saveDraft() { - if (!currentDraft || !currentLang) return; - modalStatus.textContent = "Saving..."; - const payload = { - lang: currentLang, - text: captionInput.value - }; - const res = await fetch(`/posts/${currentDraft.id}/update`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify(payload) - }); - const data = await res.json(); - if (data.ok) { - currentDraft.texts[currentLang] = payload.text; - const card = document.querySelector(`[data-id="${currentDraft.id}"]`)?.closest(".card"); - const block = card?.querySelector(`.lang-block[data-lang="${currentLang}"] pre`); - if (block) block.textContent = payload.text; - modalStatus.textContent = "Saved."; - } else { - modalStatus.textContent = data.error || "Save failed."; - } - } - - async function publishDraft() { - if (!currentDraft || !currentLang) return; - modalStatus.textContent = "Publishing..."; - const payload = { - lang: currentLang, - text: captionInput.value, - accountIds: Array.from( - scheduleAccounts?.querySelectorAll('input[type="checkbox"]:checked') ?? [] - ).map((el) => Number(el.value)) - }; - const res = await fetch(`/posts/${currentDraft.id}/publish`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify(payload) - }); - const data = await res.json(); - modalStatus.textContent = data.ok ? "Queued to outbox." : data.error || "Publish failed."; - } - - async function approvePost() { - if (!currentDraft) return; - modalStatus.textContent = "Approving..."; - const res = await fetch(`/posts/${currentDraft.id}/approve`, { - method: "POST", - headers: { "content-type": "application/json" } - }); - const data = await res.json(); - modalStatus.textContent = data.ok ? "Approved." : data.error || "Approve failed."; - } - - async function rejectDraft() { - if (!currentDraft) return; - if (!rejectReason || !rejectStatus) return; - const reason = rejectReason.value; - if (!reason) { - rejectStatus.textContent = "Please select a reason."; - return; - } - rejectStatus.textContent = "Rejecting..."; - const payload = { - reason, - notes: rejectNotes?.value ?? "" - }; - const res = await fetch(`/posts/${currentDraft.id}/reject`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify(payload) - }); - const data = await res.json(); - rejectStatus.textContent = data.ok ? "Rejected." : data.error || "Reject failed."; - } - - async function schedulePost() { - if (!currentDraft) return; - if (!scheduleAt) return; - const when = scheduleAt.value; - scheduleStatus.textContent = "Scheduling..."; - const accountIds = Array.from( - scheduleAccounts?.querySelectorAll('input[type="checkbox"]:checked') ?? [] - ).map((el) => Number(el.value)); - const res = await fetch(`/posts/${currentDraft.id}/schedule`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ scheduledAt: when, accountIds }) - }); - const data = await res.json(); - scheduleStatus.textContent = data.ok ? "Scheduled." : data.error || "Schedule failed."; - if (data.ok) { - await refreshSchedules(); - } - } - - langSelect.addEventListener("change", () => { - if (!currentDraft) return; - currentLang = langSelect.value; - captionInput.value = currentDraft.texts[currentLang] || ""; - modalStatus.textContent = ""; - }); - - saveBtn.addEventListener("click", saveDraft); - approveBtn?.addEventListener("click", approvePost); - publishBtn.addEventListener("click", publishDraft); - scheduleBtn?.addEventListener("click", schedulePost); - rejectBtn?.addEventListener("click", rejectDraft); - modalClose.addEventListener("click", closeModal); - backdrop?.addEventListener("click", closeModal); - - return { openModal, closeModal }; -} diff --git a/src/ui/js/posts.js b/src/ui/js/posts.js deleted file mode 100644 index cfd75b5..0000000 --- a/src/ui/js/posts.js +++ /dev/null @@ -1,9 +0,0 @@ -export function initPosts({ postById, modal }) { - document.querySelectorAll(".preview-btn").forEach((btn) => { - btn.addEventListener("click", () => { - const id = btn.getAttribute("data-id"); - const post = postById.get(Number(id)); - if (post) modal.openModal(post); - }); - }); -} diff --git a/src/ui/js/runJob.js b/src/ui/js/runJob.js deleted file mode 100644 index 96290e3..0000000 --- a/src/ui/js/runJob.js +++ /dev/null @@ -1,17 +0,0 @@ -export function initRunJob({ runBtn, statusEl, refreshJobs }) { - if (!runBtn) return; - runBtn.addEventListener("click", async () => { - runBtn.disabled = true; - if (statusEl) statusEl.textContent = "Running..."; - try { - const res = await fetch("/api/jobs", { method: "POST" }); - const data = await res.json(); - if (statusEl) statusEl.textContent = data.ok ? `Queued job #${data.id}` : "Failed."; - if (refreshJobs) await refreshJobs(); - } catch (e) { - if (statusEl) statusEl.textContent = "Failed."; - } finally { - runBtn.disabled = false; - } - }); -} diff --git a/src/ui/social.html b/src/ui/social.html index 30d2400..7f9a0ae 100644 --- a/src/ui/social.html +++ b/src/ui/social.html @@ -1,34 +1,2 @@ -
- -
-
- - -
-
-
Connect
-
- -
Uses OAuth to connect and import your Facebook Pages.
-
- -
-
-
Social accounts
-
-
-
- -
- +
+ diff --git a/src/ui/styles/input.css b/src/ui/styles/input.css index 6637bb9..9a8dd46 100644 --- a/src/ui/styles/input.css +++ b/src/ui/styles/input.css @@ -959,6 +959,13 @@ gap: 6px; color: var(--text); } + .confirm-list li { list-style: none; } + .confirm-select-row { + display: inline-flex; + align-items: center; + gap: 8px; + cursor: pointer; + } @media (max-width: 900px) { .page-layout { grid-template-columns: 1fr; } } diff --git a/src/ui/styles/tailwind.css b/src/ui/styles/tailwind.css index 875deec..d84e28d 100644 --- a/src/ui/styles/tailwind.css +++ b/src/ui/styles/tailwind.css @@ -77,6 +77,10 @@ .filter { filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,); } +.backdrop-filter { + -webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); + backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); +} .transition { transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to, opacity, box-shadow, transform, translate, scale, rotate, filter, -webkit-backdrop-filter, backdrop-filter, display, content-visibility, overlay, pointer-events; transition-timing-function: var(--tw-ease, ease); @@ -1646,6 +1650,15 @@ pre { gap: 6px; color: var(--text); } +.confirm-list li { + list-style: none; +} +.confirm-select-row { + display: inline-flex; + align-items: center; + gap: 8px; + cursor: pointer; +} @media (max-width: 900px) { .page-layout { grid-template-columns: 1fr; @@ -2642,6 +2655,42 @@ pre { syntax: "*"; inherits: false; } +@property --tw-backdrop-blur { + syntax: "*"; + inherits: false; +} +@property --tw-backdrop-brightness { + syntax: "*"; + inherits: false; +} +@property --tw-backdrop-contrast { + syntax: "*"; + inherits: false; +} +@property --tw-backdrop-grayscale { + syntax: "*"; + inherits: false; +} +@property --tw-backdrop-hue-rotate { + syntax: "*"; + inherits: false; +} +@property --tw-backdrop-invert { + syntax: "*"; + inherits: false; +} +@property --tw-backdrop-opacity { + syntax: "*"; + inherits: false; +} +@property --tw-backdrop-saturate { + syntax: "*"; + inherits: false; +} +@property --tw-backdrop-sepia { + syntax: "*"; + inherits: false; +} @layer properties { @supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) { *, ::before, ::after, ::backdrop { @@ -2665,6 +2714,15 @@ pre { --tw-drop-shadow-color: initial; --tw-drop-shadow-alpha: 100%; --tw-drop-shadow-size: initial; + --tw-backdrop-blur: initial; + --tw-backdrop-brightness: initial; + --tw-backdrop-contrast: initial; + --tw-backdrop-grayscale: initial; + --tw-backdrop-hue-rotate: initial; + --tw-backdrop-invert: initial; + --tw-backdrop-opacity: initial; + --tw-backdrop-saturate: initial; + --tw-backdrop-sepia: initial; } } } diff --git a/src/ui/vue/ai/App.vue b/src/ui/vue/ai/App.vue new file mode 100644 index 0000000..0dd8ee9 --- /dev/null +++ b/src/ui/vue/ai/App.vue @@ -0,0 +1,548 @@ + + +