Skip to content

Commit 3c64461

Browse files
committed
feat: video finetune/deploy/invoke full pipeline + training cost calculation
- Add finetune video create subcommand (wan2.7/2.5/2.2 i2v + kf2v) - Align video hyperparams with official docs (n_epochs=50, per-model batch_size/max_pixels) - Add --last-frame flag to video generate for kf2v (image2video endpoint) - Fix wan2.1-2.6 i2v input format (img_url instead of media[]) - Add training_cost field to finetune get/watch (catalog ft price, API-key domain only) - Add --aigc-* flags to deploy create (optional, for video LoRA prompt config)
1 parent f30fff9 commit 3c64461

15 files changed

Lines changed: 358 additions & 32 deletions

File tree

packages/cli/src/commands.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ import {
6262
finetuneTextCreate,
6363
finetuneAudioCreate,
6464
finetuneImageCreate,
65+
finetuneVideoCreate,
6566
finetuneList,
6667
finetuneGet,
6768
finetuneCancel,
@@ -183,6 +184,7 @@ export const commands: Record<string, AnyCommand> = {
183184
"finetune text create": finetuneTextCreate,
184185
"finetune audio create": finetuneAudioCreate,
185186
"finetune image create": finetuneImageCreate,
187+
"finetune video create": finetuneVideoCreate,
186188
"finetune list": finetuneList,
187189
"finetune get": finetuneGet,
188190
"finetune cancel": finetuneCancel,

packages/commands/src/commands/finetune/create.ts

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -207,7 +207,7 @@ async function uploadResolvedLocal(
207207
}
208208

209209
/** The modality a `finetune <modality> create` subcommand is bound to. */
210-
type CommandModality = "text" | "audio" | "image";
210+
type CommandModality = "text" | "audio" | "image" | "video";
211211

212212
/**
213213
* Flags shared by every `finetune <modality> create` subcommand: what to train
@@ -324,6 +324,34 @@ const AUDIO_USAGE =
324324
const IMAGE_USAGE =
325325
"--base-model <model> --datasets <id|path> [--validations <id|path>] [--model-name <name>] [--suffix <text>] [--generation-type <t2i|i2i>] [--learning-rate <str>]";
326326

327+
/**
328+
* Video (Wan i2v/kf2v) flags: exposes the three hyper-parameters that the
329+
* video API supports and users may want to override. Defaults are model-specific
330+
* (resolved by the sft-lora profile: wan2.7 → batch_size 1 / max_pixels 102400,
331+
* wan2.5 → 4 / 36864, wan2.2 → 4 / 262144).
332+
*/
333+
const VIDEO_FLAGS = {
334+
...COMMON_FLAGS,
335+
nEpochs: {
336+
type: "number",
337+
valueHint: "<n>",
338+
description: "Training epochs (default: 50)",
339+
},
340+
batchSize: {
341+
type: "number",
342+
valueHint: "<n>",
343+
description: "Batch size (default: model-specific, 1 for wan2.7, 4 for wan2.5/2.2)",
344+
},
345+
learningRate: {
346+
type: "string",
347+
valueHint: "<str>",
348+
description: 'Learning rate as a string to preserve precision (default: "2e-5")',
349+
},
350+
} satisfies FlagsDef;
351+
352+
const VIDEO_USAGE =
353+
"--base-model <model> --datasets <id|path> [--validations <id|path>] [--model-name <name>] [--suffix <text>] [--n-epochs <n>] [--batch-size <n>] [--learning-rate <str>]";
354+
327355
const COMMON_NOTES = [
328356
"Creating a job uploads any local datasets and consumes training quota.",
329357
"Use --dry-run to preview the request body without submitting.",
@@ -683,3 +711,28 @@ export const finetuneImageCreate = defineCommand({
683711
notes: IMAGE_NOTES,
684712
run: (ctx) => runCreate("image", ctx),
685713
});
714+
715+
const VIDEO_NOTES = [
716+
...COMMON_NOTES,
717+
"Video generation training (Wan i2v/kf2v) runs efficient_sft with model-",
718+
"specific defaults: wan2.7 (batch_size=1, max_pixels=102400), wan2.5/2.2",
719+
"(batch_size=4, max_pixels per model). Override with --batch-size/--n-epochs.",
720+
"Datasets are .zip archives with data.jsonl + frame images + videos.",
721+
"Recommended: ≥10 training samples, 20-100 for stable results.",
722+
];
723+
724+
/** `bl finetune video create` — fine-tune a video generation model. Datasets are `.zip`. */
725+
export const finetuneVideoCreate = defineCommand({
726+
description: "Create a video generation model fine-tune job (Wan i2v/kf2v, efficient_sft)",
727+
auth: "apiKey",
728+
usageArgs: VIDEO_USAGE,
729+
flags: VIDEO_FLAGS,
730+
exampleArgs: [
731+
"--base-model wan2.7-i2v --datasets file-xxx",
732+
"--base-model wan2.7-i2v --datasets ./i2v-data.zip",
733+
"--base-model wan2.2-kf2v-flash --datasets file-xxx --n-epochs 100",
734+
"--base-model wan2.7-i2v --datasets file-xxx --dry-run",
735+
],
736+
notes: VIDEO_NOTES,
737+
run: (ctx) => runCreate("video", ctx),
738+
});

packages/commands/src/commands/finetune/fee.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -77,9 +77,14 @@ async function fetchTrainingPrice(
7777
}
7878

7979
/**
80-
* Compute the actual training fee from the model catalog.
80+
* Compute the actual training fee from the model catalog's "ft" price entry.
8181
* Returns null when the price is unavailable (network error, model not in
82-
* catalog, or no "ft" entry in the prices array). Never throws.
82+
* catalog, or no "ft" entry). Never throws.
83+
*
84+
* Only uses the public model catalog (model metadata) — does NOT call
85+
* console-domain pricing APIs (modelCenter.getModelPrice). Models whose
86+
* catalog entry lacks a "ft" price (e.g. CosyVoice) will simply omit the
87+
* training_cost field until the platform adds it to the catalog.
8388
*/
8489
export async function computeActualFee(
8590
settings: Settings,
@@ -91,7 +96,7 @@ export async function computeActualFee(
9196
const unitPrice = Number(ftEntry?.price);
9297
if (!Number.isFinite(unitPrice) || unitPrice <= 0) return null;
9398
const priceUnit = ftEntry?.priceUnit ?? "每百万tokens";
94-
// price is yuan per million tokens.
99+
// Catalog price is yuan per million tokens.
95100
const cost = (usageTokens / 1_000_000) * unitPrice;
96101
return { cost: Number(cost.toFixed(4)), unitPrice, priceUnit };
97102
} catch {

packages/commands/src/commands/video/generate.ts

Lines changed: 47 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import {
22
defineCommand,
33
videoGeneratePath,
4+
image2videoPath,
45
taskPath,
56
detectOutputFormat,
67
type DashScopeVideoRequest,
@@ -43,6 +44,11 @@ export default defineCommand({
4344
valueHint: "<url>",
4445
description: "Input image URL for image-to-video generation",
4546
},
47+
lastFrame: {
48+
type: "string",
49+
valueHint: "<url>",
50+
description: "Last frame image URL (with --image, enables kf2v first+last frame mode)",
51+
},
4652
negativePrompt: {
4753
type: "string",
4854
valueHint: "<text>",
@@ -110,12 +116,20 @@ export default defineCommand({
110116
const format = detectOutputFormat(settings.output);
111117

112118
const imageUrl = flags.image;
119+
const lastFrameUrl = flags.lastFrame as string | undefined;
113120

114121
// Auto-upload local image file for i2v
115122
let resolvedImageUrl: string | undefined;
116123
if (imageUrl) {
117124
resolvedImageUrl = await ctx.client.resolveImageInput(imageUrl, model);
118125
}
126+
let resolvedLastFrameUrl: string | undefined;
127+
if (lastFrameUrl) {
128+
resolvedLastFrameUrl = await ctx.client.resolveImageInput(lastFrameUrl, model);
129+
}
130+
131+
// kf2v mode: both --image and --last-frame provided.
132+
const isKf2v = Boolean(resolvedImageUrl && resolvedLastFrameUrl);
119133

120134
const watermark = resolveWatermark(flags.watermark);
121135
const promptExtend = resolveBooleanFlag(flags.promptExtend, undefined, "prompt-extend");
@@ -125,10 +139,16 @@ export default defineCommand({
125139
input: {
126140
prompt: prompt,
127141
negative_prompt: flags.negativePrompt || undefined,
128-
// i2v models (happyhorse-1.1-i2v) require input.media with type 'first_frame'
129-
...(resolvedImageUrl
130-
? { media: [{ type: "first_frame" as const, url: resolvedImageUrl }] }
131-
: {}),
142+
// kf2v: first+last frame flat fields via image2video endpoint.
143+
// wan2.1~2.6 i2v: flat img_url via video-generation endpoint.
144+
// wan2.7+ / happyhorse i2v: media[] via video-generation endpoint.
145+
...(isKf2v
146+
? { first_frame_url: resolvedImageUrl, last_frame_url: resolvedLastFrameUrl }
147+
: resolvedImageUrl
148+
? /wan[x]?2\.[1-6]/i.test(model)
149+
? { img_url: resolvedImageUrl }
150+
: { media: [{ type: "first_frame" as const, url: resolvedImageUrl }] }
151+
: {}),
132152
},
133153
parameters: {
134154
resolution: flags.resolution || undefined,
@@ -141,15 +161,28 @@ export default defineCommand({
141161
};
142162

143163
if (settings.dryRun) {
144-
const previewBody = resolvedImageUrl
145-
? {
146-
...body,
147-
input: {
148-
...body.input,
149-
media: [{ type: "first_frame" as const, url: redactDataUri(resolvedImageUrl) }],
150-
},
151-
}
152-
: body;
164+
let previewBody = body;
165+
if (isKf2v) {
166+
previewBody = {
167+
...body,
168+
input: {
169+
...body.input,
170+
first_frame_url: redactDataUri(resolvedImageUrl ?? ""),
171+
last_frame_url: redactDataUri(resolvedLastFrameUrl ?? ""),
172+
},
173+
};
174+
} else if (resolvedImageUrl) {
175+
const redactedUrl = redactDataUri(resolvedImageUrl);
176+
previewBody = {
177+
...body,
178+
input: {
179+
...body.input,
180+
...(/wan[x]?2\.[1-6]/i.test(model)
181+
? { img_url: redactedUrl }
182+
: { media: [{ type: "first_frame" as const, url: redactedUrl }] }),
183+
},
184+
};
185+
}
153186
emitResult({ request: previewBody }, format);
154187
return;
155188
}
@@ -162,7 +195,7 @@ export default defineCommand({
162195
settings,
163196
() =>
164197
ctx.client.requestJson<DashScopeAsyncResponse>({
165-
path: videoGeneratePath(),
198+
path: isKf2v ? image2videoPath() : videoGeneratePath(),
166199
method: "POST",
167200
body,
168201
async: true,

packages/commands/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ export {
6666
finetuneTextCreate,
6767
finetuneAudioCreate,
6868
finetuneImageCreate,
69+
finetuneVideoCreate,
6970
} from "./commands/finetune/create.ts";
7071
export { default as finetuneList } from "./commands/finetune/list.ts";
7172
export { default as finetuneGet } from "./commands/finetune/get.ts";

packages/commands/tests/e2e/finetune.e2e.test.ts

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -365,6 +365,104 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: finetune (offline)", () => {
365365
expect(data.action).toBe("finetune.create");
366366
expect(data.body.training_type).toBe("efficient_sft");
367367
});
368+
369+
test("finetune video create --help 暴露视频超参且不含文本超参", async () => {
370+
// Video exposes --n-epochs / --batch-size / --learning-rate; the text-only
371+
// --training-type / --max-length surface is not offered.
372+
const { stderr, exitCode } = await runCommandE2e(FINETUNE_ROUTES, [
373+
"finetune",
374+
"video",
375+
"create",
376+
"--help",
377+
]);
378+
expect(exitCode, stderr).toBe(0);
379+
expect(stderr).toMatch(/--base-model/);
380+
expect(stderr).toMatch(/--n-epochs/);
381+
expect(stderr).toMatch(/--batch-size/);
382+
expect(stderr).toMatch(/--learning-rate/);
383+
expect(stderr).not.toMatch(/--training-type|--max-length/);
384+
});
385+
386+
test("finetune video create --datasets 缺失时退出为用法错误 (2)", async () => {
387+
const { stderr, exitCode } = await runCommandE2e(FINETUNE_ROUTES, [
388+
"finetune",
389+
"video",
390+
"create",
391+
"--base-model",
392+
"wan2.7-i2v",
393+
"--quiet",
394+
]);
395+
expect(exitCode).toBe(2);
396+
expect(stderr).toMatch(/--datasets|Missing required/i);
397+
});
398+
399+
test.each([
400+
// Model-family-specific defaults resolved by the sft-lora video profile.
401+
["wan2.7-i2v", 1, 102400],
402+
["wan2.5-i2v-preview", 4, 36864],
403+
["wan2.2-kf2v-flash", 4, 262144],
404+
])(
405+
"finetune video create --dry-run %s 解析 batch_size=%i / max_pixels=%i",
406+
async (baseModel, batchSize, maxPixels) => {
407+
const { stdout, stderr, exitCode } = await runCommandE2e(FINETUNE_ROUTES, [
408+
"finetune",
409+
"video",
410+
"create",
411+
"--base-model",
412+
baseModel,
413+
"--datasets",
414+
"file-video",
415+
"--dry-run",
416+
"--output",
417+
"json",
418+
]);
419+
expect(exitCode, stderr).toBe(0);
420+
const data = parseStdoutJson<{
421+
action: string;
422+
body: {
423+
model: string;
424+
training_type: string;
425+
hyper_parameters: Record<string, unknown>;
426+
};
427+
}>(stdout);
428+
expect(data.action).toBe("finetune.create");
429+
expect(data.body.model).toBe(baseModel);
430+
expect(data.body.training_type).toBe("efficient_sft");
431+
expect(data.body.hyper_parameters.batch_size).toBe(batchSize);
432+
expect(data.body.hyper_parameters.max_pixels).toBe(maxPixels);
433+
expect(data.body.hyper_parameters.learning_rate).toBe("2e-5");
434+
expect(data.body.hyper_parameters.lora_rank).toBe(32);
435+
},
436+
);
437+
438+
test("finetune video create --dry-run 转发超参覆盖且不做 clamp", async () => {
439+
const { stdout, stderr, exitCode } = await runCommandE2e(FINETUNE_ROUTES, [
440+
"finetune",
441+
"video",
442+
"create",
443+
"--base-model",
444+
"wan2.7-i2v",
445+
"--datasets",
446+
"file-video",
447+
"--n-epochs",
448+
"100",
449+
"--batch-size",
450+
"2",
451+
"--learning-rate",
452+
"1e-5",
453+
"--dry-run",
454+
"--output",
455+
"json",
456+
]);
457+
expect(exitCode, stderr).toBe(0);
458+
const data = parseStdoutJson<{
459+
body: { hyper_parameters: Record<string, unknown> };
460+
}>(stdout);
461+
// Video overrides are forwarded verbatim (no [8, 1024] text clamp).
462+
expect(data.body.hyper_parameters.n_epochs).toBe(100);
463+
expect(data.body.hyper_parameters.batch_size).toBe(2);
464+
expect(data.body.hyper_parameters.learning_rate).toBe("1e-5");
465+
});
368466
});
369467

370468
describe.skipIf(!isDashScopeE2EReady())("e2e: finetune (DashScope)", () => {

packages/commands/tests/e2e/topic-routes.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,7 @@ export const FINETUNE_ROUTES: E2eRouteExports = {
135135
"finetune text create": "finetuneTextCreate",
136136
"finetune audio create": "finetuneAudioCreate",
137137
"finetune image create": "finetuneImageCreate",
138+
"finetune video create": "finetuneVideoCreate",
138139
"finetune list": "finetuneList",
139140
"finetune get": "finetuneGet",
140141
"finetune cancel": "finetuneCancel",

0 commit comments

Comments
 (0)