fix: resume transient original-job collection and guard probe input
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { request } from "node:https";
|
||||
import { lookup } from "node:dns/promises";
|
||||
import { isIP } from "node:net";
|
||||
import { readFile, writeFile, rename } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
@@ -14,7 +15,17 @@ export async function validateImageVideoUrl(value: string, resolver?: AddressRes
|
||||
try { url = new URL(value); } catch { throw new HarnessError("I2V_URL_DENIED", "Invalid media URL"); }
|
||||
// The storage provider may require a signed query. Only remove it from host validation.
|
||||
const hostOnly = new URL(url); hostOnly.search = "";
|
||||
const checked = await validateBaseUrl(hostOnly.href, {}, resolver);
|
||||
let resolutionFailed = false;
|
||||
const resolveAddresses: AddressResolver = resolver ?? (async hostname => (await lookup(hostname, { all: true })).map(result => ({ address: result.address, family: result.family as 4 | 6 })));
|
||||
let checked;
|
||||
try {
|
||||
checked = await validateBaseUrl(hostOnly.href, {}, async hostname => {
|
||||
try { return await resolveAddresses(hostname); } catch { resolutionFailed = true; throw new Error("DNS lookup failed"); }
|
||||
});
|
||||
} catch (error) {
|
||||
if (resolutionFailed) throw new HarnessError("I2V_TRANSFER_TRANSIENT", "Media DNS lookup temporarily unavailable; retain the original job");
|
||||
throw error;
|
||||
}
|
||||
const address = checked.addresses[0];
|
||||
if (!address) throw new HarnessError("I2V_URL_DENIED", "No public media address");
|
||||
return { url, address };
|
||||
@@ -24,24 +35,30 @@ export async function validateImageVideoUrl(value: string, resolver?: AddressRes
|
||||
export async function imageVideoTransfer(value: string, options: { bytes?: Buffer; contentType?: string; maxBytes?: number } = {}): Promise<Buffer> {
|
||||
const { url, address } = await validateImageVideoUrl(value);
|
||||
return new Promise((resolve, reject) => {
|
||||
const error = () => new HarnessError("I2V_TRANSFER_FAILED", "Media transfer failed or exceeded its bounds");
|
||||
const transient = () => new HarnessError("I2V_TRANSFER_TRANSIENT", "Media transfer temporarily unavailable; retain the original job");
|
||||
const rejected = () => new HarnessError("I2V_TRANSFER_REJECTED", "Media response violates the transfer policy or bounds");
|
||||
let failure: HarnessError | undefined;
|
||||
const stop = (error: HarnessError) => { failure = error; req.destroy(error); };
|
||||
const req = request(url, {
|
||||
family: isIP(address),
|
||||
method: options.bytes ? "PUT" : "GET",
|
||||
headers: options.bytes ? { "Content-Type": options.contentType ?? "application/octet-stream", "Content-Length": options.bytes.length } : {},
|
||||
lookup: (_hostname, _options, callback) => callback(null, address, isIP(address)),
|
||||
}, response => {
|
||||
if (!response.statusCode || response.statusCode < 200 || response.statusCode >= 300) { response.destroy(); req.destroy(error()); return; }
|
||||
if (!response.statusCode || response.statusCode < 200 || response.statusCode >= 300) {
|
||||
const retryable = response.statusCode !== undefined && ([401, 403, 404, 408, 410, 429].includes(response.statusCode) || response.statusCode >= 500);
|
||||
stop(retryable ? transient() : rejected()); response.destroy(); return;
|
||||
}
|
||||
const limit = options.maxBytes ?? (options.bytes ? 1024 * 1024 : 256 * 1024 * 1024);
|
||||
if (Number(response.headers["content-length"] ?? 0) > limit) { response.destroy(); req.destroy(error()); return; }
|
||||
if (!options.bytes && !/^video\/mp4(?:;|$)/i.test(response.headers["content-type"] ?? "")) { response.destroy(); req.destroy(error()); return; }
|
||||
if (Number(response.headers["content-length"] ?? 0) > limit) { stop(rejected()); response.destroy(); return; }
|
||||
if (!options.bytes && !/^video\/mp4(?:;|$)/i.test(response.headers["content-type"] ?? "")) { stop(rejected()); response.destroy(); return; }
|
||||
let total = 0; const chunks: Buffer[] = [];
|
||||
response.on("data", (chunk: Buffer) => { total += chunk.length; if (total > limit) req.destroy(error()); else chunks.push(chunk); });
|
||||
response.once("error", () => reject(error()));
|
||||
response.on("data", (chunk: Buffer) => { total += chunk.length; if (total > limit) stop(rejected()); else chunks.push(chunk); });
|
||||
response.once("error", () => { clearTimeout(timer); reject(failure ?? transient()); });
|
||||
response.once("end", () => { clearTimeout(timer); resolve(Buffer.concat(chunks)); });
|
||||
});
|
||||
const timer = setTimeout(() => req.destroy(error()), 120_000);
|
||||
req.once("error", () => { clearTimeout(timer); reject(error()); });
|
||||
const timer = setTimeout(() => stop(transient()), 120_000);
|
||||
req.once("error", () => { clearTimeout(timer); reject(failure ?? transient()); });
|
||||
req.end(options.bytes);
|
||||
});
|
||||
}
|
||||
@@ -66,10 +83,18 @@ export function createImageVideoCollector(options: { skillRoot: string; python:
|
||||
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 writeFile(path, bytes, { mode: 0o600 });
|
||||
const path = join(directory, "candidate.mp4"); await localMediaStorage(writeFile(path, bytes, { mode: 0o600 }));
|
||||
const validation = await verifyImageVideo(path, directory, duration, options);
|
||||
if (validation.sha256 !== sha256(await readFile(path))) throw new HarnessError("I2V_INVALID_MP4", "Validated media changed");
|
||||
await rename(path, join(directory, "video.mp4"));
|
||||
if (validation.sha256 !== sha256(await localMediaStorage(readFile(path)))) throw new HarnessError("I2V_INVALID_MP4", "Validated media changed");
|
||||
await localMediaStorage(rename(path, join(directory, "video.mp4")));
|
||||
return validation;
|
||||
};
|
||||
}
|
||||
|
||||
async function localMediaStorage<T>(operation: Promise<T>): Promise<T> {
|
||||
try { return await operation; } catch (error) {
|
||||
const code = error instanceof Error && "code" in error ? error.code : undefined;
|
||||
if (typeof code === "string" && ["EIO", "EBUSY", "EMFILE", "ENFILE", "ENOSPC", "EAGAIN"].includes(code)) throw new HarnessError("I2V_TRANSFER_TRANSIENT", "Local media storage temporarily unavailable; retain the original job");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,6 +80,7 @@ export class ImageVideoService {
|
||||
if (r.state === "unknown_submission" || r.state === "ready" || r.state === "failed" || Date.now() - (r.lastPollAt ?? 0) < 30_000) return this.public(r);
|
||||
if (!r.jobId && !(r.upload && ["importing", "unknown_upload"].includes(r.state))) return this.public(r);
|
||||
r.lastPollAt = Date.now(); await this.save(r);
|
||||
let collecting = false;
|
||||
try {
|
||||
if (!r.jobId) {
|
||||
const result = await this.options.remote.importStatus(r);
|
||||
@@ -90,14 +91,16 @@ export class ImageVideoService {
|
||||
if (result.state === "succeeded") {
|
||||
if (!result.artifactUrl) throw new Error("No unambiguous MP4 artifact");
|
||||
r.artifactUrl = result.artifactUrl; r.state = "validating"; await this.save(r);
|
||||
collecting = true;
|
||||
r.validation = validationSchema.parse(await this.options.collect(result.artifactUrl, join(this.root, id), r.draft.durationSeconds));
|
||||
if (Math.abs(r.validation.videoSeconds - r.draft.durationSeconds) > 0.002 || Math.abs(r.validation.audioSeconds - r.draft.durationSeconds) > 0.05) throw new Error("Duration mismatch");
|
||||
r.state = "ready"; delete r.error;
|
||||
} else { r.state = result.state; if (result.state === "failed") r.error = "H3 生成失败;未自动重投。"; }
|
||||
}
|
||||
} catch {
|
||||
if (r.state === "validating") r.state = "failed";
|
||||
r.error = r.state === "failed" ? "成片技术验证失败:请核实 MP4、精确时长、尺寸、帧率和音轨;未自动重投。" : "状态响应暂不可用或契约不明确;保留原任务,下次只读查询。";
|
||||
} catch (error) {
|
||||
const retryCollection = collecting && error instanceof HarnessError && error.code === "I2V_TRANSFER_TRANSIENT";
|
||||
if (collecting && !retryCollection) r.state = "failed";
|
||||
r.error = retryCollection ? "成片收取暂时失败;30 秒后查询原任务并重试收取,不会重新生成。" : r.state === "failed" ? "成片技术验证失败:请核实 MP4、精确时长、尺寸、帧率和音轨;未自动重投。" : "状态响应暂不可用或契约不明确;保留原任务,下次只读查询。";
|
||||
}
|
||||
await this.save(r); return this.public(r);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user