@amaster.ai/pi-video-gen 0.1.2-beta.48
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/LICENSE +201 -0
- package/README.md +196 -0
- package/dist/compose.d.ts +43 -0
- package/dist/compose.d.ts.map +1 -0
- package/dist/compose.js +308 -0
- package/dist/compose.js.map +1 -0
- package/dist/config.d.ts +29 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +305 -0
- package/dist/config.js.map +1 -0
- package/dist/errors.d.ts +50 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +99 -0
- package/dist/errors.js.map +1 -0
- package/dist/ffmpeg.d.ts +64 -0
- package/dist/ffmpeg.d.ts.map +1 -0
- package/dist/ffmpeg.js +292 -0
- package/dist/ffmpeg.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +837 -0
- package/dist/index.js.map +1 -0
- package/dist/jobs/store.d.ts +124 -0
- package/dist/jobs/store.d.ts.map +1 -0
- package/dist/jobs/store.js +386 -0
- package/dist/jobs/store.js.map +1 -0
- package/dist/providers/ark.d.ts +26 -0
- package/dist/providers/ark.d.ts.map +1 -0
- package/dist/providers/ark.js +178 -0
- package/dist/providers/ark.js.map +1 -0
- package/dist/providers/dashscope.d.ts +28 -0
- package/dist/providers/dashscope.d.ts.map +1 -0
- package/dist/providers/dashscope.js +200 -0
- package/dist/providers/dashscope.js.map +1 -0
- package/dist/providers/kling.d.ts +46 -0
- package/dist/providers/kling.d.ts.map +1 -0
- package/dist/providers/kling.js +245 -0
- package/dist/providers/kling.js.map +1 -0
- package/dist/providers/models.d.ts +16 -0
- package/dist/providers/models.d.ts.map +1 -0
- package/dist/providers/models.js +152 -0
- package/dist/providers/models.js.map +1 -0
- package/dist/providers/newapi.d.ts +3 -0
- package/dist/providers/newapi.d.ts.map +1 -0
- package/dist/providers/newapi.js +203 -0
- package/dist/providers/newapi.js.map +1 -0
- package/dist/providers/openrouter.d.ts +21 -0
- package/dist/providers/openrouter.d.ts.map +1 -0
- package/dist/providers/openrouter.js +169 -0
- package/dist/providers/openrouter.js.map +1 -0
- package/dist/providers/request.d.ts +4 -0
- package/dist/providers/request.d.ts.map +1 -0
- package/dist/providers/request.js +20 -0
- package/dist/providers/request.js.map +1 -0
- package/dist/providers/task.d.ts +53 -0
- package/dist/providers/task.d.ts.map +1 -0
- package/dist/providers/task.js +209 -0
- package/dist/providers/task.js.map +1 -0
- package/dist/render.d.ts +53 -0
- package/dist/render.d.ts.map +1 -0
- package/dist/render.js +480 -0
- package/dist/render.js.map +1 -0
- package/dist/text-layer.d.ts +18 -0
- package/dist/text-layer.d.ts.map +1 -0
- package/dist/text-layer.js +168 -0
- package/dist/text-layer.js.map +1 -0
- package/dist/timeline-render.d.ts +33 -0
- package/dist/timeline-render.d.ts.map +1 -0
- package/dist/timeline-render.js +1051 -0
- package/dist/timeline-render.js.map +1 -0
- package/dist/timeline.d.ts +64 -0
- package/dist/timeline.d.ts.map +1 -0
- package/dist/timeline.js +246 -0
- package/dist/timeline.js.map +1 -0
- package/dist/tts/edge-tts.d.ts +22 -0
- package/dist/tts/edge-tts.d.ts.map +1 -0
- package/dist/tts/edge-tts.js +55 -0
- package/dist/tts/edge-tts.js.map +1 -0
- package/dist/types.d.ts +161 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +13 -0
- package/dist/types.js.map +1 -0
- package/package.json +93 -0
- package/preview.png +0 -0
- package/skills/video-gen/SKILL.md +284 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,837 @@
|
|
|
1
|
+
import { COPYFILE_EXCL } from 'node:constants';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { copyFile, readFile, rename } from 'node:fs/promises';
|
|
5
|
+
import { extname, join, resolve } from 'node:path';
|
|
6
|
+
import { isProjectTrusted } from '@amaster.ai/pi-shared/settings';
|
|
7
|
+
import { StringEnum } from '@earendil-works/pi-ai';
|
|
8
|
+
import { truncateHead, } from '@earendil-works/pi-coding-agent';
|
|
9
|
+
import { Type } from 'typebox';
|
|
10
|
+
import { runCompose } from './compose.js';
|
|
11
|
+
import { listModelRegistry, loadVideoGenSettings, resolveModel, resolveOutputDir, } from './config.js';
|
|
12
|
+
import { AmbiguousSubmitError, errorMessageForUser, missingKeyError, providerLabel, RemoteTaskFailedError, RemoteTaskNotFoundError, redactUrl, toLogSummary, } from './errors.js';
|
|
13
|
+
import { resolveFfmpeg, resolveFfprobe, resolveGplFfmpeg } from './ffmpeg.js';
|
|
14
|
+
import { ActiveJobs, assertSafeId, ensureSingleJobDir, loadRenderJob, loadSingleJob, newJobId, readJsonFile, resolveJobDirInsideOutput, saveRenderJob, saveSingleJob, singleJobDir, writeJsonAtomic, } from './jobs/store.js';
|
|
15
|
+
import { arkAdapter } from './providers/ark.js';
|
|
16
|
+
import { dashscopeAdapter } from './providers/dashscope.js';
|
|
17
|
+
import { klingAdapter } from './providers/kling.js';
|
|
18
|
+
import { newapiAdapter } from './providers/newapi.js';
|
|
19
|
+
import { openrouterAdapter } from './providers/openrouter.js';
|
|
20
|
+
import { requestFingerprint } from './providers/request.js';
|
|
21
|
+
import { CancelledError, pollTask, RateLimiter } from './providers/task.js';
|
|
22
|
+
import { runRender } from './render.js';
|
|
23
|
+
import { hasCjkFont } from './text-layer.js';
|
|
24
|
+
import { runTimeline } from './timeline-render.js';
|
|
25
|
+
/** Registered provider wire adapters. */
|
|
26
|
+
const ADAPTERS = {
|
|
27
|
+
ark: arkAdapter,
|
|
28
|
+
dashscope: dashscopeAdapter,
|
|
29
|
+
kling: klingAdapter,
|
|
30
|
+
newapi: newapiAdapter,
|
|
31
|
+
openrouter: openrouterAdapter,
|
|
32
|
+
};
|
|
33
|
+
const ENV_VAR_BY_STYLE = {
|
|
34
|
+
ark: 'ARK_API_KEY',
|
|
35
|
+
dashscope: 'DASHSCOPE_API_KEY',
|
|
36
|
+
kling: 'KLING_API_KEY',
|
|
37
|
+
newapi: 'NEWAPI_API_KEY',
|
|
38
|
+
openrouter: 'OPENROUTER_API_KEY',
|
|
39
|
+
};
|
|
40
|
+
function okResult(text, details = {}) {
|
|
41
|
+
return { content: [{ type: 'text', text: boundToolText(text) }], details: boundDetails(details) };
|
|
42
|
+
}
|
|
43
|
+
function errResult(text, details = {}) {
|
|
44
|
+
return {
|
|
45
|
+
isError: true,
|
|
46
|
+
content: [{ type: 'text', text: boundToolText(text) }],
|
|
47
|
+
details: boundDetails(details),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
function boundToolText(text) {
|
|
51
|
+
const result = truncateHead(text, { maxBytes: 47 * 1024, maxLines: 1_900 });
|
|
52
|
+
return result.truncated ? `${result.content}\n\n[pi-video-gen output truncated]` : result.content;
|
|
53
|
+
}
|
|
54
|
+
function boundDetails(details) {
|
|
55
|
+
try {
|
|
56
|
+
return Buffer.byteLength(JSON.stringify(details), 'utf-8') <= 2 * 1024
|
|
57
|
+
? details
|
|
58
|
+
: { truncated: true };
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return { truncated: true };
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
/** Capability-driven schema for video_generate (mirrors pi-image-gen's approach). */
|
|
65
|
+
function buildGenerateParams(caps) {
|
|
66
|
+
return Type.Object({
|
|
67
|
+
prompt: Type.String({
|
|
68
|
+
description: 'Text prompt for the clip. Describe subject, motion, and camera. When the active model has nativeAudio, append audio cues (e.g. "[Sound Effect] rain; [Speaker] Alice (soft): line").',
|
|
69
|
+
}),
|
|
70
|
+
firstFrame: Type.Optional(Type.String({
|
|
71
|
+
description: 'Path to a first-frame reference image (png/jpg/webp), absolute or relative to the session cwd.',
|
|
72
|
+
})),
|
|
73
|
+
...(caps?.supportsFirstLastFrame
|
|
74
|
+
? {
|
|
75
|
+
lastFrame: Type.Optional(Type.String({
|
|
76
|
+
description: 'Path to a last-frame reference image for first+last-frame interpolation.',
|
|
77
|
+
})),
|
|
78
|
+
}
|
|
79
|
+
: {}),
|
|
80
|
+
durationSec: Type.Optional(Type.Integer({
|
|
81
|
+
...(caps ? { minimum: caps.durations[0], maximum: caps.durations[1] } : {}),
|
|
82
|
+
description: caps
|
|
83
|
+
? `Clip duration in seconds, integer ${caps.durations[0]}-${caps.durations[1]} for the active model.`
|
|
84
|
+
: 'Clip duration in seconds (model-dependent).',
|
|
85
|
+
})),
|
|
86
|
+
aspectRatio: caps
|
|
87
|
+
? Type.Optional(StringEnum(caps.aspectRatios, { description: 'Aspect ratio for the active model.' }))
|
|
88
|
+
: Type.Optional(Type.String({ description: 'Aspect ratio (model-dependent).' })),
|
|
89
|
+
jobId: Type.Optional(Type.String({
|
|
90
|
+
description: 'Resume a previous single-clip job by id (from an interrupted call) instead of submitting a new paid task.',
|
|
91
|
+
})),
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
export default function piVideoGenExtension(pi) {
|
|
95
|
+
let settings = {};
|
|
96
|
+
let rateLimiter = new RateLimiter();
|
|
97
|
+
const activeJobs = new ActiveJobs();
|
|
98
|
+
const reloadSettings = (ctx) => {
|
|
99
|
+
settings = loadVideoGenSettings(ctx.cwd, isProjectTrusted(ctx));
|
|
100
|
+
rateLimiter = new RateLimiter(settings.rateLimit?.maxRequestsPerMinute, settings.rateLimit?.maxRequestsPerDay);
|
|
101
|
+
};
|
|
102
|
+
const sha256hex = (content) => createHash('sha256').update(content).digest('hex');
|
|
103
|
+
/**
|
|
104
|
+
* Freeze a frame into the job directory with an exclusive-create copy and
|
|
105
|
+
* record its content hash — a paid task must be provably tied to the exact
|
|
106
|
+
* image BYTES submitted, not just a path (decision 19②⑤⑧).
|
|
107
|
+
*/
|
|
108
|
+
const snapshotFrame = async (jobDir, sourcePath, label) => {
|
|
109
|
+
const ext = extname(sourcePath) || '.png';
|
|
110
|
+
const rel = join('assets', `${label}${ext}`);
|
|
111
|
+
const dest = join(jobDir, rel);
|
|
112
|
+
mkdirSync(join(jobDir, 'assets'), { recursive: true });
|
|
113
|
+
let tmp = '';
|
|
114
|
+
for (let i = 0;; i++) {
|
|
115
|
+
const candidate = `${dest}.tmp-${process.pid}-${i}`;
|
|
116
|
+
try {
|
|
117
|
+
await copyFile(sourcePath, candidate, COPYFILE_EXCL);
|
|
118
|
+
tmp = candidate;
|
|
119
|
+
break;
|
|
120
|
+
}
|
|
121
|
+
catch (error) {
|
|
122
|
+
if (error.code === 'EEXIST')
|
|
123
|
+
continue;
|
|
124
|
+
throw error;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
await rename(tmp, dest);
|
|
128
|
+
return { rel, hash: sha256hex(await readFile(dest)) };
|
|
129
|
+
};
|
|
130
|
+
/** Shared single-clip flow for the tool's execute() and `/video-gen generate`. */
|
|
131
|
+
const runGenerate = async (toolParams, ctx, signal, onUpdate) => {
|
|
132
|
+
const resolved = resolveModel(settings);
|
|
133
|
+
if (!resolved) {
|
|
134
|
+
return errResult(`Cannot resolve model "${settings.defaultModel ?? 'seedance'}" against the built-in registry. Run /video-gen models to see available ids and aliases, or fix "pi-video-gen.defaultModel" in settings.`);
|
|
135
|
+
}
|
|
136
|
+
const caps = resolved.entry.capabilities;
|
|
137
|
+
const adapter = ADAPTERS[resolved.provider.style];
|
|
138
|
+
if (!adapter) {
|
|
139
|
+
return errResult(`Provider adapter "${resolved.provider.style}" is unavailable in this build. Run /video-gen models or reinstall pi-video-gen.`);
|
|
140
|
+
}
|
|
141
|
+
if (!resolved.provider.apiKey) {
|
|
142
|
+
return errResult(missingKeyError(resolved.provider.style, ENV_VAR_BY_STYLE[resolved.provider.style], resolved.provider.apiKeyPath).message);
|
|
143
|
+
}
|
|
144
|
+
// Map tool params → internal params, applying model defaults. (A raw cast
|
|
145
|
+
// here silently DROPPED firstFrame/lastFrame and defaulted resolution,
|
|
146
|
+
// duration, ratio and audio — turning paid requests into silent t2v.)
|
|
147
|
+
const params = {
|
|
148
|
+
prompt: toolParams.prompt,
|
|
149
|
+
firstFramePath: toolParams.firstFrame ? resolve(ctx.cwd, toolParams.firstFrame) : undefined,
|
|
150
|
+
lastFramePath: toolParams.lastFrame ? resolve(ctx.cwd, toolParams.lastFrame) : undefined,
|
|
151
|
+
referenceImagePaths: toolParams.referenceImages?.map((p) => resolve(ctx.cwd, p)),
|
|
152
|
+
durationSec: toolParams.durationSec ?? resolved.entry.defaultDurationSec,
|
|
153
|
+
aspectRatio: toolParams.aspectRatio ?? resolved.entry.defaultAspectRatio,
|
|
154
|
+
resolution: resolved.entry.defaultResolution,
|
|
155
|
+
generateAudio: caps.nativeAudio,
|
|
156
|
+
};
|
|
157
|
+
if (toolParams.lastFrame && !caps.supportsFirstLastFrame) {
|
|
158
|
+
return errResult(`The active model (${resolved.entry.id}) does not support last-frame interpolation. Remove lastFrame, or switch models (/video-gen models).`);
|
|
159
|
+
}
|
|
160
|
+
if (toolParams.durationSec != null &&
|
|
161
|
+
(!Number.isInteger(toolParams.durationSec) ||
|
|
162
|
+
toolParams.durationSec < caps.durations[0] ||
|
|
163
|
+
toolParams.durationSec > caps.durations[1])) {
|
|
164
|
+
if (!Number.isInteger(toolParams.durationSec)) {
|
|
165
|
+
return errResult(`durationSec must be a whole number of seconds (${caps.durations[0]}-${caps.durations[1]} for ${resolved.entry.id}) — got ${toolParams.durationSec}.`);
|
|
166
|
+
}
|
|
167
|
+
return errResult(`durationSec must be ${caps.durations[0]}-${caps.durations[1]}s for ${resolved.entry.id} (got ${toolParams.durationSec}).`);
|
|
168
|
+
}
|
|
169
|
+
if (toolParams.aspectRatio && !caps.aspectRatios.includes(toolParams.aspectRatio)) {
|
|
170
|
+
return errResult(`aspectRatio must be one of ${caps.aspectRatios.join(', ')} for ${resolved.entry.id} (got ${toolParams.aspectRatio}).`);
|
|
171
|
+
}
|
|
172
|
+
const totalRefs = (toolParams.firstFrame ? 1 : 0) +
|
|
173
|
+
(toolParams.lastFrame ? 1 : 0) +
|
|
174
|
+
(toolParams.referenceImages?.length ?? 0);
|
|
175
|
+
if (totalRefs > caps.maxReferenceImages) {
|
|
176
|
+
return errResult(`Too many reference images (${totalRefs}) — ${resolved.entry.id} accepts at most ${caps.maxReferenceImages} total (frames + references).`);
|
|
177
|
+
}
|
|
178
|
+
const outputDir = resolveOutputDir(settings, ctx.cwd);
|
|
179
|
+
// Resume path: a job id means "keep polling the persisted handle", never re-submit.
|
|
180
|
+
if (toolParams.jobId) {
|
|
181
|
+
assertSafeId(toolParams.jobId, 'job');
|
|
182
|
+
const manifest = loadSingleJob(outputDir, toolParams.jobId);
|
|
183
|
+
if (manifest?.state === 'ambiguous') {
|
|
184
|
+
return errResult(`Job "${toolParams.jobId}" has an ambiguous submit — a paid task MAY exist. Check the provider console before starting another generation; this job will not submit again automatically.`, { jobId: toolParams.jobId });
|
|
185
|
+
}
|
|
186
|
+
// Terminal states have no business re-polling: a finished job returns
|
|
187
|
+
// its clip; a failed one refuses (permanent failures don't heal).
|
|
188
|
+
if (manifest?.state === 'done' && manifest.videoPath && existsSync(manifest.videoPath)) {
|
|
189
|
+
return okResult(`Video clip already rendered: ${manifest.videoPath}. Job id: ${manifest.jobId}.`, {
|
|
190
|
+
jobId: manifest.jobId,
|
|
191
|
+
videoPath: manifest.videoPath,
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
if (manifest?.state === 'failed') {
|
|
195
|
+
return errResult(`Job ${toolParams.jobId} already failed permanently (its manifest records the reason). Start a fresh generation instead of resuming.`);
|
|
196
|
+
}
|
|
197
|
+
if (!manifest?.handle) {
|
|
198
|
+
return errResult(`No resumable job found with id "${toolParams.jobId}". The manifest is missing or has no task handle; start a fresh generation instead.`);
|
|
199
|
+
}
|
|
200
|
+
if (!manifest.requestFingerprint ||
|
|
201
|
+
manifest.handle.requestFingerprint !== manifest.requestFingerprint) {
|
|
202
|
+
return errResult(`Job ${toolParams.jobId} has a task handle that does not match its frozen request fingerprint. Refusing to poll it; inspect or discard the manifest.`, { jobId: toolParams.jobId });
|
|
203
|
+
}
|
|
204
|
+
// Frozen task identity, fail-CLOSED: a manifest without identity
|
|
205
|
+
// fields predates the freeze and cannot be resumed safely (settings may
|
|
206
|
+
// have changed since) — no silent fallback to the current endpoint.
|
|
207
|
+
if (!manifest.modelId || !manifest.providerStyle || !manifest.providerBaseUrl) {
|
|
208
|
+
return errResult(`Job ${toolParams.jobId} was created before task-identity freezing and cannot be resumed safely. Start a new job (or delete .video-gen/single/${toolParams.jobId} under your video-gen output directory to clear it).`);
|
|
209
|
+
}
|
|
210
|
+
// Settings must still point at the same endpoint — polling the old task
|
|
211
|
+
// id against a reconfigured provider hits the wrong URL.
|
|
212
|
+
if (manifest.modelId !== resolved.remoteId ||
|
|
213
|
+
manifest.providerStyle !== resolved.provider.style ||
|
|
214
|
+
manifest.providerBaseUrl !== resolved.provider.baseUrl) {
|
|
215
|
+
return errResult(`Job ${toolParams.jobId} was created with model "${manifest.modelId ?? resolved.remoteId}" on ${manifest.providerStyle ?? resolved.provider.style} (${manifest.providerBaseUrl ? redactUrl(manifest.providerBaseUrl) : 'default endpoint'}), but current settings resolve to "${resolved.remoteId}" on ${resolved.provider.style} (${redactUrl(resolved.provider.baseUrl)}). Restore the previous settings to resume it, or start a new job.`);
|
|
216
|
+
}
|
|
217
|
+
const jobDir = resolveJobDirInsideOutput(outputDir, singleJobDir(outputDir, manifest.jobId));
|
|
218
|
+
const frozenInput = readJsonFile(join(jobDir, 'input.json'));
|
|
219
|
+
if (!frozenInput ||
|
|
220
|
+
frozenInput.model !== manifest.modelId ||
|
|
221
|
+
frozenInput.requestId !== manifest.jobId ||
|
|
222
|
+
typeof frozenInput.prompt !== 'string' ||
|
|
223
|
+
requestFingerprint(frozenInput.model, frozenInput) !==
|
|
224
|
+
manifest.requestFingerprint) {
|
|
225
|
+
return errResult(`Job ${toolParams.jobId} no longer matches its frozen input snapshot. Refusing to poll a task whose request identity cannot be verified.`, { jobId: toolParams.jobId });
|
|
226
|
+
}
|
|
227
|
+
// Frozen frame verification: resume only if the snapshots are intact.
|
|
228
|
+
if (manifest.frameHashes) {
|
|
229
|
+
for (const [rel, expected] of Object.entries(manifest.frameHashes)) {
|
|
230
|
+
const snapPath = join(singleJobDir(outputDir, manifest.jobId), rel);
|
|
231
|
+
let actual;
|
|
232
|
+
try {
|
|
233
|
+
actual = sha256hex(await readFile(snapPath));
|
|
234
|
+
}
|
|
235
|
+
catch {
|
|
236
|
+
actual = undefined;
|
|
237
|
+
}
|
|
238
|
+
if (actual !== expected) {
|
|
239
|
+
return errResult(`Frame snapshot ${rel} changed or is missing — the frozen input is no longer trustworthy. Start a new job.`);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
const release = activeJobs.acquire(jobDir);
|
|
244
|
+
try {
|
|
245
|
+
return await finishJob(manifest, resolved.provider, adapter, outputDir, signal, onUpdate);
|
|
246
|
+
}
|
|
247
|
+
finally {
|
|
248
|
+
release();
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
const jobId = newJobId('gen');
|
|
252
|
+
const jobDir = ensureSingleJobDir(outputDir, jobId);
|
|
253
|
+
params.requestId = jobId;
|
|
254
|
+
const release = activeJobs.acquire(jobDir);
|
|
255
|
+
try {
|
|
256
|
+
// Freeze every frame input into the job BEFORE the paid submit — the
|
|
257
|
+
// task must be tied to exact image BYTES, not a mutable path.
|
|
258
|
+
const frameHashes = {};
|
|
259
|
+
try {
|
|
260
|
+
if (params.firstFramePath) {
|
|
261
|
+
const snap = await snapshotFrame(jobDir, params.firstFramePath, 'first_frame');
|
|
262
|
+
frameHashes[snap.rel] = snap.hash;
|
|
263
|
+
params.firstFramePath = join(jobDir, snap.rel);
|
|
264
|
+
}
|
|
265
|
+
if (params.lastFramePath) {
|
|
266
|
+
const snap = await snapshotFrame(jobDir, params.lastFramePath, 'last_frame');
|
|
267
|
+
frameHashes[snap.rel] = snap.hash;
|
|
268
|
+
params.lastFramePath = join(jobDir, snap.rel);
|
|
269
|
+
}
|
|
270
|
+
if (params.referenceImagePaths?.length) {
|
|
271
|
+
const frozenRefs = [];
|
|
272
|
+
for (let i = 0; i < params.referenceImagePaths.length; i++) {
|
|
273
|
+
const snap = await snapshotFrame(jobDir, params.referenceImagePaths[i], `ref_${i}`);
|
|
274
|
+
frameHashes[snap.rel] = snap.hash;
|
|
275
|
+
frozenRefs.push(join(jobDir, snap.rel));
|
|
276
|
+
}
|
|
277
|
+
params.referenceImagePaths = frozenRefs;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
catch {
|
|
281
|
+
return errResult('A reference frame is not readable. Use the absolute path returned by image_generate, or an existing png/jpg/webp file.');
|
|
282
|
+
}
|
|
283
|
+
writeJsonAtomic(`${jobDir}/input.json`, { model: resolved.remoteId, ...params });
|
|
284
|
+
await rateLimiter.acquire(signal);
|
|
285
|
+
const expectedFingerprint = requestFingerprint(resolved.remoteId, params);
|
|
286
|
+
// Persist a fail-closed state BEFORE the paid request. A crash or an
|
|
287
|
+
// AmbiguousSubmitError leaves a job that refuses automatic resubmission.
|
|
288
|
+
saveSingleJob(outputDir, {
|
|
289
|
+
jobId,
|
|
290
|
+
kind: 'single',
|
|
291
|
+
state: 'ambiguous',
|
|
292
|
+
modelId: resolved.remoteId,
|
|
293
|
+
providerStyle: resolved.provider.style,
|
|
294
|
+
providerBaseUrl: resolved.provider.baseUrl,
|
|
295
|
+
requestFingerprint: expectedFingerprint,
|
|
296
|
+
frameHashes, // frozen bytes are provable even if the response is lost
|
|
297
|
+
updatedAt: new Date().toISOString(),
|
|
298
|
+
});
|
|
299
|
+
onUpdate?.(okResult('Submitting video task…'));
|
|
300
|
+
const handle = await adapter.submit(resolved.provider, resolved.remoteId, params, fetch, signal);
|
|
301
|
+
saveSingleJob(outputDir, {
|
|
302
|
+
jobId,
|
|
303
|
+
kind: 'single',
|
|
304
|
+
state: 'submitted',
|
|
305
|
+
handle,
|
|
306
|
+
requestFingerprint: expectedFingerprint,
|
|
307
|
+
modelId: resolved.remoteId,
|
|
308
|
+
providerStyle: resolved.provider.style,
|
|
309
|
+
providerBaseUrl: resolved.provider.baseUrl,
|
|
310
|
+
frameHashes,
|
|
311
|
+
updatedAt: new Date().toISOString(),
|
|
312
|
+
});
|
|
313
|
+
return await finishJob(loadSingleJob(outputDir, jobId), resolved.provider, adapter, outputDir, signal, onUpdate);
|
|
314
|
+
}
|
|
315
|
+
catch (error) {
|
|
316
|
+
return handleGenerateError(error, outputDir, jobId);
|
|
317
|
+
}
|
|
318
|
+
finally {
|
|
319
|
+
release();
|
|
320
|
+
}
|
|
321
|
+
};
|
|
322
|
+
/** Poll an existing handle to completion and download the clip. */
|
|
323
|
+
const finishJob = async (manifest, provider, adapter, outputDir, signal, onUpdate) => {
|
|
324
|
+
const handle = manifest.handle;
|
|
325
|
+
const jobDir = resolveJobDirInsideOutput(outputDir, singleJobDir(outputDir, manifest.jobId));
|
|
326
|
+
try {
|
|
327
|
+
onUpdate?.(okResult(`Task ${handle.taskId} submitted; polling…`));
|
|
328
|
+
saveSingleJob(outputDir, { ...manifest, state: 'polling' });
|
|
329
|
+
const succeeded = await pollTask({
|
|
330
|
+
check: () => adapter.inspect(provider, handle, fetch, signal),
|
|
331
|
+
signal,
|
|
332
|
+
onTick: (attempt, phase) => {
|
|
333
|
+
if (attempt % 15 === 0) {
|
|
334
|
+
onUpdate?.(okResult(`Still ${phase}… (${attempt * 2}s elapsed)`));
|
|
335
|
+
}
|
|
336
|
+
},
|
|
337
|
+
});
|
|
338
|
+
const videoPath = `${jobDir}/video.mp4`;
|
|
339
|
+
saveSingleJob(outputDir, { ...manifest, state: 'downloading' });
|
|
340
|
+
onUpdate?.(okResult('Task finished; downloading clip…'));
|
|
341
|
+
const meta = await adapter.downloadTo(provider, handle, succeeded.videoUrl, videoPath, fetch, signal);
|
|
342
|
+
saveSingleJob(outputDir, { ...manifest, state: 'done', videoPath: meta.path });
|
|
343
|
+
pi.appendEntry('video-gen:last-job', {
|
|
344
|
+
jobId: manifest.jobId,
|
|
345
|
+
kind: 'single',
|
|
346
|
+
videoPath: meta.path,
|
|
347
|
+
});
|
|
348
|
+
return okResult(`Video clip ready: ${meta.path} (${(meta.bytes / 1_000_000).toFixed(1)} MB). Job id: ${manifest.jobId}.`, { jobId: manifest.jobId, videoPath: meta.path, bytes: meta.bytes, taskId: handle.taskId });
|
|
349
|
+
}
|
|
350
|
+
catch (error) {
|
|
351
|
+
if (error instanceof CancelledError || signal?.aborted) {
|
|
352
|
+
if (adapter.cancel) {
|
|
353
|
+
try {
|
|
354
|
+
const cancelled = await adapter.cancel(provider, handle, fetch, AbortSignal.timeout(10_000));
|
|
355
|
+
if (cancelled.cancelled) {
|
|
356
|
+
const next = {
|
|
357
|
+
...manifest,
|
|
358
|
+
state: 'failed',
|
|
359
|
+
error: 'cancelled remotely',
|
|
360
|
+
};
|
|
361
|
+
delete next.handle;
|
|
362
|
+
saveSingleJob(outputDir, next);
|
|
363
|
+
return errResult(`Remote task ${handle.taskId} was cancelled.`, {
|
|
364
|
+
jobId: manifest.jobId,
|
|
365
|
+
taskId: handle.taskId,
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
catch {
|
|
370
|
+
console.error('[pi-video-gen] remote cancel failed');
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
saveSingleJob(outputDir, { ...manifest, state: 'polling_stopped' });
|
|
374
|
+
return errResult(`Stopped locally. NOTE: the remote task ${handle.taskId} may still be running and billable — local stop does not cancel it (Ark task-cancellation support is unverified). Resume polling with video_generate jobId "${manifest.jobId}" once it should have finished.`, { jobId: manifest.jobId, taskId: handle.taskId });
|
|
375
|
+
}
|
|
376
|
+
return handleGenerateError(error, outputDir, manifest.jobId);
|
|
377
|
+
}
|
|
378
|
+
};
|
|
379
|
+
const handleGenerateError = (error, outputDir, jobId) => {
|
|
380
|
+
console.error(`[pi-video-gen] generate failed: ${toLogSummary(error)}`);
|
|
381
|
+
const existing = loadSingleJob(outputDir, jobId);
|
|
382
|
+
if (existing && existing.state !== 'done') {
|
|
383
|
+
const terminal = error instanceof RemoteTaskFailedError;
|
|
384
|
+
const ambiguous = error instanceof AmbiguousSubmitError || error instanceof RemoteTaskNotFoundError;
|
|
385
|
+
const next = {
|
|
386
|
+
...existing,
|
|
387
|
+
state: ambiguous
|
|
388
|
+
? 'ambiguous'
|
|
389
|
+
: existing.handle && !terminal
|
|
390
|
+
? 'polling_stopped'
|
|
391
|
+
: 'failed',
|
|
392
|
+
error: error instanceof RemoteTaskFailedError ? error.providerMessage : toLogSummary(error),
|
|
393
|
+
};
|
|
394
|
+
if (terminal)
|
|
395
|
+
delete next.handle;
|
|
396
|
+
saveSingleJob(outputDir, next);
|
|
397
|
+
}
|
|
398
|
+
return errResult(error instanceof RemoteTaskNotFoundError
|
|
399
|
+
? `Job ${jobId} is no longer visible to the provider, but the paid task may still exist. Its handle was preserved and this job will not resubmit automatically; check the provider console before starting another generation.`
|
|
400
|
+
: errorMessageForUser(error), { jobId });
|
|
401
|
+
};
|
|
402
|
+
/** Shared C0 compose flow for the tool's execute() and `/video-gen compose`. */
|
|
403
|
+
const runComposeTool = async (p, ctx, signal, onUpdate) => {
|
|
404
|
+
try {
|
|
405
|
+
const name = p.composeSpecPath.split(/[\\/]/).pop() ?? '';
|
|
406
|
+
if (name === 'timeline-input.json') {
|
|
407
|
+
// Timeline compose (C1–C4): mixed media + overlays + TTS, local render.
|
|
408
|
+
const result = await runTimeline({
|
|
409
|
+
timelineSpecPath: p.composeSpecPath,
|
|
410
|
+
cwd: ctx.cwd,
|
|
411
|
+
settings,
|
|
412
|
+
activeJobs,
|
|
413
|
+
signal,
|
|
414
|
+
onUpdate: (msg) => onUpdate?.(okResult(msg)),
|
|
415
|
+
});
|
|
416
|
+
pi.appendEntry('video-gen:last-job', {
|
|
417
|
+
jobId: result.jobId,
|
|
418
|
+
kind: 'timeline',
|
|
419
|
+
finalVideoPath: result.finalVideoPath,
|
|
420
|
+
});
|
|
421
|
+
return okResult(`Promo video ready: ${result.finalVideoPath} (${result.segments} segments, ${result.durationSec.toFixed(1)}s). QC frames: ${result.qcFrames.join(', ')}. Job: ${result.jobId}.`, result);
|
|
422
|
+
}
|
|
423
|
+
// C0: lossless concat of existing clips.
|
|
424
|
+
const result = await runCompose({
|
|
425
|
+
composeSpecPath: p.composeSpecPath,
|
|
426
|
+
cwd: ctx.cwd,
|
|
427
|
+
settings,
|
|
428
|
+
activeJobs,
|
|
429
|
+
signal,
|
|
430
|
+
onUpdate: (msg) => onUpdate?.(okResult(msg)),
|
|
431
|
+
});
|
|
432
|
+
pi.appendEntry('video-gen:last-job', {
|
|
433
|
+
jobId: result.jobId,
|
|
434
|
+
kind: 'compose',
|
|
435
|
+
finalVideoPath: result.finalVideoPath,
|
|
436
|
+
});
|
|
437
|
+
return okResult(`Final video ready: ${result.finalVideoPath} (${result.clipCount} clips, lossless concat${result.resumed ? ', resumed' : ''}). Job: ${result.jobId}.`, result);
|
|
438
|
+
}
|
|
439
|
+
catch (error) {
|
|
440
|
+
console.error(`[pi-video-gen] video_compose failed: ${toLogSummary(error)}`);
|
|
441
|
+
return errResult(errorMessageForUser(error));
|
|
442
|
+
}
|
|
443
|
+
};
|
|
444
|
+
/** Shared multi-shot render flow for the tool's execute() and `/video-gen render`. */
|
|
445
|
+
const runRenderTool = async (p, ctx, signal, onUpdate) => {
|
|
446
|
+
try {
|
|
447
|
+
const resolved = resolveModel(settings);
|
|
448
|
+
if (!resolved) {
|
|
449
|
+
return errResult(`Cannot resolve model "${settings.defaultModel ?? 'seedance'}". Run /video-gen models, or fix pi-video-gen.defaultModel.`);
|
|
450
|
+
}
|
|
451
|
+
const adapter = ADAPTERS[resolved.provider.style];
|
|
452
|
+
if (!adapter) {
|
|
453
|
+
return errResult(`Provider adapter "${resolved.provider.style}" is unavailable in this build. Run /video-gen models or reinstall pi-video-gen.`);
|
|
454
|
+
}
|
|
455
|
+
if (!resolved.provider.apiKey) {
|
|
456
|
+
return errResult(missingKeyError(resolved.provider.style, ENV_VAR_BY_STYLE[resolved.provider.style], resolved.provider.apiKeyPath).message);
|
|
457
|
+
}
|
|
458
|
+
const ffmpeg = resolveFfmpeg(settings.ffmpegPath);
|
|
459
|
+
if (!ffmpeg.runnable) {
|
|
460
|
+
return errResult(`ffmpeg is not runnable (tried source: ${ffmpeg.source}). Set pi-video-gen.ffmpegPath in GLOBAL settings or FFMPEG_PATH, then /video-gen doctor.`);
|
|
461
|
+
}
|
|
462
|
+
const result = await runRender({
|
|
463
|
+
renderSpecPath: p.renderSpecPath,
|
|
464
|
+
allowDegradations: p.allowDegradations,
|
|
465
|
+
settings,
|
|
466
|
+
cwd: ctx.cwd,
|
|
467
|
+
resolved,
|
|
468
|
+
adapter,
|
|
469
|
+
activeJobs,
|
|
470
|
+
rateLimiter,
|
|
471
|
+
ffmpegPath: ffmpeg.path,
|
|
472
|
+
signal,
|
|
473
|
+
onUpdate: (msg) => onUpdate?.(okResult(msg)),
|
|
474
|
+
});
|
|
475
|
+
const degradedNote = result.degraded.length > 0 ? ` Degradations applied: ${result.degraded.join('; ')}.` : '';
|
|
476
|
+
pi.appendEntry('video-gen:last-job', {
|
|
477
|
+
jobId: result.jobId,
|
|
478
|
+
kind: 'render',
|
|
479
|
+
finalVideoPath: result.finalVideoPath,
|
|
480
|
+
});
|
|
481
|
+
return okResult(`Final video ready: ${result.finalVideoPath} (${result.shotsDone} shots). Job: ${result.jobId}.${degradedNote}`, result);
|
|
482
|
+
}
|
|
483
|
+
catch (error) {
|
|
484
|
+
console.error(`[pi-video-gen] video_render failed: ${toLogSummary(error)}`);
|
|
485
|
+
return errResult(errorMessageForUser(error));
|
|
486
|
+
}
|
|
487
|
+
};
|
|
488
|
+
const capabilitiesText = () => {
|
|
489
|
+
const info = listModelRegistry(settings);
|
|
490
|
+
const resolved = resolveModel(settings);
|
|
491
|
+
const lines = [
|
|
492
|
+
`Active model: ${info.activeId}${info.activeResolved ? '' : ' (NOT resolvable — check pi-video-gen.defaultModel)'}`,
|
|
493
|
+
];
|
|
494
|
+
if (resolved) {
|
|
495
|
+
const c = resolved.entry.capabilities;
|
|
496
|
+
lines.push(`Provider: ${resolved.provider.style} (key ${resolved.provider.apiKey ? 'ready' : 'MISSING'})`, `Durations: ${c.durations[0]}-${c.durations[1]}s; Resolutions: ${c.resolutions.join('/')}; Aspect ratios: ${c.aspectRatios.join(', ')}`, `Native audio: ${c.nativeAudio ? 'yes' : 'no'}; First+last frame: ${c.supportsFirstLastFrame ? 'yes' : 'no'}; Max reference images: ${c.maxReferenceImages}`);
|
|
497
|
+
}
|
|
498
|
+
lines.push('Registered models:');
|
|
499
|
+
for (const m of info.models) {
|
|
500
|
+
lines.push(`- ${m.id} [${m.provider}] aliases: ${m.aliases.join(', ')} (key ${m.keyReady ? 'ready' : 'missing'})`);
|
|
501
|
+
}
|
|
502
|
+
return lines.join('\n');
|
|
503
|
+
};
|
|
504
|
+
const registerTools = () => {
|
|
505
|
+
const caps = resolveModel(settings)?.entry.capabilities ?? null;
|
|
506
|
+
pi.registerTool({
|
|
507
|
+
name: 'video_generate',
|
|
508
|
+
label: 'Generate Video Clip',
|
|
509
|
+
description: 'Generate a single short video clip (one shot) from a text prompt, optionally anchored by first/last frame images. Paid, slow (minutes per clip). For multi-shot videos, use the video-gen skill workflow instead of calling this repeatedly.',
|
|
510
|
+
parameters: buildGenerateParams(caps),
|
|
511
|
+
promptSnippet: 'Generate one short video clip (paid, minutes) via the active video model',
|
|
512
|
+
promptGuidelines: [
|
|
513
|
+
"Before composing prompts for a video task, call video_capabilities to learn the active model's duration range, aspect ratios, audio support, and first/last-frame support — do not assume.",
|
|
514
|
+
'Video generation is paid and slow. State the expected clip count and duration to the user and get explicit confirmation before the first call.',
|
|
515
|
+
'Only pass lastFrame when the user needs first+last-frame interpolation and the active model supports it.',
|
|
516
|
+
'If a call is interrupted, resume with the returned jobId instead of submitting a new task (avoids double billing).',
|
|
517
|
+
'If submit is reported as ambiguous, do not start another generation until the provider console confirms that no paid task exists.',
|
|
518
|
+
],
|
|
519
|
+
async execute(_toolCallId, params, signal, onUpdate, ctx) {
|
|
520
|
+
try {
|
|
521
|
+
return await runGenerate(params, ctx, signal, onUpdate);
|
|
522
|
+
}
|
|
523
|
+
catch (error) {
|
|
524
|
+
console.error(`[pi-video-gen] video_generate failed: ${toLogSummary(error)}`);
|
|
525
|
+
return errResult(errorMessageForUser(error));
|
|
526
|
+
}
|
|
527
|
+
},
|
|
528
|
+
});
|
|
529
|
+
pi.registerTool({
|
|
530
|
+
name: 'video_compose',
|
|
531
|
+
label: 'Compose Video Clips',
|
|
532
|
+
description: 'Local video assembly, no paid models. Two specs: <jobDir>/compose-input.json (C0: lossless concat of 2+ existing mp4 clips, ffprobe stream precheck) or <jobDir>/timeline-input.json (C1+: promo from mixed images/video clips + overlays + TTS + transitions + soft or burned subtitles, rendered locally).',
|
|
533
|
+
parameters: Type.Object({
|
|
534
|
+
composeSpecPath: Type.String({
|
|
535
|
+
description: 'Path to <jobDir>/compose-input.json (C0) or <jobDir>/timeline-input.json (promo timeline). The parent directory must live under the video-gen output dir and acts as the job. Rerunning the same path resumes identical input; revisions require a NEW job directory.',
|
|
536
|
+
}),
|
|
537
|
+
}),
|
|
538
|
+
promptSnippet: 'Assemble video locally — lossless clip concat (C0) or a mixed image/video promo timeline with TTS and soft/burned subtitles, no paid models',
|
|
539
|
+
promptGuidelines: [
|
|
540
|
+
'Two modes: <jobDir>/compose-input.json (C0: concat 2+ compatible mp4 clips) or <jobDir>/timeline-input.json (promo: mixed images/video + overlays + TTS + xfade + soft/burned subtitles, all local).',
|
|
541
|
+
'For C0: every ordered stream across all clips must match (codec/resolution/fps/timebase/pix_fmt/sample-rate/audio layout) — mismatches are reported via ffprobe, never silently transcoded. Do NOT use for AI video generation (use video_generate/video_render).',
|
|
542
|
+
'For timeline: each segment contains exactly one image or video. Video uses numeric durationSec with optional trimStartSec, fit, and sourceAudio; image may use auto duration and motion.',
|
|
543
|
+
'Reuse existing images/screenshots/clips first and generate only missing visuals with image_generate. Put Chinese titles in overlay and use subtitles.mode "burn" when narration subtitles must appear directly in the frames.',
|
|
544
|
+
'Timeline TTS failures stop by default. Set ttsFailureMode "silent-subtitles" in timeline-input.json only after the user explicitly accepts silent audio with preserved subtitles.',
|
|
545
|
+
'Local compute — no paid-model confirmation needed, but tell the user the segment/clip count and planned duration before calling.',
|
|
546
|
+
'The spec is immutable per job directory: rerunning the same path resumes identical input; changing input means a NEW job directory.',
|
|
547
|
+
],
|
|
548
|
+
async execute(_toolCallId, params, signal, onUpdate, ctx) {
|
|
549
|
+
return runComposeTool(params, ctx, signal, onUpdate);
|
|
550
|
+
},
|
|
551
|
+
});
|
|
552
|
+
pi.registerTool({
|
|
553
|
+
name: 'video_render',
|
|
554
|
+
label: 'Render Multi-Shot Video',
|
|
555
|
+
description: 'Render a multi-shot video from <jobDir>/render-input.json: submits one paid video task per shot (resuming persisted handles on rerun — finished shots never re-bill), downloads clips, and concatenates them into final_video.mp4. Foreground, reports progress; cancelling stops locally (remote tasks may keep billing). Frames must already exist (generate them with image_generate first).',
|
|
556
|
+
parameters: Type.Object({
|
|
557
|
+
renderSpecPath: Type.String({
|
|
558
|
+
description: 'Path to <jobDir>/render-input.json. The parent directory must live under the video-gen output dir and acts as the (immutable) job. Rerunning the same path resumes; revisions require a NEW job directory.',
|
|
559
|
+
}),
|
|
560
|
+
allowDegradations: Type.Optional(Type.Array(StringEnum(['first-frame-only']), {
|
|
561
|
+
description: 'Explicit user-approved downgrades for capability mismatches.',
|
|
562
|
+
})),
|
|
563
|
+
}),
|
|
564
|
+
promptSnippet: 'Render a prepared multi-shot video spec (paid, long-running) into a final mp4',
|
|
565
|
+
promptGuidelines: [
|
|
566
|
+
'Call ONLY after the user explicitly confirmed rendering: frames ready, shot count and cost magnitude stated.',
|
|
567
|
+
'Generate all frames first via image_generate (pi-image-gen) and record their returned absolute paths in the render spec.',
|
|
568
|
+
'The spec is immutable per job directory: rerunning the same path resumes identical input; revisions go in a NEW job directory.',
|
|
569
|
+
'Interrupting stops locally only — remote tasks may keep billing; rerun the same spec path to resume after they finish.',
|
|
570
|
+
],
|
|
571
|
+
async execute(_toolCallId, params, signal, onUpdate, ctx) {
|
|
572
|
+
return runRenderTool(params, ctx, signal, onUpdate);
|
|
573
|
+
},
|
|
574
|
+
});
|
|
575
|
+
pi.registerTool({
|
|
576
|
+
name: 'video_capabilities',
|
|
577
|
+
label: 'Video Model Capabilities',
|
|
578
|
+
description: "Read-only: list the active video model's capabilities (duration range, resolutions, aspect ratios, native audio, first/last-frame support) and the registered models. Call before composing video prompts or shot books.",
|
|
579
|
+
parameters: Type.Object({}),
|
|
580
|
+
promptSnippet: 'Show active video model capabilities and registered models',
|
|
581
|
+
async execute() {
|
|
582
|
+
return okResult(capabilitiesText());
|
|
583
|
+
},
|
|
584
|
+
});
|
|
585
|
+
};
|
|
586
|
+
pi.on('session_start', async (_event, ctx) => {
|
|
587
|
+
reloadSettings(ctx);
|
|
588
|
+
registerTools();
|
|
589
|
+
});
|
|
590
|
+
pi.registerCommand('video-gen', {
|
|
591
|
+
description: 'pi-video-gen: /video-gen [generate <prompt>|render <spec>|compose <spec>|recover <jobId>|models|reload|doctor]',
|
|
592
|
+
handler: async (args, ctx) => {
|
|
593
|
+
const tokens = (args ?? '').trim().split(/\s+/).filter(Boolean);
|
|
594
|
+
const sub = tokens[0];
|
|
595
|
+
if (sub === 'generate') {
|
|
596
|
+
const prompt = tokens.slice(1).join(' ').trim();
|
|
597
|
+
if (!prompt) {
|
|
598
|
+
ctx.ui.notify('Usage: /video-gen generate <prompt>', 'error');
|
|
599
|
+
return;
|
|
600
|
+
}
|
|
601
|
+
const result = await runGenerate({ prompt }, ctx, ctx.signal);
|
|
602
|
+
ctx.ui.notify(result.content[0].text, result.isError ? 'error' : 'info');
|
|
603
|
+
return;
|
|
604
|
+
}
|
|
605
|
+
if (sub === 'reload') {
|
|
606
|
+
reloadSettings(ctx);
|
|
607
|
+
registerTools();
|
|
608
|
+
ctx.ui.notify('pi-video-gen settings reloaded.', 'info');
|
|
609
|
+
return;
|
|
610
|
+
}
|
|
611
|
+
if (sub === 'compose') {
|
|
612
|
+
const specPath = (args ?? '').trim().slice('compose'.length).trim();
|
|
613
|
+
if (!specPath) {
|
|
614
|
+
ctx.ui.notify('Usage: /video-gen compose <jobDir/compose-input.json|timeline-input.json>', 'error');
|
|
615
|
+
return;
|
|
616
|
+
}
|
|
617
|
+
const result = await runComposeTool({ composeSpecPath: specPath }, ctx, ctx.signal);
|
|
618
|
+
ctx.ui.notify(result.content[0].text, result.isError ? 'error' : 'info');
|
|
619
|
+
return;
|
|
620
|
+
}
|
|
621
|
+
if (sub === 'render') {
|
|
622
|
+
const specPath = tokens[1];
|
|
623
|
+
if (!specPath) {
|
|
624
|
+
ctx.ui.notify('Usage: /video-gen render <jobDir/render-input.json>', 'error');
|
|
625
|
+
return;
|
|
626
|
+
}
|
|
627
|
+
const result = await runRenderTool({ renderSpecPath: specPath }, ctx, ctx.signal);
|
|
628
|
+
ctx.ui.notify(result.content[0].text, result.isError ? 'error' : 'info');
|
|
629
|
+
return;
|
|
630
|
+
}
|
|
631
|
+
if (sub === 'recover') {
|
|
632
|
+
// Manual resolution of ambiguous shots. Paths:
|
|
633
|
+
// reset → nothing was created, re-render the shot from scratch
|
|
634
|
+
// adopt → the task exists remotely, track it and resume polling
|
|
635
|
+
const jobId = tokens[1];
|
|
636
|
+
if (!jobId) {
|
|
637
|
+
ctx.ui.notify('Usage: /video-gen recover <jobId> [shotId reset|adopt <taskId>]\nWithout arguments, lists ambiguous shots of the job.', 'error');
|
|
638
|
+
return;
|
|
639
|
+
}
|
|
640
|
+
try {
|
|
641
|
+
assertSafeId(jobId, 'job');
|
|
642
|
+
}
|
|
643
|
+
catch {
|
|
644
|
+
ctx.ui.notify(`Invalid job id "${jobId}".`, 'error');
|
|
645
|
+
return;
|
|
646
|
+
}
|
|
647
|
+
const outputDir = resolveOutputDir(settings, ctx.cwd);
|
|
648
|
+
const requestedJobDir = join(outputDir, jobId);
|
|
649
|
+
let manifest;
|
|
650
|
+
try {
|
|
651
|
+
manifest = loadRenderJob(requestedJobDir);
|
|
652
|
+
}
|
|
653
|
+
catch (error) {
|
|
654
|
+
ctx.ui.notify(errorMessageForUser(error), 'error');
|
|
655
|
+
return;
|
|
656
|
+
}
|
|
657
|
+
if (!manifest) {
|
|
658
|
+
ctx.ui.notify(`No render job found with id "${jobId}" under the video-gen output directory.`, 'error');
|
|
659
|
+
return;
|
|
660
|
+
}
|
|
661
|
+
let jobDir;
|
|
662
|
+
try {
|
|
663
|
+
jobDir = resolveJobDirInsideOutput(outputDir, requestedJobDir);
|
|
664
|
+
manifest = loadRenderJob(jobDir);
|
|
665
|
+
}
|
|
666
|
+
catch (error) {
|
|
667
|
+
ctx.ui.notify(errorMessageForUser(error), 'error');
|
|
668
|
+
return;
|
|
669
|
+
}
|
|
670
|
+
if (!manifest) {
|
|
671
|
+
ctx.ui.notify(`No render job found with id "${jobId}".`, 'error');
|
|
672
|
+
return;
|
|
673
|
+
}
|
|
674
|
+
// tokens: recover <jobId> [shotId] [reset|adopt <taskId>]
|
|
675
|
+
const shotId = tokens[2];
|
|
676
|
+
if (!shotId) {
|
|
677
|
+
const blocked = Object.entries(manifest.shots).filter(([, st]) => st.state === 'ambiguous');
|
|
678
|
+
if (blocked.length === 0) {
|
|
679
|
+
ctx.ui.notify(`Job ${jobId} has no ambiguous shots.`, 'info');
|
|
680
|
+
return;
|
|
681
|
+
}
|
|
682
|
+
const lines = [
|
|
683
|
+
`Job ${jobId}: ${blocked.length} ambiguous shot(s) — a paid task MAY exist per shot.`,
|
|
684
|
+
];
|
|
685
|
+
for (const [shotId] of blocked) {
|
|
686
|
+
lines.push(` ${shotId}: check the provider console, then either`, ` /video-gen recover ${jobId} ${shotId} reset (nothing was created → re-render)`, ` /video-gen recover ${jobId} ${shotId} adopt <taskId> (task exists → resume polling)`);
|
|
687
|
+
}
|
|
688
|
+
ctx.ui.notify(lines.join('\n'), 'info');
|
|
689
|
+
return;
|
|
690
|
+
}
|
|
691
|
+
// Form: /video-gen recover <jobId> <shotId> reset|adopt <taskId>
|
|
692
|
+
const action = tokens[3];
|
|
693
|
+
if (!action) {
|
|
694
|
+
ctx.ui.notify(`Usage: /video-gen recover ${jobId} <shotId> reset|adopt <taskId>`, 'error');
|
|
695
|
+
return;
|
|
696
|
+
}
|
|
697
|
+
try {
|
|
698
|
+
assertSafeId(shotId, 'shot');
|
|
699
|
+
}
|
|
700
|
+
catch {
|
|
701
|
+
ctx.ui.notify(`Invalid shot id "${shotId}".`, 'error');
|
|
702
|
+
return;
|
|
703
|
+
}
|
|
704
|
+
let release;
|
|
705
|
+
try {
|
|
706
|
+
release = activeJobs.acquire(jobDir);
|
|
707
|
+
// Re-read under the lock: the job may have advanced since the list/read above.
|
|
708
|
+
manifest = loadRenderJob(jobDir);
|
|
709
|
+
if (!manifest) {
|
|
710
|
+
ctx.ui.notify(`No render job found with id "${jobId}".`, 'error');
|
|
711
|
+
return;
|
|
712
|
+
}
|
|
713
|
+
const shot = manifest.shots[shotId];
|
|
714
|
+
if (!shot) {
|
|
715
|
+
ctx.ui.notify(`No shot "${shotId}" in job ${jobId}.`, 'error');
|
|
716
|
+
return;
|
|
717
|
+
}
|
|
718
|
+
if (shot.state !== 'ambiguous') {
|
|
719
|
+
ctx.ui.notify(`Shot "${shotId}" is not ambiguous (state: ${shot.state}). Nothing to resolve.`, 'info');
|
|
720
|
+
return;
|
|
721
|
+
}
|
|
722
|
+
if (action === 'reset') {
|
|
723
|
+
manifest.shots[shotId] = { state: 'pending', attempt: shot.attempt };
|
|
724
|
+
saveRenderJob(jobDir, manifest);
|
|
725
|
+
ctx.ui.notify(`Shot "${shotId}" reset to pending. Rerun video_render with the same render-input.json to re-render it.`, 'info');
|
|
726
|
+
return;
|
|
727
|
+
}
|
|
728
|
+
if (action === 'adopt') {
|
|
729
|
+
const taskId = tokens[4];
|
|
730
|
+
if (!taskId) {
|
|
731
|
+
ctx.ui.notify(`Usage: /video-gen recover ${jobId} ${shotId} adopt <taskId>`, 'error');
|
|
732
|
+
return;
|
|
733
|
+
}
|
|
734
|
+
manifest.shots[shotId] = {
|
|
735
|
+
state: 'submitted',
|
|
736
|
+
attempt: shot.attempt,
|
|
737
|
+
handle: {
|
|
738
|
+
taskId,
|
|
739
|
+
submittedAt: new Date().toISOString(),
|
|
740
|
+
requestFingerprint: 'manual-adopt',
|
|
741
|
+
},
|
|
742
|
+
requestFingerprint: 'manual-adopt',
|
|
743
|
+
};
|
|
744
|
+
saveRenderJob(jobDir, manifest);
|
|
745
|
+
ctx.ui.notify(`Shot "${shotId}" now tracks remote task ${taskId}. Rerun video_render with the same render-input.json to resume polling and download.`, 'info');
|
|
746
|
+
return;
|
|
747
|
+
}
|
|
748
|
+
ctx.ui.notify(`Unknown recover action "${action}" — use reset or adopt.`, 'error');
|
|
749
|
+
}
|
|
750
|
+
catch (error) {
|
|
751
|
+
ctx.ui.notify(errorMessageForUser(error), 'error');
|
|
752
|
+
}
|
|
753
|
+
finally {
|
|
754
|
+
release?.();
|
|
755
|
+
}
|
|
756
|
+
return;
|
|
757
|
+
}
|
|
758
|
+
if (sub === 'models') {
|
|
759
|
+
ctx.ui.notify(boundToolText(capabilitiesText()), 'info');
|
|
760
|
+
return;
|
|
761
|
+
}
|
|
762
|
+
if (sub === 'doctor') {
|
|
763
|
+
const result = runDoctor(ctx);
|
|
764
|
+
ctx.ui.notify(result.content[0].text, 'info');
|
|
765
|
+
return;
|
|
766
|
+
}
|
|
767
|
+
ctx.ui.notify('pi-video-gen commands:\n /video-gen generate <prompt> Generate a single clip\n /video-gen render <spec> Render a multi-shot video\n /video-gen compose <spec> Concat clips or render an image/TTS timeline\n /video-gen recover <jobId> Resolve ambiguous shots (reset/adopt)\n /video-gen models List registered models\n /video-gen reload Reload settings\n /video-gen doctor Check environment (ffmpeg, CJK fonts, keys, image_generate, output dir)', 'info');
|
|
768
|
+
},
|
|
769
|
+
});
|
|
770
|
+
function runDoctor(ctx) {
|
|
771
|
+
const checks = [];
|
|
772
|
+
// 1. video provider key + model resolution
|
|
773
|
+
const resolved = resolveModel(settings);
|
|
774
|
+
if (!resolved) {
|
|
775
|
+
checks.push(`❌ defaultModel "${settings.defaultModel ?? 'seedance'}" not in registry — fix pi-video-gen.defaultModel`);
|
|
776
|
+
}
|
|
777
|
+
else {
|
|
778
|
+
const label = providerLabel(resolved.provider.style);
|
|
779
|
+
checks.push(`✅ model: ${resolved.entry.id} [${resolved.provider.style}]`);
|
|
780
|
+
checks.push(resolved.provider.apiKey
|
|
781
|
+
? `✅ ${label} API key configured`
|
|
782
|
+
: `❌ ${label} API key missing — set apiKey for the active provider in global or agent-dir Pi settings`);
|
|
783
|
+
}
|
|
784
|
+
// 2. ffmpeg (multi-shot concat; resolution chain: settings → env → bundled → PATH)
|
|
785
|
+
const ffmpeg = resolveFfmpeg(settings.ffmpegPath);
|
|
786
|
+
checks.push(ffmpeg.runnable
|
|
787
|
+
? `✅ ffmpeg found (source: ${ffmpeg.source})`
|
|
788
|
+
: `❌ ffmpeg not runnable (source tried: ${ffmpeg.source}) — reinstall pi-video-gen to restore its platform package, or set pi-video-gen.ffmpegPath in global/agent-dir settings`);
|
|
789
|
+
const ffprobe = resolveFfprobe(settings.ffmpegPath);
|
|
790
|
+
checks.push(ffprobe.runnable
|
|
791
|
+
? `✅ ffprobe found (source: ${ffprobe.source})`
|
|
792
|
+
: `⚠️ ffprobe not runnable (source tried: ${ffprobe.source}) — needed for video_compose stream prechecks`);
|
|
793
|
+
const h264Ffmpeg = resolveGplFfmpeg(settings.ffmpegPath);
|
|
794
|
+
checks.push(h264Ffmpeg.runnable
|
|
795
|
+
? `✅ libx264 encoder available (source: ${h264Ffmpeg.source})`
|
|
796
|
+
: '⚠️ libx264 encoder unavailable — timeline output.codec "h264" will not work; use "mpeg4" or configure a compatible ffmpeg');
|
|
797
|
+
// 3. local CJK font (required by Sharp/SVG timeline overlays)
|
|
798
|
+
checks.push(hasCjkFont()
|
|
799
|
+
? '✅ CJK font available for local text overlays'
|
|
800
|
+
: '❌ CJK font missing — install PingFang, Microsoft YaHei, Noto Sans CJK, or WenQuanYi before rendering Chinese overlays');
|
|
801
|
+
// 4. image_generate presence (image stages live in pi-image-gen)
|
|
802
|
+
try {
|
|
803
|
+
const all = pi.getAllTools?.() ?? [];
|
|
804
|
+
const active = pi.getActiveTools?.() ?? [];
|
|
805
|
+
const registered = all.some((t) => t.name === 'image_generate');
|
|
806
|
+
const enabled = active.includes('image_generate');
|
|
807
|
+
checks.push(registered && enabled
|
|
808
|
+
? `✅ image_generate registered and active (pi-image-gen)`
|
|
809
|
+
: `⚠️ image_generate ${registered ? 'registered but inactive' : 'not found'} — multi-shot workflows need pi-image-gen installed; check its config with /image-gen list`);
|
|
810
|
+
}
|
|
811
|
+
catch {
|
|
812
|
+
checks.push('⚠️ could not query registered tools on this runtime');
|
|
813
|
+
}
|
|
814
|
+
// 5. output dir writable
|
|
815
|
+
const outputDir = resolveOutputDir(settings, ctx.cwd);
|
|
816
|
+
try {
|
|
817
|
+
mkdirSync(outputDir, { recursive: true });
|
|
818
|
+
const probeDir = mkdtempSync(join(outputDir, '.doctor-probe-'));
|
|
819
|
+
try {
|
|
820
|
+
writeFileSync(join(probeDir, 'probe'), 'ok', { encoding: 'utf-8', flag: 'wx' });
|
|
821
|
+
}
|
|
822
|
+
finally {
|
|
823
|
+
rmSync(probeDir, { recursive: true, force: true });
|
|
824
|
+
}
|
|
825
|
+
checks.push(`✅ output dir writable: ${outputDir}`);
|
|
826
|
+
}
|
|
827
|
+
catch {
|
|
828
|
+
checks.push(`❌ output dir not writable: ${outputDir}`);
|
|
829
|
+
}
|
|
830
|
+
// 6. trust status
|
|
831
|
+
checks.push(isProjectTrusted(ctx)
|
|
832
|
+
? '✅ project trusted (project-level pi-video-gen settings: whitelisted keys only)'
|
|
833
|
+
: 'ℹ️ project not trusted — project-level .pi/settings.json is ignored');
|
|
834
|
+
return okResult(checks.join('\n'));
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
//# sourceMappingURL=index.js.map
|