feat: publish Agent Studio integrations and setup guide
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 请求失败或返回格式不符;未使用模拟数据替代");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }>;
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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"); }
|
||||
|
||||
@@ -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("&","&").replaceAll("<","<").replaceAll(">",">").replaceAll(" "," ").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"});}
|
||||
}
|
||||
@@ -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};
|
||||
}
|
||||
}
|
||||
@@ -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 }));
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
}));
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user