Skip to content
Open

. #1

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions apps/api/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,7 @@ ARK_ENDPOINT=

# 样例 / 用户素材上传大小上限(MB),默认 200;需与 Fastify multipart 一致
# MAX_UPLOAD_MB=500

# 可选:Pexels 免费视频检索(用真实素材补齐缺口)。无 key 时缺口回退 text_card 占位。
# 注册免费 key:https://www.pexels.com/api/
PEXELS_API_KEY=
68 changes: 68 additions & 0 deletions apps/api/src/agents/__tests__/fillWithStock.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { describe, expect, it } from 'vitest';
import { sampleBlueprint } from '../../core/mocks/sample-blueprint';
import { runRuleBasedMigration } from '../../core/migration';
import type { TaggedAsset } from '../../core/slot';
import { enhanceFillsWithStock } from '../fillWithStock';

const assets: TaggedAsset[] = [
{ id: 'a1', mediaType: 'video', assetTags: ['product_closeup'], durationSec: 5, confidence: 0.9, summary: '特写' },
];

const basePlan = () =>
runRuleBasedMigration({
projectId: 'p',
sampleId: sampleBlueprint.sourceSampleId,
blueprint: sampleBlueprint,
assets,
topic: 'F1 赛车冠军征程',
durationSec: 30,
});

describe('enhanceFillsWithStock', () => {
it('检索命中:text_card 缺口被替换为 stock_clip,记一条 decision', async () => {
const plan = basePlan();
const tcCount = plan.fills.filter((f) => f.kind === 'text_card').length;
expect(tcCount).toBeGreaterThan(0);

const enhanced = await enhanceFillsWithStock(plan, {
search: async () => ({ path: '/tmp/stock.mp4', attribution: 'Pexels stub', sourceUrl: 'https://x' }),
outDir: '/tmp',
blueprint: sampleBlueprint,
});

expect(enhanced.fills.filter((f) => f.kind === 'stock_clip')).toHaveLength(tcCount);
expect(enhanced.fills.filter((f) => f.kind === 'text_card')).toHaveLength(0);
expect(enhanced.fills.every((f) => f.kind !== 'stock_clip' || f.source === '/tmp/stock.mp4')).toBe(true);
expect(enhanced.decisions.some((d) => d.chosen === 'stock_clip')).toBe(true);
});

it('检索全部 null:保留 text_card,计划不变', async () => {
const plan = basePlan();
const enhanced = await enhanceFillsWithStock(plan, {
search: async () => null,
outDir: '/tmp',
blueprint: sampleBlueprint,
});
expect(enhanced.fills.filter((f) => f.kind === 'stock_clip')).toHaveLength(0);
expect(enhanced.fills.filter((f) => f.kind === 'text_card').length).toBe(
plan.fills.filter((f) => f.kind === 'text_card').length,
);
});

it('检索词来自 topic + 资产类型映射', async () => {
const plan = basePlan();
const queries: string[] = [];
await enhanceFillsWithStock(plan, {
search: async (q) => {
queries.push(q);
return null;
},
outDir: '/tmp',
blueprint: sampleBlueprint,
});
// 至少有一个 query 同时含 topic 关键词 'F1' 和某个英文素材关键词
const joined = queries.join(' | ');
expect(joined).toContain('F1');
expect(/talking|product|comparison|b roll|using/i.test(joined)).toBe(true);
});
});
67 changes: 67 additions & 0 deletions apps/api/src/agents/fillWithStock.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import type { VideoStructureBlueprint } from '../core/blueprint';
import type { Decision } from '../core/explain';
import type { MigrationPlan } from '../core/migration';
import { searchAndDownloadStock, type StockSearchFn } from './stockFootage';

export interface StockFillOptions {
/** 检索函数;默认走 Pexels(需 PEXELS_API_KEY,缺则回退)。可注入用于测试。 */
search?: StockSearchFn;
/** 下载落地目录。 */
outDir: string;
/** 用于按 slot 上的 segmentRole / requiredAssetTypes 构造检索词。 */
blueprint?: VideoStructureBlueprint;
/** 自定义检索词构造;默认 topic + 资产类型英语关键词。 */
buildQuery?: (ctx: { topic: string; role?: string; requiredAssetTypes?: string[] }) => string;
}

// AssetTag → Pexels 检索的英文关键词(Pexels 中文检索效果差)
const ASSET_TERM: Record<string, string> = {
talking_head: 'person talking',
product_closeup: 'product closeup',
usage_demo: 'using product',
comparison: 'comparison',
b_roll: 'cinematic b roll',
text_card: '',
};

function defaultBuildQuery(ctx: { topic: string; requiredAssetTypes?: string[] }): string {
const term = ctx.requiredAssetTypes?.map((t) => ASSET_TERM[t]).find(Boolean) ?? '';
return [ctx.topic, term].filter(Boolean).join(' ').trim();
}

/**
* 用免费 stock 素材替换 text_card 缺口补全;检索失败/无 key → 保留 text_card。
* 借鉴 OpenMontage「免费档案补全」的范式,自研薄适配器(非依赖)。
*/
export async function enhanceFillsWithStock(
plan: MigrationPlan,
opts: StockFillOptions,
): Promise<MigrationPlan> {
const search = opts.search ?? searchAndDownloadStock;
const buildQuery = opts.buildQuery ?? defaultBuildQuery;
const slots = opts.blueprint?.slots ?? [];
const decisions: Decision[] = [...plan.decisions];

const fills = await Promise.all(
plan.fills.map(async (f) => {
if (f.kind !== 'text_card') return f;
const slot = slots.find((s) => s.id === f.slotId);
const query = buildQuery({
topic: plan.topic,
role: slot?.segmentRole,
requiredAssetTypes: slot?.requiredAssetTypes,
});
const clip = await search(query, opts.outDir);
if (!clip) return f;
decisions.push({
chosen: 'stock_clip',
alternatives: ['text_card'],
confidence: 0.7,
reason: `Pexels 检索命中:${clip.attribution}(query=${query})`,
});
return { ...f, kind: 'stock_clip' as const, source: clip.path };
}),
);

return { ...plan, fills, decisions };
}
34 changes: 34 additions & 0 deletions apps/api/src/agents/scripts/versions-demo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import type { VideoStructureBlueprint } from '../../core/blueprint';
import type { TaggedAsset } from '../../core/slot';
import { generateVersions } from '../../core/versions';

// 多版本演示(规则版,无需 key):用 sample1 蓝图套预设变换。
const blueprint = JSON.parse(
readFileSync(resolve('out/analysis/sample1/blueprint.json'), 'utf8'),
) as VideoStructureBlueprint;

const assets: TaggedAsset[] = [
{ id: 'a', mediaType: 'video', assetTags: ['talking_head'], durationSec: 12, confidence: 0.8, summary: '口播' },
{ id: 'b', mediaType: 'video', assetTags: ['b_roll'], durationSec: 20, confidence: 0.8, summary: '空镜' },
];

const variants = generateVersions({
projectId: 'demo',
sampleId: blueprint.sourceSampleId,
blueprint,
assets,
topic: 'F1 赛车手追逐冠军梦想的赛季故事',
sellingPoints: ['全力以赴争取每一分'],
durationSec: 40,
});

console.log(`base: cutDensity=${blueprint.rhythmStructure.cutDensity} avgShot=${blueprint.rhythmStructure.avgShotSec} peakAt=${blueprint.rhythmStructure.peakAt}`);
for (const v of variants) {
const r = v.blueprint.rhythmStructure;
const hook = v.blueprint.scriptStructure.segments.find((s) => s.role === 'hook');
console.log(
`\n[${v.label}] ${v.describe}\n cutDensity=${r.cutDensity} avgShot=${r.avgShotSec} peakAt=${r.peakAt} hook%=${hook ? Math.round(hook.durationRatio * 100) : '-'} gaps=${v.migration.gaps.length}`,
);
}
63 changes: 63 additions & 0 deletions apps/api/src/agents/stockFootage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { randomUUID } from 'node:crypto';
import { createWriteStream, mkdirSync } from 'node:fs';
import { join } from 'node:path';
import { Readable } from 'node:stream';
import { pipeline } from 'node:stream/promises';
import type { ReadableStream as WebReadable } from 'node:stream/web';
import { getPexelsKey } from '../config';

export interface StockClip {
/** 下载到本地的文件绝对路径(供 render 使用)。 */
path: string;
/** 归属信息(供 UI / 答辩展示,遵守 Pexels 署名要求)。 */
attribution: string;
sourceUrl: string;
}

export type StockSearchFn = (query: string, outDir: string) => Promise<StockClip | null>;

interface PexelsFile {
link: string;
width: number;
height: number;
file_type: string;
}
interface PexelsVideo {
url: string;
user?: { name?: string };
video_files?: PexelsFile[];
}

const PEXELS_VIDEO_SEARCH = 'https://api.pexels.com/videos/search';

/** 选一个合适分辨率的 mp4 文件(尽量靠近 720p,避免超大)。 */
function pickFile(files: PexelsFile[]): PexelsFile | null {
const mp4 = files.filter((f) => f.file_type === 'video/mp4');
if (mp4.length === 0) return null;
return mp4.slice().sort((a, b) => (a.height - 720) ** 2 - (b.height - 720) ** 2)[0];
}

/** 默认实现:Pexels 视频检索 + 下载。无 key / 失败 → null(调用方回退 text_card)。 */
export const searchAndDownloadStock: StockSearchFn = async (query, outDir) => {
const key = getPexelsKey();
if (!key || !query.trim()) return null;
try {
const url = `${PEXELS_VIDEO_SEARCH}?query=${encodeURIComponent(query)}&per_page=1`;
const res = await fetch(url, { headers: { Authorization: key } });
if (!res.ok) return null;
const data = (await res.json()) as { videos?: PexelsVideo[] };
const video = data.videos?.[0];
const file = pickFile(video?.video_files ?? []);
if (!video || !file) return null;

mkdirSync(outDir, { recursive: true });
const dest = join(outDir, `stock_${randomUUID().slice(0, 8)}.mp4`);
const dl = await fetch(file.link);
if (!dl.ok || !dl.body) return null;
await pipeline(Readable.fromWeb(dl.body as WebReadable<Uint8Array>), createWriteStream(dest));
const by = video.user?.name ? ` by ${video.user.name}` : '';
return { path: dest, attribution: `Pexels${by}`, sourceUrl: video.url };
} catch {
return null;
}
};
5 changes: 5 additions & 0 deletions apps/api/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,8 @@ export function requireArkConfig(): ArkConfig {
}
return cfg;
}

/** Pexels 免费视频检索 API key(可选);缺则 stock 补全自动回退为 text_card。 */
export function getPexelsKey(): string | null {
return process.env.PEXELS_API_KEY?.trim() || null;
}
59 changes: 59 additions & 0 deletions apps/api/src/core/__tests__/versions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { describe, expect, it } from 'vitest';
import { applyBlueprintPatch } from '../applyPatch';
import { sampleBlueprint } from '../mocks/sample-blueprint';
import type { TaggedAsset } from '../slot';
import { validateBlueprint, validateMigrationPlan } from '../validate';
import { VERSION_PRESETS, generateVersions } from '../versions';

const assets: TaggedAsset[] = [
{ id: 'a1', mediaType: 'video', assetTags: ['product_closeup'], durationSec: 5, confidence: 0.9, summary: '特写' },
];

describe('applyBlueprintPatch', () => {
it('set + scale 生效,且段落比例归一化到 1', () => {
const fast = VERSION_PRESETS.find((p) => p.id === 'fast')!;
const out = applyBlueprintPatch(sampleBlueprint, fast.patch);

expect(out.rhythmStructure.cutDensity).toBe('high');
expect(out.rhythmStructure.avgShotSec).toBeCloseTo(sampleBlueprint.rhythmStructure.avgShotSec * 0.7, 2);
const sum = out.scriptStructure.segments.reduce((a, s) => a + s.durationRatio, 0);
expect(sum).toBeCloseTo(1, 2);
expect(validateBlueprint(out).ok).toBe(true);
// 不改原对象(base 的 avgShotSec 不应被改动)
expect(sampleBlueprint.rhythmStructure.avgShotSec).not.toBe(out.rhythmStructure.avgShotSec);
});

it('未识别 path 被忽略、不报错', () => {
const out = applyBlueprintPatch(sampleBlueprint, {
origin: 'nl',
ops: [{ path: 'nonsense.path', op: 'set', value: 1 }],
});
expect(validateBlueprint(out).ok).toBe(true);
});
});

describe('generateVersions', () => {
it('产出 3 个差异明确且各自合法的版本', () => {
const variants = generateVersions({
projectId: 'p',
sampleId: sampleBlueprint.sourceSampleId,
blueprint: sampleBlueprint,
assets,
topic: '测试主题',
durationSec: 30,
});

expect(variants).toHaveLength(3);
const byId = Object.fromEntries(variants.map((v) => [v.id, v]));
expect(byId.fast.blueprint.rhythmStructure.cutDensity).toBe('high');
expect(byId.cinematic.blueprint.rhythmStructure.cutDensity).toBe('low');
// 高节奏 vs 高质感的平均镜头时长应不同(差异明确)
expect(byId.fast.blueprint.rhythmStructure.avgShotSec).toBeLessThan(
byId.cinematic.blueprint.rhythmStructure.avgShotSec,
);
for (const v of variants) {
expect(validateBlueprint(v.blueprint).ok).toBe(true);
expect(validateMigrationPlan(v.migration).ok).toBe(true);
}
});
});
70 changes: 70 additions & 0 deletions apps/api/src/core/applyPatch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import type { VideoStructureBlueprint } from './blueprint';
import type { CutDensity, SubtitleDensity } from './enums';
import type { BlueprintPatch, BlueprintPatchOp } from './patch';

function round(n: number): number {
return Number(n.toFixed(3));
}
function clamp01(n: number): number {
return Math.max(0, Math.min(1, n));
}

/**
* 应用一个 BlueprintPatch,返回新蓝图(不改原对象)。
* 人工调参 / 多版本预设 / NL 编辑统一走这里(见 DESIGN §5.6)。
* 支持的 path:
* - `rhythmStructure.cutDensity` (set)
* - `rhythmStructure.avgShotSec` (set | scale)
* - `rhythmStructure.peakAt` (set)
* - `packagingStructure.subtitleDensity` (set)
* - `segment.<role>.durationRatio` (scale,按 role 命中)
* 未识别的 path 忽略。最后对段落 durationRatio 归一化到和为 1。
*/
export function applyBlueprintPatch(
bp: VideoStructureBlueprint,
patch: BlueprintPatch,
): VideoStructureBlueprint {
const next = structuredClone(bp);
for (const op of patch.ops) applyOp(next, op);
normalizeRatios(next);
return next;
}

function applyOp(bp: VideoStructureBlueprint, op: BlueprintPatchOp): void {
const { path, op: kind, value } = op;

if (path === 'rhythmStructure.cutDensity' && kind === 'set') {
bp.rhythmStructure.cutDensity = value as CutDensity;
return;
}
if (path === 'rhythmStructure.peakAt' && kind === 'set') {
bp.rhythmStructure.peakAt = clamp01(Number(value));
return;
}
if (path === 'rhythmStructure.avgShotSec') {
if (kind === 'scale') bp.rhythmStructure.avgShotSec = round(bp.rhythmStructure.avgShotSec * Number(value));
else if (kind === 'set') bp.rhythmStructure.avgShotSec = Number(value);
return;
}
if (path === 'packagingStructure.subtitleDensity' && kind === 'set' && bp.packagingStructure) {
bp.packagingStructure.subtitleDensity = value as SubtitleDensity;
return;
}
const seg = path.match(/^segment\.([a-z_]+)\.durationRatio$/);
if (seg && kind === 'scale') {
for (const s of bp.scriptStructure.segments) {
if (s.role === seg[1]) s.durationRatio = Math.max(0.01, s.durationRatio * Number(value));
}
}
// 未识别 path:忽略(保持健壮)
}

function normalizeRatios(bp: VideoStructureBlueprint): void {
const segs = bp.scriptStructure.segments;
const sum = segs.reduce((a, s) => a + s.durationRatio, 0);
if (sum <= 0 || segs.length === 0) return;
for (const s of segs) s.durationRatio = round(s.durationRatio / sum);
// 修正四舍五入漂移,让和恰好为 1
const drift = round(1 - segs.reduce((a, s) => a + s.durationRatio, 0));
segs[segs.length - 1].durationRatio = round(segs[segs.length - 1].durationRatio + drift);
}
1 change: 1 addition & 0 deletions apps/api/src/core/enums.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export const FillKind = z.enum([
'reused_clip',
'text_card',
'packaging_overlay',
'stock_clip',
'aigc_clip',
'aigc_image',
]);
Expand Down
2 changes: 2 additions & 0 deletions apps/api/src/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,7 @@ export * from './sample';
export * from './timeline';
export * from './migration';
export * from './patch';
export * from './applyPatch';
export * from './versions';
export * from './validate';
export * from './jsonSchema';
Loading