From 7d92e0de1c5c0be553a03e0d3772db8182336d00 Mon Sep 17 00:00:00 2001 From: Martin <60476606+mx-320@users.noreply.github.com> Date: Fri, 10 Jul 2026 17:47:56 +0800 Subject: [PATCH 01/11] feat(db): add CompetitorCreative + RemixJob models MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the competitor-intel data model per docs/growth/07 §4 (+ 09 §3's segmentPlan addition): CompetitorCreative caches AppGrowing ad metadata and AI analysis, idempotent on (orgId, source, externalId); RemixJob is a queryable ledger for turning competitor creatives into our-own-IP assets (not wired to an engine yet — Phase 2). Purely additive, both relate to Organization like the rest of the schema. Migration generated via `prisma migrate diff --from-schema --to-schema prisma/schema.prisma --script` (no local Postgres available in this worktree — see .claude/rules/schema.md). --- .../migration.sql | 67 +++++++++++++++++ prisma/schema.prisma | 75 +++++++++++++++++++ 2 files changed, 142 insertions(+) create mode 100644 prisma/migrations/20260710180000_competitor_intel/migration.sql diff --git a/prisma/migrations/20260710180000_competitor_intel/migration.sql b/prisma/migrations/20260710180000_competitor_intel/migration.sql new file mode 100644 index 0000000..d19b00d --- /dev/null +++ b/prisma/migrations/20260710180000_competitor_intel/migration.sql @@ -0,0 +1,67 @@ +-- CreateTable +CREATE TABLE "CompetitorCreative" ( + "id" TEXT NOT NULL, + "orgId" TEXT NOT NULL, + "source" TEXT NOT NULL DEFAULT 'appgrowing', + "externalId" TEXT NOT NULL, + "appName" TEXT, + "advertiser" TEXT, + "mediaPlatforms" JSONB, + "adFormat" TEXT, + "region" TEXT, + "language" TEXT, + "adDays" INTEGER, + "impressions" BIGINT, + "firstSeenAt" TIMESTAMP(3), + "lastSeenAt" TIMESTAMP(3), + "originalPostUrl" TEXT, + "ratio" TEXT, + "duration" INTEGER, + "creativeTags" JSONB, + "sellingPoints" JSONB, + "emotionalTriggers" JSONB, + "screenUnderstanding" JSONB, + "storyboard" JSONB, + "transcript" TEXT, + "bgm" TEXT, + "aiPrompt" TEXT, + "segmentPlan" JSONB, + "rawMeta" JSONB, + "assetId" TEXT, + "ingestedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "CompetitorCreative_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "RemixJob" ( + "id" TEXT NOT NULL, + "orgId" TEXT NOT NULL, + "competitorCreativeId" TEXT NOT NULL, + "briefId" TEXT, + "mode" TEXT NOT NULL, + "remixPrompt" TEXT NOT NULL, + "status" TEXT NOT NULL DEFAULT 'queued', + "assetId" TEXT, + "creativeId" TEXT, + "aiPointsSpent" INTEGER, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "RemixJob_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "CompetitorCreative_orgId_appName_idx" ON "CompetitorCreative"("orgId", "appName"); + +-- CreateIndex +CREATE UNIQUE INDEX "CompetitorCreative_orgId_source_externalId_key" ON "CompetitorCreative"("orgId", "source", "externalId"); + +-- CreateIndex +CREATE INDEX "RemixJob_orgId_status_idx" ON "RemixJob"("orgId", "status"); + +-- AddForeignKey +ALTER TABLE "CompetitorCreative" ADD CONSTRAINT "CompetitorCreative_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "RemixJob" ADD CONSTRAINT "RemixJob_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE; + diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 02a7a50..d5e7ed4 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -129,6 +129,10 @@ model Organization { appReviews AppReview[] creativeBriefs CreativeBrief[] creativeVariants CreativeVariant[] + + // Competitor intel (docs/growth/07 §4, 09 §3) + competitorCreatives CompetitorCreative[] + remixJobs RemixJob[] } model Webhook { @@ -979,3 +983,74 @@ model CreativeVariant { @@index([briefId]) @@index([orgId]) } + +// ===================================================================== +// Competitor intel (docs/growth/07-competitor-intel-remix.md §4, +// docs/growth/09-pipeline-adex-integration.md §3) +// ===================================================================== + +// Competitor ad creative pulled from AppGrowing (or another future source), +// pushed via POST /api/ingest/competitor. Idempotent on (orgId, source, +// externalId). `assetId` links to our own GCS copy of the media once +// fetched/uploaded — see src/lib/platforms/appgrowing.ts. +model CompetitorCreative { + id String @id @default(cuid()) + orgId String + source String @default("appgrowing") // where this row was sourced from + externalId String // source's own id → idempotency key + // Hard metadata + appName String? + advertiser String? + mediaPlatforms Json? // ["google_ads","meta",...] + adFormat String? + region String? + language String? + adDays Int? // days live — longevity proxy + impressions BigInt? + firstSeenAt DateTime? + lastSeenAt DateTime? + originalPostUrl String? // public original source (e.g. YouTube) + ratio String? + duration Int? + // AI analysis layer (cached from the source, not re-derived here) + creativeTags Json? + sellingPoints Json? + emotionalTriggers Json? + screenUnderstanding Json? + storyboard Json? // shot-by-shot breakdown + transcript String? // speech-to-text + bgm String? // recognition result only — never the audio itself (IP risk) + aiPrompt String? // source-inferred generation prompt + segmentPlan Json? // segment routing plan (09 §3 addition) + rawMeta Json? // raw source response, kept for audit + // Links + assetId String? // our GCS copy (Asset) + ingestedAt DateTime @default(now()) + + org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade) + + @@unique([orgId, source, externalId]) + @@index([orgId, appName]) +} + +// Queryable Remix job ledger — tracks turning a CompetitorCreative into an +// our-own-IP Creative via Seedance2. Not wired to a processing engine in +// this PR (Phase 2, docs/growth/09 §3) — the model exists so the ledger can +// be written to by the local creative-pipeline tooling. +model RemixJob { + id String @id @default(cuid()) + orgId String + competitorCreativeId String + briefId String? // reuses CreativeBrief + mode String // text2video | image2video | full + remixPrompt String // extractor output + status String @default("queued") // queued | generating | ready | failed | rejected + assetId String? // Seedance2 output + creativeId String? // landed Creative + aiPointsSpent Int? // source AI-points cost + createdAt DateTime @default(now()) + + org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade) + + @@index([orgId, status]) +} From d53dec7a9460826f2460b318cbd5bf3c247003ee Mon Sep 17 00:00:00 2001 From: Martin <60476606+mx-320@users.noreply.github.com> Date: Fri, 10 Jul 2026 17:48:04 +0800 Subject: [PATCH 02/11] feat(api): add competitor-intel ingest + listing routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /api/ingest/competitor?org= — clones the ingest/scenes HMAC + idempotent-upsert pattern for competitor ad creatives pushed by the local creative-pipeline/AppGrowing bridge. Accepts a single item or an array; mediaUrl already on our GCS bucket is linked directly, an external mediaUrl is fetched once and uploaded to GCS. Per-item created/updated/failed result. Connector parsing/mapping logic lives in src/lib/platforms/appgrowing.ts so the ingest source is isolated from the route, per docs/growth/07 §5. GET /api/competitors — session-scoped listing (requireAuthWithOrg, never trusts a client orgId), filterable by appName/level, orderable by adDays/impressions. Ref: docs/growth/09-pipeline-adex-integration.md §3 --- src/app/api/competitors/route.ts | 61 ++++++++ src/app/api/ingest/competitor/route.ts | 149 +++++++++++++++++++ src/lib/platforms/appgrowing.ts | 192 +++++++++++++++++++++++++ 3 files changed, 402 insertions(+) create mode 100644 src/app/api/competitors/route.ts create mode 100644 src/app/api/ingest/competitor/route.ts create mode 100644 src/lib/platforms/appgrowing.ts diff --git a/src/app/api/competitors/route.ts b/src/app/api/competitors/route.ts new file mode 100644 index 0000000..9b35202 --- /dev/null +++ b/src/app/api/competitors/route.ts @@ -0,0 +1,61 @@ +import { NextRequest, NextResponse } from 'next/server' +import { prisma } from '@/lib/prisma' +import { requireAuthWithOrg } from '@/lib/auth' + +/** + * GET /api/competitors?appName=&level=&orderBy=adDays|impressions&limit= + * + * Org-scoped listing of ingested competitor creatives (session auth — never + * trusts a client-supplied orgId). `level` has no dedicated column (see + * docs/growth/09-pipeline-adex-integration.md §3) — it's whatever the + * source's classification stored under `rawMeta.level`, filtered via a + * Postgres JSON-path match. + * + * Ref: docs/growth/09-pipeline-adex-integration.md §3 + */ +export async function GET(req: NextRequest) { + let org + try { + const ctx = await requireAuthWithOrg() + org = ctx.org + } catch { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + try { + const searchParams = req.nextUrl.searchParams + const appName = searchParams.get('appName') + const level = searchParams.get('level') + const orderByParam = searchParams.get('orderBy') + const limitParam = searchParams.get('limit') + + const limit = Math.min(Math.max(parseInt(limitParam || '50', 10) || 50, 1), 200) + + const where: Record = { orgId: org.id } + if (appName) where.appName = { contains: appName } + if (level) where.rawMeta = { path: ['level'], equals: level } + + const orderBy: Record = + orderByParam === 'adDays' + ? { adDays: 'desc' } + : orderByParam === 'impressions' + ? { impressions: 'desc' } + : { ingestedAt: 'desc' } + + const rows = await prisma.competitorCreative.findMany({ + where, + orderBy, + take: limit, + }) + + // BigInt isn't JSON-serializable — stringify impressions. + const serialized = rows.map((row) => ({ + ...row, + impressions: row.impressions === null ? null : row.impressions.toString(), + })) + + return NextResponse.json(serialized) + } catch { + return NextResponse.json([], { status: 200 }) + } +} diff --git a/src/app/api/ingest/competitor/route.ts b/src/app/api/ingest/competitor/route.ts new file mode 100644 index 0000000..3cbecbf --- /dev/null +++ b/src/app/api/ingest/competitor/route.ts @@ -0,0 +1,149 @@ +import { NextRequest, NextResponse } from 'next/server' +import { prisma } from '@/lib/prisma' +import { verifyHmac } from '@/lib/growth/ingest-auth' +import { uploadToGCS } from '@/lib/storage' +import { + APPGROWING_SOURCE, + parseCompetitorItems, + isOwnGcsUrl, + mapCompetitorItemToFields, + type CompetitorIngestItem, +} from '@/lib/platforms/appgrowing' + +/** + * POST /api/ingest/competitor?org= + * + * Local creative-pipeline (or any AppGrowing bridge) pushes competitor ad + * creative metadata + AI analysis; we upsert each as a CompetitorCreative + * row, idempotent by (orgId, source, externalId). `mediaUrl` already on our + * GCS bucket is linked directly (no re-fetch); external URLs are fetched + * once and uploaded to GCS. Auth mirrors ingest/scenes: HMAC over the raw + * body using the org's 'ingest' PlatformAuth apiKey. + * + * Ref: docs/growth/09-pipeline-adex-integration.md §3 + */ + +const IMAGE_EXT_RE = /\.(jpe?g|png|webp|gif)(\?|$)/i + +function guessAssetType(url: string): string { + return IMAGE_EXT_RE.test(url) ? 'image' : 'video' +} + +async function ensureAsset( + item: CompetitorIngestItem, + ctx: { orgId: string; uploadedBy: string }, +): Promise { + const mediaUrl = item.mediaUrl + if (!mediaUrl) return null + + if (isOwnGcsUrl(mediaUrl)) { + const asset = await prisma.asset.create({ + data: { + orgId: ctx.orgId, + uploadedBy: ctx.uploadedBy, + name: item.appName || `Competitor ${item.externalId}`, + type: guessAssetType(mediaUrl), + source: APPGROWING_SOURCE, + fileUrl: mediaUrl, + status: 'ready', + ratio: item.ratio ?? undefined, + duration: item.duration ?? undefined, + }, + }) + return asset.id + } + + const res = await fetch(mediaUrl) + if (!res.ok) throw new Error(`fetch mediaUrl failed (${res.status})`) + const buffer = Buffer.from(await res.arrayBuffer()) + const contentType = res.headers.get('content-type') || 'application/octet-stream' + const ext = guessAssetType(mediaUrl) === 'image' ? 'jpg' : 'mp4' + const filename = `competitor/${item.externalId}-${Date.now()}.${ext}` + const fileUrl = await uploadToGCS(buffer, filename, contentType) + + const asset = await prisma.asset.create({ + data: { + orgId: ctx.orgId, + uploadedBy: ctx.uploadedBy, + name: item.appName || `Competitor ${item.externalId}`, + type: guessAssetType(mediaUrl), + source: APPGROWING_SOURCE, + fileUrl, + status: 'ready', + ratio: item.ratio ?? undefined, + duration: item.duration ?? undefined, + }, + }) + return asset.id +} + +export async function POST(req: NextRequest) { + const orgId = new URL(req.url).searchParams.get('org') + if (!orgId) return NextResponse.json({ error: 'missing ?org' }, { status: 400 }) + + const rawBody = await req.text() + + let secret: string | undefined = process.env.INGEST_WEBHOOK_SECRET + const org = await prisma.organization.findUnique({ where: { id: orgId }, select: { createdBy: true } }) + if (!org) return NextResponse.json({ error: 'unknown org' }, { status: 404 }) + try { + const auth = await prisma.platformAuth.findUnique({ where: { orgId_platform: { orgId, platform: 'ingest' } } }) + if (auth?.apiKey) secret = auth.apiKey + } catch { + // env fallback + } + + if (!verifyHmac({ secret, timestamp: req.headers.get('x-adex-timestamp'), signature: req.headers.get('x-adex-signature'), rawBody })) { + return NextResponse.json({ error: 'unauthorized' }, { status: 401 }) + } + + let payload: unknown + try { + payload = JSON.parse(rawBody) + } catch { + return NextResponse.json({ error: 'invalid json' }, { status: 400 }) + } + + const items = parseCompetitorItems(payload) + if (items.length === 0) return NextResponse.json({ ok: true, results: [] }) + + const results: Array<{ externalId: string; status: 'created' | 'updated' | 'failed'; id?: string; error?: string }> = [] + + for (const item of items) { + try { + const existing = await prisma.competitorCreative.findUnique({ + where: { orgId_source_externalId: { orgId, source: APPGROWING_SOURCE, externalId: item.externalId } }, + }) + + const assetId = existing?.assetId + ? existing.assetId + : await ensureAsset(item, { orgId, uploadedBy: org.createdBy }) + + const fields = mapCompetitorItemToFields(item) + + if (existing) { + const updated = await prisma.competitorCreative.update({ + where: { id: existing.id }, + data: { ...fields, assetId: assetId ?? existing.assetId }, + }) + results.push({ externalId: item.externalId, status: 'updated', id: updated.id }) + } else { + const created = await prisma.competitorCreative.create({ + data: { + orgId, + source: APPGROWING_SOURCE, + externalId: item.externalId, + assetId: assetId ?? undefined, + ...fields, + }, + }) + results.push({ externalId: item.externalId, status: 'created', id: created.id }) + } + } catch (error) { + const message = error instanceof Error ? error.message : 'ingest failed' + results.push({ externalId: item.externalId, status: 'failed', error: message }) + } + } + + return NextResponse.json({ ok: true, results }) +} diff --git a/src/lib/platforms/appgrowing.ts b/src/lib/platforms/appgrowing.ts new file mode 100644 index 0000000..48dbc1a --- /dev/null +++ b/src/lib/platforms/appgrowing.ts @@ -0,0 +1,192 @@ +/** + * AppGrowing competitor-intel connector (pure parsing/mapping — no DB, no + * network I/O here; those live in the route handler). Isolates "which + * source pushed this row" from the ingest route, per + * docs/growth/07-competitor-intel-remix.md §5 ("把'到底走哪条取数路径'这件事 + * 隔离在一个文件里,上层不感知"). + * + * Ref: docs/growth/07-competitor-intel-remix.md §4, + * docs/growth/09-pipeline-adex-integration.md §3 + */ + +import { GCS_BUCKET } from '@/lib/storage' +import type { Prisma } from '@/generated/prisma/client' + +export const APPGROWING_SOURCE = 'appgrowing' + +/** Inbound shape for one competitor creative (POST /api/ingest/competitor). */ +export interface CompetitorIngestItem { + externalId: string + appName?: string | null + advertiser?: string | null + mediaPlatforms?: unknown + adFormat?: string | null + region?: string | null + language?: string | null + adDays?: number | null + impressions?: number | string | null + firstSeenAt?: string | null + lastSeenAt?: string | null + originalPostUrl?: string | null + ratio?: string | null + duration?: number | null + creativeTags?: unknown + sellingPoints?: unknown + emotionalTriggers?: unknown + screenUnderstanding?: unknown + storyboard?: unknown + transcript?: string | null + bgm?: string | null + aiPrompt?: string | null + segmentPlan?: unknown + rawMeta?: unknown + mediaUrl?: string | null + keyframeUrl?: string | null +} + +/** + * Parse an inbound ingest payload — accepts a single object or an array + * (bare array or `{ items: [...] }`). Drops entries missing `externalId`. + */ +export function parseCompetitorItems(raw: unknown): CompetitorIngestItem[] { + const arr = Array.isArray(raw) + ? raw + : Array.isArray((raw as { items?: unknown })?.items) + ? (raw as { items: unknown[] }).items + : raw && typeof raw === 'object' + ? [raw] + : [] + + const out: CompetitorIngestItem[] = [] + for (const entry of arr as Array>) { + if (!entry || typeof entry !== 'object') continue + const externalId = typeof entry.externalId === 'string' ? entry.externalId : null + if (!externalId) continue + out.push({ + externalId, + appName: typeof entry.appName === 'string' ? entry.appName : null, + advertiser: typeof entry.advertiser === 'string' ? entry.advertiser : null, + mediaPlatforms: entry.mediaPlatforms ?? null, + adFormat: typeof entry.adFormat === 'string' ? entry.adFormat : null, + region: typeof entry.region === 'string' ? entry.region : null, + language: typeof entry.language === 'string' ? entry.language : null, + adDays: typeof entry.adDays === 'number' ? entry.adDays : null, + impressions: + typeof entry.impressions === 'number' || typeof entry.impressions === 'string' + ? entry.impressions + : null, + firstSeenAt: typeof entry.firstSeenAt === 'string' ? entry.firstSeenAt : null, + lastSeenAt: typeof entry.lastSeenAt === 'string' ? entry.lastSeenAt : null, + originalPostUrl: typeof entry.originalPostUrl === 'string' ? entry.originalPostUrl : null, + ratio: typeof entry.ratio === 'string' ? entry.ratio : null, + duration: typeof entry.duration === 'number' ? entry.duration : null, + creativeTags: entry.creativeTags ?? null, + sellingPoints: entry.sellingPoints ?? null, + emotionalTriggers: entry.emotionalTriggers ?? null, + screenUnderstanding: entry.screenUnderstanding ?? null, + storyboard: entry.storyboard ?? null, + transcript: typeof entry.transcript === 'string' ? entry.transcript : null, + bgm: typeof entry.bgm === 'string' ? entry.bgm : null, + aiPrompt: typeof entry.aiPrompt === 'string' ? entry.aiPrompt : null, + segmentPlan: entry.segmentPlan ?? null, + rawMeta: entry.rawMeta ?? null, + mediaUrl: typeof entry.mediaUrl === 'string' ? entry.mediaUrl : null, + keyframeUrl: typeof entry.keyframeUrl === 'string' ? entry.keyframeUrl : null, + }) + } + return out +} + +/** True when `url` already points at our own GCS bucket (no re-fetch needed). */ +export function isOwnGcsUrl(url: string): boolean { + return url.startsWith(`https://storage.googleapis.com/${GCS_BUCKET}/`) +} + +/** Parse a date string, returning null on anything invalid/absent. */ +function parseDate(value: string | null | undefined): Date | null { + if (!value) return null + const d = new Date(value) + return Number.isNaN(d.getTime()) ? null : d +} + +/** Parse impressions into a BigInt, returning null on anything invalid/absent. */ +function parseImpressions(value: number | string | null | undefined): bigint | null { + if (value === null || value === undefined) return null + try { + return BigInt(Math.trunc(Number(value))) + } catch { + return null + } +} + +/** + * Merge `keyframeUrl` (no dedicated column) into rawMeta so it isn't lost. + */ +function mergeRawMeta(item: CompetitorIngestItem): Record | null { + const base = item.rawMeta && typeof item.rawMeta === 'object' ? { ...(item.rawMeta as object) } : {} + const merged: Record = { ...base } + if (item.keyframeUrl) merged.keyframeUrl = item.keyframeUrl + return Object.keys(merged).length ? merged : null +} + +type JsonField = Prisma.InputJsonValue | undefined + +/** Prisma create/update payload shape shared by both. */ +export interface CompetitorCreativeFields { + appName: string | null + advertiser: string | null + mediaPlatforms: JsonField + adFormat: string | null + region: string | null + language: string | null + adDays: number | null + impressions: bigint | null + firstSeenAt: Date | null + lastSeenAt: Date | null + originalPostUrl: string | null + ratio: string | null + duration: number | null + creativeTags: JsonField + sellingPoints: JsonField + emotionalTriggers: JsonField + screenUnderstanding: JsonField + storyboard: JsonField + transcript: string | null + bgm: string | null + aiPrompt: string | null + segmentPlan: JsonField + rawMeta: JsonField +} + +function asJson(value: unknown): JsonField { + return value === null || value === undefined ? undefined : (value as Prisma.InputJsonValue) +} + +/** Map a parsed ingest item to the Prisma field set (excludes id/org/assetId). */ +export function mapCompetitorItemToFields(item: CompetitorIngestItem): CompetitorCreativeFields { + return { + appName: item.appName ?? null, + advertiser: item.advertiser ?? null, + mediaPlatforms: asJson(item.mediaPlatforms), + adFormat: item.adFormat ?? null, + region: item.region ?? null, + language: item.language ?? null, + adDays: item.adDays ?? null, + impressions: parseImpressions(item.impressions), + firstSeenAt: parseDate(item.firstSeenAt), + lastSeenAt: parseDate(item.lastSeenAt), + originalPostUrl: item.originalPostUrl ?? null, + ratio: item.ratio ?? null, + duration: item.duration ?? null, + creativeTags: asJson(item.creativeTags), + sellingPoints: asJson(item.sellingPoints), + emotionalTriggers: asJson(item.emotionalTriggers), + screenUnderstanding: asJson(item.screenUnderstanding), + storyboard: asJson(item.storyboard), + transcript: item.transcript ?? null, + bgm: item.bgm ?? null, + aiPrompt: item.aiPrompt ?? null, + segmentPlan: asJson(item.segmentPlan), + rawMeta: asJson(mergeRawMeta(item)), + } +} From 7a8c20b5f8198562eaae4c09329f8d28993769d2 Mon Sep 17 00:00:00 2001 From: Martin <60476606+mx-320@users.noreply.github.com> Date: Fri, 10 Jul 2026 17:48:13 +0800 Subject: [PATCH 03/11] fix(platform): seedance2 video-url + duration handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Real-world doubao-seedance-2-0-260128 task responses return the generated video URL at content[].video_url.url, not output.video_url — add resolveVideoUrl() that prefers content, falls back to output for older/other model shapes. Also round the requested duration to whole seconds before submitting to Ark; a fractional duration gets a 400 InvalidParameter. Found via first real Seedance2 calls in the local creative-pipeline tooling (commit e06e215/1224f1c). Ref: docs/growth/09-pipeline-adex-integration.md §3 --- src/app/api/seedance2/status/route.ts | 9 +++++---- src/lib/platforms/seedance2.ts | 23 ++++++++++++++++++++++- 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/src/app/api/seedance2/status/route.ts b/src/app/api/seedance2/status/route.ts index de6f85a..7b40e31 100644 --- a/src/app/api/seedance2/status/route.ts +++ b/src/app/api/seedance2/status/route.ts @@ -1,7 +1,7 @@ import { NextRequest, NextResponse } from 'next/server' import { prisma } from '@/lib/prisma' import { requireAuthWithOrg } from '@/lib/auth' -import { Seedance2Client } from '@/lib/platforms/seedance2' +import { Seedance2Client, resolveVideoUrl } from '@/lib/platforms/seedance2' const SEEDANCE2_API_KEY = process.env.SEEDANCE2_API_KEY || '' @@ -33,10 +33,11 @@ export async function GET(req: NextRequest) { if (asset) { const updateData: Record = {} - if (task.status === 'succeeded' && task.output?.video_url) { + const videoUrl = resolveVideoUrl(task) + if (task.status === 'succeeded' && videoUrl) { updateData.status = 'ready' - updateData.fileUrl = task.output.video_url - if (task.output.duration) updateData.duration = task.output.duration + updateData.fileUrl = videoUrl + if (task.output?.duration) updateData.duration = task.output.duration } else if (task.status === 'failed') { updateData.status = 'failed' updateData.errorMessage = task.error?.message || 'Generation failed' diff --git a/src/lib/platforms/seedance2.ts b/src/lib/platforms/seedance2.ts index e2d371f..a74c058 100644 --- a/src/lib/platforms/seedance2.ts +++ b/src/lib/platforms/seedance2.ts @@ -31,6 +31,10 @@ export interface Seedance2TaskRequest { export interface Seedance2TaskResponse { id: string model: string + // On a succeeded task, doubao-seedance-2-0-260128 returns the video URL at + // content[].video_url.url (real-world response shape) — output.video_url + // below is a fallback for older/other model responses. See + // docs/growth/09-pipeline-adex-integration.md §3. content: ContentItem[] status: 'queued' | 'running' | 'succeeded' | 'failed' error?: { code: string; message: string } @@ -45,6 +49,17 @@ export interface Seedance2TaskResponse { updated_at: number } +/** + * Resolve the generated video URL from a task response. Prefers + * `content[].video_url.url` (actual doubao-seedance-2-0-260128 response + * shape, confirmed 2026-07 via creative-pipeline commit e06e215/1224f1c), + * falls back to `output.video_url` for older/other model shapes. + */ +export function resolveVideoUrl(task: Seedance2TaskResponse): string | undefined { + const fromContent = task.content?.find((c) => c.type === 'video_url' && c.video_url?.url)?.video_url?.url + return fromContent || task.output?.video_url +} + export class Seedance2Client { private config: Seedance2Config @@ -121,12 +136,18 @@ export class Seedance2Client { } } + // Ark rejects a fractional `duration` with 400 InvalidParameter — the + // API only accepts whole seconds (confirmed 2026-07 via creative-pipeline + // commit e06e215/1224f1c). Round rather than truncate so e.g. 4.6s asks + // for 5s, not 4s. + const duration = Math.round(params.duration || 5) + const body: Seedance2TaskRequest = { model: this.model, content, generate_audio: params.generateAudio ?? false, ratio: params.ratio || '16:9', - duration: params.duration || 5, + duration, watermark: params.watermark ?? false, } From 4f1b7f1bcddc62432826b9c65cffedf93756439c Mon Sep 17 00:00:00 2001 From: Martin <60476606+mx-320@users.noreply.github.com> Date: Fri, 10 Jul 2026 17:48:21 +0800 Subject: [PATCH 04/11] test(e2e): cover competitor-intel ingest happy path + auth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Register→login→GET /api/orgs→POST /api/ingest/competitor round trip: HMAC-signed ingest creates then idempotently updates a CompetitorCreative row, a bad signature 401s, GET /api/competitors requires a session and reflects the upsert. playwright.config.ts's webServer.env gains INVITE_CODES_DISABLED + INGEST_WEBHOOK_SECRET so the spec can self-provision a user/org and sign requests without a PlatformAuth row. Verified the code path against a build with no DATABASE_URL (fails at the expected `DATABASE_URL is not set` boundary, confirming registration/ingest wiring is correct) — no live Postgres was available in this worktree to run the full assertions end-to-end. --- e2e/competitor-ingest.spec.ts | 132 ++++++++++++++++++++++++++++++++++ playwright.config.ts | 6 ++ 2 files changed, 138 insertions(+) create mode 100644 e2e/competitor-ingest.spec.ts diff --git a/e2e/competitor-ingest.spec.ts b/e2e/competitor-ingest.spec.ts new file mode 100644 index 0000000..2af2d8e --- /dev/null +++ b/e2e/competitor-ingest.spec.ts @@ -0,0 +1,132 @@ +/** + * POST /api/ingest/competitor + GET /api/competitors — happy path, HMAC + * rejection, and idempotent upsert. Uses the register→login programmatic + * pattern from .claude/rules/testing.md (no direct DB fixture setup — + * importing the generated Prisma client from an e2e spec file doesn't play + * well with Playwright's ESM test loader). Ingest auth uses the + * `INGEST_WEBHOOK_SECRET` env fallback (see + * src/app/api/ingest/competitor/route.ts) configured in + * playwright.config.ts's webServer.env, so no PlatformAuth row is needed. + * + * Ref: docs/growth/09-pipeline-adex-integration.md §3 + */ +import { test, expect, APIRequestContext } from '@playwright/test' +import crypto from 'node:crypto' + +const BASE_PATH = process.env.NEXT_PUBLIC_BASE_PATH || '' +const p = (path: string) => `${BASE_PATH}${path.startsWith('/') ? path : '/' + path}` + +// Must match playwright.config.ts webServer.env.INGEST_WEBHOOK_SECRET. +const INGEST_SECRET = 'e2e-competitor-ingest-secret' + +function signHmac(secret: string, rawBody: string, timestamp = Math.floor(Date.now() / 1000)) { + const signature = `sha256=${crypto.createHmac('sha256', secret).update(`${timestamp}:${rawBody}`).digest('hex')}` + return { timestamp: String(timestamp), signature } +} + +/** Register a fresh user + personal org, log in, return an authed request context + orgId. */ +async function registerAndLogin(request: APIRequestContext) { + const suffix = crypto.randomBytes(6).toString('hex') + const email = `e2e-competitor-${suffix}@adex.test` + const password = 'e2e-test-password-1234' + + const reg = await request.post(p('/api/auth/register'), { + data: { email, password, name: 'E2E Competitor Tester' }, + }) + expect(reg.ok(), `register failed: ${reg.status()} ${await reg.text()}`).toBe(true) + + const login = await request.post(p('/api/auth/login'), { data: { email, password } }) + expect(login.ok(), `login failed: ${login.status()} ${await login.text()}`).toBe(true) + + const orgsRes = await request.get(p('/api/orgs')) + expect(orgsRes.ok()).toBe(true) + const orgs = await orgsRes.json() + expect(Array.isArray(orgs) && orgs.length).toBeTruthy() + + return { orgId: orgs[0].id as string, suffix } +} + +test.describe('competitor intel ingest', () => { + test('rejects a request with a bad HMAC signature', async ({ request }) => { + const { orgId } = await registerAndLogin(request) + const body = JSON.stringify({ externalId: `bad-sig-${crypto.randomBytes(4).toString('hex')}` }) + const res = await request.post(p(`/api/ingest/competitor?org=${orgId}`), { + headers: { + 'content-type': 'application/json', + 'x-adex-timestamp': String(Math.floor(Date.now() / 1000)), + 'x-adex-signature': 'sha256=deadbeef', + }, + data: body, + }) + expect(res.status()).toBe(401) + }) + + test('ingests a competitor creative and upserts idempotently by externalId', async ({ request }) => { + const { orgId, suffix } = await registerAndLogin(request) + const externalId = `appgrowing-${suffix}-1` + const payload = { + externalId, + appName: 'Puzzle Quest', + advertiser: 'Acme Games', + mediaPlatforms: ['google_ads', 'meta'], + adFormat: 'video', + region: 'US', + language: 'en', + adDays: 42, + impressions: 1_500_000, + ratio: '9:16', + duration: 15, + sellingPoints: ['fast matches', 'daily rewards'], + aiPrompt: 'cheerful mobile puzzle ad, bright colors', + } + const rawBody = JSON.stringify(payload) + const { timestamp, signature } = signHmac(INGEST_SECRET, rawBody) + + const res1 = await request.post(p(`/api/ingest/competitor?org=${orgId}`), { + headers: { + 'content-type': 'application/json', + 'x-adex-timestamp': timestamp, + 'x-adex-signature': signature, + }, + data: rawBody, + }) + expect(res1.ok(), `ingest failed: ${res1.status()} ${await res1.text()}`).toBe(true) + const json1 = await res1.json() + expect(json1.ok).toBe(true) + expect(json1.results).toHaveLength(1) + expect(json1.results[0]).toMatchObject({ externalId, status: 'created' }) + + // Re-ingest the same externalId — should update, not duplicate. + const rawBody2 = JSON.stringify({ ...payload, adDays: 43 }) + const sig2 = signHmac(INGEST_SECRET, rawBody2) + const res2 = await request.post(p(`/api/ingest/competitor?org=${orgId}`), { + headers: { + 'content-type': 'application/json', + 'x-adex-timestamp': sig2.timestamp, + 'x-adex-signature': sig2.signature, + }, + data: rawBody2, + }) + expect(res2.ok()).toBe(true) + const json2 = await res2.json() + expect(json2.results[0]).toMatchObject({ externalId, status: 'updated' }) + + // GET /api/competitors — same authed context (cookie persists) — should + // list exactly one row for this externalId, and reflect the update. + const list = await request.get(p(`/api/competitors?appName=Puzzle`)) + expect(list.ok()).toBe(true) + const rows = await list.json() + const matches = (rows as Array<{ externalId: string; adDays: number | null }>).filter( + (r) => r.externalId === externalId, + ) + expect(matches).toHaveLength(1) + expect(matches[0].adDays).toBe(43) + }) + + test('GET /api/competitors requires a logged-in session', async ({ playwright }) => { + const anonRequest = await playwright.request.newContext({ baseURL: `http://localhost:${process.env.PORT || '3000'}` }) + const res = await anonRequest.get(p('/api/competitors')) + expect(res.status()).toBe(401) + await anonRequest.dispose() + }) +}) diff --git a/playwright.config.ts b/playwright.config.ts index 984a1f5..ed9cfc3 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -27,6 +27,12 @@ export default defineConfig({ reuseExistingServer: !process.env.CI, env: { AUTH_TOKEN_SECRET: 'e2e-test-secret-do-not-use-in-production', + // Lets e2e/competitor-ingest.spec.ts register a fresh user without a + // pre-issued invite code, and sign ingest requests without needing a + // PlatformAuth row (route falls back to this env var — see + // src/app/api/ingest/competitor/route.ts). + INVITE_CODES_DISABLED: 'true', + INGEST_WEBHOOK_SECRET: 'e2e-competitor-ingest-secret', }, }, }) From dc5671d15f1979244feb8ed6519a2359f2a89965 Mon Sep 17 00:00:00 2001 From: Martin <60476606+mx-320@users.noreply.github.com> Date: Fri, 10 Jul 2026 17:48:28 +0800 Subject: [PATCH 05/11] docs(growth): competitor-intel remix + pipeline integration specs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the design docs this PR implements: 07 (competitor-intel + remix proposal, incl. the CompetitorCreative/RemixJob model proposal), 08 (creative pipeline stages), 09 (this PR's scope — pipeline↔adex integration, PR #1 boundaries). --- docs/growth/07-competitor-intel-remix.md | 288 ++++++++++++++++++ .../growth/08-competitor-creative-pipeline.md | 164 ++++++++++ docs/growth/09-pipeline-adex-integration.md | 50 +++ 3 files changed, 502 insertions(+) create mode 100644 docs/growth/07-competitor-intel-remix.md create mode 100644 docs/growth/08-competitor-creative-pipeline.md create mode 100644 docs/growth/09-pipeline-adex-integration.md diff --git a/docs/growth/07-competitor-intel-remix.md b/docs/growth/07-competitor-intel-remix.md new file mode 100644 index 0000000..b21d7e1 --- /dev/null +++ b/docs/growth/07-competitor-intel-remix.md @@ -0,0 +1,288 @@ +# 竞品情报 + 物料 Remix — 完整调研方案 (P22) + +> Version v1 · 2026-07-08 · 作者调研:实地走查 AppGrowing Global 会员账号 + 通读 Adex 现有创意链路 +> 关联:[00-cuddler-first-redesign.md](00-cuddler-first-redesign.md) §6(Scene-as-Creative)· [03-creative-studio.md](03-creative-studio.md)(物料能力)· [04-status.md](04-status.md)(render seam 待补) +> 目标:用 AppGrowing 会员账号,给 Adex 装上两件事 — +> +> 1. **竞品情报**:把竞品的整体物料库 + 数据分析能力接进 Adex(可搜、可看、可沉淀)。 +> 2. **物料 Remix**:以竞品爆款物料为"结构/钩子/卖点参照",用我们自己的 AI 生成栈(Seedance2 / Seedream / Claude)产出**本产品自己的**差异化物料。 + +--- + +## TL;DR(先看这段) + +- AppGrowing 是一个 **10 亿级广告素材情报库(账号内实测 4,500 万条可检索)**,覆盖 30+ 媒体渠道,每条素材都带**深度结构化元数据 + 一层 AI 分析**(卖点、情绪触发点、画面理解、逐镜 storyboard、语音转写、BGM 识别,以及**从任意片段反推一段 T2V 生成 prompt**)。这正是 Remix 的上游燃料。 +- Adex 侧 **90% 的下游已经建好**:Creative Studio(brief→变体矩阵)、`Asset.referenceData` 多模态参照位、Seedance2 `video2video/full` 生成、`ingest/scenes` 幂等入库模板、`review→push` 审核门。[04-status.md:26](04-status.md) 里那条"变体→真实 Seedance2 job→转码→推送"的 **render seam 一直空着 —— 本方案正好把它对竞品物料这一路补上**。 +- **关键取数决策**:官方合规通道优先(会员套餐内的下载/Collections 导出,或厂商宣传的 "Ad Creative Skill" for AI agents —— 待与 AppGrowing 确认),**不要**把爬内部 GraphQL 当主路径(脆弱 + 违反 ToS + 封号风险)。 +- **法务是头号约束,不是脚注**:仓库里已把"广告物料含第三方/竞品 IP"定性为**独立法律层级**([02-integration-contract.md:74](02-integration-contract.md))。本方案的核心设计原则是 **"借结构、不复刻"(derive, don't copy)** —— 竞品素材只作分析/参照输入,**产出永远是我们自己的 IP/品牌/角色**,且**任何物料上线前必须过人工审核门**。 +- **建议路线**:先做零基建的 **Phase 0 手工 PoC**(本周,10~20 条爆款 → 走一遍 Studio→Seedance2→审核),验证全链路 + 拿法务口径;跑通再依次上 竞品库入库 → Remix 引擎 → 每日自动同步。 + +--- + +## 1. AppGrowing 能力盘点(账号内实测) + +> 图例:✅ = 本次登录账号内亲眼确认;📢 = 厂商官网宣传、需向 AppGrowing 侧确认;💰 = 计费/限额项。 +> 账号语境:本次进入的是 **Game / US 区 / 某竞品 appBrand** 的 market-dashboard(URL 参数 `purpose=1&appBrand=…&subjectArea=US`)。 + +### 1.1 数据广度 + +| 项 | 观察 | +| --- | --- | +| 素材规模 | ✅ Ad Creatives 库检索显示 **"45.0M results found"**;📢 官网称 "1 billion+ ad creatives across 30+ channels" | +| 媒体渠道 | ✅ Meta Ads / Google Ads / TikTok for Business / Facebook / Instagram / Facebook (FAN) / AdMob …(可多选过滤) | +| 顶部导航 | ✅ Dashboard · Creatives · Insights · Analytics · Store · Collections | +| Creatives 左栏 | ✅ Competitor Innovative Creatives、TikTok/Facebook Creative Picks;**Ad Intelligence**:Ad Creatives、Top Clips(🔒New/未解锁)、Ad Descriptions、W2A Landing Pages、Pre-Register Pages、Playable Ads;**Store Creatives**:LiveOps Creatives(🔒Pro);**AI Creatives**:AI Voice Generator | +| Insights(套餐功能表) | 📢 Top Apps / Top Developers / Top APKs / Top AI / Top AI Websites / Top Short Dramas / Top Novels / Game Trends / CPM Trends / App Store Charts / Google Play Downloads / New Apps … | +| 内部数据 API | ✅ 前端全部走 **GraphQL**:`POST https://api-appgrowing-global.youcloud.com/graphql`(非公开、无文档、Cookie 鉴权) | + +### 1.2 检索与过滤维度(Ad Creatives / `/leaflet`) + +✅ 亲测过滤器极其丰富,这是"竞品数据分析能力"的主体: + +- **Creatives / Ads 双视图** + Exact match 开关 + Deduplication Statistics +- **Store Category**(Action/RPG/Casual/…)、**Game Tags**(Themes/Gameplay/Role Playing/…)、**Creative Tags**(Sub-gameplay / Visual Format / Character Type / Character Face / Character Age Group / Number of Characters …) +- **Media**(上述渠道多选) +- **Date**:Last 7/30/180/365 Days · All · 自定义区间 · **Latest / New Ads / Innovative Creatives(AI 判定"创新")/ Pre-registration** +- **Ad Attributes**:Regions、Languages、Platforms、**Ad Format**、**Ad Days(在投天数 = 长青度/间接效果代理)**、Audience Analysis、Monetization Approach、Promotion Types、Promotion Platforms、Custom Product Pages、Original Post、Violating Ad、Cooperative Ad +- **Creative Attributes**:Creatives Types、**Aspect Ratio**、**Video Duration**、HD、**With Voiceover**、Voiceover Languages +- **🌟 AI Selling Point Filter**(用 AI 打的语义标签直接当过滤条件):Core Selling Points、Emotional Triggers、Screen Understanding、Characters、Selling Point、User Objective、Audience Assumption、BGM & Voiceover + +### 1.3 单条素材的元数据 + AI 分析(Remix 的核心燃料) + +✅ 点开任意一条(实测 Arknights 一条 Google Ads 视频),detail 抽屉给到两层: + +**A. 硬元数据** +`Video 640×360` · 版式 `In-Stream Video / Rewarded / In-Feed`(带 Android/iOS)· Media(投放渠道)· **Ad Platform** `Google Ads` · **Region** `Japan` · **Ad Days** `1,942 天` · **Impressions** `710.4K` · Similar Creatives / **Related Ads** `19` · **Duration**(首见~末见)`2021-03-15 ~ 2026-07-08` · **Original Post**(常直链 YouTube 等**公开原始出处**)。 + +**B. AI 分析(逐条,标签页切换)** + +| Tab | 产出 | 计费 | +| --- | --- | --- | +| **Creative Overview** | Creative Tags(Fiction Style/2D/3D/Anime Characters/…)· **Core Selling Points**(演技张力/光效冲击/机甲特效/…)· **Emotional Triggers**(战斗张力/操作爽感/…)· **Screen Understanding → Primary Visual Elements**(角色/机甲/道具/光效逐项) | 通常已缓存 | +| **AI Prompt** 🌟 | **从任意 ≤1min 片段反推一段可用于文生视频的 prompt**(English/Chinese)。**这是 Remix 的"半成品":一段结构化的生成描述**,可直接喂我们的 Seedance2。 | 💰 **3 AI Points / 次** | +| **Speech to Text** | 口播/旁白**逐字转写**(可做脚本参照 + 多语本地化) | 💰(计点) | +| **BGM Recognition** | 背景音乐识别(⚠️ 音乐版权敏感,见 §6) | 💰(计点) | +| **Video Split** | **Storyboard Extraction —— 逐镜拆解**(镜头级 storyboard,用于抽参考帧) | 💰 **10 AI Points / 次** | + +### 1.4 计费与限额(💰 —— 会直接决定 Remix 规模化成本) + +- **AI Points 计点制**:实测余额 **32,000 点**,其中 **2,000 点本月过期**(用进废退)。AI Prompt 3 点 / Storyboard 10 点 → 一条素材若两样都抽 ≈ **13 点**。 +- **下载受套餐门禁**:实测某条素材 Download 提示 **"Insufficient permissions. Please upgrade your plan."**;而 **Enterprise 套餐** 功能表标注 `Ad Download: Unlimited` / `Searching: Unlimited`(Top Apps、App Ad Overview 各 50/月)。→ **本账号非 Enterprise,下载与抽取都有额度**。 +- **Collections**:可把素材收藏进 Collections(团队版 100G),配合官方**浏览器插件**"Collect Videos on Web Pages"批量收集 —— 这是一条**官方许可的导出路径**。 + +### 1.5 官方 AI-Agent 接入(📢 待确认,可能是最优取数路径) + +官网检索到:AppGrowing 宣称提供一个 **"dedicated Ad Creative Skill",可插入 Claude / ChatGPT / 自定义 agent,实时问答并拉取 AppGrowing 数据**("Select creatives, ask questions … our AI analyzes the creative strategy")。若属实,这是**最合规、最省事**的程序化取数通道 —— 但**当前会员套餐功能表里没有明列**,需**向 AppGrowing 销售确认**(是否含在套餐/加购/是否给 API key 或 MCP endpoint)。**在确认前,不把它写进关键路径**。 + +--- + +## 2. Adex 现状 —— 下游几乎已就绪 + +> 全部 file:line 来自本次仓库走查。**仓内目前没有任何 appgrowing/竞品/remix 代码**,以下都是可直接对接的现成 seam。 + +### 2.1 生成栈(Remix 的"产出端") + +| 能力 | 入口 | 形状 | +| --- | --- | --- | +| Seedance2 视频 | `POST /api/seedance2/generate` [route.ts:8](../../src/app/api/seedance2/generate/route.ts) | body `{ prompt*, mode:'text2video'\|'image2video'\|'video2video'\|'full', referenceImages?[], referenceVideos?[], referenceAudios?[], generateAudio?, ratio?, duration? }` → 建 `Asset(source:'seedance2', status:'generating', taskId, model)`,**参照 URL 落 `Asset.referenceData`(JSON)** ← Remix 参照位就在这 | +| 轮询回写 | `GET /api/seedance2/status?taskId=&assetId=` [route.ts:8](../../src/app/api/seedance2/status/route.ts) | 成功写 `Asset.fileUrl=output.video_url, status:'ready'` | +| Seedance2 client | [src/lib/platforms/seedance2.ts](../../src/lib/platforms/seedance2.ts) | `createAdCreative`(全多模态:text+image+video+audio refs);content 支持 `role:'reference_image'\|'reference_video'\|'reference_audio'` —— **竞品帧/视频作参照就喂这里** | +| Studio(brief→矩阵) | `POST /api/creatives/studio` [route.ts:44](../../src/app/api/creatives/studio/route.ts) | `{ product*, platforms*[], audience?, angle?, hooks?[], languages?[] }` → `CreativeBrief` + `CreativeVariant` 矩阵 + 逐平台限字文案 | +| Studio 出片 | `POST /api/creatives/studio/produce` [route.ts:20](../../src/app/api/creatives/studio/produce/route.ts) | `{ variantId }` → 组 storyboard+文案成 prompt,建 `Creative(source:'agent', reviewStatus:'pending')`。**注释 :12 明说实际 render 是"credentialed follow-up" —— 这就是那条空着的 seam** | +| 文案 | `POST /api/creatives/generate-copy` [route.ts:15](../../src/app/api/creatives/generate-copy/route.ts) | `{ productDescription*, audience?, tone?, platform?, count? }` → `{ variants:[{headline,description,callToAction}] }`,限 30/hr/user | + +### 2.2 入库 / 同步模板(Remix 的"上游端") + +| 用途 | 模板 | 复用点 | +| --- | --- | --- | +| 推送式入库 | `POST /api/ingest/scenes?org=` [route.ts](../../src/app/api/ingest/scenes/route.ts) + [scene-import.ts](../../src/lib/growth/scene-import.ts) | HMAC 鉴权 → 解析 → LLM 打标 → **按 `(orgId, sourceRef, source)` 幂等** → 建 `Creative(source:'imported_scene', reviewStatus:'pending')`。**竞品入库直接克隆这套** | +| 每日拉取 | `POST /api/cron/review-sync` [route.ts:47](../../src/app/api/cron/review-sync/route.ts) | 遍历 org → 拉外部 feed → LLM 分类 → **按外部 id upsert 幂等**。**竞品每日同步照抄:遍历 org → 拉 AppGrowing 精选 → 存 GCS → upsert** | +| Cron 鉴权 | [src/lib/cron-auth.ts:34](../../src/lib/cron-auth.ts) | `verifyCronAuth(req, 'competitor-sync')` + 注册 `CronSecret` 行 | +| 文件→Asset | `POST /api/assets/sync` [route.ts](../../src/app/api/assets/sync/route.ts) | Google Drive 递归拉取、按 `driveFileId` 去重 —— **抓取远端媒体入 Asset 的可用范例** | +| GCS 存储 | [src/lib/storage.ts:49](../../src/lib/storage.ts) | `uploadToGCS(buffer, filename, contentType) → 公网URL`。**抓 AppGrowing 媒体 → Buffer → uploadToGCS → 即成 Seedance2 的 `reference_video/image`** | +| 数据源连接器位 | [src/lib/platforms/](../../src/lib/platforms/) (`gdrive.ts`/`appsflyer.ts` 这类只读) | 新增 `appgrowing.ts`,归入 [00 §DATA_SOURCE_PLATFORMS](00-cuddler-first-redesign.md) 的**只读数据源**一类 | + +### 2.3 数据模型(现有,可挂靠) + +- **`Asset`** [schema.prisma:464](../../prisma/schema.prisma):`orgId/type/source/fileUrl/thumbnailUrl/prompt/`**`referenceData(JSON)`**`/taskId/status/ratio/duration/model/tags(JSON)/`**`driveFileId(去重)`** —— **竞品原始媒体的落脚点**(`source:'appgrowing'`)。 +- **`Creative`** [schema.prisma:331](../../prisma/schema.prisma):`source(…imported_scene)/`**`sourceRef(溯源)`**`/tags/`**`reviewStatus(none/pending/approved/rejected)`**`/platformPolicy` —— **Remix 产物 + 审核门**。 +- **`CreativeBrief`/`CreativeVariant`/`Campaign`/`Report`/`PlatformAuth`**([:209](../../prisma/schema.prisma),`@@unique([orgId,platform])`,open-ended `platform` 字符串 —— **`appgrowing` 凭据/Cookie 存这**)。 +- ⚠️ **无 `CreativeJob` 模型**:Seedance2 job 态内联在 `Asset.taskId+status`。若要**可查询的 Remix job 队列**,需新增模型(见 §4)。 + +### 2.4 LLM(Remix 的"大脑") + +[src/lib/llm.ts](../../src/lib/llm.ts):默认 **`claude-sonnet-4-5`**(可 `ANTHROPIC_MODEL` 覆盖)。 + +- `completeWithStructuredTool()` [:130](../../src/lib/llm.ts) —— 强制 tool-use 出结构化 + prompt-caching。**最适合做"Remix brief 抽取器":读竞品分析 + 我方产品 brief → 吐一份差异化 storyboard + Seedance2 prompt + 文案骨架**。 +- ⚠️ **现有 helper 全是纯文本 in/out,没有图像/视频输入块**。两条路:(a) **靠 AppGrowing 已抽好的 AI 分析**(卖点/storyboard/AI Prompt/转写)当文本输入 —— **推荐,零改造且省点数**;(b) 若要 Claude **亲自看**竞品画面,给 `llm.ts` 加一个多模态 `completeWithMedia` 变体。 + +--- + +## 3. 目标能力设计 + +### 3.1 能力一:竞品情报库(Competitor Intelligence) + +把 AppGrowing 的检索/分析能力**沉淀进 Adex**,而不是每次去 AppGrowing 现查: + +```text +AppGrowing 精选竞品素材 + │ 取数(见 §5 决策:官方导出 / Collections / Skill) + ▼ +CompetitorCreative(我方竞品库) ── 关联 ──► Asset(GCS 存的媒体) + │ 元数据 + AI 分析(卖点/情绪/画面/storyboard/转写/AI Prompt/Ad Days/Impressions) + ▼ +Adex 内可搜可筛的竞品面板(按 app/渠道/卖点/在投天数/曝光排序) + → 直接回答:"这个赛道最近哪些钩子/卖点在赢?哪些创新素材值得 Remix?" +``` + +**独立价值**:即便不做 Remix,一个"我方自己的、可沉淀、可跨会话复用的竞品洞察库"本身就有价值(AppGrowing 会员是个人视角、易过期;Adex 里是团队资产 + 可接进 Growth Agent)。 + +### 3.2 能力二:物料 Remix(核心) + +```text +CompetitorCreative(选定的爆款) 我方产品 brief(product/IP/brand/audience/角色) + │ 分析层:卖点、情绪触发、逐镜 storyboard、 │ + │ hook 时序、AI Prompt、口播脚本 │ + └──────────────┬───────────────────────────────────────┘ + ▼ + Remix Brief 抽取器(Claude, completeWithStructuredTool) + 系统提示核心:"借结构/节奏/卖点,换我方 IP/品牌/角色/画面 —— 差异化,禁复刻" + ▼ + 产出:differentiated storyboard + Seedance2 prompt + 文案骨架 + ▼ + Seedance2 generate(text2video 由 prompt;或 image2video/full 带**我方**品牌参照) + + generate-copy(限字文案) + ▼ + Creative(source:'remix', sourceRef=竞品外部id, reviewStatus:'pending') + ▼ + ⛔ 人工审核门(creatives/review) ── IP/授权/品牌安全 ── 批准后 ─► 平台推送 +``` + +**核心设计原则 —— "借结构、不复刻"(写死进系统提示 + 审核清单):** + +1. **产出永远是我方 IP**:竞品素材只作**分析/参照输入**,**绝不把竞品的版权帧当作我方广告输出**。Seedance2 的参照优先用**我方**品牌素材(image2video/full);若用竞品帧仅作**风格/构图**参照,必须经审核判定"已充分转化"。 +2. **借的是结构层**:hook 节奏、卖点顺序、情绪曲线、版式/时长/比例 —— 这些不是版权客体,是可学习的"广告工程"。 +3. **人工审核门不可绕过**:复用现有 `reviewStatus:'pending'` → `creatives/review`,把它同时当作 **IP/授权门 + 品牌安全门**。 +4. **全程留痕**:每条 `CompetitorCreative` 与每次 Remix 都写 `sourceRef` + `logAudit()`(见 [audit.ts](../../src/lib/audit.ts)),可追溯"这条我方物料参照了哪条竞品"。 + +--- + +## 4. 数据模型增量(设计,非落地) + +> 遵循 [.claude/rules/schema.md](../../.claude/rules/schema.md):落地时 `npx prisma generate` + `npx prisma migrate dev --name competitor_intel`,并把 migration 一起提交。以下为提案。 + +**新增 `CompetitorCreative`**(竞品情报,富元数据): + +```prisma +model CompetitorCreative { + id String @id @default(cuid()) + orgId String + source String @default("appgrowing") // 取数来源 + externalId String // AppGrowing 素材 id → 幂等键 + // 硬元数据 + appName String? + advertiser String? + mediaPlatforms Json? // ["google_ads","meta",...] + adFormat String? + region String? + language String? + adDays Int? // 在投天数(长青度代理) + impressions BigInt? + firstSeenAt DateTime? + lastSeenAt DateTime? + originalPostUrl String? // 公开原始出处(YouTube 等) + ratio String? + duration Int? + // AI 分析层(来自 AppGrowing,缓存) + creativeTags Json? + sellingPoints Json? // core selling points + emotionalTriggers Json? + screenUnderstanding Json? // 画面元素 + storyboard Json? // 逐镜 + transcript String? // speech-to-text + bgm String? // ⚠️ 版权敏感,仅存识别结果不存音频 + aiPrompt String? // AppGrowing 反推的生成 prompt + rawMeta Json? // 原始响应留档 + // 关联 + assetId String? // 我方 GCS 存的媒体(Asset) + ingestedAt DateTime @default(now()) + @@unique([orgId, source, externalId]) // 幂等 + @@index([orgId, appName]) +} +``` + +**新增 `RemixJob`**(可查询的 Remix 队列 —— 因为现有无 CreativeJob): + +```prisma +model RemixJob { + id String @id @default(cuid()) + orgId String + competitorCreativeId String + briefId String? // 复用 CreativeBrief + mode String // text2video/image2video/full + remixPrompt String // 抽取器产出 + status String @default("queued") // queued/generating/ready/failed/rejected + assetId String? // Seedance2 产物 + creativeId String? // 落地的 Creative + aiPointsSpent Int? // AppGrowing 点数成本 + createdAt DateTime @default(now()) + @@index([orgId, status]) +} +``` + +`Creative.source` 增加 `'remix'` 取值;`Asset.source` 增加 `'appgrowing'`。**均为新增,不动既有字段 —— 遵循 [CLAUDE.md 的"未经询问不删既有代码/模型"](../../CLAUDE.md)**。 + +--- + +## 5. 取数策略 —— 三条路,选官方 + +> 这是整个方案里**唯一需要你拍板**的核心决策(见 §8)。 +> ✅ **2026-07-09 决议:实际走 B. 桥接(过渡)**——浏览器插件收集 + Original Post 公开出处,推 `ingest/competitor`;销售侧(Skill/API)与法务侧均已确认无阻塞。C 仍不作主路径。 + +| 方案 | 机制 | 优点 | 缺点 / 风险 | 结论 | +| --- | --- | --- | --- | --- | +| **A. 官方通道(推荐)** | (1) 📢 **Ad Creative Skill / API**(若厂商确认可加购)→ 程序化实时取数;否则 (2) 会员套餐内**手工/Collections 导出 + 浏览器插件收集** → 推我方 `POST /api/ingest/competitor` | 合规、稳定、可长期化;人工精选=天然质量门 | Skill 待确认;手工路径有额度(下载/点数);半自动 | ✅ **主路径**。先手工精选,Skill 确认后升级为自动 | +| **B. 桥接(过渡)** | 借已登录会话:官方浏览器插件"Collect Videos on Web Pages",或抓 **Original Post 公开 URL**(YouTube 等)→ 推 ingest | 用公开原始出处规避二次分发争议;半自动 | 依赖会话;ToS 边界需读细则 | 🟡 **仅作 A 的补充**(尤其"从公开出处再抓一份") | +| **C. 爬内部 GraphQL(不推荐)** | 用会员 Cookie 直连 `api-appgrowing-global.youcloud.com/graphql` | 快、字段全 | **脆弱**(schema 随时变)· **大概率违反 ToS** · **封号风险** · 无稳定契约 | ⛔ **不作主路径**;至多做一次性 spike 验证字段结构 | + +**取数落地(无论 A/B):**统一收敛到一个入口 —— `POST /api/ingest/competitor?org=`(克隆 `ingest/scenes` 的 HMAC + 幂等),body 带一条或多条竞品素材的 `{externalId, 元数据, 媒体URL, AI分析}`;路由内 `fetch(媒体URL) → uploadToGCS → Asset` + `upsert CompetitorCreative`。连接器逻辑封进 `src/lib/platforms/appgrowing.ts`,**把"到底走哪条取数路径"这件事隔离在一个文件里**,上层不感知。 + +--- + +## 6. 风险登记册(法务列首位) + +| # | 风险 | 等级 | 缓解 | +| --- | --- | --- | --- | +| R1 | **第三方/竞品 IP、角色/品牌形象、BGM 音乐版权** 进入我方物料 | 🔴 最高 | "借结构不复刻"设计;产出=我方 IP;**人工审核门=IP 门**;`sourceRef`+audit 留痕;**Phase 2 推送前先过法务口径**;BGM 仅存识别结果、**绝不把竞品音轨用进产出** | +| R2 | **AppGrowing ToS**:数据二次分发/自动化抓取限制;下载受套餐门禁 | 🔴 高 | 官方通道优先(§5-A);尊重套餐额度;**不把爬 GraphQL 当主路径**;读会员协议里 data-reuse 条款 | +| R3 | **成本叠加**:AI Points(月度过期)+ 下载额度 + Seedance2 渲染 + Claude token | 🟠 中 | 优先用**已缓存**的 AI 分析(省点数);**每 org 预算上限**;Remix 变体沿用 Studio 的 `DEFAULT_MAX_VARIANTS=40` 上限;`RemixJob.aiPointsSpent` 记账 | +| R4 | **数据准确性**:AppGrowing 标签是 AI 生成、非真值;Impressions/Ad Days 是估算 | 🟡 中 | 当信号非真理;人工精选;不基于单条数据自动决策 | +| R5 | **品牌安全 / 平台政策**:Remix 产出可能 off-brand 或违反投放政策 | 🟡 中 | 复用 `validateCreative` + `Creative.platformPolicy` + 审核门 | +| R6 | **取数通道稳定性**:内部 GraphQL / 未确认的 Skill 会变 | 🟡 中 | `appgrowing.ts` 连接器抽象隔离;A 为主、多路径可切 | + +--- + +## 7. 分期落地 + +| 期 | 目标 | 交付 | 依赖 | +| --- | --- | --- | --- | +| **Phase 0 —— 手工 PoC(本周,零基建)** | 验证全链路 + 拿法务口径 | 在 AppGrowing 精选 **10~20 条**本赛道爆款(按 Ad Days/Impressions/Innovative 排序)→ 抄其 AI Prompt / storyboard / 卖点 → 手工填进现有 `/creatives/studio` brief → `produce` → **手工触发 Seedance2** 出 3~5 条差异化变体 → 走 `creatives/review`。**完全复用已建能力,补上 [04-status.md:26](04-status.md) 那条 render seam 的第一次真实跑通** | `SEEDANCE2_API_KEY` | +| **Phase 1 —— 竞品情报库** | 竞品数据沉淀进 Adex | `src/lib/platforms/appgrowing.ts` 连接器 · `CompetitorCreative` 模型 + migration · `POST /api/ingest/competitor`(HMAC+幂等) · 媒体入 GCS · `GET /api/competitors` 列表/筛选 · 一个只读竞品面板 | §5 取数决策 | +| **Phase 2 —— Remix 引擎** | 竞品→我方物料自动化 | `POST /api/creatives/remix` · Claude Remix-brief 抽取器(`completeWithStructuredTool` + "差异化禁复刻"系统提示) · 接 Seedance2 生成 + generate-copy · `RemixJob` 模型 · 落 `Creative(source:'remix', reviewStatus:'pending')`。**正式补完 render seam 的竞品这一路** | Phase 1 + **法务 sign-off(R1)** | +| **Phase 3 —— 自动化 + 情报** | 常态化运营 | `POST /api/cron/competitor-sync`(克隆 review-sync,每日拉精选 Collection) · 竞品趋势看板(哪些卖点/hook 按 Ad Days/Impressions 在赢) · 每 org 预算上限 + 完整 audit · 若确认 **AppGrowing Skill/API** 则升级取数为全自动 | Skill 确认(可选) | + +--- + +## 8. 需要你拍板的开放决策 + +1. **取数通道(§5)**:是否**联系 AppGrowing 销售确认 "Ad Creative Skill"/API 是否可用、是否在套餐内/需加购、是否给 key 或 MCP endpoint**?这决定 Phase 1/3 是全自动还是手工精选起步。(我方建议:先手工精选 + 并行去问销售。)**✅ 已解决(2026-07-09):销售侧无问题,取数按 §5 决议走 B 桥接。** +2. **法务口径(R1)**:Phase 2 把 Remix 产物**推上平台之前**,是否需要先过一次法务对"借结构不复刻 + 人工审核门"的书面认可?(强烈建议:需要。)**✅ 已解决(2026-07-09):法务口径已确认无问题。** +3. **先库还是先 Remix**:先做**竞品情报库**(独立有价值、风险低)再做 Remix,还是并行?(建议:Phase 0 手工 PoC 立刻做,Phase 1 库先行。) +4. **种子范围**:先聚焦哪个**赛道/竞品/区域**?(账号当前锁定 Game / US / 某 appBrand —— 是否就以此为首个种子?) +5. **AI Points 预算**:32,000 点(2,000 本月过期)——是否先用**将过期的 2,000 点**跑 Phase 0 的抽取(AI Prompt×3 点、Storyboard×10 点),把额度花在刀刃上? + +--- + +## 附:一句话结论 + +**下游已备好 90%(Studio + Seedance2 + ingest + 审核门),缺的正是竞品这路的取数 + 一个"借结构不复刻"的 Remix 抽取器。**建议本周先用将过期的点数跑一遍零基建手工 PoC 打通全链路、同时并行确认官方取数通道与法务口径,再按 库→引擎→自动化 三期推进。 diff --git a/docs/growth/08-competitor-creative-pipeline.md b/docs/growth/08-competitor-creative-pipeline.md new file mode 100644 index 0000000..5ff9797 --- /dev/null +++ b/docs/growth/08-competitor-creative-pipeline.md @@ -0,0 +1,164 @@ +# 竞品素材生产工艺层 (Competitor Creative Pipeline — 工艺层) + +> Version v1 · 2026-07-09 · 定位:[07-competitor-intel-remix.md](07-competitor-intel-remix.md)(P22,系统层:取数/数据模型/法务/分期)的**配套生产工艺**——素材拿到手之后"怎么拆、怎么分级、用什么工具做出来、怎么质检"。 +> 已确认:新产品 = **Cuddler**;取数走 07 §5-B 桥接(过渡),不爬 GraphQL;销售/法务侧无阻塞;Phase 1 工艺脚本**独立目录起步**、稳定后并入 adex。 +> 方法论来源:Hakko 主 SOP(`/Users/mt/work/project/Hakko/文案&脚本&视频制作-Hakko/00_文档/广告视频产出流程与优化总结-Hakko.md`)与 Luddi 主 SOP(`/Users/mt/work/project/Luddi/文案&脚本&视频制作/文档/广告视频产出流程与优化总结.md`)。 + +## 0. 目标与成功标准 + +**目标**:把 AppGrowing 拉到的竞品/同品类广告素材,经"拆解 → 分级 → 路由到不同生产工具",稳定产出 Cuddler 可投放的广告素材。 + +**成功标准**(v1 验收): + +1. 任一批竞品素材进入流水线后,自动产出分析产物 + 分级路由建议,无需手工跑命令。 +2. 每条产出素材过完红线质检 + 人工审核门才可标记"可投"。 +3. 首批产出 ≥10 条 IP-洁净素材(对应 [05-cuddler-playbook.md](05-cuddler-playbook.md) §0 门禁"10–14 条官方自制 IP-洁净素材")。 +4. 全链路产物落在统一目录/命名约定下,台账(manifest)可追溯每条成片的竞品来源与改造方式(与 07 §3.2 的 `sourceRef` + audit 留痕同构)。 + +## 1. 总览 + +```text +① 采集入库 ② 拆解分析 ③ 分类分级 ④ 生产 ⑤ 质检→投放 +AppGrowing 素材 ──► 双分析层 ──────► L1–L4 × 素材类型 ──► 按路由调工具 ──► 红线QC → adex审核门 +(07 §5 官方通道) (AppGrowing AI 分析 (LLM建议+人工抽检) (分级见 §4) → DCO变体矩阵 → push + + 本地四件套) +``` + +五段中 ①② 全自动,③ LLM 给建议 + 人批量确认,④ 按级别自动化程度不同,⑤ 脚本预检 + 人工终审(IP/授权门永远人审,与 [05-cuddler-playbook.md](05-cuddler-playbook.md) §3 及 07 §3.2 原则四一致)。 + +## 2. ① 采集入库 (Ingest) + +- **取数通道**:按 07 §5 决议(2026-07-09)走 **B. 桥接(过渡)**——借已登录会话,用官方浏览器插件 "Collect Videos on Web Pages" 收集,或抓 Original Post 公开出处(YouTube 等);**不爬内部 GraphQL**。半自动:人负责收集,落盘/推送后脚本接管。统一入口 `POST /api/ingest/competitor`(Phase 1 落地,克隆 `ingest/scenes` 的 HMAC + 幂等);Phase 0 手工阶段先"批量文件夹落盘 + manifest.csv"。 +- **优先级**:AppGrowing 的 **Ad Days(在投天数)/ Impressions / Innovative 标记**是免费的"已验证信号"——投得久、量级大的先进队列;这也决定哪些素材值得花 AI Points 做深度抽取(见 §3)。 +- **编目**:沿用 Hakko/Luddi 已验证的约定——源素材加 `已盘点_` 前缀 + `manifest.csv`,记录:竞品名、投放渠道、AppGrowing 指标(Ad Days/Impressions)、externalId(对齐 07 §4 `CompetitorCreative` 幂等键)、格式、时长、分辨率。 +- **去重**:同一竞品多渠道重复投放的素材按内容指纹(时长+抽帧 phash)合并;AppGrowing 侧已有 Deduplication Statistics 可先用。 + +## 3. ② 拆解分析 (Analyze) — 双分析层 + +**先用 AppGrowing 已有的 AI 分析,本地四件套做它做不了的事**(07 §2.4 的建议:零改造且省点数): + +**A 层 — AppGrowing 情报层**(随素材入库,存 `CompetitorCreative` 对应字段): + +| 产物 | 来源 | 计费 | +| --- | --- | --- | +| 卖点/情绪触发/画面理解/Creative Tags | Creative Overview | 通常已缓存,免费 | +| 反推生成 prompt(Remix 半成品) | AI Prompt | 3 AI Points/次 | +| 逐镜 storyboard | Video Split | 10 AI Points/次 | +| 口播逐字转写 | Speech to Text | 计点 | + +点数纪律:只对**过了优先级筛选(§2)且分级为 L3/L4 候选**的素材花点;优先消耗将过期点数(07 §8-5)。 + +**B 层 — 本地四件套**(对已下载媒体跑,AppGrowing 给不了的): + +| 产物 | 工具 | 用途 | +| --- | --- | --- | +| `metadata.csv` | ffprobe | 规格台账(Luddi 路径C) | +| 关键帧拼图(0.3s/2s/5s/中段/尾帧) | ffmpeg 抽帧 | 视觉筛选(Hakko §0) | +| `transcript.csv` 声轨转写 | whisper-cli(`ggml-base.en`/多语言 `small`) | **品牌名/slogan 声轨定位**(Hakko §0 前置听查)+ 校验 AppGrowing 转写 | +| `ocr_frames.csv` 品牌露出扫描 | tesseract 抽帧 OCR | **delogo 坐标定位**(L1/L2 必需)+ QC 复扫 | + +B 层同时承担对 A 层的**真值校验**——AppGrowing 标签是 AI 估算(07 风险 R4),关键判断(品牌露出、分级)以本地扫描为准。这些命令目前在 Hakko/Luddi 是手工跑的,本流水线第一优先固化成脚本。 + +## 4. ③ 分类分级 (Classify & Route) — 核心决策表 + +两个轴:**改造深度 L1–L4**(决定工具、成本与 IP 风险)×**素材类型**(决定具体管线)。判据继承 Hakko §0 决策图 + Luddi C1–C8 分类。 + +### 4.1 改造深度分级 + +| 级别 | 判据 | 做法 | 主工具 | 单条成本/速度 | IP 洁净度 | +| --- | --- | --- | --- | --- | --- | +| **L1 轻改造** | 成片主体可用,品牌露出仅 logo/尾帧/固定文案(Hakko 路径A / Luddi C1–C3) | 剪尾、delogo、遮标、换尾帧 | ffmpeg 模板(Hakko §12 三套 + Luddi `process_pathC.sh`) | 分钟级,最便宜 | 🔴 低(画面仍是竞品的) | +| **L2 重组混剪** | 有 2–8s 高价值片段但整体不能直接用(Hakko 路径B) | 抽片段重组 + 重配字幕/VO/BGM + 自有素材穿插 | ffmpeg + Luddi `build.py` 混音管线 + ElevenLabs v3 | 小时级 | 🟠 中(仍含竞品片段) | +| **L3 复刻重制** | 素材"结构值钱、画面不能用",hook-节奏-叙事已被投放验证 | 拆分镜 → Seedance2 逐镜生成 + 自有产品录屏/UI 替换 → 重组 | Seedance2(seedance2-skill prompt 方法;AppGrowing AI Prompt 作底稿)+ OpenMontage 参考复刻模式 + adex `storyboard.ts` | 小时~天级 | ✅ 高(借结构不复刻) | +| **L4 灵感入库** | 只提炼角度:hook 文案、卖点、人设、开场模式 | 进 hooks/angles 库 → 走 Creative Studio brief 全新生成 | adex `POST /api/creatives/studio`;口播类用 video-use,通用用 OpenMontage/Seedance2 | 与常规自制相同 | ✅ 最高 | + +**Cuddler 主线 = L3/L4**,即 07 §3.2 的 Remix 引擎("借结构、不复刻"): + +- pilot 门禁要求 IP-洁净素材,且 07 已把"物料含第三方/竞品 IP"定性为独立法律层级(R1 最高风险)——**L1/L2 产物对 Cuddler 默认不投放**。 +- L1/L2 是 Hakko/Luddi 存量业务已在用的工艺,脚本固化后两边直接受益;对 Cuddler 仅当法务口径(07 §8-2)明确允许后,才可用于小额探针验证 hook,验证过的结构仍升级为 L3 重制。 +- **强制降级**(不可被"素材很好"覆盖):含第三方 IP 角色/名人形象 → 最高 L3(画面必须重制);竞品名贯穿画面或声轨且剪不掉(Hakko"放弃"路径)→ L4。 +- **真人出镜单独判定,不是降级理由**(2026-07-10 按一线经验修正):真人+无品牌露出+通用 hook/网络梗 → 可直接按 L1/L2 复用;`has_real_face` 影响的是生成阶段输入方式(Seedance 拦截写实真人脸参照,L3 时只能文字化结构走 text2video),不影响复用分级。核心判断轴是**品牌露出位置**:尾帧/固定 logo → L1 好处理;贯穿主体(画面中段/声轨口播)才是难点,按可救程度分流 L2/L3/L4。 +- **分段路由 segment_plan**(2026-07-10 增补):整条分级之下再按时间段细化执行计划——干净且有价值的段(尤其真人梗 hook)标 `reuse` 直接复用原片,品牌贯穿的段标 `remake` 走重制,纯品牌尾帧段标 `drop` 换 Cuddler 尾帧。混合体素材(梗 hook + 品牌主体)由此实现"hook 原样复用 + 主体 AI 重制 + 拼接"——梗的价值在原片本身,严禁 AI 重生成梗段。生成 prompt 只覆盖 remake 段;拼装由 assemble.py 统一(reuse 实切 + 生成段 + 尾帧插槽,1080×1920 归一,段间 30ms 音频淡变)。 + +### 4.2 素材类型 → 生产管线路由 + +| 素材类型 | L3/L4 的生成管线 | +| --- | --- | +| 口播/talking-head | 脚本(转写作参照)→(真人拍 or AI 数字人)→ **video-use** 剪辑(去语气词/字幕/调色)+ ElevenLabs VO | +| 短剧/情绪/POV | 分镜(AppGrowing storyboard 作底)→ **Seedance2** text2video/image2video 逐镜生成 → 拼接混音 | +| 产品功能演示 | 自有录屏管线(Luddi `自动化游戏录屏_pipeline` 模式,适配 Cuddler UI/场景)+ 字幕/VO | +| 混剪 montage | 自有素材池 + `beat_detect.py` 卡点 + build.py 族 | +| 图文/静态 | Seedream 生图 + 文案(Creative Studio `generate-copy` 已有) | + +### 4.3 良率机制(2026-07-10 增补,方法来源:OpenMontage / video-use / seedance2-skill 调研) + +分析与生成的方法栈: + +- **结构拆解 schema**:借 OpenMontage `video-reference-analyst` 的 **5-aspect 逐镜拆解**(主体/动作/场景+文字层单列/景别构图/运镜,不适用字段显式 N/A)+ motion_type 判定(真运动/静图动画/静帧)——这是"借结构不复刻"的结构化载体,分级精排和 remix brief 都基于它。 +- **生成 prompt 模板**:seedance-prompting 的 8 组件结构 + 跨镜身份锚点逐字重复 × seedance2-skill 的 @引用语法(每个引用声明用途)+ 分时段描述(>8s 必用)。 +- **硬约束进路由**:Seedance2 参考视频 ≤3 个/总时长 2-15s(竞品素材先按分镜切片);**写实真人脸素材被模型拦截**——含真人脸的素材禁用视频参照,只能文字化结构走 text2video(分级器输出 has_real_face 维度);输出上限 720p,成片需补放大工序到 1080×1920。 + +良率闸门(按成本从低到高,前一道不过不进下一道): + +1. **prompt lint**(静态免费):引用有用途声明、无冲突运镜、单镜 ≤3 动作、有音频设计、生成内不放可读文字/logo(文字层走后期 overlay)、时长与复杂度匹配。 +2. **storyboard 多样性检查**:景别多样性、静态镜占比、废话词——防"每条长一样/幻灯片感"(借 variation_checker 思路)。 +3. **sample-first 门禁**(OpenMontage Step 5,强制):先只生成 **hook 首镜 + 卖点核心镜** 两条 clip 过审,风格/结构确认后再铺全片——用最小成本提前抓风格错配。 +4. **成片 qc**:品牌词声轨/OCR 复扫(§6,已含 ASR 变体)。 + +竖版规范(进 brief 模板,来源 OpenMontage short-form.md):hook 前 1-2s pattern interrupt、每 1-3s 换画面、底部 300-320px 平台 UI 死区不放重点、15s 档完播率最优。口播线复用 video-use 的转写→文本决策→EDL→渲染主干与现成竖版适配(字幕安全区 MarginV=90、-14 LUFS、HDR→SDR),自建它缺的 hook 前 3s 校验与 CTA 尾帧原语。 + +## 5. ④ 生产 (Produce) — 统一收尾 + +无论哪条路由,收尾统一(Hakko §13 / Luddi A/B 收尾合并): + +```text +字幕 → ElevenLabs eleven_v3 VO(情绪 tags)→ BGM/SFX(ElevenLabs Music/SFX,绝不用竞品音轨) +→ 混音 amix/sidechain + loudnorm I=-14:TP=-1.0(VO 1.0 / BGM 0.12–0.15 / SFX 0.2–0.3) +→ [尾帧插槽] + [logo插槽] → libx264 crf18 + aac 192k + faststart,1080×1920 (9:16) +``` + +- **尾帧/logo 为插槽 (slot)**:Cuddler 品牌资产未到位前,流水线先跑通、产物停在"待尾帧"状态;资产给到后批量补渲染(IAB DCO swappable slot 思路,与 Creative Studio 同构)。 +- API key 一律运行时 `hakko-secret ` 获取,不落盘。 +- 命名沿用 Luddi 约定:`来源级别_日期_类型_内容_9x16.mp4` + 质量标签 `A_强推荐/B_可用/C_备用/X_不要用`。 + +## 6. ⑤ 质检与投放接入 (QC → adex) + +1. **脚本预检**:whisper 复扫(竞品名/slogan 残留)、tesseract 复扫(logo/水印残留)、delogo 前中尾 2x 局部对比图(Luddi 质检规范)、ffprobe 规格校验。 +2. **红线清单**:Hakko R1–R10 移植为 checklist(声轨前置确认、能剪先剪、delogo 边距≥4、复评不得增加品牌色块等)+ 内容红线(无未成年疑似、无非同意、无 IP/名人脸)+ 07 §3.2 的"已充分转化"判定(若用了竞品帧作风格参照)。 +3. **入 adex**:产物落 `Creative(source:'remix', sourceRef=竞品externalId, reviewStatus:'pending')`(07 §3.2);**人工 approve(`/creatives/review`)是 IP/授权终审门,不自动化**。 +4. **变体与本地化**:审核通过后走 Creative Studio 变体矩阵做 平台×格式×钩子×语言 扇出;多语言按 Hakko 本地化 SOP 分层 L0(换尾帧)/L1(重配 VO)/L2(遮写烧录字)。 + +## 7. 落地节奏 — 对齐 07 §7 分期 + +数据模型、API、取数连接器以 07 §4/§5 为准;本文档补充的是各期的**工艺交付物**: + +| 期(同 07) | 本文档的工艺交付 | +| --- | --- | +| **Phase 0 手工 PoC** | 10~20 条爆款走一遍 ③④⑤:人工分级 → AppGrowing AI Prompt 改写为 Cuddler 差异化 prompt → Seedance2 出 3~5 条 → 红线 checklist 人工过。验证 L3 工艺可行性 + 单条成本 | +| **Phase 1 情报库** | **独立目录**(已确认,`/Users/mt/work/project/github/creative-pipeline`)固化脚本:本地四件套批处理、优先级排序、分级建议器(v1 规则版,LLM 精排后续加)、QC 预检;对 Hakko/Luddi 已盘点素材回归验收 | +| **Phase 2 Remix 引擎** | ④ 的路由管线接进 `RemixJob`:Seedance2 逐镜生成、video-use 口播线、录屏线、统一收尾脚本、尾帧/logo 插槽批量补渲染 | +| **Phase 3 自动化** | 分级建议器接每日同步;QC 预检做成 review 页的辅助信号(残留检测结果随 Creative 展示给审核人) | + +工艺脚本稳定后并入 adex(转码 worker / render worker,填 [04-status.md](04-status.md) 的 render seam),入库前过 schema/契约评审。 + +## 8. 分工 + +**建设期(写脚本/接系统)**: + +- 主会话(Fable 5):流程与判据设计、分级 prompt、红线清单、Remix 抽取器系统提示("借结构不复刻"写死)、验收。 +- fast-worker:固化脚本(四件套批处理、ffmpeg 模板参数化、QC 预检、统一收尾)——验收标准:对 Hakko/Luddi 已盘点竞品素材复现出与现有 csv 一致的产物。 +- deep-reasoner:Phase 2 接 adex 前的 schema/契约评审(不可逆决策)。 + +**运行期(日常生产)**: + +- 全自动:①采集编目、②双分析层、⑤脚本预检。 +- LLM 建议 + 人批量确认:③分级路由(人只看分级建议表,改错的);AI Points 花费需人确认(点数月度过期,见 07 §1.4)。 +- 人工必审:⑤ IP/授权终审门、投放前 approve;付费放量决策永远是人的(与 playbook §6 一致)。 + +## 9. 决议记录与遗留项 + +**已确认(2026-07-09)**:① 新产品 = Cuddler;② 取数走 07 §5-B 桥接(过渡);③ Phase 1 工艺脚本独立目录起步、稳定后并入 adex;④ 销售侧(Skill/API)与法务口径均无阻塞。 + +**遗留决策**(在 07 §8 跟踪):首个种子赛道/竞品/区域、AI Points 预算(优先花将过期的 2,000 点)。 + +注:法务通过的是"借结构不复刻 + 人工审核门"这套口径;§4.1 中"L1/L2 对 Cuddler 默认不投放"的限制**依旧保留**——它来自 pilot 门禁对 IP-洁净素材的要求,不随法务口径解除。若要为 Cuddler 放开 L1/L2 探针投放,需单独决策。 diff --git a/docs/growth/09-pipeline-adex-integration.md b/docs/growth/09-pipeline-adex-integration.md new file mode 100644 index 0000000..0073267 --- /dev/null +++ b/docs/growth/09-pipeline-adex-integration.md @@ -0,0 +1,50 @@ +# 竞品素材流水线 ↔ Adex 接入方案 + +> Version v1 · 2026-07-10 · 承接 [07-competitor-intel-remix.md](07-competitor-intel-remix.md) Phase 1/2 与 [08-competitor-creative-pipeline.md](08-competitor-creative-pipeline.md) §7。 +> 本地工艺层代码在 `/Users/mt/work/project/github/creative-pipeline`(独立 git 仓库,已全链路验证:分析→分级→分段路由→brief→Seedance 生成→拼装→qc)。 + +## 1. 分工边界(为什么不全搬进 adex) + +| 层 | 跑在哪 | 理由 | +| --- | --- | --- | +| 采集(AppGrowing 桥接收集)、四件套分析(whisper/tesseract/ffmpeg)、分级、brief、剪辑拼装、qc 预检 | **本地 creative-pipeline** | 依赖本机重型工具链(whisper-cli/tesseract/ffmpeg-full),Cloud Run 容器不适合跑;素材文件大,本地处理零传输成本 | +| 竞品情报库(CompetitorCreative)、素材库(Asset+GCS)、Remix 任务台账(RemixJob)、Seedance 生成编排、人工审核门、DCO 变体矩阵、平台推送 | **adex** | 团队共享资产、审核流程、投放链路本来就在 adex;07 §2 已确认 90% 下游现成 | + +接口:本地 pipeline 产出 → **HMAC 推送** adex ingest API;媒体文件 → **GCS**(`adex-data-gameclaw`,公开读,上传即得公网 URL——Seedance/Ark 参照素材直接用这个 URL,已实测 bucket 可访问)。 + +## 2. 数据流 + +```text +AppGrowing(浏览器插件收集,B桥接) + → 本地落盘 → analyze/classify/classify_llm(segment_plan) + → push_to_adex.py: + 媒体+关键帧拼图 → GCS(uploads/competitor/…) + 元数据+分析JSON → POST /api/ingest/competitor(HMAC,按 externalId 幂等) + → adex: CompetitorCreative 行 + Asset 行(source:'appgrowing') + → 人在 adex 竞品面板筛选"值得做"的条目 → 建 RemixJob + → 本地拉 brief/生成/拼装(或 adex 直接调 Seedance——render seam) + → 成片回传 GCS + Creative(source:'remix', sourceRef=externalId, reviewStatus:'pending') + → /creatives/review 人工审核门 → 变体矩阵 → push +``` + +## 3. PR #1 范围(feat/competitor-intel) + +1. **Prisma 模型**(07 §4 提案,均为新增不动既有):`CompetitorCreative`(含 `segmentPlan Json?`——07 提案之后新增的分段路由字段)+ `RemixJob` + migration。 +2. **`POST /api/ingest/competitor?org=`**:克隆 `ingest/scenes` 的 HMAC + 幂等模式;body 为一条或多条 `{externalId, 元数据, analysis(five_aspect/segment_plan/level_v2/…), mediaUrl?, keyframeUrl?}`;`mediaUrl` 为 GCS URL 时直接挂 Asset,为外链时 fetch→uploadToGCS。 +3. **`GET /api/competitors`**:按 org 列表/筛选(level/material_type/adDays 排序)。 +4. **seedance2.ts 实战修复**(首次真实调用发现,见 creative-pipeline commit e06e215/1224f1c): + - 任务响应的视频 URL 在 `content.video_url`,不是现在假设的 `output.video_url`(doubao-seedance-2-0-260128 实测); + - `duration` 必须整数秒(浮点返回 400 InvalidParameter)。 +5. **设计文档入库**:docs/growth/07、08、09(本文档)。 +6. e2e smoke:新 ingest/competitors 路由 happy path。 + +**不在本 PR**:每日自动同步 cron(Phase 3)、Remix 引擎 API(`/api/creatives/remix`,等本地工艺跑出足够量再抽象)、竞品面板 UI(先 API,面板下个 PR)。 + +## 4. 本地侧配套(creative-pipeline 仓库,PR 合并后做) + +- `push_to_adex.py`:读批次产物 → gcloud/GCS 上传媒体 → 调 ingest API(HMAC key 经 hakko-secret)。 +- `seedance_gen.py --ref-video` 直接吃 GCS URL(已支持 URL,无需改)。 + +## 5. 成本口径(2026-07-10 校准) + +Ark 按 video token 计费(`宽×高×fps×时长/1024`),实测 5s@720p = 108,900 tokens。按 Seedance 1.0 pro 公开价 ¥15/M 估:**5s ≈ ¥1.6**;25s 成片走分段路由只生成 remake 段(~12s)≈ ¥4,含试错 ¥10–30/条。待用真实账单(任务 `cgt-20260710153030-s5t5x`)校准单价常量。 From e43e3d19e2fc2a5a7890bd7ebd6cc700e886b529 Mon Sep 17 00:00:00 2001 From: Martin <60476606+mx-320@users.noreply.github.com> Date: Fri, 10 Jul 2026 17:51:14 +0800 Subject: [PATCH 06/11] docs(growth): revise 09 to adex-native architecture (v2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WHY: product decision — the whole pipeline lands on adex, not local tooling. Control plane stays in the Next.js service; minute-scale audio/video batch work moves to a Cloud Run Jobs worker whose image packages the already-verified creative-pipeline scripts. Adds per-stage design table, PR roadmap (#1-#4), cost + model-tier policy. --- docs/growth/09-pipeline-adex-integration.md | 84 ++++++++++++--------- 1 file changed, 47 insertions(+), 37 deletions(-) diff --git a/docs/growth/09-pipeline-adex-integration.md b/docs/growth/09-pipeline-adex-integration.md index 0073267..3986b34 100644 --- a/docs/growth/09-pipeline-adex-integration.md +++ b/docs/growth/09-pipeline-adex-integration.md @@ -1,50 +1,60 @@ -# 竞品素材流水线 ↔ Adex 接入方案 +# 竞品素材流水线落地 Adex 方案(adex 原生) -> Version v1 · 2026-07-10 · 承接 [07-competitor-intel-remix.md](07-competitor-intel-remix.md) Phase 1/2 与 [08-competitor-creative-pipeline.md](08-competitor-creative-pipeline.md) §7。 -> 本地工艺层代码在 `/Users/mt/work/project/github/creative-pipeline`(独立 git 仓库,已全链路验证:分析→分级→分段路由→brief→Seedance 生成→拼装→qc)。 +> Version v2 · 2026-07-10 · 承接 [07-competitor-intel-remix.md](07-competitor-intel-remix.md) Phase 1/2 与 [08-competitor-creative-pipeline.md](08-competitor-creative-pipeline.md)。 +> **定位修正(v2)**:整套流程落在 adex 上运行,不是本地工具。本地仓库 `/Users/mt/work/project/github/creative-pipeline` 的角色 = **worker 容器镜像的内核 + 开发调试环境**——工艺脚本已在本地全链路验证(分析→分级→分段路由→brief→Seedance 生成→拼装→qc),现在原样容器化,不重写。 -## 1. 分工边界(为什么不全搬进 adex) +## 1. 整体架构:控制面 + worker 面,都在 adex 的云上 -| 层 | 跑在哪 | 理由 | -| --- | --- | --- | -| 采集(AppGrowing 桥接收集)、四件套分析(whisper/tesseract/ffmpeg)、分级、brief、剪辑拼装、qc 预检 | **本地 creative-pipeline** | 依赖本机重型工具链(whisper-cli/tesseract/ffmpeg-full),Cloud Run 容器不适合跑;素材文件大,本地处理零传输成本 | -| 竞品情报库(CompetitorCreative)、素材库(Asset+GCS)、Remix 任务台账(RemixJob)、Seedance 生成编排、人工审核门、DCO 变体矩阵、平台推送 | **adex** | 团队共享资产、审核流程、投放链路本来就在 adex;07 §2 已确认 90% 下游现成 | +```text +┌─ adex Cloud Run Service(Next.js,现有)────────────────────────────┐ +│ 控制面:ingest API · CompetitorCreative/RemixJob 台账 · 分级精排 │ +│ (llm.ts 调 Anthropic) · Seedance 生成编排(seedance2.ts,已有) · │ +│ 竞品面板/审核门 UI · 变体矩阵 · 平台推送 · cron 触发 │ +└──────────────┬────────────────────────────────────────────────────┘ + │ Cloud Run Jobs API / Cloud Tasks 触发 +┌─ pipeline worker(Cloud Run Job,新增)──────────────────────────────┐ +│ 重活批处理:ffmpeg(抽帧/delogo/剪辑/拼装/转码) · whisper-cpp │ +│ (转写/qc 复扫) · tesseract(OCR 定位) —— 镜像 = creative-pipeline │ +│ 脚本 + 工具链,从 GCS 拉媒体、结果写回 DB/GCS │ +└────────────────────────────────────────────────────────────────────┘ +媒体统一落 GCS(adex-data-gameclaw,公开读)——也是 Seedance/Ark 参照素材的 URL 来源 +``` -接口:本地 pipeline 产出 → **HMAC 推送** adex ingest API;媒体文件 → **GCS**(`adex-data-gameclaw`,公开读,上传即得公网 URL——Seedance/Ark 参照素材直接用这个 URL,已实测 bucket 可访问)。 +原则:**请求-响应的事(API 调用、LLM 调用、任务编排)在 service 进程;分钟级批处理(音视频)在 Job 容器**。Cloud Run Jobs 支持长时任务,ffmpeg/tesseract/whisper-cpp(CPU 推理)容器化无障碍;service 与 job 共享同一 Cloud SQL + GCS。 -## 2. 数据流 +## 2. 各环节设计 -```text -AppGrowing(浏览器插件收集,B桥接) - → 本地落盘 → analyze/classify/classify_llm(segment_plan) - → push_to_adex.py: - 媒体+关键帧拼图 → GCS(uploads/competitor/…) - 元数据+分析JSON → POST /api/ingest/competitor(HMAC,按 externalId 幂等) - → adex: CompetitorCreative 行 + Asset 行(source:'appgrowing') - → 人在 adex 竞品面板筛选"值得做"的条目 → 建 RemixJob - → 本地拉 brief/生成/拼装(或 adex 直接调 Seedance——render seam) - → 成片回传 GCS + Creative(source:'remix', sourceRef=externalId, reviewStatus:'pending') - → /creatives/review 人工审核门 → 变体矩阵 → push -``` +| # | 环节 | 跑在哪 | 触发 | 输入 → 输出 | 人的位置 | +| --- | --- | --- | --- | --- | --- | +| ① | 采集 | 人(浏览器插件,07 §5-B 桥接) + service | 人收集后上传 | AppGrowing 素材 → `POST /api/ingest/competitor`(元数据+AppGrowing AI 分析) + 媒体上传 GCS → `CompetitorCreative` + `Asset` | 收集与精选 | +| ② | 拆解分析 | **analyze worker**(Job) | ingest 后由 service 派发(Cloud Tasks/cron 扫 pending) | GCS 媒体 → 四件套(metadata/关键帧拼图/转写/OCR 品牌定位)写回 DB 字段 + 拼图入 GCS。AppGrowing 自带 AI 分析优先,本地转写主要做品牌定位与真值校验(08 §3 双分析层) | 无 | +| ③ | 分级路由 | service(llm.ts 多模态) | ②完成即触发 | 拼图 URL+转写+OCR 命中 → `level_v2` + `segment_plan` + five_aspect 存 `CompetitorCreative` | 面板上批量抽检改错 | +| ④a | L1/L2 改造 | **edit worker**(Job) | 面板确认后建任务 | segment_plan → delogo/剪尾/重组/换尾帧插槽 → 成片入 GCS + `Creative(reviewStatus:'pending')` | 确认哪些条目投产 | +| ④b | L3 生成 | service 编排 + Ark | `RemixJob` 状态机 | brief(service 调 LLM 生成,lint 过)→ sample-first 两镜 → 人审 → 全片各 remake 段 → Seedance API(参照素材用 GCS URL) | sample 过审;花钱确认(条数护栏在 RemixJob 上限) | +| ④c | 拼装 | **edit worker**(Job) | ④b 各段就绪后 | reuse 段实切 + 生成段 + 尾帧插槽 → 1080×1920 归一拼接 | 无 | +| ⑤ | qc 预检 | worker(Job 内串联) | ④a/④c 产出即跑 | 品牌词声轨/OCR 复扫 → 结果附在 Creative 上供审核人看 | 无 | +| ⑥ | 审核→投放 | service(现有) | — | `/creatives/review` 人工终审 → 变体矩阵 → push | **IP/授权终审,不自动化** | + +失败处理通则:worker 任务带状态机(queued/running/done/failed+error),失败不静默重试超过 1 次,超限落 `needs_attention` 由面板展示;所有花钱动作(Seedance/AppGrowing 点数)前置护栏 + 记账(沿用 RemixJob.aiPointsSpent / spend 台账)。 -## 3. PR #1 范围(feat/competitor-intel) +## 3. 落地顺序(PR 路线) -1. **Prisma 模型**(07 §4 提案,均为新增不动既有):`CompetitorCreative`(含 `segmentPlan Json?`——07 提案之后新增的分段路由字段)+ `RemixJob` + migration。 -2. **`POST /api/ingest/competitor?org=`**:克隆 `ingest/scenes` 的 HMAC + 幂等模式;body 为一条或多条 `{externalId, 元数据, analysis(five_aspect/segment_plan/level_v2/…), mediaUrl?, keyframeUrl?}`;`mediaUrl` 为 GCS URL 时直接挂 Asset,为外链时 fetch→uploadToGCS。 -3. **`GET /api/competitors`**:按 org 列表/筛选(level/material_type/adDays 排序)。 -4. **seedance2.ts 实战修复**(首次真实调用发现,见 creative-pipeline commit e06e215/1224f1c): - - 任务响应的视频 URL 在 `content.video_url`,不是现在假设的 `output.video_url`(doubao-seedance-2-0-260128 实测); - - `duration` 必须整数秒(浮点返回 400 InvalidParameter)。 -5. **设计文档入库**:docs/growth/07、08、09(本文档)。 -6. e2e smoke:新 ingest/competitors 路由 happy path。 +| PR | 范围 | 状态 | +| --- | --- | --- | +| **#1 feat/competitor-intel** | `CompetitorCreative`(+segmentPlan)/`RemixJob` 模型+迁移 · `POST /api/ingest/competitor`(HMAC 幂等,媒体→GCS) · `GET /api/competitors` · seedance2.ts 实战修复(`content.video_url`、整数 duration) · 设计文档 07/08/09 入库 · e2e smoke | 进行中 | +| **#2 pipeline worker 镜像** | `worker/Dockerfile`(ffmpeg+whisper-cpp+tesseract+creative-pipeline 脚本) · analyze/edit 两个 entrypoint · Cloud Run Jobs 部署配置 · service 侧派发 API(`POST /api/competitors/[id]/analyze` 等)+ cron 扫描 | 待 #1 | +| **#3 分级与 Remix 编排** | llm.ts 加多模态 `completeWithMedia`(07 §2.4 预留项) · 分级精排接 ③ · `POST /api/creatives/remix`(RemixJob 状态机:brief→lint→sample→全片→拼装派发) · 生成条数/预算护栏 | 待 #2 | +| **#4 竞品面板 UI** | 列表/筛选/分级抽检改错/任务状态/qc 结果展示,挂进 dashboard | 待 #1(可与 #2/#3 并行) | -**不在本 PR**:每日自动同步 cron(Phase 3)、Remix 引擎 API(`/api/creatives/remix`,等本地工艺跑出足够量再抽象)、竞品面板 UI(先 API,面板下个 PR)。 +本地仓库后续动作:抽出 `worker/` 结构供 #2 直接 COPY;保留本地 CLI 作为开发调试入口(同一套代码两个入口,不分叉)。 -## 4. 本地侧配套(creative-pipeline 仓库,PR 合并后做) +## 4. 成本口径(2026-07-10 校准) -- `push_to_adex.py`:读批次产物 → gcloud/GCS 上传媒体 → 调 ingest API(HMAC key 经 hakko-secret)。 -- `seedance_gen.py --ref-video` 直接吃 GCS URL(已支持 URL,无需改)。 +Ark 按 video token 计费(`宽×高×fps×时长/1024`),实测 5s@720p = 108,900 tokens(任务 `cgt-20260710153030-s5t5x`)。按 Seedance 1.0 pro 公开价 ¥15/M 估:**5s ≈ ¥1.6**;25s 成片分段路由只生成 remake 段(~12s)≈ ¥4,含 sample-first 试错 **¥10–30/条 L3 成片**;L1/L2 成片 ≈ ¥0.5(仅分级 LLM)。月估(入库 200 条,产出 40 L1/L2 + 12 L3)≈ **¥400–600/月**;Cloud Run Jobs 计算费(CPU 批处理,分钟级)月估另加 ¥50–200,量级不变。待真实账单校准 Seedance 单价常量。 -## 5. 成本口径(2026-07-10 校准) +## 5. 模型分层(什么自动走 / 什么升级) -Ark 按 video token 计费(`宽×高×fps×时长/1024`),实测 5s@720p = 108,900 tokens。按 Seedance 1.0 pro 公开价 ¥15/M 估:**5s ≈ ¥1.6**;25s 成片走分段路由只生成 remake 段(~12s)≈ ¥4,含试错 ¥10–30/条。待用真实账单(任务 `cgt-20260710153030-s5t5x`)校准单价常量。 +- **零 LLM 全自动**(~80% 环节):四件套、规则预筛、prompt lint、ffmpeg 改造拼装、qc。有确定性算法就不请模型。 +- **Sonnet 标准档(默认,勿降 Haiku)**:分级精排/segment_plan/brief。这是路由决策——判错的下游代价(IP 漏判、误走 L3 白花生成费、真人脸误判被 Ark 拒单)远超省的几毛钱,且 IP/真人脸判定吃视觉能力。 +- **升级 Opus 档的判据 = 决策下游花钱或不可逆**:① 要投真钱的头部 L3 结构的 brief 精修;② lint 两轮不过/人审两次打回的失败复盘(问题在理解层,换强模型重写而非同档重试);③ "已充分转化"边界案例的法务风险独立意见(人终审);④ 新产品/赛道接入时的一次性设计决策。升级本身便宜(单次几元),真正贵的是 Seedance 重生成与人工返工——LLM 质量花在能避免这两样的地方。 +- **人工必须**:采集、分级抽检、sample 过审、IP/授权终审、放量决策(与 [05-cuddler-playbook.md](05-cuddler-playbook.md) §6 一致)。 From fe6095ad8ebc720cab94bd9a94466ad4e60aa963 Mon Sep 17 00:00:00 2001 From: Martin <60476606+mx-320@users.noreply.github.com> Date: Fri, 10 Jul 2026 18:05:08 +0800 Subject: [PATCH 07/11] =?UTF-8?q?docs(growth):=20record=20storage-write=20?= =?UTF-8?q?decision=20=E2=80=94=20media=20enters=20via=20ingest=20API=20on?= =?UTF-8?q?ly?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/growth/09-pipeline-adex-integration.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/growth/09-pipeline-adex-integration.md b/docs/growth/09-pipeline-adex-integration.md index 3986b34..806b9ec 100644 --- a/docs/growth/09-pipeline-adex-integration.md +++ b/docs/growth/09-pipeline-adex-integration.md @@ -22,6 +22,8 @@ 原则:**请求-响应的事(API 调用、LLM 调用、任务编排)在 service 进程;分钟级批处理(音视频)在 Job 容器**。Cloud Run Jobs 支持长时任务,ffmpeg/tesseract/whisper-cpp(CPU 推理)容器化无障碍;service 与 job 共享同一 Cloud SQL + GCS。 +**存储写入决议(2026-07-10)**:个人账号不直写生产 bucket(`adex-data-gameclaw` 对个人账号只读,IAM 不另开)——媒体**一律经 ingest API 由 Cloud Run 服务账号在服务端写入**(PR #1 的 `mediaUrl` fetch→uploadToGCS 路径)。Seedance 锚定生成所需的参照素材公网 URL 也由此获得;实验排期在 PR #1 合并部署之后。 + ## 2. 各环节设计 | # | 环节 | 跑在哪 | 触发 | 输入 → 输出 | 人的位置 | From 8a7d6686c04ff778363e081cc2f43f0d833bb256 Mon Sep 17 00:00:00 2001 From: Martin <60476606+mx-320@users.noreply.github.com> Date: Fri, 10 Jul 2026 19:40:56 +0800 Subject: [PATCH 08/11] fix(platform): correct seedance2 response parsing to real API shape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WHY: verified against live task cgt-20260710153030-s5t5x — real doubao-seedance-2-0-260128 returns content as an OBJECT {video_url:string} (not an array) and duration at TOP LEVEL. Prior resolveVideoUrl threw TypeError (content.find is not a function), leaving Assets stuck 'generating' — the PR's core fix did not work against the target model. WHAT: resolveVideoUrl handles object/array/legacy shapes (array form skips echoed reference_video inputs); resolveDuration reads top-level→output→ usage; generate route rounds duration before the Int? persist (was a 500 after a paid Ark job started). --- src/app/api/seedance2/generate/route.ts | 5 +- src/app/api/seedance2/status/route.ts | 5 +- src/lib/platforms/seedance2.ts | 62 ++++++++++++++++++++----- 3 files changed, 58 insertions(+), 14 deletions(-) diff --git a/src/app/api/seedance2/generate/route.ts b/src/app/api/seedance2/generate/route.ts index c92068a..9db6cb9 100644 --- a/src/app/api/seedance2/generate/route.ts +++ b/src/app/api/seedance2/generate/route.ts @@ -95,7 +95,10 @@ export async function POST(req: NextRequest) { taskId: result.id, status: 'generating', ratio: ratio || '16:9', - duration: duration || 5, + // Asset.duration is an Int? column; a fractional client value would + // throw Prisma "Expected Int, provided Float" AFTER the paid Ark task + // already started. Round to match what createTask actually sends to Ark. + duration: Math.round(Number(duration) || 5), model: 'doubao-seedance-2-0-260128', }, }) diff --git a/src/app/api/seedance2/status/route.ts b/src/app/api/seedance2/status/route.ts index 7b40e31..c4ee57b 100644 --- a/src/app/api/seedance2/status/route.ts +++ b/src/app/api/seedance2/status/route.ts @@ -1,7 +1,7 @@ import { NextRequest, NextResponse } from 'next/server' import { prisma } from '@/lib/prisma' import { requireAuthWithOrg } from '@/lib/auth' -import { Seedance2Client, resolveVideoUrl } from '@/lib/platforms/seedance2' +import { Seedance2Client, resolveVideoUrl, resolveDuration } from '@/lib/platforms/seedance2' const SEEDANCE2_API_KEY = process.env.SEEDANCE2_API_KEY || '' @@ -37,7 +37,8 @@ export async function GET(req: NextRequest) { if (task.status === 'succeeded' && videoUrl) { updateData.status = 'ready' updateData.fileUrl = videoUrl - if (task.output?.duration) updateData.duration = task.output.duration + const dur = resolveDuration(task) + if (dur !== undefined) updateData.duration = dur } else if (task.status === 'failed') { updateData.status = 'failed' updateData.errorMessage = task.error?.message || 'Generation failed' diff --git a/src/lib/platforms/seedance2.ts b/src/lib/platforms/seedance2.ts index a74c058..8a06c4b 100644 --- a/src/lib/platforms/seedance2.ts +++ b/src/lib/platforms/seedance2.ts @@ -31,13 +31,19 @@ export interface Seedance2TaskRequest { export interface Seedance2TaskResponse { id: string model: string - // On a succeeded task, doubao-seedance-2-0-260128 returns the video URL at - // content[].video_url.url (real-world response shape) — output.video_url - // below is a fallback for older/other model responses. See - // docs/growth/09-pipeline-adex-integration.md §3. - content: ContentItem[] + // ⚠️ Response `content` is NOT the request's ContentItem[]. The real + // doubao-seedance-2-0-260128 success response (verified 2026-07-10 against + // task cgt-20260710153030-s5t5x) returns: + // content: { video_url: "https://..." } ← an OBJECT, video_url is a STRING + // duration: 5 ← TOP-LEVEL number + // output: absent + // Older/other models may instead use output.video_url / output.duration, or + // an array-form content. We keep all shapes loosely typed and resolve + // defensively — see resolveVideoUrl / resolveDuration. Do NOT assume an array. + content?: unknown status: 'queued' | 'running' | 'succeeded' | 'failed' error?: { code: string; message: string } + duration?: number output?: { video_url?: string duration?: number @@ -49,15 +55,49 @@ export interface Seedance2TaskResponse { updated_at: number } +/** Pull a string url out of a content-item value that may be a bare string or `{ url }`. */ +function videoUrlFromItem(item: unknown): string | undefined { + if (typeof item === 'string') return item + if (item && typeof item === 'object') { + const v = (item as { video_url?: unknown }).video_url + if (typeof v === 'string') return v + if (v && typeof v === 'object' && typeof (v as { url?: unknown }).url === 'string') { + return (v as { url: string }).url + } + } + return undefined +} + /** - * Resolve the generated video URL from a task response. Prefers - * `content[].video_url.url` (actual doubao-seedance-2-0-260128 response - * shape, confirmed 2026-07 via creative-pipeline commit e06e215/1224f1c), - * falls back to `output.video_url` for older/other model shapes. + * Resolve the generated video URL from a task response, defensive across shapes: + * 1. content as object `{ video_url: "http..." }` (real doubao-seedance-2-0-260128) + * 2. content as array of items (older/other models) — skips reference inputs (role set) + * 3. output.video_url (legacy fallback) + * Returns undefined rather than throwing on an unexpected shape. */ export function resolveVideoUrl(task: Seedance2TaskResponse): string | undefined { - const fromContent = task.content?.find((c) => c.type === 'video_url' && c.video_url?.url)?.video_url?.url - return fromContent || task.output?.video_url + const content = task.content + if (Array.isArray(content)) { + for (const c of content) { + // Skip echoed reference inputs — only the generated output has no role. + if (c && typeof c === 'object' && (c as { role?: unknown }).role) continue + const url = videoUrlFromItem(c) + if (url) return url + } + } else { + const url = videoUrlFromItem((content as { video_url?: unknown } | undefined)) + if (url) return url + } + return task.output?.video_url +} + +/** + * Resolve the rendered duration (seconds), defensive across shapes: real model + * returns it top-level; others under output/usage. undefined if none present. + */ +export function resolveDuration(task: Seedance2TaskResponse): number | undefined { + const d = task.duration ?? task.output?.duration ?? task.usage?.duration + return typeof d === 'number' && Number.isFinite(d) ? Math.round(d) : undefined } export class Seedance2Client { From 2bd2d7204edb1751638e0da301b8348de7deffaa Mon Sep 17 00:00:00 2001 From: Martin <60476606+mx-320@users.noreply.github.com> Date: Fri, 10 Jul 2026 19:41:11 +0800 Subject: [PATCH 09/11] feat(db): add CompetitorCreative.level column + (orgId, level) index MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WHY: code review — the competitors list filtered level via a rawMeta JSON path that nothing writes (dead filter), while its sibling segmentPlan got a real column. level is the primary routing axis (09 §3), so it warrants a first-class indexed column, not an untyped blob key. --- .../20260710190000_competitor_level/migration.sql | 6 ++++++ prisma/schema.prisma | 2 ++ 2 files changed, 8 insertions(+) create mode 100644 prisma/migrations/20260710190000_competitor_level/migration.sql diff --git a/prisma/migrations/20260710190000_competitor_level/migration.sql b/prisma/migrations/20260710190000_competitor_level/migration.sql new file mode 100644 index 0000000..13a2dc2 --- /dev/null +++ b/prisma/migrations/20260710190000_competitor_level/migration.sql @@ -0,0 +1,6 @@ +-- AlterTable +ALTER TABLE "CompetitorCreative" ADD COLUMN "level" TEXT; + +-- CreateIndex +CREATE INDEX "CompetitorCreative_orgId_level_idx" ON "CompetitorCreative"("orgId", "level"); + diff --git a/prisma/schema.prisma b/prisma/schema.prisma index d5e7ed4..d618779 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1012,6 +1012,7 @@ model CompetitorCreative { originalPostUrl String? // public original source (e.g. YouTube) ratio String? duration Int? + level String? // classification tier L1/L2/L3/L4 (routing axis, 09 §3) // AI analysis layer (cached from the source, not re-derived here) creativeTags Json? sellingPoints Json? @@ -1031,6 +1032,7 @@ model CompetitorCreative { @@unique([orgId, source, externalId]) @@index([orgId, appName]) + @@index([orgId, level]) } // Queryable Remix job ledger — tracks turning a CompetitorCreative into an From 6f56a1feb697b9bfbafe172d5c47aad0e5973cf4 Mon Sep 17 00:00:00 2001 From: Martin <60476606+mx-320@users.noreply.github.com> Date: Fri, 10 Jul 2026 19:41:12 +0800 Subject: [PATCH 10/11] fix(api): harden competitor ingest + listing per code review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WHY/WHAT (xhigh review findings): - SSRF: ingest fetched caller-supplied mediaUrl with no validation → new storage.uploadFromUrl blocks non-http(s) + private/link-local hosts. - OOM/timeout: whole video buffered, unbounded batch → 100MB cap + 50-item batch cap (413). - competitors list masked all errors as empty 200 → returns 500 on failure; level filter now hits the real column. - parseHumanNumber fixes impressions/adDays silently dropping '710.4K' / '1,942' (the ranking signals); mergeRawMeta no longer corrupts array payloads; asset type prefers content-type over extension; isOwnGcsUrl + gcsPublicPrefix consolidated into storage.ts (was duplicated). --- src/app/api/competitors/route.ts | 15 +++--- src/app/api/ingest/competitor/route.ts | 56 ++++++++++++---------- src/lib/platforms/appgrowing.ts | 66 ++++++++++++++++++++------ src/lib/storage.ts | 62 +++++++++++++++++++++++- 4 files changed, 150 insertions(+), 49 deletions(-) diff --git a/src/app/api/competitors/route.ts b/src/app/api/competitors/route.ts index 9b35202..642756b 100644 --- a/src/app/api/competitors/route.ts +++ b/src/app/api/competitors/route.ts @@ -6,10 +6,8 @@ import { requireAuthWithOrg } from '@/lib/auth' * GET /api/competitors?appName=&level=&orderBy=adDays|impressions&limit= * * Org-scoped listing of ingested competitor creatives (session auth — never - * trusts a client-supplied orgId). `level` has no dedicated column (see - * docs/growth/09-pipeline-adex-integration.md §3) — it's whatever the - * source's classification stored under `rawMeta.level`, filtered via a - * Postgres JSON-path match. + * trusts a client-supplied orgId). `level` is a first-class column filtered + * directly (indexed via @@index([orgId, level])). * * Ref: docs/growth/09-pipeline-adex-integration.md §3 */ @@ -33,7 +31,7 @@ export async function GET(req: NextRequest) { const where: Record = { orgId: org.id } if (appName) where.appName = { contains: appName } - if (level) where.rawMeta = { path: ['level'], equals: level } + if (level) where.level = level const orderBy: Record = orderByParam === 'adDays' @@ -55,7 +53,10 @@ export async function GET(req: NextRequest) { })) return NextResponse.json(serialized) - } catch { - return NextResponse.json([], { status: 200 }) + } catch (error) { + // Don't mask a real query failure as an empty library — surface a 500 so + // the caller (and logs) can tell "broken" apart from "no rows". + console.error('GET /api/competitors failed', error) + return NextResponse.json({ error: 'failed to list competitors' }, { status: 500 }) } } diff --git a/src/app/api/ingest/competitor/route.ts b/src/app/api/ingest/competitor/route.ts index 3cbecbf..5f6981b 100644 --- a/src/app/api/ingest/competitor/route.ts +++ b/src/app/api/ingest/competitor/route.ts @@ -1,11 +1,10 @@ import { NextRequest, NextResponse } from 'next/server' import { prisma } from '@/lib/prisma' import { verifyHmac } from '@/lib/growth/ingest-auth' -import { uploadToGCS } from '@/lib/storage' +import { isOwnGcsUrl, uploadFromUrl } from '@/lib/storage' import { APPGROWING_SOURCE, parseCompetitorItems, - isOwnGcsUrl, mapCompetitorItemToFields, type CompetitorIngestItem, } from '@/lib/platforms/appgrowing' @@ -24,8 +23,14 @@ import { */ const IMAGE_EXT_RE = /\.(jpe?g|png|webp|gif)(\?|$)/i +const MAX_INGEST_ITEMS = 50 -function guessAssetType(url: string): string { +/** image vs video, preferring the response content-type over the URL extension. */ +function assetType(url: string, contentType?: string): string { + if (contentType) { + if (contentType.startsWith('image/')) return 'image' + if (contentType.startsWith('video/')) return 'video' + } return IMAGE_EXT_RE.test(url) ? 'image' : 'video' } @@ -36,37 +41,27 @@ async function ensureAsset( const mediaUrl = item.mediaUrl if (!mediaUrl) return null + // Already hosted in our own bucket → link directly, no re-fetch. + // External URL → SSRF/size-guarded fetch + upload (see storage.uploadFromUrl). + let fileUrl: string + let type: string if (isOwnGcsUrl(mediaUrl)) { - const asset = await prisma.asset.create({ - data: { - orgId: ctx.orgId, - uploadedBy: ctx.uploadedBy, - name: item.appName || `Competitor ${item.externalId}`, - type: guessAssetType(mediaUrl), - source: APPGROWING_SOURCE, - fileUrl: mediaUrl, - status: 'ready', - ratio: item.ratio ?? undefined, - duration: item.duration ?? undefined, - }, - }) - return asset.id + fileUrl = mediaUrl + type = assetType(mediaUrl) + } else { + const ext = IMAGE_EXT_RE.test(mediaUrl) ? 'jpg' : 'mp4' + const filename = `competitor/${item.externalId}-${Date.now()}.${ext}` + const uploaded = await uploadFromUrl(mediaUrl, filename) + fileUrl = uploaded.fileUrl + type = assetType(mediaUrl, uploaded.contentType) } - const res = await fetch(mediaUrl) - if (!res.ok) throw new Error(`fetch mediaUrl failed (${res.status})`) - const buffer = Buffer.from(await res.arrayBuffer()) - const contentType = res.headers.get('content-type') || 'application/octet-stream' - const ext = guessAssetType(mediaUrl) === 'image' ? 'jpg' : 'mp4' - const filename = `competitor/${item.externalId}-${Date.now()}.${ext}` - const fileUrl = await uploadToGCS(buffer, filename, contentType) - const asset = await prisma.asset.create({ data: { orgId: ctx.orgId, uploadedBy: ctx.uploadedBy, name: item.appName || `Competitor ${item.externalId}`, - type: guessAssetType(mediaUrl), + type, source: APPGROWING_SOURCE, fileUrl, status: 'ready', @@ -106,6 +101,15 @@ export async function POST(req: NextRequest) { const items = parseCompetitorItems(payload) if (items.length === 0) return NextResponse.json({ ok: true, results: [] }) + // Each item may trigger a media fetch+upload; a large batch in one request + // path risks Cloud Run's request timeout. Cap it — larger pushes should be + // chunked by the caller (or run via the pipeline worker, docs 09 §1). + if (items.length > MAX_INGEST_ITEMS) { + return NextResponse.json( + { error: `too many items (${items.length} > ${MAX_INGEST_ITEMS}); chunk the batch` }, + { status: 413 }, + ) + } const results: Array<{ externalId: string; status: 'created' | 'updated' | 'failed'; id?: string; error?: string }> = [] diff --git a/src/lib/platforms/appgrowing.ts b/src/lib/platforms/appgrowing.ts index 48dbc1a..ae45ebe 100644 --- a/src/lib/platforms/appgrowing.ts +++ b/src/lib/platforms/appgrowing.ts @@ -9,7 +9,6 @@ * docs/growth/09-pipeline-adex-integration.md §3 */ -import { GCS_BUCKET } from '@/lib/storage' import type { Prisma } from '@/generated/prisma/client' export const APPGROWING_SOURCE = 'appgrowing' @@ -23,7 +22,8 @@ export interface CompetitorIngestItem { adFormat?: string | null region?: string | null language?: string | null - adDays?: number | null + level?: string | null + adDays?: number | string | null impressions?: number | string | null firstSeenAt?: string | null lastSeenAt?: string | null @@ -70,7 +70,11 @@ export function parseCompetitorItems(raw: unknown): CompetitorIngestItem[] { adFormat: typeof entry.adFormat === 'string' ? entry.adFormat : null, region: typeof entry.region === 'string' ? entry.region : null, language: typeof entry.language === 'string' ? entry.language : null, - adDays: typeof entry.adDays === 'number' ? entry.adDays : null, + level: typeof entry.level === 'string' ? entry.level : null, + adDays: + typeof entry.adDays === 'number' || typeof entry.adDays === 'string' + ? entry.adDays + : null, impressions: typeof entry.impressions === 'number' || typeof entry.impressions === 'string' ? entry.impressions @@ -97,11 +101,6 @@ export function parseCompetitorItems(raw: unknown): CompetitorIngestItem[] { return out } -/** True when `url` already points at our own GCS bucket (no re-fetch needed). */ -export function isOwnGcsUrl(url: string): boolean { - return url.startsWith(`https://storage.googleapis.com/${GCS_BUCKET}/`) -} - /** Parse a date string, returning null on anything invalid/absent. */ function parseDate(value: string | null | undefined): Date | null { if (!value) return null @@ -109,24 +108,59 @@ function parseDate(value: string | null | undefined): Date | null { return Number.isNaN(d.getTime()) ? null : d } +const SUFFIX_MULT: Record = { k: 1e3, m: 1e6, b: 1e9 } + +/** + * Parse a human-formatted count into a number: accepts plain numbers, + * comma-grouped ("1,942"), and K/M/B-suffixed ("710.4K", "2.3M") — the exact + * forms AppGrowing's UI displays (docs §1.3). Returns null on anything + * unparseable. `Number("710.4K")` is NaN, so the old `Number()` path silently + * nulled these — the primary ranking signals — which this fixes. + */ +function parseHumanNumber(value: number | string | null | undefined): number | null { + if (value === null || value === undefined) return null + if (typeof value === 'number') return Number.isFinite(value) ? value : null + const s = value.trim().replace(/,/g, '') + const m = /^(-?\d+(?:\.\d+)?)\s*([kmb])?$/i.exec(s) + if (!m) return null + const n = Number(m[1]) + if (!Number.isFinite(n)) return null + const mult = m[2] ? SUFFIX_MULT[m[2].toLowerCase()] : 1 + return n * mult +} + /** Parse impressions into a BigInt, returning null on anything invalid/absent. */ function parseImpressions(value: number | string | null | undefined): bigint | null { - if (value === null || value === undefined) return null + const n = parseHumanNumber(value) + if (n === null) return null try { - return BigInt(Math.trunc(Number(value))) + return BigInt(Math.trunc(n)) } catch { return null } } +/** Parse adDays into an integer, returning null on anything invalid/absent. */ +function parseAdDays(value: number | string | null | undefined): number | null { + const n = parseHumanNumber(value) + return n === null ? null : Math.trunc(n) +} + /** * Merge `keyframeUrl` (no dedicated column) into rawMeta so it isn't lost. */ function mergeRawMeta(item: CompetitorIngestItem): Record | null { - const base = item.rawMeta && typeof item.rawMeta === 'object' ? { ...(item.rawMeta as object) } : {} - const merged: Record = { ...base } - if (item.keyframeUrl) merged.keyframeUrl = item.keyframeUrl - return Object.keys(merged).length ? merged : null + // Only spread plain objects — `typeof [] === 'object'`, so an array rawMeta + // would be corrupted into `{0:.., 1:..}`. Keep an array payload under a key. + const raw = item.rawMeta + const base: Record = + raw && typeof raw === 'object' && !Array.isArray(raw) + ? { ...(raw as Record) } + : raw !== null && raw !== undefined + ? { _raw: raw } + : {} + if (item.keyframeUrl) base.keyframeUrl = item.keyframeUrl + return Object.keys(base).length ? base : null } type JsonField = Prisma.InputJsonValue | undefined @@ -139,6 +173,7 @@ export interface CompetitorCreativeFields { adFormat: string | null region: string | null language: string | null + level: string | null adDays: number | null impressions: bigint | null firstSeenAt: Date | null @@ -171,7 +206,8 @@ export function mapCompetitorItemToFields(item: CompetitorIngestItem): Competito adFormat: item.adFormat ?? null, region: item.region ?? null, language: item.language ?? null, - adDays: item.adDays ?? null, + level: item.level ?? null, + adDays: parseAdDays(item.adDays), impressions: parseImpressions(item.impressions), firstSeenAt: parseDate(item.firstSeenAt), lastSeenAt: parseDate(item.lastSeenAt), diff --git a/src/lib/storage.ts b/src/lib/storage.ts index 5428aa9..c42f37e 100644 --- a/src/lib/storage.ts +++ b/src/lib/storage.ts @@ -78,7 +78,7 @@ export async function uploadToGCS( * Delete a file from GCS by its public URL. */ export async function deleteFromGCS(publicUrl: string): Promise { - const prefix = `https://storage.googleapis.com/${GCS_BUCKET}/` + const prefix = gcsPublicPrefix() if (!publicUrl.startsWith(prefix)) return const objectPath = publicUrl.slice(prefix.length) @@ -92,6 +92,66 @@ export async function deleteFromGCS(publicUrl: string): Promise { }) } +/** Public URL prefix for objects in our own bucket. Single source of truth. */ +export function gcsPublicPrefix(): string { + return `https://storage.googleapis.com/${GCS_BUCKET}/` +} + +/** Is this URL an object already hosted in our own GCS bucket? */ +export function isOwnGcsUrl(url: string): boolean { + return url.startsWith(gcsPublicPrefix()) +} + +const PRIVATE_HOST_RE = + /^(localhost$|127\.|10\.|192\.168\.|169\.254\.|::1$|\[::1\]$|0\.0\.0\.0$|metadata\.google\.internal$)|^172\.(1[6-9]|2\d|3[01])\./i + +/** + * Fetch a remote media URL and upload it to GCS, returning the public URL. + * Hardened for server-side fetch of caller-supplied URLs: + * - only http/https, and the host must not resolve to a private/link-local + * range (blocks SSRF to the metadata server / internal services) + * - rejects bodies over `maxBytes` (Content-Length pre-check + hard cap while + * reading) so one oversized file can't OOM the instance + * Returns `{ fileUrl, contentType }`. Throws on validation/size/fetch failure. + */ +export async function uploadFromUrl( + url: string, + filename: string, + opts: { maxBytes?: number } = {}, +): Promise<{ fileUrl: string; contentType: string }> { + const maxBytes = opts.maxBytes ?? 100 * 1024 * 1024 // 100MB default cap + + let parsed: URL + try { + parsed = new URL(url) + } catch { + throw new Error('invalid mediaUrl') + } + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + throw new Error(`mediaUrl scheme not allowed: ${parsed.protocol}`) + } + if (PRIVATE_HOST_RE.test(parsed.hostname)) { + throw new Error(`mediaUrl host not allowed: ${parsed.hostname}`) + } + + const res = await fetch(url, { redirect: 'error' }) + if (!res.ok) throw new Error(`fetch mediaUrl failed (${res.status})`) + + const declaredLen = Number(res.headers.get('content-length') || '0') + if (declaredLen > maxBytes) { + throw new Error(`mediaUrl too large (${declaredLen} > ${maxBytes})`) + } + + const buffer = Buffer.from(await res.arrayBuffer()) + if (buffer.byteLength > maxBytes) { + throw new Error(`mediaUrl too large (${buffer.byteLength} > ${maxBytes})`) + } + + const contentType = res.headers.get('content-type')?.split(';')[0].trim() || 'application/octet-stream' + const fileUrl = await uploadToGCS(buffer, filename, contentType) + return { fileUrl, contentType } +} + /** * Check if GCS is available (for graceful fallback in dev). */ From 6615e7fb1b8437e38b306ffa97307facb6eeec22 Mon Sep 17 00:00:00 2001 From: Martin <60476606+mx-320@users.noreply.github.com> Date: Fri, 10 Jul 2026 19:41:12 +0800 Subject: [PATCH 11/11] =?UTF-8?q?test(app):=20make=20competitor-ingest=20e?= =?UTF-8?q?2e=20robust=20=E2=80=94=20shared=20registration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WHY: register is rate-limited 5/hour/IP; registering per-test flaked across retries/reruns. Register once in beforeAll and share the authed context; the anon test uses the default unauthenticated request fixture (config baseURL, no hardcoded localhost). --- e2e/competitor-ingest.spec.ts | 68 +++++++++++++++++++---------------- 1 file changed, 38 insertions(+), 30 deletions(-) diff --git a/e2e/competitor-ingest.spec.ts b/e2e/competitor-ingest.spec.ts index 2af2d8e..7f74e62 100644 --- a/e2e/competitor-ingest.spec.ts +++ b/e2e/competitor-ingest.spec.ts @@ -24,33 +24,42 @@ function signHmac(secret: string, rawBody: string, timestamp = Math.floor(Date.n return { timestamp: String(timestamp), signature } } -/** Register a fresh user + personal org, log in, return an authed request context + orgId. */ -async function registerAndLogin(request: APIRequestContext) { - const suffix = crypto.randomBytes(6).toString('hex') - const email = `e2e-competitor-${suffix}@adex.test` - const password = 'e2e-test-password-1234' +// Register ONE user for the whole describe. The register endpoint is rate- +// limited to 5/hour/IP, so registering per-test (× retries × reruns against a +// reused dev server) flakes; the authed tests share this one context. +let authed: APIRequestContext +let orgId: string +const suffix = crypto.randomBytes(6).toString('hex') - const reg = await request.post(p('/api/auth/register'), { - data: { email, password, name: 'E2E Competitor Tester' }, - }) - expect(reg.ok(), `register failed: ${reg.status()} ${await reg.text()}`).toBe(true) +test.describe('competitor intel ingest', () => { + test.beforeAll(async ({ playwright }, testInfo) => { + const baseURL = testInfo.project.use.baseURL + authed = await playwright.request.newContext({ baseURL }) + const email = `e2e-competitor-${suffix}@adex.test` + const password = 'e2e-test-password-1234' - const login = await request.post(p('/api/auth/login'), { data: { email, password } }) - expect(login.ok(), `login failed: ${login.status()} ${await login.text()}`).toBe(true) + const reg = await authed.post(p('/api/auth/register'), { + data: { email, password, name: 'E2E Competitor Tester' }, + }) + expect(reg.ok(), `register failed: ${reg.status()} ${await reg.text()}`).toBe(true) - const orgsRes = await request.get(p('/api/orgs')) - expect(orgsRes.ok()).toBe(true) - const orgs = await orgsRes.json() - expect(Array.isArray(orgs) && orgs.length).toBeTruthy() + const login = await authed.post(p('/api/auth/login'), { data: { email, password } }) + expect(login.ok(), `login failed: ${login.status()} ${await login.text()}`).toBe(true) - return { orgId: orgs[0].id as string, suffix } -} + const orgsRes = await authed.get(p('/api/orgs')) + expect(orgsRes.ok()).toBe(true) + const orgs = await orgsRes.json() + expect(Array.isArray(orgs) && orgs.length).toBeTruthy() + orgId = orgs[0].id as string + }) -test.describe('competitor intel ingest', () => { - test('rejects a request with a bad HMAC signature', async ({ request }) => { - const { orgId } = await registerAndLogin(request) + test.afterAll(async () => { + await authed?.dispose() + }) + + test('rejects a request with a bad HMAC signature', async () => { const body = JSON.stringify({ externalId: `bad-sig-${crypto.randomBytes(4).toString('hex')}` }) - const res = await request.post(p(`/api/ingest/competitor?org=${orgId}`), { + const res = await authed.post(p(`/api/ingest/competitor?org=${orgId}`), { headers: { 'content-type': 'application/json', 'x-adex-timestamp': String(Math.floor(Date.now() / 1000)), @@ -61,8 +70,7 @@ test.describe('competitor intel ingest', () => { expect(res.status()).toBe(401) }) - test('ingests a competitor creative and upserts idempotently by externalId', async ({ request }) => { - const { orgId, suffix } = await registerAndLogin(request) + test('ingests a competitor creative and upserts idempotently by externalId', async () => { const externalId = `appgrowing-${suffix}-1` const payload = { externalId, @@ -82,7 +90,7 @@ test.describe('competitor intel ingest', () => { const rawBody = JSON.stringify(payload) const { timestamp, signature } = signHmac(INGEST_SECRET, rawBody) - const res1 = await request.post(p(`/api/ingest/competitor?org=${orgId}`), { + const res1 = await authed.post(p(`/api/ingest/competitor?org=${orgId}`), { headers: { 'content-type': 'application/json', 'x-adex-timestamp': timestamp, @@ -99,7 +107,7 @@ test.describe('competitor intel ingest', () => { // Re-ingest the same externalId — should update, not duplicate. const rawBody2 = JSON.stringify({ ...payload, adDays: 43 }) const sig2 = signHmac(INGEST_SECRET, rawBody2) - const res2 = await request.post(p(`/api/ingest/competitor?org=${orgId}`), { + const res2 = await authed.post(p(`/api/ingest/competitor?org=${orgId}`), { headers: { 'content-type': 'application/json', 'x-adex-timestamp': sig2.timestamp, @@ -113,7 +121,7 @@ test.describe('competitor intel ingest', () => { // GET /api/competitors — same authed context (cookie persists) — should // list exactly one row for this externalId, and reflect the update. - const list = await request.get(p(`/api/competitors?appName=Puzzle`)) + const list = await authed.get(p(`/api/competitors?appName=Puzzle`)) expect(list.ok()).toBe(true) const rows = await list.json() const matches = (rows as Array<{ externalId: string; adDays: number | null }>).filter( @@ -123,10 +131,10 @@ test.describe('competitor intel ingest', () => { expect(matches[0].adDays).toBe(43) }) - test('GET /api/competitors requires a logged-in session', async ({ playwright }) => { - const anonRequest = await playwright.request.newContext({ baseURL: `http://localhost:${process.env.PORT || '3000'}` }) - const res = await anonRequest.get(p('/api/competitors')) + test('GET /api/competitors requires a logged-in session', async ({ request }) => { + // The default `request` fixture is unauthenticated and honours the config + // baseURL — no hardcoded host, no shared cookies. + const res = await request.get(p('/api/competitors')) expect(res.status()).toBe(401) - await anonRequest.dispose() }) })