Skip to content

Commit fd2077b

Browse files
committed
feat: pipeline watermark 处理完善
1 parent 3477a08 commit fd2077b

6 files changed

Lines changed: 241 additions & 10 deletions

File tree

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

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,4 +240,75 @@ describe("e2e: pipeline", () => {
240240
expect(stdout).toBe("");
241241
expect(stderr).toMatch(/--events must be one of: jsonl/i);
242242
});
243+
244+
test("pipeline image/generate dry-run 继承 Profile watermark=false", async () => {
245+
const configDir = await mkdtemp(join(tmpdir(), "bl-pipeline-wm-"));
246+
const workflowPath = join(configDir, "image-generate.json");
247+
try {
248+
await writeFile(
249+
join(configDir, "config.json"),
250+
JSON.stringify({ api_key: "sk-test-placeholder", watermark: false }, null, 2) + "\n",
251+
);
252+
await writeFile(
253+
workflowPath,
254+
JSON.stringify({
255+
version: "workflow/v1",
256+
steps: [{ id: "gen", type: "image/generate", input: { prompt: "A cat" } }],
257+
}),
258+
);
259+
const { stdout, stderr, exitCode } = await runCommandE2e(
260+
PIPELINE_ROUTES,
261+
["pipeline", "run", "--file", workflowPath, "--dry-run", "--output", "json"],
262+
{ BAILIAN_CONFIG_DIR: configDir, DASHSCOPE_API_KEY: "", DASHSCOPE_BASE_URL: "" },
263+
);
264+
expect(exitCode, stderr).toBe(0);
265+
const report = parseStdoutJson<{
266+
status?: string;
267+
steps?: Array<{ type?: string; input?: { watermark?: boolean; prompt?: string } }>;
268+
}>(stdout);
269+
expect(report.status).toBe("planned");
270+
expect(report.steps?.[0]).toMatchObject({
271+
type: "image/generate",
272+
input: { prompt: "A cat", watermark: false },
273+
});
274+
} finally {
275+
await rm(configDir, { recursive: true, force: true });
276+
}
277+
});
278+
279+
test("pipeline 步骤显式 watermark=true 覆盖 Profile false", async () => {
280+
const configDir = await mkdtemp(join(tmpdir(), "bl-pipeline-wm-ov-"));
281+
const workflowPath = join(configDir, "image-generate.json");
282+
try {
283+
await writeFile(
284+
join(configDir, "config.json"),
285+
JSON.stringify({ api_key: "sk-test-placeholder", watermark: false }, null, 2) + "\n",
286+
);
287+
await writeFile(
288+
workflowPath,
289+
JSON.stringify({
290+
version: "workflow/v1",
291+
steps: [
292+
{
293+
id: "gen",
294+
type: "image/generate",
295+
input: { prompt: "A cat", watermark: true },
296+
},
297+
],
298+
}),
299+
);
300+
const { stdout, stderr, exitCode } = await runCommandE2e(
301+
PIPELINE_ROUTES,
302+
["pipeline", "run", "--file", workflowPath, "--dry-run", "--output", "json"],
303+
{ BAILIAN_CONFIG_DIR: configDir, DASHSCOPE_API_KEY: "", DASHSCOPE_BASE_URL: "" },
304+
);
305+
expect(exitCode, stderr).toBe(0);
306+
const report = parseStdoutJson<{
307+
steps?: Array<{ input?: { watermark?: boolean } }>;
308+
}>(stdout);
309+
expect(report.steps?.[0]?.input?.watermark).toBe(true);
310+
} finally {
311+
await rm(configDir, { recursive: true, force: true });
312+
}
313+
});
243314
});

packages/runtime/src/pipeline/bl-config.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,22 @@ export interface PipelineEnv {
1616
settings: Settings;
1717
}
1818

19+
/** Media steps that inherit Profile `watermark` when the YAML omits the field. */
20+
export const PIPELINE_WATERMARK_STEPS = new Set(["image/generate", "image/edit", "video/generate"]);
21+
22+
/**
23+
* Fill Profile watermark into a planned/executed step input.
24+
* Explicit step `watermark` wins; otherwise use Settings (file → default true).
25+
*/
26+
export function applyProfileWatermarkToStepInput(
27+
stepType: string,
28+
input: Record<string, unknown>,
29+
settings: Settings,
30+
): Record<string, unknown> {
31+
if (!PIPELINE_WATERMARK_STEPS.has(stepType) || input.watermark !== undefined) return input;
32+
return { ...input, watermark: settings.watermark };
33+
}
34+
1935
/**
2036
* Build the in-process env for pipeline steps. Uses the same source resolution
2137
* as the CLI itself (env vars, config file; no CLI flags), but forces JSON

packages/runtime/src/pipeline/executor.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { PipelineError, toPipelineError } from "./errors.ts";
2-
import { buildPipelineEnv } from "./bl-config.ts";
2+
import { applyProfileWatermarkToStepInput, buildPipelineEnv } from "./bl-config.ts";
33
import { getDefaultStepDispatcher, type StepDispatcher } from "./dispatcher.ts";
44
import {
55
evaluateCondition,
@@ -156,12 +156,17 @@ async function executePipelineInternal(
156156
if (options.dryRun) {
157157
for (const planStep of topologicalOrder(plan)) {
158158
const resolved = resolvePlannedStepInput(planStep.step, pipeline, normalizedRuntimeInput);
159+
const plannedInput = applyProfileWatermarkToStepInput(
160+
planStep.step.type,
161+
resolved.redacted,
162+
blEnv.settings,
163+
);
159164
const report: PipelineStepReport = {
160165
id: planStep.step.id,
161166
type: planStep.step.type,
162167
status: "planned",
163168
dependencies: planStep.dependencies,
164-
input: resolved.redacted,
169+
input: plannedInput,
165170
...(planStep.step.when !== undefined ? { condition: "pending" } : {}),
166171
};
167172
reports.push(report);
@@ -170,14 +175,14 @@ async function executePipelineInternal(
170175
timestamp: now(),
171176
status: "planned",
172177
step: stepEvent(planStep),
173-
input: inputSummary(resolved.redacted, resolved.sensitiveKeys),
178+
input: inputSummary(plannedInput, resolved.sensitiveKeys),
174179
});
175180
await emit({
176181
type: "step.planned",
177182
timestamp: now(),
178183
status: "planned",
179184
step: stepEvent(planStep),
180-
input: inputSummary(resolved.redacted, resolved.sensitiveKeys),
185+
input: inputSummary(plannedInput, resolved.sensitiveKeys),
181186
...(planStep.step.when !== undefined ? { condition: "pending" as const } : {}),
182187
});
183188
}

packages/runtime/src/pipeline/steps/bl-api.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -192,7 +192,8 @@ export async function imageGenerate(
192192
n,
193193
seed: input.seed,
194194
prompt_extend: promptExtend,
195-
watermark: resolveWatermark(input.watermark),
195+
// Step input overrides Profile; omit → use Profile / CLI default (true).
196+
watermark: resolveWatermark(input.watermark, env.settings.watermark),
196197
};
197198

198199
const body: DashScopeImageRequest =
@@ -299,7 +300,8 @@ export async function imageEdit(
299300
n,
300301
seed: input.seed,
301302
prompt_extend: promptExtend,
302-
watermark: resolveWatermark(input.watermark),
303+
// Step input overrides Profile; omit → use Profile / CLI default (true).
304+
watermark: resolveWatermark(input.watermark, env.settings.watermark),
303305
};
304306

305307
let body: DashScopeImageRequest;
@@ -460,7 +462,8 @@ export async function videoGenerate(
460462
ratio: input.ratio || undefined,
461463
duration: input.duration,
462464
prompt_extend: resolveBooleanFlag(input["prompt-extend"], undefined, "prompt-extend"),
463-
watermark: resolveWatermark(input.watermark),
465+
// Step input overrides Profile; omit → use Profile / CLI default (true).
466+
watermark: resolveWatermark(input.watermark, env.settings.watermark),
464467
seed: input.seed,
465468
},
466469
};

packages/runtime/src/pipeline/steps/bl-steps.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import { registerStep } from "../dispatcher.ts";
2-
import { buildPipelineEnv, type PipelineEnv } from "../bl-config.ts";
2+
import {
3+
applyProfileWatermarkToStepInput,
4+
buildPipelineEnv,
5+
type PipelineEnv,
6+
} from "../bl-config.ts";
37
import { isRecord } from "../utils.ts";
48
import {
59
textChat,
@@ -153,9 +157,13 @@ async function executeDirectBlStep(
153157
input: Record<string, unknown>,
154158
ctx: StepContext,
155159
): Promise<StepResult> {
160+
const env = (ctx.blEnv as PipelineEnv | undefined) ?? buildPipelineEnv();
161+
156162
if (ctx.dryRun) {
163+
// Surface the Profile-effective watermark in dry-run so plans match runtime.
164+
const plannedInput = applyProfileWatermarkToStepInput(id, input, env.settings);
157165
return {
158-
metadata: { dryRun: true, step: id, plannedInput: input },
166+
metadata: { dryRun: true, step: id, plannedInput },
159167
warnings: [
160168
{ code: "dry_run_skipped", message: `Step ${id} was not executed in dry-run mode` },
161169
],
@@ -167,7 +175,6 @@ async function executeDirectBlStep(
167175
throw new Error(`No direct API handler registered for step: ${id}`);
168176
}
169177

170-
const env = (ctx.blEnv as PipelineEnv | undefined) ?? buildPipelineEnv();
171178
const data = await handler(env, input, ctx);
172179
const builder = RESULT_BUILDERS[id];
173180
if (builder) {
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import { expect, test } from "vite-plus/test";
2+
import type { Client } from "bailian-cli-core";
3+
import { applyProfileWatermarkToStepInput, type PipelineEnv } from "../src/pipeline/bl-config.ts";
4+
import { imageEdit, imageGenerate, videoGenerate } from "../src/pipeline/steps/bl-api.ts";
5+
import type { StepContext } from "../src/pipeline/types.ts";
6+
7+
type CapturedRequest = {
8+
path?: string;
9+
method?: string;
10+
body?: {
11+
parameters?: { watermark?: boolean };
12+
};
13+
async?: boolean;
14+
};
15+
16+
function makeEnv(watermark: boolean): {
17+
env: PipelineEnv;
18+
captured: CapturedRequest[];
19+
} {
20+
const captured: CapturedRequest[] = [];
21+
const client = {
22+
uploadFile: async (source: string) => source,
23+
requestJson: async (opts: CapturedRequest) => {
24+
captured.push(opts);
25+
if (opts.async) {
26+
return { output: { task_id: "task-wm", task_status: "PENDING" } };
27+
}
28+
// Sync image response shape (qwen-image / wan2.7-image).
29+
return {
30+
request_id: "req-wm",
31+
output: {
32+
choices: [{ message: { content: [{ image: "https://example.com/out.png" }] } }],
33+
},
34+
};
35+
},
36+
} as unknown as Client;
37+
38+
return {
39+
env: {
40+
client,
41+
settings: {
42+
quiet: true,
43+
output: "json",
44+
outputExplicit: true,
45+
timeout: 300,
46+
watermark,
47+
verbose: false,
48+
dryRun: false,
49+
telemetry: false,
50+
} as PipelineEnv["settings"],
51+
},
52+
captured,
53+
};
54+
}
55+
56+
function makeCtx(): StepContext {
57+
return { dryRun: false, signal: new AbortController().signal, timeoutSeconds: 1 };
58+
}
59+
60+
test("pipeline imageGenerate inherits Profile watermark=false when step omits it", async () => {
61+
const { env, captured } = makeEnv(false);
62+
await imageGenerate(env, { prompt: "A cat" }, makeCtx());
63+
expect(captured[0]?.body?.parameters?.watermark).toBe(false);
64+
});
65+
66+
test("pipeline imageGenerate keeps step watermark over Profile", async () => {
67+
const { env, captured } = makeEnv(false);
68+
await imageGenerate(env, { prompt: "A cat", watermark: true }, makeCtx());
69+
expect(captured[0]?.body?.parameters?.watermark).toBe(true);
70+
});
71+
72+
test("pipeline imageEdit inherits Profile watermark=false when step omits it", async () => {
73+
const { env, captured } = makeEnv(false);
74+
await imageEdit(env, { prompt: "Blue sky", image: "https://example.com/in.png" }, makeCtx());
75+
expect(captured[0]?.body?.parameters?.watermark).toBe(false);
76+
});
77+
78+
test("pipeline videoGenerate inherits Profile watermark=false when step omits it", async () => {
79+
const { env, captured } = makeEnv(false);
80+
// First call submits async task; pollTaskWithOptions will keep polling — mock SUCCEEDED quickly.
81+
const client = env.client as unknown as {
82+
requestJson: (opts: CapturedRequest) => Promise<unknown>;
83+
};
84+
let calls = 0;
85+
client.requestJson = async (opts: CapturedRequest) => {
86+
captured.push(opts);
87+
calls += 1;
88+
if (calls === 1) {
89+
return { output: { task_id: "task-wm", task_status: "PENDING" } };
90+
}
91+
return {
92+
output: {
93+
task_id: "task-wm",
94+
task_status: "SUCCEEDED",
95+
video_url: "https://example.com/out.mp4",
96+
},
97+
};
98+
};
99+
100+
await videoGenerate(
101+
env,
102+
{ prompt: "A cat walks", "poll-interval": 0 },
103+
{ ...makeCtx(), timeoutSeconds: 5 },
104+
);
105+
expect(captured[0]?.body?.parameters?.watermark).toBe(false);
106+
});
107+
108+
test("pipeline defaults watermark to true when Profile leaves the compliance default", async () => {
109+
const { env, captured } = makeEnv(true);
110+
await imageGenerate(env, { prompt: "A cat" }, makeCtx());
111+
expect(captured[0]?.body?.parameters?.watermark).toBe(true);
112+
});
113+
114+
test("applyProfileWatermarkToStepInput fills omitted watermark from Settings", () => {
115+
const settings = { watermark: false } as PipelineEnv["settings"];
116+
expect(
117+
applyProfileWatermarkToStepInput("image/generate", { prompt: "A cat" }, settings).watermark,
118+
).toBe(false);
119+
expect(
120+
applyProfileWatermarkToStepInput(
121+
"image/generate",
122+
{ prompt: "A cat", watermark: true },
123+
settings,
124+
).watermark,
125+
).toBe(true);
126+
expect(
127+
applyProfileWatermarkToStepInput("text/chat", { message: "hi" }, settings).watermark,
128+
).toBeUndefined();
129+
});

0 commit comments

Comments
 (0)