feat: publish Agent Studio integrations and setup guide

This commit is contained in:
Wenxin Duan
2026-09-16 16:32:16 +08:00
parent 2ad089d68a
commit 05e6060490
106 changed files with 4942 additions and 198 deletions
@@ -0,0 +1,30 @@
import {z} from "zod";
import {HarnessError} from "../errors/harness-error.js";
export type AnalysisEvidence={analysisId:string;dramaId:string;fetchedAt:string;title:string;synopsis:string|null;tags:string[];episodeCount:number;
sources:{episodeId:string;episodeNo:number;duration:number;subtitle:string;frames:{id:string;time:number}[]}[]};
const Shape=z.object({analysis_id:z.string(),drama:z.object({drama_id:z.string(),title:z.string(),synopsis:z.string(),tags:z.array(z.string()),episode_count:z.number()}),cms_source:z.object({fetched_at:z.string()}),evidence:z.object({
frame_evidence:z.array(z.object({episode:z.number(),episode_id:z.string(),frame_id:z.string(),time:z.number().finite(),claim:z.string().min(1)})).min(2),
subtitle_evidence:z.array(z.object({episode:z.number(),episode_id:z.string(),story_position:z.enum(["opening","middle","late"]),quote:z.string().trim().min(3)})).min(3),
})});
export function assertAnalysisBinding(value:unknown,expected:AnalysisEvidence):void {
const fail=():never=>{throw new HarnessError("ANALYSIS_EVIDENCE_MISMATCH","分析必须绑定当前任务的真实 CMS、字幕和帧证据,不能自行修改身份或补造证据");};
const parsed=Shape.safeParse(value);if(!parsed.success)fail();
const actual=parsed.data!;
if(actual.analysis_id!==expected.analysisId||actual.drama.drama_id!==expected.dramaId||actual.cms_source.fetched_at!==expected.fetchedAt||actual.drama.title!==expected.title||actual.drama.synopsis!==expected.synopsis||actual.drama.episode_count!==expected.episodeCount||JSON.stringify([...actual.drama.tags].sort())!==JSON.stringify([...expected.tags].sort()))fail();
const seen=new Set<string>();
for(const frame of actual.evidence.frame_evidence){
const source=expected.sources.find(s=>s.episodeId===frame.episode_id&&s.episodeNo===frame.episode);
const original=source?.frames.find(f=>f.id===frame.frame_id);
if(!original||Math.abs(original.time-frame.time)>0.001||seen.has(frame.frame_id))fail();
seen.add(frame.frame_id);
}
const positions={opening:1,middle:Math.floor(expected.episodeCount/2)+1,late:expected.episodeCount};
const covered=new Set<string>();
for(const subtitle of actual.evidence.subtitle_evidence){
const source=expected.sources.find(s=>s.episodeId===subtitle.episode_id&&s.episodeNo===subtitle.episode);
if(!source||positions[subtitle.story_position]!==source.episodeNo||!source.subtitle.includes(subtitle.quote))fail();
covered.add(subtitle.story_position);
}
if(covered.size!==3)fail();
}
@@ -0,0 +1,125 @@
import { randomUUID, createHash } from "node:crypto";
import {createReadStream} from "node:fs";
import { mkdir, mkdtemp, readFile, writeFile, rename, stat } from "node:fs/promises";
import { join } from "node:path";
import { z } from "zod";
import type { CmsConnection } from "../cms/cms-connection.js";
import { HarnessError } from "../errors/harness-error.js";
import { downloadHls, mediaRuntime, prepareLocalMedia } from "./media-worker.js";
import { SafeWorkspace } from "../workspace/safe-workspace.js";
import {assertAnalysisBinding} from "./analysis-binding.js";
import type {SkillRunner} from "./skill-runner.js";
import {EditorWorkflow} from "./editor-workflow.js";
type Frame={id:string;time:number;path:string};
type Source={episodeId:string;episodeNo:number;source:string;sha256:string;duration:number;frames:Frame[];subtitle:string};
type Job={id:string;analysisId:string;dramaId:string;title:string;drama:Awaited<ReturnType<CmsConnection["inventory"]>>["drama"];fetchedAt:string;root:string;state:"preparing"|"sources_ready"|"failed"|"cancelled";error?:string;sources:Source[];evidenceShared:boolean;analysisValidated:boolean;validationAttempts:number};
const Input=z.object({dramaId:z.string().uuid(),episodeNumbers:z.array(z.number().int().positive()).min(1).max(5)}).strict();
let mediaBusy=false;
export class EditorService {
readonly #jobs=new Map<string,Job>();
readonly #workflows=new Map<string,EditorWorkflow>();
public constructor(private readonly options:{cms:CmsConnection;runtimeRoot:string;sessionId:string;runner?:SkillRunner}){}
public status(jobId:string) {
const job=this.#get(jobId);
return {jobId:job.id,analysisId:job.analysisId,dramaId:job.dramaId,title:job.title,fetchedAt:job.fetchedAt,state:job.state,error:job.error,
sources:job.sources.map(source=>({episodeId:source.episodeId,episodeNo:source.episodeNo,duration:source.duration,sha256:source.sha256,
frames:source.frames.map(frame=>({frameId:frame.id,time:frame.time,url:`/api/sessions/${this.options.sessionId}/editor/${job.id}/frames/${frame.id}`}))})),
analysisValidated:job.analysisValidated,workflowUrl:job.analysisValidated?`/api/sessions/${this.options.sessionId}/editor/${job.id}/workflow`:undefined,renderingReady:this.#workflows.get(job.id)?.view().plan?.reviewed===true,humanReviewPassed:false};
}
public async prepare(input:z.infer<typeof Input>,signal:AbortSignal) {
Input.parse(input);
if(new Set(input.episodeNumbers).size!==input.episodeNumbers.length) throw new HarnessError("MEDIA_DUPLICATE_EPISODE","不能重复选择同一分集");
if(!this.options.cms.status().connected) throw new HarnessError("CMS_LOGIN_REQUIRED","请先登录 CMS");
if(mediaBusy) throw new HarnessError("MEDIA_BUSY","已有重型媒体任务在运行");
if(this.#jobs.size>=3) throw new HarnessError("MEDIA_JOB_LIMIT","本会话最多准备三次素材;请先检查已有任务");
mediaBusy=true;let job:Job|undefined;
try {
signal.throwIfAborted();await mediaRuntime();
const inventory=await this.options.cms.inventory(input.dramaId);
if(!inventory.complete || inventory.drama.language!=="en") throw new HarnessError("MEDIA_INVENTORY_INCOMPLETE","需要完整的英文剧目录和片源记录");
const episodes=input.episodeNumbers.map(number=>inventory.episodes.find(episode=>episode.episodeNo===number));
if(episodes.some(episode=>!episode || !episode.subtitleLocales.includes("en"))) throw new HarnessError("MEDIA_EPISODE_INVALID","所选分集不存在或缺少英文字幕");
await mkdir(this.options.runtimeRoot,{recursive:true,mode:0o700});
const root=await mkdtemp(join(this.options.runtimeRoot,"job-"));
job={id:`job_${randomUUID()}`,analysisId:`analysis_${randomUUID()}`,dramaId:input.dramaId,title:inventory.drama.title,drama:inventory.drama,fetchedAt:inventory.fetchedAt,root,state:"preparing",sources:[],evidenceShared:false,analysisValidated:false,validationAttempts:0};
this.#jobs.set(job.id,job);await this.#persist(job);
const budget={remaining:1024*1024*1024};
for(const episode of episodes) {
signal.throwIfAborted();if(!episode)throw new Error();
const episodeRoot=join(root,`episode-${episode.episodeNo.toString()}`);await mkdir(episodeRoot,{mode:0o700});
const signed=await this.options.cms.playback(input.dramaId,episode.id);
await downloadHls(new URL(signed.url),episodeRoot,signal,budget);
const media=await prepareLocalMedia(episodeRoot,signal);
const subtitle=await this.options.cms.subtitle(input.dramaId,episode.id,"en");
await writeFile(join(episodeRoot,"subtitles.vtt"),subtitle.text,{mode:0o600,flag:"wx"});
// MP4 is bounded by the task download quota; no signed source URL is retained.
const hash=createHash("sha256");for await(const chunk of createReadStream(media.source)){signal.throwIfAborted();hash.update(chunk as Buffer);}const sha256=hash.digest("hex");
job.sources.push({episodeId:episode.id,episodeNo:episode.episodeNo,source:media.source,sha256,duration:media.duration,subtitle:subtitle.text,
frames:media.frames.map(frame=>({id:`frame_${randomUUID()}`,time:frame.time,path:frame.path}))});
await this.#persist(job);
}
signal.throwIfAborted();job.state="sources_ready";await this.#persist(job);return this.status(job.id);
} catch(error) {
if(job){job.state=signal.aborted?"cancelled":"failed";job.error=error instanceof HarnessError?error.code:"MEDIA_PREPARATION_FAILED";await this.#persist(job);}
if(error instanceof HarnessError)throw error;
throw new HarnessError(signal.aborted?"MEDIA_CANCELLED":"MEDIA_PREPARATION_FAILED","真实素材准备失败;未使用模拟素材替代");
} finally {mediaBusy=false;}
}
public async evidence(jobId:string) {
const job=this.#get(jobId);
if(job.state!=="sources_ready")throw new HarnessError("MEDIA_NOT_READY","素材尚未准备完成");
const metadata={...this.status(jobId),drama:job.drama,subtitles:job.sources.map(source=>({episodeId:source.episodeId,episodeNo:source.episodeNo,text:source.subtitle})),
instruction:"Images follow in sources/frame order. For submit_drama_analysis, use original v2 schema plus evidence.frame_evidence[].episode_id, frame_id, time and evidence.subtitle_evidence[].episode_id, quote (exact substring of actual VTT). Preserve CMS synopsis, tags, episode_count and server analysisId/fetchedAt exactly. These samples are NOT full-clip viewing or content clearance. Never claim analysis PASS before submit_drama_analysis returns pass:true."};
if(JSON.stringify(metadata).length>100_000) throw new HarnessError("MEDIA_EVIDENCE_TOO_LARGE","字幕超过单次模型证据范围,需分批处理");
const images=await Promise.all(job.sources.flatMap(source=>source.frames).map(async frame=>({type:"image" as const,mimeType:"image/jpeg",data:(await this.readFrame(jobId,frame.id)).toString("base64")})));
job.evidenceShared=true;
return {content:[{type:"text" as const,text:JSON.stringify(metadata)},...images],details:{...this.status(jobId),imageCount:images.length,subtitleCount:job.sources.length}};
}
public async submitAnalysis(jobId:string,value:unknown,signal:AbortSignal){
const job=this.#get(jobId);
if(job.state!=="sources_ready"||!job.evidenceShared)throw new HarnessError("ANALYSIS_EVIDENCE_REQUIRED","请先完成授权后的真实字幕与帧读取");
if(job.analysisValidated)throw new HarnessError("ANALYSIS_IMMUTABLE","已通过的分析不能覆盖,请使用新的素材任务");
if(!this.options.runner)throw new HarnessError("EDITOR_VALIDATOR_UNAVAILABLE","校验运行时未配置");
if(job.validationAttempts>=10)throw new HarnessError("ANALYSIS_ATTEMPT_LIMIT","本任务分析提交次数达到限制");
const json=JSON.stringify(value);if(json.length>128*1024)throw new HarnessError("ANALYSIS_TOO_LARGE","分析文件超过限制");
assertAnalysisBinding(value,{analysisId:job.analysisId,dramaId:job.dramaId,fetchedAt:job.fetchedAt,title:job.title,synopsis:job.drama.synopsis,tags:job.drama.tags,episodeCount:job.drama.total_episodes,sources:job.sources});
job.validationAttempts++;
const attempt=await mkdtemp(join(job.root,"analysis-"));const input=join(attempt,"target-drama-analysis.json");
await writeFile(input,json,{mode:0o600,flag:"wx"});
const result=await this.options.runner.validate("analysis",input,attempt,signal);
if(result.pass){await writeFile(join(job.root,"target-drama-analysis.json"),json,{mode:0o600,flag:"wx"});job.analysisValidated=true;this.#workflows.set(job.id,new EditorWorkflow({root:job.root,jobId:job.id,sessionId:this.options.sessionId,analysisId:job.analysisId,dramaId:job.dramaId,sources:job.sources,runner:this.options.runner}));}
await writeFile(join(attempt,"target-drama-analysis-validation.json"),JSON.stringify(result),{mode:0o600,flag:"wx"});
await this.#persist(job);return {...result,jobId,analysisId:job.analysisId,dramaId:job.dramaId,workflowUrl:this.status(jobId).workflowUrl,renderingReady:false,contentReview:"required"};
}
public workflowView(jobId:string){return this.#workflow(jobId).view();}
public async proposeSelection(jobId:string,value:unknown){await this.#workflow(jobId).proposeSelection(value);return this.workflowSummary(jobId);}
public async submitPlan(jobId:string,value:unknown,signal:AbortSignal){const result=await this.#workflow(jobId).submitPlan(value,signal);return {...result,...this.workflowSummary(jobId),plan:undefined,selection:undefined};}
public async renderPreview(jobId:string,signal:AbortSignal){if(mediaBusy)throw new HarnessError("MEDIA_BUSY","已有重型媒体任务在运行");mediaBusy=true;try{await this.#workflow(jobId).render(signal);return this.workflowSummary(jobId);}finally{mediaBusy=false;}}
public async review(jobId:string,stage:"selection"|"plan",value:unknown){return this.#workflow(jobId).review(stage,value);}
public workflowSummary(jobId:string){const view=this.workflowView(jobId);return {jobId,workflowUrl:view.workflowUrl,selectionReviewed:view.selection?.reviewed??false,planReviewed:view.plan?.reviewed??false,preview:view.preview,humanReviewPassed:false};}
public async video(jobId:string,kind:"sources"|"plans",id:string){
const job=this.#get(jobId);const path=kind==="sources"?job.sources.find(source=>source.episodeId===id)?.source:this.#workflow(jobId).previewPath(id);
if(!path)throw new HarnessError("EDITOR_SOURCE_NOT_FOUND","该会话没有此媒体文件");
const workspace=await SafeWorkspace.create(job.root);const resolved=await workspace.resolveReadable(path);const info=await stat(resolved);
if(!info.isFile())throw new HarnessError("EDITOR_SOURCE_NOT_FOUND","媒体文件不存在");
return {path:resolved,size:info.size};
}
public async readFrame(jobId:string,frameId:string) {
const job=this.#get(jobId);const frame=job.sources.flatMap(source=>source.frames).find(item=>item.id===frameId);
if(!frame)throw new HarnessError("FRAME_NOT_FOUND","该会话中不存在此图片");
const workspace=await SafeWorkspace.create(job.root);
const bytes=await readFile(await workspace.resolveReadable(frame.path));
if(bytes.length>512*1024)throw new HarnessError("FRAME_TOO_LARGE","图片超过安全范围");
return bytes;
}
#get(id:string):Job {const job=this.#jobs.get(id);if(!job)throw new HarnessError("EDITOR_JOB_NOT_FOUND","该会话没有此媒体任务");return job;}
#workflow(id:string){this.#get(id);const workflow=this.#workflows.get(id);if(!workflow)throw new HarnessError("ANALYSIS_REQUIRED","先完成真实剧情分析并通过原始校验器");return workflow;}
async #persist(job:Job) {
// Atomic metadata; source/frames persist for recovery, but no automatic Session takeover.
const temporary=join(job.root,"job.json.tmp");
await writeFile(temporary,JSON.stringify({...this.status(job.id),ownerSessionId:this.options.sessionId},null,2),{mode:0o600});
await rename(temporary,join(job.root,"job.json"));
}
}
@@ -0,0 +1,21 @@
import {HarnessError} from "../errors/harness-error.js";
const time=(text:string)=>text.split(":").reduce((total,item)=>total*60+Number(item),0);
const stamp=(seconds:number)=>{const ms=Math.round(seconds*1000);return `${String(Math.floor(ms/3600000)).padStart(2,"0")}:${String(Math.floor(ms/60000)%60).padStart(2,"0")}:${String(Math.floor(ms/1000)%60).padStart(2,"0")},${String(ms%1000).padStart(3,"0")}`;};
export function cutSubtitles(spans:{subtitle:string;sourceStart:number;sourceEnd:number;targetStart:number;segmentId:string}[]){
const cues:string[]=[];const warnings=new Set<string>();
for(const span of spans){
if(!span.subtitle.trimStart().startsWith("WEBVTT"))throw new HarnessError("SUBTITLE_INVALID","需要真实英文 WebVTT");
for(const block of span.subtitle.replaceAll("\r","").split(/\n\s*\n/)){
const lines=block.split("\n");const index=lines.findIndex(line=>line.includes(" --> "));if(index<0)continue;
const match=/((?:\d{2}:)?\d{2}:\d{2}\.\d{3})\s+-->\s+((?:\d{2}:)?\d{2}:\d{2}\.\d{3})/.exec(lines[index]??"");
if(!match?.[1]||!match[2])throw new HarnessError("SUBTITLE_INVALID","不支持此字幕时间格式");
const start=time(match[1]);const end=time(match[2]);
if(end<=span.sourceStart||start>=span.sourceEnd)continue;
if(start<span.sourceStart-0.001||end>span.sourceEnd+0.001)warnings.add(span.segmentId);
const text=lines.slice(index+1).join("\n").replaceAll(/<[^>]*>/g,"").replaceAll("&amp;","&").replaceAll("&lt;","<").replaceAll("&gt;",">").replaceAll("&nbsp;"," ").trim();
if(!text)continue;
cues.push(`${String(cues.length+1)}\n${stamp(Math.max(start,span.sourceStart)-span.sourceStart+span.targetStart)} --> ${stamp(Math.min(end,span.sourceEnd)-span.sourceStart+span.targetStart)}\n${text}`);
}
}
return {srt:cues.join("\n\n")+(cues.length?"\n":""),boundaryWarnings:[...warnings]};
}
@@ -0,0 +1,21 @@
import {defineTool} from "@earendil-works/pi-coding-agent";
import {Type} from "typebox";
import type {EditorService} from "./editor-service.js";
import {HarnessError} from "../errors/harness-error.js";
const job=Type.Object({jobId:Type.String({pattern:"^job_[a-zA-Z0-9-]+$"})},{additionalProperties:false});
export function createMediaTools(service:EditorService,supportsImages:()=>boolean) {
return [
defineTool({name:"propose_drama_selection",label:"Review source selection",description:"After real analysis PASS and before strategy/reference research, propose exact candidate spans for human adult-content review in the webpage. Input selection={segments:[{id,episodeId,start,end,reason}]}; times in seconds on 24fps boundaries, at most 10 spans and 60 seconds each. These are candidates, not approved clips. The user must watch with original sound and record findings. No tool can approve the review. Initial output is a 10-second original-audio preview, no new CTA/cover/overlays.",parameters:Type.Object({jobId:Type.String(),selection:Type.Record(Type.String(),Type.Unknown())},{additionalProperties:false}),async execute(_id,args){const details=await service.proposeSelection(args.jobId,args.selection);return {content:[{type:"text" as const,text:JSON.stringify(details)}],details};}}),
defineTool({name:"submit_editor_plan",label:"Validate bound editing plan",description:"Only after human selection review. Read the original Skill references first. bundle requires recipe, hookMap, strategy, referenceCandidates, referenceAnalysis, referenceRecipe, continuity JSON objects. All except referenceCandidates/recipe require target_binding={drama_id,analysis_id} and exact withheld_information. recipe v2 segments add episode_id and must exactly match reviewed segment IDs/source ranges; target duration exactly 10s,24fps. Supply no file paths, subtitle_path or invented PASS flags. Reference candidates need actual full short-drama marketing video plus A/B evidence; webpage does not inherit Codex web search, request actual URLs/evidence when missing. C is not enabled. Original reference/recipe/hook scripts run, then human confirms the exact plan, reference and story. No rendering from this tool.",parameters:Type.Object({jobId:Type.String(),bundle:Type.Record(Type.String(),Type.Unknown())},{additionalProperties:false}),async execute(_id,args,signal){const details=await service.submitPlan(args.jobId,args.bundle,signal??new AbortController().signal);return {content:[{type:"text" as const,text:JSON.stringify(details)}],details};}}),
defineTool({name:"render_editor_preview",label:"Render actual REVIEW MP4",description:"Render a 10-second local REVIEW preview using the original Skill renderer and machine QA. Requires analysis, reference/recipe/hook checks, human source and exact plan reviews, plus this page approval. Original audio at 1.00x. Returns registered video URL, duration, hash and real technical QA, never final content PASS. No publishing or upload.",parameters:job,async execute(_id,args,signal){const details=await service.renderPreview(args.jobId,signal??new AbortController().signal);return {content:[{type:"text" as const,text:JSON.stringify(details)}],details};}}),
defineTool({name:"submit_drama_analysis",label:"Validate actual drama analysis",description:"Submit original Skill v2 analysis JSON after approved read_drama_frames. Required: exact server analysisId as analysis_id, CMS drama_id/title/synopsis/tags/episode_count and fetchedAt as cms_source.fetched_at. Frame evidence adds exact episode_id/frame_id/time; subtitle evidence adds episode_id/quote from actual subtitles. Runs original validator; returns real issue codes. Passing analysis is not adult-content or rendering approval. No caller file paths are used.",parameters:Type.Object({jobId:Type.String(),analysis:Type.Record(Type.String(),Type.Unknown())},{additionalProperties:false}),
async execute(_id,args,signal){const details=await service.submitAnalysis(args.jobId,args.analysis,signal??new AbortController().signal);return {content:[{type:"text" as const,text:JSON.stringify(details)}],details};}}),
defineTool({name:"prepare_drama_sources",label:"Prepare licensed CMS sources",description:"After reading the editor Skill, download only approved exact episodeNumbers of an English CMS drama, maximum 5 episodes / 1 GiB per job. Fixed CMS admin/CDN only. Remux original audio/video locally and extract 3 representative frames per episode; no images or subtitles sent to the model by this tool. Requires page approval and CMS login. Returns a jobId for read_drama_frames; sources_ready is NOT analysis or content clearance. Cannot render yet.",
parameters:Type.Object({dramaId:Type.String({pattern:"^[a-fA-F0-9-]{36}$"}),episodeNumbers:Type.Array(Type.Integer({minimum:1}),{minItems:1,maxItems:5,uniqueItems:true})},{additionalProperties:false}),
async execute(_id,args,signal){const details=await service.prepare(args,signal??new AbortController().signal);return {content:[{type:"text" as const,text:JSON.stringify(details)}],details};}}),
defineTool({name:"editor_job_status",label:"Editor source job",description:"Read this Session's actual media preparation status by jobId. No source URLs or credentials. Does not grant any rendering or review approval.",parameters:job,
execute(_id,args){const details=service.status(args.jobId);return Promise.resolve({content:[{type:"text" as const,text:JSON.stringify(details)}],details});}}),
defineTool({name:"read_drama_frames",label:"Approved frame and subtitle evidence",description:"Requires separate page approval: sends the prepared job's English subtitles, CMS synopsis/categories and 3 representative JPEG frames per episode to the currently configured image-capable model. Cite exact frameId/episodeId/time in observations. Sampled frames cannot prove full-clip continuity, audio review or adult-content PASS. Submit analysis using submit_drama_analysis; no reference research or rendering until it passes.",parameters:job,
async execute(_id,args){if(!supportsImages())throw new HarnessError("MODEL_DOES_NOT_SUPPORT_IMAGES","请为已验证模型启用图片能力");return service.evidence(args.jobId);}}),
];
}
@@ -0,0 +1,135 @@
import {createHash,randomUUID} from "node:crypto";
import {createReadStream} from "node:fs";
import {readFile,writeFile,mkdtemp} from "node:fs/promises";
import {join} from "node:path";
import {z} from "zod";
import {HarnessError} from "../errors/harness-error.js";
import {bindRecipe} from "./recipe-binding.js";
import {assertSelectionReview} from "./review-ledger.js";
import type {SkillRunner} from "./skill-runner.js";
import {cutSubtitles} from "./editor-subtitles.js";
type Source={episodeId:string;episodeNo:number;source:string;duration:number;sha256:string;subtitle:string};
const Span=z.object({id:z.string().regex(/^[\w-]{1,80}$/),episodeId:z.string(),start:z.number().nonnegative(),end:z.number().positive(),reason:z.string().min(10).max(2000)}).strict();
const Selection=z.object({segments:z.array(Span).min(1).max(10)}).strict();
const SourceReview=z.object({planHash:z.string(),reviewer:z.string().trim().min(1).max(100),policyCheckedAt:z.string().datetime({offset:true}),policyPageUpdated:z.string().min(4).max(100),pictureAudioReviewed:z.literal(true),noUnresolvedFindings:z.literal(true),segmentEvidence:z.array(z.object({segmentId:z.string(),pictureEvidence:z.string().trim().min(20).max(2000),audioEvidence:z.string().trim().min(20).max(2000),subtitleEvidence:z.string().trim().min(20).max(2000)}).strict()).min(1).max(10)}).strict();
const Json=z.record(z.string(),z.unknown());
const Bundle=z.object({recipe:Json,hookMap:Json,strategy:Json,referenceCandidates:Json,referenceAnalysis:Json,referenceRecipe:Json,continuity:Json}).strict();
type Selected={id:string;hash:string;root:string;input:z.infer<typeof Selection>;review?:z.infer<typeof SourceReview>};
type Plan={id:string;hash:string;root:string;recipe:ReturnType<typeof bindRecipe>;bundle:z.infer<typeof Bundle>;reference:Record<string,unknown>;subtitles:string;review?:ReturnType<typeof assertSelectionReview>;preview?:Awaited<ReturnType<SkillRunner["render"]>>};
const policyUrl="https://ads.tiktok.com/resources/help/article/tiktok-ads-policy-adult-content";
const hash=(value:unknown)=>createHash("sha256").update(JSON.stringify(value)).digest("hex");
/** Created only after EditorService's real analysis validator passes. Human reviews are API-only. */
export class EditorWorkflow {
#selection:Selected|undefined;
#plan:Plan|undefined;
#attempts=0;
#busy=false;
constructor(private readonly context:{root:string;sessionId:string;jobId:string;analysisId:string;dramaId:string;sources:Source[];runner:SkillRunner}){}
view(){
const base=`/api/sessions/${this.context.sessionId}/editor/${this.context.jobId}`;
const selection=this.#selection;const plan=this.#plan;
return {jobId:this.context.jobId,workflowUrl:`${base}/workflow`,policyUrl,
selection:selection?{id:selection.id,hash:selection.hash,reviewed:!!selection.review,segments:selection.input.segments.map(span=>({...span,episodeNo:this.source(span.episodeId).episodeNo,videoUrl:`${base}/sources/${span.episodeId}`,subtitles:cutSubtitles([{subtitle:this.source(span.episodeId).subtitle,sourceStart:span.start,sourceEnd:span.end,targetStart:0,segmentId:span.id}]).srt}))}:null,
plan:plan?{id:plan.id,hash:plan.hash,reviewed:!!plan.review,reference:plan.reference,artifacts:plan.bundle,subtitles:plan.subtitles,segmentIds:plan.recipe.segments.map(s=>s.id)}:null,
preview:plan?.preview?{url:`${base}/plans/${plan.id}/preview`,duration:plan.preview.duration,sha256:plan.preview.sha256,machinePass:plan.preview.machinePass,checks:plan.preview.checks,events:plan.preview.events,reviewStatus:"REVIEW",pending:["完整播放最终 MP4 并听原声","逐项核对字幕、边界与内容;预览不是投放审批"]}:null,
humanReviewPassed:false};
}
source(id:string){const source=this.context.sources.find(item=>item.episodeId===id);if(!source)throw new HarnessError("EDITOR_SOURCE_NOT_FOUND","当前任务中没有此源文件");return source;}
previewPath(id:string){if(this.#plan?.id!==id||!this.#plan.preview)throw new HarnessError("EDITOR_PREVIEW_NOT_FOUND","当前版本没有预览");return this.#plan.preview.output;}
async proposeSelection(value:unknown){
if(this.#busy)throw new HarnessError("EDITOR_BUSY","任务运行中");
const input=Selection.parse(value);
if(++this.#attempts>10)throw new HarnessError("EDITOR_ATTEMPT_LIMIT","本任务版本数已达限制");
const ids=new Set<string>();
for(const span of input.segments){
const source=this.context.sources.find(s=>s.episodeId===span.episodeId);
if(!source||ids.has(span.id)||span.end<=span.start||span.end>source.duration||span.end-span.start>60||[span.start,span.end].some(t=>Math.abs(t-Math.round(t*24)/24)>0.001))throw new HarnessError("SELECTION_INVALID","选段必须绑定本任务源文件及有效的 24fps 边界");
ids.add(span.id);
}
const root=await mkdtemp(join(this.context.root,"selection-"));
const record={target_binding:this.binding(),input,sources:input.segments.map(span=>({episodeId:span.episodeId,sha256:this.source(span.episodeId).sha256,subtitleHash:hash(this.source(span.episodeId).subtitle)})),packaging:{overlays:"not_present",cover:"not_present",cta:"not_present",adCopy:"not_present",subtitles:"original English captions only"}};
const selection={id:`selection_${randomUUID()}`,hash:hash(record),input,root};
await this.write(root,"selection.json",record);this.#selection=selection;this.#plan=undefined;return this.view();
}
async review(stage:"selection"|"plan",value:unknown){
if(this.#busy)throw new HarnessError("EDITOR_BUSY","任务运行中");
const selected=this.#selection;
if(!selected)throw new HarnessError("SELECTION_REVIEW_REQUIRED","请先提交候选选段");
if(stage==="selection"){
if(selected.review)throw new HarnessError("REVIEW_IMMUTABLE","审核已保存;修改选段需要新版本");
const review=SourceReview.parse(value);const checked=Date.parse(review.policyCheckedAt);
if(review.planHash!==selected.hash||checked>Date.now()+60_000||Date.now()-checked>86400000||review.segmentEvidence.length!==selected.input.segments.length||new Set(review.segmentEvidence.map(e=>e.segmentId)).size!==selected.input.segments.length||review.segmentEvidence.some(e=>!selected.input.segments.some(s=>s.id===e.segmentId)))throw new HarnessError("SELECTION_REVIEW_INVALID","审核必须对应当前版本每一选段和本批次政策核对时间");
await this.write(selected.root,"adult-content-review.json",{policy_source:{url:policyUrl,page_updated:review.policyPageUpdated,checked_at:review.policyCheckedAt,verification_status:"verified"},target_binding:this.binding(),stage:"selection",status:"PASS",reviewer:review.reviewer,reviewed_at:new Date().toISOString(),segments:selected.input.segments.map(span=>({...span,...review.segmentEvidence.find(e=>e.segmentId===span.id),decision:"KEEP"})),surfaces:{picture:"reviewed",original_audio:"reviewed",subtitles:"reviewed",overlays:"not_present",cover:"not_present",CTA:"not_present",ad_copy:"not_present"},findings:[],unresolved_findings:[],excluded_candidates:[],selectionHash:selected.hash});
selected.review=review;
}else{
const plan=this.#plan;if(!plan)throw new HarnessError("PLAN_REVIEW_REQUIRED","剪辑方案尚未通过脚本校验");
if(plan.review)throw new HarnessError("REVIEW_IMMUTABLE","审核已保存,修改需要新版本");
const review=assertSelectionReview(value,{hash:plan.hash,segmentIds:plan.recipe.segments.map(s=>s.id)});
// Candidate content/full-video evidence is reviewed BEFORE ranking, not cleared by scores.
this.#busy=true;
try{
const scored=await this.context.runner.scoreReferences(join(plan.root,"reference-candidates.json"),plan.root,AbortSignal.timeout(60_000));
if(scored.status!=="selected"||!scored.selected_reference)throw new HarnessError("VERIFIED_REFERENCE_REQUIRED","没有 A/B 级短剧营销参考;需要真实可播放来源及证据,不能自动降为 C 级");
if(plan.recipe.reference_id!==scored.selected_reference.id||plan.recipe.reference_evidence_tier!==scored.selected_reference.evidence_tier||plan.bundle.referenceAnalysis.reference_id!==scored.selected_reference.id||plan.bundle.referenceRecipe.reference_id!==scored.selected_reference.id)throw new HarnessError("REFERENCE_BINDING_INVALID","方案必须绑定本次评分选中的参考及其证据等级");
await this.write(plan.root,"selected-reference.json",{...scored,target_binding:this.binding()});
await this.write(plan.root,"plan-review.json",{...review,target_binding:this.binding(),reviewedAt:new Date().toISOString()});plan.reference=scored.selected_reference;plan.review=review;
}finally{this.#busy=false;}
}
return this.view();
}
async submitPlan(value:unknown,signal:AbortSignal){
const selection=this.#selection;
if(!selection?.review)throw new HarnessError("SELECTION_REVIEW_REQUIRED","策略与参考研究前,先在网页带声音审核候选源片段");
if(this.#busy)throw new HarnessError("EDITOR_BUSY","任务运行中");
if(++this.#attempts>10)throw new HarnessError("EDITOR_ATTEMPT_LIMIT","本任务版本数已达限制");
if(JSON.stringify(value).length>256*1024)throw new HarnessError("PLAN_TOO_LARGE","方案超过限制");
const bundle=Bundle.parse(value);
const analysis=Json.parse(JSON.parse(await readFile(join(this.context.root,"target-drama-analysis.json"),"utf8")));
const withheld=z.array(z.string()).min(1).parse(analysis.withheld_information);
const recipe=bindRecipe(bundle.recipe,{analysisId:this.context.analysisId,dramaId:this.context.dramaId,analysisPath:join(this.context.root,"target-drama-analysis.json"),withheldInformation:withheld,sources:this.context.sources});
for(const segment of recipe.segments)if(!selection.input.segments.some(s=>s.id===segment.id&&s.episodeId===segment.episode_id&&s.start===segment.source_start&&s.end===segment.source_end))throw new HarnessError("UNREVIEWED_SELECTION","时间范围改变后必须重新审核,不能沿用原 PASS");
const captions=cutSubtitles(recipe.segments.map(s=>({subtitle:this.source(s.episode_id).subtitle,sourceStart:s.source_start,sourceEnd:s.source_end,targetStart:s.target_start,segmentId:s.id})));
if(captions.boundaryWarnings.length)throw new HarnessError("SUBTITLE_BOUNDARY_REVIEW","切点截断了完整字幕句,需要重新选择完整对白区间");
for(const artifact of [bundle.hookMap,bundle.strategy,bundle.referenceAnalysis,bundle.referenceRecipe,bundle.continuity]){
const binding=Json.parse(artifact.target_binding);
if(binding.analysis_id!==this.context.analysisId||binding.drama_id!==this.context.dramaId||hash(artifact.withheld_information)!==hash(withheld))throw new HarnessError("PLAN_BINDING_INVALID","所有方案文件必须绑定当前分析及相同 withheld_information");
}
const strategy=z.object({narrative_structure:z.object({mode:z.enum(["linear","reverse"]),reason:z.string().min(10)}),opening:z.object({mode:z.literal("original_footage"),voiceover_authorized:z.literal(false)}),first_30_seconds:Json,character_amplification:Json,event_density:Json}).parse(bundle.strategy);void strategy;
for(const artifact of [bundle.referenceAnalysis,bundle.referenceRecipe,bundle.continuity])if(Object.keys(artifact).length<4)throw new HarnessError("PLAN_EVIDENCE_REQUIRED","参考与连续性报告需要实质分析,不接受仅有绑定和 PASS 标记");
const candidates={...bundle.referenceCandidates,target_analysis_path:join(this.context.root,"target-drama-analysis.json"),...this.binding(),allow_unverified_reference:false};
const root=await mkdtemp(join(this.context.root,"plan-"));this.#busy=true;this.#plan=undefined;
try{
await this.write(root,"reference-candidates.json",candidates);
await writeFile(join(root,"final-subtitles.srt"),captions.srt,{mode:0o600,flag:"wx"});
const renderedRecipe={...recipe,...(captions.srt?{subtitle_path:join(root,"final-subtitles.srt")}:{} )};
const artifacts={"recipe.json":renderedRecipe,"hook-map.json":bundle.hookMap,"creative-strategy.json":bundle.strategy,"reference-analysis.json":bundle.referenceAnalysis,"reference-recipe.json":bundle.referenceRecipe,"continuity-report.json":bundle.continuity};
for(const [name,data]of Object.entries(artifacts))await this.write(root,name,data);
const recipeReport=await this.context.runner.validate("recipe",join(root,"recipe.json"),root,signal);
const hookReport=await this.context.runner.validate("hook",join(root,"hook-map.json"),root,signal,join(root,"recipe.json"));
await this.write(root,"recipe-report.json",recipeReport);await this.write(root,"hook-report.json",hookReport);
if(!recipeReport.pass||!hookReport.pass)return {pass:false,recipeReport,hookReport,workflowUrl:this.view().workflowUrl};
const planHash=hash({selectionHash:selection.hash,bundle,subtitles:captions.srt});
this.#plan={id:`plan_${randomUUID()}`,hash:planHash,root,recipe,bundle,reference:{status:"pending_human_content_review_before_scoring",candidates:bundle.referenceCandidates.candidates},subtitles:captions.srt};
return {pass:true,validationStage:"recipe_and_hook_only",referenceScoring:"after_human_candidate_review",...this.view()};
}finally{this.#busy=false;}
}
async render(signal:AbortSignal){
const plan=this.#plan;if(!plan?.review||!this.#selection?.review)throw new HarnessError("PLAN_REVIEW_REQUIRED","生成前需要在网页完成当前版本的源片段、参考和故事审核");
if(plan.preview)return this.view();
if(this.#busy)throw new HarnessError("EDITOR_BUSY","任务运行中");
this.#busy=true;
try{
for(const id of new Set(plan.recipe.segments.map(s=>s.episode_id))){
const source=this.source(id);const actual=createHash("sha256");
for await(const chunk of createReadStream(source.source)){signal.throwIfAborted();actual.update(chunk as Buffer);}
if(actual.digest("hex")!==source.sha256)throw new HarnessError("REVIEWED_SOURCE_CHANGED","源文件已改变,旧审核不可复用");
}
plan.preview=await this.context.runner.render(join(plan.root,"recipe.json"),plan.root,signal);return this.view();
}
finally{this.#busy=false;}
}
private binding(){return {drama_id:this.context.dramaId,analysis_id:this.context.analysisId};}
private async write(root:string,name:string,data:unknown){await writeFile(join(root,name),JSON.stringify(data,null,2),{mode:0o600,flag:"wx"});}
}
+48
View File
@@ -0,0 +1,48 @@
import { HarnessError } from "../errors/harness-error.js";
type Playlist = {kind:"master";variants:string[]} | {kind:"media";segments:{uri:string;duration:number}[];duration:number};
function invalid(): never { throw new HarnessError("HLS_UNSUPPORTED", "HLS 清单格式不支持或超过安全范围;未下载替代素材"); }
export function parseHls(text: string): Playlist {
if (Buffer.byteLength(text)>1_048_576 || !text.trimStart().startsWith("#EXTM3U")) invalid();
const lines=text.trim().split(/\r?\n/).map((line)=>line.trim()).filter(Boolean);
if (lines.some((line)=>/^#EXT-X-(KEY|MAP|BYTERANGE|MEDIA:|I-FRAMES-ONLY|GAP|DISCONTINUITY)/.test(line))) invalid();
const variants:string[]=[];
const segments:{uri:string;duration:number}[]=[];
let pending:number|"variant"|undefined;
for (const line of lines.slice(1)) {
if (line.startsWith("#EXT-X-STREAM-INF:")) { if(pending!==undefined) invalid(); pending="variant"; }
else if (line.startsWith("#EXTINF:")) {
if(pending!==undefined) invalid();
const duration=Number(line.slice(8).split(",")[0]);
if(!Number.isFinite(duration)||duration<=0||duration>60) invalid();
pending=duration;
} else if (!line.startsWith("#")) {
if(pending==="variant") variants.push(line);
else if(typeof pending==="number") segments.push({uri:line,duration:pending});
else invalid();
pending=undefined;
}
}
if(pending!==undefined || (variants.length>0 && segments.length>0)) invalid();
if(variants.length>0) { if(variants.length>10) invalid(); return {kind:"master",variants}; }
const duration=segments.reduce((sum,item)=>sum+item.duration,0);
if(!segments.length||segments.length>1000||duration>1800||!lines.includes("#EXT-X-ENDLIST")) invalid();
return {kind:"media",segments,duration};
}
export function resolveHlsAsset(base:URL, uri:string, episodePrefix:string):URL {
if(uri.length>4096 || /[\s\\]/.test(uri) || [...uri].some(character=>character.charCodeAt(0)<32)) invalid();
const result=new URL(uri,base);
if(result.origin!==base.origin || result.protocol!=="https:" || result.username || result.password || result.hash) invalid();
let decoded:string;
try { decoded=decodeURIComponent(result.pathname); } catch { return invalid(); }
if(!result.pathname.startsWith(episodePrefix)||!decoded.startsWith(episodePrefix)||decoded.includes("..")||decoded.includes("\\")||!(/\.(ts|m3u8)$/i.test(result.pathname))) invalid();
// CDN inheritance signs each child path separately; never replace that signature with the master's.
if(result.search && result.search!==base.search) {
const entries=[...result.searchParams.entries()];
if(entries.length!==1 || entries[0]?.[0]!=="auth_key" || !/^\d+-[a-zA-Z0-9]+-[a-zA-Z0-9]+-[a-fA-F0-9]+$/.test(entries[0][1])) invalid();
}
if(!result.search) result.search=base.search;
return result;
}
@@ -0,0 +1,82 @@
import { spawn } from "node:child_process";
import { createRequire } from "node:module";
import { access, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { HarnessError } from "../errors/harness-error.js";
import { parseHls, resolveHlsAsset } from "./hls.js";
const require=createRequire(import.meta.url);
export async function mediaRuntime() {
const ffmpeg=require("ffmpeg-static") as string|null;
const ffprobe=(require("ffprobe-static") as {path:string}).path;
if(!ffmpeg) throw new HarnessError("MEDIA_RUNTIME_MISSING","当前平台缺少 FFmpeg");
try { await Promise.all([access(ffmpeg),access(ffprobe)]); } catch { throw new HarnessError("MEDIA_RUNTIME_MISSING","FFmpeg/FFprobe 安装尚未完成"); }
return {ffmpeg,ffprobe};
}
export async function downloadHls(url:URL,root:string,signal:AbortSignal,budget:{remaining:number},fetcher:typeof fetch=fetch) {
if(url.origin!=="https://video.pencil-stub.com"||url.username||url.password||url.hash) throw new HarnessError("MEDIA_ORIGIN_DENIED","只允许 CMS 固定 CDN 源");
const prefix=url.pathname.slice(0,url.pathname.lastIndexOf("/")+1);
const request=async(target:URL,limit:number)=>{
signal.throwIfAborted();
const response=await fetcher(target,{redirect:"error",signal:AbortSignal.any([signal,AbortSignal.timeout(30_000)])});
if(response.status!==200||!response.body) throw new HarnessError("MEDIA_FETCH_FAILED",`片源请求返回 HTTP ${response.status.toString()}`);
const reader=response.body.getReader();const chunks:Uint8Array[]=[];let size=0;
try {
for(;;){ const chunk=await reader.read();if(chunk.done)break;size+=chunk.value.byteLength;budget.remaining-=chunk.value.byteLength;
if(size>limit||budget.remaining<0) throw new HarnessError("MEDIA_BYTE_LIMIT","下载超过单次安全字节上限");
chunks.push(chunk.value);
}
} finally { await reader.cancel().catch(()=>undefined); }
return Buffer.concat(chunks);
};
let current=url;
let playlist=parseHls((await request(current,1_048_576)).toString("utf8"));
for(let depth=0;playlist.kind==="master" && depth<3;depth++) {
current=resolveHlsAsset(current,playlist.variants[0]!,prefix);
playlist=parseHls((await request(current,1_048_576)).toString("utf8"));
}
if(playlist.kind!=="media") throw new HarnessError("MEDIA_PLAYLIST_DEPTH","HLS 主清单嵌套超限");
const local=["#EXTM3U","#EXT-X-VERSION:3",`#EXT-X-TARGETDURATION:${Math.ceil(Math.max(...playlist.segments.map(s=>s.duration))).toString()}`,"#EXT-X-MEDIA-SEQUENCE:0"];
for(const [index,segment] of playlist.segments.entries()) {
const data=await request(resolveHlsAsset(current,segment.uri,prefix),32*1024*1024);
const name=`segment-${index.toString()}.ts`;
await writeFile(join(root,name),data,{flag:"wx",mode:0o600});
local.push(`#EXTINF:${segment.duration.toString()},`,name);
}
local.push("#EXT-X-ENDLIST","");
const path=join(root,"source.m3u8");await writeFile(path,local.join("\n"),{flag:"wx",mode:0o600});
return {path,duration:playlist.duration,segmentCount:playlist.segments.length};
}
export function runMedia(executable:string,args:string[],cwd:string,signal:AbortSignal,timeoutMs=180_000):Promise<string> {
signal.throwIfAborted();
return new Promise((resolve,reject)=>{
const child=spawn(executable,args,{cwd,env:{PATH:"/usr/bin:/bin",LANG:"en_US.UTF-8"},detached:process.platform!=="win32",stdio:["ignore","pipe","pipe"]});
let stdout="";let bytes=0;let terminated=false;
const kill=()=>{terminated=true;try{if(process.platform!=="win32"&&child.pid)process.kill(-child.pid,"SIGKILL");else child.kill("SIGKILL");}catch{/* Already exited. */}};
const timer=setTimeout(kill,timeoutMs);signal.addEventListener("abort",kill,{once:true});
const cleanup=()=>{clearTimeout(timer);signal.removeEventListener("abort",kill);};
child.stdout.on("data",(chunk:Buffer)=>{bytes+=chunk.length;if(bytes>2*1024*1024)kill();else stdout+=chunk.toString();});
child.stderr.on("data",(chunk:Buffer)=>{bytes+=chunk.length;if(bytes>2*1024*1024)kill();});
child.once("error",()=>{cleanup();reject(new HarnessError("MEDIA_EXEC_FAILED","媒体进程启动失败"));});
child.once("close",code=>{cleanup();if(code===0&&!terminated&&!signal.aborted)resolve(stdout);else reject(new HarnessError(signal.aborted?"MEDIA_CANCELLED":"MEDIA_EXEC_FAILED","媒体进程失败、取消或超过限制"));});
if(signal.aborted)kill();
});
}
export async function prepareLocalMedia(root:string,signal:AbortSignal) {
const {ffmpeg,ffprobe}=await mediaRuntime();
const source=join(root,"episode.mp4");
await runMedia(ffmpeg,["-nostdin","-v","error","-protocol_whitelist","file,crypto,data","-i",join(root,"source.m3u8"),"-map","0:v:0","-map","0:a:0","-c","copy","-movflags","+faststart","-n",source],root,signal);
const raw=JSON.parse(await runMedia(ffprobe,["-v","error","-protocol_whitelist","file","-show_streams","-show_format","-of","json",source],root,signal)) as {streams?:{codec_type?:string;width?:number;height?:number}[];format?:{duration?:string}};
const duration=Number(raw.format?.duration);const video=raw.streams?.find(s=>s.codec_type==="video");
if(!video||!raw.streams?.some(s=>s.codec_type==="audio")||!Number.isFinite(duration)||duration<=0||duration>1800||!video.width||!video.height) throw new HarnessError("MEDIA_STREAM_INVALID","素材缺少有效音视频流或超出时长范围");
const frames:{path:string;time:number}[]=[];
for(const [index,fraction] of [0.15,0.5,0.85].entries()) {
const time=Math.round(duration*fraction*1000)/1000;const path=join(root,`frame-${index.toString()}.jpg`);
await runMedia(ffmpeg,["-nostdin","-v","error","-protocol_whitelist","file","-ss",time.toString(),"-i",source,"-frames:v","1","-vf","scale=512:512:force_original_aspect_ratio=decrease","-q:v","3","-n",path],root,signal);
frames.push({path,time});
}
return {source,duration,width:video.width,height:video.height,frames};
}
@@ -0,0 +1,19 @@
import {z} from "zod";
import {HarnessError} from "../errors/harness-error.js";
const number=z.number().finite().nonnegative();
const Segment=z.object({id:z.string().min(1),hook_id:z.string().min(1),episode_id:z.string(),episode:z.number().int().positive(),source_start:number,source_end:number,target_start:number,target_end:number,hold:number.default(0),semantic_role:z.string().min(1),continuity_group:z.string().min(1),source_gap_before:z.number().finite(),confidence:z.enum(["high","medium","low"]),reveal_risk:z.enum(["low","medium","high"]),cut_reason:z.string().min(1),intentional_discontinuity:z.boolean().optional(),discontinuity_reason:z.string().optional()});
const Recipe=z.object({schema_version:z.literal("2.0"),analysis_id:z.string(),drama_id:z.string(),title:z.string().optional(),creative_goal:z.string().optional(),reference_id:z.string().optional(),reference_evidence_tier:z.enum(["A","B","C"]).optional(),duration_strategy:z.literal("content_led"),withheld_information:z.array(z.string()).min(1),ending:z.object({cutoff_reason:z.string().min(1),before_withheld_reveal:z.literal(true),hold:number.default(0)}),segments:z.array(Segment).min(1).max(10),transitions:z.array(z.unknown()).optional(),subtitle_path:z.never().optional()});
export function bindRecipe(value:unknown,binding:{analysisId:string;dramaId:string;analysisPath:string;withheldInformation:string[];sources:{episodeId:string;episodeNo:number;source:string;duration:number}[]}){
const recipe=Recipe.parse(value);const fail=():never=>{throw new HarnessError("RECIPE_BINDING_INVALID","剪辑必须使用当前分析和授权源、精确 24fps 时间线,总长必须为 10 秒");};
if(recipe.analysis_id!==binding.analysisId||recipe.drama_id!==binding.dramaId||JSON.stringify(recipe.withheld_information)!==JSON.stringify(binding.withheldInformation))fail();
let end=0;const ids=new Set<string>();
const segments=recipe.segments.map(segment=>{
const source=binding.sources.find(s=>s.episodeId===segment.episode_id&&s.episodeNo===segment.episode);
if(!source||ids.has(segment.id))return fail();ids.add(segment.id);
for(const value of [segment.source_start,segment.source_end,segment.target_start,segment.target_end,segment.hold])if(Math.abs(value-Math.round(value*24)/24)>0.001)fail();
if(segment.source_end>source.duration||segment.source_end<=segment.source_start||Math.abs(segment.target_start-end)>0.001||Math.abs((segment.source_end-segment.source_start+segment.hold)-(segment.target_end-segment.target_start))>0.001)fail();
end=segment.target_end;return {...segment,source:source.source};
});
if(Math.abs(end-10)>0.001)fail();
return {...recipe,segments,target_analysis_path:binding.analysisPath,target_duration_preference:10,maximum_duration_seconds:300};
}
@@ -0,0 +1,9 @@
import {z} from "zod";
import {HarnessError} from "../errors/harness-error.js";
const Review=z.object({planHash:z.string(),reviewer:z.string().trim().min(1).max(100),policyCheckedAt:z.string().datetime({offset:true}),pictureAudioReviewed:z.literal(true),referenceReviewed:z.literal(true),storyReviewed:z.literal(true),packagingReviewed:z.literal(true),noUnresolvedFindings:z.literal(true),segmentEvidence:z.array(z.object({segmentId:z.string(),observation:z.string().trim().min(20).max(3000)})).min(1).max(10)}).strict();
export function assertSelectionReview(value:unknown,plan:{hash:string;segmentIds:string[]}){
const review=Review.parse(value);
const checked=Date.parse(review.policyCheckedAt);
if(review.planHash!==plan.hash||checked>Date.now()+60_000||Date.now()-checked>24*60*60*1000||review.segmentEvidence.length!==plan.segmentIds.length||new Set(review.segmentEvidence.map(item=>item.segmentId)).size!==plan.segmentIds.length||review.segmentEvidence.some(item=>!plan.segmentIds.includes(item.segmentId)))throw new HarnessError("SELECTION_REVIEW_INVALID","审核必须对应当前版本、每一个选段及本批次政策核对时间");
return review;
}
@@ -0,0 +1,48 @@
import {mkdtemp,readFile,stat} from "node:fs/promises";
import {join} from "node:path";
import {z} from "zod";
import {HarnessError} from "../errors/harness-error.js";
import {runMedia,mediaRuntime} from "./media-worker.js";
const Report=z.object({pass:z.boolean(),issues:z.array(z.object({code:z.string(),severity:z.string(),message:z.string()}))});
const validators={analysis:["validate_target_drama_analysis.py","--analysis"],recipe:["validate_recipe.py","--recipe"],hook:["validate_hook_map.py","--hook-map"]} as const;
export class SkillRunner{
constructor(private readonly options:{skillRoot:string;pythonExecutable:string}){}
async validate(kind:keyof typeof validators,input:string,root:string,signal:AbortSignal,recipe?:string){
const work=await mkdtemp(join(root,"validation-"));
const reportPath=join(work,"report.json");
const [script,flag]=validators[kind];
const args=["-B",join(this.options.skillRoot,"scripts",script),flag,input,"--report",reportPath];
if(kind==="hook"&&recipe)args.push("--recipe",recipe);
try{await runMedia(this.options.pythonExecutable,args,work,signal,30_000);}
catch(error){if(signal.aborted)throw error;if(!(error instanceof HarnessError)||error.code!=="MEDIA_EXEC_FAILED")throw error;}
signal.throwIfAborted();
try{
if((await stat(reportPath)).size>128*1024)throw new Error();
const result=Report.parse(JSON.parse(await readFile(reportPath,"utf8")));
// Output only issue codes: original script messages may contain private absolute paths.
return {pass:result.pass&&result.issues.every(issue=>issue.severity!=="error"),issues:result.issues.map(issue=>({code:issue.code,severity:issue.severity}))};
}catch{throw new HarnessError("EDITOR_VALIDATOR_FAILED","原始 Skill 校验器未生成有效报告,未放行");}
}
/** Internal only: the workflow must bind paths and enforce all pre-render gates first. */
async scoreReferences(input:string,root:string,signal:AbortSignal){
const work=await mkdtemp(join(root,"references-"));const output=join(work,"selected-reference.json");
try{await runMedia(this.options.pythonExecutable,["-B",join(this.options.skillRoot,"scripts/score_reference_candidates.py"),"--input",input,"--output",output],work,signal,30_000);}
catch(error){if(signal.aborted||!(error instanceof HarnessError)||error.code!=="MEDIA_EXEC_FAILED")throw error;}
signal.throwIfAborted();if((await stat(output)).size>256*1024)throw new HarnessError("REFERENCE_REPORT_INVALID","参考报告超限");
return z.object({status:z.string(),selected_reference:z.record(z.string(),z.unknown()).nullable(),warnings:z.array(z.string())}).parse(JSON.parse(await readFile(output,"utf8")));
}
/** Internal only: the workflow must bind paths and enforce all pre-render gates first. */
async render(recipe:string,root:string,signal:AbortSignal){
const work=await mkdtemp(join(root,"render-"));const output=join(work,"preview.mp4");
const runtime=await mediaRuntime();
const binaries=["--ffmpeg",runtime.ffmpeg,"--ffprobe",runtime.ffprobe];
await runMedia(this.options.pythonExecutable,["-B",join(this.options.skillRoot,"scripts/render_exact.py"),"--recipe",recipe,"--work-dir",work,"--output",output,...binaries],work,signal,300_000);
try{await runMedia(this.options.pythonExecutable,["-B",join(this.options.skillRoot,"scripts/qa_video.py"),"--video",output,"--recipe",recipe,"--matches",join(work,"matches.json"),"--work-dir",work,...binaries],work,signal,300_000);}
catch(error){if(signal.aborted||!(error instanceof HarnessError)||error.code!=="MEDIA_EXEC_FAILED")throw error;}
signal.throwIfAborted();
const report=join(work,"qa-report.json");if((await stat(report)).size>256*1024)throw new HarnessError("QA_REPORT_INVALID","QA 报告超过限制");
const qa=z.object({pass:z.boolean(),sha256:z.string().regex(/^[a-f0-9]{64}$/),probe:z.object({duration:z.number().positive().max(300)}),checks:z.record(z.string(),z.boolean()),audio_sync:z.object({results:z.array(z.unknown())}),black_events:z.array(z.string()),silence_events:z.array(z.string()),freeze_events:z.array(z.string())}).parse(JSON.parse(await readFile(report,"utf8")));
return {output,duration:qa.probe.duration,sha256:qa.sha256,checks:qa.checks,machinePass:qa.pass,reviewStatus:"REVIEW" as const,events:{black:qa.black_events.length,silence:qa.silence_events.length,freeze:qa.freeze_events.length},audioSync:qa.audio_sync.results};
}
}