diff --git a/docker-compose.yml b/docker-compose.yml index 8f6b75f..81e0b69 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,14 +1,14 @@ version: '3.8' services: - jimeng-free-api: - build: - context: ./ - dockerfile: Dockerfile - image: jimeng-free-api:latest - container_name: jimeng-free-api - ports: - - "8443:3302" + # jimeng-free-api: + # build: + # context: ./ + # dockerfile: Dockerfile + # image: jimeng-free-api:latest + # container_name: jimeng-free-api + # ports: + # - "8443:3302" jimeng-free-api-pro: build: context: ./ diff --git a/src/api/ImagesTaskCache.ts b/src/api/ImagesTaskCache.ts new file mode 100644 index 0000000..1202b8c --- /dev/null +++ b/src/api/ImagesTaskCache.ts @@ -0,0 +1,92 @@ +import fs from 'fs-extra'; +import path from 'path'; +// import { format as dateFormat } from 'date-fns'; +const timeZone = 'Asia/Shanghai'; // Beijing Time +import { formatInTimeZone } from 'date-fns-tz'; + +const LOG_PATH = path.resolve("./logs/images_task_cache.log"); + +function cacheLog(value: string, color?: string) { + try { + const head = `[ImagesTaskCache][${formatInTimeZone(new Date(),timeZone, "yyyy-MM-dd HH:mm:ss.SSS")}] `; + value = head + value; + // console.log(color ? value[color] : value); + fs.ensureDirSync(path.dirname(LOG_PATH)); + fs.appendFileSync(LOG_PATH, value + "\n"); + } + catch(err) { + console.error("ImagesTaskCache log write error:", err); + } +} + +export class ImagesTaskCache { + private static instance: ImagesTaskCache; + private taskCache: Map; + + private constructor() { + this.taskCache = new Map(); + cacheLog("ImagesTaskCache initialized"); + } + + public static getInstance(): ImagesTaskCache { + if (!ImagesTaskCache.instance) { + ImagesTaskCache.instance = new ImagesTaskCache(); + } + return ImagesTaskCache.instance; + } + + public startTask(taskId: string): void { + const startTime = Math.floor(Date.now() / 1000); // Current time in seconds + this.taskCache.set(taskId, startTime); + cacheLog(`Task started: ${taskId} at ${startTime}`); + } + + public finishTask(taskId: string, status: -1 | -2 | -3, url:string = ''): void { + if (this.taskCache.has(taskId)) { + this.taskCache.set(taskId, status); + let statusMessage = ''; + switch (status) { + case -1: + { + statusMessage = 'successfully'; + if (url) { + this.taskCache.set(taskId, url); + } + } + break; + case -2: statusMessage = 'failed'; break; + case -3: statusMessage = 'timed out'; break; + } + cacheLog(`Task ${taskId} finished ${statusMessage} (status: ${status})`); + } else { + cacheLog(`Attempted to finish non-existent task: ${taskId}`); + } + } + + public getTaskStatus(taskId: string): number | string | undefined { + return this.taskCache.get(taskId); + } + + public getPendingTasks(): string[] { + const pendingTasks: string[] = []; + for (const [taskId, status] of this.taskCache.entries()) { + if (typeof status == 'number' && status > 0) { + pendingTasks.push(taskId); + } + } + return pendingTasks; + } + + public logPendingTasksOnShutdown(): void { + const pendingTasks = this.getPendingTasks(); + if (pendingTasks.length > 0) { + cacheLog(`Pending tasks at shutdown: ${pendingTasks.join(', ')}`, 'yellow'); + } else { + cacheLog("No pending tasks at shutdown."); + } + } +} + +// Initialize the singleton instance when the module is loaded. +// This ensures it's ready when the service starts. +ImagesTaskCache.getInstance(); diff --git a/src/api/controllers/images.ts b/src/api/controllers/images.ts index a9054e3..7e3f56b 100644 --- a/src/api/controllers/images.ts +++ b/src/api/controllers/images.ts @@ -5,11 +5,13 @@ import EX from "@/api/consts/exceptions.ts"; import util from "@/lib/util.ts"; import { getCredit, receiveCredit, request } from "./core.ts"; import logger from "@/lib/logger.ts"; +import { ImagesTaskCache } from '@/api/ImagesTaskCache.ts'; const DEFAULT_ASSISTANT_ID = "513695"; export const DEFAULT_MODEL = "jimeng-3.0"; const DRAFT_VERSION = "3.0.2"; const MODEL_MAP = { + "jimeng-3.1": "high_aes_general_v30l_art_fangzhou:general_v3.0_18b", "jimeng-3.0": "high_aes_general_v30l:general_v3.0_18b", "jimeng-2.1": "high_aes_general_v21_L:general_v2.1_L", "jimeng-2.0-pro": "high_aes_general_v20_L:general_v2.0_L", @@ -24,6 +26,7 @@ export function getModel(model: string) { export async function generateImages( _model: string, + task_id: string, prompt: string, { width = 1024, @@ -38,6 +41,10 @@ export async function generateImages( }, refreshToken: string ) { + const imagesTaskCache = ImagesTaskCache.getInstance(); + imagesTaskCache.startTask(task_id); + + try { const model = getModel(_model); logger.info(`使用模型: ${_model} 映射模型: ${model} ${width}x${height} 精细度: ${sampleStrength}`); @@ -109,6 +116,7 @@ export async function generateImages( id: util.uuid(), height, width, + resolution_type: "1k", }, }, history_option: { @@ -131,7 +139,7 @@ export async function generateImages( throw new APIException(EX.API_IMAGE_GENERATION_FAILED, "记录ID不存在"); let status = 20, failCode, item_list = []; while (status === 20) { - await new Promise((resolve) => setTimeout(resolve, 1000)); + await new Promise((resolve) => setTimeout(resolve, 5000)); const result = await request("post", "/mweb/v1/get_history_by_ids", refreshToken, { data: { history_ids: [historyId], @@ -243,11 +251,25 @@ export async function generateImages( else throw new APIException(EX.API_IMAGE_GENERATION_FAILED); } - return item_list.map((item) => { + const imageUrls = item_list.map((item) => { if(!item?.image?.large_images?.[0]?.image_url) return item?.common_attr?.cover_url || null; return item.image.large_images[0].image_url; }); + const validImageUrls = imageUrls.filter(url => url !== null); + if (validImageUrls.length > 0) { + imagesTaskCache.finishTask(task_id, -1, validImageUrls.join(",")); // Success + } else { + // If no valid URLs but no explicit error thrown earlier, consider it a failure. + // This could happen if item_list is empty or items don't have video_url. + imagesTaskCache.finishTask(task_id, -2); // Failure + throw new APIException(EX.API_IMAGE_GENERATION_FAILED, "图片生成未返回有效链接"); + } + return validImageUrls; +}catch (error) { + imagesTaskCache.finishTask(task_id, -2); // Failure due to exception + throw error; // Re-throw the error to be handled by the caller +} } export async function uploadImages( diff --git a/src/api/controllers/video.ts b/src/api/controllers/video.ts index 7a46801..ee4a2a5 100644 --- a/src/api/controllers/video.ts +++ b/src/api/controllers/video.ts @@ -13,6 +13,7 @@ const DRAFT_VERSION = "3.0.5"; const DRAFT_V_VERSION = "1.0.0"; const MODEL_MAP = { "jimeng-v-3.0": "dreamina_ic_generate_video_model_vgfm_3.0", + "jimeng-v-3.0-pro": "dreamina_ic_generate_video_model_vgfm_3.0_pro", }; export function getModel(model: string) { @@ -20,19 +21,18 @@ export function getModel(model: string) { } //图片生成视频 首帧 export async function generateVideo( - _model: string, task_id: string, prompt: string, { - width = 512, - height = 512, - imgURL = "", + images = [], + isPro = false, duration = 5000, + ratio = '9:16', }: { - width: number; - height: number; - imgURL: string; + images: any[]; + isPro: boolean; duration: number; + ratio: string; }, refreshToken: string ) { @@ -40,13 +40,64 @@ export async function generateVideo( videoTaskCache.startTask(task_id); try { - if(!imgURL){ - throw new APIException(EX.API_REQUEST_PARAMS_INVALID); - return; + let _model = DEFAULT_MODEL; + if(isPro){ + _model = "jimeng-v-3.0-pro"; + } + let video_gen_inputs:any = { + type: "", + id: util.uuid(), + min_version: DRAFT_V_VERSION, + prompt: prompt, + video_mode:2, + fps:24, + duration_ms:duration, + }; + if(!images || !images.length){ + //无图片 + }else if(images.length === 1){ + //首帧 + video_gen_inputs.first_frame_image = { + type: "image", + id: util.uuid(), + source_from: "upload", + platform_type: 1, + name: "", + image_uri: images[0].url, + width: images[0].width, + height: images[0].height, + format: "", + uri: images[0].url, + } + }else if(images.length >= 2){ + //首尾帧 + video_gen_inputs.first_frame_image = { + type: "image", + id: util.uuid(), + source_from: "upload", + platform_type: 1, + name: "", + image_uri: images[0].url, + width: images[0].width, + height: images[0].height, + format: "", + uri: images[0].url, + } + video_gen_inputs.end_frame_image = { + type: "image", + id: util.uuid(), + source_from: "upload", + platform_type: 1, + name: "", + image_uri: images[1].url, + width: images[1].width, + height: images[1].height, + format: "", + uri: images[1].url, + } } const model = getModel(_model); - logger.info(`使用模型: ${_model} : ${model} 参考图片尺寸: ${width}x${height} 图片地址 ${imgURL} 持续时间: ${duration} 提示词: ${prompt}`); - + const { totalCredit } = await getCredit(refreshToken); if (totalCredit <= 0) await receiveCredit(refreshToken); @@ -120,30 +171,8 @@ export async function generateVideo( text_to_video_params:{ type: "", id: util.uuid(), - video_gen_inputs:[ - { - type: "", - id: util.uuid(), - min_version: DRAFT_V_VERSION, - prompt: prompt, - first_frame_image:{ - type: "image", - id: util.uuid(), - source_from: "upload", - platform_type: 1, - name: "", - image_uri: imgURL, - width: width, - height: height, - format: "", - uri: imgURL, - }, - video_mode:2, - fps:24, - duration_ms:duration, - } - ], - video_aspect_ratio:"9:16", + video_gen_inputs:[video_gen_inputs], + video_aspect_ratio:ratio, seed: Math.floor(Math.random() * 100000000) + 2500000000, model_req_key: model, }, @@ -174,7 +203,7 @@ export async function generateVideo( // let emptyCount = 30; while (status === 20) { - await new Promise((resolve) => setTimeout(resolve, 1000)); + await new Promise((resolve) => setTimeout(resolve, 10000)); const result = await request("post", "/mweb/v1/get_history_by_ids", refreshToken, { data: { history_ids: [historyId], diff --git a/src/api/routes/images.ts b/src/api/routes/images.ts index cc398db..84adbc0 100644 --- a/src/api/routes/images.ts +++ b/src/api/routes/images.ts @@ -4,19 +4,44 @@ import Request from "@/lib/request/Request.ts"; import { generateImages } from "@/api/controllers/images.ts"; import { tokenSplit } from "@/api/controllers/core.ts"; import util from "@/lib/util.ts"; +import { ImagesTaskCache } from '@/api/ImagesTaskCache.ts'; export default { prefix: "/v1/images", + get: { + "/query": async (request: Request) => { + const imagesTaskCache = ImagesTaskCache.getInstance(); + request + .validate("query.task_id", _.isString) // 从 query 中校验 + const { + task_id, + } = request.query; // 从 query 中获取 + let res = imagesTaskCache.getTaskStatus(task_id); + // console.log("查询任务状态", task_id, 'res:',res); + if(typeof res === 'string'){ + return { + created: util.unixTimestamp(), + data:{task_id, url:res, status:-1}, + }; + }else{ + return { + created: util.unixTimestamp(), + data:{task_id, url:"", status:res||0}, + }; + } + }, + }, post: { "/generations": async (request: Request) => { request - .validate("body.model", v => _.isUndefined(v) || _.isString(v)) + // .validate("body.model", v => _.isUndefined(v) || _.isString(v)) + .validate("body.task_id", _.isString) .validate("body.prompt", _.isString) - .validate("body.negative_prompt", v => _.isUndefined(v) || _.isString(v)) + // .validate("body.negative_prompt", v => _.isUndefined(v) || _.isString(v)) .validate("body.width", v => _.isUndefined(v) || _.isFinite(v)) .validate("body.height", v => _.isUndefined(v) || _.isFinite(v)) - .validate("body.sample_strength", v => _.isUndefined(v) || _.isFinite(v)) + // .validate("body.sample_strength", v => _.isUndefined(v) || _.isFinite(v)) .validate("body.response_format", v => _.isUndefined(v) || _.isString(v)) .validate("headers.authorization", _.isString); // refresh_token切分 @@ -24,20 +49,21 @@ export default { // 随机挑选一个refresh_token const token = _.sample(tokens); const { - model, + // model, + task_id, prompt, - negative_prompt: negativePrompt, + // negative_prompt: negativePrompt, width, height, - sample_strength: sampleStrength, + // sample_strength: sampleStrength, response_format, } = request.body; const responseFormat = _.defaultTo(response_format, "url"); - const imageUrls = await generateImages(model, prompt, { + const imageUrls = await generateImages('jimeng-3.1', task_id, prompt, { width, height, - sampleStrength, - negativePrompt, + sampleStrength:0.5, + negativePrompt:"", }, token); let data = []; if (responseFormat == "b64_json") { diff --git a/src/api/routes/video.ts b/src/api/routes/video.ts index 32b2f16..c5b8d9a 100644 --- a/src/api/routes/video.ts +++ b/src/api/routes/video.ts @@ -35,13 +35,12 @@ export default { post: { "/generations": async (request: Request) => { request - .validate("body.model", v => _.isUndefined(v) || _.isString(v)) .validate("body.task_id", _.isString) .validate("body.prompt", _.isString) - .validate("body.image", v => _.isUndefined(v) || _.isString(v)) - .validate("body.width", v => _.isUndefined(v) || _.isFinite(v)) - .validate("body.height", v => _.isUndefined(v) || _.isFinite(v)) + .validate("body.images", v => _.isUndefined(v) || _.isArray(v)) + .validate("body.is_pro", v => _.isUndefined(v) || _.isBoolean(v)) .validate("body.duration", v => _.isUndefined(v) || _.isFinite(v)) + .validate("body.ratio", v => _.isUndefined(v) || _.isString(v)) .validate("headers.authorization", _.isString); // refresh_token切分 const tokens = tokenSplit(request.headers.authorization); @@ -49,20 +48,19 @@ export default { const token = _.sample(tokens); const { task_id, - model, prompt, - width, - height, - image, + images, + is_pro, duration, + ratio, } = request.body; // const imageUrls = await generateVideo(model, task_id, prompt, { //不等结果 直接返回 - generateVideo(model, task_id, prompt, { - width, - height, - imgURL:image, + generateVideo(task_id, prompt, { + images:images, + isPro:is_pro, duration:duration*1000, + ratio, }, token); // let data = []; // data = imageUrls.map((url) => ({