SPB Git forge
7commits 1branches 0releases
229.0 KBsize
maindefault branch
12 days agolast push
TypeScript 91.8% HTML 3.2% JavaScript 3% SQL 1.4% CSS 0.7%
5.5 KB · 120 lines typescript
Raw Blame History
1import fs from "node:fs";2import path from "node:path";3import type { Page } from "playwright";4import { createLogger, shortHash, sleep, type MediaLevel, type ObservedEntity, type ObservedMedia, type Platform, type Provenance } from "@src/shared";5import type { ClassifiedResponse, DomSnapshot } from "@src/observers";67const log = createLogger("media");89/**10 * Media Intelligence Layer (§17–§19, §47). Video detection fuses three surfaces:11 *  - DOM <video> elements (playback state, dimensions, duration)12 *  - network media manifests / segments (delivery kind + CDN hostnames)13 *  - entities of type video (platform id, title, author, thumbnail)14 * Frame sampling (LEVEL 2) captures a few element screenshots at fixed positions — no download of the artifact.15 */16export function detectVideos(opts: { platform: Platform; snapshot: DomSnapshot; entities: ObservedEntity[]; responses: ClassifiedResponse[]; pageUrl: string }): ObservedMedia[] {17  const out = new Map<string, ObservedMedia>();18  const mediaResponses = opts.responses.filter((r) => r.kind === "media_manifest" || r.kind === "media_segment");19  const hostnames = [...new Set(mediaResponses.map((r) => r.fingerprint.hostname))];20  const manifest = mediaResponses.find((r) => r.kind === "media_manifest");21  const deliveryKind: "progressive" | "hls" | "dash" | "unknown" = manifest22    ? /m3u8|mpegurl/i.test(manifest.response.url + manifest.response.content_type)23      ? "hls"24      : /mpd|dash/i.test(manifest.response.url + manifest.response.content_type)25        ? "dash"26        : "unknown"27    : mediaResponses.length28      ? "progressive"29      : "unknown";3031  // 1) Current video (the one playing / present in DOM) — bind it to the current-page video entity when there is one.32  const current = opts.entities.find((e) => e.type === "video" && (e.context === "current video" || (e.url && samePage(e.url, opts.pageUrl))));33  for (const v of opts.snapshot.video_elements) {34    if (!(v.width > 0 || v.duration || v.current_src)) continue;35    const id = current?.platform_id ?? shortHash(v.current_src ?? v.src ?? opts.pageUrl, 12);36    const fp = `${opts.platform}:video:${id}`;37    const prov: Provenance[] = [{ surface: "dom", confidence: 0.9, detail: "video element" }];38    if (mediaResponses.length) prov.push({ surface: "network", confidence: 0.85, detail: `${mediaResponses.length} media responses` });39    if (current) prov.push(...current.provenance);40    out.set(fp, {41      media_type: "video",42      platform: opts.platform,43      platform_media_id: current?.platform_id ?? undefined,44      url: v.current_src ?? v.src,45      page_url: opts.pageUrl,46      title: current?.name ?? opts.snapshot.h1,47      author: current?.author,48      duration_s: v.duration ?? current?.media?.duration_s,49      width: v.width || undefined,50      height: v.height || undefined,51      thumbnail_url: v.poster ?? current?.media?.thumbnail_url,52      delivery: { kind: deliveryKind, manifest_url: manifest?.response.url, hostnames },53      fingerprint: fp,54      provenance: prov,55    });56  }57  // 2) Video entities visible on the page (feed cards, search results): metadata-level media records.58  for (const e of opts.entities) {59    if (e.type !== "video" || out.has(e.fingerprint)) continue;60    out.set(e.fingerprint, {61      media_type: "video",62      platform: opts.platform,63      platform_media_id: e.platform_id,64      page_url: e.url,65      title: e.name,66      author: e.author,67      duration_s: e.media?.duration_s,68      thumbnail_url: e.media?.thumbnail_url,69      fingerprint: e.fingerprint,70      provenance: e.provenance,71    });72  }73  return [...out.values()];74}7576function samePage(a: string, b: string): boolean {77  try {78    const ua = new URL(a);79    const ub = new URL(b);80    return ua.pathname === ub.pathname && ua.searchParams.get("v") === ub.searchParams.get("v");81  } catch {82    return false;83  }84}8586/**87 * VideoFrameSampler (§18): LEVEL ≥ 2 — screenshot the <video> element at 0 / 25 / 50 / 75 / 100 %.88 * Seeking a video the account is legitimately watching is normal viewer behaviour.89 * Returns file paths; perceptual hashing is left as a follow-up (MediaDeduplicator seam).90 */91export async function sampleVideoFrames(page: Page, media: ObservedMedia, mediaDir: string, level: MediaLevel, maxFrames = 5): Promise<string[]> {92  if (level < 2) return [];93  const video = page.locator("video").first();94  if (!(await video.isVisible().catch(() => false))) return [];95  const dir = path.join(mediaDir, media.platform, media.platform_media_id ?? shortHash(media.fingerprint, 10));96  fs.mkdirSync(dir, { recursive: true });97  const duration = media.duration_s ?? (await video.evaluate((v: HTMLVideoElement) => (Number.isFinite(v.duration) ? v.duration : 0)).catch(() => 0));98  const positions = [0, 0.25, 0.5, 0.75, 0.98].slice(0, maxFrames);99  const files: string[] = [];100  const deadline = Date.now() + 20_000; // frame sampling is best-effort and bounded101  for (const p of positions) {102    if (Date.now() > deadline) break;103    try {104      if (duration > 2) {105        await video.evaluate((v: HTMLVideoElement, t: number) => {106          v.currentTime = t;107        }, Math.min(duration - 0.5, duration * p));108        await sleep(600);109      }110      const file = path.join(dir, `frame_${Math.round(p * 100)}.jpg`);111      await video.screenshot({ path: file, type: "jpeg", quality: 70, timeout: 5000 });112      files.push(file);113      if (duration <= 2) break;114    } catch (err) {115      log.debug("frame capture failed", { err: (err as Error).message });116    }117  }118  return files;119}120