social-reporting-tool/v2/pipeline/lib/frames.ts
DJP b89e8b511e Add V2: multi-team social-reporting platform with manifest-gated linking
V2 lives entirely under v2/ and is built around three asks the team raised
about V1: per-video assets sometimes drifted onto the wrong trend, hashtag
scrapes returned junk that wasn't filterable per-client, and there was no
multi-user model behind Microsoft SSO.

Highlights:
- Stable TikTok numeric-id key for every per-video asset; URL form drift is
  logged loudly to drift_log.jsonl and never silently nulls assets. Stage 5
  manifest hard-gates Stage 6 if any selected video is missing any required
  asset; --drop-failing auto-backfills from the next-best recipe candidates.
- Per-brief engagement floor (min_likes / min_plays / min_stl_pct), applied
  at Apify scrape time and re-validated locally; spend_log.json records
  raw_returned vs kept_after_floor per scrape.
- Users + teams + memberships with owner/admin/editor/viewer roles; SSO
  upserts a user keyed on Azure oid, auto-creates a personal team, and a
  super-admin is bootstrapped via BOOTSTRAP_SUPER_ADMIN_EMAIL on first
  sign-in. Phase A integration test: 16/16 pass.
- 10-stage TS pipeline (brief → seed → scrape1 → select → scrape2 →
  validate → analyse → insights → trends → qa → build) wired through one
  CLI; each stage idempotent + resumable from disk via .state sentinels.
  §4.5 rubrics shipped under prompts/ and loaded into Claude calls.
- React 18 + Vite + TS + Tailwind operator SPA: brief intake form,
  team management, super-admin user list, help/FAQ ported from V1.
- Separate Docker Compose project (name: social-reporting-v2, port 3457,
  Postgres 5437) with deploy/setup-v2.sh, deploy-v2.sh, rollback-to-v1.sh
  scripts that take over V1's /social-reports URL and let us roll back.

Verification: 62 unit tests pass (auth/session, ids extractor with full URL
fixture, engagement floor, recipes, manifest, linking-fix, MoM compare).
Live smoke run on a Dove brief: 1400 raw → 253 kept (82% culled) → 21
fully-bundled videos → 25 editorial trends across 8 brief-driven categories,
with drift=0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 17:39:07 -04:00

56 lines
2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ffmpeg-based frame extraction. Cap based on video length per V3 brief §4 stage 4:
// ≤15s : 1 fps (max 15)
// 1660s: 1/2 fps (max 30)
// 61180s: 1/4 fps (max 45)
// >180s : 1/6 fps (max 60)
// All frames downscaled to 720px wide jpg.
import { spawnSync } from 'node:child_process';
import { mkdirSync, existsSync, readdirSync } from 'node:fs';
export interface FrameExtractOpts {
/** path to local mp4 file. */
mp4Path: string;
/** output directory; will be created. Files written as 0001.jpg, 0002.jpg, … */
outDir: string;
/** video duration in seconds (used to pick fps + cap). */
durationSec: number;
/** override fps and cap (testing only). */
override?: { fps?: number; cap?: number };
}
export interface FrameExtractResult {
ok: boolean;
frames: string[]; // basenames written
fps: number;
cap: number;
error?: string;
}
export function chooseFpsAndCap(durationSec: number): { fps: number; cap: number } {
if (durationSec <= 15) return { fps: 1, cap: 15 };
if (durationSec <= 60) return { fps: 0.5, cap: 30 };
if (durationSec <= 180) return { fps: 0.25, cap: 45 };
return { fps: 1 / 6, cap: 60 };
}
export function extractFrames(opts: FrameExtractOpts): FrameExtractResult {
if (!existsSync(opts.mp4Path)) return { ok: false, frames: [], fps: 0, cap: 0, error: 'mp4 missing' };
mkdirSync(opts.outDir, { recursive: true });
const { fps, cap } = opts.override
? { fps: opts.override.fps ?? 1, cap: opts.override.cap ?? 15 }
: chooseFpsAndCap(opts.durationSec);
const args = [
'-y', '-i', opts.mp4Path,
'-vf', `fps=${fps},scale=720:-2`,
'-frames:v', String(cap),
'-q:v', '4',
`${opts.outDir}/%04d.jpg`,
];
const res = spawnSync('ffmpeg', args, { encoding: 'utf-8' });
if (res.status !== 0) {
return { ok: false, frames: [], fps, cap, error: `ffmpeg exit ${res.status}: ${res.stderr.slice(-400)}` };
}
const files = existsSync(opts.outDir) ? readdirSync(opts.outDir).filter((f) => f.endsWith('.jpg')).sort() : [];
return { ok: files.length > 0, frames: files, fps, cap };
}