Merge branch 'develop' of https://github.com/HBAI-Ltd/Toonflow-app into develop

# Conflicts:
#	src/types/database.d.ts
This commit is contained in:
zhishi 2026-04-12 17:47:38 +08:00
commit 493e9c2ef0
18 changed files with 1087 additions and 1230 deletions

View File

@ -112,13 +112,13 @@ Toonflow 是面向短剧生产的 AI 工作台,围绕“策划 → 编剧 →
5. 切换到 ProductionAgent在无限画布中组织分镜、素材与视频节点。
6. 对分镜图进行节点化精调后回流工作台,完成视频拼接与导出。
## 📺 视频教程(待更新,老版本教程已无参考价值)
## 📺 视频教程
https://www.bilibili.com/video/BV1na6wB6Ea2
[![Toonflow 8 分钟快速上手 AI 视频](./docs/videoCover.png)](https://www.bilibili.com/video/BV1na6wB6Ea2)
[![Toonflow 8 分钟快速上手 AI 视频](./docs/videoCover.png)](https://www.bilibili.com/video/BV1oXD7BqEqJ)
**Toonflow 8 分钟快速上手 AI 视频**
👉 [点击观看](https://www.bilibili.com/video/BV1na6wB6Ea2/?share_source=copy_web&vd_source=5b718c25439a901a34c7bc0c1d35b38e)
👉 [点击观看](https://www.bilibili.com/video/BV1oXD7BqEqJ)
📱 手机微信扫码观看

View File

@ -134,7 +134,7 @@ declare const exports: {
const vendor: VendorConfig = {
id: "minimax",
version: "2.0",
version: "2.1",
author: "Toonflow",
name: "MiniMax(海螺AI)",
description: "MiniMax官方接口适配支持M系列推理文本模型、文生图/图生图、视频生成(文生视频、图生视频、首尾帧生成)能力 \n [前往平台](https://minimaxi.com/)",

View File

@ -133,7 +133,7 @@ declare const exports: {
const vendor: VendorConfig = {
id: "volcengine",
version: "2.0",
version: "2.1",
author: "leeqi",
name: "火山引擎(豆包)",
description:
@ -326,43 +326,120 @@ const imageRequest = async (config: ImageConfig, model: ImageModel): Promise<str
const baseUrl = getBaseUrl();
const headers = getHeaders();
const content: any[] = [];
const body: any = {
model: model.modelName,
prompt: config.prompt || "",
response_format: "url",
watermark: false,
};
if (config.prompt) {
content.push({ type: "text", text: config.prompt });
const isOldModel = model.modelName.includes("seedream-3-0");
const is5Lite = model.modelName.includes("seedream-5-0-lite");
// sequential_image_generation 仅 seedream 5.0-lite/4.5/4.0 支持
if (!isOldModel) {
body.sequential_image_generation = "disabled";
}
if (config.referenceList && config.referenceList.length > 0) {
for (const ref of config.referenceList) {
content.push({
type: "image_url",
image_url: { url: ref.base64 },
});
// 参考图片:单图为 string多图为 arrayseedream-3.0-t2i 不支持 image 参数)
if (!isOldModel && config.referenceList && config.referenceList.length > 0) {
const images = config.referenceList.map((ref) => ref.base64);
body.image = images.length === 1 ? images[0] : images;
}
// 尺寸处理:优先使用推荐像素值,未匹配则直接传分辨率字符串让模型自行决定
const [w, h] = config.aspectRatio.split(":").map(Number);
const sizeTable: Record<string, Record<string, string>> = {
"1K": {
"1:1": "1024x1024",
"4:3": "1152x864",
"3:4": "864x1152",
"16:9": "1280x720",
"9:16": "720x1280",
"3:2": "1248x832",
"2:3": "832x1248",
"21:9": "1512x648",
},
"2K": {
"1:1": "2048x2048",
"4:3": "2304x1728",
"3:4": "1728x2304",
"16:9": "2848x1600",
"9:16": "1600x2848",
"3:2": "2496x1664",
"2:3": "1664x2496",
"21:9": "3136x1344",
},
"4K": {
"1:1": "4096x4096",
"4:3": "4704x3520",
"3:4": "3520x4704",
"16:9": "5504x3040",
"9:16": "3040x5504",
"3:2": "4992x3328",
"2:3": "3328x4992",
"21:9": "6240x2656",
},
};
const sizeKey = config.size || "2K";
const ratioKey = config.aspectRatio;
const table = sizeTable[sizeKey];
if (table && table[ratioKey]) {
// 推荐像素值匹配到了,但需要检查是否满足模型最低像素要求
const [pw, ph] = table[ratioKey].split("x").map(Number);
const totalPixels = pw * ph;
if (isOldModel) {
// seedream-3.0-t2i: 像素范围 [512x512, 2048x2048]
body.size = table[ratioKey];
} else if (totalPixels < 3686400) {
// 1K 像素值不满足新模型最低要求,直接传 "2K" 让模型自行决定
body.size = "2K";
} else if (is5Lite && totalPixels > 10404496) {
// seedream-5.0-lite 最高 104044964K 超限,回退传 "2K"
body.size = "2K";
} else {
body.size = table[ratioKey];
}
} else if (isOldModel) {
// seedream-3.0-t2i: 像素范围 [512x512, 2048x2048],直接按比例计算
const base = sizeKey === "1K" ? 1024 : 2048;
const calcW = Math.min(2048, Math.round(base * Math.sqrt(w / h)));
const calcH = Math.min(2048, Math.round(base * Math.sqrt(h / w)));
body.size = `${Math.max(512, calcW)}x${Math.max(512, calcH)}`;
} else {
// 新模型未匹配推荐值时直接传分辨率字符串方式1由模型根据 prompt 自行决定尺寸
// seedream 5.0-lite 支持 "2K"/"3K"seedream 4.5 支持 "2K"/"4K"seedream 4.0 支持 "1K"/"2K"/"4K"
if (is5Lite) {
body.size = sizeKey === "4K" ? "3K" : sizeKey === "1K" ? "2K" : sizeKey;
} else {
body.size = sizeKey === "1K" ? "2K" : sizeKey;
}
}
const [w, h] = config.aspectRatio.split(":").map(Number);
const sizeMap: Record<string, { width: number; height: number }> = {
"1K": { width: 1024, height: Math.round(1024 * (h / w)) },
"2K": { width: 2048, height: Math.round(2048 * (h / w)) },
"4K": { width: 4096, height: Math.round(4096 * (h / w)) },
};
const size = sizeMap[config.size] || sizeMap["1K"];
const body = {
model: model.modelName,
content,
size: `${size.width}x${size.height}`,
response_format: "url",
};
logger(`[图片生成] 请求模型: ${model.modelName}`);
logger(`[图片生成] 请求模型: ${model.modelName}, 尺寸: ${body.size}`);
const response = await axios.post(`${baseUrl}/images/generations`, body, { headers });
const data = response.data;
if (data?.data?.[0]?.url) {
return await urlToBase64(data.data[0].url);
if (data?.error) {
throw new Error(`图片生成失败:${data.error.message || data.error.code}`);
}
// 从 data 数组中提取第一张成功的图片
if (data?.data && data.data.length > 0) {
for (const item of data.data) {
if (item.url) {
return await urlToBase64(item.url);
}
if (item.b64_json) {
return item.b64_json;
}
if (item.error) {
throw new Error(`图片生成失败:${item.error.message || item.error.code}`);
}
}
}
throw new Error("图片生成失败:未返回有效结果");

File diff suppressed because one or more lines are too long

View File

@ -1,6 +1,6 @@
{
"name": "toonflow",
"version": "1.1.3",
"version": "1.1.4",
"description": "Toonflow 是一款 AI 短剧漫剧工具,能够利用 AI 技术将小说自动转化为剧本,并结合 AI 生成的图片和视频,实现高效的短剧创作。",
"author": "HBAI-Ltd <ltlctools@outlook.com>",
"license": "Apache-2.0",
@ -31,7 +31,8 @@
"dist:mac": "yarn build && electron-builder --mac",
"dist:linux": "yarn build && electron-builder --linux",
"debug:ai": "npx @ai-sdk/devtools",
"license": "bun run scripts/license.ts"
"license": "node scripts/license.ts",
"vendor2json": "node scripts/vendor2json.ts"
},
"dependencies": {
"@ai-sdk/anthropic": "^3.0.35",
@ -98,4 +99,4 @@
"better-sqlite3": "^12.8.0"
}
}
}
}

11
scripts/vendor2json.ts Normal file
View File

@ -0,0 +1,11 @@
import fs from "fs";
import path from "path";
const vendorDir = path.join("data", "vendor");
const files = fs.readdirSync(vendorDir).filter((f) => f.endsWith(".ts"));
const result: Record<string, string> = {};
for (const file of files) {
result[file] = fs.readFileSync(path.join(vendorDir, file), "utf-8");
}
fs.writeFileSync(path.join(vendorDir, "vendor.json"), JSON.stringify(result, null, 2), "utf-8");
console.log("Done, saved vendor.json");

View File

@ -1,234 +0,0 @@
import { Socket } from "socket.io";
import { tool } from "ai";
import { z } from "zod";
import u from "@/utils";
import Memory from "@/utils/agent/memory";
import { createSkillTools, parseFrontmatter, scanSkills, useSkill } from "@/utils/agent/skillsTools";
import useTools from "@/agents/productionAgent/tools";
import ResTool from "@/socket/resTool";
import * as fs from "fs";
import path from "path";
export interface AgentContext {
socket: Socket;
isolationKey: string;
text: string;
userMessageTime?: number;
abortSignal?: AbortSignal;
resTool: ResTool;
msg: ReturnType<ResTool["newMessage"]>;
}
function buildMemPrompt(mem: Awaited<ReturnType<Memory["get"]>>): string {
let memoryContext = "";
if (mem.rag.length) {
memoryContext += `[相关记忆]\n${mem.rag.map((r) => r.content).join("\n")}`;
}
if (mem.summaries.length) {
if (memoryContext) memoryContext += "\n\n";
memoryContext += `[历史摘要]\n${mem.summaries.map((s, i) => `${i + 1}. ${s.content}`).join("\n")}`;
}
if (mem.shortTerm.length) {
if (memoryContext) memoryContext += "\n\n";
memoryContext += `[近期对话]\n${mem.shortTerm.map((m) => `${m.role}: ${m.content}`).join("\n")}`;
}
return `## Memory\n以下是你对用户的记忆可作为参考但不要主动提及\n${memoryContext}`;
}
export async function decisionAI(ctx: AgentContext) {
const { isolationKey, text, abortSignal } = ctx;
const memory = new Memory("productionAgent", isolationKey);
await memory.add("user", text);
const skill = path.join(u.getPath("skills"), "production_agent_decision.md");
const prompt = await fs.promises.readFile(skill, "utf-8");
const projectInfo = await u.db("o_project").where("id", ctx.resTool.data.projectId).first();
if (!projectInfo) throw new Error(`项目不存在ID: ${ctx.resTool.data.projectId}`);
const [_, imageModelName] = projectInfo.imageModel!.split(":");
const [id, videoModelName] = projectInfo.videoModel!.split(":");
const data = await u.db("o_vendorConfig").where("id", id).select("models").first();
const models = JSON.parse(data!.models!);
const findData = models.find((i: any) => i.modelName == videoModelName);
const isRef = findData.mode.every((i: any) => Array.isArray(i));
const modelInfo = `项目使用的模型如下:\n图像模型${imageModelName}\n视频模型${videoModelName}\n多参${isRef ? "是" : "否"}`;
const mem = buildMemPrompt(await memory.get(text));
const { textStream } = await u.Ai.Text("productionAgent").stream({
messages: [
{ role: "system", content: prompt },
{ role: "assistant", content: mem + "\n" + modelInfo },
{ role: "user", content: text },
],
abortSignal,
tools: {
...memory.getTools(),
...useTools({ resTool: ctx.resTool, msg: ctx.msg }),
...createSubAgent(ctx),
},
onFinish: async (completion) => {
await memory.add("assistant:decision", removeAllXmlTags(completion.text));
},
});
return textStream;
}
function createSubAgent(parentCtx: AgentContext) {
const { resTool, abortSignal } = parentCtx;
const memory = new Memory("productionAgent", parentCtx.isolationKey);
async function runAgent({
prompt,
system,
name,
memoryKey,
tools: extraTools,
messages,
}: {
prompt: string;
system: string;
name: string;
memoryKey: string;
tools?: Record<string, any>;
messages?: { role: "user" | "assistant" | "system"; content: string }[];
}) {
parentCtx.msg.complete();
const subMsg = resTool.newMessage("assistant", name);
const text = subMsg.text();
let fullResponse = "";
const { textStream } = await u.Ai.Text("scriptAgent").stream({
system,
messages: messages ?? [{ role: "user", content: prompt }],
abortSignal,
tools: { ...extraTools, ...useTools({ resTool, msg: subMsg }) },
});
try {
for await (const chunk of textStream) {
await new Promise<void>((resolve) => setTimeout(() => resolve(), 1));
text.append(chunk);
fullResponse += chunk;
}
text.complete();
subMsg.complete();
} catch (err: any) {
text.complete();
subMsg.stop();
throw err;
}
if (fullResponse.trim()) {
await memory.add(memoryKey, removeAllXmlTags(fullResponse), {
name,
createTime: new Date(subMsg.datetime).getTime(),
});
}
parentCtx.msg = resTool.newMessage("assistant", "视频策划");
return fullResponse;
}
const promptInput = z.object({
prompt: z.string().describe("交给子Agent的任务简约描述100字以内"),
});
const run_sub_agent_execution = tool({
description: "执行层子Agent负责衍生资产、",
inputSchema: promptInput,
execute: async ({ prompt }) => {
const skill = path.join(u.getPath("skills"), "production_agent_execution.md");
const systemPrompt = await fs.promises.readFile(skill, "utf-8");
const addPrompt =
"\n" +
[
"你必须使用如下XML格式写入工作区\n```",
"拍摄计划:<scriptPlan>内容</scriptPlan>",
"分镜表:<storyboardTable>内容</storyboardTable>",
"分镜面板:<storyboardItem videoDesc='视频描述' prompt=提示词内容 track='分组' duration='视频推荐时间' associateAssetsIds='[该分镜所需的资产ID列表]'></storyboardItem>",
"```",
].join("\n");
const projectInfo = await u.db("o_project").where("id", resTool.data.projectId).first();
if (!projectInfo) throw new Error(`项目不存在ID: ${resTool.data.projectId}`);
const artSkills = await createArtSkills(projectInfo?.artStyle!, projectInfo?.directorManual!);
const [_, imageModelName] = projectInfo.imageModel!.split(":");
const [id, videoModelName] = projectInfo.videoModel!.split(":");
const data = await u.db("o_vendorConfig").where("id", id).select("models").first();
const models = JSON.parse(data!.models!);
const findData = models.find((i: any) => i.modelName == videoModelName);
const isRef = findData.mode.every((i: any) => Array.isArray(i));
const modelInfo = `项目使用的模型如下:\n图像模型${imageModelName}\n视频模型${videoModelName}\n多参${isRef ? "是" : "否"}`;
return runAgent({
prompt,
system: systemPrompt + addPrompt,
name: "执行导演",
memoryKey: "assistant:execution",
messages: [
{ role: "assistant", content: artSkills.prompt + `\n${modelInfo}` },
{ role: "user", content: prompt + addPrompt },
],
tools: { ...artSkills.tools },
});
},
});
const run_sub_agent_supervision = tool({
description: "监制层子Agent负责审核执行结果",
inputSchema: promptInput,
execute: async ({ prompt }) => {
const skill = path.join(u.getPath("skills"), "production_agent_supervision.md");
const systemPrompt = await fs.promises.readFile(skill, "utf-8");
return runAgent({
prompt,
system: systemPrompt,
name: "监制",
memoryKey: "assistant:supervision",
});
},
});
return { run_sub_agent_execution, run_sub_agent_supervision };
}
async function createArtSkills(artName: string, storyName: string) {
const artWorkerPath = u.getPath(["skills", "art_skills", artName, "driector_skills"]);
const storyWorkerPath = u.getPath(["skills", "story_skills", storyName, "driector_skills"]);
const skillList = [...(await scanSkills(artWorkerPath + "/*.md")), ...(await scanSkills(storyWorkerPath + "/*.md"))];
const mainSkills: { path: string; name: string; description: string }[] = [];
for (const skillPath of skillList) {
if (!fs.existsSync(skillPath)) throw new Error(`主技能文件不存在: ${skillPath}`);
const content = await fs.promises.readFile(skillPath, "utf-8");
const parsed = parseFrontmatter(content);
mainSkills.push({ path: skillPath, ...parsed });
}
const res = {
prompt: `## Skills
activate_skill
read_skill_file
${buildSkillPrompt(mainSkills)}`,
tools: createSkillTools(mainSkills, { mainSkill: mainSkills, secondarySkills: [], tertiarySkills: [] }),
};
return res;
}
function removeAllXmlTags(text: string): string {
text = text.replace(/<([a-zA-Z][\w-]*)(\s+[^>]*)?>([\s\S]*?)<\/\1>/g, "");
text = text.replace(/<([a-zA-Z][\w-]*)(\s+[^>]*)?\/>/g, "");
text = text.replace(/<\/?[a-zA-Z][\w-]*(\s+[^>]*)?>/g, "");
return text.trim();
}
export function buildSkillPrompt(skills: { name: string; description: string }[]): string {
const skillEntries = skills
.map((s) => ` <skill>\n <name>${s.name}</name>\n <description>${s.description}</description>\n </skill>`)
.join("\n");
return `
<available_skills>
${skillEntries}
</available_skills>`;
}

View File

@ -47,8 +47,8 @@ export async function decisionAI(ctx: AgentContext) {
if (!projectInfo) throw new Error(`项目不存在ID: ${ctx.resTool.data.projectId}`);
const [_, imageModelName] = projectInfo.imageModel!.split(":");
const [id, videoModelName] = projectInfo.videoModel!.split(":");
const data = await u.db("o_vendorConfig").where("id", id).select("models").first();
const models = JSON.parse(data!.models!);
const models = await u.vendor.getModelList(id);
if(!models.length) throw new Error(`项目使用的模型不存在ID: ${projectInfo.videoModel}`);
const findData = models.find((i: any) => i.modelName == videoModelName);
const isRef = findData.mode.every((i: any) => Array.isArray(i));
const modelInfo = `项目使用的模型如下:\n图像模型${imageModelName}\n视频模型${videoModelName}\n多参${isRef ? "是" : "否"}`;
@ -140,8 +140,8 @@ async function createSubAgent(parentCtx: AgentContext) {
const [_, imageModelName] = projectInfo.imageModel!.split(":");
const [id, videoModelName] = projectInfo.videoModel!.split(":");
const data = await u.db("o_vendorConfig").where("id", id).select("models").first();
const models = JSON.parse(data!.models!);
const models = await u.vendor.getModelList(id);
if(!models.length) throw new Error(`项目使用的模型不存在ID: ${projectInfo.videoModel}`);
const findData = models.find((i: any) => i.modelName == videoModelName);
const isRef = findData.mode.every((i: any) => Array.isArray(i));
const modelInfo = `项目使用的模型如下:\n图像模型${imageModelName}\n视频模型${videoModelName}\n多参${isRef ? "是" : "否"}`;

View File

@ -93,7 +93,7 @@ export default (toolCpnfig: ToolConfig) => {
const thinking = msg.thinking(`正在获取脚本内容...`);
const data = await u.db("o_script").whereIn("id", ids).select("content", "name");
const text = data && data.length ? data.map((d) => `<scriptItem name="${d.name}">${d.content}</scriptItem>`).join("\n") : "";
thinking.appendText(`获取到脚本内容:\n` + text);
thinking.appendText(`获取到脚本内容:\n` + JSON.stringify(data, null, 2));
thinking.updateTitle(`获取脚本内容完成`);
thinking.complete();
return text ?? "无数据";

File diff suppressed because one or more lines are too long

View File

@ -5,7 +5,6 @@ import sharp from "sharp";
import { success } from "@/lib/responseFormat";
import { validateFields } from "@/middleware/middleware";
import { Output } from "ai";
import { urlToBase64 } from "@/utils/vm";
const router = express.Router();
export default router.post(
@ -28,14 +27,6 @@ export default router.post(
.leftJoin("o_image", "o_assets.imageId", "o_image.id")
.whereIn("o_assets.id", parentIds as number[])
.select("o_assets.id", "o_image.filePath", "o_assets.describe");
const assetsSrcArr = await Promise.all(
parentAssetsData.map(async (item) => {
return {
src: await u.oss.getFileUrl(item.filePath),
id: item.id,
};
}),
);
assetsDataArr.forEach((i: any) => {
const parent = parentAssetsData.find((item) => item.id === i.assetsId);
if (parent) {
@ -43,8 +34,8 @@ export default router.post(
}
});
const imageUrlRecord: Record<number, string> = {};
assetsSrcArr.forEach((item) => {
imageUrlRecord[item.id] = item.src;
parentAssetsData.forEach((item) => {
if (item.filePath) imageUrlRecord[item.id] = item.filePath;
});
const rolePrompt = u.getArtPrompt(projectSettingData!.artStyle!, "art_skills", "art_character_derivative");
const toolPrompt = u.getArtPrompt(projectSettingData!.artStyle!, "art_skills", "art_prop_derivative");
@ -92,7 +83,7 @@ export default router.post(
],
});
const imageBase64 = imageUrlRecord[item.assetsId!] ? await urlToBase64(imageUrlRecord[item.assetsId!]) : null;
const imageBase64 = imageUrlRecord[item.assetsId!] ? await u.oss.getImageBase64(imageUrlRecord[item.assetsId!]) : null;
try {
const repeloadObj = {
prompt: text,

View File

@ -7,6 +7,9 @@ import axios from "axios";
const router = express.Router();
async function urlToBase64(imageUrl: string): Promise<string> {
if (imageUrl.startsWith("/oss/")) {
return await u.oss.getImageBase64(u.replaceUrl(imageUrl));
}
const response = await axios.get(imageUrl, { responseType: "arraybuffer" });
const contentType = response.headers["content-type"] || "image/png";
const base64 = Buffer.from(response.data, "binary").toString("base64");

View File

@ -5,7 +5,6 @@ import sharp from "sharp";
import { error, success } from "@/lib/responseFormat";
import { validateFields } from "@/middleware/middleware";
import { Output, tool } from "ai";
import { urlToBase64 } from "@/utils/vm";
import { assetItemSchema } from "@/agents/productionAgent/tools";
const router = express.Router();
export type AssetData = z.infer<typeof assetItemSchema>;
@ -157,7 +156,7 @@ async function getAssetsImageBase64(imageIds: number[]) {
const filePath = id2Path.get(id);
if (filePath) {
try {
return await urlToBase64(await u.oss.getFileUrl(filePath));
return await u.oss.getImageBase64(filePath);
} catch {
return null;
}

View File

@ -23,7 +23,7 @@ export default router.post(
"/",
validateFields({
source: z.enum(["toonflow", "github", "gitee", "atomgit"]),
url: z.url().optional(),
url: z.url().nullable().optional(),
}),
async (req, res) => {
const { source, url } = req.body;

View File

@ -85,7 +85,6 @@ export default router.post(
.db("o_vendorConfig")
.where("id", id)
.update({
inputValues: JSON.stringify(vendor.inputValues ?? {}),
models: JSON.stringify(vendor.models ?? []),
});
u.vendor.writeCode(id, tsCode);

View File

@ -1,4 +1,4 @@
// @db-hash 8669d907d827a8f55da1f1724d7ece06
// @db-hash 3296433eb24314b094ac5d3839c049c5
//该文件由脚本自动生成,请勿手动修改
export interface memories {
@ -201,7 +201,6 @@ export interface o_user {
'password'?: string | null;
}
export interface o_vendorConfig {
'code'?: string | null;
'enable'?: number | null;
'id'?: string;
'inputValues'?: string | null;

View File

@ -50,7 +50,9 @@ class OSS {
await this.ensureInit();
const safePath = normalizeUserPath(userRelPath);
// URL 始终使用 /,所以这里需要将系统分隔符转回 /
let url = `http://127.0.0.1:10588/${prefix}/`;
let url = `/${prefix}/`;
if (process.env.ossURL && process.env.ossURL !== "") url = process.env.ossURL + `/${prefix}/`;
if (process.env.NODE_ENV == "dev") url = `http://localhost:10588/${prefix}/`;
if (isEletron()) url = `http://localhost:${process.env.PORT}/${prefix}/`;
return `${url}${safePath.split(path.sep).join("/")}`;
}

1436
yarn.lock

File diff suppressed because it is too large Load Diff