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>
56 lines
2 KiB
TypeScript
56 lines
2 KiB
TypeScript
// ffmpeg-based frame extraction. Cap based on video length per V3 brief §4 stage 4:
|
||
// ≤15s : 1 fps (max 15)
|
||
// 16–60s: 1/2 fps (max 30)
|
||
// 61–180s: 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 };
|
||
}
|