|
|
@@ -281,7 +281,7 @@ export async function moderateImage(params: ModerateImageParams = {}): Promise<a
|
|
|
}
|
|
|
|
|
|
export interface ModerateVideoParams {
|
|
|
- /** 本地视频文件(Web);与 uni 的 filePath 二选一或同时有远端字段 */
|
|
|
+ /** 可选;与图片同一套 Tus 上传后应只传 `video_url`,不在此再传文件 */
|
|
|
file?: File | null;
|
|
|
filePath?: string;
|
|
|
path?: string;
|
|
|
@@ -294,9 +294,8 @@ export interface ModerateVideoParams {
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
- * 上传视频并进入审核:POST `/api/v1/video/submit`,multipart/form-data(与 Apifox 一致)
|
|
|
- * 字段:`file`(可选,二进制)+ `video_path` / `video_url` / `text`(可选)
|
|
|
- * 服务基址:`BASE_AI_MODERATE_URL`(默认 https://verify.ailien.shop:8444)
|
|
|
+ * 仅提交「已上传视频」的审核任务:POST `/api/v1/video/submit`
|
|
|
+ * 与图片共用 Tus 上传拿到地址后,传 `video_url`;`getVideoModerateResult` 查询审核进度/结果。
|
|
|
*/
|
|
|
export async function moderateVideo(params: ModerateVideoParams = {}): Promise<any> {
|
|
|
const fd = new FormData();
|
|
|
@@ -344,7 +343,7 @@ export async function moderateVideo(params: ModerateVideoParams = {}): Promise<a
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
- * 查询视频异步审核结果:GET `/api/v1/video/status/{task_id}`
|
|
|
+ * 查询视频审核进度与结果:GET `/api/v1/video/status/{task_id}`
|
|
|
* @param data.task_id 或传入字符串即 task_id
|
|
|
*/
|
|
|
export async function getVideoModerateResult(data: { task_id?: string } | string): Promise<any> {
|
|
|
@@ -386,6 +385,165 @@ export async function getVideoModerateResult(data: { task_id?: string } | string
|
|
|
return body;
|
|
|
}
|
|
|
|
|
|
+function mapVideoModerateReasonToUserMessage(raw?: string | null): string {
|
|
|
+ return mapModerateReasonToUserMessage(raw).replace(/图片/g, "视频");
|
|
|
+}
|
|
|
+
|
|
|
+function pickVideoTaskIdFromSubmit(res: unknown): string {
|
|
|
+ const r = res as Record<string, any> | null | undefined;
|
|
|
+ if (!r || typeof r !== "object") return "";
|
|
|
+ let cur: any = r.data !== undefined ? r.data : r;
|
|
|
+ if (cur && typeof cur === "object" && cur.data !== undefined) cur = cur.data;
|
|
|
+ if (cur && typeof cur === "object") {
|
|
|
+ return String(cur.task_id ?? cur.taskId ?? cur.taskID ?? "").trim();
|
|
|
+ }
|
|
|
+ return "";
|
|
|
+}
|
|
|
+
|
|
|
+function flattenVideoStatusPayload(body: Record<string, any>): Record<string, any> {
|
|
|
+ const d = body?.data;
|
|
|
+ if (d && typeof d === "object" && !Array.isArray(d)) {
|
|
|
+ return { ...body, ...d };
|
|
|
+ }
|
|
|
+ return body;
|
|
|
+}
|
|
|
+
|
|
|
+function isVideoModerationStatusTerminal(payload: Record<string, any>): boolean {
|
|
|
+ const p = flattenVideoStatusPayload(payload);
|
|
|
+ const s = String(p?.status ?? p?.state ?? "").toLowerCase();
|
|
|
+ const terminal = [
|
|
|
+ "completed",
|
|
|
+ "complete",
|
|
|
+ "success",
|
|
|
+ "succeeded",
|
|
|
+ "done",
|
|
|
+ "finished",
|
|
|
+ "failed",
|
|
|
+ "fail",
|
|
|
+ "rejected",
|
|
|
+ "error",
|
|
|
+ "cancelled",
|
|
|
+ "canceled"
|
|
|
+ ];
|
|
|
+ if (terminal.includes(s)) return true;
|
|
|
+ const r = p?.result;
|
|
|
+ if (r && typeof r === "object" && (r.flagged !== undefined || r.passed !== undefined || r.approved !== undefined)) {
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+ return false;
|
|
|
+}
|
|
|
+
|
|
|
+function isVideoModerationRejectedPayload(payload: Record<string, any>): boolean {
|
|
|
+ const p = flattenVideoStatusPayload(payload);
|
|
|
+ if (p?.flagged === true) return true;
|
|
|
+ const r = p?.result;
|
|
|
+ if (r && typeof r === "object" && r.flagged === true) return true;
|
|
|
+ const s = String(p?.status ?? "").toLowerCase();
|
|
|
+ if (["failed", "fail", "rejected", "error"].includes(s)) return true;
|
|
|
+ return false;
|
|
|
+}
|
|
|
+
|
|
|
+function pickVideoRejectReason(payload: Record<string, any>): string | null {
|
|
|
+ const p = flattenVideoStatusPayload(payload);
|
|
|
+ const r = p?.result;
|
|
|
+ const fromResult = r && typeof r === "object" ? (r.reason ?? r.message) : null;
|
|
|
+ const raw = fromResult ?? p?.reason ?? p?.message ?? p?.msg;
|
|
|
+ return raw != null && typeof raw === "string" ? raw : null;
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * 是否为视频(MIME 或后缀)。上传与图片同走 Tus;通过后以 `moderateVideo({ video_url })` + 轮询区分审核链路。
|
|
|
+ */
|
|
|
+export function isLikelyVideoFileForAiUpload(file: File): boolean {
|
|
|
+ if (!(file instanceof File)) return false;
|
|
|
+ if (typeof file.type === "string" && file.type.startsWith("video/")) return true;
|
|
|
+ const n = file.name || "";
|
|
|
+ return /\.(mp4|m4v|webm|ogg|mov)(\?.*)?$/i.test(n);
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * Tus 上传 → finalize → 返回公网 URL(图片/视频同一套上传;不含审核)
|
|
|
+ */
|
|
|
+async function uploadViaTusToPublicUrl(file: File, onProgress?: (p: number) => void): Promise<string> {
|
|
|
+ const report = (n: number) => onProgress?.(Math.min(100, Math.max(0, Math.round(n))));
|
|
|
+ report(5);
|
|
|
+ const buf = await file.arrayBuffer();
|
|
|
+ const size = buf.byteLength;
|
|
|
+ report(18);
|
|
|
+
|
|
|
+ const defaultName = isLikelyVideoFileForAiUpload(file) ? `video_${Date.now()}.mp4` : `img_${Date.now()}.jpg`;
|
|
|
+ const { response: sessionRes, body: sessionBody } = await createUploadSession({
|
|
|
+ filename: file.name || defaultName,
|
|
|
+ size
|
|
|
+ });
|
|
|
+ const uploadId = pickUploadIdFromResponse(sessionRes, sessionBody);
|
|
|
+ if (!uploadId) {
|
|
|
+ throw new Error("创建上传会话失败:未返回 upload_id");
|
|
|
+ }
|
|
|
+
|
|
|
+ const publicUrl = `${AI_UPLOAD_FILES_PUBLIC_BASE}/${uploadId}`;
|
|
|
+
|
|
|
+ report(35);
|
|
|
+ await patchBinaryToUploadChunked(uploadId, buf, ratio => {
|
|
|
+ report(35 + ratio * (82 - 35));
|
|
|
+ });
|
|
|
+ report(82);
|
|
|
+
|
|
|
+ const finalizeBody = await finalizeUploadSession(uploadId, {});
|
|
|
+ report(90);
|
|
|
+
|
|
|
+ const urlFromApi = pickFileUrlFromBody(finalizeBody);
|
|
|
+ const fileUrl = urlFromApi || publicUrl;
|
|
|
+ if (!fileUrl) {
|
|
|
+ throw new Error("上传完成确认失败:未返回文件地址");
|
|
|
+ }
|
|
|
+ return fileUrl;
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * Tus 已得到视频 URL:`moderateVideo` 仅提交审核(video_url),`getVideoModerateResult` 轮询至结束。
|
|
|
+ */
|
|
|
+async function submitAndAwaitVideoModeration(fileUrl: string, onProgress?: (p: number) => void): Promise<void> {
|
|
|
+ const report = (n: number) => onProgress?.(Math.min(100, Math.max(0, Math.round(n))));
|
|
|
+ const submitRes: any = await moderateVideo({ video_url: fileUrl, text: "", video_path: "" });
|
|
|
+ report(92);
|
|
|
+ const taskId = pickVideoTaskIdFromSubmit(submitRes);
|
|
|
+ const submitFlat =
|
|
|
+ submitRes && typeof submitRes === "object" ? flattenVideoStatusPayload(submitRes as Record<string, any>) : {};
|
|
|
+
|
|
|
+ if (!taskId) {
|
|
|
+ if (isVideoModerationStatusTerminal(submitFlat)) {
|
|
|
+ if (isVideoModerationRejectedPayload(submitFlat)) {
|
|
|
+ throw new Error(mapVideoModerateReasonToUserMessage(pickVideoRejectReason(submitFlat)));
|
|
|
+ }
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const c = submitRes?.code;
|
|
|
+ if (c === 200 || c === 0) return;
|
|
|
+ throw new Error("视频审核提交未返回任务信息,请稍后重试");
|
|
|
+ }
|
|
|
+
|
|
|
+ const maxMs = 5 * 60 * 1000;
|
|
|
+ const step = 2000;
|
|
|
+ const t0 = Date.now();
|
|
|
+ let pollCount = 0;
|
|
|
+ while (Date.now() - t0 < maxMs) {
|
|
|
+ const raw: any = await getVideoModerateResult(taskId);
|
|
|
+ const flat = raw && typeof raw === "object" ? flattenVideoStatusPayload(raw as Record<string, any>) : {};
|
|
|
+ pollCount += 1;
|
|
|
+ report(Math.min(98, 92 + Math.min(6, pollCount)));
|
|
|
+
|
|
|
+ if (isVideoModerationStatusTerminal(flat)) {
|
|
|
+ if (isVideoModerationRejectedPayload(flat)) {
|
|
|
+ throw new Error(mapVideoModerateReasonToUserMessage(pickVideoRejectReason(flat)));
|
|
|
+ }
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ await new Promise<void>(resolve => setTimeout(resolve, step));
|
|
|
+ }
|
|
|
+ throw new Error("视频审核等待超时,请稍后重试");
|
|
|
+}
|
|
|
+
|
|
|
export interface UploadFileViaAiOptions {
|
|
|
/** 0~100 */
|
|
|
onProgress?: (progress: number) => void;
|
|
|
@@ -394,7 +552,8 @@ export interface UploadFileViaAiOptions {
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
- * 浏览器 File 走 Tus 上传 → finalize →(可选)AI 审核,返回可访问的文件 URL
|
|
|
+ * 图片与视频均:Tus 上传 → finalize 得同一套访问 URL。
|
|
|
+ * 图片再 `moderateImage`;视频再 `moderateVideo({ video_url })` + `getVideoModerateResult` 轮询。`skipModerate` 则只上传不审。
|
|
|
*/
|
|
|
export async function uploadFileViaAi(file: File, options: UploadFileViaAiOptions = {}): Promise<string> {
|
|
|
const { onProgress, skipModerate } = options;
|
|
|
@@ -403,49 +562,23 @@ export async function uploadFileViaAi(file: File, options: UploadFileViaAiOption
|
|
|
onProgress?.(Math.min(100, Math.max(0, Math.round(p))));
|
|
|
};
|
|
|
|
|
|
- let uploadId = "";
|
|
|
try {
|
|
|
- report(5);
|
|
|
- const buf = await file.arrayBuffer();
|
|
|
- const size = buf.byteLength;
|
|
|
- report(18);
|
|
|
-
|
|
|
- const { response: sessionRes, body: sessionBody } = await createUploadSession({
|
|
|
- filename: file.name || `img_${Date.now()}.jpg`,
|
|
|
- size
|
|
|
- });
|
|
|
- uploadId = pickUploadIdFromResponse(sessionRes, sessionBody);
|
|
|
- if (!uploadId) {
|
|
|
- throw new Error("创建上传会话失败:未返回 upload_id");
|
|
|
- }
|
|
|
-
|
|
|
- const publicUrl = `${AI_UPLOAD_FILES_PUBLIC_BASE}/${uploadId}`;
|
|
|
-
|
|
|
- report(35);
|
|
|
- await patchBinaryToUploadChunked(uploadId, buf, ratio => {
|
|
|
- report(35 + ratio * (82 - 35));
|
|
|
- });
|
|
|
- report(82);
|
|
|
-
|
|
|
- const finalizeBody = await finalizeUploadSession(uploadId, {});
|
|
|
- report(90);
|
|
|
-
|
|
|
- const urlFromApi = pickFileUrlFromBody(finalizeBody);
|
|
|
- const fileUrl = urlFromApi || publicUrl;
|
|
|
- if (!fileUrl) {
|
|
|
- throw new Error("上传完成确认失败:未返回文件地址");
|
|
|
- }
|
|
|
+ const fileUrl = await uploadViaTusToPublicUrl(file, onProgress);
|
|
|
|
|
|
if (!skipModerate) {
|
|
|
- const moderateRes = await moderateImage({
|
|
|
- text: "",
|
|
|
- image_urls: fileUrl,
|
|
|
- file
|
|
|
- });
|
|
|
- const first = moderateRes?.results?.[0];
|
|
|
- if (first?.flagged) {
|
|
|
- if (first.reason) console.warn("[AI审核]", first.reason);
|
|
|
- throw new Error(mapModerateReasonToUserMessage(first.reason));
|
|
|
+ if (isLikelyVideoFileForAiUpload(file)) {
|
|
|
+ await submitAndAwaitVideoModeration(fileUrl, onProgress);
|
|
|
+ } else {
|
|
|
+ const moderateRes = await moderateImage({
|
|
|
+ text: "",
|
|
|
+ image_urls: fileUrl,
|
|
|
+ file
|
|
|
+ });
|
|
|
+ const first = moderateRes?.results?.[0];
|
|
|
+ if (first?.flagged) {
|
|
|
+ if (first.reason) console.warn("[AI审核]", first.reason);
|
|
|
+ throw new Error(mapModerateReasonToUserMessage(first.reason));
|
|
|
+ }
|
|
|
}
|
|
|
}
|
|
|
|
|
|
@@ -469,7 +602,7 @@ export function isAiImageModerationEnabled(): boolean {
|
|
|
return import.meta.env.VITE_UPLOAD_IMAGE_AI_MODERATE !== "false";
|
|
|
}
|
|
|
|
|
|
-/** 图片走 Tus + 审核,返回与 uploadMore 相近结构 */
|
|
|
+/** 图/视频均 Tus 上传;图 moderateImage、视频 moderateVideo(url)+轮询;返回与 uploadMore 相近结构 */
|
|
|
export async function uploadImageAsUploadMoreResponse(file: File): Promise<UploadMoreLikeResponse> {
|
|
|
const fileUrl = await uploadFileViaAi(file);
|
|
|
return {
|
|
|
@@ -481,7 +614,7 @@ export async function uploadImageAsUploadMoreResponse(file: File): Promise<Uploa
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
- * FormData 内含字段 `file` 且为图片时走 AI 上传+审核,否则走原接口
|
|
|
+ * FormData 内含字段 `file` 且为图片或视频时走 Tus + 对应审核(图 moderateImage,视频 moderateVideo 的 video_url + 轮询),否则走原接口
|
|
|
*/
|
|
|
export function runUploadMoreWithOptionalAi<T = UploadMoreLikeResponse>(
|
|
|
params: FormData,
|
|
|
@@ -491,7 +624,12 @@ export function runUploadMoreWithOptionalAi<T = UploadMoreLikeResponse>(
|
|
|
return fallback();
|
|
|
}
|
|
|
const file = params.get("file");
|
|
|
- if (file instanceof File && typeof file.type === "string" && file.type.startsWith("image/")) {
|
|
|
+ if (!(file instanceof File)) {
|
|
|
+ return fallback();
|
|
|
+ }
|
|
|
+ const isImage = typeof file.type === "string" && file.type.startsWith("image/");
|
|
|
+ const isVideo = isLikelyVideoFileForAiUpload(file);
|
|
|
+ if (isImage || isVideo) {
|
|
|
return uploadImageAsUploadMoreResponse(file) as Promise<T>;
|
|
|
}
|
|
|
return fallback();
|