feat: publish Agent Studio integrations and setup guide

This commit is contained in:
Wenxin Duan
2026-09-16 16:32:16 +08:00
parent 2ad089d68a
commit 05e6060490
106 changed files with 4942 additions and 198 deletions
+6 -1
View File
@@ -20,7 +20,12 @@
"@agent-studio/shared": "workspace:*",
"@earendil-works/pi-ai": "0.85.1",
"@earendil-works/pi-coding-agent": "^0.85.1",
"@modelcontextprotocol/sdk": "^1.30.0",
"@supabase/supabase-js": "2.116.0",
"ffmpeg-static": "5.3.0",
"ffprobe-static": "3.1.0",
"sharp": "^0.35.4",
"typebox": "1.3.7"
"typebox": "1.3.7",
"zod": "4.5.4"
}
}
@@ -7,6 +7,7 @@ import {
createAgentSession,
type AgentSession,
type ExtensionFactory,
type Skill,
type ToolDefinition,
} from "@earendil-works/pi-coding-agent";
import type { ApprovalBroker } from "../approvals/approval-broker.js";
@@ -40,6 +41,8 @@ export type CreatePiAgentSessionPortOptions = {
agentDir: string;
customTools?: ToolDefinition[];
approvalBroker?: ApprovalBroker;
skills?: Skill[];
appendSystemPrompt?: string[];
};
export function createPiAgentSessionPortFactory(
@@ -70,6 +73,9 @@ export function createPiAgentSessionPortFactory(
settingsManager,
extensionFactories,
noExtensions: true,
noSkills: true,
skillsOverride: () => ({ skills: options.skills ?? [], diagnostics: [] }),
...(options.appendSystemPrompt === undefined ? {} : { appendSystemPrompt: options.appendSystemPrompt }),
noPromptTemplates: true,
noThemes: true,
noContextFiles: true,
@@ -80,7 +86,7 @@ export function createPiAgentSessionPortFactory(
agentDir: options.agentDir,
modelRuntime,
model,
tools: ["read", "grep", "find", "ls", "mock_ads_metrics", "save_report_draft"],
tools: ["read", "grep", "find", "ls", ...(options.customTools ?? []).map((tool) => tool.name)],
customTools: options.customTools ?? [],
resourceLoader,
settingsManager,
+19 -1
View File
@@ -1,4 +1,5 @@
import type { EventStore } from "../events/event-store.js";
import { HarnessError } from "../errors/harness-error.js";
import { redactAndLimit } from "../security/redact.js";
type SkillInferencePort = {
@@ -46,6 +47,18 @@ export class PiEventAdapter {
return;
}
if (value.type === "message_end" && isRecord(value.message) && value.message.role === "assistant") {
if (value.message.stopReason === "toolUse") return;
if (value.message.stopReason === "error") {
throw new HarnessError("MODEL_REQUEST_FAILED", "Model request failed; check the configured model and gateway");
}
if (value.message.stopReason === "length") {
throw new HarnessError("MODEL_OUTPUT_LIMIT", "模型达到输出额度,回复未完成;请缩小本轮任务或检查模型输出配置");
}
if (Array.isArray(value.message.content) && !value.message.content.some(
(block: unknown) => isRecord(block) && block.type === "text" && typeof block.text === "string" && block.text.trim() !== "",
)) {
throw new HarnessError("MODEL_EMPTY_RESPONSE", "模型未返回可用正文或工具调用;本轮未成功完成,请检查网关响应与模型配置");
}
if (!this.#assistantCompleted) {
this.#assistantCompleted = true;
this.#eventStore.append(this.#sessionId, {
@@ -108,7 +121,12 @@ export class PiEventAdapter {
payload: {
toolCallId: event.toolCallId,
toolName: event.toolName,
result: redactAndLimit(event.result, this.#maxPayloadBytes),
result: redactAndLimit(
["i2v_create_task", "i2v_upload_reference", "i2v_submit", "i2v_status", "mock_ads_metrics", "save_report_draft", "generate_minis_report", "metrics_catalog", "metrics_semantic_query", "swads_whoami", "prepare_drama_sources", "editor_job_status", "read_drama_frames", "submit_drama_analysis", "propose_drama_selection", "submit_editor_plan", "render_editor_preview"].includes(event.toolName) &&
isRecord(event.result) && event.result.details !== undefined
? event.result.details : event.result,
this.#maxPayloadBytes,
),
},
});
}
+205
View File
@@ -0,0 +1,205 @@
import { createClient, type SupabaseClient } from "@supabase/supabase-js";
import { z } from "zod";
import { HarnessError } from "../errors/harness-error.js";
export const CMS_SUPABASE_URL = "https://lmcysqgfhoxppwuorsmu.supabase.co";
export const CMS_BACKEND_URL = "https://swave.pencil-stub.com/swave-api";
const Id = z.string().uuid();
const Drama = z.object({ id: Id, title: z.string(), synopsis: z.string().nullable(), language: z.string(), total_episodes: z.number().int().nonnegative(), status: z.string() });
const Episode = z.object({ id: Id, drama_id: Id, episode_no: z.number().int().positive(), title: z.string().nullable(), duration_seconds: z.number().nullable(), status: z.string() });
const Source = z.object({ episode_id: Id, provider: z.string(), playback_path: z.string().nullable() });
const Subtitle = z.object({ episode_id: Id, locale: z.string(), path: z.string().nullable() });
const dramaSelect = "id,title,synopsis,language,total_episodes,status";
type Options = { publishableKey: string; fetch?: typeof fetch };
export class CmsConnection {
#client: SupabaseClient | undefined;
#expiresAt = 0;
#generation = 0;
#loggingIn = false;
readonly #key: string;
readonly #fetch: typeof fetch;
public constructor(options: Options) {
this.#key = options.publishableKey;
const externalFetch = options.fetch ?? fetch;
this.#fetch = async (input, init) => {
const url = new URL(typeof input === "string" ? input : input instanceof URL ? input.href : input.url);
if (![new URL(CMS_SUPABASE_URL).origin, new URL(CMS_BACKEND_URL).origin].includes(url.origin)) throw new Error("CMS destination denied");
const timeout = AbortSignal.timeout(20_000);
const signal = init?.signal == null ? timeout : AbortSignal.any([init.signal, timeout]);
const response = await externalFetch(input, { ...init, redirect: "error", signal });
const reader = response.body?.getReader();
if (reader === undefined) return response;
const chunks: Uint8Array[] = [];
let size = 0;
try {
for (;;) {
const chunk = await reader.read();
if (chunk.done) break;
size += chunk.value.byteLength;
if (size > 4 * 1024 * 1024) throw new Error("CMS response limit exceeded");
chunks.push(chunk.value);
}
} finally { await reader.cancel().catch(() => undefined); }
return new Response(Buffer.concat(chunks), { status: response.status, statusText: response.statusText, headers: response.headers });
};
}
public status() {
const connected = this.#client !== undefined && this.#expiresAt > Date.now();
return { configured: this.#key.length > 0, connected, ...(connected ? { expiresAt: new Date(this.#expiresAt).toISOString() } : {}), project: "shorts-sg", cmsUrl: "https://swave.pencil-stub.com/swave/" };
}
public async login(email: string, password: string) {
if (!this.#key) throw new HarnessError("CMS_NOT_CONFIGURED", "CMS publishable key is missing");
if (this.#loggingIn) throw new HarnessError("SESSION_BUSY", "CMS login is already running");
z.object({ email: z.string().email().max(254), password: z.string().min(1).max(1024) }).parse({ email, password });
this.clear();
const generation = this.#generation;
this.#loggingIn = true;
const client = createClient(CMS_SUPABASE_URL, this.#key, { auth: { persistSession: false, autoRefreshToken: false, detectSessionInUrl: false }, global: { fetch: this.#fetch } });
try {
const result = await client.auth.signInWithPassword({ email, password });
if (result.error !== null || result.data.session === null) throw new HarnessError("CMS_LOGIN_FAILED", "CMS 登录失败,请检查账户、密码或验证码要求");
const user = await client.auth.getUser(result.data.session.access_token);
if (user.error !== null || user.data.user?.app_metadata.role !== "admin") throw new HarnessError("CMS_ADMIN_REQUIRED", "需要具有 CMS 管理员权限的 Supabase 账户");
if (generation !== this.#generation) throw new HarnessError("CMS_LOGIN_CANCELLED", "CMS login was cancelled");
this.#expiresAt = (result.data.session.expires_at ?? 0) * 1000;
if (this.#expiresAt <= Date.now()) throw new HarnessError("CMS_LOGIN_FAILED", "CMS 返回的登录会话已过期");
this.#client = client;
return this.status();
} catch (error) {
if (generation === this.#generation) this.clear();
if (error instanceof HarnessError) throw error;
throw new HarnessError("CMS_LOGIN_FAILED", "CMS 登录请求失败;未保存密码");
} finally { this.#loggingIn = false; }
}
public clear(): void {
this.#generation++;
this.#client = undefined;
this.#expiresAt = 0;
}
async #authenticated(): Promise<SupabaseClient> {
const client = this.#client;
if (client === undefined || this.#expiresAt <= Date.now()) {
this.clear();
throw new HarnessError("CMS_LOGIN_REQUIRED", "请先在网页 CMS 连接面板登录管理员账户");
}
const generation = this.#generation;
const { data, error } = await client.auth.getUser();
if (generation !== this.#generation) throw new HarnessError("CMS_LOGIN_REQUIRED", "CMS connection changed");
if (error !== null || data.user?.app_metadata.role !== "admin") {
this.clear();
throw new HarnessError("CMS_LOGIN_REQUIRED", "CMS 会话失效或管理员权限已撤销,请重新登录");
}
return client;
}
public async listDramas(query: string, offset: number) {
z.string().max(120).parse(query);
z.number().int().min(0).max(10_000).parse(offset);
return this.#safe(async () => {
const client = await this.#authenticated();
let request = client.from("drama").select(dramaSelect).order("title").order("id").range(offset, offset + 29);
if (query.trim()) request = request.ilike("title", `%${query.trim().replaceAll(/[\\%_]/g, "\\$&")}%`);
const { data, error } = await request;
if (error !== null) throw new Error();
return { dramas: z.array(Drama).parse(data), offset, pageSize: 30 };
});
}
public async inventory(dramaId: string) {
Id.parse(dramaId);
return this.#safe(async () => {
const client = await this.#authenticated();
const [dramaResult, episodeResult, categoryResult] = await Promise.all([
client.from("drama").select(dramaSelect).eq("id", dramaId).limit(1),
client.from("episode").select("id,drama_id,episode_no,title,duration_seconds,status").eq("drama_id", dramaId).order("episode_no").range(0, 999),
client.from("drama_category").select("drama_id,category(name)").eq("drama_id",dramaId).order("category_id").range(0,99),
]);
if (dramaResult.error !== null || episodeResult.error !== null || categoryResult.error !== null) throw new Error();
const categories=z.array(z.object({drama_id:Id,category:z.object({name:z.string()})})).parse(categoryResult.data);
if(categories.length>=100||categories.some(category=>category.drama_id!==dramaId))throw new HarnessError("CMS_CATEGORY_INVALID","CMS 分类返回不完整或不属于目标剧");
const tags=[...new Set(categories.map(category=>category.category.name))];
const drama = z.array(Drama).parse(dramaResult.data)[0];
if (drama === undefined || drama.id !== dramaId) throw new HarnessError("CMS_DRAMA_NOT_FOUND", "CMS 剧集不存在或无权访问");
const episodes = z.array(Episode).parse(episodeResult.data);
if (episodes.length >= 1000 || drama.total_episodes > 1000) throw new HarnessError("CMS_INVENTORY_TOO_LARGE", "剧集超过单次安全处理范围,未声称完整读取");
if (episodes.some((episode) => episode.drama_id !== dramaId)) throw new Error();
const sources: z.infer<typeof Source>[] = [];
const subtitles: z.infer<typeof Subtitle>[] = [];
for (let index = 0; index < episodes.length; index += 25) {
const ids = episodes.slice(index, index + 25).map((episode) => episode.id);
const [sourceResult, subtitleResult] = await Promise.all([
client.from("episode_source").select("episode_id,provider,playback_path").in("episode_id", ids).range(0, 999),
client.from("subtitle_track").select("episode_id,locale,path").in("episode_id", ids).range(0, 999),
]);
if (sourceResult.error !== null || subtitleResult.error !== null) throw new Error();
const sourceRows = z.array(Source).parse(sourceResult.data);
const subtitleRows = z.array(Subtitle).parse(subtitleResult.data);
if (sourceRows.length >= 1000 || subtitleRows.length >= 1000) throw new HarnessError("CMS_INVENTORY_TOO_LARGE", "源或字幕分页超限,目录不完整");
sources.push(...sourceRows); subtitles.push(...subtitleRows);
}
const missingEpisodeNumbers = Array.from({ length: drama.total_episodes }, (_, index) => index + 1).filter((number) => !episodes.some((episode) => episode.episode_no === number));
const rows = episodes.map((episode) => ({ id: episode.id, episodeNo: episode.episode_no, title: episode.title, durationSeconds: episode.duration_seconds, status: episode.status,
hasSource: sources.some((source) => source.episode_id === episode.id && Boolean(source.playback_path?.trim())),
subtitleLocales: [...new Set(subtitles.filter((subtitle) => subtitle.episode_id === episode.id && subtitle.path?.trim()).map((subtitle) => subtitle.locale))],
}));
return { drama:{...drama,tags}, fetchedAt: new Date().toISOString(), episodes: rows, missingEpisodeNumbers,
complete: drama.total_episodes > 0 && rows.length === drama.total_episodes && missingEpisodeNumbers.length === 0 && rows.every((episode) => episode.hasSource),
playbackVerified: false,
};
});
}
public async subtitle(dramaId: string, episodeId: string, locale: string) {
Id.parse(episodeId); z.string().regex(/^[a-zA-Z]{2,3}(?:-[a-zA-Z0-9]{2,8})*$/).max(24).parse(locale);
const inventory = await this.inventory(dramaId);
const episode = inventory.episodes.find((item) => item.id === episodeId);
if (episode === undefined) throw new HarnessError("CMS_EPISODE_NOT_FOUND", "该分集不属于所选剧集");
if (!episode.subtitleLocales.includes(locale)) throw new HarnessError("CMS_SUBTITLE_NOT_FOUND", "该分集未配置所选语言字幕");
return this.#safe(async () => {
const client = await this.#authenticated();
const { data } = await client.auth.getSession();
if (data.session === null) throw new HarnessError("CMS_LOGIN_REQUIRED", "CMS 会话已失效");
const response = await this.#fetch(`${CMS_BACKEND_URL}/api/v1/cms/playback/${episodeId}/subtitles/${locale}.vtt`, { headers: { Authorization: `Bearer ${data.session.access_token}` } });
if (!response.ok) throw new HarnessError("CMS_SUBTITLE_FAILED", `CMS 字幕接口返回 HTTP ${response.status.toString()}`);
const text = await response.text();
if (!text.trimStart().startsWith("WEBVTT") || text.length > 200_000) throw new HarnessError("CMS_SUBTITLE_INVALID", "CMS 字幕格式无效或过大");
return { dramaId, episodeId, locale, text, fetchedAt: new Date().toISOString() };
});
}
/** Server-only capability. Never expose the signed URL in a model tool or event. */
public async playback(dramaId: string, episodeId: string) {
Id.parse(episodeId);
const inventory = await this.inventory(dramaId);
if (!inventory.episodes.some((episode) => episode.id === episodeId && episode.hasSource)) throw new HarnessError("CMS_EPISODE_NOT_FOUND", "所选分集没有属于该剧的可用源记录");
return this.#safe(async () => {
const client = await this.#authenticated();
const {data} = await client.auth.getSession();
if (!data.session) throw new HarnessError("CMS_LOGIN_REQUIRED", "CMS 会话已失效");
const response = await this.#fetch(`${CMS_BACKEND_URL}/api/v1/cms/playback/${episodeId}`, {headers:{Authorization:`Bearer ${data.session.access_token}`}});
if (!response.ok) throw new HarnessError("CMS_SIGNING_FAILED", "CMS 管理员签名接口失败;不代表片源缺失");
const signed = z.object({url:z.string().url(),expires_at:z.string().datetime({offset:true})}).parse(await response.json());
const url = new URL(signed.url);
if (url.origin !== "https://video.pencil-stub.com" || url.username || url.password || url.hash || !url.pathname.endsWith(".m3u8") || Date.parse(signed.expires_at)<=Date.now()) throw new HarnessError("CMS_SIGNING_INVALID", "CMS 返回的签名源不符合固定 CDN 安全范围");
return signed;
});
}
async #safe<T>(operation: () => Promise<T>): Promise<T> {
const generation = this.#generation;
try {
const result = await operation();
if (generation !== this.#generation) throw new HarnessError("CMS_LOGIN_REQUIRED", "CMS connection changed");
return result;
} catch (error) {
if (error instanceof HarnessError) throw error;
throw new HarnessError("CMS_REQUEST_FAILED", "CMS 请求失败或返回格式不符;未使用模拟数据替代");
}
}
}
+32
View File
@@ -0,0 +1,32 @@
import { defineTool } from "@earendil-works/pi-coding-agent";
import { Type } from "typebox";
import type { CmsConnection } from "./cms-connection.js";
export const EDITOR_RESOURCES = [
"references/cms-source-contract.md", "references/target-drama-analysis.md", "references/creative-strategy-contract.md",
"references/viral-reference-research.md", "references/story-editing-rules.md", "references/recipe-schema.md",
"references/recipe.schema.json", "references/reference-candidates.schema.json", "references/continuity-contract.md",
"references/hook-design-contract.md", "references/qa-contract.md", "references/editorial-gates.md",
"references/local-delivery.md", "references/swads-delivery-contract.md", "requirements.txt",
"references/tiktok-adult-content-policy.md",
"scripts/common.py", "scripts/inspect_media.py", "scripts/validate_target_drama_analysis.py", "scripts/score_reference_candidates.py",
"scripts/validate_hook_map.py", "scripts/validate_recipe.py", "scripts/render_exact.py", "scripts/verify_audio_sync.py", "scripts/qa_video.py",
] as const;
function output(details: unknown) { return { content: [{ type: "text" as const, text: JSON.stringify(details) }], details }; }
const id = Type.String({ pattern: "^[a-fA-F0-9-]{36}$" });
export function createEditorTools(cms: CmsConnection,mediaCapabilities:()=>{rendering:boolean;vision:boolean;humanReview:boolean}=()=>({rendering:false,vision:false,humanReview:false})) {
return [
defineTool({ name: "cms_connection_status", label: "CMS connection", description: "Check this Session's CMS connection and configured media tools, not per-job readiness. Never request passwords in chat. If disconnected, direct the user to CMS 连接与选剧. Rendering remains gated by real analysis, reference and human reviews. Public reference search is not configured; actual reference evidence must be supplied. Do not fabricate readiness or files.", parameters: Type.Object({}, { additionalProperties: false }),
execute() { return Promise.resolve(output({ ...cms.status(), capabilities: { inventory: true, subtitles: true, referenceResearch: false, ...mediaCapabilities() }, note:"Capabilities mean registered tools, not analysis/content PASS. Use editor_job_status for an actual job." })); } }),
defineTool({ name: "cms_list_dramas", label: "CMS drama catalog", description: "Search the real CMS by title. Requires CMS administrator login and page approval before sending CMS metadata to the model. Resolve ambiguous titles with the user.",
parameters: Type.Object({ query: Type.String({ maxLength: 120 }), offset: Type.Integer({ minimum: 0, maximum: 10_000 }) }, { additionalProperties: false }),
async execute(_id, args) { return output(await cms.listDramas(args.query, args.offset)); } }),
defineTool({ name: "cms_drama_inventory", label: "CMS complete inventory", description: "Read real synopsis, episode inventory and configured subtitle languages for an exact drama ID. Requires page approval. complete checks database source coverage only; playbackVerified=false does NOT prove footage can be downloaded. Preserve fetchedAt for analysis v2.",
parameters: Type.Object({ dramaId: id }, { additionalProperties: false }),
async execute(_id, args) { return output(await cms.inventory(args.dramaId)); } }),
defineTool({ name: "cms_read_subtitle", label: "CMS subtitles", description: "Read real WebVTT from the authenticated CMS admin subtitle endpoint. Requires page approval and exact drama/episode binding. Subtitle evidence is not frame evidence and cannot substitute for viewing footage.",
parameters: Type.Object({ dramaId: id, episodeId: id, locale: Type.String({ maxLength: 24 }) }, { additionalProperties: false }),
async execute(_id, args) { return output(await cms.subtitle(args.dramaId, args.episodeId, args.locale)); } }),
];
}
@@ -11,17 +11,18 @@ export const recordSchema = z.object({
state: z.enum(["draft", "initializing_upload", "uploading", "finalizing", "importing", "awaiting_submission", "submitting", "queued", "running", "validating", "ready", "failed", "unknown_submission", "unknown_upload"]),
createdAt: z.string().datetime(), updatedAt: z.string().datetime(), lastPollAt: z.number().optional(),
identity: z.object({ tenantId: z.string().min(1), platform: z.string().min(1) }).strict().optional(),
continuations: z.array(z.object({ fromSessionId: z.string().regex(/^ses_[\w-]+$/), toSessionId: z.string().regex(/^ses_[\w-]+$/), revision: z.number().int().nonnegative(), at: z.string().datetime() }).strict()).max(100).optional(),
upload: z.object({ uploadId: z.string().min(1), objectKey: z.string().min(1), uploadUrl: z.string().url() }).strict().optional(),
assetId: z.string().min(1).optional(), jobId: z.string().min(1).max(200).optional(), artifactUrl: z.string().url().optional(),
error: z.string().max(500).optional(), validation: validationSchema.optional(),
}).strict();
export type ImageVideoRecord = z.infer<typeof recordSchema>;
export type ImageVideoPublicTask = { id: string; title: string; prompt: string; durationSeconds: number; state: string; createdAt: string; updatedAt: string; referenceUrl: string; videoUrl?: string; jobId?: string; error?: string; validation?: ImageVideoValidation };
export type ImageVideoPublicTask = { id: string; revision: number; title: string; prompt: string; durationSeconds: number; state: string; createdAt: string; updatedAt: string; referenceUrl: string; videoUrl?: string; jobId?: string; error?: string; validation?: ImageVideoValidation };
export interface ImageVideoRemote {
preflight(): Promise<{ tenantId: string; platform: string }>;
initialize(record: ImageVideoRecord, bytes: Buffer): Promise<{ uploadId: string; objectKey: string; uploadUrl: string }>;
initialize(record: ImageVideoRecord, bytes: Buffer): Promise<{ uploadId: string; objectKey: string; uploadUrl: string } | { reused: true; assetId: string }>;
put(record: ImageVideoRecord, bytes: Buffer): Promise<void>;
finalize(record: ImageVideoRecord): Promise<void>;
finalize(record: ImageVideoRecord, bytes: Buffer): Promise<void>;
importStatus(record: ImageVideoRecord): Promise<{ state: "pending" | "ready" | "failed"; assetId?: string }>;
submit(record: ImageVideoRecord): Promise<{ jobId: string }>;
status(record: ImageVideoRecord): Promise<{ state: "queued" | "running" | "succeeded" | "failed"; artifactUrl?: string }>;
+64 -15
View File
@@ -1,4 +1,7 @@
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { createHash, randomUUID } from "node:crypto";
import { lstat, open, rename } from "node:fs/promises";
import { join } from "node:path";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
import { z } from "zod";
@@ -7,6 +10,7 @@ import type { TSchema } from "typebox";
import { HarnessError } from "../errors/harness-error.js";
import { SWADS_MCP_URL } from "../tools/swads-mcp.js";
import type { ImageVideoRecord, ImageVideoRemote } from "./contracts.js";
import { taskIdSchema } from "./contracts.js";
import { imageVideoTransfer, validateImageVideoUrl } from "./media.js";
const names = ["swads_whoami", "creative_upload_from_url", "creative_import_status", "video_h3_submit", "video_job_status"] as const;
@@ -21,15 +25,25 @@ export function imageVideoResponse(value: unknown): Record<string, unknown> {
if (envelope.isError) throw new Error("Remote error");
const texts = envelope.content?.filter(part => part.type === "text");
const result = envelope.structuredContent ?? (texts?.length === 1 && texts[0]?.text ? JSON.parse(texts[0].text) as unknown : undefined);
return z.record(z.string(), z.unknown()).parse(result);
} catch { throw new HarnessError("I2V_RESPONSE_UNSUPPORTED", "SW Ads response contract is unclear; retain the task and inspect its receipt"); }
const body = z.record(z.string(), z.unknown()).parse(result);
if (body.error_code !== undefined) {
// Provider errors are sometimes returned as successful MCP text envelopes.
// Never forward arbitrary error details (which can contain credentials).
const code = body.error_code === "invalid_arguments" ? "invalid_arguments" : "remote_error";
throw new HarnessError("I2V_REMOTE_REJECTED", `SW Ads rejected the request (${code})`);
}
return body;
} catch (error) {
if (error instanceof HarnessError) throw error;
throw new HarnessError("I2V_RESPONSE_UNSUPPORTED", "SW Ads response contract is unclear; retain the task and inspect its receipt");
}
}
/** Dedicated capability set. The analytics client's allowlist is never broadened. */
export class ImageVideoMcpRemote implements ImageVideoRemote {
#connection: Promise<Client> | undefined;
#schemas = new Map<string, Record<string, unknown>>();
constructor(private readonly options: { token: string }) {}
constructor(private readonly options: { token: string; receiptRoot?: string }) {}
private async connect() {
if (!this.options.token.trim()) throw new HarnessError("SWADS_NOT_CONFIGURED", "SWADS_MCP_TOKEN is not configured on the server");
this.#connection ??= (async () => {
@@ -53,30 +67,57 @@ export class ImageVideoMcpRemote implements ImageVideoRemote {
if (!identity.success) throw new HarnessError("I2V_IDENTITY_AMBIGUOUS", "Current MCP tenant/platform is not unambiguous; upload blocked");
return { tenantId: identity.data.access.current_account.tenant_id, platform: identity.data.access.current_account.platform };
}
private async call(name: typeof names[number], args: Record<string, unknown>): Promise<Record<string, unknown>> {
private async call(name: typeof names[number], args: Record<string, unknown>, receipt?: { record: ImageVideoRecord; operation: "initialize" | "finalize" | "submit" }): Promise<Record<string, unknown>> {
if (!names.includes(name)) throw new HarnessError("I2V_TOOL_DENIED", "Tool unavailable");
const schema = this.#schemas.get(name);
// Status collection after restart discovers the same live capability set.
if (!schema) { await this.preflight(); return this.call(name, args); }
if (!schema) { await this.preflight(); return this.call(name, args, receipt); }
assertImageVideoArguments(schema, args);
try {
const result = await (await this.connect()).callTool({ name, arguments: args }, undefined, { timeout: 60_000 });
if (receipt && this.options.receiptRoot) {
// Retain the exact upstream response BEFORE interpreting it. Only this
// process owner's private task directory receives signed URLs/details.
const r = receipt.record;
const directory = join(this.options.receiptRoot, taskIdSchema.parse(r.id));
const info = await lstat(directory);
if (!info.isDirectory() || info.isSymbolicLink()) throw new Error("Unsafe receipt directory");
const path = join(directory, `remote-${receipt.operation}-${randomUUID()}.json`);
const temp = `${path}.tmp`;
const file = await open(temp, "wx", 0o600);
try { await file.writeFile(JSON.stringify({ id: r.id, revision: r.revision, requestHash: r.requestHash, operation: receipt.operation, receivedAt: new Date().toISOString(), response: result })); await file.sync(); }
finally { await file.close(); }
await rename(temp, path);
}
return imageVideoResponse(result);
} catch { throw new HarnessError("I2V_REMOTE_FAILED", "SW Ads request failed or its response is unsupported; inspect saved task state"); }
} catch (error) {
if (error instanceof HarnessError) throw error;
throw new HarnessError("I2V_REMOTE_FAILED", "SW Ads request failed or its response is unsupported; inspect saved task state");
}
}
private uploadArgs(r: ImageVideoRecord) {
if (!r.identity) throw new HarnessError("I2V_IDENTITY_AMBIGUOUS", "Missing approved upload identity");
return { tenant_id: r.identity.tenantId, platforms: [r.identity.platform], platform: r.identity.platform, media_kind: "image", review_status: "review_pending", asset_role: "reference" };
return { tenant_id: r.identity.tenantId, platforms: [r.identity.platform], platform: r.identity.platform, media_kind: "image", review_status: "review_pending", asset_role: "reference", ratio: "9:16", prompt: r.draft.prompt };
}
async initialize(r: ImageVideoRecord, bytes: Buffer) {
const ext = r.referenceType.split("/")[1];
const receipt = z.object({ upload_id: nonempty, object_key: nonempty, upload_url: z.string().url() }).parse(await this.call("creative_upload_from_url", { ...this.uploadArgs(r), file_name: `${r.id}.${ext}`, content_type: r.referenceType, size_bytes: bytes.length, content_sha256: r.referenceHash }));
const body = await this.call("creative_upload_from_url", this.uploadMetadata(r, bytes), { record: r, operation: "initialize" });
if (body.reused === true) {
// Deduplicated uploads have no PUT URL. Accept only an existing asset
// cryptographically bound to the approved local image, never a URL guess.
const file = z.object({ creative_factory_asset_id: nonempty, mime_type: z.literal(r.referenceType), size_bytes: z.literal(bytes.length), attachment_kind: z.literal("image"), asset_metadata: z.object({ content_sha256: z.literal(r.referenceHash) }) }).safeParse(body.reused_file);
if (!file.success) throw new HarnessError("I2V_RESPONSE_UNSUPPORTED", "Reused reference does not match the frozen image");
return { reused: true as const, assetId: file.data.creative_factory_asset_id };
}
const receipt = z.object({ upload_id: nonempty, object_key: nonempty, upload_url: z.string().url() }).parse(body);
return { uploadId: receipt.upload_id, objectKey: receipt.object_key, uploadUrl: receipt.upload_url };
}
async put(r: ImageVideoRecord, bytes: Buffer) { if (!r.upload) throw new Error("Missing receipt"); await imageVideoTransfer(r.upload.uploadUrl, { bytes, contentType: r.referenceType }); }
async finalize(r: ImageVideoRecord) {
private uploadMetadata(r: ImageVideoRecord, bytes: Buffer) {
return { ...this.uploadArgs(r), file_name: `${r.id}.${r.referenceType.split("/")[1]}`, content_type: r.referenceType, size_bytes: bytes.length, content_sha256: r.referenceHash };
}
async finalize(r: ImageVideoRecord, bytes: Buffer) {
if (!r.upload) throw new Error("Missing receipt");
const result = await this.call("creative_upload_from_url", { ...this.uploadArgs(r), upload_id: r.upload.uploadId, object_key: r.upload.objectKey });
const result = await this.call("creative_upload_from_url", { ...this.uploadMetadata(r, bytes), upload_id: r.upload.uploadId, object_key: r.upload.objectKey }, { record: r, operation: "finalize" });
if (result.status !== "finalizing") throw new HarnessError("I2V_RESPONSE_UNSUPPORTED", "Finalization receipt is unclear; poll the saved upload ID");
}
async importStatus(r: ImageVideoRecord) {
@@ -89,7 +130,11 @@ export class ImageVideoMcpRemote implements ImageVideoRemote {
}
async submit(r: ImageVideoRecord) {
if (!r.assetId) throw new Error("Missing reference asset");
const result = z.object({ job_id: nonempty }).parse(await this.call("video_h3_submit", { prompt: r.draft.prompt, title: r.draft.title, reference_asset_ids: [r.assetId], aspect_ratio: "9:16", duration_seconds: r.draft.durationSeconds, resolution: "768p", h3_mode: "ref2va", idempotency_key: `${r.id}/${r.requestHash}`, skill_id: "swads-image-to-video" }));
// The gateway adds a caller namespace within a 128-character limit.
// Keep the full collision-resistant task/request binding in just 68 chars.
// Unknown submissions remain blocked by the service; never re-key a retry.
const key = `i2v:${createHash("sha256").update(`${r.id}/${r.requestHash}`).digest("hex")}`;
const result = z.object({ job_id: nonempty }).parse(await this.call("video_h3_submit", { prompt: r.draft.prompt, title: r.draft.title, reference_asset_ids: [r.assetId], aspect_ratio: "9:16", duration_seconds: r.draft.durationSeconds, resolution: "768p", h3_mode: "ref2va", idempotency_key: key, skill_id: "swads-image-to-video" }, { record: r, operation: "submit" }));
return { jobId: result.job_id };
}
async status(r: ImageVideoRecord) {
@@ -100,9 +145,13 @@ export class ImageVideoMcpRemote implements ImageVideoRemote {
if (["running", "processing"].includes(String(value.status))) return { state: "running" as const };
if (["failed", "rejected", "cancelled"].includes(String(value.status))) return { state: "failed" as const };
if (!["succeeded", "completed", "success"].includes(String(value.status))) throw new HarnessError("I2V_RESPONSE_UNSUPPORTED", "Unknown generation state");
const artifacts = z.array(z.object({ content_type: z.string(), url: z.string().url() })).parse(value.artifacts).filter(artifact => artifact.content_type === "video/mp4");
const artifacts = z.array(z.object({ content_type: z.string(), job_id: nonempty.optional(), storage_url: z.string().url().nullish(), url: z.string().url().nullish() })).parse(value.artifacts).filter(artifact => artifact.content_type === "video/mp4");
if (artifacts.length !== 1 || !artifacts[0]) throw new HarnessError("I2V_RESPONSE_UNSUPPORTED", "Expected one registered MP4 artifact");
await validateImageVideoUrl(artifacts[0].url);
return { state: "succeeded" as const, artifactUrl: artifacts[0].url };
const artifact = artifacts[0];
if ((artifact.job_id !== undefined && artifact.job_id !== r.jobId) || (artifact.storage_url && artifact.url && artifact.storage_url !== artifact.url)) throw new HarnessError("I2V_RESPONSE_UNSUPPORTED", "Artifact receipt conflicts with registered job or URL");
const artifactUrl = artifact.storage_url ?? artifact.url;
if (!artifactUrl) throw new HarnessError("I2V_RESPONSE_UNSUPPORTED", "Registered MP4 has no storage URL");
await validateImageVideoUrl(artifactUrl);
return { state: "succeeded" as const, artifactUrl };
}
}
+26 -1
View File
@@ -1,6 +1,7 @@
import { request } from "node:https";
import { lookup } from "node:dns/promises";
import { isIP } from "node:net";
import { randomUUID } from "node:crypto";
import { readFile, writeFile, rename } from "node:fs/promises";
import { join } from "node:path";
import { z } from "zod";
@@ -79,13 +80,37 @@ export async function verifyImageVideo(path: string, directory: string, duration
return parseImageVideoValidation(result, duration);
}
/** H3 may return a few extra frames. Trim only a small overrun, never stretch,
* loop, pad missing time or overwrite the provider's original MP4. */
export async function conformImageVideoDuration(source: string, directory: string, duration: number): Promise<string> {
const { ffmpeg, ffprobe } = await mediaRuntime();
const signal = new AbortController().signal;
const probe = z.object({ streams: z.array(z.object({ codec_type: z.string(), duration: z.string().optional(), start_time: z.string().optional(), avg_frame_rate: z.string().optional(), r_frame_rate: z.string().optional() })) }).parse(JSON.parse(await runMedia(ffprobe, ["-v", "error", "-protocol_whitelist", "file,pipe", "-format_whitelist", "mov", "-show_streams", "-of", "json", source], directory, signal)));
const video = probe.streams.filter(s => s.codec_type === "video");
const audio = probe.streams.filter(s => s.codec_type === "audio");
const vd = Number(video[0]?.duration); const ad = Number(audio[0]?.duration);
const [n, d = "1"] = (video[0]?.avg_frame_rate ?? "0").split("/"); const fps = Number(n) / Number(d);
// H3's verified worker rounds 24fps output up to 17k+5 frames:
// 2s -> 56 frames (2.333s), 10s -> 243 frames (10.125s).
// Extend the small-overrun allowance ONLY for that exact native grid result.
const nativeSeconds = (5 + 17 * Math.ceil((Math.max(5, Math.round(duration * 24)) - 5) / 17)) / 24;
const maximum = fps === 24 && Math.abs(vd - nativeSeconds) <= 0.002 ? Math.max(duration + 0.25, nativeSeconds + 0.002) : duration + 0.25;
if (video.length !== 1 || audio.length !== 1 || !Number.isFinite(duration) || duration <= 0 || !Number.isFinite(vd) || !Number.isFinite(ad) || !Number.isFinite(fps) || fps <= 0 || fps > 120 || video[0]?.avg_frame_rate !== video[0]?.r_frame_rate || Math.abs(Number(video[0]?.start_time)) > 0.002 || Math.abs(Number(audio[0]?.start_time)) > 0.05 || vd < duration - 0.002 || ad < duration - 0.002 || vd > maximum || ad > maximum || Math.abs(duration * fps - Math.round(duration * fps)) > 0.002) throw new HarnessError("I2V_VALIDATION_FAILED", "Output duration cannot be safely conformed without changing speed or adding content");
if (Math.abs(vd - duration) <= 0.002 && Math.abs(ad - duration) <= 0.05) return source;
const output = join(directory, `conformed-${randomUUID()}.mp4`);
await runMedia(ffmpeg, ["-nostdin", "-v", "error", "-protocol_whitelist", "file,pipe", "-format_whitelist", "mov", "-i", source, "-map", "0:v:0", "-map", "0:a:0", "-vf", `trim=duration=${duration},setpts=PTS-STARTPTS`, "-af", `atrim=duration=${duration},asetpts=PTS-STARTPTS`, "-c:v", "libx264", "-preset", "fast", "-crf", "18", "-c:a", "aac", "-b:a", "192k", "-movflags", "+faststart", "-n", output], directory, signal);
return output;
}
export function createImageVideoCollector(options: { skillRoot: string; python: string }) {
return async (url: string, directory: string, duration: number) => {
const bytes = await imageVideoTransfer(url);
if (bytes.length < 16 || bytes.subarray(4, 8).toString("ascii") !== "ftyp") throw new HarnessError("I2V_INVALID_MP4", "Artifact is not an MP4 file");
const path = join(directory, "candidate.mp4"); await localMediaStorage(writeFile(path, bytes, { mode: 0o600 }));
const source = join(directory, "candidate.mp4"); await localMediaStorage(writeFile(source, bytes, { mode: 0o600 }));
const path = await conformImageVideoDuration(source, directory, duration);
const validation = await verifyImageVideo(path, directory, duration, options);
if (validation.sha256 !== sha256(await localMediaStorage(readFile(path)))) throw new HarnessError("I2V_INVALID_MP4", "Validated media changed");
await localMediaStorage(writeFile(join(directory, "artifact-provenance.json"), JSON.stringify({ sourceSha256: sha256(bytes), outputSha256: validation.sha256, durationSeconds: duration, operation: source === path ? "unchanged" : "trim-small-tail-overrun-original-speed", checkedAt: new Date().toISOString() }), { mode: 0o600 }));
await localMediaStorage(rename(path, join(directory, "video.mp4")));
return validation;
};
+37 -5
View File
@@ -9,6 +9,10 @@ const locks = new Map<string, Promise<unknown>>();
const submissionReceiptSchema = recordSchema.pick({ version: true, id: true, revision: true, requestHash: true, jobId: true }).required().strict();
export const sha256 = (bytes: Buffer | string) => createHash("sha256").update(bytes).digest("hex");
const fail = (code: string, message: string): never => { throw new HarnessError(code, message); };
function safeRemoteCode(error: unknown): string {
const codes = ["I2V_REMOTE_REJECTED", "I2V_RESPONSE_UNSUPPORTED", "I2V_REMOTE_FAILED", "I2V_SCHEMA_CHANGED", "I2V_TRANSFER_TRANSIENT"];
return error instanceof HarnessError && codes.includes(error.code) ? error.code : "I2V_OPERATION_FAILED";
}
export type ImageVideoOptions = { root: string; attachments: Pick<AttachmentStore, "get" | "openNormalized">; remote: ImageVideoRemote; collect: (url: string, directory: string, duration: number) => Promise<ImageVideoValidation> };
/** One process owns this private store. Per-task locks are shared by service instances. */
@@ -37,6 +41,25 @@ export class ImageVideoService {
async view(id: string): Promise<ImageVideoPublicTask> { return this.lock(id, async () => this.public(await this.read(id))); }
async owned(sessionId: string, id: string): Promise<ImageVideoPublicTask> { return this.lock(id, async () => { const r = await this.read(id); this.owner(r, sessionId); return this.public(r); }); }
/** Local-page confirmation only. Not exposed to the model as a tool. */
async continueTask(sessionId: string, id: string, expectedRevision: number, ownerIsActive: (sessionId: string) => boolean, requireTargetSession: () => void): Promise<ImageVideoPublicTask> {
return this.lock(id, async () => {
const r = await this.read(id);
requireTargetSession();
if (r.revision !== expectedRevision) return fail("I2V_STALE_REVISION", "任务已更新,请重新核对后接续。");
if (!["draft", "awaiting_submission"].includes(r.state) || r.jobId || (r.state === "awaiting_submission" && !r.assetId)) return fail("I2V_CONTINUE_BLOCKED", "当前状态不允许接续;不得重置未知结果或重新生成已有任务。");
if (r.ownerSessionId === sessionId) return this.public(r);
if (ownerIsActive(r.ownerSessionId)) return fail("I2V_OWNER_ACTIVE", "原会话仍有效,请回原页面继续,不能由其他会话接管。");
if ((r.continuations?.length ?? 0) >= 100) return fail("I2V_CONTINUE_BLOCKED", "接续记录已达上限,请人工核实。");
await this.reference(r);
requireTargetSession();
r.continuations = [...(r.continuations ?? []), { fromSessionId: r.ownerSessionId, toSessionId: sessionId, revision: r.revision, at: new Date().toISOString() }];
r.ownerSessionId = sessionId;
await this.save(r);
return this.public(r);
});
}
async upload(sessionId: string, id: string): Promise<ImageVideoPublicTask> {
return this.lock(id, async () => {
const r = await this.read(id); this.owner(r, sessionId);
@@ -47,10 +70,19 @@ export class ImageVideoService {
const bytes = await this.reference(r);
r.state = "initializing_upload"; await this.save(r);
try {
r.upload = await this.options.remote.initialize(r, bytes); r.state = "uploading"; await this.save(r);
const initialized = await this.options.remote.initialize(r, bytes);
if ("reused" in initialized) {
r.assetId = initialized.assetId; r.state = "awaiting_submission"; delete r.error; await this.save(r);
return this.public(r);
}
r.upload = initialized; r.state = "uploading"; await this.save(r);
await this.options.remote.put(r, bytes); r.state = "finalizing"; await this.save(r);
await this.options.remote.finalize(r); r.state = "importing"; await this.save(r);
} catch { r.state = "unknown_upload"; r.error = "参考图上传或入库结果未知;禁止重复导入。请核实本任务收据。"; await this.save(r); }
await this.options.remote.finalize(r, bytes); r.state = "importing"; await this.save(r);
} catch (error) {
const stage = r.state;
const code = safeRemoteCode(error);
r.state = "unknown_upload"; r.error = `参考图上传或入库未完成(${stage}/${code});禁止重复导入,请核实本任务收据。`; await this.save(r);
}
return this.public(r);
});
}
@@ -69,7 +101,7 @@ export class ImageVideoService {
if (!receipt.jobId) throw new Error("Missing job ID");
await this.atomic(join(this.root, id, "submission-receipt.json"), JSON.stringify({ version: 1, id, revision: r.revision, requestHash: r.requestHash, jobId: receipt.jobId }));
r.jobId = receipt.jobId; r.state = "queued"; delete r.lastPollAt; await this.save(r);
} catch { r.state = "unknown_submission"; r.error = "生成提交结果未知;禁止重投。请人工核实,避免重复计费。"; await this.save(r); }
} catch (error) { r.state = "unknown_submission"; r.error = `生成提交结果未知(submitting/${safeRemoteCode(error)});禁止重投。请核实私有回执,避免重复计费。`; await this.save(r); }
return this.public(r);
});
}
@@ -118,7 +150,7 @@ export class ImageVideoService {
}
private owner(r: ImageVideoRecord, sessionId: string) { if (r.ownerSessionId !== sessionId) fail("I2V_TASK_NOT_FOUND", "Task not found in this Session"); }
private public(r: ImageVideoRecord): ImageVideoPublicTask {
return { id: r.id, title: r.draft.title, prompt: r.draft.prompt, durationSeconds: r.draft.durationSeconds, state: r.state, createdAt: r.createdAt, updatedAt: r.updatedAt, referenceUrl: `/api/image-video/tasks/${r.id}/reference`, ...(r.jobId ? { jobId: r.jobId } : {}), ...(r.error ? { error: r.error } : {}), ...(r.validation ? { validation: r.validation } : {}), ...(r.state === "ready" ? { videoUrl: `/api/image-video/tasks/${r.id}/video` } : {}) };
return { id: r.id, revision: r.revision, title: r.draft.title, prompt: r.draft.prompt, durationSeconds: r.draft.durationSeconds, state: r.state, createdAt: r.createdAt, updatedAt: r.updatedAt, referenceUrl: `/api/image-video/tasks/${r.id}/reference`, ...(r.jobId ? { jobId: r.jobId } : {}), ...(r.error ? { error: r.error } : {}), ...(r.validation ? { validation: r.validation } : {}), ...(r.state === "ready" ? { videoUrl: `/api/image-video/tasks/${r.id}/video` } : {}) };
}
private async reference(r: ImageVideoRecord) { await this.regular(join(this.root, r.id, "reference")); const bytes = await readFile(join(this.root, r.id, "reference")); if (sha256(bytes) !== r.referenceHash) fail("I2V_MEDIA_INVALID", "Reference changed after draft"); return bytes; }
private async regular(path: string) { const info = await lstat(path); if (!info.isFile() || info.isSymbolicLink()) fail("I2V_RECORD_INVALID", "Unsafe registered file"); }
+12
View File
@@ -23,4 +23,16 @@ export * from "./testing/fake-agent-session.js";
export * from "./tools/mock-ads-metrics.js";
export * from "./tools/save-report-draft.js";
export * from "./tools/tool-policy.js";
export * from "./tools/swads-mcp.js";
export * from "./tools/minis-report.js";
export * from "./workspace/safe-workspace.js";
export * from "./cms/cms-connection.js";
export * from "./cms/editor-tools.js";
export * from "./media/editor-service.js";
export * from "./media/editor-tools.js";
export * from "./media/skill-runner.js";
export * from "./image-video/contracts.js";
export * from "./image-video/service.js";
export * from "./image-video/media.js";
export * from "./image-video/mcp.js";
export * from "./image-video/tools.js";
@@ -0,0 +1,30 @@
import {z} from "zod";
import {HarnessError} from "../errors/harness-error.js";
export type AnalysisEvidence={analysisId:string;dramaId:string;fetchedAt:string;title:string;synopsis:string|null;tags:string[];episodeCount:number;
sources:{episodeId:string;episodeNo:number;duration:number;subtitle:string;frames:{id:string;time:number}[]}[]};
const Shape=z.object({analysis_id:z.string(),drama:z.object({drama_id:z.string(),title:z.string(),synopsis:z.string(),tags:z.array(z.string()),episode_count:z.number()}),cms_source:z.object({fetched_at:z.string()}),evidence:z.object({
frame_evidence:z.array(z.object({episode:z.number(),episode_id:z.string(),frame_id:z.string(),time:z.number().finite(),claim:z.string().min(1)})).min(2),
subtitle_evidence:z.array(z.object({episode:z.number(),episode_id:z.string(),story_position:z.enum(["opening","middle","late"]),quote:z.string().trim().min(3)})).min(3),
})});
export function assertAnalysisBinding(value:unknown,expected:AnalysisEvidence):void {
const fail=():never=>{throw new HarnessError("ANALYSIS_EVIDENCE_MISMATCH","分析必须绑定当前任务的真实 CMS、字幕和帧证据,不能自行修改身份或补造证据");};
const parsed=Shape.safeParse(value);if(!parsed.success)fail();
const actual=parsed.data!;
if(actual.analysis_id!==expected.analysisId||actual.drama.drama_id!==expected.dramaId||actual.cms_source.fetched_at!==expected.fetchedAt||actual.drama.title!==expected.title||actual.drama.synopsis!==expected.synopsis||actual.drama.episode_count!==expected.episodeCount||JSON.stringify([...actual.drama.tags].sort())!==JSON.stringify([...expected.tags].sort()))fail();
const seen=new Set<string>();
for(const frame of actual.evidence.frame_evidence){
const source=expected.sources.find(s=>s.episodeId===frame.episode_id&&s.episodeNo===frame.episode);
const original=source?.frames.find(f=>f.id===frame.frame_id);
if(!original||Math.abs(original.time-frame.time)>0.001||seen.has(frame.frame_id))fail();
seen.add(frame.frame_id);
}
const positions={opening:1,middle:Math.floor(expected.episodeCount/2)+1,late:expected.episodeCount};
const covered=new Set<string>();
for(const subtitle of actual.evidence.subtitle_evidence){
const source=expected.sources.find(s=>s.episodeId===subtitle.episode_id&&s.episodeNo===subtitle.episode);
if(!source||positions[subtitle.story_position]!==source.episodeNo||!source.subtitle.includes(subtitle.quote))fail();
covered.add(subtitle.story_position);
}
if(covered.size!==3)fail();
}
@@ -0,0 +1,125 @@
import { randomUUID, createHash } from "node:crypto";
import {createReadStream} from "node:fs";
import { mkdir, mkdtemp, readFile, writeFile, rename, stat } from "node:fs/promises";
import { join } from "node:path";
import { z } from "zod";
import type { CmsConnection } from "../cms/cms-connection.js";
import { HarnessError } from "../errors/harness-error.js";
import { downloadHls, mediaRuntime, prepareLocalMedia } from "./media-worker.js";
import { SafeWorkspace } from "../workspace/safe-workspace.js";
import {assertAnalysisBinding} from "./analysis-binding.js";
import type {SkillRunner} from "./skill-runner.js";
import {EditorWorkflow} from "./editor-workflow.js";
type Frame={id:string;time:number;path:string};
type Source={episodeId:string;episodeNo:number;source:string;sha256:string;duration:number;frames:Frame[];subtitle:string};
type Job={id:string;analysisId:string;dramaId:string;title:string;drama:Awaited<ReturnType<CmsConnection["inventory"]>>["drama"];fetchedAt:string;root:string;state:"preparing"|"sources_ready"|"failed"|"cancelled";error?:string;sources:Source[];evidenceShared:boolean;analysisValidated:boolean;validationAttempts:number};
const Input=z.object({dramaId:z.string().uuid(),episodeNumbers:z.array(z.number().int().positive()).min(1).max(5)}).strict();
let mediaBusy=false;
export class EditorService {
readonly #jobs=new Map<string,Job>();
readonly #workflows=new Map<string,EditorWorkflow>();
public constructor(private readonly options:{cms:CmsConnection;runtimeRoot:string;sessionId:string;runner?:SkillRunner}){}
public status(jobId:string) {
const job=this.#get(jobId);
return {jobId:job.id,analysisId:job.analysisId,dramaId:job.dramaId,title:job.title,fetchedAt:job.fetchedAt,state:job.state,error:job.error,
sources:job.sources.map(source=>({episodeId:source.episodeId,episodeNo:source.episodeNo,duration:source.duration,sha256:source.sha256,
frames:source.frames.map(frame=>({frameId:frame.id,time:frame.time,url:`/api/sessions/${this.options.sessionId}/editor/${job.id}/frames/${frame.id}`}))})),
analysisValidated:job.analysisValidated,workflowUrl:job.analysisValidated?`/api/sessions/${this.options.sessionId}/editor/${job.id}/workflow`:undefined,renderingReady:this.#workflows.get(job.id)?.view().plan?.reviewed===true,humanReviewPassed:false};
}
public async prepare(input:z.infer<typeof Input>,signal:AbortSignal) {
Input.parse(input);
if(new Set(input.episodeNumbers).size!==input.episodeNumbers.length) throw new HarnessError("MEDIA_DUPLICATE_EPISODE","不能重复选择同一分集");
if(!this.options.cms.status().connected) throw new HarnessError("CMS_LOGIN_REQUIRED","请先登录 CMS");
if(mediaBusy) throw new HarnessError("MEDIA_BUSY","已有重型媒体任务在运行");
if(this.#jobs.size>=3) throw new HarnessError("MEDIA_JOB_LIMIT","本会话最多准备三次素材;请先检查已有任务");
mediaBusy=true;let job:Job|undefined;
try {
signal.throwIfAborted();await mediaRuntime();
const inventory=await this.options.cms.inventory(input.dramaId);
if(!inventory.complete || inventory.drama.language!=="en") throw new HarnessError("MEDIA_INVENTORY_INCOMPLETE","需要完整的英文剧目录和片源记录");
const episodes=input.episodeNumbers.map(number=>inventory.episodes.find(episode=>episode.episodeNo===number));
if(episodes.some(episode=>!episode || !episode.subtitleLocales.includes("en"))) throw new HarnessError("MEDIA_EPISODE_INVALID","所选分集不存在或缺少英文字幕");
await mkdir(this.options.runtimeRoot,{recursive:true,mode:0o700});
const root=await mkdtemp(join(this.options.runtimeRoot,"job-"));
job={id:`job_${randomUUID()}`,analysisId:`analysis_${randomUUID()}`,dramaId:input.dramaId,title:inventory.drama.title,drama:inventory.drama,fetchedAt:inventory.fetchedAt,root,state:"preparing",sources:[],evidenceShared:false,analysisValidated:false,validationAttempts:0};
this.#jobs.set(job.id,job);await this.#persist(job);
const budget={remaining:1024*1024*1024};
for(const episode of episodes) {
signal.throwIfAborted();if(!episode)throw new Error();
const episodeRoot=join(root,`episode-${episode.episodeNo.toString()}`);await mkdir(episodeRoot,{mode:0o700});
const signed=await this.options.cms.playback(input.dramaId,episode.id);
await downloadHls(new URL(signed.url),episodeRoot,signal,budget);
const media=await prepareLocalMedia(episodeRoot,signal);
const subtitle=await this.options.cms.subtitle(input.dramaId,episode.id,"en");
await writeFile(join(episodeRoot,"subtitles.vtt"),subtitle.text,{mode:0o600,flag:"wx"});
// MP4 is bounded by the task download quota; no signed source URL is retained.
const hash=createHash("sha256");for await(const chunk of createReadStream(media.source)){signal.throwIfAborted();hash.update(chunk as Buffer);}const sha256=hash.digest("hex");
job.sources.push({episodeId:episode.id,episodeNo:episode.episodeNo,source:media.source,sha256,duration:media.duration,subtitle:subtitle.text,
frames:media.frames.map(frame=>({id:`frame_${randomUUID()}`,time:frame.time,path:frame.path}))});
await this.#persist(job);
}
signal.throwIfAborted();job.state="sources_ready";await this.#persist(job);return this.status(job.id);
} catch(error) {
if(job){job.state=signal.aborted?"cancelled":"failed";job.error=error instanceof HarnessError?error.code:"MEDIA_PREPARATION_FAILED";await this.#persist(job);}
if(error instanceof HarnessError)throw error;
throw new HarnessError(signal.aborted?"MEDIA_CANCELLED":"MEDIA_PREPARATION_FAILED","真实素材准备失败;未使用模拟素材替代");
} finally {mediaBusy=false;}
}
public async evidence(jobId:string) {
const job=this.#get(jobId);
if(job.state!=="sources_ready")throw new HarnessError("MEDIA_NOT_READY","素材尚未准备完成");
const metadata={...this.status(jobId),drama:job.drama,subtitles:job.sources.map(source=>({episodeId:source.episodeId,episodeNo:source.episodeNo,text:source.subtitle})),
instruction:"Images follow in sources/frame order. For submit_drama_analysis, use original v2 schema plus evidence.frame_evidence[].episode_id, frame_id, time and evidence.subtitle_evidence[].episode_id, quote (exact substring of actual VTT). Preserve CMS synopsis, tags, episode_count and server analysisId/fetchedAt exactly. These samples are NOT full-clip viewing or content clearance. Never claim analysis PASS before submit_drama_analysis returns pass:true."};
if(JSON.stringify(metadata).length>100_000) throw new HarnessError("MEDIA_EVIDENCE_TOO_LARGE","字幕超过单次模型证据范围,需分批处理");
const images=await Promise.all(job.sources.flatMap(source=>source.frames).map(async frame=>({type:"image" as const,mimeType:"image/jpeg",data:(await this.readFrame(jobId,frame.id)).toString("base64")})));
job.evidenceShared=true;
return {content:[{type:"text" as const,text:JSON.stringify(metadata)},...images],details:{...this.status(jobId),imageCount:images.length,subtitleCount:job.sources.length}};
}
public async submitAnalysis(jobId:string,value:unknown,signal:AbortSignal){
const job=this.#get(jobId);
if(job.state!=="sources_ready"||!job.evidenceShared)throw new HarnessError("ANALYSIS_EVIDENCE_REQUIRED","请先完成授权后的真实字幕与帧读取");
if(job.analysisValidated)throw new HarnessError("ANALYSIS_IMMUTABLE","已通过的分析不能覆盖,请使用新的素材任务");
if(!this.options.runner)throw new HarnessError("EDITOR_VALIDATOR_UNAVAILABLE","校验运行时未配置");
if(job.validationAttempts>=10)throw new HarnessError("ANALYSIS_ATTEMPT_LIMIT","本任务分析提交次数达到限制");
const json=JSON.stringify(value);if(json.length>128*1024)throw new HarnessError("ANALYSIS_TOO_LARGE","分析文件超过限制");
assertAnalysisBinding(value,{analysisId:job.analysisId,dramaId:job.dramaId,fetchedAt:job.fetchedAt,title:job.title,synopsis:job.drama.synopsis,tags:job.drama.tags,episodeCount:job.drama.total_episodes,sources:job.sources});
job.validationAttempts++;
const attempt=await mkdtemp(join(job.root,"analysis-"));const input=join(attempt,"target-drama-analysis.json");
await writeFile(input,json,{mode:0o600,flag:"wx"});
const result=await this.options.runner.validate("analysis",input,attempt,signal);
if(result.pass){await writeFile(join(job.root,"target-drama-analysis.json"),json,{mode:0o600,flag:"wx"});job.analysisValidated=true;this.#workflows.set(job.id,new EditorWorkflow({root:job.root,jobId:job.id,sessionId:this.options.sessionId,analysisId:job.analysisId,dramaId:job.dramaId,sources:job.sources,runner:this.options.runner}));}
await writeFile(join(attempt,"target-drama-analysis-validation.json"),JSON.stringify(result),{mode:0o600,flag:"wx"});
await this.#persist(job);return {...result,jobId,analysisId:job.analysisId,dramaId:job.dramaId,workflowUrl:this.status(jobId).workflowUrl,renderingReady:false,contentReview:"required"};
}
public workflowView(jobId:string){return this.#workflow(jobId).view();}
public async proposeSelection(jobId:string,value:unknown){await this.#workflow(jobId).proposeSelection(value);return this.workflowSummary(jobId);}
public async submitPlan(jobId:string,value:unknown,signal:AbortSignal){const result=await this.#workflow(jobId).submitPlan(value,signal);return {...result,...this.workflowSummary(jobId),plan:undefined,selection:undefined};}
public async renderPreview(jobId:string,signal:AbortSignal){if(mediaBusy)throw new HarnessError("MEDIA_BUSY","已有重型媒体任务在运行");mediaBusy=true;try{await this.#workflow(jobId).render(signal);return this.workflowSummary(jobId);}finally{mediaBusy=false;}}
public async review(jobId:string,stage:"selection"|"plan",value:unknown){return this.#workflow(jobId).review(stage,value);}
public workflowSummary(jobId:string){const view=this.workflowView(jobId);return {jobId,workflowUrl:view.workflowUrl,selectionReviewed:view.selection?.reviewed??false,planReviewed:view.plan?.reviewed??false,preview:view.preview,humanReviewPassed:false};}
public async video(jobId:string,kind:"sources"|"plans",id:string){
const job=this.#get(jobId);const path=kind==="sources"?job.sources.find(source=>source.episodeId===id)?.source:this.#workflow(jobId).previewPath(id);
if(!path)throw new HarnessError("EDITOR_SOURCE_NOT_FOUND","该会话没有此媒体文件");
const workspace=await SafeWorkspace.create(job.root);const resolved=await workspace.resolveReadable(path);const info=await stat(resolved);
if(!info.isFile())throw new HarnessError("EDITOR_SOURCE_NOT_FOUND","媒体文件不存在");
return {path:resolved,size:info.size};
}
public async readFrame(jobId:string,frameId:string) {
const job=this.#get(jobId);const frame=job.sources.flatMap(source=>source.frames).find(item=>item.id===frameId);
if(!frame)throw new HarnessError("FRAME_NOT_FOUND","该会话中不存在此图片");
const workspace=await SafeWorkspace.create(job.root);
const bytes=await readFile(await workspace.resolveReadable(frame.path));
if(bytes.length>512*1024)throw new HarnessError("FRAME_TOO_LARGE","图片超过安全范围");
return bytes;
}
#get(id:string):Job {const job=this.#jobs.get(id);if(!job)throw new HarnessError("EDITOR_JOB_NOT_FOUND","该会话没有此媒体任务");return job;}
#workflow(id:string){this.#get(id);const workflow=this.#workflows.get(id);if(!workflow)throw new HarnessError("ANALYSIS_REQUIRED","先完成真实剧情分析并通过原始校验器");return workflow;}
async #persist(job:Job) {
// Atomic metadata; source/frames persist for recovery, but no automatic Session takeover.
const temporary=join(job.root,"job.json.tmp");
await writeFile(temporary,JSON.stringify({...this.status(job.id),ownerSessionId:this.options.sessionId},null,2),{mode:0o600});
await rename(temporary,join(job.root,"job.json"));
}
}
@@ -0,0 +1,21 @@
import {HarnessError} from "../errors/harness-error.js";
const time=(text:string)=>text.split(":").reduce((total,item)=>total*60+Number(item),0);
const stamp=(seconds:number)=>{const ms=Math.round(seconds*1000);return `${String(Math.floor(ms/3600000)).padStart(2,"0")}:${String(Math.floor(ms/60000)%60).padStart(2,"0")}:${String(Math.floor(ms/1000)%60).padStart(2,"0")},${String(ms%1000).padStart(3,"0")}`;};
export function cutSubtitles(spans:{subtitle:string;sourceStart:number;sourceEnd:number;targetStart:number;segmentId:string}[]){
const cues:string[]=[];const warnings=new Set<string>();
for(const span of spans){
if(!span.subtitle.trimStart().startsWith("WEBVTT"))throw new HarnessError("SUBTITLE_INVALID","需要真实英文 WebVTT");
for(const block of span.subtitle.replaceAll("\r","").split(/\n\s*\n/)){
const lines=block.split("\n");const index=lines.findIndex(line=>line.includes(" --> "));if(index<0)continue;
const match=/((?:\d{2}:)?\d{2}:\d{2}\.\d{3})\s+-->\s+((?:\d{2}:)?\d{2}:\d{2}\.\d{3})/.exec(lines[index]??"");
if(!match?.[1]||!match[2])throw new HarnessError("SUBTITLE_INVALID","不支持此字幕时间格式");
const start=time(match[1]);const end=time(match[2]);
if(end<=span.sourceStart||start>=span.sourceEnd)continue;
if(start<span.sourceStart-0.001||end>span.sourceEnd+0.001)warnings.add(span.segmentId);
const text=lines.slice(index+1).join("\n").replaceAll(/<[^>]*>/g,"").replaceAll("&amp;","&").replaceAll("&lt;","<").replaceAll("&gt;",">").replaceAll("&nbsp;"," ").trim();
if(!text)continue;
cues.push(`${String(cues.length+1)}\n${stamp(Math.max(start,span.sourceStart)-span.sourceStart+span.targetStart)} --> ${stamp(Math.min(end,span.sourceEnd)-span.sourceStart+span.targetStart)}\n${text}`);
}
}
return {srt:cues.join("\n\n")+(cues.length?"\n":""),boundaryWarnings:[...warnings]};
}
@@ -0,0 +1,21 @@
import {defineTool} from "@earendil-works/pi-coding-agent";
import {Type} from "typebox";
import type {EditorService} from "./editor-service.js";
import {HarnessError} from "../errors/harness-error.js";
const job=Type.Object({jobId:Type.String({pattern:"^job_[a-zA-Z0-9-]+$"})},{additionalProperties:false});
export function createMediaTools(service:EditorService,supportsImages:()=>boolean) {
return [
defineTool({name:"propose_drama_selection",label:"Review source selection",description:"After real analysis PASS and before strategy/reference research, propose exact candidate spans for human adult-content review in the webpage. Input selection={segments:[{id,episodeId,start,end,reason}]}; times in seconds on 24fps boundaries, at most 10 spans and 60 seconds each. These are candidates, not approved clips. The user must watch with original sound and record findings. No tool can approve the review. Initial output is a 10-second original-audio preview, no new CTA/cover/overlays.",parameters:Type.Object({jobId:Type.String(),selection:Type.Record(Type.String(),Type.Unknown())},{additionalProperties:false}),async execute(_id,args){const details=await service.proposeSelection(args.jobId,args.selection);return {content:[{type:"text" as const,text:JSON.stringify(details)}],details};}}),
defineTool({name:"submit_editor_plan",label:"Validate bound editing plan",description:"Only after human selection review. Read the original Skill references first. bundle requires recipe, hookMap, strategy, referenceCandidates, referenceAnalysis, referenceRecipe, continuity JSON objects. All except referenceCandidates/recipe require target_binding={drama_id,analysis_id} and exact withheld_information. recipe v2 segments add episode_id and must exactly match reviewed segment IDs/source ranges; target duration exactly 10s,24fps. Supply no file paths, subtitle_path or invented PASS flags. Reference candidates need actual full short-drama marketing video plus A/B evidence; webpage does not inherit Codex web search, request actual URLs/evidence when missing. C is not enabled. Original reference/recipe/hook scripts run, then human confirms the exact plan, reference and story. No rendering from this tool.",parameters:Type.Object({jobId:Type.String(),bundle:Type.Record(Type.String(),Type.Unknown())},{additionalProperties:false}),async execute(_id,args,signal){const details=await service.submitPlan(args.jobId,args.bundle,signal??new AbortController().signal);return {content:[{type:"text" as const,text:JSON.stringify(details)}],details};}}),
defineTool({name:"render_editor_preview",label:"Render actual REVIEW MP4",description:"Render a 10-second local REVIEW preview using the original Skill renderer and machine QA. Requires analysis, reference/recipe/hook checks, human source and exact plan reviews, plus this page approval. Original audio at 1.00x. Returns registered video URL, duration, hash and real technical QA, never final content PASS. No publishing or upload.",parameters:job,async execute(_id,args,signal){const details=await service.renderPreview(args.jobId,signal??new AbortController().signal);return {content:[{type:"text" as const,text:JSON.stringify(details)}],details};}}),
defineTool({name:"submit_drama_analysis",label:"Validate actual drama analysis",description:"Submit original Skill v2 analysis JSON after approved read_drama_frames. Required: exact server analysisId as analysis_id, CMS drama_id/title/synopsis/tags/episode_count and fetchedAt as cms_source.fetched_at. Frame evidence adds exact episode_id/frame_id/time; subtitle evidence adds episode_id/quote from actual subtitles. Runs original validator; returns real issue codes. Passing analysis is not adult-content or rendering approval. No caller file paths are used.",parameters:Type.Object({jobId:Type.String(),analysis:Type.Record(Type.String(),Type.Unknown())},{additionalProperties:false}),
async execute(_id,args,signal){const details=await service.submitAnalysis(args.jobId,args.analysis,signal??new AbortController().signal);return {content:[{type:"text" as const,text:JSON.stringify(details)}],details};}}),
defineTool({name:"prepare_drama_sources",label:"Prepare licensed CMS sources",description:"After reading the editor Skill, download only approved exact episodeNumbers of an English CMS drama, maximum 5 episodes / 1 GiB per job. Fixed CMS admin/CDN only. Remux original audio/video locally and extract 3 representative frames per episode; no images or subtitles sent to the model by this tool. Requires page approval and CMS login. Returns a jobId for read_drama_frames; sources_ready is NOT analysis or content clearance. Cannot render yet.",
parameters:Type.Object({dramaId:Type.String({pattern:"^[a-fA-F0-9-]{36}$"}),episodeNumbers:Type.Array(Type.Integer({minimum:1}),{minItems:1,maxItems:5,uniqueItems:true})},{additionalProperties:false}),
async execute(_id,args,signal){const details=await service.prepare(args,signal??new AbortController().signal);return {content:[{type:"text" as const,text:JSON.stringify(details)}],details};}}),
defineTool({name:"editor_job_status",label:"Editor source job",description:"Read this Session's actual media preparation status by jobId. No source URLs or credentials. Does not grant any rendering or review approval.",parameters:job,
execute(_id,args){const details=service.status(args.jobId);return Promise.resolve({content:[{type:"text" as const,text:JSON.stringify(details)}],details});}}),
defineTool({name:"read_drama_frames",label:"Approved frame and subtitle evidence",description:"Requires separate page approval: sends the prepared job's English subtitles, CMS synopsis/categories and 3 representative JPEG frames per episode to the currently configured image-capable model. Cite exact frameId/episodeId/time in observations. Sampled frames cannot prove full-clip continuity, audio review or adult-content PASS. Submit analysis using submit_drama_analysis; no reference research or rendering until it passes.",parameters:job,
async execute(_id,args){if(!supportsImages())throw new HarnessError("MODEL_DOES_NOT_SUPPORT_IMAGES","请为已验证模型启用图片能力");return service.evidence(args.jobId);}}),
];
}
@@ -0,0 +1,135 @@
import {createHash,randomUUID} from "node:crypto";
import {createReadStream} from "node:fs";
import {readFile,writeFile,mkdtemp} from "node:fs/promises";
import {join} from "node:path";
import {z} from "zod";
import {HarnessError} from "../errors/harness-error.js";
import {bindRecipe} from "./recipe-binding.js";
import {assertSelectionReview} from "./review-ledger.js";
import type {SkillRunner} from "./skill-runner.js";
import {cutSubtitles} from "./editor-subtitles.js";
type Source={episodeId:string;episodeNo:number;source:string;duration:number;sha256:string;subtitle:string};
const Span=z.object({id:z.string().regex(/^[\w-]{1,80}$/),episodeId:z.string(),start:z.number().nonnegative(),end:z.number().positive(),reason:z.string().min(10).max(2000)}).strict();
const Selection=z.object({segments:z.array(Span).min(1).max(10)}).strict();
const SourceReview=z.object({planHash:z.string(),reviewer:z.string().trim().min(1).max(100),policyCheckedAt:z.string().datetime({offset:true}),policyPageUpdated:z.string().min(4).max(100),pictureAudioReviewed:z.literal(true),noUnresolvedFindings:z.literal(true),segmentEvidence:z.array(z.object({segmentId:z.string(),pictureEvidence:z.string().trim().min(20).max(2000),audioEvidence:z.string().trim().min(20).max(2000),subtitleEvidence:z.string().trim().min(20).max(2000)}).strict()).min(1).max(10)}).strict();
const Json=z.record(z.string(),z.unknown());
const Bundle=z.object({recipe:Json,hookMap:Json,strategy:Json,referenceCandidates:Json,referenceAnalysis:Json,referenceRecipe:Json,continuity:Json}).strict();
type Selected={id:string;hash:string;root:string;input:z.infer<typeof Selection>;review?:z.infer<typeof SourceReview>};
type Plan={id:string;hash:string;root:string;recipe:ReturnType<typeof bindRecipe>;bundle:z.infer<typeof Bundle>;reference:Record<string,unknown>;subtitles:string;review?:ReturnType<typeof assertSelectionReview>;preview?:Awaited<ReturnType<SkillRunner["render"]>>};
const policyUrl="https://ads.tiktok.com/resources/help/article/tiktok-ads-policy-adult-content";
const hash=(value:unknown)=>createHash("sha256").update(JSON.stringify(value)).digest("hex");
/** Created only after EditorService's real analysis validator passes. Human reviews are API-only. */
export class EditorWorkflow {
#selection:Selected|undefined;
#plan:Plan|undefined;
#attempts=0;
#busy=false;
constructor(private readonly context:{root:string;sessionId:string;jobId:string;analysisId:string;dramaId:string;sources:Source[];runner:SkillRunner}){}
view(){
const base=`/api/sessions/${this.context.sessionId}/editor/${this.context.jobId}`;
const selection=this.#selection;const plan=this.#plan;
return {jobId:this.context.jobId,workflowUrl:`${base}/workflow`,policyUrl,
selection:selection?{id:selection.id,hash:selection.hash,reviewed:!!selection.review,segments:selection.input.segments.map(span=>({...span,episodeNo:this.source(span.episodeId).episodeNo,videoUrl:`${base}/sources/${span.episodeId}`,subtitles:cutSubtitles([{subtitle:this.source(span.episodeId).subtitle,sourceStart:span.start,sourceEnd:span.end,targetStart:0,segmentId:span.id}]).srt}))}:null,
plan:plan?{id:plan.id,hash:plan.hash,reviewed:!!plan.review,reference:plan.reference,artifacts:plan.bundle,subtitles:plan.subtitles,segmentIds:plan.recipe.segments.map(s=>s.id)}:null,
preview:plan?.preview?{url:`${base}/plans/${plan.id}/preview`,duration:plan.preview.duration,sha256:plan.preview.sha256,machinePass:plan.preview.machinePass,checks:plan.preview.checks,events:plan.preview.events,reviewStatus:"REVIEW",pending:["完整播放最终 MP4 并听原声","逐项核对字幕、边界与内容;预览不是投放审批"]}:null,
humanReviewPassed:false};
}
source(id:string){const source=this.context.sources.find(item=>item.episodeId===id);if(!source)throw new HarnessError("EDITOR_SOURCE_NOT_FOUND","当前任务中没有此源文件");return source;}
previewPath(id:string){if(this.#plan?.id!==id||!this.#plan.preview)throw new HarnessError("EDITOR_PREVIEW_NOT_FOUND","当前版本没有预览");return this.#plan.preview.output;}
async proposeSelection(value:unknown){
if(this.#busy)throw new HarnessError("EDITOR_BUSY","任务运行中");
const input=Selection.parse(value);
if(++this.#attempts>10)throw new HarnessError("EDITOR_ATTEMPT_LIMIT","本任务版本数已达限制");
const ids=new Set<string>();
for(const span of input.segments){
const source=this.context.sources.find(s=>s.episodeId===span.episodeId);
if(!source||ids.has(span.id)||span.end<=span.start||span.end>source.duration||span.end-span.start>60||[span.start,span.end].some(t=>Math.abs(t-Math.round(t*24)/24)>0.001))throw new HarnessError("SELECTION_INVALID","选段必须绑定本任务源文件及有效的 24fps 边界");
ids.add(span.id);
}
const root=await mkdtemp(join(this.context.root,"selection-"));
const record={target_binding:this.binding(),input,sources:input.segments.map(span=>({episodeId:span.episodeId,sha256:this.source(span.episodeId).sha256,subtitleHash:hash(this.source(span.episodeId).subtitle)})),packaging:{overlays:"not_present",cover:"not_present",cta:"not_present",adCopy:"not_present",subtitles:"original English captions only"}};
const selection={id:`selection_${randomUUID()}`,hash:hash(record),input,root};
await this.write(root,"selection.json",record);this.#selection=selection;this.#plan=undefined;return this.view();
}
async review(stage:"selection"|"plan",value:unknown){
if(this.#busy)throw new HarnessError("EDITOR_BUSY","任务运行中");
const selected=this.#selection;
if(!selected)throw new HarnessError("SELECTION_REVIEW_REQUIRED","请先提交候选选段");
if(stage==="selection"){
if(selected.review)throw new HarnessError("REVIEW_IMMUTABLE","审核已保存;修改选段需要新版本");
const review=SourceReview.parse(value);const checked=Date.parse(review.policyCheckedAt);
if(review.planHash!==selected.hash||checked>Date.now()+60_000||Date.now()-checked>86400000||review.segmentEvidence.length!==selected.input.segments.length||new Set(review.segmentEvidence.map(e=>e.segmentId)).size!==selected.input.segments.length||review.segmentEvidence.some(e=>!selected.input.segments.some(s=>s.id===e.segmentId)))throw new HarnessError("SELECTION_REVIEW_INVALID","审核必须对应当前版本每一选段和本批次政策核对时间");
await this.write(selected.root,"adult-content-review.json",{policy_source:{url:policyUrl,page_updated:review.policyPageUpdated,checked_at:review.policyCheckedAt,verification_status:"verified"},target_binding:this.binding(),stage:"selection",status:"PASS",reviewer:review.reviewer,reviewed_at:new Date().toISOString(),segments:selected.input.segments.map(span=>({...span,...review.segmentEvidence.find(e=>e.segmentId===span.id),decision:"KEEP"})),surfaces:{picture:"reviewed",original_audio:"reviewed",subtitles:"reviewed",overlays:"not_present",cover:"not_present",CTA:"not_present",ad_copy:"not_present"},findings:[],unresolved_findings:[],excluded_candidates:[],selectionHash:selected.hash});
selected.review=review;
}else{
const plan=this.#plan;if(!plan)throw new HarnessError("PLAN_REVIEW_REQUIRED","剪辑方案尚未通过脚本校验");
if(plan.review)throw new HarnessError("REVIEW_IMMUTABLE","审核已保存,修改需要新版本");
const review=assertSelectionReview(value,{hash:plan.hash,segmentIds:plan.recipe.segments.map(s=>s.id)});
// Candidate content/full-video evidence is reviewed BEFORE ranking, not cleared by scores.
this.#busy=true;
try{
const scored=await this.context.runner.scoreReferences(join(plan.root,"reference-candidates.json"),plan.root,AbortSignal.timeout(60_000));
if(scored.status!=="selected"||!scored.selected_reference)throw new HarnessError("VERIFIED_REFERENCE_REQUIRED","没有 A/B 级短剧营销参考;需要真实可播放来源及证据,不能自动降为 C 级");
if(plan.recipe.reference_id!==scored.selected_reference.id||plan.recipe.reference_evidence_tier!==scored.selected_reference.evidence_tier||plan.bundle.referenceAnalysis.reference_id!==scored.selected_reference.id||plan.bundle.referenceRecipe.reference_id!==scored.selected_reference.id)throw new HarnessError("REFERENCE_BINDING_INVALID","方案必须绑定本次评分选中的参考及其证据等级");
await this.write(plan.root,"selected-reference.json",{...scored,target_binding:this.binding()});
await this.write(plan.root,"plan-review.json",{...review,target_binding:this.binding(),reviewedAt:new Date().toISOString()});plan.reference=scored.selected_reference;plan.review=review;
}finally{this.#busy=false;}
}
return this.view();
}
async submitPlan(value:unknown,signal:AbortSignal){
const selection=this.#selection;
if(!selection?.review)throw new HarnessError("SELECTION_REVIEW_REQUIRED","策略与参考研究前,先在网页带声音审核候选源片段");
if(this.#busy)throw new HarnessError("EDITOR_BUSY","任务运行中");
if(++this.#attempts>10)throw new HarnessError("EDITOR_ATTEMPT_LIMIT","本任务版本数已达限制");
if(JSON.stringify(value).length>256*1024)throw new HarnessError("PLAN_TOO_LARGE","方案超过限制");
const bundle=Bundle.parse(value);
const analysis=Json.parse(JSON.parse(await readFile(join(this.context.root,"target-drama-analysis.json"),"utf8")));
const withheld=z.array(z.string()).min(1).parse(analysis.withheld_information);
const recipe=bindRecipe(bundle.recipe,{analysisId:this.context.analysisId,dramaId:this.context.dramaId,analysisPath:join(this.context.root,"target-drama-analysis.json"),withheldInformation:withheld,sources:this.context.sources});
for(const segment of recipe.segments)if(!selection.input.segments.some(s=>s.id===segment.id&&s.episodeId===segment.episode_id&&s.start===segment.source_start&&s.end===segment.source_end))throw new HarnessError("UNREVIEWED_SELECTION","时间范围改变后必须重新审核,不能沿用原 PASS");
const captions=cutSubtitles(recipe.segments.map(s=>({subtitle:this.source(s.episode_id).subtitle,sourceStart:s.source_start,sourceEnd:s.source_end,targetStart:s.target_start,segmentId:s.id})));
if(captions.boundaryWarnings.length)throw new HarnessError("SUBTITLE_BOUNDARY_REVIEW","切点截断了完整字幕句,需要重新选择完整对白区间");
for(const artifact of [bundle.hookMap,bundle.strategy,bundle.referenceAnalysis,bundle.referenceRecipe,bundle.continuity]){
const binding=Json.parse(artifact.target_binding);
if(binding.analysis_id!==this.context.analysisId||binding.drama_id!==this.context.dramaId||hash(artifact.withheld_information)!==hash(withheld))throw new HarnessError("PLAN_BINDING_INVALID","所有方案文件必须绑定当前分析及相同 withheld_information");
}
const strategy=z.object({narrative_structure:z.object({mode:z.enum(["linear","reverse"]),reason:z.string().min(10)}),opening:z.object({mode:z.literal("original_footage"),voiceover_authorized:z.literal(false)}),first_30_seconds:Json,character_amplification:Json,event_density:Json}).parse(bundle.strategy);void strategy;
for(const artifact of [bundle.referenceAnalysis,bundle.referenceRecipe,bundle.continuity])if(Object.keys(artifact).length<4)throw new HarnessError("PLAN_EVIDENCE_REQUIRED","参考与连续性报告需要实质分析,不接受仅有绑定和 PASS 标记");
const candidates={...bundle.referenceCandidates,target_analysis_path:join(this.context.root,"target-drama-analysis.json"),...this.binding(),allow_unverified_reference:false};
const root=await mkdtemp(join(this.context.root,"plan-"));this.#busy=true;this.#plan=undefined;
try{
await this.write(root,"reference-candidates.json",candidates);
await writeFile(join(root,"final-subtitles.srt"),captions.srt,{mode:0o600,flag:"wx"});
const renderedRecipe={...recipe,...(captions.srt?{subtitle_path:join(root,"final-subtitles.srt")}:{} )};
const artifacts={"recipe.json":renderedRecipe,"hook-map.json":bundle.hookMap,"creative-strategy.json":bundle.strategy,"reference-analysis.json":bundle.referenceAnalysis,"reference-recipe.json":bundle.referenceRecipe,"continuity-report.json":bundle.continuity};
for(const [name,data]of Object.entries(artifacts))await this.write(root,name,data);
const recipeReport=await this.context.runner.validate("recipe",join(root,"recipe.json"),root,signal);
const hookReport=await this.context.runner.validate("hook",join(root,"hook-map.json"),root,signal,join(root,"recipe.json"));
await this.write(root,"recipe-report.json",recipeReport);await this.write(root,"hook-report.json",hookReport);
if(!recipeReport.pass||!hookReport.pass)return {pass:false,recipeReport,hookReport,workflowUrl:this.view().workflowUrl};
const planHash=hash({selectionHash:selection.hash,bundle,subtitles:captions.srt});
this.#plan={id:`plan_${randomUUID()}`,hash:planHash,root,recipe,bundle,reference:{status:"pending_human_content_review_before_scoring",candidates:bundle.referenceCandidates.candidates},subtitles:captions.srt};
return {pass:true,validationStage:"recipe_and_hook_only",referenceScoring:"after_human_candidate_review",...this.view()};
}finally{this.#busy=false;}
}
async render(signal:AbortSignal){
const plan=this.#plan;if(!plan?.review||!this.#selection?.review)throw new HarnessError("PLAN_REVIEW_REQUIRED","生成前需要在网页完成当前版本的源片段、参考和故事审核");
if(plan.preview)return this.view();
if(this.#busy)throw new HarnessError("EDITOR_BUSY","任务运行中");
this.#busy=true;
try{
for(const id of new Set(plan.recipe.segments.map(s=>s.episode_id))){
const source=this.source(id);const actual=createHash("sha256");
for await(const chunk of createReadStream(source.source)){signal.throwIfAborted();actual.update(chunk as Buffer);}
if(actual.digest("hex")!==source.sha256)throw new HarnessError("REVIEWED_SOURCE_CHANGED","源文件已改变,旧审核不可复用");
}
plan.preview=await this.context.runner.render(join(plan.root,"recipe.json"),plan.root,signal);return this.view();
}
finally{this.#busy=false;}
}
private binding(){return {drama_id:this.context.dramaId,analysis_id:this.context.analysisId};}
private async write(root:string,name:string,data:unknown){await writeFile(join(root,name),JSON.stringify(data,null,2),{mode:0o600,flag:"wx"});}
}
+48
View File
@@ -0,0 +1,48 @@
import { HarnessError } from "../errors/harness-error.js";
type Playlist = {kind:"master";variants:string[]} | {kind:"media";segments:{uri:string;duration:number}[];duration:number};
function invalid(): never { throw new HarnessError("HLS_UNSUPPORTED", "HLS 清单格式不支持或超过安全范围;未下载替代素材"); }
export function parseHls(text: string): Playlist {
if (Buffer.byteLength(text)>1_048_576 || !text.trimStart().startsWith("#EXTM3U")) invalid();
const lines=text.trim().split(/\r?\n/).map((line)=>line.trim()).filter(Boolean);
if (lines.some((line)=>/^#EXT-X-(KEY|MAP|BYTERANGE|MEDIA:|I-FRAMES-ONLY|GAP|DISCONTINUITY)/.test(line))) invalid();
const variants:string[]=[];
const segments:{uri:string;duration:number}[]=[];
let pending:number|"variant"|undefined;
for (const line of lines.slice(1)) {
if (line.startsWith("#EXT-X-STREAM-INF:")) { if(pending!==undefined) invalid(); pending="variant"; }
else if (line.startsWith("#EXTINF:")) {
if(pending!==undefined) invalid();
const duration=Number(line.slice(8).split(",")[0]);
if(!Number.isFinite(duration)||duration<=0||duration>60) invalid();
pending=duration;
} else if (!line.startsWith("#")) {
if(pending==="variant") variants.push(line);
else if(typeof pending==="number") segments.push({uri:line,duration:pending});
else invalid();
pending=undefined;
}
}
if(pending!==undefined || (variants.length>0 && segments.length>0)) invalid();
if(variants.length>0) { if(variants.length>10) invalid(); return {kind:"master",variants}; }
const duration=segments.reduce((sum,item)=>sum+item.duration,0);
if(!segments.length||segments.length>1000||duration>1800||!lines.includes("#EXT-X-ENDLIST")) invalid();
return {kind:"media",segments,duration};
}
export function resolveHlsAsset(base:URL, uri:string, episodePrefix:string):URL {
if(uri.length>4096 || /[\s\\]/.test(uri) || [...uri].some(character=>character.charCodeAt(0)<32)) invalid();
const result=new URL(uri,base);
if(result.origin!==base.origin || result.protocol!=="https:" || result.username || result.password || result.hash) invalid();
let decoded:string;
try { decoded=decodeURIComponent(result.pathname); } catch { return invalid(); }
if(!result.pathname.startsWith(episodePrefix)||!decoded.startsWith(episodePrefix)||decoded.includes("..")||decoded.includes("\\")||!(/\.(ts|m3u8)$/i.test(result.pathname))) invalid();
// CDN inheritance signs each child path separately; never replace that signature with the master's.
if(result.search && result.search!==base.search) {
const entries=[...result.searchParams.entries()];
if(entries.length!==1 || entries[0]?.[0]!=="auth_key" || !/^\d+-[a-zA-Z0-9]+-[a-zA-Z0-9]+-[a-fA-F0-9]+$/.test(entries[0][1])) invalid();
}
if(!result.search) result.search=base.search;
return result;
}
@@ -0,0 +1,82 @@
import { spawn } from "node:child_process";
import { createRequire } from "node:module";
import { access, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { HarnessError } from "../errors/harness-error.js";
import { parseHls, resolveHlsAsset } from "./hls.js";
const require=createRequire(import.meta.url);
export async function mediaRuntime() {
const ffmpeg=require("ffmpeg-static") as string|null;
const ffprobe=(require("ffprobe-static") as {path:string}).path;
if(!ffmpeg) throw new HarnessError("MEDIA_RUNTIME_MISSING","当前平台缺少 FFmpeg");
try { await Promise.all([access(ffmpeg),access(ffprobe)]); } catch { throw new HarnessError("MEDIA_RUNTIME_MISSING","FFmpeg/FFprobe 安装尚未完成"); }
return {ffmpeg,ffprobe};
}
export async function downloadHls(url:URL,root:string,signal:AbortSignal,budget:{remaining:number},fetcher:typeof fetch=fetch) {
if(url.origin!=="https://video.pencil-stub.com"||url.username||url.password||url.hash) throw new HarnessError("MEDIA_ORIGIN_DENIED","只允许 CMS 固定 CDN 源");
const prefix=url.pathname.slice(0,url.pathname.lastIndexOf("/")+1);
const request=async(target:URL,limit:number)=>{
signal.throwIfAborted();
const response=await fetcher(target,{redirect:"error",signal:AbortSignal.any([signal,AbortSignal.timeout(30_000)])});
if(response.status!==200||!response.body) throw new HarnessError("MEDIA_FETCH_FAILED",`片源请求返回 HTTP ${response.status.toString()}`);
const reader=response.body.getReader();const chunks:Uint8Array[]=[];let size=0;
try {
for(;;){ const chunk=await reader.read();if(chunk.done)break;size+=chunk.value.byteLength;budget.remaining-=chunk.value.byteLength;
if(size>limit||budget.remaining<0) throw new HarnessError("MEDIA_BYTE_LIMIT","下载超过单次安全字节上限");
chunks.push(chunk.value);
}
} finally { await reader.cancel().catch(()=>undefined); }
return Buffer.concat(chunks);
};
let current=url;
let playlist=parseHls((await request(current,1_048_576)).toString("utf8"));
for(let depth=0;playlist.kind==="master" && depth<3;depth++) {
current=resolveHlsAsset(current,playlist.variants[0]!,prefix);
playlist=parseHls((await request(current,1_048_576)).toString("utf8"));
}
if(playlist.kind!=="media") throw new HarnessError("MEDIA_PLAYLIST_DEPTH","HLS 主清单嵌套超限");
const local=["#EXTM3U","#EXT-X-VERSION:3",`#EXT-X-TARGETDURATION:${Math.ceil(Math.max(...playlist.segments.map(s=>s.duration))).toString()}`,"#EXT-X-MEDIA-SEQUENCE:0"];
for(const [index,segment] of playlist.segments.entries()) {
const data=await request(resolveHlsAsset(current,segment.uri,prefix),32*1024*1024);
const name=`segment-${index.toString()}.ts`;
await writeFile(join(root,name),data,{flag:"wx",mode:0o600});
local.push(`#EXTINF:${segment.duration.toString()},`,name);
}
local.push("#EXT-X-ENDLIST","");
const path=join(root,"source.m3u8");await writeFile(path,local.join("\n"),{flag:"wx",mode:0o600});
return {path,duration:playlist.duration,segmentCount:playlist.segments.length};
}
export function runMedia(executable:string,args:string[],cwd:string,signal:AbortSignal,timeoutMs=180_000):Promise<string> {
signal.throwIfAborted();
return new Promise((resolve,reject)=>{
const child=spawn(executable,args,{cwd,env:{PATH:"/usr/bin:/bin",LANG:"en_US.UTF-8"},detached:process.platform!=="win32",stdio:["ignore","pipe","pipe"]});
let stdout="";let bytes=0;let terminated=false;
const kill=()=>{terminated=true;try{if(process.platform!=="win32"&&child.pid)process.kill(-child.pid,"SIGKILL");else child.kill("SIGKILL");}catch{/* Already exited. */}};
const timer=setTimeout(kill,timeoutMs);signal.addEventListener("abort",kill,{once:true});
const cleanup=()=>{clearTimeout(timer);signal.removeEventListener("abort",kill);};
child.stdout.on("data",(chunk:Buffer)=>{bytes+=chunk.length;if(bytes>2*1024*1024)kill();else stdout+=chunk.toString();});
child.stderr.on("data",(chunk:Buffer)=>{bytes+=chunk.length;if(bytes>2*1024*1024)kill();});
child.once("error",()=>{cleanup();reject(new HarnessError("MEDIA_EXEC_FAILED","媒体进程启动失败"));});
child.once("close",code=>{cleanup();if(code===0&&!terminated&&!signal.aborted)resolve(stdout);else reject(new HarnessError(signal.aborted?"MEDIA_CANCELLED":"MEDIA_EXEC_FAILED","媒体进程失败、取消或超过限制"));});
if(signal.aborted)kill();
});
}
export async function prepareLocalMedia(root:string,signal:AbortSignal) {
const {ffmpeg,ffprobe}=await mediaRuntime();
const source=join(root,"episode.mp4");
await runMedia(ffmpeg,["-nostdin","-v","error","-protocol_whitelist","file,crypto,data","-i",join(root,"source.m3u8"),"-map","0:v:0","-map","0:a:0","-c","copy","-movflags","+faststart","-n",source],root,signal);
const raw=JSON.parse(await runMedia(ffprobe,["-v","error","-protocol_whitelist","file","-show_streams","-show_format","-of","json",source],root,signal)) as {streams?:{codec_type?:string;width?:number;height?:number}[];format?:{duration?:string}};
const duration=Number(raw.format?.duration);const video=raw.streams?.find(s=>s.codec_type==="video");
if(!video||!raw.streams?.some(s=>s.codec_type==="audio")||!Number.isFinite(duration)||duration<=0||duration>1800||!video.width||!video.height) throw new HarnessError("MEDIA_STREAM_INVALID","素材缺少有效音视频流或超出时长范围");
const frames:{path:string;time:number}[]=[];
for(const [index,fraction] of [0.15,0.5,0.85].entries()) {
const time=Math.round(duration*fraction*1000)/1000;const path=join(root,`frame-${index.toString()}.jpg`);
await runMedia(ffmpeg,["-nostdin","-v","error","-protocol_whitelist","file","-ss",time.toString(),"-i",source,"-frames:v","1","-vf","scale=512:512:force_original_aspect_ratio=decrease","-q:v","3","-n",path],root,signal);
frames.push({path,time});
}
return {source,duration,width:video.width,height:video.height,frames};
}
@@ -0,0 +1,19 @@
import {z} from "zod";
import {HarnessError} from "../errors/harness-error.js";
const number=z.number().finite().nonnegative();
const Segment=z.object({id:z.string().min(1),hook_id:z.string().min(1),episode_id:z.string(),episode:z.number().int().positive(),source_start:number,source_end:number,target_start:number,target_end:number,hold:number.default(0),semantic_role:z.string().min(1),continuity_group:z.string().min(1),source_gap_before:z.number().finite(),confidence:z.enum(["high","medium","low"]),reveal_risk:z.enum(["low","medium","high"]),cut_reason:z.string().min(1),intentional_discontinuity:z.boolean().optional(),discontinuity_reason:z.string().optional()});
const Recipe=z.object({schema_version:z.literal("2.0"),analysis_id:z.string(),drama_id:z.string(),title:z.string().optional(),creative_goal:z.string().optional(),reference_id:z.string().optional(),reference_evidence_tier:z.enum(["A","B","C"]).optional(),duration_strategy:z.literal("content_led"),withheld_information:z.array(z.string()).min(1),ending:z.object({cutoff_reason:z.string().min(1),before_withheld_reveal:z.literal(true),hold:number.default(0)}),segments:z.array(Segment).min(1).max(10),transitions:z.array(z.unknown()).optional(),subtitle_path:z.never().optional()});
export function bindRecipe(value:unknown,binding:{analysisId:string;dramaId:string;analysisPath:string;withheldInformation:string[];sources:{episodeId:string;episodeNo:number;source:string;duration:number}[]}){
const recipe=Recipe.parse(value);const fail=():never=>{throw new HarnessError("RECIPE_BINDING_INVALID","剪辑必须使用当前分析和授权源、精确 24fps 时间线,总长必须为 10 秒");};
if(recipe.analysis_id!==binding.analysisId||recipe.drama_id!==binding.dramaId||JSON.stringify(recipe.withheld_information)!==JSON.stringify(binding.withheldInformation))fail();
let end=0;const ids=new Set<string>();
const segments=recipe.segments.map(segment=>{
const source=binding.sources.find(s=>s.episodeId===segment.episode_id&&s.episodeNo===segment.episode);
if(!source||ids.has(segment.id))return fail();ids.add(segment.id);
for(const value of [segment.source_start,segment.source_end,segment.target_start,segment.target_end,segment.hold])if(Math.abs(value-Math.round(value*24)/24)>0.001)fail();
if(segment.source_end>source.duration||segment.source_end<=segment.source_start||Math.abs(segment.target_start-end)>0.001||Math.abs((segment.source_end-segment.source_start+segment.hold)-(segment.target_end-segment.target_start))>0.001)fail();
end=segment.target_end;return {...segment,source:source.source};
});
if(Math.abs(end-10)>0.001)fail();
return {...recipe,segments,target_analysis_path:binding.analysisPath,target_duration_preference:10,maximum_duration_seconds:300};
}
@@ -0,0 +1,9 @@
import {z} from "zod";
import {HarnessError} from "../errors/harness-error.js";
const Review=z.object({planHash:z.string(),reviewer:z.string().trim().min(1).max(100),policyCheckedAt:z.string().datetime({offset:true}),pictureAudioReviewed:z.literal(true),referenceReviewed:z.literal(true),storyReviewed:z.literal(true),packagingReviewed:z.literal(true),noUnresolvedFindings:z.literal(true),segmentEvidence:z.array(z.object({segmentId:z.string(),observation:z.string().trim().min(20).max(3000)})).min(1).max(10)}).strict();
export function assertSelectionReview(value:unknown,plan:{hash:string;segmentIds:string[]}){
const review=Review.parse(value);
const checked=Date.parse(review.policyCheckedAt);
if(review.planHash!==plan.hash||checked>Date.now()+60_000||Date.now()-checked>24*60*60*1000||review.segmentEvidence.length!==plan.segmentIds.length||new Set(review.segmentEvidence.map(item=>item.segmentId)).size!==plan.segmentIds.length||review.segmentEvidence.some(item=>!plan.segmentIds.includes(item.segmentId)))throw new HarnessError("SELECTION_REVIEW_INVALID","审核必须对应当前版本、每一个选段及本批次政策核对时间");
return review;
}
@@ -0,0 +1,48 @@
import {mkdtemp,readFile,stat} from "node:fs/promises";
import {join} from "node:path";
import {z} from "zod";
import {HarnessError} from "../errors/harness-error.js";
import {runMedia,mediaRuntime} from "./media-worker.js";
const Report=z.object({pass:z.boolean(),issues:z.array(z.object({code:z.string(),severity:z.string(),message:z.string()}))});
const validators={analysis:["validate_target_drama_analysis.py","--analysis"],recipe:["validate_recipe.py","--recipe"],hook:["validate_hook_map.py","--hook-map"]} as const;
export class SkillRunner{
constructor(private readonly options:{skillRoot:string;pythonExecutable:string}){}
async validate(kind:keyof typeof validators,input:string,root:string,signal:AbortSignal,recipe?:string){
const work=await mkdtemp(join(root,"validation-"));
const reportPath=join(work,"report.json");
const [script,flag]=validators[kind];
const args=["-B",join(this.options.skillRoot,"scripts",script),flag,input,"--report",reportPath];
if(kind==="hook"&&recipe)args.push("--recipe",recipe);
try{await runMedia(this.options.pythonExecutable,args,work,signal,30_000);}
catch(error){if(signal.aborted)throw error;if(!(error instanceof HarnessError)||error.code!=="MEDIA_EXEC_FAILED")throw error;}
signal.throwIfAborted();
try{
if((await stat(reportPath)).size>128*1024)throw new Error();
const result=Report.parse(JSON.parse(await readFile(reportPath,"utf8")));
// Output only issue codes: original script messages may contain private absolute paths.
return {pass:result.pass&&result.issues.every(issue=>issue.severity!=="error"),issues:result.issues.map(issue=>({code:issue.code,severity:issue.severity}))};
}catch{throw new HarnessError("EDITOR_VALIDATOR_FAILED","原始 Skill 校验器未生成有效报告,未放行");}
}
/** Internal only: the workflow must bind paths and enforce all pre-render gates first. */
async scoreReferences(input:string,root:string,signal:AbortSignal){
const work=await mkdtemp(join(root,"references-"));const output=join(work,"selected-reference.json");
try{await runMedia(this.options.pythonExecutable,["-B",join(this.options.skillRoot,"scripts/score_reference_candidates.py"),"--input",input,"--output",output],work,signal,30_000);}
catch(error){if(signal.aborted||!(error instanceof HarnessError)||error.code!=="MEDIA_EXEC_FAILED")throw error;}
signal.throwIfAborted();if((await stat(output)).size>256*1024)throw new HarnessError("REFERENCE_REPORT_INVALID","参考报告超限");
return z.object({status:z.string(),selected_reference:z.record(z.string(),z.unknown()).nullable(),warnings:z.array(z.string())}).parse(JSON.parse(await readFile(output,"utf8")));
}
/** Internal only: the workflow must bind paths and enforce all pre-render gates first. */
async render(recipe:string,root:string,signal:AbortSignal){
const work=await mkdtemp(join(root,"render-"));const output=join(work,"preview.mp4");
const runtime=await mediaRuntime();
const binaries=["--ffmpeg",runtime.ffmpeg,"--ffprobe",runtime.ffprobe];
await runMedia(this.options.pythonExecutable,["-B",join(this.options.skillRoot,"scripts/render_exact.py"),"--recipe",recipe,"--work-dir",work,"--output",output,...binaries],work,signal,300_000);
try{await runMedia(this.options.pythonExecutable,["-B",join(this.options.skillRoot,"scripts/qa_video.py"),"--video",output,"--recipe",recipe,"--matches",join(work,"matches.json"),"--work-dir",work,...binaries],work,signal,300_000);}
catch(error){if(signal.aborted||!(error instanceof HarnessError)||error.code!=="MEDIA_EXEC_FAILED")throw error;}
signal.throwIfAborted();
const report=join(work,"qa-report.json");if((await stat(report)).size>256*1024)throw new HarnessError("QA_REPORT_INVALID","QA 报告超过限制");
const qa=z.object({pass:z.boolean(),sha256:z.string().regex(/^[a-f0-9]{64}$/),probe:z.object({duration:z.number().positive().max(300)}),checks:z.record(z.string(),z.boolean()),audio_sync:z.object({results:z.array(z.unknown())}),black_events:z.array(z.string()),silence_events:z.array(z.string()),freeze_events:z.array(z.string())}).parse(JSON.parse(await readFile(report,"utf8")));
return {output,duration:qa.probe.duration,sha256:qa.sha256,checks:qa.checks,machinePass:qa.pass,reviewStatus:"REVIEW" as const,events:{black:qa.black_events.length,silence:qa.silence_events.length,freeze:qa.freeze_events.length},audioSync:qa.audio_sync.results};
}
}
+36 -2
View File
@@ -1,6 +1,7 @@
import { access, realpath, stat } from "node:fs/promises";
import { access, mkdir, mkdtemp, readFile, realpath, stat, writeFile } from "node:fs/promises";
import { realpathSync } from "node:fs";
import { delimiter, isAbsolute, relative, resolve, sep } from "node:path";
import { delimiter, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
import { SafeWorkspace } from "../workspace/safe-workspace.js";
import {
DefaultResourceLoader,
SettingsManager,
@@ -49,13 +50,16 @@ export type SkillCatalogOptions = {
cwd: string;
agentDir: string;
extraDirsValue?: string;
resources?: Record<string, readonly string[]>;
};
export class SkillCatalog {
readonly #cwd: string;
readonly #agentDir: string;
readonly #extraDirsValue: string | undefined;
readonly #resources: Record<string, readonly string[]>;
#entries: SkillCatalogEntry[] = [];
#skills: Skill[] = [];
#indexedSkills: IndexedSkill[] = [];
#diagnostics: SkillDiagnostic[] = [];
@@ -63,6 +67,7 @@ export class SkillCatalog {
this.#cwd = canonicalPathSync(options.cwd);
this.#agentDir = canonicalPathSync(options.agentDir);
this.#extraDirsValue = options.extraDirsValue;
this.#resources = Object.fromEntries(Object.entries(options.resources ?? {}).map(([path, files]) => [canonicalPathSync(path), files]));
}
public async reload(): Promise<void> {
@@ -81,6 +86,7 @@ export class SkillCatalog {
await loader.reload();
const loaded = loader.getSkills();
this.#skills = loaded.skills;
const loaderDiagnostics = loaded.diagnostics.map((diagnostic) =>
mapDiagnostic(diagnostic, this.#cwd),
);
@@ -106,6 +112,34 @@ export class SkillCatalog {
return new SkillIndex(this.#indexedSkills);
}
/** Copy only catalogued instruction bodies, never arbitrary project files or credentials. */
public async createSessionSnapshot(workspaceRoot: string): Promise<{ skills: Skill[]; index: SkillIndex }> {
const root = await realpath(workspaceRoot);
const snapshotRoot = await mkdtemp(join(root, ".skills-"));
const skills = await Promise.all(this.#skills.map(async (skill, index) => {
const baseDir = join(snapshotRoot, String(index));
await mkdir(baseDir, { mode: 0o700 });
const filePath = join(baseDir, "SKILL.md");
await writeFile(filePath, await readFile(skill.filePath), { flag: "wx", mode: 0o400 });
const source = await SafeWorkspace.create(skill.baseDir);
for (const resource of this.#resources[canonicalPathSync(skill.filePath)] ?? []) {
if (isAbsolute(resource) || resource.split(/[\\/]/).includes("..")) throw new Error("Unsafe Skill resource path");
const from = await source.resolveReadable(resource);
if ((await stat(from)).size > 1_048_576) throw new Error("Skill resource exceeds size limit");
const target = join(baseDir, resource);
await mkdir(dirname(target), { recursive: true, mode: 0o700 });
await writeFile(target, await readFile(from), { flag: "wx", mode: 0o400 });
}
return { ...skill, baseDir, filePath };
}));
return {
skills,
index: new SkillIndex(skills.map((skill) => ({
name: skill.name, source: displaySource(skill), canonicalFilePath: skill.filePath,
}))),
};
}
public readonly diagnostics = (): SkillDiagnostic[] => {
return this.#diagnostics.map((diagnostic) => ({ ...diagnostic }));
};
+108
View File
@@ -0,0 +1,108 @@
import { execFile } from "node:child_process";
import { randomUUID } from "node:crypto";
import { chmod, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { pathToFileURL } from "node:url";
import { promisify } from "node:util";
import { defineTool } from "@earendil-works/pi-coding-agent";
import { Type } from "typebox";
import { HarnessError } from "../errors/harness-error.js";
import { SafeWorkspace } from "../workspace/safe-workspace.js";
const exec = promisify(execFile);
const formats = { json: "application/json", html: "text/html; charset=utf-8", pdf: "application/pdf" } as const;
type Format = keyof typeof formats;
type QueryRecord = { queryId: string; requestedAt: string; arguments: Record<string, unknown>; result: unknown };
type ReportInput = { data: Record<string, unknown>; source_query_ids: string[] };
export type MinisReportOptions = { workspace: SafeWorkspace; sessionId: string; skillRoot: string; pythonExecutable?: string; chromeExecutable?: string };
export class MinisReportService {
readonly #queries = new Map<string, QueryRecord>();
readonly #reports = new Map<string, { root: string; formats: Set<Format> }>();
public constructor(private readonly options: MinisReportOptions) {}
public recordQuery(args: Record<string, unknown>, result: unknown): string {
const queryId = `query_${randomUUID()}`;
this.#queries.set(queryId, { queryId, requestedAt: new Date().toISOString(), arguments: structuredClone(args), result: structuredClone(result) });
return queryId;
}
public async generate(input: ReportInput, signal?: AbortSignal) {
if (!input.source_query_ids.length || input.source_query_ids.some((id) => !this.#queries.has(id))) {
throw new HarnessError("REPORT_SOURCE_REQUIRED", "Report requires successful swads query IDs from this Session; never use example data");
}
const { data } = input;
for (const key of ["title", "report_type", "period", "comparison_period", "currency", "timezone", "data_status", "conclusion"]) {
if (typeof data[key] !== "string" || !data[key].trim()) throw new HarnessError("INVALID_REPORT", `Report requires ${key}`);
}
if (!["daily", "weekly"].includes(String(data.report_type)) || !Array.isArray(data.cards) || !Array.isArray(data.issues)) throw new HarnessError("INVALID_REPORT", "Invalid report type, cards or issues");
if (data.currency !== "USD") throw new HarnessError("REPORT_CURRENCY_UNSUPPORTED", "This renderer labels charts in USD. Keep other currencies separate and do not generate misleading USD charts.");
const payload = JSON.stringify({ ...data, source_queries: input.source_query_ids.map((id) => this.#queries.get(id)) }, null, 2);
if (Buffer.byteLength(payload) > 4_194_304) throw new HarnessError("REPORT_TOO_LARGE", "Report source data exceeds 4 MiB");
const reportId = `rpt_${randomUUID()}`;
// A fresh random directory avoids overwrites and untrusted symlink targets.
const root = await mkdtemp(join(this.options.workspace.root, ".report-"));
const jsonPath = join(root, "report.json");
const htmlPath = join(root, "report.html");
await writeFile(jsonPath, payload, { mode: 0o600, flag: "wx" });
const source = await SafeWorkspace.create(this.options.skillRoot);
const script = await source.resolveReadable("scripts/generate_report.py");
const env = { PATH: process.env.PATH ?? "/usr/bin:/bin", LANG: "en_US.UTF-8", PYTHONDONTWRITEBYTECODE: "1", PYTHONNOUSERSITE: "1" };
try {
await exec(this.options.pythonExecutable ?? "python3", ["-I", script, jsonPath, htmlPath], {
cwd: root, env, timeout: 30_000, maxBuffer: 65_536, ...(signal === undefined ? {} : { signal }),
});
await chmod(htmlPath, 0o600);
} catch {
throw new HarnessError("REPORT_RENDER_FAILED", "HTML generation failed. Check report JSON against the Skill schema and the configured Python runtime.");
}
const available = new Set<Format>(["json", "html"]);
let pdfWarning: string | undefined;
const pdfPath = join(root, "report.pdf");
const profile = await mkdtemp(join(root, ".chrome-"));
try {
await exec(this.options.chromeExecutable ?? (process.platform === "darwin" ? "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" : "chromium"), [
"--headless", "--disable-gpu", "--disable-extensions", "--disable-background-networking", "--no-first-run",
"--host-resolver-rules=MAP * ~NOTFOUND", "--no-pdf-header-footer", `--user-data-dir=${profile}`, `--print-to-pdf=${pdfPath}`, pathToFileURL(htmlPath).href,
], { cwd: root, env, timeout: 60_000, maxBuffer: 65_536, ...(signal === undefined ? {} : { signal }) });
const pdf = await readFile(pdfPath);
if (!pdf.subarray(0, 5).equals(Buffer.from("%PDF-")) || pdf.byteLength < 1_000 || !pdf.includes(Buffer.from("%%EOF"))) throw new Error("Invalid PDF output");
await chmod(pdfPath, 0o600);
available.add("pdf");
} catch {
pdfWarning = "PDF 生成失败:请检查 Chrome/Chromium 安装与运行权限;HTML 和 JSON 已保留。";
} finally {
await rm(profile, { recursive: true, force: true }).catch(() => undefined);
}
if (signal?.aborted) throw new HarnessError("REPORT_CANCELLED", "Report generation was cancelled");
this.#reports.set(reportId, { root, formats: available });
return {
reportId, title: data.title, pdfStatus: available.has("pdf") ? "ready" : "failed",
...(pdfWarning === undefined ? {} : { warning: pdfWarning }),
files: await Promise.all([...available].map(async (format) => ({
format, url: `/api/sessions/${this.options.sessionId}/reports/${reportId}/${format}`,
byteSize: (await stat(join(root, `report.${format}`))).size,
}))),
};
}
public async read(reportId: string, format: string): Promise<{ bytes: Buffer; contentType: string }> {
const report = this.#reports.get(reportId);
if (report === undefined || !Object.hasOwn(formats, format) || !report.formats.has(format as Format)) throw new HarnessError("REPORT_NOT_FOUND", "Report file not found");
const file = await this.options.workspace.resolveReadable(join(report.root, `report.${format}`));
return { bytes: await readFile(file), contentType: formats[format as Format] };
}
}
export function createMinisReportTool(service: MinisReportService) {
return defineTool({
name: "generate_minis_report", label: "Drama Minis HTML / PDF",
description: "Generate HTML + A4 PDF + editable JSON using the installed Drama Minis Skill renderer. Read the Skill, references and example JSON structure first, NEVER reuse example values. Pass source_query_ids returned by successful REAL metrics_semantic_query calls in this Session, and freshly normalized report data (title, report_type daily/weekly, period, comparison_period, currency USD, timezone, data_status, conclusion, cards, issues, funnel, countries, country_revenue, country_funnels; weekly trend). Preserve missing values and source/account coverage in footnote. Requires page approval. Only declare PDF success when pdfStatus is ready. This replaces shell execution for the Skill report script.",
parameters: Type.Object({ source_query_ids: Type.Array(Type.String(), { minItems: 1 }), data: Type.Record(Type.String(), Type.Unknown()) }, { additionalProperties: false }),
executionMode: "sequential",
async execute(_id, params, signal) {
const details = await service.generate(params, signal);
return { content: [{ type: "text" as const, text: JSON.stringify(details) }], details };
},
});
}
+78
View File
@@ -0,0 +1,78 @@
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
import { defineTool } from "@earendil-works/pi-coding-agent";
import { Type } from "typebox";
import { HarnessError } from "../errors/harness-error.js";
const allowed = ["swads_whoami", "metrics_catalog", "metrics_semantic_query"] as const;
export const SWADS_MCP_URL = "https://ads.mincode.cn/mcp";
export class SwadsMcpClient {
#connection: Promise<Client> | undefined;
public constructor(private readonly options: { token: string; fetch?: typeof fetch }) {}
async #connect(): Promise<Client> {
if (!this.options.token.trim()) throw new HarnessError("SWADS_NOT_CONFIGURED", "SWADS_MCP_TOKEN is not configured on the server");
this.#connection ??= (async () => {
const client = new Client({ name: "agent-studio-mini", version: "0.1.0" });
try {
await client.connect(new StreamableHTTPClientTransport(new URL(SWADS_MCP_URL), {
requestInit: { redirect: "error", headers: { Authorization: `Bearer ${this.options.token}` } },
...(this.options.fetch === undefined ? {} : { fetch: this.options.fetch }),
}) as Transport, { timeout: 20_000 });
return client;
} catch {
await client.close().catch(() => undefined);
this.#connection = undefined;
throw new HarnessError("SWADS_CONNECTION_FAILED", "swads MCP connection failed; check the server credential and network");
}
})();
return this.#connection;
}
public async call(name: string, args: Record<string, unknown>, signal?: AbortSignal) {
if (!allowed.some((item) => item === name)) throw new HarnessError("SWADS_TOOL_DENIED", "MCP tool is not allowed");
const client = await this.#connect();
try {
const result = await client.callTool({ name, arguments: args }, undefined, {
timeout: 60_000, ...(signal === undefined ? {} : { signal }),
});
// Remove the credential even if an upstream error reflects request headers in text.
return JSON.parse(JSON.stringify(result).replaceAll(this.options.token, "[REDACTED]")) as typeof result;
} catch {
throw new HarnessError("SWADS_REQUEST_FAILED", "swads MCP request failed or was cancelled; no metric values were fabricated");
}
}
public async close(): Promise<void> {
const connection = this.#connection;
this.#connection = undefined;
const client = await connection?.catch(() => undefined);
await client?.close();
}
}
export function createSwadsTools(client: SwadsMcpClient, onQueryResult: (args: Record<string, unknown>, result: unknown) => string) {
const descriptions = {
swads_whoami: "Read the authenticated swads identity and all visible advertising accounts. Do not assume the current account is the whole platform.",
metrics_catalog: "Read actual swads metric definitions, dimensions and request_schema before querying. No advertising metrics are queried by this call.",
metrics_semantic_query: "Query REAL swads analytics using metrics_catalog request_schema. Confirm analysis period, comparison period and account scope with the user first. Each call requires page approval. Pass date_from/date_to explicitly; external_account_id selects an account (not a campaign). Gateway can fill current tenant/org/platform/account. Never substitute mock data. Query accounts separately, preserve currencies/timezones and distinguish null from zero.",
};
return allowed.map((name) => defineTool({
name, label: name, description: descriptions[name],
parameters: Type.Object({}, { additionalProperties: true }),
executionMode: "sequential",
async execute(_id, params, signal) {
const args = params as Record<string, unknown>;
const result = await client.call(name, args, signal);
if (result.isError) throw new HarnessError("SWADS_TOOL_FAILED", JSON.stringify(result.content).slice(0, 2_000));
const sourceQueryId = name === "metrics_semantic_query" ? onQueryResult(args, result) : undefined;
const details = { source: "swads MCP", toolName: name, ...(sourceQueryId === undefined ? {} : { sourceQueryId }), result: result.structuredContent ?? result.content };
return {
content: [{ type: "text" as const, text: JSON.stringify(details) }],
details,
};
},
}));
}
+4 -2
View File
@@ -1,9 +1,11 @@
export type ToolPolicyDecision = "allow" | "ask" | "deny";
const allowedTools = new Set(["read", "grep", "find", "ls", "mock_ads_metrics"]);
const allowedTools = new Set(["read", "grep", "find", "ls", "mock_ads_metrics", "swads_whoami", "metrics_catalog", "cms_connection_status", "editor_job_status", "submit_drama_analysis", "propose_drama_selection", "submit_editor_plan"]);
export function decideToolPolicy(toolName: string): ToolPolicyDecision {
if (["i2v_create_task", "i2v_status"].includes(toolName)) return "allow";
if (["i2v_upload_reference", "i2v_submit"].includes(toolName)) return "ask";
if (allowedTools.has(toolName)) return "allow";
if (toolName === "save_report_draft") return "ask";
if (["save_report_draft", "metrics_semantic_query", "generate_minis_report", "cms_list_dramas", "cms_drama_inventory", "cms_read_subtitle", "prepare_drama_sources", "read_drama_frames", "render_editor_preview"].includes(toolName)) return "ask";
return "deny";
}
@@ -0,0 +1,27 @@
import {describe,it,expect} from "vitest";
import {assertAnalysisBinding} from "../src/media/analysis-binding.js";
const expected={analysisId:"analysis_real",dramaId:"drama_real",fetchedAt:"2026-09-14T00:00:00Z",title:"Actual title",synopsis:"Actual CMS synopsis",tags:["Mystery"],episodeCount:5,
sources:[{episodeId:"e1",episodeNo:1,duration:30,subtitle:"WEBVTT\n\n00:00:01.000 --> 00:00:03.000\nWhere is she?",frames:[{id:"f1",time:2}]},{episodeId:"e3",episodeNo:3,duration:30,subtitle:"WEBVTT\n\n00:00:01.000 --> 00:00:03.000\nFind her.",frames:[{id:"f3",time:2}]},{episodeId:"e5",episodeNo:5,duration:30,subtitle:"WEBVTT\n\n00:00:01.000 --> 00:00:03.000\nWho did this?",frames:[{id:"f5",time:2}]}]};
function analysis(){return {analysis_id:"analysis_real",drama:{drama_id:"drama_real",title:"Actual title",synopsis:"Actual CMS synopsis",tags:["Mystery"],episode_count:5},cms_source:{fetched_at:expected.fetchedAt},evidence:{
frame_evidence:[{episode:1,episode_id:"e1",frame_id:"f1",time:2,claim:"A person stands"},{episode:3,episode_id:"e3",frame_id:"f3",time:2,claim:"A person speaks"}],
subtitle_evidence:[{episode:1,episode_id:"e1",story_position:"opening",quote:"Where is she?"},{episode:3,episode_id:"e3",story_position:"middle",quote:"Find her."},{episode:5,episode_id:"e5",story_position:"late",quote:"Who did this?"}]}};}
describe("analysis evidence binding",()=>{
it("accepts only the actual job identity, CMS record and registered frame/subtitle evidence",()=>{
expect(()=>assertAnalysisBinding(analysis(),expected)).not.toThrow();
const changed=analysis();changed.analysis_id="analysis_other";
expect(()=>assertAnalysisBinding(changed,expected)).toThrow();
changed.analysis_id="analysis_real";changed.drama.synopsis="Model invented synopsis";
expect(()=>assertAnalysisBinding(changed,expected)).toThrow();
});
it("rejects invented frames, mismatched timestamps, duplicate frames and invented subtitle quotes",()=>{
const unknown=analysis();unknown.evidence.frame_evidence[0]!.frame_id="unknown";
expect(()=>assertAnalysisBinding(unknown,expected)).toThrow();
const timestamp=analysis();timestamp.evidence.frame_evidence[0]!.time=29;
expect(()=>assertAnalysisBinding(timestamp,expected)).toThrow();
const duplicate=analysis();duplicate.evidence.frame_evidence[1]=duplicate.evidence.frame_evidence[0]!;
expect(()=>assertAnalysisBinding(duplicate,expected)).toThrow();
const quote=analysis();quote.evidence.subtitle_evidence[0]!.quote="Invented dialogue";
expect(()=>assertAnalysisBinding(quote,expected)).toThrow();
});
});
@@ -0,0 +1,102 @@
import { describe, expect, it } from "vitest";
import { CmsConnection } from "../src/cms/cms-connection.js";
const dramaId = "11111111-1111-4111-8111-111111111111";
const episodeId = "22222222-2222-4222-8222-222222222222";
const userId = "33333333-3333-4333-8333-333333333333";
const token = "test-access-secret";
function server(role = "admin") {
const requests: { url: URL; init: RequestInit | undefined }[] = [];
const fetcher: typeof fetch = (input, init) => {
const url = new URL(typeof input === "string" ? input : input instanceof URL ? input.href : input.url);
requests.push({ url, init });
const user = { id: userId, aud: "authenticated", role: "authenticated", email: "fixture@example.com", app_metadata: { role }, user_metadata: { role: "admin" }, created_at: "2026-01-01T00:00:00Z" };
let data: unknown;
if (url.pathname === "/auth/v1/token") data = { access_token: token, refresh_token: "refresh-secret", token_type: "bearer", expires_in: 3600, user };
else if (url.pathname === "/auth/v1/user") data = user;
else if (url.pathname === "/rest/v1/drama") data = [{ id: dramaId, title: "Test drama", synopsis: "Fixture only", language: "en", total_episodes: 2, status: "published" }];
else if (url.pathname === "/rest/v1/episode") data = [{ id: episodeId, drama_id: dramaId, episode_no: 1, title: "One", duration_seconds: 90, status: "published" }];
else if (url.pathname === "/rest/v1/drama_category") data = [{drama_id:dramaId,category:{name:"Mystery"}}];
else if (url.pathname === "/rest/v1/episode_source") data = [{ episode_id: episodeId, provider: "fixture", playback_path: "private/fixture.m3u8" }];
else if (url.pathname === "/rest/v1/subtitle_track") data = [{ episode_id: episodeId, locale: "en", path: "private/fixture.srt" }];
else if (url.pathname.endsWith("/subtitles/en.vtt")) return Promise.resolve(new Response("WEBVTT\n\n00:00:00.000 --> 00:00:01.000\nFixture", { headers: { "content-type": "text/vtt" } }));
else return Promise.resolve(new Response("unexpected destination", { status: 500 }));
return Promise.resolve(new Response(JSON.stringify(data), { headers: { "content-type": "application/json" } }));
};
return { requests, fetcher };
}
describe("CMS connection", () => {
it("binds signing to the selected drama and keeps it on the fixed CMS admin route", async () => {
const fixture = server();
const cms = new CmsConnection({publishableKey:"sb_publishable_test",fetch:async(input,init)=>{
const url=new URL(typeof input==="string"?input:input instanceof URL?input.href:input.url);
if(url.pathname.endsWith(`/cms/playback/${episodeId}`)) {
expect(url.origin).toBe("https://swave.pencil-stub.com");
expect(new Headers(init?.headers).get("authorization")).toBe(`Bearer ${token}`);
return new Response(JSON.stringify({url:"https://video.pencil-stub.com/fixture/master.m3u8?auth_key=private",expires_at:new Date(Date.now()+600000).toISOString()}));
}
return fixture.fetcher(input,init);
}});
await cms.login("fixture@example.com","secret");
await expect(cms.playback(dramaId,userId)).rejects.toMatchObject({code:"CMS_EPISODE_NOT_FOUND"});
expect((await cms.playback(dramaId,episodeId)).url).toContain("https://video.pencil-stub.com/");
expect(JSON.stringify(cms.status())).not.toContain("auth_key");
});
it("blocks content before login and never sends credentials to a model-facing status", async () => {
const { fetcher } = server();
const cms = new CmsConnection({ publishableKey: "sb_publishable_test", fetch: fetcher });
await expect(cms.listDramas("", 0)).rejects.toMatchObject({ code: "CMS_LOGIN_REQUIRED" });
await cms.login("fixture@example.com", "password-secret");
expect(cms.status()).toMatchObject({ configured: true, connected: true });
expect(JSON.stringify(cms.status())).not.toMatch(/password-secret|test-access-secret|refresh-secret|fixture@example/);
expect((await cms.listDramas("", 0)).dramas[0]?.title).toBe("Test drama");
cms.clear();
expect(cms.status().connected).toBe(false);
await expect(cms.listDramas("", 0)).rejects.toMatchObject({ code: "CMS_LOGIN_REQUIRED" });
});
it("rejects user-editable administrator claims and discards the session", async () => {
const { fetcher } = server("viewer");
const cms = new CmsConnection({ publishableKey: "sb_publishable_test", fetch: fetcher });
await expect(cms.login("fixture@example.com", "secret")).rejects.toMatchObject({ code: "CMS_ADMIN_REQUIRED" });
expect(cms.status().connected).toBe(false);
});
it("reports incomplete inventory without leaking playback or subtitle paths", async () => {
const { fetcher } = server();
const cms = new CmsConnection({ publishableKey: "sb_publishable_test", fetch: fetcher });
await cms.login("fixture@example.com", "secret");
const inventory = await cms.inventory(dramaId);
expect(inventory.complete).toBe(false);
expect(inventory.drama.tags).toEqual(["Mystery"]);
expect(inventory.missingEpisodeNumbers).toEqual([2]);
expect(inventory.episodes[0]).toMatchObject({ episodeNo: 1, hasSource: true, subtitleLocales: ["en"] });
expect(JSON.stringify(inventory)).not.toContain("private/");
expect(inventory.fetchedAt).toMatch(/^\d{4}-/);
});
it("binds subtitle access to an actual episode in the chosen drama", async () => {
const { fetcher, requests } = server();
const cms = new CmsConnection({ publishableKey: "sb_publishable_test", fetch: fetcher });
await cms.login("fixture@example.com", "secret");
await expect(cms.subtitle(dramaId, userId, "en")).rejects.toMatchObject({ code: "CMS_EPISODE_NOT_FOUND" });
expect((await cms.subtitle(dramaId, episodeId, "en")).text).toContain("WEBVTT");
const subtitleRequest = requests.find((request) => request.url.pathname.endsWith(".vtt"));
expect(subtitleRequest?.url.origin).toBe("https://swave.pencil-stub.com");
expect(subtitleRequest?.init?.redirect).toBe("error");
expect(new Headers(subtitleRequest?.init?.headers).get("authorization")).toBe(`Bearer ${token}`);
});
it("rejects a late login result after the connection was cleared", async () => {
const { fetcher } = server();
let release: (() => void) | undefined;
const gate = new Promise<void>((resolve) => { release = resolve; });
const cms = new CmsConnection({ publishableKey: "sb_publishable_test", fetch: async (input, init) => { await gate; return fetcher(input, init); } });
const pending = cms.login("fixture@example.com", "secret");
cms.clear();
release?.();
await expect(pending).rejects.toMatchObject({ code: "CMS_LOGIN_CANCELLED" });
expect(cms.status().connected).toBe(false);
});
});
@@ -0,0 +1,18 @@
import {describe,it,expect} from "vitest";
import {EditorService} from "../src/media/editor-service.js";
import {CmsConnection} from "../src/cms/cms-connection.js";
describe("editor job boundary",()=>{
it("requires CMS login before preparing media and rejects unknown jobs",async()=>{
const service=new EditorService({cms:new CmsConnection({publishableKey:""}),runtimeRoot:"/unused/editor-tests",sessionId:"ses_test"});
await expect(service.prepare({dramaId:"06aa396d-175a-48ed-ad76-02786ef4d0a9",episodeNumbers:[1,3,5]},new AbortController().signal)).rejects.toMatchObject({code:"CMS_LOGIN_REQUIRED"});
expect(()=>service.status("job_unknown")).toThrow();
await expect(service.submitAnalysis("job_unknown",{},new AbortController().signal)).rejects.toMatchObject({code:"EDITOR_JOB_NOT_FOUND"});
expect(()=>service.workflowView("job_unknown")).toThrow();
await expect(service.review("job_unknown","selection",{})).rejects.toMatchObject({code:"EDITOR_JOB_NOT_FOUND"});
});
it("rejects duplicate episode requests and oversized preparation scope before fetching",async()=>{
const service=new EditorService({cms:new CmsConnection({publishableKey:""}),runtimeRoot:"/unused/editor-tests",sessionId:"ses_test"});
for(const numbers of [[1,1],[1,2,3,4,5,6],[]])await expect(service.prepare({dramaId:"06aa396d-175a-48ed-ad76-02786ef4d0a9",episodeNumbers:numbers},new AbortController().signal)).rejects.toThrow();
});
});
@@ -0,0 +1,9 @@
import {it,expect} from "vitest";
import {cutSubtitles} from "../src/media/editor-subtitles.js";
it("maps original English cues to the selected timeline without invented text",()=>{
const result=cutSubtitles([{subtitle:"WEBVTT\n\n00:00:02.000 --> 00:00:04.000\nA &amp; B.\n\n00:00:07.000 --> 00:00:09.000\nDo not clip this line.\n",sourceStart:1,sourceEnd:8,targetStart:0,segmentId:"one"}]);
expect(result.srt).toContain("00:00:01,000 --> 00:00:03,000\nA & B.");
expect(result.boundaryWarnings).toEqual(["one"]);
expect(result.srt).toContain("00:00:06,000 --> 00:00:07,000");
expect(()=>cutSubtitles([{subtitle:"invalid",sourceStart:0,sourceEnd:10,targetStart:0,segmentId:"one"}])).toThrow();
});
@@ -0,0 +1,19 @@
import { describe, expect, it } from "vitest";
import { decideToolPolicy } from "../src/tools/tool-policy.js";
describe("editor content approval", () => {
it("requires approval before exposing licensed CMS content to the model", () => {
expect(decideToolPolicy("cms_connection_status")).toBe("allow");
expect(decideToolPolicy("editor_job_status")).toBe("allow");
expect(decideToolPolicy("submit_drama_analysis")).toBe("allow");
expect(decideToolPolicy("propose_drama_selection")).toBe("allow");
expect(decideToolPolicy("submit_editor_plan")).toBe("allow");
expect(decideToolPolicy("render_editor_preview")).toBe("ask");
expect(decideToolPolicy("approve_editor_review")).toBe("deny");
expect(decideToolPolicy("prepare_drama_sources")).toBe("ask");
expect(decideToolPolicy("read_drama_frames")).toBe("ask");
for (const name of ["cms_list_dramas", "cms_drama_inventory", "cms_read_subtitle"]) expect(decideToolPolicy(name)).toBe("ask");
expect(decideToolPolicy("cms_login")).toBe("deny");
expect(decideToolPolicy("render_exact")).toBe("deny");
});
});
@@ -0,0 +1,56 @@
import {it,expect} from "vitest";
import {mkdtemp,rm,writeFile,readFile} from "node:fs/promises";
import {tmpdir,homedir} from "node:os";
import {join} from "node:path";
import {createHash} from "node:crypto";
import {EditorWorkflow} from "../src/media/editor-workflow.js";
import {SkillRunner} from "../src/media/skill-runner.js";
import {analysisFixture} from "./fixtures/editor-analysis.js";
import {planFixture} from "./fixtures/editor-plan.js";
import {mediaRuntime,runMedia} from "../src/media/media-worker.js";
it("requires source review before accepting a plan and never treats model PASS as human review",async()=>{
const root=await mkdtemp(join(tmpdir(),"workflow-"));
try{
await writeFile(join(root,"target-drama-analysis.json"),JSON.stringify({withheld_information:["The answer"]}));
const workflow=new EditorWorkflow({root,sessionId:"ses_test",jobId:"job_test",analysisId:"analysis_test",dramaId:"drama_test",sources:[{episodeId:"ep_test",episodeNo:1,source:join(root,"source.mp4"),duration:20,sha256:"abc",subtitle:"WEBVTT"}],runner:new SkillRunner({skillRoot:"/unused",pythonExecutable:"/unused"})});
await expect(workflow.submitPlan({},new AbortController().signal)).rejects.toMatchObject({code:"SELECTION_REVIEW_REQUIRED"});
const selected=await workflow.proposeSelection({segments:[{id:"one",episodeId:"ep_test",start:0,end:10,reason:"A complete question and reaction"}]});
expect(selected.selection?.reviewed).toBe(false);
expect(JSON.stringify(selected)).not.toContain(root);
await expect(workflow.review("selection",{planHash:selected.selection?.hash,pass:true})).rejects.toThrow();
await expect(workflow.render(new AbortController().signal)).rejects.toMatchObject({code:"PLAN_REVIEW_REQUIRED"});
await expect(workflow.proposeSelection({segments:[{id:"one",episodeId:"foreign",start:0,end:10,reason:"Invalid source"}]})).rejects.toMatchObject({code:"SELECTION_INVALID"});
}finally{await rm(root,{recursive:true,force:true});}
});
it("binds human-reviewed selection to original validators and renders only a REVIEW artifact",async()=>{
const root=await mkdtemp(join(tmpdir(),"workflow-render-"));const signal=new AbortController().signal;
try{
const runtime=await mediaRuntime();const source=join(root,"source.mp4");
await runMedia(runtime.ffmpeg,["-v","error","-f","lavfi","-i","testsrc2=size=180x320:rate=24","-f","lavfi","-i","sine=frequency=440:sample_rate=48000","-t","11","-c:v","libx264","-c:a","aac",source],root,signal);
await writeFile(join(root,"target-drama-analysis.json"),JSON.stringify(analysisFixture()));
const original=await readFile(source);
const workflow=new EditorWorkflow({root,sessionId:"ses_test",jobId:"job_test",analysisId:"analysis_fixture",dramaId:"drama_fixture",sources:[{episodeId:"ep_test",episodeNo:1,source,duration:11,sha256:createHash("sha256").update(original).digest("hex"),subtitle:"WEBVTT\n\n00:00:01.000 --> 00:00:04.000\nSynthetic test caption.\n"}],runner:new SkillRunner({skillRoot:join(homedir(),".codex/skills/swads-short-drama-editor"),pythonExecutable:join(homedir(),".cache/codex-runtimes/codex-primary-runtime/dependencies/python/bin/python3")})});
const selected=await workflow.proposeSelection({segments:[{id:"one",episodeId:"ep_test",start:0,end:10,reason:"Synthetic complete test sequence"}]});
const evidence="Synthetic engineering review; not a licensed drama clearance.";
await workflow.review("selection",{planHash:selected.selection!.hash,reviewer:"Test fixture",policyCheckedAt:new Date().toISOString(),policyPageUpdated:"Test fixture policy",pictureAudioReviewed:true,noUnresolvedFindings:true,segmentEvidence:[{segmentId:"one",pictureEvidence:evidence,audioEvidence:evidence,subtitleEvidence:evidence}]});
const unverified=planFixture();unverified.referenceCandidates.candidates[0]!.evidence.metrics={views:0,likes:0};
const pendingReference=await workflow.submitPlan(unverified,signal);
expect(pendingReference.pass).toBe(true);
await expect(workflow.review("plan",{planHash:workflow.view().plan!.hash,reviewer:"Test fixture",policyCheckedAt:new Date().toISOString(),pictureAudioReviewed:true,referenceReviewed:true,storyReviewed:true,packagingReviewed:true,noUnresolvedFindings:true,segmentEvidence:[{segmentId:"one",observation:evidence}]})).rejects.toMatchObject({code:"VERIFIED_REFERENCE_REQUIRED"});
const result=await workflow.submitPlan(planFixture(),signal);
expect(result.pass).toBe(true);
const plan=workflow.view().plan!;
await expect(workflow.render(signal)).rejects.toMatchObject({code:"PLAN_REVIEW_REQUIRED"});
await workflow.review("plan",{planHash:plan.hash,reviewer:"Test fixture",policyCheckedAt:new Date().toISOString(),pictureAudioReviewed:true,referenceReviewed:true,storyReviewed:true,packagingReviewed:true,noUnresolvedFindings:true,segmentEvidence:[{segmentId:"one",observation:evidence}]});
await writeFile(source,"changed after review");
await expect(workflow.render(signal)).rejects.toMatchObject({code:"REVIEWED_SOURCE_CHANGED"});
await writeFile(source,original);
const rendered=await workflow.render(signal);
expect(rendered.preview?.duration).toBeCloseTo(10,1);expect(rendered.preview?.reviewStatus).toBe("REVIEW");
expect(JSON.stringify(rendered)).not.toContain(root);
const bytes=await readFile(workflow.previewPath(plan.id));expect(bytes.byteLength).toBeGreaterThan(1000);
expect(rendered.humanReviewPassed).toBe(false);
}finally{await rm(root,{recursive:true,force:true});}
},60_000);
+9
View File
@@ -0,0 +1,9 @@
// Synthetic engineering fixture, never used as a real drama diagnosis.
export function analysisFixture(){return {
schema_version:"2.0",analysis_id:"analysis_fixture",analysis_status:"ready",
drama:{drama_id:"drama_fixture",title:"Synthetic validation fixture",synopsis:"Test-only conflict with an unresolved question.",tags:["Test"],episode_count:3},cms_source:{fetched_at:"2026-09-14T00:00:00Z"},
genre:{primary:"family evidence dispute mystery",secondary:["family conflict"],anti_genres:["comedy","romantic payoff"],reason:"Synthetic test only",evidence:[{source_type:"synopsis",claim:"Fixture synopsis"},{source_type:"subtitle",claim:"Fixture subtitle"},{source_type:"frame",claim:"Fixture frame"}]},
core_promise:"Resolve the fixture dispute",primary_hook:{question:"Who left the test note?",promise:"An unresolved dispute"},relationship_conflict:{protagonist:"A",opposition:"B",surface_conflict:"A note",deeper_conflict:"Trust"},character_engine:{misery:["Uncertainty"],power:["Search"],desire:"An answer"},emotional_pattern:["doubt","discovery","uncertainty"],
available_story_beats:[1,2,3].map(episode=>({episode,time_ranges:["00:00:00-00:00:10"],beat:"Synthetic test"})),
evidence:{synopsis_used:true,synopsis_source:"Fixture",subtitle_evidence:["opening","middle","late"].map((story_position,index)=>({episode:index+1,story_position,time_range:"00:00:00-00:00:10",claim:"Synthetic"})),frame_evidence:[{episode:1,time_range:"00:00:01",claim:"Synthetic pattern"},{episode:2,time_range:"00:00:02",claim:"Synthetic pattern"}],conflicts:[],conflicts_resolved:true},must_explain:["The note"],withheld_information:["The author"],confidence:0.8,
};}
+15
View File
@@ -0,0 +1,15 @@
// Synthetic data for isolated engineering tests only. Not production reference evidence.
export function planFixture(){
const binding={analysis_id:"analysis_fixture",drama_id:"drama_fixture"};
const shared={target_binding:binding,withheld_information:["The author"]};
const questions=["Who left this test note?","Why was the test note hidden?","Will the note resolve this dispute?","How will they find the author?"];
return {
recipe:{schema_version:"2.0",...binding,duration_strategy:"content_led",withheld_information:shared.withheld_information,reference_id:"ref_fixture",reference_evidence_tier:"A",ending:{cutoff_reason:"Synthetic ending with reaction",before_withheld_reveal:true},segments:[{id:"one",hook_id:"hook_1",episode_id:"ep_test",episode:1,source_start:0,source_end:10,target_start:0,target_end:10,semantic_role:"Synthetic question",continuity_group:"scene",source_gap_before:0,confidence:"high",reveal_risk:"low",cut_reason:"Test unit complete"}]},
hookMap:{...shared,mainline_question:questions[0],beats:["beat","phase","mainline"].map((hook_level,index)=>({id:`hook_${String(index+1)}`,target_start:index*3,target_end:index===2?10:(index+1)*3,hook_level,audience_question_before:questions[index],information_revealed:["A test clue"],revealed_categories:["evidence"],question_resolved:"A clue exists",new_question:questions[index+1],expectation_a:null,actual_b:null,hook_c:null,withheld_information:shared.withheld_information,evidence:"Synthetic span"})),phase_hooks:[{id:"phase",beat_ids:["hook_1","hook_2"],stage_payoff:"The clue was found",escalated_question:questions[2]}],final_hook:{beat_id:"hook_3",cutoff_after_complete_action:true,reaction_retained:true,answer_withheld:true},final_unanswered_question:questions[3]},
strategy:{...shared,narrative_structure:{mode:"linear",reason:"Synthetic causal sequence for engineering verification"},opening:{mode:"original_footage",voiceover_authorized:false},first_30_seconds:{central_promise:"Test clue"},character_amplification:{primary:"power",evidence:["Fixture"]},event_density:{major_events:["Test clue"]}},
referenceCandidates:{candidates:[{id:"ref_fixture",platform:"Synthetic",reference_type:"short_drama_ad",origin_name:"Test-only publisher",origin_evidence_url:"https://example.com/title",playable_url:"https://example.com/video",evidence_url:"https://example.com/evidence",captured_at:new Date().toISOString(),full_video_access:true,evidence:{metrics:{views:10,likes:1}},genre_match:1,hook_match:1,replicability:1,market_recency:1}]},
referenceAnalysis:{...shared,reference_id:"ref_fixture",structure:"Synthetic question then partial answer"},
referenceRecipe:{...shared,reference_id:"ref_fixture",beats:[{role:"Synthetic question"}]},
continuity:{...shared,checks:["Synthetic continuous shot"],findings:[]},
};
}
+25
View File
@@ -0,0 +1,25 @@
import { describe, expect, it } from "vitest";
import { parseHls, resolveHlsAsset } from "../src/media/hls.js";
describe("bounded HLS inputs", () => {
it("preserves CDN-issued per-path signatures without accepting unrelated parameters", () => {
const base = new URL("https://cdn.example/episode/master.m3u8?auth_key=123-0-0-abcdef");
expect(resolveHlsAsset(base, "child.m3u8?auth_key=124-0-0-123abc", "/episode/").search).toBe("?auth_key=124-0-0-123abc");
for (const uri of ["child.m3u8?auth_key=124-0-0-123abc&token=secret", "child.m3u8?auth_key=oops", "child.m3u8?auth_key=124-0-0-123abc&auth_key=124-0-0-123abc"]) expect(() => resolveHlsAsset(base, uri, "/episode/")).toThrow();
});
it("parses finite TS playlists and preserves exact durations", () => {
expect(parseHls("#EXTM3U\n#EXTINF:4.5,\none.ts\n#EXTINF:2,\ntwo.ts\n#EXT-X-ENDLIST\n")).toEqual({kind:"media",segments:[{uri:"one.ts",duration:4.5},{uri:"two.ts",duration:2}],duration:6.5});
});
it("rejects incomplete, encrypted and unsupported source structures", () => {
for (const text of ["not hls", "#EXTM3U\n#EXTINF:3,\none.ts", "#EXTM3U\n#EXT-X-KEY:METHOD=AES-128\n#EXTINF:3,\none.ts\n#EXT-X-ENDLIST", "#EXTM3U\n#EXT-X-MAP:URI=init.mp4\n#EXTINF:3,\none.m4s\n#EXT-X-ENDLIST", "#EXTM3U\n#EXTINF:-3,\none.ts\n#EXT-X-ENDLIST"]) expect(()=>parseHls(text)).toThrow();
});
it("recognizes a simple master but refuses separate audio renditions", () => {
expect(parseHls("#EXTM3U\n#EXT-X-STREAM-INF:BANDWIDTH=400000\nlow/list.m3u8")).toEqual({kind:"master",variants:["low/list.m3u8"]});
expect(()=>parseHls('#EXTM3U\n#EXT-X-MEDIA:TYPE=AUDIO,URI="audio.m3u8"\n#EXT-X-STREAM-INF:BANDWIDTH=1\nvideo.m3u8')).toThrow();
});
it("keeps signing parameters only within the signed episode's path and origin", () => {
const base = new URL("https://cdn.example/video/episode/master.m3u8?auth_key=fixture");
expect(resolveHlsAsset(base,"low/0001.ts",base.pathname.slice(0,base.pathname.lastIndexOf('/')+1)).href).toBe("https://cdn.example/video/episode/low/0001.ts?auth_key=fixture");
for (const uri of ["https://attacker.example/a.ts","../other.ts","file:///tmp/a.ts","//127.0.0.1/a.ts","%2e%2e/other.ts","one.ts?token=other"]) expect(()=>resolveHlsAsset(base,uri,"/video/episode/")).toThrow();
});
});
@@ -0,0 +1,37 @@
import { expect, it } from "vitest";
import { mkdtemp, readFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import * as media from "../src/image-video/media.js";
import { mediaRuntime, runMedia } from "../src/media/media-worker.js";
it("trims H3's small frame overrun to the requested duration at original speed, preserving the source and rejecting short or excessive output", async () => {
const directory = await mkdtemp(join(tmpdir(), "i2v-duration-"));
const { ffmpeg, ffprobe } = await mediaRuntime();
const signal = new AbortController().signal;
const make = async (name: string, seconds: string) => {
const path = join(directory, name);
await runMedia(ffmpeg, ["-v", "error", "-f", "lavfi", "-i", "testsrc2=size=90x160:rate=24", "-f", "lavfi", "-i", "sine=frequency=440:sample_rate=32000", "-t", seconds, "-c:v", "libx264", "-preset", "ultrafast", "-c:a", "aac", "-n", path], directory, signal);
return path;
};
const source = await make("original.mp4", "1.125");
const original = await readFile(source);
const output = await media.conformImageVideoDuration(source, directory, 1);
const probe = JSON.parse(await runMedia(ffprobe, ["-v", "error", "-show_streams", "-of", "json", output], directory, signal)) as { streams: { codec_type: string; duration: string; nb_frames: string; avg_frame_rate: string }[] };
const video = probe.streams.find(s => s.codec_type === "video")!;
expect(Number(video.duration)).toBe(1);
expect(video.nb_frames).toBe("24");
expect(video.avg_frame_rate).toBe("24/1");
expect(Number(probe.streams.find(s => s.codec_type === "audio")!.duration)).toBeCloseTo(1, 2);
expect(await readFile(source)).toEqual(original);
// Actual H3 2-second response is 56 frames (17k+5), not 48 frames.
const gridSource = await make("h3-2s.mp4", "2.333333");
const gridOriginal = await readFile(gridSource);
const gridOutput = await media.conformImageVideoDuration(gridSource, directory, 2);
const gridProbe = JSON.parse(await runMedia(ffprobe, ["-v", "error", "-select_streams", "v:0", "-show_entries", "stream=duration,nb_frames,avg_frame_rate", "-of", "json", gridOutput], directory, signal)) as { streams: { duration: string; nb_frames: string; avg_frame_rate: string }[] };
expect(gridProbe.streams[0]).toMatchObject({ duration: "2.000000", nb_frames: "48", avg_frame_rate: "24/1" });
expect(await readFile(gridSource)).toEqual(gridOriginal);
await expect(media.conformImageVideoDuration(output, directory, 1)).resolves.toBe(output);
await expect(media.conformImageVideoDuration(await make("short.mp4", "0.875"), directory, 1)).rejects.toBeDefined();
await expect(media.conformImageVideoDuration(await make("long.mp4", "1.5"), directory, 1)).rejects.toBeDefined();
}, 30_000);
@@ -0,0 +1,107 @@
import { afterEach, expect, it, vi } from "vitest";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { ImageVideoMcpRemote, imageVideoResponse } from "../src/image-video/mcp.js";
import type { ImageVideoRecord } from "../src/image-video/contracts.js";
import { mkdtemp, mkdir, readdir, readFile, stat } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
vi.mock("node:dns/promises", () => ({ lookup: () => Promise.resolve([{ address: "8.8.8.8", family: 4 }]) }));
afterEach(() => vi.restoreAllMocks());
it("accepts the live reused-image receipt only when hash, type and size match the frozen reference", async () => {
vi.spyOn(Client.prototype, "connect").mockResolvedValue();
vi.spyOn(Client.prototype, "listTools").mockResolvedValue({ tools: ["swads_whoami", "creative_upload_from_url", "creative_import_status", "video_h3_submit", "video_job_status"].map(name => ({ name, inputSchema: { type: "object", properties: Object.fromEntries(["tenant_id", "platform", "platforms", "media_kind", "review_status", "asset_role", "ratio", "prompt", "file_name", "content_type", "size_bytes", "content_sha256"].map(key => [key, {}])) } })) });
const file = { file_id: "file_1", filename: "i2v_test.jpeg", stored_path: "https://cdn.example/image.jpeg", object_key: "image.jpeg", asset_url: "https://cdn.example/image.jpeg", mime_type: "image/jpeg", size_bytes: 9, attachment_kind: "image", creative_factory_asset_id: "existing_asset", creative_factory_request_id: "request_1", asset_metadata: { content_sha256: "a".repeat(64) } };
let reusedFile: unknown = file;
vi.spyOn(Client.prototype, "callTool").mockImplementation(request => Promise.resolve({ content: [], structuredContent: request.name === "swads_whoami" ? { access: { current_account: { tenant_id: "tenant", platform: "tiktok" } } } : { upload_id: "du-reuse-1", object_key: "image.jpeg", upload_url: "", public_url: "https://cdn.example/image.jpeg", method: "PUT", headers: {}, expires_in_seconds: 0, reused: true, reused_file: reusedFile } }));
const remote = new ImageVideoMcpRemote({ token: "test" });
const record = { id: "i2v_test", identity: { tenantId: "tenant", platform: "tiktok" }, draft: { prompt: "waves" }, referenceType: "image/jpeg", referenceHash: "a".repeat(64) } as ImageVideoRecord;
await expect(remote.initialize(record, Buffer.from("reference"))).resolves.toEqual({ reused: true, assetId: "existing_asset" });
for (const changed of [{ asset_metadata: { content_sha256: "b".repeat(64) } }, { mime_type: "image/png" }, { size_bytes: 10 }, { creative_factory_asset_id: "" }]) {
reusedFile = { ...file, ...changed };
await expect(remote.initialize(record, Buffer.from("reference"))).rejects.toBeDefined();
}
});
it("collects the registered job's storage_url MP4 and ignores JSON artifacts, rejecting foreign or conflicting receipts", async () => {
vi.spyOn(Client.prototype, "connect").mockResolvedValue();
vi.spyOn(Client.prototype, "listTools").mockResolvedValue({ tools: ["swads_whoami", "creative_upload_from_url", "creative_import_status", "video_h3_submit", "video_job_status"].map(name => ({ name, inputSchema: { type: "object", properties: { job_id: {} } } })) });
const video = { artifact_id: "artifact_video", job_id: "job_1", org_id: "org", asset_id: null, version_id: null, artifact_kind: "video", content_type: "video/mp4", storage_url: "https://cdn.example/video.mp4", object_key: "video.mp4", json_payload: null, metadata: {}, created_at: "2026-09-15T00:00:00Z" };
let result: Record<string, unknown> = { job_id: "job_1", status: "succeeded", artifacts: [video, { ...video, artifact_id: "artifact_json", artifact_kind: "metadata", content_type: "application/json", storage_url: null, object_key: null, json_payload: { provider: "h3" } }] };
vi.spyOn(Client.prototype, "callTool").mockImplementation(request => Promise.resolve({ content: [], structuredContent: request.name === "swads_whoami" ? { access: { current_account: { tenant_id: "tenant", platform: "tiktok" } } } : result }));
const remote = new ImageVideoMcpRemote({ token: "test" });
const record = { jobId: "job_1" } as ImageVideoRecord;
await expect(remote.status(record)).resolves.toEqual({ state: "succeeded", artifactUrl: "https://cdn.example/video.mp4" });
result = { job_id: "job_1", status: "succeeded", artifacts: [{ ...video, job_id: "job_other" }] };
await expect(remote.status(record)).rejects.toBeDefined();
result = { job_id: "job_1", status: "succeeded", artifacts: [{ ...video, url: "https://cdn.example/other.mp4" }] };
await expect(remote.status(record)).rejects.toBeDefined();
});
it("submits within the gateway key budget and deduplicates the same frozen request without merging different requests", async () => {
vi.spyOn(Client.prototype, "connect").mockResolvedValue();
vi.spyOn(Client.prototype, "listTools").mockResolvedValue({ tools: ["swads_whoami", "creative_upload_from_url", "creative_import_status", "video_h3_submit", "video_job_status"].map(name => ({ name, inputSchema: { type: "object", properties: Object.fromEntries(["prompt", "title", "reference_asset_ids", "aspect_ratio", "duration_seconds", "resolution", "h3_mode", "idempotency_key", "skill_id"].map(key => [key, {}])) } })) });
const jobs = new Map<string, string>();
vi.spyOn(Client.prototype, "callTool").mockImplementation(request => {
if (request.name === "swads_whoami") return Promise.resolve({ content: [], structuredContent: { access: { current_account: { tenant_id: "tenant", platform: "tiktok" } } } });
const key = String(request.arguments?.idempotency_key);
if (key.length > 87) return Promise.resolve({ content: [], structuredContent: { error_code: "invalid_arguments", detail: "idempotency_key must be at most 87 characters" } });
if (!jobs.has(key)) jobs.set(key, `job_${jobs.size + 1}`);
return Promise.resolve({ content: [], structuredContent: { job_id: jobs.get(key) } });
});
const remote = new ImageVideoMcpRemote({ token: "test" });
const record = { id: "i2v_11111111-1111-1111-1111-111111111111", requestHash: "a".repeat(64), draft: { prompt: "waves", title: "test", durationSeconds: 10 }, assetId: "asset_1" } as ImageVideoRecord;
await expect(remote.submit(record)).resolves.toEqual({ jobId: "job_1" });
await expect(remote.submit(record)).resolves.toEqual({ jobId: "job_1" });
await expect(remote.submit({ ...record, requestHash: "b".repeat(64) })).resolves.toEqual({ jobId: "job_2" });
await expect(remote.submit({ ...record, id: "i2v_22222222-2222-2222-2222-222222222222" })).resolves.toEqual({ jobId: "job_3" });
});
it("uploads with the frozen prompt and ratio required by the live begin/finish contract", async () => {
vi.spyOn(Client.prototype, "connect").mockResolvedValue();
const properties = Object.fromEntries(["tenant_id", "platform", "platforms", "media_kind", "review_status", "asset_role", "ratio", "prompt", "file_name", "content_type", "size_bytes", "content_sha256", "upload_id", "object_key"].map(key => [key, {}]));
vi.spyOn(Client.prototype, "listTools").mockResolvedValue({ tools: ["swads_whoami", "creative_upload_from_url", "creative_import_status", "video_h3_submit", "video_job_status"].map(name => ({ name, inputSchema: { type: "object", properties } })) });
vi.spyOn(Client.prototype, "callTool").mockImplementation(request => {
const args = request.arguments;
const result = request.name === "swads_whoami" ? { access: { current_account: { tenant_id: "tenant", platform: "tiktok" } } }
: args?.ratio !== "9:16" || args.prompt !== "Subtle waves and breeze" ? { error_code: "invalid_arguments", detail: "push begin needs: ratio, prompt" }
: args.file_name !== "i2v_test.jpeg" || args.content_type !== "image/jpeg" || args.size_bytes !== 9 ? { error_code: "invalid_arguments", detail: "push finish needs: file_name, content_type, size_bytes" }
: args.upload_id ? { status: "finalizing" } : { upload_id: "upload_1", object_key: "image.jpeg", upload_url: "https://public.example/put?signature=private" };
return Promise.resolve({ content: [{ type: "text", text: JSON.stringify(result) }] });
});
const remote = new ImageVideoMcpRemote({ token: "test" });
const record = { id: "i2v_test", identity: { tenantId: "tenant", platform: "tiktok" }, draft: { prompt: "Subtle waves and breeze" }, referenceType: "image/jpeg", referenceHash: "a".repeat(64) } as ImageVideoRecord;
const upload = await remote.initialize(record, Buffer.from("reference"));
if ("reused" in upload) throw new Error("Expected a new upload ticket");
expect(upload).toEqual({ uploadId: "upload_1", objectKey: "image.jpeg", uploadUrl: "https://public.example/put?signature=private" });
await expect(remote.finalize({ ...record, upload }, Buffer.from("reference"))).resolves.toBeUndefined();
});
it("recognizes error_code failures even when MCP isError is false without exposing remote details", () => {
expect(() => imageVideoResponse({ isError: false, content: [{ type: "text", text: JSON.stringify({ error_code: "invalid_arguments", detail: "token=private https://private.example/?signature=secret" }) }] })).toThrow(expect.objectContaining({ code: "I2V_REMOTE_REJECTED", message: "SW Ads rejected the request (invalid_arguments)" }));
});
it("retains an unparseable submission response privately before rejecting it, without a second submit", async () => {
const root = await mkdtemp(join(tmpdir(), "i2v-receipt-"));
const id = "i2v_11111111-1111-1111-1111-111111111111";
await mkdir(join(root, id), { mode: 0o700 });
vi.spyOn(Client.prototype, "connect").mockResolvedValue();
vi.spyOn(Client.prototype, "listTools").mockResolvedValue({ tools: ["swads_whoami", "creative_upload_from_url", "creative_import_status", "video_h3_submit", "video_job_status"].map(name => ({ name, inputSchema: { type: "object", properties: Object.fromEntries(["prompt", "title", "reference_asset_ids", "aspect_ratio", "duration_seconds", "resolution", "h3_mode", "idempotency_key", "skill_id"].map(key => [key, {}])) } })) });
let submissions = 0;
const raw = { content: [{ type: "text" as const, text: '{"unexpected_job":{"id":"recoverable_job"},"private_url":"https://public.example/?signature=secret"}' }] };
vi.spyOn(Client.prototype, "callTool").mockImplementation(request => {
if (request.name === "swads_whoami") return Promise.resolve({ structuredContent: { access: { current_account: { tenant_id: "tenant", platform: "tiktok" } } }, content: [] });
submissions++; return Promise.resolve(raw);
});
const remote = new ImageVideoMcpRemote({ token: "private-token", receiptRoot: root });
const record = { id, revision: 4, requestHash: "a".repeat(64), draft: { prompt: "waves", title: "test", durationSeconds: 10 }, assetId: "asset_1" } as ImageVideoRecord;
await expect(remote.submit(record)).rejects.toBeDefined();
const files = await readdir(join(root, id));
expect(files).toHaveLength(1);
const file = join(root, id, files[0]!);
expect(JSON.parse(await readFile(file, "utf8"))).toMatchObject({ id, revision: 4, requestHash: "a".repeat(64), operation: "submit", response: raw });
expect((await stat(file)).mode & 0o777).toBe(0o600);
expect(await readFile(file, "utf8")).not.toContain("private-token");
expect(submissions).toBe(1);
});
@@ -29,6 +29,62 @@ async function fixture() {
}
describe("durable image video tasks", () => {
it("continues an orphaned uploaded task locally without losing receipts or automatically submitting", async () => {
const f = await fixture(); const task = await f.service.create("ses_owner", f.draft);
await f.service.upload("ses_owner", task.id); await f.service.refresh(task.id);
const path = join(f.options.root, task.id, "record.json");
const before = JSON.parse(await readFile(path, "utf8")) as ImageVideoRecord;
f.calls.length = 0;
const restored = new harness.ImageVideoService(f.options);
const result = await restored.continueTask("ses_new", task.id, before.revision, () => false, () => {});
expect(result).toMatchObject({ id: task.id, state: "awaiting_submission", revision: before.revision + 1 });
expect(f.calls).toEqual([]);
const after = JSON.parse(await readFile(path, "utf8")) as ImageVideoRecord;
expect(after).toMatchObject({ ownerSessionId: "ses_new", draft: before.draft, referenceHash: before.referenceHash, requestHash: before.requestHash, upload: before.upload, assetId: "asset_1" });
expect(after.continuations).toHaveLength(1);
expect(after.continuations?.[0]).toMatchObject({ fromSessionId: "ses_owner", toSessionId: "ses_new", revision: before.revision });
expect(Number.isFinite(Date.parse(after.continuations?.[0]?.at ?? ""))).toBe(true);
await expect(restored.owned("ses_owner", task.id)).rejects.toMatchObject({ code: "I2V_TASK_NOT_FOUND" });
expect((await restored.owned("ses_new", task.id)).id).toBe(task.id);
await restored.submit("ses_new", task.id);
expect(f.calls).toEqual(["identity", "submit"]);
expect(JSON.stringify(result)).not.toMatch(/ownerSessionId|continuations|uploadUrl/);
});
it("rejects active-owner takeover, stale confirmation, uncertain state and concurrent continuation", async () => {
const f = await fixture(); const task = await f.service.create("ses_owner", f.draft);
await expect(f.service.continueTask("ses_new", task.id, 0, () => true, () => {})).rejects.toMatchObject({ code: "I2V_OWNER_ACTIVE" });
await expect(f.service.continueTask("ses_new", task.id, 99, () => false, () => {})).rejects.toMatchObject({ code: "I2V_STALE_REVISION" });
const attempts = await Promise.allSettled([f.service.continueTask("ses_new", task.id, 0, () => false, () => {}), f.service.continueTask("ses_other", task.id, 0, () => false, () => {})]);
expect(attempts.map(a => a.status)).toEqual(["fulfilled", "rejected"]);
const path = join(f.options.root, task.id, "record.json");
const record = JSON.parse(await readFile(path, "utf8")) as ImageVideoRecord;
for (const state of ["unknown_upload", "unknown_submission", "failed", "queued"] as const) {
await writeFile(path, JSON.stringify({ ...record, state }));
await expect(f.service.continueTask("ses_new", task.id, record.revision, () => false, () => {})).rejects.toMatchObject({ code: "I2V_CONTINUE_BLOCKED" });
expect((JSON.parse(await readFile(path, "utf8")) as ImageVideoRecord).state).toBe(state);
}
expect(f.calls).toEqual([]);
});
it("reuses a verified existing image without PUT, finalize or automatic generation and persists it across restart", async () => {
const f = await fixture(); const task = await f.service.create("ses_owner", f.draft);
f.remote.initialize = () => { f.calls.push("initialize"); return Promise.resolve({ reused: true as const, assetId: "existing_asset" }); };
expect((await f.service.upload("ses_owner", task.id)).state).toBe("awaiting_submission");
expect(f.calls).toEqual(["identity", "initialize"]);
const restored = new harness.ImageVideoService(f.options);
expect((await restored.view(task.id)).state).toBe("awaiting_submission");
expect((await restored.submit("ses_owner", task.id)).jobId).toBe("remote_job_1");
expect(f.calls).not.toContain("put"); expect(f.calls).not.toContain("finalize");
});
it("persists a sanitized failure stage while keeping rejected uploads quarantined", async () => {
const f = await fixture(); const task = await f.service.create("ses_owner", f.draft);
f.remote.initialize = () => Promise.reject(new harness.HarnessError("I2V_REMOTE_REJECTED", "secret signed URL"));
const result = await f.service.upload("ses_owner", task.id);
expect(result).toMatchObject({ state: "unknown_upload" });
expect(result.error).toContain("initializing_upload/I2V_REMOTE_REJECTED");
expect(result.error).not.toContain("secret");
expect((await new harness.ImageVideoService(f.options).view(task.id)).error).toBe(result.error);
expect(f.calls).not.toContain("put");
});
it("copies a local reference without transmitting it and hides private bindings", async () => {
const f = await fixture(); const task = await f.service.create("ses_owner", f.draft);
expect(f.calls).toEqual([]); expect(task.state).toBe("draft");
@@ -65,6 +121,16 @@ describe("durable image video tasks", () => {
record.state = "submitting"; await writeFile(file, JSON.stringify(record));
expect((await new harness.ImageVideoService(f.options).view(task.id)).state).toBe("unknown_submission");
});
it("records a typed submission rejection without exposing provider details or retrying", async () => {
const f = await fixture(); const task = await f.service.create("ses_owner", f.draft);
await f.service.upload("ses_owner", task.id); await f.service.refresh(task.id);
f.remote.submit = () => Promise.reject(new harness.HarnessError("I2V_REMOTE_REJECTED", "secret upstream detail"));
const result = await f.service.submit("ses_owner", task.id);
expect(result.error).toContain("submitting/I2V_REMOTE_REJECTED");
expect(result.error).not.toContain("secret");
expect(result.state).toBe("unknown_submission");
await expect(f.service.submit("ses_owner", task.id)).rejects.toMatchObject({ code: "I2V_UNKNOWN_SUBMISSION" });
});
it("never reinitializes uncertain uploads and blocks stale persisted revisions", async () => {
const f = await fixture(); const task = await f.service.create("ses_owner", f.draft);
f.remote.initialize = () => { f.calls.push("initialize"); return Promise.reject(new Error("timeout")); };
@@ -0,0 +1,28 @@
import { afterEach, describe, expect, it } from "vitest";
import { mkdtemp, readFile, rm } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { downloadHls } from "../src/media/media-worker.js";
const roots:string[]=[];
afterEach(async()=>{for(const root of roots.splice(0)) await rm(root,{recursive:true,force:true});});
describe("private source download",()=>{
it("localizes a signed VOD playlist without leaking credentials to disk",async()=>{
const root=await mkdtemp(join(tmpdir(),"hls-test-"));roots.push(root);
const requests:URL[]=[];
const fetcher:typeof fetch=(input,init)=>{
const url=new URL(typeof input==="string"?input:input instanceof URL?input.href:input.url);requests.push(url);
expect(init?.redirect).toBe("error");expect(new Headers(init?.headers).has("authorization")).toBe(false);
return Promise.resolve(new Response(url.pathname.endsWith(".m3u8")?"#EXTM3U\n#EXTINF:2,\n0.ts\n#EXT-X-ENDLIST\n":new Uint8Array([0x47,0,0,0])));
};
const result=await downloadHls(new URL("https://video.pencil-stub.com/episode/master.m3u8?auth_key=private"),root,new AbortController().signal,{remaining:1024},fetcher);
expect(result.duration).toBe(2);
const playlist=await readFile(result.path,"utf8");
expect(playlist).toContain("segment-0.ts");expect(playlist).not.toMatch(/private|https:|auth_key/);
expect(requests.every(url=>url.origin==="https://video.pencil-stub.com"&&url.search==="?auth_key=private")).toBe(true);
});
it("enforces byte quota before persisting oversized segments",async()=>{
const root=await mkdtemp(join(tmpdir(),"hls-test-"));roots.push(root);
await expect(downloadHls(new URL("https://video.pencil-stub.com/e/a.m3u8"),root,new AbortController().signal,{remaining:1},()=>Promise.resolve(new Response("#EXTM3U\n#EXTINF:2,\na.ts\n#EXT-X-ENDLIST")))).rejects.toMatchObject({code:"MEDIA_BYTE_LIMIT"});
});
});
@@ -0,0 +1,34 @@
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import * as harness from "../src/index.js";
const roots: string[] = [];
afterEach(async () => { for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true }); });
describe("Minis report service", () => {
it("requires captured query evidence and returns real HTML with an explicit PDF failure", async () => {
const root = await mkdtemp(join(tmpdir(), "minis-report-test-")); roots.push(root);
const skill = join(root, "skill");
await mkdir(join(skill, "scripts"), { recursive: true });
await writeFile(join(skill, "scripts/generate_report.py"), 'import sys,json,html\nfrom pathlib import Path\nd=json.loads(Path(sys.argv[1]).read_text())\nPath(sys.argv[2]).write_text("<!doctype html><h1>"+html.escape(d["title"])+"</h1>")\n');
const Constructor = Reflect.get(harness, "MinisReportService");
expect(Constructor).toBeDefined();
const service = new Constructor({ workspace: await harness.SafeWorkspace.create(root), sessionId: "ses_test", skillRoot: skill, chromeExecutable: "/missing/chrome", pythonExecutable: "/usr/bin/python3" });
const data = { title: "渲染验收(人工测试数据)", report_type: "daily", period: "2026-09-01", comparison_period: "2026-08-31", currency: "USD", timezone: "UTC", data_status: "测试", conclusion: "仅测试排版,不是经营数据", cards: [], issues: [] };
await expect(service.generate({ data, source_query_ids: [] })).rejects.toThrow("query");
const queryId = service.recordQuery({ date_from: "2026-09-01" }, { content: [{ type: "text", text: "test-only" }] });
await expect(service.generate({ data, source_query_ids: ["unknown"] })).rejects.toThrow("query");
const output = await service.generate({ data, source_query_ids: [queryId] });
expect(output.pdfStatus).toBe("failed");
expect(output.files.map((f: {format: string}) => f.format)).toEqual(["json", "html"]);
expect(output.files[1]?.url).toMatch(/^\/api\/sessions\/ses_test\/reports\/rpt_[\w-]+\/html$/);
const html = await service.read(output.reportId, "html");
expect(html.bytes.toString()).toContain("人工测试数据");
await expect(service.read(output.reportId, "../../.env")).rejects.toThrow();
const json = JSON.parse((await service.read(output.reportId, "json")).bytes.toString()) as { source_queries: Array<{queryId: string}> };
expect(json.source_queries[0]?.queryId).toBe(queryId);
expect(await readFile(join(skill, "scripts/generate_report.py"), "utf8")).not.toContain("SWADS_MCP_TOKEN");
});
});
@@ -2,6 +2,45 @@ import { describe, expect, it, vi } from "vitest";
import * as harness from "../src/index.js";
describe("PiEventAdapter", () => {
it.each([
{ stopReason: "stop", content: [], code: "MODEL_EMPTY_RESPONSE" },
{ stopReason: "stop", content: [{ type: "thinking", thinking: "private reasoning" }], code: "MODEL_EMPTY_RESPONSE" },
{ stopReason: "length", content: [{ type: "text", text: "partial output" }], code: "MODEL_OUTPUT_LIMIT" },
])("rejects incomplete assistant replies ($stopReason, $code)", async ({ stopReason, content, code }) => {
const store = new harness.InMemoryEventStore();
const adapter = new harness.PiEventAdapter({ sessionId: "ses_1", runId: "run_1", eventStore: store });
await expect(adapter.observe({ type: "message_end", message: { role: "assistant", stopReason, content } })).rejects.toMatchObject({ code });
expect(store.listAfter("ses_1").events).toHaveLength(0);
});
it("keeps licensed frame bytes and subtitles out of event history", async () => {
const store = new harness.InMemoryEventStore();
const adapter = new harness.PiEventAdapter({ sessionId: "ses_1", runId: "run_1", eventStore: store });
await adapter.observe({ type: "tool_execution_end", toolName: "read_drama_frames", toolCallId: "call_1", isError: false,
result: { content: [{ type: "image", data: "PRIVATE_IMAGE_BYTES", mimeType: "image/jpeg" }, { type: "text", text: "PRIVATE_SUBTITLES" }], details: { jobId: "job_1", imageCount: 9 } } });
expect(store.listAfter("ses_1").events[0]?.payload).toMatchObject({ result: { jobId: "job_1", imageCount: 9 } });
expect(JSON.stringify(store.listAfter("ses_1"))).not.toContain("PRIVATE_");
});
it("does not announce completion between tool turns or turn provider errors into success", async () => {
const store = new harness.InMemoryEventStore();
const adapter = new harness.PiEventAdapter({ sessionId: "ses_1", runId: "run_1", eventStore: store });
await adapter.observe({ type: "message_end", message: { role: "assistant", stopReason: "toolUse" } });
expect(store.listAfter("ses_1").events).toHaveLength(0);
await expect(adapter.observe({ type: "message_end", message: { role: "assistant", stopReason: "error", errorMessage: "provider secret" } })).rejects.toThrow("Model request failed");
expect(store.listAfter("ses_1").events).toHaveLength(0);
});
it("unwraps real Pi custom-tool details for the metric card", async () => {
const store = new harness.InMemoryEventStore();
const adapter = new harness.PiEventAdapter({ sessionId: "ses_1", runId: "run_1", eventStore: store });
await adapter.observe({
type: "tool_execution_end", toolName: "mock_ads_metrics", toolCallId: "call_1", isError: false,
result: { content: [{ type: "text", text: '{"spend":120,"revenue":360,"roas":3}' }], details: { spend: 120, revenue: 360, roas: 3 } },
});
expect(store.listAfter("ses_1").events[0]?.payload).toEqual({
toolCallId: "call_1", toolName: "mock_ads_metrics", result: { spend: 120, revenue: 360, roas: 3 },
});
});
it("maps assistant deltas and completion with one stable message id", async () => {
const store = new harness.InMemoryEventStore();
const Adapter = Reflect.get(harness, "PiEventAdapter") as unknown as new (options: Record<string, unknown>) => {
@@ -0,0 +1,22 @@
import {describe,it,expect} from "vitest";
import {bindRecipe} from "../src/media/recipe-binding.js";
const source={episodeId:"episode_real",episodeNo:1,source:"/private/authorized/episode.mp4",duration:20};
function recipe(){return {schema_version:"2.0",analysis_id:"analysis_real",drama_id:"drama_real",duration_strategy:"content_led",withheld_information:["An answer"],ending:{cutoff_reason:"Question remains",before_withheld_reveal:true},segments:[{id:"one",hook_id:"hook",episode_id:"episode_real",episode:1,source:"/etc/passwd",source_start:1,source_end:11,target_start:0,target_end:10,semantic_role:"Question",continuity_group:"scene",source_gap_before:0,confidence:"high",reveal_risk:"low",cut_reason:"Complete exchange"}]};}
const binding={analysisId:"analysis_real",dramaId:"drama_real",analysisPath:"/private/job/target-drama-analysis.json",withheldInformation:["An answer"],sources:[source]};
describe("authorized ten-second recipe",()=>{
it("resolves source and analysis paths on the server instead of accepting model-supplied paths",()=>{
const bound=bindRecipe(recipe(),binding);
expect(bound.segments[0]?.source).toBe(source.source);expect(bound.target_analysis_path).toBe(binding.analysisPath);
});
it("rejects wrong identities, wrong episodes, out-of-source ranges and non-frame-aligned cuts",()=>{
const foreign=recipe();foreign.drama_id="other";expect(()=>bindRecipe(foreign,binding)).toThrow();
const episode=recipe();episode.segments[0]!.episode_id="other";expect(()=>bindRecipe(episode,binding)).toThrow();
const range=recipe();range.segments[0]!.source_end=30;expect(()=>bindRecipe(range,binding)).toThrow();
const boundary=recipe();boundary.segments[0]!.source_start=1.01;expect(()=>bindRecipe(boundary,binding)).toThrow();
const reveal=recipe();reveal.withheld_information=["Different answer"];expect(()=>bindRecipe(reveal,binding)).toThrow();
});
it("rejects non-ten-second output and arbitrary subtitle input paths",()=>{
const long=recipe();long.segments[0]!.source_end=12;long.segments[0]!.target_end=11;expect(()=>bindRecipe(long,binding)).toThrow();
expect(()=>bindRecipe({...recipe(),subtitle_path:"/private/secret.srt"},binding)).toThrow();
});
});
@@ -0,0 +1,12 @@
import {describe,it,expect} from "vitest";
import {assertSelectionReview} from "../src/media/review-ledger.js";
const plan={hash:"abc123",segmentIds:["one"]};
const review={planHash:"abc123",reviewer:"Local user",policyCheckedAt:new Date().toISOString(),pictureAudioReviewed:true,referenceReviewed:true,storyReviewed:true,packagingReviewed:true,noUnresolvedFindings:true,segmentEvidence:[{segmentId:"one",observation:"Reviewed the complete selected span with original audio; nonsexual conversation."}]};
describe("selection review gate",()=>{
it("requires every real review and exact immutable plan hash",()=>{
expect(()=>assertSelectionReview(review,plan)).not.toThrow();
for(const field of ["pictureAudioReviewed","referenceReviewed","storyReviewed","packagingReviewed","noUnresolvedFindings"])expect(()=>assertSelectionReview({...review,[field]:false},plan)).toThrow();
expect(()=>assertSelectionReview({...review,planHash:"changed"},plan)).toThrow();
expect(()=>assertSelectionReview({...review,segmentEvidence:[]},plan)).toThrow();
});
});
@@ -0,0 +1,43 @@
import {it,expect} from "vitest";
import {mkdtemp,rm,writeFile} from "node:fs/promises";
import {tmpdir,homedir} from "node:os";
import {join} from "node:path";
import {SkillRunner} from "../src/media/skill-runner.js";
import {mediaRuntime,runMedia} from "../src/media/media-worker.js";
import {analysisFixture} from "./fixtures/editor-analysis.js";
it.each(["analysis","recipe","hook"] as const)("runs the original %s validator and returns its real failures, never a caller PASS flag",async(kind)=>{
const root=await mkdtemp(join(tmpdir(),"original-validator-"));
try{
const runner=new SkillRunner({skillRoot:join(homedir(),".codex/skills/swads-short-drama-editor"),pythonExecutable:join(homedir(),".cache/codex-runtimes/codex-primary-runtime/dependencies/python/bin/python3")});
const input=join(root,"analysis.json");await writeFile(input,JSON.stringify({pass:true}));
const result=await runner.validate(kind,input,root,new AbortController().signal);
expect(result.pass).toBe(false);expect(result.issues.length).toBeGreaterThan(0);
expect(JSON.stringify(result)).not.toContain(root);
}finally{await rm(root,{recursive:true,force:true});}
});
it("keeps references blocked when only unverified evidence is supplied",async()=>{
const root=await mkdtemp(join(tmpdir(),"original-reference-"));
try{
const analysis=join(root,"target-drama-analysis.json");await writeFile(analysis,JSON.stringify(analysisFixture()));
const input=join(root,"candidates.json");await writeFile(input,JSON.stringify({target_analysis_path:analysis,analysis_id:"analysis_fixture",drama_id:"drama_fixture",candidates:[{id:"r1",full_video_access:true,reference_type:"short_drama_ad",origin_name:"Fixture",origin_evidence_url:"https://example.com/title",playable_url:"https://example.com/video",evidence_url:"https://example.com/metrics",platform:"fixture",captured_at:"2026-09-14T00:00:00Z",genre_match:1,hook_match:1,replicability:1,market_recency:1}]}));
const runner=new SkillRunner({skillRoot:join(homedir(),".codex/skills/swads-short-drama-editor"),pythonExecutable:join(homedir(),".cache/codex-runtimes/codex-primary-runtime/dependencies/python/bin/python3")});
const result=await runner.scoreReferences(input,root,new AbortController().signal);
expect(result.status).toBe("blocked_unverified_reference");expect(result.selected_reference).toBeNull();
}finally{await rm(root,{recursive:true,force:true});}
});
it("renders an actual 10-second H264/AAC file using the original renderer and reports actual QA",async()=>{
const root=await mkdtemp(join(tmpdir(),"original-render-"));const signal=new AbortController().signal;
try{
const runtime=await mediaRuntime();const source=join(root,"source.mp4");
await runMedia(runtime.ffmpeg,["-v","error","-f","lavfi","-i","testsrc2=size=180x320:rate=24","-f","lavfi","-i","sine=frequency=440:sample_rate=48000","-t","11","-c:v","libx264","-c:a","aac",source],root,signal);
const analysis=join(root,"target-drama-analysis.json");await writeFile(analysis,JSON.stringify(analysisFixture()));
const recipe=join(root,"recipe.json");await writeFile(recipe,JSON.stringify({schema_version:"2.0",analysis_id:"analysis_fixture",drama_id:"drama_fixture",target_analysis_path:analysis,withheld_information:["The author"],duration_strategy:"content_led",ending:{cutoff_reason:"Test",before_withheld_reveal:true},segments:[{id:"one",hook_id:"fixture",source,episode:1,source_start:0,source_end:10,target_start:0,target_end:10,semantic_role:"Test",continuity_group:"scene",source_gap_before:0,confidence:"high",reveal_risk:"low",cut_reason:"Synthetic test"}]}));
const runner=new SkillRunner({skillRoot:join(homedir(),".codex/skills/swads-short-drama-editor"),pythonExecutable:join(homedir(),".cache/codex-runtimes/codex-primary-runtime/dependencies/python/bin/python3")});
const result=await runner.render(recipe,root,signal);
expect(result.duration).toBeCloseTo(10,1);expect(result.sha256).toMatch(/^[a-f0-9]{64}$/);
expect(result.checks.complete_decode).toBe(true);expect(result.checks.h264_aac).toBe(true);expect(result.checks.portrait_1080x1920).toBe(true);expect(result.reviewStatus).toBe("REVIEW");
}finally{await rm(root,{recursive:true,force:true});}
},60_000);
+56 -1
View File
@@ -1,4 +1,4 @@
import { access, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { access, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { delimiter, join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
@@ -52,6 +52,61 @@ function catalogConstructor(): CatalogConstructor {
}
describe("SkillCatalog", () => {
it("keeps the editor's new content-review reference readable in the isolated Skill snapshot", async () => {
const original = await createSkill(".agents/skills", "swads-short-drama-editor");
const baseDir = join(root, ".agents/skills/swads-short-drama-editor");
await mkdir(join(baseDir, "references"));
await mkdir(join(baseDir, "scripts"));
for (const resource of harness.EDITOR_RESOURCES) await writeFile(join(baseDir, resource), "fixture resource");
await writeFile(join(baseDir, "references/tiktok-adult-content-policy.md"), "fixture selection and final review gate");
const catalog = new harness.SkillCatalog({ cwd: root, agentDir, resources: { [original]: [...harness.EDITOR_RESOURCES] } });
await catalog.reload();
const sessionRoot = join(root, "session");
await mkdir(sessionRoot);
const snapshot = await catalog.createSessionSnapshot(sessionRoot);
const editor = snapshot.skills.find((skill) => skill.name === "swads-short-drama-editor")!;
const workspace = await harness.SafeWorkspace.create(sessionRoot);
const policy = await workspace.resolveReadable(join(editor.baseDir, "references/tiktok-adult-content-policy.md"));
expect(await readFile(policy, "utf8")).toBe("fixture selection and final review gate");
});
it("copies only approved resources with relative links intact and rejects escaped resources", async () => {
const original = await createSkill(".agents/skills", "diagnosis");
const baseDir = join(root, ".agents/skills/diagnosis");
await mkdir(join(baseDir, "references"));
await writeFile(join(baseDir, "references/metrics.md"), "metric contract");
await writeFile(join(baseDir, ".env"), "do not copy");
const catalog = new harness.SkillCatalog({ cwd: root, agentDir,
resources: { [original]: ["references/metrics.md"] },
});
await catalog.reload();
const sessionRoot = join(root, "session");
await mkdir(sessionRoot);
const snapshot = await catalog.createSessionSnapshot(sessionRoot);
const copied = snapshot.skills.find((skill) => skill.name === "diagnosis")!;
expect(await readFile(join(copied.baseDir, "references/metrics.md"), "utf8")).toBe("metric contract");
await expect(access(join(copied.baseDir, ".env"))).rejects.toThrow();
await rm(join(baseDir, "references/metrics.md"));
await symlink(join(baseDir, ".env"), join(baseDir, "references/metrics.md"));
await expect(catalog.createSessionSnapshot(sessionRoot)).rejects.toThrow();
});
it("makes discovered Skill bodies readable inside the Session without admitting project paths", async () => {
const original = await createSkill(".agents/skills", "ads-analysis");
const sessionRoot = join(root, "session");
await mkdir(sessionRoot);
const catalog = new harness.SkillCatalog({ cwd: root, agentDir });
await catalog.reload();
const snapshot = await catalog.createSessionSnapshot(sessionRoot);
const workspace = await harness.SafeWorkspace.create(sessionRoot);
const copied = snapshot.skills.find((skill) => skill.name === "ads-analysis")!;
await expect(workspace.resolveReadable(copied.filePath)).resolves.toBe(copied.filePath);
expect(await readFile(copied.filePath, "utf8")).toContain("Full secret skill body.");
expect(snapshot.index.find(copied.filePath)?.name).toBe("ads-analysis");
expect(snapshot.index.find(original)).toBeUndefined();
await expect(workspace.resolveReadable(original)).rejects.toThrow();
});
it("discovers .agents, .pi, and delimiter-separated extra roots through Pi", async () => {
await createSkill(".agents/skills", "ads-analysis");
await createSkill(".pi/skills", "report-writer");
+38
View File
@@ -0,0 +1,38 @@
import { describe, expect, it } from "vitest";
import * as harness from "../src/index.js";
describe("swads MCP bridge", () => {
it("performs MCP initialization, sends credentials only in headers and blocks non-allowlisted tools", async () => {
const requests: Array<{ method: string; authorization: string | null }> = [];
const fetcher: typeof fetch = async (_url, init) => {
await Promise.resolve();
const headers = new Headers(init?.headers);
if (init?.method === "GET") return new Response(null, { status: 405 });
if (typeof init?.body !== "string") throw new Error("Expected JSON-RPC body");
const message = JSON.parse(init.body) as { method: string; id?: number; params?: { name?: string } };
requests.push({ method: message.method, authorization: headers.get("authorization") });
if (message.id === undefined) return new Response(null, { status: 202 });
const result = message.method === "initialize"
? { protocolVersion: "2025-03-26", capabilities: { tools: {} }, serverInfo: { name: "test", version: "1" } }
: { content: [{ type: "text", text: '{"metric":"spend","value":7}' }], structuredContent: { metric: "spend", value: 7 } };
return Response.json({ jsonrpc: "2.0", id: message.id, result });
};
const Constructor = Reflect.get(harness, "SwadsMcpClient");
expect(Constructor).toBeDefined();
const client = new Constructor({ token: "test-only-token", fetch: fetcher });
await expect(client.call("campaign_delete", {})).rejects.toThrow("not allowed");
expect(requests).toHaveLength(0);
const result = await client.call("metrics_catalog", {});
expect(result.structuredContent).toEqual({ metric: "spend", value: 7 });
expect(requests.filter((r) => r.method === "tools/call")).toEqual([{ method: "tools/call", authorization: "Bearer test-only-token" }]);
expect(JSON.stringify(result)).not.toContain("test-only-token");
await client.close();
});
it("fails closed without credentials", async () => {
const Constructor = Reflect.get(harness, "SwadsMcpClient");
expect(Constructor).toBeDefined();
const client = new Constructor({ token: "" });
await expect(client.call("metrics_catalog", {})).rejects.toThrow("SWADS_MCP_TOKEN");
});
});
+8
View File
@@ -44,6 +44,14 @@ describe("mock_ads_metrics", () => {
});
describe("tool policy", () => {
it("requires approval for real analytics and report generation but never allows arbitrary MCP mutations", () => {
expect(decideToolPolicy("swads_whoami")).toBe("allow");
expect(decideToolPolicy("metrics_catalog")).toBe("allow");
expect(decideToolPolicy("metrics_semantic_query")).toBe("ask");
expect(decideToolPolicy("generate_minis_report")).toBe("ask");
expect(decideToolPolicy("campaign_update")).toBe("deny");
});
it("allows only the explicit safe set, asks for report save, and denies unknown tools", () => {
expect(["read", "grep", "find", "ls", "mock_ads_metrics"].map(decideToolPolicy)).toEqual([
"allow", "allow", "allow", "allow", "allow",