koishi-plugin-ai-video 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/index.d.ts +204 -0
- package/lib/index.js +683 -0
- package/package.json +47 -0
- package/readme.md +273 -0
package/lib/index.d.ts
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { Schema } from 'koishi';
|
|
2
|
+
export declare const name = "ai-video";
|
|
3
|
+
export declare const inject: {
|
|
4
|
+
required: string[];
|
|
5
|
+
optional: string[];
|
|
6
|
+
};
|
|
7
|
+
type Infer<T> = T extends Schema<infer U> ? U : never;
|
|
8
|
+
export declare const Config: Schema<Schemastery.ObjectS<{
|
|
9
|
+
debug: Schema<boolean, boolean>;
|
|
10
|
+
timeout: Schema<number, number>;
|
|
11
|
+
rateLimit: Schema<number, number>;
|
|
12
|
+
maxVideos: Schema<number, number>;
|
|
13
|
+
videoDuration: Schema<number, number>;
|
|
14
|
+
videoResolution: Schema<string, string>;
|
|
15
|
+
enableForward: Schema<boolean, boolean>;
|
|
16
|
+
enableTxt2Video: Schema<boolean, boolean>;
|
|
17
|
+
enableImg2Video: Schema<boolean, boolean>;
|
|
18
|
+
videoSendMode: Schema<"video" | "url" | "both", "video" | "url" | "both">;
|
|
19
|
+
pollEnabled: Schema<boolean, boolean>;
|
|
20
|
+
pollInterval: Schema<number, number>;
|
|
21
|
+
pollTimeout: Schema<number, number>;
|
|
22
|
+
}> | Schemastery.ObjectS<{
|
|
23
|
+
proxyEnabled: Schema<boolean, boolean>;
|
|
24
|
+
proxyProtocol: Schema<"http" | "https", "http" | "https">;
|
|
25
|
+
proxyHost: Schema<string, string>;
|
|
26
|
+
proxyPort: Schema<number, number>;
|
|
27
|
+
proxyAuth: Schema<boolean, boolean>;
|
|
28
|
+
proxyUsername: Schema<string, string>;
|
|
29
|
+
proxyPassword: Schema<string, string>;
|
|
30
|
+
}> | Schemastery.ObjectS<{
|
|
31
|
+
useCustomApi: Schema<boolean, boolean>;
|
|
32
|
+
apiEndpoint: Schema<string, string>;
|
|
33
|
+
apiKey: Schema<string, string>;
|
|
34
|
+
model: Schema<string, string>;
|
|
35
|
+
img2videoModel: Schema<string, string>;
|
|
36
|
+
videoDuration: Schema<number, number>;
|
|
37
|
+
videoResolution: Schema<string, string>;
|
|
38
|
+
txt2videoPrompt: Schema<string, string>;
|
|
39
|
+
img2videoPrompt: Schema<string, string>;
|
|
40
|
+
customHeaders: Schema<string, string>;
|
|
41
|
+
}> | Schemastery.ObjectS<{
|
|
42
|
+
apiStrategy: Schema<"sequence" | "roundrobin", "sequence" | "roundrobin">;
|
|
43
|
+
customApiList: Schema<Schemastery.ObjectS<{
|
|
44
|
+
enable: Schema<boolean, boolean>;
|
|
45
|
+
endpoint: Schema<string, string>;
|
|
46
|
+
apiKey: Schema<string, string>;
|
|
47
|
+
model: Schema<string, string>;
|
|
48
|
+
img2videoModel: Schema<string, string>;
|
|
49
|
+
videoDuration: Schema<number, number>;
|
|
50
|
+
videoResolution: Schema<string, string>;
|
|
51
|
+
txt2videoPrompt: Schema<string, string>;
|
|
52
|
+
img2videoPrompt: Schema<string, string>;
|
|
53
|
+
customHeaders: Schema<string, string>;
|
|
54
|
+
bodyTemplate: Schema<string, string>;
|
|
55
|
+
}>[], Schemastery.ObjectT<{
|
|
56
|
+
enable: Schema<boolean, boolean>;
|
|
57
|
+
endpoint: Schema<string, string>;
|
|
58
|
+
apiKey: Schema<string, string>;
|
|
59
|
+
model: Schema<string, string>;
|
|
60
|
+
img2videoModel: Schema<string, string>;
|
|
61
|
+
videoDuration: Schema<number, number>;
|
|
62
|
+
videoResolution: Schema<string, string>;
|
|
63
|
+
txt2videoPrompt: Schema<string, string>;
|
|
64
|
+
img2videoPrompt: Schema<string, string>;
|
|
65
|
+
customHeaders: Schema<string, string>;
|
|
66
|
+
bodyTemplate: Schema<string, string>;
|
|
67
|
+
}>[]>;
|
|
68
|
+
}> | Schemastery.ObjectS<{
|
|
69
|
+
blacklistAdmins: Schema<string[], string[]>;
|
|
70
|
+
}> | Schemastery.ObjectS<{
|
|
71
|
+
messages: Schema<Schemastery.ObjectS<{
|
|
72
|
+
generating: Schema<string, string>;
|
|
73
|
+
empty: Schema<string, string>;
|
|
74
|
+
noApi: Schema<string, string>;
|
|
75
|
+
fail: Schema<string, string>;
|
|
76
|
+
noContent: Schema<string, string>;
|
|
77
|
+
templateError: Schema<string, string>;
|
|
78
|
+
txt2videoDisabled: Schema<string, string>;
|
|
79
|
+
img2videoDisabled: Schema<string, string>;
|
|
80
|
+
rateLimit: Schema<string, string>;
|
|
81
|
+
needAssets: Schema<string, string>;
|
|
82
|
+
blacklisted: Schema<string, string>;
|
|
83
|
+
noPermission: Schema<string, string>;
|
|
84
|
+
blacklistAddSuccess: Schema<string, string>;
|
|
85
|
+
blacklistRemoveSuccess: Schema<string, string>;
|
|
86
|
+
blacklistAddFail: Schema<string, string>;
|
|
87
|
+
blacklistRemoveFail: Schema<string, string>;
|
|
88
|
+
invalidUserId: Schema<string, string>;
|
|
89
|
+
blacklistListEmpty: Schema<string, string>;
|
|
90
|
+
blacklistListTitle: Schema<string, string>;
|
|
91
|
+
noLastTask: Schema<string, string>;
|
|
92
|
+
redrawing: Schema<string, string>;
|
|
93
|
+
redrawImg2Video: Schema<string, string>;
|
|
94
|
+
}>, Schemastery.ObjectT<{
|
|
95
|
+
generating: Schema<string, string>;
|
|
96
|
+
empty: Schema<string, string>;
|
|
97
|
+
noApi: Schema<string, string>;
|
|
98
|
+
fail: Schema<string, string>;
|
|
99
|
+
noContent: Schema<string, string>;
|
|
100
|
+
templateError: Schema<string, string>;
|
|
101
|
+
txt2videoDisabled: Schema<string, string>;
|
|
102
|
+
img2videoDisabled: Schema<string, string>;
|
|
103
|
+
rateLimit: Schema<string, string>;
|
|
104
|
+
needAssets: Schema<string, string>;
|
|
105
|
+
blacklisted: Schema<string, string>;
|
|
106
|
+
noPermission: Schema<string, string>;
|
|
107
|
+
blacklistAddSuccess: Schema<string, string>;
|
|
108
|
+
blacklistRemoveSuccess: Schema<string, string>;
|
|
109
|
+
blacklistAddFail: Schema<string, string>;
|
|
110
|
+
blacklistRemoveFail: Schema<string, string>;
|
|
111
|
+
invalidUserId: Schema<string, string>;
|
|
112
|
+
blacklistListEmpty: Schema<string, string>;
|
|
113
|
+
blacklistListTitle: Schema<string, string>;
|
|
114
|
+
noLastTask: Schema<string, string>;
|
|
115
|
+
redrawing: Schema<string, string>;
|
|
116
|
+
redrawImg2Video: Schema<string, string>;
|
|
117
|
+
}>>;
|
|
118
|
+
}>, {
|
|
119
|
+
debug: boolean;
|
|
120
|
+
timeout: number;
|
|
121
|
+
rateLimit: number;
|
|
122
|
+
maxVideos: number;
|
|
123
|
+
videoDuration: number;
|
|
124
|
+
videoResolution: string;
|
|
125
|
+
enableForward: boolean;
|
|
126
|
+
enableTxt2Video: boolean;
|
|
127
|
+
enableImg2Video: boolean;
|
|
128
|
+
videoSendMode: "video" | "url" | "both";
|
|
129
|
+
pollEnabled: boolean;
|
|
130
|
+
pollInterval: number;
|
|
131
|
+
pollTimeout: number;
|
|
132
|
+
} & import("cosmokit").Dict & {
|
|
133
|
+
proxyEnabled: boolean;
|
|
134
|
+
proxyProtocol: "http" | "https";
|
|
135
|
+
proxyHost: string;
|
|
136
|
+
proxyPort: number;
|
|
137
|
+
proxyAuth: boolean;
|
|
138
|
+
proxyUsername: string;
|
|
139
|
+
proxyPassword: string;
|
|
140
|
+
} & {
|
|
141
|
+
useCustomApi: boolean;
|
|
142
|
+
apiEndpoint: string;
|
|
143
|
+
apiKey: string;
|
|
144
|
+
model: string;
|
|
145
|
+
img2videoModel: string;
|
|
146
|
+
videoDuration: number;
|
|
147
|
+
videoResolution: string;
|
|
148
|
+
txt2videoPrompt: string;
|
|
149
|
+
img2videoPrompt: string;
|
|
150
|
+
customHeaders: string;
|
|
151
|
+
} & {
|
|
152
|
+
apiStrategy: "sequence" | "roundrobin";
|
|
153
|
+
customApiList: Schemastery.ObjectT<{
|
|
154
|
+
enable: Schema<boolean, boolean>;
|
|
155
|
+
endpoint: Schema<string, string>;
|
|
156
|
+
apiKey: Schema<string, string>;
|
|
157
|
+
model: Schema<string, string>;
|
|
158
|
+
img2videoModel: Schema<string, string>;
|
|
159
|
+
videoDuration: Schema<number, number>;
|
|
160
|
+
videoResolution: Schema<string, string>;
|
|
161
|
+
txt2videoPrompt: Schema<string, string>;
|
|
162
|
+
img2videoPrompt: Schema<string, string>;
|
|
163
|
+
customHeaders: Schema<string, string>;
|
|
164
|
+
bodyTemplate: Schema<string, string>;
|
|
165
|
+
}>[];
|
|
166
|
+
} & {
|
|
167
|
+
blacklistAdmins: string[];
|
|
168
|
+
} & {
|
|
169
|
+
messages: Schemastery.ObjectT<{
|
|
170
|
+
generating: Schema<string, string>;
|
|
171
|
+
empty: Schema<string, string>;
|
|
172
|
+
noApi: Schema<string, string>;
|
|
173
|
+
fail: Schema<string, string>;
|
|
174
|
+
noContent: Schema<string, string>;
|
|
175
|
+
templateError: Schema<string, string>;
|
|
176
|
+
txt2videoDisabled: Schema<string, string>;
|
|
177
|
+
img2videoDisabled: Schema<string, string>;
|
|
178
|
+
rateLimit: Schema<string, string>;
|
|
179
|
+
needAssets: Schema<string, string>;
|
|
180
|
+
blacklisted: Schema<string, string>;
|
|
181
|
+
noPermission: Schema<string, string>;
|
|
182
|
+
blacklistAddSuccess: Schema<string, string>;
|
|
183
|
+
blacklistRemoveSuccess: Schema<string, string>;
|
|
184
|
+
blacklistAddFail: Schema<string, string>;
|
|
185
|
+
blacklistRemoveFail: Schema<string, string>;
|
|
186
|
+
invalidUserId: Schema<string, string>;
|
|
187
|
+
blacklistListEmpty: Schema<string, string>;
|
|
188
|
+
blacklistListTitle: Schema<string, string>;
|
|
189
|
+
noLastTask: Schema<string, string>;
|
|
190
|
+
redrawing: Schema<string, string>;
|
|
191
|
+
redrawImg2Video: Schema<string, string>;
|
|
192
|
+
}>;
|
|
193
|
+
}>;
|
|
194
|
+
declare module 'koishi' {
|
|
195
|
+
interface Tables {
|
|
196
|
+
ai_video_blacklist: AIVideoBlacklist;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
interface AIVideoBlacklist {
|
|
200
|
+
id: string;
|
|
201
|
+
createdAt: Date;
|
|
202
|
+
}
|
|
203
|
+
export declare function apply(ctx: any, cfg: Infer<typeof Config>): Promise<void>;
|
|
204
|
+
export {};
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,683 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.Config = exports.inject = exports.name = void 0;
|
|
7
|
+
exports.apply = apply;
|
|
8
|
+
const koishi_1 = require("koishi");
|
|
9
|
+
const axios_1 = __importDefault(require("axios"));
|
|
10
|
+
const yaml_1 = __importDefault(require("yaml"));
|
|
11
|
+
const fs_1 = __importDefault(require("fs"));
|
|
12
|
+
const path_1 = __importDefault(require("path"));
|
|
13
|
+
exports.name = 'ai-video';
|
|
14
|
+
exports.inject = {
|
|
15
|
+
required: ['console', 'i18n', 'database'],
|
|
16
|
+
optional: ['assets'],
|
|
17
|
+
};
|
|
18
|
+
const logger = new koishi_1.Logger('ai-video');
|
|
19
|
+
exports.Config = koishi_1.Schema.intersect([
|
|
20
|
+
koishi_1.Schema.object({
|
|
21
|
+
debug: koishi_1.Schema.boolean().default(false).description('开启调试模式,输出完整请求日志'),
|
|
22
|
+
timeout: koishi_1.Schema.number().default(600000).description('接口请求超时时间(毫秒),视频生成较慢,建议加大'),
|
|
23
|
+
rateLimit: koishi_1.Schema.number().default(50).description('每小时调用次数限制'),
|
|
24
|
+
maxVideos: koishi_1.Schema.number().default(1).description('单次生成最多视频数量'),
|
|
25
|
+
videoDuration: koishi_1.Schema.number().default(5).description('默认视频时长(秒)'),
|
|
26
|
+
videoResolution: koishi_1.Schema.string().default('1024x576').description('默认视频分辨率(宽x高)'),
|
|
27
|
+
enableForward: koishi_1.Schema.boolean().default(true).description('多视频结果是否使用合并转发'),
|
|
28
|
+
enableTxt2Video: koishi_1.Schema.boolean().default(true).description('启用文生视频功能'),
|
|
29
|
+
enableImg2Video: koishi_1.Schema.boolean().default(true).description('启用图生视频功能'),
|
|
30
|
+
videoSendMode: koishi_1.Schema.union([
|
|
31
|
+
koishi_1.Schema.const('video').description('仅发送视频文件'),
|
|
32
|
+
koishi_1.Schema.const('url').description('仅发送视频链接'),
|
|
33
|
+
koishi_1.Schema.const('both').description('发送视频文件和链接'),
|
|
34
|
+
]).default('video').description('生成结果发送方式'),
|
|
35
|
+
pollEnabled: koishi_1.Schema.boolean().default(false).description('是否启用异步轮询(若 API 返回任务 ID 则自动轮询至完成)'),
|
|
36
|
+
pollInterval: koishi_1.Schema.number().default(3000).description('轮询间隔(毫秒)'),
|
|
37
|
+
pollTimeout: koishi_1.Schema.number().default(600000).description('轮询总超时时间(毫秒)'),
|
|
38
|
+
}).description('基本设置'),
|
|
39
|
+
koishi_1.Schema.object({
|
|
40
|
+
proxyEnabled: koishi_1.Schema.boolean().default(false).description('是否启用 HTTP/HTTPS 代理'),
|
|
41
|
+
proxyProtocol: koishi_1.Schema.union([
|
|
42
|
+
koishi_1.Schema.const('http').description('HTTP'),
|
|
43
|
+
koishi_1.Schema.const('https').description('HTTPS'),
|
|
44
|
+
]).default('http').description('代理协议'),
|
|
45
|
+
proxyHost: koishi_1.Schema.string().default('').description('代理地址'),
|
|
46
|
+
proxyPort: koishi_1.Schema.number().default(8080).description('代理端口'),
|
|
47
|
+
proxyAuth: koishi_1.Schema.boolean().default(false).description('代理是否需要认证'),
|
|
48
|
+
proxyUsername: koishi_1.Schema.string().default('').description('代理用户名'),
|
|
49
|
+
proxyPassword: koishi_1.Schema.string().role('secret').default('').description('代理密码'),
|
|
50
|
+
}).description('代理设置'),
|
|
51
|
+
koishi_1.Schema.object({
|
|
52
|
+
useCustomApi: koishi_1.Schema.boolean().default(false).description('是否使用自定义 API 配置(开启后下方自定义列表生效)'),
|
|
53
|
+
apiEndpoint: koishi_1.Schema.string().default('https://api.openai.com/v1/video/generations').description('API 端点地址'),
|
|
54
|
+
apiKey: koishi_1.Schema.string().role('secret').default('').description('API 密钥'),
|
|
55
|
+
model: koishi_1.Schema.string().default('video-generation-model').description('模型名称'),
|
|
56
|
+
img2videoModel: koishi_1.Schema.string().default('').description('图生视频专用模型名称(留空则使用上方模型)'),
|
|
57
|
+
videoDuration: koishi_1.Schema.number().default(0).description('视频时长(秒,留空则使用全局默认)'),
|
|
58
|
+
videoResolution: koishi_1.Schema.string().default('').description('视频分辨率(留空则使用全局默认)'),
|
|
59
|
+
txt2videoPrompt: koishi_1.Schema.string().default('').description('文生视频提示词模板。变量:{prompt}(留空则直接使用用户输入)'),
|
|
60
|
+
img2videoPrompt: koishi_1.Schema.string().default('').description('图生视频提示词模板。变量:{url} {prompt}(留空则直接使用用户输入)'),
|
|
61
|
+
customHeaders: koishi_1.Schema.string().role('textarea').default('{}').description('自定义请求头 JSON 对象(合并到默认请求头)'),
|
|
62
|
+
}).description('内置 API 设置'),
|
|
63
|
+
koishi_1.Schema.object({
|
|
64
|
+
apiStrategy: koishi_1.Schema.union([
|
|
65
|
+
koishi_1.Schema.const('sequence').description('顺序模式'),
|
|
66
|
+
koishi_1.Schema.const('roundrobin').description('负载均衡模式'),
|
|
67
|
+
]).default('roundrobin').description('API 调度策略'),
|
|
68
|
+
customApiList: koishi_1.Schema.array(koishi_1.Schema.object({
|
|
69
|
+
enable: koishi_1.Schema.boolean().default(true).description('是否启用此 API'),
|
|
70
|
+
endpoint: koishi_1.Schema.string().default('https://api.openai.com/v1/video/generations').description('API 端点地址'),
|
|
71
|
+
apiKey: koishi_1.Schema.string().role('secret').default('').description('API 密钥'),
|
|
72
|
+
model: koishi_1.Schema.string().default('video-generation-model').description('模型名称'),
|
|
73
|
+
img2videoModel: koishi_1.Schema.string().default('').description('图生视频专用模型名称(留空则使用上方模型)'),
|
|
74
|
+
videoDuration: koishi_1.Schema.number().default(0).description('视频时长(秒,留空使用全局默认)'),
|
|
75
|
+
videoResolution: koishi_1.Schema.string().default('').description('视频分辨率(留空使用全局默认)'),
|
|
76
|
+
txt2videoPrompt: koishi_1.Schema.string().default('').description('文生视频提示词模板。变量:{prompt}(留空则直接使用用户输入)'),
|
|
77
|
+
img2videoPrompt: koishi_1.Schema.string().default('').description('图生视频提示词模板。变量:{url} {prompt}(留空则直接使用用户输入)'),
|
|
78
|
+
customHeaders: koishi_1.Schema.string().role('textarea')
|
|
79
|
+
.default('{"Authorization":"Bearer {apiKey}","Content-Type":"application/json"}')
|
|
80
|
+
.description('自定义请求头 JSON,支持 {apiKey} 变量'),
|
|
81
|
+
bodyTemplate: koishi_1.Schema.string().role('textarea')
|
|
82
|
+
.default(JSON.stringify({
|
|
83
|
+
txt2videoBody: { model: '{model}', prompt: '{prompt}', duration: '{duration}', size: '{size}' },
|
|
84
|
+
img2videoBody: { model: '{model}', prompt: '{prompt}', duration: '{duration}', size: '{size}', image_url: '{url}' },
|
|
85
|
+
responseVideoPath: 'video_url',
|
|
86
|
+
pollUrlTemplate: '{endpoint}/{task_id}',
|
|
87
|
+
taskIdPath: 'task_id',
|
|
88
|
+
}, null, 2))
|
|
89
|
+
.description('自定义请求体 JSON 模板(高级,留空使用内置格式)。\n支持变量:{model}、{prompt}、{duration}、{size}、{url}。\n轮询配置可选:pollUrlTemplate、taskIdPath'),
|
|
90
|
+
})).default([]).description('自定义 API 配置列表(仅当"使用自定义 API 配置"开启时生效)'),
|
|
91
|
+
}).description('自定义 API 配置'),
|
|
92
|
+
koishi_1.Schema.object({
|
|
93
|
+
blacklistAdmins: koishi_1.Schema.array(String).default([]).description('黑名单管理员的 QQ 号列表'),
|
|
94
|
+
}).description('权限管理'),
|
|
95
|
+
koishi_1.Schema.object({
|
|
96
|
+
messages: koishi_1.Schema.object({
|
|
97
|
+
generating: koishi_1.Schema.string().default('视频生成中,请耐心等待...').description('开始生成视频时的提示'),
|
|
98
|
+
empty: koishi_1.Schema.string().default('[提示] 请输入提示词').description('未输入提示词时的提示'),
|
|
99
|
+
noApi: koishi_1.Schema.string().default('[提示] 未配置可用API').description('无可用 API 时的提示'),
|
|
100
|
+
fail: koishi_1.Schema.string().default('[提示] 视频生成失败').description('视频生成失败时的提示'),
|
|
101
|
+
noContent: koishi_1.Schema.string().default('(未返回任何视频内容)').description('API 返回空结果时的追加提示'),
|
|
102
|
+
templateError: koishi_1.Schema.string().default('(模板配置错误)').description('请求体模板解析失败时的追加提示'),
|
|
103
|
+
txt2videoDisabled: koishi_1.Schema.string().default('[提示] 文生视频功能未启用').description('文生视频被禁用时的提示'),
|
|
104
|
+
img2videoDisabled: koishi_1.Schema.string().default('[提示] 图生视频功能未启用').description('图生视频被禁用时的提示'),
|
|
105
|
+
rateLimit: koishi_1.Schema.string().default('[提示] 调用次数已达上限,请稍后再试').description('触发频率限制时的提示'),
|
|
106
|
+
needAssets: koishi_1.Schema.string().default('[提示] 图生视频需要正确配置 assets 服务(selfUrl 未正确设置或服务未启动)').description('缺少 assets 服务时的提示'),
|
|
107
|
+
blacklisted: koishi_1.Schema.string().default('[提示] 你已被加入黑名单,无法使用视频生成功能').description('黑名单用户被拦截时的提示'),
|
|
108
|
+
noPermission: koishi_1.Schema.string().default('[提示] 你没有权限管理黑名单').description('无黑名单管理权限时的提示'),
|
|
109
|
+
blacklistAddSuccess: koishi_1.Schema.string().default('已将 {targets} 加入黑名单').description('黑名单添加成功的提示'),
|
|
110
|
+
blacklistRemoveSuccess: koishi_1.Schema.string().default('已将 {targets} 移出黑名单').description('黑名单移除成功的提示'),
|
|
111
|
+
blacklistAddFail: koishi_1.Schema.string().default('{targets} 已在黑名单中或无效').description('黑名单添加失败的提示'),
|
|
112
|
+
blacklistRemoveFail: koishi_1.Schema.string().default('{targets} 不在黑名单中').description('黑名单移除失败的提示'),
|
|
113
|
+
invalidUserId: koishi_1.Schema.string().default('无效的QQ号:{targets}').description('无效 QQ 号的提示'),
|
|
114
|
+
blacklistListEmpty: koishi_1.Schema.string().default('当前黑名单为空').description('黑名单为空时的提示'),
|
|
115
|
+
blacklistListTitle: koishi_1.Schema.string().default('当前黑名单:').description('黑名单列表标题'),
|
|
116
|
+
noLastTask: koishi_1.Schema.string().default('没有上一次生成记录,无法重绘').description('无重绘历史时的提示'),
|
|
117
|
+
redrawing: koishi_1.Schema.string().default('正在重绘上一次文生视频...').description('重绘开始时的提示'),
|
|
118
|
+
redrawImg2Video: koishi_1.Schema.string().default('[提示] 重绘仅支持文生视频任务,图生视频任务请直接发起新的图生视频指令').description('图生视频任务无法重绘时的提示'),
|
|
119
|
+
}).description('所有提示文案的自定义配置,支持模板变量'),
|
|
120
|
+
}).description('消息文本'),
|
|
121
|
+
]);
|
|
122
|
+
async function apply(ctx, cfg) {
|
|
123
|
+
const debug = cfg.debug;
|
|
124
|
+
try {
|
|
125
|
+
const loc = path_1.default.join(__dirname, 'locales', 'zh-CN.yml');
|
|
126
|
+
if (fs_1.default.existsSync(loc)) {
|
|
127
|
+
ctx.i18n.define('zh-CN', yaml_1.default.parse(fs_1.default.readFileSync(loc, 'utf8')));
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
catch { }
|
|
131
|
+
const lastTaskMap = new Map();
|
|
132
|
+
let apiRoundRobinIdx = 0;
|
|
133
|
+
const apiCallTimestamps = [];
|
|
134
|
+
ctx.model.extend('ai_video_blacklist', {
|
|
135
|
+
id: 'string',
|
|
136
|
+
createdAt: 'date',
|
|
137
|
+
}, {
|
|
138
|
+
primary: 'id',
|
|
139
|
+
});
|
|
140
|
+
function checkRateLimit() {
|
|
141
|
+
const now = Date.now();
|
|
142
|
+
const oneHourAgo = now - 3600000;
|
|
143
|
+
let trimIdx = 0;
|
|
144
|
+
while (trimIdx < apiCallTimestamps.length && apiCallTimestamps[trimIdx] < oneHourAgo) {
|
|
145
|
+
trimIdx++;
|
|
146
|
+
}
|
|
147
|
+
if (trimIdx > 0) {
|
|
148
|
+
apiCallTimestamps.splice(0, trimIdx);
|
|
149
|
+
}
|
|
150
|
+
return apiCallTimestamps.length + 1 <= cfg.rateLimit;
|
|
151
|
+
}
|
|
152
|
+
function recordApiCall() {
|
|
153
|
+
apiCallTimestamps.push(Date.now());
|
|
154
|
+
}
|
|
155
|
+
const BUILTIN_TXT2VIDEO = {
|
|
156
|
+
model: '{model}',
|
|
157
|
+
prompt: '{prompt}',
|
|
158
|
+
duration: '{duration}',
|
|
159
|
+
size: '{size}',
|
|
160
|
+
};
|
|
161
|
+
const BUILTIN_IMG2VIDEO = {
|
|
162
|
+
model: '{model}',
|
|
163
|
+
prompt: '{prompt}',
|
|
164
|
+
duration: '{duration}',
|
|
165
|
+
size: '{size}',
|
|
166
|
+
image_url: '{url}',
|
|
167
|
+
};
|
|
168
|
+
function parseApiEntry(entry) {
|
|
169
|
+
if (!entry.endpoint)
|
|
170
|
+
return null;
|
|
171
|
+
const headers = {
|
|
172
|
+
'Content-Type': 'application/json'
|
|
173
|
+
};
|
|
174
|
+
if (entry.apiKey) {
|
|
175
|
+
headers['Authorization'] = `Bearer ${entry.apiKey}`;
|
|
176
|
+
}
|
|
177
|
+
if (entry.customHeaders) {
|
|
178
|
+
try {
|
|
179
|
+
const custom = JSON.parse(entry.customHeaders);
|
|
180
|
+
for (const [k, v] of Object.entries(custom)) {
|
|
181
|
+
headers[k] = typeof v === 'string' ? v.replace(/\{apiKey\}/g, entry.apiKey || '') : String(v);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
catch { }
|
|
185
|
+
}
|
|
186
|
+
let txt2videoBody;
|
|
187
|
+
let img2videoBody;
|
|
188
|
+
let responseVideoPath;
|
|
189
|
+
let pollUrlTemplate;
|
|
190
|
+
let taskIdPath;
|
|
191
|
+
if (entry.bodyTemplate) {
|
|
192
|
+
try {
|
|
193
|
+
const tmpl = JSON.parse(entry.bodyTemplate);
|
|
194
|
+
txt2videoBody = tmpl.txt2videoBody || BUILTIN_TXT2VIDEO;
|
|
195
|
+
img2videoBody = tmpl.img2videoBody || BUILTIN_IMG2VIDEO;
|
|
196
|
+
responseVideoPath = tmpl.responseVideoPath || 'video_url';
|
|
197
|
+
pollUrlTemplate = tmpl.pollUrlTemplate || '';
|
|
198
|
+
taskIdPath = tmpl.taskIdPath || '';
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
else {
|
|
205
|
+
txt2videoBody = BUILTIN_TXT2VIDEO;
|
|
206
|
+
img2videoBody = BUILTIN_IMG2VIDEO;
|
|
207
|
+
responseVideoPath = 'video_url';
|
|
208
|
+
pollUrlTemplate = '';
|
|
209
|
+
taskIdPath = '';
|
|
210
|
+
}
|
|
211
|
+
return {
|
|
212
|
+
endpoint: entry.endpoint,
|
|
213
|
+
headers,
|
|
214
|
+
txt2videoBody,
|
|
215
|
+
img2videoBody,
|
|
216
|
+
responseVideoPath,
|
|
217
|
+
pollUrlTemplate,
|
|
218
|
+
taskIdPath,
|
|
219
|
+
method: 'POST',
|
|
220
|
+
videoDuration: entry.videoDuration || 0,
|
|
221
|
+
videoResolution: entry.videoResolution || '',
|
|
222
|
+
txt2videoPrompt: entry.txt2videoPrompt || '',
|
|
223
|
+
img2videoPrompt: entry.img2videoPrompt || '',
|
|
224
|
+
model: entry.model,
|
|
225
|
+
img2videoModel: entry.img2videoModel || '',
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
function buildBuiltinApi() {
|
|
229
|
+
const headers = {
|
|
230
|
+
'Content-Type': 'application/json'
|
|
231
|
+
};
|
|
232
|
+
if (cfg.apiKey) {
|
|
233
|
+
headers['Authorization'] = `Bearer ${cfg.apiKey}`;
|
|
234
|
+
}
|
|
235
|
+
if (cfg.customHeaders) {
|
|
236
|
+
try {
|
|
237
|
+
const custom = JSON.parse(cfg.customHeaders);
|
|
238
|
+
for (const [k, v] of Object.entries(custom)) {
|
|
239
|
+
headers[k] = typeof v === 'string' ? v.replace(/\{apiKey\}/g, cfg.apiKey || '') : String(v);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
catch { }
|
|
243
|
+
}
|
|
244
|
+
return {
|
|
245
|
+
endpoint: cfg.apiEndpoint || 'https://api.openai.com/v1/video/generations',
|
|
246
|
+
headers,
|
|
247
|
+
txt2videoBody: BUILTIN_TXT2VIDEO,
|
|
248
|
+
img2videoBody: BUILTIN_IMG2VIDEO,
|
|
249
|
+
responseVideoPath: 'video_url',
|
|
250
|
+
pollUrlTemplate: '',
|
|
251
|
+
taskIdPath: '',
|
|
252
|
+
method: 'POST',
|
|
253
|
+
videoDuration: cfg.videoDuration || 0,
|
|
254
|
+
videoResolution: cfg.videoResolution || '',
|
|
255
|
+
txt2videoPrompt: cfg.txt2videoPrompt || '',
|
|
256
|
+
img2videoPrompt: cfg.img2videoPrompt || '',
|
|
257
|
+
model: cfg.model || 'video-generation-model',
|
|
258
|
+
img2videoModel: cfg.img2videoModel || '',
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
function getApi() {
|
|
262
|
+
if (cfg.useCustomApi) {
|
|
263
|
+
const entries = cfg.customApiList.filter((e) => e.enable);
|
|
264
|
+
if (entries.length === 0)
|
|
265
|
+
return null;
|
|
266
|
+
const apis = entries
|
|
267
|
+
.map((e) => parseApiEntry(e))
|
|
268
|
+
.filter((a) => a !== null);
|
|
269
|
+
if (apis.length === 0)
|
|
270
|
+
return null;
|
|
271
|
+
if (cfg.apiStrategy === 'sequence')
|
|
272
|
+
return apis[0];
|
|
273
|
+
const api = apis[apiRoundRobinIdx % apis.length];
|
|
274
|
+
apiRoundRobinIdx++;
|
|
275
|
+
return api;
|
|
276
|
+
}
|
|
277
|
+
else {
|
|
278
|
+
if (!cfg.apiEndpoint && !cfg.apiKey)
|
|
279
|
+
return null;
|
|
280
|
+
return buildBuiltinApi();
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
function resolveTemplate(template, vars) {
|
|
284
|
+
const jsonStr = JSON.stringify(template);
|
|
285
|
+
let processed = jsonStr;
|
|
286
|
+
for (const [key, value] of Object.entries(vars)) {
|
|
287
|
+
if (value === undefined || value === null)
|
|
288
|
+
continue;
|
|
289
|
+
const regex = new RegExp(`\\{${key}\\}`, 'g');
|
|
290
|
+
processed = processed.replace(regex, JSON.stringify(String(value)).slice(1, -1));
|
|
291
|
+
}
|
|
292
|
+
return JSON.parse(processed);
|
|
293
|
+
}
|
|
294
|
+
function getValueByPath(obj, pathStr) {
|
|
295
|
+
if (!obj || !pathStr)
|
|
296
|
+
return undefined;
|
|
297
|
+
const normalized = pathStr.replace(/\[(\d+)\]/g, '.$1');
|
|
298
|
+
const keys = normalized.split('.').filter(k => k !== '');
|
|
299
|
+
let current = obj;
|
|
300
|
+
for (const key of keys) {
|
|
301
|
+
if (current === undefined || current === null)
|
|
302
|
+
return undefined;
|
|
303
|
+
const numKey = /^\d+$/.test(key) ? parseInt(key) : key;
|
|
304
|
+
current = current[numKey];
|
|
305
|
+
}
|
|
306
|
+
return current;
|
|
307
|
+
}
|
|
308
|
+
async function pollForResult(pollUrl, headers, path, interval, timeout) {
|
|
309
|
+
const start = Date.now();
|
|
310
|
+
while (Date.now() - start < timeout) {
|
|
311
|
+
try {
|
|
312
|
+
const res = await axios_1.default.get(pollUrl, { headers, timeout: 10000 });
|
|
313
|
+
const status = getValueByPath(res.data, 'status');
|
|
314
|
+
if (status === 'completed' || status === 'succeeded') {
|
|
315
|
+
return res.data;
|
|
316
|
+
}
|
|
317
|
+
if (status === 'failed' || status === 'error') {
|
|
318
|
+
throw new Error('Task failed');
|
|
319
|
+
}
|
|
320
|
+
await new Promise(r => setTimeout(r, interval));
|
|
321
|
+
}
|
|
322
|
+
catch (e) {
|
|
323
|
+
if (e.message === 'Task failed')
|
|
324
|
+
throw e;
|
|
325
|
+
await new Promise(r => setTimeout(r, interval));
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
throw new Error('Polling timeout');
|
|
329
|
+
}
|
|
330
|
+
async function sendVideo(session, url) {
|
|
331
|
+
const mode = cfg.videoSendMode;
|
|
332
|
+
if (mode === 'url') {
|
|
333
|
+
await safeSend(session, url);
|
|
334
|
+
}
|
|
335
|
+
else {
|
|
336
|
+
try {
|
|
337
|
+
await safeSend(session, koishi_1.segment.video(url));
|
|
338
|
+
}
|
|
339
|
+
catch {
|
|
340
|
+
if (mode === 'both')
|
|
341
|
+
await safeSend(session, url);
|
|
342
|
+
}
|
|
343
|
+
if (mode === 'both')
|
|
344
|
+
await safeSend(session, url);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
async function handleVideoResponse(session, responseData, api) {
|
|
348
|
+
let videoUrl = getValueByPath(responseData, api.responseVideoPath);
|
|
349
|
+
if (!videoUrl) {
|
|
350
|
+
const found = findFirstVideoUrl(responseData);
|
|
351
|
+
if (found)
|
|
352
|
+
videoUrl = found;
|
|
353
|
+
}
|
|
354
|
+
if (videoUrl && typeof videoUrl === 'string') {
|
|
355
|
+
await sendVideo(session, videoUrl.trim());
|
|
356
|
+
}
|
|
357
|
+
else {
|
|
358
|
+
await safeSend(session, cfg.messages.fail + cfg.messages.noContent);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
function findFirstVideoUrl(obj) {
|
|
362
|
+
if (!obj)
|
|
363
|
+
return null;
|
|
364
|
+
if (typeof obj === 'string') {
|
|
365
|
+
const trimmed = obj.trim();
|
|
366
|
+
if (/^https?:\/\/.+\.(mp4|mov|avi|webm|mkv)(\?.*)?$/i.test(trimmed))
|
|
367
|
+
return trimmed;
|
|
368
|
+
return null;
|
|
369
|
+
}
|
|
370
|
+
if (Array.isArray(obj)) {
|
|
371
|
+
for (const item of obj) {
|
|
372
|
+
const found = findFirstVideoUrl(item);
|
|
373
|
+
if (found)
|
|
374
|
+
return found;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
else if (typeof obj === 'object') {
|
|
378
|
+
for (const key of Object.keys(obj)) {
|
|
379
|
+
const found = findFirstVideoUrl(obj[key]);
|
|
380
|
+
if (found)
|
|
381
|
+
return found;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
return null;
|
|
385
|
+
}
|
|
386
|
+
function validateEndpointUrl(url) {
|
|
387
|
+
try {
|
|
388
|
+
const parsed = new URL(url);
|
|
389
|
+
return parsed.protocol === 'http:' || parsed.protocol === 'https:';
|
|
390
|
+
}
|
|
391
|
+
catch {
|
|
392
|
+
return false;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
async function safeSend(session, message) {
|
|
396
|
+
try {
|
|
397
|
+
await session.send(message);
|
|
398
|
+
}
|
|
399
|
+
catch (e) {
|
|
400
|
+
logger.error('发送消息失败', e);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
function getErrorMessage(err) {
|
|
404
|
+
if (axios_1.default.isAxiosError(err)) {
|
|
405
|
+
if (err.code === 'ECONNABORTED')
|
|
406
|
+
return '请求超时';
|
|
407
|
+
if (err.response) {
|
|
408
|
+
const status = err.response.status;
|
|
409
|
+
if (status === 401)
|
|
410
|
+
return 'API Key 无效';
|
|
411
|
+
if (status === 429)
|
|
412
|
+
return '请求过于频繁';
|
|
413
|
+
if (status >= 500)
|
|
414
|
+
return '服务器错误';
|
|
415
|
+
return `HTTP ${status}`;
|
|
416
|
+
}
|
|
417
|
+
return '网络错误';
|
|
418
|
+
}
|
|
419
|
+
return err.message?.slice(0, 100) || '未知错误';
|
|
420
|
+
}
|
|
421
|
+
function sanitizeForLog(obj, sensitive) {
|
|
422
|
+
if (!sensitive)
|
|
423
|
+
return obj;
|
|
424
|
+
try {
|
|
425
|
+
const str = JSON.stringify(obj);
|
|
426
|
+
return JSON.parse(str.split(sensitive).join('***'));
|
|
427
|
+
}
|
|
428
|
+
catch {
|
|
429
|
+
return obj;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
function isValidQQ(id) {
|
|
433
|
+
return /^\d{5,11}$/.test(id);
|
|
434
|
+
}
|
|
435
|
+
async function isBlacklisted(userId) {
|
|
436
|
+
try {
|
|
437
|
+
const rows = await ctx.database.get('ai_video_blacklist', { id: userId });
|
|
438
|
+
return rows.length > 0;
|
|
439
|
+
}
|
|
440
|
+
catch (e) {
|
|
441
|
+
return false;
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
async function addToBlacklist(ids) {
|
|
445
|
+
const success = [];
|
|
446
|
+
const fail = [];
|
|
447
|
+
const validIds = ids.filter(id => { if (isValidQQ(id))
|
|
448
|
+
return true; fail.push(id); return false; });
|
|
449
|
+
if (validIds.length === 0)
|
|
450
|
+
return { success, fail };
|
|
451
|
+
const existing = await ctx.database.get('ai_video_blacklist', { id: validIds });
|
|
452
|
+
const existingSet = new Set(existing.map((e) => e.id));
|
|
453
|
+
const toCreate = validIds.filter(id => !existingSet.has(id));
|
|
454
|
+
for (const id of toCreate) {
|
|
455
|
+
try {
|
|
456
|
+
await ctx.database.create('ai_video_blacklist', { id, createdAt: new Date() });
|
|
457
|
+
success.push(id);
|
|
458
|
+
}
|
|
459
|
+
catch {
|
|
460
|
+
fail.push(id);
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
for (const entry of existing)
|
|
464
|
+
fail.push(entry.id);
|
|
465
|
+
return { success, fail };
|
|
466
|
+
}
|
|
467
|
+
async function removeFromBlacklist(ids) {
|
|
468
|
+
const success = [];
|
|
469
|
+
const fail = [];
|
|
470
|
+
const validIds = ids.filter(id => { if (isValidQQ(id))
|
|
471
|
+
return true; fail.push(id); return false; });
|
|
472
|
+
if (validIds.length === 0)
|
|
473
|
+
return { success, fail };
|
|
474
|
+
const existing = await ctx.database.get('ai_video_blacklist', { id: validIds });
|
|
475
|
+
const existingSet = new Set(existing.map((e) => e.id));
|
|
476
|
+
const toRemove = validIds.filter(id => existingSet.has(id));
|
|
477
|
+
for (const id of toRemove) {
|
|
478
|
+
try {
|
|
479
|
+
await ctx.database.remove('ai_video_blacklist', { id });
|
|
480
|
+
success.push(id);
|
|
481
|
+
}
|
|
482
|
+
catch {
|
|
483
|
+
fail.push(id);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
for (const id of validIds.filter(id => !existingSet.has(id)))
|
|
487
|
+
fail.push(id);
|
|
488
|
+
return { success, fail };
|
|
489
|
+
}
|
|
490
|
+
async function customGenerateVideo(session, api, prompt, imageUrl = '', modelOverride) {
|
|
491
|
+
const isImg2Video = !!imageUrl;
|
|
492
|
+
const model = modelOverride || (isImg2Video ? (api.img2videoModel || api.model) : api.model);
|
|
493
|
+
const duration = api.videoDuration || cfg.videoDuration || 5;
|
|
494
|
+
const resolution = api.videoResolution || cfg.videoResolution || '1024x576';
|
|
495
|
+
const promptTemplate = isImg2Video ? api.img2videoPrompt : api.txt2videoPrompt;
|
|
496
|
+
let finalPrompt = prompt;
|
|
497
|
+
if (promptTemplate) {
|
|
498
|
+
finalPrompt = promptTemplate.replace('{prompt}', prompt).replace('{url}', imageUrl || '');
|
|
499
|
+
}
|
|
500
|
+
const bodyTemplate = isImg2Video ? api.img2videoBody : api.txt2videoBody;
|
|
501
|
+
const bodyVars = { model, prompt: finalPrompt, duration: String(duration), size: resolution };
|
|
502
|
+
if (isImg2Video) {
|
|
503
|
+
bodyVars['url'] = imageUrl;
|
|
504
|
+
}
|
|
505
|
+
let body;
|
|
506
|
+
try {
|
|
507
|
+
body = resolveTemplate(bodyTemplate, bodyVars);
|
|
508
|
+
}
|
|
509
|
+
catch {
|
|
510
|
+
await safeSend(session, cfg.messages.fail + cfg.messages.templateError);
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
if (!validateEndpointUrl(api.endpoint)) {
|
|
514
|
+
await safeSend(session, cfg.messages.fail + '(API端点配置无效)');
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
const sensitive = api.headers?.Authorization?.split(' ')[1] || '';
|
|
518
|
+
if (debug) {
|
|
519
|
+
logger.info('API请求', JSON.stringify(sanitizeForLog(body, sensitive)));
|
|
520
|
+
}
|
|
521
|
+
try {
|
|
522
|
+
const config = {
|
|
523
|
+
url: api.endpoint,
|
|
524
|
+
method: api.method,
|
|
525
|
+
headers: api.headers,
|
|
526
|
+
data: body,
|
|
527
|
+
timeout: cfg.timeout,
|
|
528
|
+
};
|
|
529
|
+
if (cfg.proxyEnabled && cfg.proxyHost) {
|
|
530
|
+
config.proxy = {
|
|
531
|
+
protocol: cfg.proxyProtocol,
|
|
532
|
+
host: cfg.proxyHost,
|
|
533
|
+
port: cfg.proxyPort,
|
|
534
|
+
auth: cfg.proxyAuth && cfg.proxyUsername ? { username: cfg.proxyUsername, password: cfg.proxyPassword } : undefined,
|
|
535
|
+
};
|
|
536
|
+
}
|
|
537
|
+
let responseData = (await (0, axios_1.default)(config)).data;
|
|
538
|
+
if (cfg.pollEnabled && api.taskIdPath) {
|
|
539
|
+
const taskId = getValueByPath(responseData, api.taskIdPath);
|
|
540
|
+
if (taskId) {
|
|
541
|
+
const pollUrl = api.pollUrlTemplate.replace('{endpoint}', api.endpoint).replace('{task_id}', taskId);
|
|
542
|
+
responseData = await pollForResult(pollUrl, api.headers, api.responseVideoPath, cfg.pollInterval, cfg.pollTimeout);
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
await handleVideoResponse(session, responseData, api);
|
|
546
|
+
const userId = `${session.guildId || 'private'}-${session.userId}`;
|
|
547
|
+
lastTaskMap.set(userId, {
|
|
548
|
+
prompt,
|
|
549
|
+
imageUrl,
|
|
550
|
+
isImg2Video,
|
|
551
|
+
model,
|
|
552
|
+
duration,
|
|
553
|
+
resolution,
|
|
554
|
+
});
|
|
555
|
+
}
|
|
556
|
+
catch (err) {
|
|
557
|
+
logger.error('视频生成失败', err);
|
|
558
|
+
await safeSend(session, cfg.messages.fail + ` [${getErrorMessage(err)}]`);
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
async function generateVideo(session, prompt, imageUrl = '', modelOverride) {
|
|
562
|
+
if (!checkRateLimit()) {
|
|
563
|
+
await safeSend(session, cfg.messages.rateLimit);
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
566
|
+
const api = getApi();
|
|
567
|
+
if (!api) {
|
|
568
|
+
await safeSend(session, cfg.messages.noApi);
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
recordApiCall();
|
|
572
|
+
return customGenerateVideo(session, api, prompt, imageUrl, modelOverride);
|
|
573
|
+
}
|
|
574
|
+
ctx.command('video <raw:text>', 'AI视频生成(文生视频/图生视频自动识别)')
|
|
575
|
+
.action(async ({ session }, raw) => {
|
|
576
|
+
try {
|
|
577
|
+
if (!session)
|
|
578
|
+
return;
|
|
579
|
+
if (await isBlacklisted(session.userId))
|
|
580
|
+
return safeSend(session, cfg.messages.blacklisted);
|
|
581
|
+
const prompt = (raw || '').trim();
|
|
582
|
+
const imgs = koishi_1.h.select(session.elements, 'img');
|
|
583
|
+
const hasImage = imgs.length > 0;
|
|
584
|
+
if (hasImage && !cfg.enableImg2Video)
|
|
585
|
+
return safeSend(session, cfg.messages.img2videoDisabled);
|
|
586
|
+
if (!hasImage && !cfg.enableTxt2Video)
|
|
587
|
+
return safeSend(session, cfg.messages.txt2videoDisabled);
|
|
588
|
+
if (!prompt) {
|
|
589
|
+
if (hasImage)
|
|
590
|
+
return safeSend(session, '图生视频请提供提示词');
|
|
591
|
+
return safeSend(session, cfg.messages.empty);
|
|
592
|
+
}
|
|
593
|
+
if (prompt.length > 6000)
|
|
594
|
+
return safeSend(session, '提示词过长,请限制在6000字符以内');
|
|
595
|
+
if (!hasImage) {
|
|
596
|
+
await safeSend(session, cfg.messages.generating);
|
|
597
|
+
await generateVideo(session, prompt);
|
|
598
|
+
return;
|
|
599
|
+
}
|
|
600
|
+
const assets = ctx.assets;
|
|
601
|
+
if (!assets)
|
|
602
|
+
return safeSend(session, cfg.messages.needAssets);
|
|
603
|
+
const uploadResult = await assets.upload(imgs[0].attrs.src, 'ref_image.jpg');
|
|
604
|
+
if (!uploadResult || !/^https?:\/\//.test(uploadResult)) {
|
|
605
|
+
return safeSend(session, cfg.messages.needAssets);
|
|
606
|
+
}
|
|
607
|
+
await safeSend(session, cfg.messages.generating);
|
|
608
|
+
await generateVideo(session, prompt, uploadResult);
|
|
609
|
+
}
|
|
610
|
+
catch (e) {
|
|
611
|
+
logger.error('video命令异常', e);
|
|
612
|
+
await safeSend(session, cfg.messages.fail);
|
|
613
|
+
}
|
|
614
|
+
});
|
|
615
|
+
ctx.command('redraw', '重绘上一次文生视频')
|
|
616
|
+
.action(async ({ session }) => {
|
|
617
|
+
try {
|
|
618
|
+
if (!session)
|
|
619
|
+
return;
|
|
620
|
+
if (await isBlacklisted(session.userId))
|
|
621
|
+
return safeSend(session, cfg.messages.blacklisted);
|
|
622
|
+
const userId = `${session.guildId || 'private'}-${session.userId}`;
|
|
623
|
+
const last = lastTaskMap.get(userId);
|
|
624
|
+
if (!last)
|
|
625
|
+
return safeSend(session, cfg.messages.noLastTask);
|
|
626
|
+
if (last.isImg2Video)
|
|
627
|
+
return safeSend(session, cfg.messages.redrawImg2Video);
|
|
628
|
+
await safeSend(session, cfg.messages.redrawing);
|
|
629
|
+
await generateVideo(session, last.prompt, '', last.model);
|
|
630
|
+
}
|
|
631
|
+
catch (e) {
|
|
632
|
+
logger.error('重绘命令异常', e);
|
|
633
|
+
await safeSend(session, cfg.messages.fail);
|
|
634
|
+
}
|
|
635
|
+
});
|
|
636
|
+
const blacklistCmd = ctx.command('blacklist', '黑名单管理');
|
|
637
|
+
blacklistCmd.subcommand('.list', '查看黑名单').action(async ({ session }) => {
|
|
638
|
+
if (!session)
|
|
639
|
+
return;
|
|
640
|
+
if (!cfg.blacklistAdmins.includes(session.userId))
|
|
641
|
+
return safeSend(session, cfg.messages.noPermission);
|
|
642
|
+
try {
|
|
643
|
+
const entries = await ctx.database.get('ai_video_blacklist', {});
|
|
644
|
+
if (entries.length === 0)
|
|
645
|
+
return safeSend(session, cfg.messages.blacklistListEmpty);
|
|
646
|
+
const list = entries.map(e => e.id).join('\n');
|
|
647
|
+
return safeSend(session, cfg.messages.blacklistListTitle + '\n' + list);
|
|
648
|
+
}
|
|
649
|
+
catch (e) {
|
|
650
|
+
return safeSend(session, cfg.messages.fail);
|
|
651
|
+
}
|
|
652
|
+
});
|
|
653
|
+
blacklistCmd.subcommand('.add <...targets:string>', '添加黑名单').action(async ({ session }, ...targets) => {
|
|
654
|
+
if (!session)
|
|
655
|
+
return;
|
|
656
|
+
if (!cfg.blacklistAdmins.includes(session.userId))
|
|
657
|
+
return safeSend(session, cfg.messages.noPermission);
|
|
658
|
+
const ids = targets.map(t => t.trim()).filter(id => id.length > 0);
|
|
659
|
+
const invalid = ids.filter(id => !isValidQQ(id));
|
|
660
|
+
if (invalid.length)
|
|
661
|
+
return safeSend(session, cfg.messages.invalidUserId.replace('{targets}', invalid.join(', ')));
|
|
662
|
+
const { success, fail } = await addToBlacklist(ids);
|
|
663
|
+
if (success.length)
|
|
664
|
+
await safeSend(session, cfg.messages.blacklistAddSuccess.replace('{targets}', success.join(', ')));
|
|
665
|
+
if (fail.length)
|
|
666
|
+
await safeSend(session, cfg.messages.blacklistAddFail.replace('{targets}', fail.join(', ')));
|
|
667
|
+
});
|
|
668
|
+
blacklistCmd.subcommand('.remove <...targets:string>', '移除黑名单').action(async ({ session }, ...targets) => {
|
|
669
|
+
if (!session)
|
|
670
|
+
return;
|
|
671
|
+
if (!cfg.blacklistAdmins.includes(session.userId))
|
|
672
|
+
return safeSend(session, cfg.messages.noPermission);
|
|
673
|
+
const ids = targets.map(t => t.trim()).filter(id => id.length > 0);
|
|
674
|
+
const invalid = ids.filter(id => !isValidQQ(id));
|
|
675
|
+
if (invalid.length)
|
|
676
|
+
return safeSend(session, cfg.messages.invalidUserId.replace('{targets}', invalid.join(', ')));
|
|
677
|
+
const { success, fail } = await removeFromBlacklist(ids);
|
|
678
|
+
if (success.length)
|
|
679
|
+
await safeSend(session, cfg.messages.blacklistRemoveSuccess.replace('{targets}', success.join(', ')));
|
|
680
|
+
if (fail.length)
|
|
681
|
+
await safeSend(session, cfg.messages.blacklistRemoveFail.replace('{targets}', fail.join(', ')));
|
|
682
|
+
});
|
|
683
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "koishi-plugin-ai-video",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Koishi AI 视频生成插件,支持文生视频、图生视频",
|
|
5
|
+
"main": "lib/index.js",
|
|
6
|
+
"typings": "lib/index.d.ts",
|
|
7
|
+
"files": [
|
|
8
|
+
"lib",
|
|
9
|
+
"locales"
|
|
10
|
+
],
|
|
11
|
+
"scripts": {
|
|
12
|
+
"build": "tsc"
|
|
13
|
+
},
|
|
14
|
+
"keywords": [
|
|
15
|
+
"koishi",
|
|
16
|
+
"koishi-plugin",
|
|
17
|
+
"ai",
|
|
18
|
+
"video",
|
|
19
|
+
"ai-video",
|
|
20
|
+
"text-to-video",
|
|
21
|
+
"image-to-video",
|
|
22
|
+
"文生视频",
|
|
23
|
+
"图生视频",
|
|
24
|
+
"视频生成"
|
|
25
|
+
],
|
|
26
|
+
"author": "Minecraft-1314",
|
|
27
|
+
"license": "MIT",
|
|
28
|
+
"repository": {
|
|
29
|
+
"type": "git",
|
|
30
|
+
"url": "git+https://github.com/Minecraft-1314/koishi-plugin-ai-video.git"
|
|
31
|
+
},
|
|
32
|
+
"bugs": {
|
|
33
|
+
"url": "https://github.com/Minecraft-1314/koishi-plugin-ai-video/issues"
|
|
34
|
+
},
|
|
35
|
+
"homepage": "https://github.com/Minecraft-1314/koishi-plugin-ai-video#readme",
|
|
36
|
+
"peerDependencies": {
|
|
37
|
+
"koishi": "^4.18.0"
|
|
38
|
+
},
|
|
39
|
+
"dependencies": {
|
|
40
|
+
"axios": "^1.7.9",
|
|
41
|
+
"yaml": "^2.6.1"
|
|
42
|
+
},
|
|
43
|
+
"devDependencies": {
|
|
44
|
+
"@types/node": "^22.10.5",
|
|
45
|
+
"typescript": "^5.7.2"
|
|
46
|
+
}
|
|
47
|
+
}
|
package/readme.md
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
# koishi-plugin-ai-video
|
|
2
|
+
|
|
3
|
+
## 项目介绍 (Project Introduction)
|
|
4
|
+
|
|
5
|
+
### 中文
|
|
6
|
+
一款为 Koishi 聊天机器人框架开发的 AI 视频生成插件,支持**文生视频 + 图生视频**,**兼容 OpenAI 标准接口的同时支持自定义 API 端点**,可接入任意视频生成服务。
|
|
7
|
+
内置多 API 负载均衡、调试日志、超时机制、异步轮询、全配置化提示文案(含自定义提示模板),配置灵活、开箱即用、稳定可靠。
|
|
8
|
+
图生视频支持附带参考图片,自动切换模式。
|
|
9
|
+
提供**黑名单管理**功能,数据持久化到数据库,管理员可通过指令添加、移除用户及查看黑名单。
|
|
10
|
+
|
|
11
|
+
### English
|
|
12
|
+
An AI video generation plugin for the Koishi chatbot framework, supporting **text-to-video & image-to-video**.
|
|
13
|
+
Compatible with OpenAI‑standard APIs, and also supports **custom API endpoints** for any video generation service.
|
|
14
|
+
Built‑in multi‑API load balancing, debug logging, timeout mechanism, async polling, fully configurable messages and prompt templates.
|
|
15
|
+
Image‑to‑video works by attaching a reference image, automatic mode switching.
|
|
16
|
+
**Blacklist management** with database persistence.
|
|
17
|
+
|
|
18
|
+
## 使用说明 (Usage)
|
|
19
|
+
|
|
20
|
+
### 中文
|
|
21
|
+
|
|
22
|
+
| 命令 (Command) | 功能说明 (Description) |
|
|
23
|
+
|------------------------------------|------------------------|
|
|
24
|
+
| `video <提示词>` | **AI 视频生成**:根据提示词生成视频;若消息中同时附带了图片,则自动切换为图生视频模式。 |
|
|
25
|
+
| `redraw` / `rd` / `重绘` | 重绘:重新生成上一次的文生视频结果 |
|
|
26
|
+
| `blacklist list` | 查看当前黑名单(仅管理员) |
|
|
27
|
+
| `blacklist add <QQ号> [QQ号 ...]` | 将指定 QQ 号加入黑名单(仅管理员) |
|
|
28
|
+
| `blacklist remove <QQ号> [QQ号 ...]` | 将指定 QQ 号移出黑名单(仅管理员) |
|
|
29
|
+
|
|
30
|
+
### English
|
|
31
|
+
|
|
32
|
+
| Command | Description |
|
|
33
|
+
|-------------------------------------|-------------|
|
|
34
|
+
| `video <prompt>` | **AI Video Generation**: Generate video from prompt. If an image is attached, automatically switches to image-to-video mode. |
|
|
35
|
+
| `redraw` / `rd` / `重绘` | Redraw: Re-generate the last text-to-video result |
|
|
36
|
+
| `blacklist list` | Show current blacklist (admin only) |
|
|
37
|
+
| `blacklist add <QQ_number> [QQ_number ...]` | Add QQ number(s) to blacklist (admin only) |
|
|
38
|
+
| `blacklist remove <QQ_number> [QQ_number ...]` | Remove QQ number(s) from blacklist (admin only) |
|
|
39
|
+
|
|
40
|
+
## 配置说明 (Configuration)
|
|
41
|
+
|
|
42
|
+
### 中文
|
|
43
|
+
|
|
44
|
+
| 配置项 | 说明 | 默认值 |
|
|
45
|
+
|--------|------|--------|
|
|
46
|
+
| **基本设置** | | |
|
|
47
|
+
| `debug` | 调试模式,输出完整请求/响应日志 | `false` |
|
|
48
|
+
| `timeout` | API 请求超时时间(毫秒),视频生成较慢建议加大 | `600000` |
|
|
49
|
+
| `rateLimit` | 每小时调用次数上限 | `50` |
|
|
50
|
+
| `maxVideos` | 单次生成最多视频数量 | `1` |
|
|
51
|
+
| `videoDuration` | 默认视频时长(秒) | `5` |
|
|
52
|
+
| `videoResolution` | 默认视频分辨率(宽x高) | `1024x576` |
|
|
53
|
+
| `enableForward` | 多视频结果是否使用合并转发 | `true` |
|
|
54
|
+
| `enableTxt2Video` | 启用文生视频功能 | `true` |
|
|
55
|
+
| `enableImg2Video` | 启用图生视频功能 | `true` |
|
|
56
|
+
| `videoSendMode` | 视频发送方式:`video`(仅视频文件)、`url`(仅链接)、`both`(视频文件+链接) | `video` |
|
|
57
|
+
| `pollEnabled` | 是否启用异步轮询(若 API 返回任务 ID 则自动轮询至完成) | `false` |
|
|
58
|
+
| `pollInterval` | 轮询间隔(毫秒) | `3000` |
|
|
59
|
+
| `pollTimeout` | 轮询总超时时间(毫秒) | `600000` |
|
|
60
|
+
| **代理设置** | | |
|
|
61
|
+
| `proxyEnabled` | 是否启用 HTTP/HTTPS 代理 | `false` |
|
|
62
|
+
| `proxyProtocol` | 代理协议:`http` / `https` | `http` |
|
|
63
|
+
| `proxyHost` | 代理地址 | (空) |
|
|
64
|
+
| `proxyPort` | 代理端口 | `8080` |
|
|
65
|
+
| `proxyAuth` | 代理是否需要认证 | `false` |
|
|
66
|
+
| `proxyUsername` | 代理用户名 | (空) |
|
|
67
|
+
| `proxyPassword` | 代理密码 | (空) |
|
|
68
|
+
| **内置 API 设置** | 简单模式,使用 OpenAI 格式 | |
|
|
69
|
+
| `useCustomApi` | 是否启用自定义 API(开启后下方自定义列表生效) | `false` |
|
|
70
|
+
| `apiEndpoint` | API 端点地址 | `https://api.openai.com/v1/video/generations` |
|
|
71
|
+
| `apiKey` | API 密钥 | (空) |
|
|
72
|
+
| `model` | 模型名称 | `video-generation-model` |
|
|
73
|
+
| `img2videoModel` | 图生视频专用模型(留空使用上方模型) | (空) |
|
|
74
|
+
| `videoDuration` | 视频时长(秒,留空使用全局默认) | `0` |
|
|
75
|
+
| `videoResolution` | 视频分辨率(留空使用全局默认) | (空) |
|
|
76
|
+
| `txt2videoPrompt` | 文生视频提示词模板(留空直接使用用户输入) | (空) |
|
|
77
|
+
| `img2videoPrompt` | 图生视频提示词模板(留空直接使用用户输入) | (空) |
|
|
78
|
+
| `customHeaders` | 自定义请求头 JSON(合并到默认请求头) | `{}` |
|
|
79
|
+
| **自定义 API 配置** | 高级模式,每个条目独立配置 | `[]` |
|
|
80
|
+
| `apiStrategy` | API 调度策略:`sequence` / `roundrobin` | `roundrobin` |
|
|
81
|
+
| `customApiList[].enable` | 是否启用此 API | `true` |
|
|
82
|
+
| `customApiList[].endpoint` | API 端点地址 | `https://api.openai.com/v1/video/generations` |
|
|
83
|
+
| `customApiList[].apiKey` | API 密钥 | (空) |
|
|
84
|
+
| `customApiList[].model` | 模型名称 | `video-generation-model` |
|
|
85
|
+
| `customApiList[].img2videoModel` | 图生视频专用模型(留空使用上方模型) | (空) |
|
|
86
|
+
| `customApiList[].videoDuration` | 视频时长(秒,留空使用全局默认) | `0` |
|
|
87
|
+
| `customApiList[].videoResolution` | 视频分辨率(留空使用全局默认) | (空) |
|
|
88
|
+
| `customApiList[].txt2videoPrompt` | 文生视频提示词模板 | (空) |
|
|
89
|
+
| `customApiList[].img2videoPrompt` | 图生视频提示词模板 | (空) |
|
|
90
|
+
| `customApiList[].customHeaders` | 自定义请求头 JSON,支持 `{apiKey}` 变量 | 见预设 |
|
|
91
|
+
| `customApiList[].bodyTemplate` | 自定义请求体 JSON(高级,留空使用内置) | 见预设 |
|
|
92
|
+
| **权限管理** | | |
|
|
93
|
+
| `blacklistAdmins` | 黑名单管理员 QQ 号列表 | `[]` |
|
|
94
|
+
| **消息文本** | 所有提示文案均可自定义,支持模板变量 | 见配置页 |
|
|
95
|
+
|
|
96
|
+
> 指令名称(`video`、`redraw`、`blacklist`)为固定注册名,可在 Koishi 的指令管理页面中单独配置别名。
|
|
97
|
+
|
|
98
|
+
#### 请求范式 JSON (`bodyTemplate` 字段)
|
|
99
|
+
|
|
100
|
+
`bodyTemplate` 是高级选项,用于完全自定义请求体。留空时插件使用内置模板。
|
|
101
|
+
|
|
102
|
+
| 字段 | 说明 | 默认值 |
|
|
103
|
+
|------|------|--------|
|
|
104
|
+
| `txt2videoBody` | 文生视频请求体 JSON 模板,支持变量 `{model}` `{prompt}` `{duration}` `{size}` | 内置 |
|
|
105
|
+
| `img2videoBody` | 图生视频请求体 JSON 模板,支持变量 `{model}` `{prompt}` `{duration}` `{size}` `{url}` | 内置 |
|
|
106
|
+
| `responseVideoPath` | 响应 JSON 中视频 URL 的字段路径,如 `video_url` | `video_url` |
|
|
107
|
+
| `pollUrlTemplate` | 轮询地址模板,如 `{endpoint}/{task_id}` | 无 |
|
|
108
|
+
| `taskIdPath` | 返回的任务 ID 字段路径,如 `task_id` | 无 |
|
|
109
|
+
| `method` | HTTP 方法 | `POST` |
|
|
110
|
+
|
|
111
|
+
**变量说明**
|
|
112
|
+
- `{model}`:模型名称(取自配置中的全局模型或专用模型)
|
|
113
|
+
- `{prompt}`:用户输入的提示词(经过提示模板处理后的最终文本)
|
|
114
|
+
- `{duration}`:视频时长(秒)
|
|
115
|
+
- `{size}`:视频分辨率(宽x高)
|
|
116
|
+
- `{url}`:图生视频时参考图片的链接(字符串)
|
|
117
|
+
- `{apiKey}`:API 密钥,仅用于 `headers` 字段
|
|
118
|
+
|
|
119
|
+
**异步轮询说明**
|
|
120
|
+
当 `pollEnabled` 开启且 API 返回任务 ID 时,插件会根据 `pollUrlTemplate` 和 `taskIdPath` 自动轮询任务状态,直到完成或超时。
|
|
121
|
+
|
|
122
|
+
**视频 URL 自动扫描**
|
|
123
|
+
当 `responseVideoPath` 未命中时,插件会自动扫描响应 JSON 中**第一个包含视频扩展名(.mp4/.mov/.avi/.webm/.mkv)的 HTTP/HTTPS URL** 作为视频地址。
|
|
124
|
+
|
|
125
|
+
---
|
|
126
|
+
|
|
127
|
+
### English
|
|
128
|
+
|
|
129
|
+
| Config Item | Description | Default |
|
|
130
|
+
|-------------|-------------|---------|
|
|
131
|
+
| **Basic** | | |
|
|
132
|
+
| `debug` | Debug mode, logs full request/response | `false` |
|
|
133
|
+
| `timeout` | Request timeout (ms), video generation usually slower | `600000` |
|
|
134
|
+
| `rateLimit` | Hourly call limit | `50` |
|
|
135
|
+
| `maxVideos` | Max number of videos per generation | `1` |
|
|
136
|
+
| `videoDuration` | Default video duration (seconds) | `5` |
|
|
137
|
+
| `videoResolution` | Default video resolution (WxH) | `1024x576` |
|
|
138
|
+
| `enableForward` | Use forward message for multiple videos | `true` |
|
|
139
|
+
| `enableTxt2Video` | Enable text-to-video | `true` |
|
|
140
|
+
| `enableImg2Video` | Enable image-to-video | `true` |
|
|
141
|
+
| `videoSendMode` | Video send mode: `video`, `url`, `both` | `video` |
|
|
142
|
+
| `pollEnabled` | Enable async polling (polls until completion if task ID returned) | `false` |
|
|
143
|
+
| `pollInterval` | Polling interval (ms) | `3000` |
|
|
144
|
+
| `pollTimeout` | Polling total timeout (ms) | `600000` |
|
|
145
|
+
| **Proxy** | | |
|
|
146
|
+
| `proxyEnabled` | Enable HTTP/HTTPS proxy | `false` |
|
|
147
|
+
| `proxyProtocol` | Proxy protocol: `http` / `https` | `http` |
|
|
148
|
+
| `proxyHost` | Proxy host | (empty) |
|
|
149
|
+
| `proxyPort` | Proxy port | `8080` |
|
|
150
|
+
| `proxyAuth` | Proxy requires authentication | `false` |
|
|
151
|
+
| `proxyUsername` | Proxy username | (empty) |
|
|
152
|
+
| `proxyPassword` | Proxy password | (empty) |
|
|
153
|
+
| **Built-in API** | Simple mode, uses OpenAI format | |
|
|
154
|
+
| `useCustomApi` | Enable custom API (below list takes effect) | `false` |
|
|
155
|
+
| `apiEndpoint` | API endpoint URL | `https://api.openai.com/v1/video/generations` |
|
|
156
|
+
| `apiKey` | API key | (empty) |
|
|
157
|
+
| `model` | Model name | `video-generation-model` |
|
|
158
|
+
| `img2videoModel` | Image-to-video model (falls back to model) | (empty) |
|
|
159
|
+
| `videoDuration` | Video duration (seconds, 0 = use global) | `0` |
|
|
160
|
+
| `videoResolution` | Video resolution (empty = use global) | (empty) |
|
|
161
|
+
| `txt2videoPrompt` | Prompt template (empty = raw user input) | (empty) |
|
|
162
|
+
| `img2videoPrompt` | Prompt template (empty = raw user input) | (empty) |
|
|
163
|
+
| `customHeaders` | Custom headers JSON (merged into default) | `{}` |
|
|
164
|
+
| **Custom API Config** | Advanced mode, per-entry configuration | `[]` |
|
|
165
|
+
| `apiStrategy` | API strategy: `sequence` / `roundrobin` | `roundrobin` |
|
|
166
|
+
| `customApiList[].enable` | Enable this API | `true` |
|
|
167
|
+
| `customApiList[].endpoint` | API endpoint URL | `https://api.openai.com/v1/video/generations` |
|
|
168
|
+
| `customApiList[].apiKey` | API key | (empty) |
|
|
169
|
+
| `customApiList[].model` | Model name | `video-generation-model` |
|
|
170
|
+
| `customApiList[].img2videoModel` | Image-to-video model (falls back to model) | (empty) |
|
|
171
|
+
| `customApiList[].videoDuration` | Video duration (seconds, 0 = use global) | `0` |
|
|
172
|
+
| `customApiList[].videoResolution` | Video resolution (empty = use global) | (empty) |
|
|
173
|
+
| `customApiList[].txt2videoPrompt` | Prompt template | (empty) |
|
|
174
|
+
| `customApiList[].img2videoPrompt` | Prompt template | (empty) |
|
|
175
|
+
| `customApiList[].customHeaders` | Custom headers JSON, supports `{apiKey}` | preset |
|
|
176
|
+
| `customApiList[].bodyTemplate` | Custom request body JSON (advanced) | preset |
|
|
177
|
+
| **Permissions** | | |
|
|
178
|
+
| `blacklistAdmins` | Admin QQ number list | `[]` |
|
|
179
|
+
| **Messages** | All messages customizable with template vars | see schema |
|
|
180
|
+
|
|
181
|
+
> Command names (`video`, `redraw`, `blacklist`) are fixed; aliases can be configured via Koishi's command management page.
|
|
182
|
+
|
|
183
|
+
#### Request Template JSON (`bodyTemplate` field)
|
|
184
|
+
|
|
185
|
+
`bodyTemplate` is the advanced option for full custom request bodies. Leave empty to use built-in templates.
|
|
186
|
+
|
|
187
|
+
| Field | Description | Default |
|
|
188
|
+
|-------|-------------|---------|
|
|
189
|
+
| `txt2videoBody` | Txt2video request body template, vars `{model}` `{prompt}` `{duration}` `{size}` | built-in |
|
|
190
|
+
| `img2videoBody` | Img2video request body template, vars `{model}` `{prompt}` `{duration}` `{size}` `{url}` | built-in |
|
|
191
|
+
| `responseVideoPath` | JSON path to video URL, e.g. `video_url` | `video_url` |
|
|
192
|
+
| `pollUrlTemplate` | Polling URL template, e.g. `{endpoint}/{task_id}` | none |
|
|
193
|
+
| `taskIdPath` | JSON path to task ID, e.g. `task_id` | none |
|
|
194
|
+
| `method` | HTTP method | `POST` |
|
|
195
|
+
|
|
196
|
+
**Variable placeholders**
|
|
197
|
+
- `{model}` — Model name
|
|
198
|
+
- `{prompt}` — Processed user prompt after template expansion
|
|
199
|
+
- `{duration}` — Video duration in seconds
|
|
200
|
+
- `{size}` — Video resolution (WxH)
|
|
201
|
+
- `{url}` — Reference image URL for image-to-video
|
|
202
|
+
- `{apiKey}` — API key, used only in `headers`
|
|
203
|
+
|
|
204
|
+
**Async Polling**
|
|
205
|
+
When `pollEnabled` is on and the API returns a task ID, the plugin will automatically poll using `pollUrlTemplate` and `taskIdPath` until completion or timeout.
|
|
206
|
+
|
|
207
|
+
**Auto Video URL Scanning**
|
|
208
|
+
When `responseVideoPath` fails, the plugin scans the response JSON for the **first HTTP/HTTPS URL with a video extension (.mp4/.mov/.avi/.webm/.mkv)** as the video address.
|
|
209
|
+
|
|
210
|
+
---
|
|
211
|
+
|
|
212
|
+
## 自定义 API 端点示例 (Custom Endpoint Examples)
|
|
213
|
+
|
|
214
|
+
### 基础视频生成 API(同步返回)
|
|
215
|
+
```json
|
|
216
|
+
{
|
|
217
|
+
"endpoint": "https://api.example.com/v1/video/generations",
|
|
218
|
+
"apiKey": "sk-xxxx",
|
|
219
|
+
"headers": {"Authorization":"Bearer {apiKey}","Content-Type":"application/json"},
|
|
220
|
+
"txt2videoBody": "{\"model\":\"{model}\",\"prompt\":\"{prompt}\",\"duration\":{duration},\"size\":\"{size}\"}",
|
|
221
|
+
"img2videoBody": "{\"model\":\"{model}\",\"prompt\":\"{prompt}\",\"duration\":{duration},\"size\":\"{size}\",\"image_url\":\"{url}\"}",
|
|
222
|
+
"responseVideoPath": "video_url"
|
|
223
|
+
}
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
### 异步轮询 API(返回任务 ID)
|
|
227
|
+
```json
|
|
228
|
+
{
|
|
229
|
+
"endpoint": "https://api.example.com/v1/video/tasks",
|
|
230
|
+
"apiKey": "sk-xxxx",
|
|
231
|
+
"headers": {"Authorization":"Bearer {apiKey}","Content-Type":"application/json"},
|
|
232
|
+
"txt2videoBody": "{\"model\":\"{model}\",\"prompt\":\"{prompt}\",\"duration\":{duration},\"size\":\"{size}\"}",
|
|
233
|
+
"img2videoBody": "{\"model\":\"{model}\",\"prompt\":\"{prompt}\",\"duration\":{duration},\"size\":\"{size}\",\"image_url\":\"{url}\"}",
|
|
234
|
+
"responseVideoPath": "result.video_url",
|
|
235
|
+
"pollUrlTemplate": "{endpoint}/{task_id}",
|
|
236
|
+
"taskIdPath": "task_id"
|
|
237
|
+
}
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
## 依赖 (Dependencies)
|
|
241
|
+
- **数据库 (database)**:必须启用,用于黑名单持久化。
|
|
242
|
+
- **图生视频 (img2video)**:需要 `assets` 服务及正确的 `selfUrl` 配置。
|
|
243
|
+
|
|
244
|
+
## 功能特性 (Features)
|
|
245
|
+
- 文生视频 / 图生视频(自动识别消息中是否附带图片)
|
|
246
|
+
- 多 API 轮询与负载均衡(sequence / roundrobin)
|
|
247
|
+
- 支持同步返回与异步轮询两种模式,适配不同视频生成服务
|
|
248
|
+
- 灵活的请求体模板系统,变量自动替换,一套配置兼容任意平台
|
|
249
|
+
- 视频结果支持文件发送和链接发送
|
|
250
|
+
- 黑名单管理(持久化到数据库)
|
|
251
|
+
- 全配置化提示文案,支持模板变量
|
|
252
|
+
- 调试日志、超时控制、频率限制
|
|
253
|
+
|
|
254
|
+
## 项目贡献者 (Contributors)
|
|
255
|
+
| 贡献者 (Contributor) | 贡献内容 (Contribution) |
|
|
256
|
+
| --- | --- |
|
|
257
|
+
| Minecraft-1314 | 插件完整开发(从绘图插件改造) |
|
|
258
|
+
| (欢迎提交 PR / Issues 加入贡献者列表) |
|
|
259
|
+
|
|
260
|
+
## 许可协议 (License)
|
|
261
|
+
本项目采用 MIT 许可证,详情参见 LICENSE 文件。
|
|
262
|
+
|
|
263
|
+
This project is licensed under the MIT License, see the LICENSE file for details.
|
|
264
|
+
|
|
265
|
+
## 支持我们 (Support Us)
|
|
266
|
+
如果这个项目对您有帮助,欢迎点亮右上角的 Star 支持我们,这将是对所有贡献者最大的鼓励!
|
|
267
|
+
|
|
268
|
+
If this project is helpful to you, please feel free to star it in the upper right corner to support us, which will be the greatest encouragement to all contributors!
|
|
269
|
+
|
|
270
|
+
## 问题反馈 (Feedback)
|
|
271
|
+
如有问题或建议,可通过 Issues 提交反馈。
|
|
272
|
+
|
|
273
|
+
If you have any questions or suggestions, please submit feedback via Issues.
|