diff --git a/packages/commands/src/commands/config/set.ts b/packages/commands/src/commands/config/set.ts index 7a4a0dc83..f81a7be78 100644 --- a/packages/commands/src/commands/config/set.ts +++ b/packages/commands/src/commands/config/set.ts @@ -12,9 +12,9 @@ export default defineCommand({ valueHint: "", description: { "en-US": - "Config key (language, base_url, output, output_dir, timeout, api_key, api_key_capabilities, access_token, access_key_id, access_key_secret, security_token, default_*_model, workspace_id)", + "Config key (language, base_url, output, output_dir, timeout, watermark, api_key, api_key_capabilities, access_token, access_key_id, access_key_secret, security_token, default_*_model, workspace_id)", "zh-CN": - "配置项名称(language、base_url、output、output_dir、timeout、api_key、api_key_capabilities、access_token、access_key_id、access_key_secret、security_token、default_*_model、workspace_id)", + "配置项名称(language、base_url、output、output_dir、timeout、watermark、api_key、api_key_capabilities、access_token、access_key_id、access_key_secret、security_token、default_*_model、workspace_id)", }, required: true, }, @@ -29,6 +29,7 @@ export default defineCommand({ "--key language --value zh-CN", "--key output --value json", "--key timeout --value 600", + "--key watermark --value false", "--key base_url --value https://dashscope.aliyuncs.com", "--config company-plan --key api-key-capabilities --value text.chat,image.generate", ], diff --git a/packages/commands/src/commands/config/shared.ts b/packages/commands/src/commands/config/shared.ts index 66865d4e5..e903d84c0 100644 --- a/packages/commands/src/commands/config/shared.ts +++ b/packages/commands/src/commands/config/shared.ts @@ -3,6 +3,7 @@ import { ExitCode, isApiKeyCapability, normalizeModelBaseUrl, + parseBooleanValue, SUPPORTED_LANGUAGES, } from "bailian-cli-core"; @@ -13,6 +14,7 @@ export const VALID_KEYS = [ "output", "output_dir", "timeout", + "watermark", "api_key", "access_token", "access_key_id", @@ -62,7 +64,7 @@ export const UI_ENUM_KEYS: Record = { }; // Keys the UI renders as a true/false dropdown and stores as a boolean. -export const UI_BOOLEAN_KEYS = new Set(["telemetry"]); +export const UI_BOOLEAN_KEYS = new Set(["telemetry", "watermark"]); // Default model each `default_*_model` key falls back to when left unset. These // mirror the inline `|| ""` fallbacks in the generation commands @@ -164,7 +166,10 @@ export function resolveKey(key: string): string { * Validate a single config entry and coerce its value to the stored type. * Throws BailianError(USAGE) for unknown keys or invalid values. */ -export function validateAndCoerce(key: string, value: string): string | number | string[] { +export function validateAndCoerce( + key: string, + value: string, +): string | number | boolean | string[] { const resolvedKey = resolveKey(key); if (!(VALID_KEYS as readonly string[]).includes(resolvedKey)) { @@ -201,6 +206,8 @@ export function validateAndCoerce(key: string, value: string): string | number | if (resolvedKey === "base_url") return normalizeModelBaseUrl(value); + if (resolvedKey === "watermark") return parseBooleanValue(value, "watermark"); + if (resolvedKey === "api_key_capabilities") { let rawCapabilities: unknown; if (value.trim().startsWith("[")) { diff --git a/packages/commands/src/commands/config/show.ts b/packages/commands/src/commands/config/show.ts index 49ca83d21..c8d41f7ee 100644 --- a/packages/commands/src/commands/config/show.ts +++ b/packages/commands/src/commands/config/show.ts @@ -18,6 +18,7 @@ export default defineCommand({ base_url: client.baseUrl, output: settings.output, timeout: settings.timeout, + watermark: settings.watermark, config: settings.configName ?? "default", config_file: store.path, }; diff --git a/packages/commands/src/commands/image/edit.ts b/packages/commands/src/commands/image/edit.ts index 466b256d6..e6af2c0ae 100644 --- a/packages/commands/src/commands/image/edit.ts +++ b/packages/commands/src/commands/image/edit.ts @@ -207,7 +207,7 @@ export default defineCommand({ "prompt-extend", ); - const watermark = resolveWatermark(flags.watermark); + const watermark = resolveWatermark(flags.watermark, settings.watermark); const parameters: NonNullable = { size: resolveImageSize(flags.size, route.sizeProfile), diff --git a/packages/commands/src/commands/image/generate.ts b/packages/commands/src/commands/image/generate.ts index 44c5d4802..6c09a12b0 100644 --- a/packages/commands/src/commands/image/generate.ts +++ b/packages/commands/src/commands/image/generate.ts @@ -185,7 +185,7 @@ export default defineCommand({ "prompt-extend", ); - const watermark = resolveWatermark(flags.watermark); + const watermark = resolveWatermark(flags.watermark, settings.watermark); const parameters: NonNullable = { size, diff --git a/packages/commands/src/commands/video/edit.ts b/packages/commands/src/commands/video/edit.ts index 3db1b5a78..28ab29c11 100644 --- a/packages/commands/src/commands/video/edit.ts +++ b/packages/commands/src/commands/video/edit.ts @@ -197,7 +197,7 @@ export default defineCommand({ // --- Build request body --- const promptExtend = resolveBooleanFlag(flags.promptExtend, undefined, "prompt-extend"); - const watermark = resolveWatermark(flags.watermark); + const watermark = resolveWatermark(flags.watermark, settings.watermark); const body: DashScopeVideoEditRequest = { model, diff --git a/packages/commands/src/commands/video/generate.ts b/packages/commands/src/commands/video/generate.ts index a2aab9aff..f3121dbb4 100644 --- a/packages/commands/src/commands/video/generate.ts +++ b/packages/commands/src/commands/video/generate.ts @@ -208,7 +208,7 @@ export default defineCommand({ resolvedFileUrl = await ctx.client.uploadFile(fileUrl, model); } - const watermark = resolveWatermark(flags.watermark); + const watermark = resolveWatermark(flags.watermark, settings.watermark); const promptExtend = resolveBooleanFlag(flags.promptExtend, undefined, "prompt-extend"); const body: DashScopeVideoRequest = { diff --git a/packages/commands/src/commands/video/ref.ts b/packages/commands/src/commands/video/ref.ts index 65359072a..67819fefe 100644 --- a/packages/commands/src/commands/video/ref.ts +++ b/packages/commands/src/commands/video/ref.ts @@ -249,7 +249,7 @@ export default defineCommand({ // --- Build request body --- const promptExtend = resolveBooleanFlag(flags.promptExtend, undefined, "prompt-extend"); - const watermark = resolveWatermark(flags.watermark); + const watermark = resolveWatermark(flags.watermark, settings.watermark); const body: DashScopeVideoRefRequest = { model, diff --git a/packages/commands/tests/config-shared.test.ts b/packages/commands/tests/config-shared.test.ts index 2c30e41e1..925942da1 100644 --- a/packages/commands/tests/config-shared.test.ts +++ b/packages/commands/tests/config-shared.test.ts @@ -33,3 +33,9 @@ test("default-speech-recognition-model alias accepts an ASR model ID", () => { "qwen-audio-3.0-asr-flash", ); }); + +test("watermark config accepts only boolean text and stores a boolean", () => { + expect(validateAndCoerce("watermark", "false")).toBe(false); + expect(validateAndCoerce("watermark", "TRUE")).toBe(true); + expect(() => validateAndCoerce("watermark", "yes")).toThrow(/true or false/i); +}); diff --git a/packages/commands/tests/config-ui.test.ts b/packages/commands/tests/config-ui.test.ts index dfccaa727..3a5d6ae21 100644 --- a/packages/commands/tests/config-ui.test.ts +++ b/packages/commands/tests/config-ui.test.ts @@ -200,8 +200,10 @@ test("GET /api/config 返回全部 profile、明文密钥与持久化激活项", expect(res.json.keys).toContain("console_site"); expect(res.json.keys).toContain("telemetry"); expect(res.json.keys).toContain("default_speech_recognition_model"); + expect(res.json.keys).toContain("watermark"); expect(res.json.enums.console_site).toEqual(["domestic", "international"]); expect(res.json.booleanKeys).toContain("telemetry"); + expect(res.json.booleanKeys).toContain("watermark"); // Default field hints are surfaced as prefilled values in the UI. expect(res.json.fieldDefaults.default_image_model).toBe("qwen-image-3.0"); expect(res.json.fieldDefaults.default_text_model).toBe("qwen3.8-max"); diff --git a/packages/commands/tests/e2e/config.e2e.test.ts b/packages/commands/tests/e2e/config.e2e.test.ts index 173b6b84f..d38e15fb5 100644 --- a/packages/commands/tests/e2e/config.e2e.test.ts +++ b/packages/commands/tests/e2e/config.e2e.test.ts @@ -64,10 +64,53 @@ describe("e2e: config", () => { config_file?: string; base_url?: string; timeout?: number; + watermark?: boolean; }>(stdout); expect(data.config_file).toBeDefined(); expect(data.base_url).toBeDefined(); expect(data.timeout).toBeDefined(); + expect(data.watermark).toBe(true); + }); + + test("config set 将 watermark 作为 boolean 写入并由 config show 读回", async () => { + const configDir = mkdtempSync(join(tmpdir(), "bl-config-watermark-")); + try { + const env = { BAILIAN_CONFIG_DIR: configDir }; + const setResult = await runCommandE2e( + CONFIG_ROUTES, + [ + "config", + "set", + "--config", + "media", + "--key", + "watermark", + "--value", + "false", + "--output", + "json", + ], + env, + ); + expect(setResult.exitCode, setResult.stderr).toBe(0); + expect(parseStdoutJson<{ watermark?: boolean }>(setResult.stdout).watermark).toBe(false); + + const persisted = JSON.parse(readFileSync(join(configDir, "config.json"), "utf8")) as Record< + string, + Record + >; + expect(persisted.media?.watermark).toBe(false); + + const showResult = await runCommandE2e( + CONFIG_ROUTES, + ["config", "show", "--config", "media", "--output", "json"], + env, + ); + expect(showResult.exitCode, showResult.stderr).toBe(0); + expect(parseStdoutJson<{ watermark?: boolean }>(showResult.stdout).watermark).toBe(false); + } finally { + rmSync(configDir, { recursive: true, force: true }); + } }); test("config show --output text", async () => { diff --git a/packages/commands/tests/e2e/pipeline.e2e.test.ts b/packages/commands/tests/e2e/pipeline.e2e.test.ts index cdf0a02b8..722168973 100644 --- a/packages/commands/tests/e2e/pipeline.e2e.test.ts +++ b/packages/commands/tests/e2e/pipeline.e2e.test.ts @@ -240,4 +240,75 @@ describe("e2e: pipeline", () => { expect(stdout).toBe(""); expect(stderr).toMatch(/--events must be one of: jsonl/i); }); + + test("pipeline image/generate dry-run 继承 Profile watermark=false", async () => { + const configDir = await mkdtemp(join(tmpdir(), "bl-pipeline-wm-")); + const workflowPath = join(configDir, "image-generate.json"); + try { + await writeFile( + join(configDir, "config.json"), + JSON.stringify({ api_key: "sk-test-placeholder", watermark: false }, null, 2) + "\n", + ); + await writeFile( + workflowPath, + JSON.stringify({ + version: "workflow/v1", + steps: [{ id: "gen", type: "image/generate", input: { prompt: "A cat" } }], + }), + ); + const { stdout, stderr, exitCode } = await runCommandE2e( + PIPELINE_ROUTES, + ["pipeline", "run", "--file", workflowPath, "--dry-run", "--output", "json"], + { BAILIAN_CONFIG_DIR: configDir, DASHSCOPE_API_KEY: "", DASHSCOPE_BASE_URL: "" }, + ); + expect(exitCode, stderr).toBe(0); + const report = parseStdoutJson<{ + status?: string; + steps?: Array<{ type?: string; input?: { watermark?: boolean; prompt?: string } }>; + }>(stdout); + expect(report.status).toBe("planned"); + expect(report.steps?.[0]).toMatchObject({ + type: "image/generate", + input: { prompt: "A cat", watermark: false }, + }); + } finally { + await rm(configDir, { recursive: true, force: true }); + } + }); + + test("pipeline 步骤显式 watermark=true 覆盖 Profile false", async () => { + const configDir = await mkdtemp(join(tmpdir(), "bl-pipeline-wm-ov-")); + const workflowPath = join(configDir, "image-generate.json"); + try { + await writeFile( + join(configDir, "config.json"), + JSON.stringify({ api_key: "sk-test-placeholder", watermark: false }, null, 2) + "\n", + ); + await writeFile( + workflowPath, + JSON.stringify({ + version: "workflow/v1", + steps: [ + { + id: "gen", + type: "image/generate", + input: { prompt: "A cat", watermark: true }, + }, + ], + }), + ); + const { stdout, stderr, exitCode } = await runCommandE2e( + PIPELINE_ROUTES, + ["pipeline", "run", "--file", workflowPath, "--dry-run", "--output", "json"], + { BAILIAN_CONFIG_DIR: configDir, DASHSCOPE_API_KEY: "", DASHSCOPE_BASE_URL: "" }, + ); + expect(exitCode, stderr).toBe(0); + const report = parseStdoutJson<{ + steps?: Array<{ input?: { watermark?: boolean } }>; + }>(stdout); + expect(report.steps?.[0]?.input?.watermark).toBe(true); + } finally { + await rm(configDir, { recursive: true, force: true }); + } + }); }); diff --git a/packages/commands/tests/e2e/watermark-config.e2e.test.ts b/packages/commands/tests/e2e/watermark-config.e2e.test.ts new file mode 100644 index 000000000..91a663631 --- /dev/null +++ b/packages/commands/tests/e2e/watermark-config.e2e.test.ts @@ -0,0 +1,80 @@ +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, test } from "vite-plus/test"; +import { parseStdoutJson, runCommandE2e } from "./helpers.ts"; +import { IMAGE_ROUTES, VIDEO_ROUTES, type E2eRouteExports } from "./topic-routes.ts"; + +interface WatermarkScenario { + name: string; + routes: E2eRouteExports; + args: string[]; +} + +const scenarios: WatermarkScenario[] = [ + { + name: "image generate", + routes: IMAGE_ROUTES, + args: ["image", "generate", "--prompt", "A cat"], + }, + { + name: "image edit", + routes: IMAGE_ROUTES, + args: [ + "image", + "edit", + "--image", + "https://example.com/input.png", + "--prompt", + "Blue background", + ], + }, + { + name: "video generate", + routes: VIDEO_ROUTES, + args: ["video", "generate", "--prompt", "A cat waves"], + }, + { + name: "video edit", + routes: VIDEO_ROUTES, + args: ["video", "edit", "--video", "https://example.com/input.mp4", "--prompt", "Warm colors"], + }, + { + name: "video ref", + routes: VIDEO_ROUTES, + args: [ + "video", + "ref", + "--image", + "https://example.com/person.png", + "--prompt", + "Image 1 waves", + ], + }, +]; + +describe("e2e: global watermark config", () => { + for (const scenario of scenarios) { + test(`${scenario.name} uses watermark=false from the selected Profile`, async () => { + const configDir = mkdtempSync(join(tmpdir(), "bl-watermark-profile-")); + try { + writeFileSync( + join(configDir, "config.json"), + JSON.stringify({ media: { watermark: false } }, null, 2) + "\n", + ); + const { stdout, stderr, exitCode } = await runCommandE2e( + scenario.routes, + [...scenario.args, "--config", "media", "--dry-run", "--output", "json"], + { BAILIAN_CONFIG_DIR: configDir }, + ); + expect(exitCode, stderr).toBe(0); + const data = parseStdoutJson<{ + request?: { parameters?: { watermark?: boolean } }; + }>(stdout); + expect(data.request?.parameters?.watermark).toBe(false); + } finally { + rmSync(configDir, { recursive: true, force: true }); + } + }); + } +}); diff --git a/packages/core/src/config/loader.ts b/packages/core/src/config/loader.ts index 096f9913e..faec8390e 100644 --- a/packages/core/src/config/loader.ts +++ b/packages/core/src/config/loader.ts @@ -272,6 +272,7 @@ export function buildSettings(s: ResolutionSources): Settings { outputExplicit: Boolean(flags.output || env.DASHSCOPE_OUTPUT || file.output), outputDir: file.output_dir || undefined, timeout, + watermark: file.watermark ?? true, defaultTextModel: file.default_text_model, defaultVideoModel: file.default_video_model, defaultImageToVideoModel: file.default_image_to_video_model, diff --git a/packages/core/src/config/schema.ts b/packages/core/src/config/schema.ts index 0b945c6a0..a64e6183e 100644 --- a/packages/core/src/config/schema.ts +++ b/packages/core/src/config/schema.ts @@ -35,6 +35,7 @@ export interface ConfigFile { output?: "text" | "json"; output_dir?: string; timeout?: number; + watermark?: boolean; default_text_model?: string; default_video_model?: string; default_image_to_video_model?: string; @@ -63,6 +64,7 @@ export const CONFIG_FILE_KEYS = [ "output", "output_dir", "timeout", + "watermark", "default_text_model", "default_video_model", "default_image_to_video_model", @@ -156,6 +158,7 @@ export function parseConfigFile(raw: unknown): ConfigFile { if (typeof obj.output_dir === "string" && obj.output_dir.length > 0) out.output_dir = obj.output_dir; if (typeof obj.timeout === "number" && obj.timeout > 0) out.timeout = obj.timeout; + if (typeof obj.watermark === "boolean") out.watermark = obj.watermark; if (typeof obj.default_text_model === "string" && obj.default_text_model.length > 0) out.default_text_model = obj.default_text_model; if (typeof obj.default_video_model === "string" && obj.default_video_model.length > 0) @@ -224,6 +227,7 @@ export interface Settings { outputExplicit: boolean; outputDir?: string; timeout: number; + watermark: boolean; defaultTextModel?: string; defaultVideoModel?: string; defaultImageToVideoModel?: string; diff --git a/packages/core/src/utils/boolean-flag.ts b/packages/core/src/utils/boolean-flag.ts index 984af8cdb..c3e007362 100644 --- a/packages/core/src/utils/boolean-flag.ts +++ b/packages/core/src/utils/boolean-flag.ts @@ -34,7 +34,7 @@ export function resolveBooleanFlag( return defaultWhenUnset; } -/** Resolve `--watermark` flag; default true when unset. */ -export function resolveWatermark(flagValue: unknown): boolean { - return parseOptionalBooleanValue(flagValue, "watermark") ?? true; +/** Resolve `--watermark`; command flag overrides config, then defaults to true. */ +export function resolveWatermark(flagValue: unknown, configuredValue?: boolean): boolean { + return parseOptionalBooleanValue(flagValue, "watermark") ?? configuredValue ?? true; } diff --git a/packages/core/tests/config-priority.test.ts b/packages/core/tests/config-priority.test.ts index 9d5ce1b58..fcbbf14bf 100644 --- a/packages/core/tests/config-priority.test.ts +++ b/packages/core/tests/config-priority.test.ts @@ -79,6 +79,14 @@ test("default_speech_recognition_model 从配置文件进入运行时 Settings", expect(resolve({ file }).defaultSpeechRecognitionModel).toBe("qwen-audio-3.0-asr-flash"); }); +test("watermark 从配置文件进入 Settings,缺省时保持合规默认值 true", () => { + expect(parseConfigFile({ watermark: false }).watermark).toBe(false); + expect(parseConfigFile({ watermark: true }).watermark).toBe(true); + expect(parseConfigFile({ watermark: "false" }).watermark).toBeUndefined(); + expect(resolve({ file: { watermark: false } }).watermark).toBe(false); + expect(resolve({}).watermark).toBe(true); +}); + test("baseUrl:flag > env > file > 默认,所有来源统一归一化", () => { const flags = { baseUrl: "https://flag.example.com/compatible-mode/v1?source=flag" }; const env = { DASHSCOPE_BASE_URL: "https://env.example.com/apps/anthropic#env" }; diff --git a/packages/core/tests/image-input.test.ts b/packages/core/tests/image-input.test.ts index 85e87b315..48214312c 100644 --- a/packages/core/tests/image-input.test.ts +++ b/packages/core/tests/image-input.test.ts @@ -28,6 +28,7 @@ function makeSettings(configName?: string): Settings { output: "json", outputExplicit: false, timeout: 30, + watermark: true, verbose: false, quiet: true, dryRun: false, diff --git a/packages/core/tests/index.test.ts b/packages/core/tests/index.test.ts index 44eb765fe..cc0a90a68 100644 --- a/packages/core/tests/index.test.ts +++ b/packages/core/tests/index.test.ts @@ -31,6 +31,7 @@ function testDeps(identity: Partial = {}): { output: "json", outputExplicit: true, timeout: 30, + watermark: true, verbose: false, quiet: true, dryRun: false, @@ -290,6 +291,9 @@ test("resolveWatermark uses flag or defaults to true", () => { expect(resolveWatermark("false")).toBe(false); expect(resolveWatermark("true")).toBe(true); expect(resolveWatermark(undefined)).toBe(true); + expect(resolveWatermark(undefined, false)).toBe(false); + expect(resolveWatermark("true", false)).toBe(true); + expect(resolveWatermark("false", true)).toBe(false); }); test("resolveBooleanFlag uses flag or defaultWhenUnset", () => { diff --git a/packages/core/tests/mcp.test.ts b/packages/core/tests/mcp.test.ts index 357f4192f..510097798 100644 --- a/packages/core/tests/mcp.test.ts +++ b/packages/core/tests/mcp.test.ts @@ -23,6 +23,7 @@ function testDeps(overrides?: Partial): { identity: Identity; settings output: "json", outputExplicit: true, timeout: 5, + watermark: true, verbose: false, quiet: true, dryRun: false, diff --git a/packages/runtime/src/pipeline/bl-config.ts b/packages/runtime/src/pipeline/bl-config.ts index 39bc9c093..a5ed3f777 100644 --- a/packages/runtime/src/pipeline/bl-config.ts +++ b/packages/runtime/src/pipeline/bl-config.ts @@ -16,6 +16,22 @@ export interface PipelineEnv { settings: Settings; } +/** Media steps that inherit Profile `watermark` when the YAML omits the field. */ +export const PIPELINE_WATERMARK_STEPS = new Set(["image/generate", "image/edit", "video/generate"]); + +/** + * Fill Profile watermark into a planned/executed step input. + * Explicit step `watermark` wins; otherwise use Settings (file → default true). + */ +export function applyProfileWatermarkToStepInput( + stepType: string, + input: Record, + settings: Settings, +): Record { + if (!PIPELINE_WATERMARK_STEPS.has(stepType) || input.watermark !== undefined) return input; + return { ...input, watermark: settings.watermark }; +} + /** * Build the in-process env for pipeline steps. Uses the same source resolution * as the CLI itself (env vars, config file; no CLI flags), but forces JSON diff --git a/packages/runtime/src/pipeline/executor.ts b/packages/runtime/src/pipeline/executor.ts index aac5a3fbf..20df0e4c5 100644 --- a/packages/runtime/src/pipeline/executor.ts +++ b/packages/runtime/src/pipeline/executor.ts @@ -1,5 +1,5 @@ import { PipelineError, toPipelineError } from "./errors.ts"; -import { buildPipelineEnv } from "./bl-config.ts"; +import { applyProfileWatermarkToStepInput, buildPipelineEnv } from "./bl-config.ts"; import { getDefaultStepDispatcher, type StepDispatcher } from "./dispatcher.ts"; import { evaluateCondition, @@ -156,12 +156,17 @@ async function executePipelineInternal( if (options.dryRun) { for (const planStep of topologicalOrder(plan)) { const resolved = resolvePlannedStepInput(planStep.step, pipeline, normalizedRuntimeInput); + const plannedInput = applyProfileWatermarkToStepInput( + planStep.step.type, + resolved.redacted, + blEnv.settings, + ); const report: PipelineStepReport = { id: planStep.step.id, type: planStep.step.type, status: "planned", dependencies: planStep.dependencies, - input: resolved.redacted, + input: plannedInput, ...(planStep.step.when !== undefined ? { condition: "pending" } : {}), }; reports.push(report); @@ -170,14 +175,14 @@ async function executePipelineInternal( timestamp: now(), status: "planned", step: stepEvent(planStep), - input: inputSummary(resolved.redacted, resolved.sensitiveKeys), + input: inputSummary(plannedInput, resolved.sensitiveKeys), }); await emit({ type: "step.planned", timestamp: now(), status: "planned", step: stepEvent(planStep), - input: inputSummary(resolved.redacted, resolved.sensitiveKeys), + input: inputSummary(plannedInput, resolved.sensitiveKeys), ...(planStep.step.when !== undefined ? { condition: "pending" as const } : {}), }); } diff --git a/packages/runtime/src/pipeline/steps/bl-api.ts b/packages/runtime/src/pipeline/steps/bl-api.ts index 491470e91..aa9c90671 100644 --- a/packages/runtime/src/pipeline/steps/bl-api.ts +++ b/packages/runtime/src/pipeline/steps/bl-api.ts @@ -193,7 +193,8 @@ export async function imageGenerate( n, seed: input.seed, prompt_extend: promptExtend, - watermark: resolveWatermark(input.watermark), + // Step input overrides Profile; omit → use Profile / CLI default (true). + watermark: resolveWatermark(input.watermark, env.settings.watermark), }; const body: DashScopeImageRequest = @@ -300,7 +301,8 @@ export async function imageEdit( n, seed: input.seed, prompt_extend: promptExtend, - watermark: resolveWatermark(input.watermark), + // Step input overrides Profile; omit → use Profile / CLI default (true). + watermark: resolveWatermark(input.watermark, env.settings.watermark), }; let body: DashScopeImageRequest; @@ -461,7 +463,8 @@ export async function videoGenerate( ratio: input.ratio || undefined, duration: input.duration, prompt_extend: resolveBooleanFlag(input["prompt-extend"], undefined, "prompt-extend"), - watermark: resolveWatermark(input.watermark), + // Step input overrides Profile; omit → use Profile / CLI default (true). + watermark: resolveWatermark(input.watermark, env.settings.watermark), seed: input.seed, }, }; diff --git a/packages/runtime/src/pipeline/steps/bl-steps.ts b/packages/runtime/src/pipeline/steps/bl-steps.ts index 41f325a2c..6a433c958 100644 --- a/packages/runtime/src/pipeline/steps/bl-steps.ts +++ b/packages/runtime/src/pipeline/steps/bl-steps.ts @@ -1,5 +1,9 @@ import { registerStep } from "../dispatcher.ts"; -import { buildPipelineEnv, type PipelineEnv } from "../bl-config.ts"; +import { + applyProfileWatermarkToStepInput, + buildPipelineEnv, + type PipelineEnv, +} from "../bl-config.ts"; import { isRecord } from "../utils.ts"; import { textChat, @@ -153,9 +157,13 @@ async function executeDirectBlStep( input: Record, ctx: StepContext, ): Promise { + const env = (ctx.blEnv as PipelineEnv | undefined) ?? buildPipelineEnv(); + if (ctx.dryRun) { + // Surface the Profile-effective watermark in dry-run so plans match runtime. + const plannedInput = applyProfileWatermarkToStepInput(id, input, env.settings); return { - metadata: { dryRun: true, step: id, plannedInput: input }, + metadata: { dryRun: true, step: id, plannedInput }, warnings: [ { code: "dry_run_skipped", message: `Step ${id} was not executed in dry-run mode` }, ], @@ -167,7 +175,6 @@ async function executeDirectBlStep( throw new Error(`No direct API handler registered for step: ${id}`); } - const env = (ctx.blEnv as PipelineEnv | undefined) ?? buildPipelineEnv(); const data = await handler(env, input, ctx); const builder = RESULT_BUILDERS[id]; if (builder) { diff --git a/packages/runtime/src/utils/flag-descriptions.ts b/packages/runtime/src/utils/flag-descriptions.ts index 9faea8c56..bc49b9b32 100644 --- a/packages/runtime/src/utils/flag-descriptions.ts +++ b/packages/runtime/src/utils/flag-descriptions.ts @@ -1,8 +1,9 @@ /** Shared --foo help text; keep wording consistent with actual CLI/request behavior. */ export const BOOL_FLAG_WATERMARK = { - "en-US": "Enable watermark (true/false). Omit flag to use CLI default (true).", - "zh-CN": "是否启用水印(true/false)。不传时使用 CLI 默认值 true。", + "en-US": + "Enable watermark (true/false). Omit flag to use the Profile setting or CLI default (true).", + "zh-CN": "是否启用水印(true/false)。不传时使用 Profile 配置或 CLI 默认值 true。", }; /** CLI sends prompt_extend=true when flag omitted (qwen-image edit, etc.). */ diff --git a/packages/runtime/tests/watermark-pipeline.test.ts b/packages/runtime/tests/watermark-pipeline.test.ts new file mode 100644 index 000000000..4ecef94d2 --- /dev/null +++ b/packages/runtime/tests/watermark-pipeline.test.ts @@ -0,0 +1,129 @@ +import { expect, test } from "vite-plus/test"; +import type { Client } from "bailian-cli-core"; +import { applyProfileWatermarkToStepInput, type PipelineEnv } from "../src/pipeline/bl-config.ts"; +import { imageEdit, imageGenerate, videoGenerate } from "../src/pipeline/steps/bl-api.ts"; +import type { StepContext } from "../src/pipeline/types.ts"; + +type CapturedRequest = { + path?: string; + method?: string; + body?: { + parameters?: { watermark?: boolean }; + }; + async?: boolean; +}; + +function makeEnv(watermark: boolean): { + env: PipelineEnv; + captured: CapturedRequest[]; +} { + const captured: CapturedRequest[] = []; + const client = { + uploadFile: async (source: string) => source, + requestJson: async (opts: CapturedRequest) => { + captured.push(opts); + if (opts.async) { + return { output: { task_id: "task-wm", task_status: "PENDING" } }; + } + // Sync image response shape (qwen-image / wan2.7-image). + return { + request_id: "req-wm", + output: { + choices: [{ message: { content: [{ image: "https://example.com/out.png" }] } }], + }, + }; + }, + } as unknown as Client; + + return { + env: { + client, + settings: { + quiet: true, + output: "json", + outputExplicit: true, + timeout: 300, + watermark, + verbose: false, + dryRun: false, + telemetry: false, + } as PipelineEnv["settings"], + }, + captured, + }; +} + +function makeCtx(): StepContext { + return { dryRun: false, signal: new AbortController().signal, timeoutSeconds: 1 }; +} + +test("pipeline imageGenerate inherits Profile watermark=false when step omits it", async () => { + const { env, captured } = makeEnv(false); + await imageGenerate(env, { prompt: "A cat" }, makeCtx()); + expect(captured[0]?.body?.parameters?.watermark).toBe(false); +}); + +test("pipeline imageGenerate keeps step watermark over Profile", async () => { + const { env, captured } = makeEnv(false); + await imageGenerate(env, { prompt: "A cat", watermark: true }, makeCtx()); + expect(captured[0]?.body?.parameters?.watermark).toBe(true); +}); + +test("pipeline imageEdit inherits Profile watermark=false when step omits it", async () => { + const { env, captured } = makeEnv(false); + await imageEdit(env, { prompt: "Blue sky", image: "https://example.com/in.png" }, makeCtx()); + expect(captured[0]?.body?.parameters?.watermark).toBe(false); +}); + +test("pipeline videoGenerate inherits Profile watermark=false when step omits it", async () => { + const { env, captured } = makeEnv(false); + // First call submits async task; pollTaskWithOptions will keep polling — mock SUCCEEDED quickly. + const client = env.client as unknown as { + requestJson: (opts: CapturedRequest) => Promise; + }; + let calls = 0; + client.requestJson = async (opts: CapturedRequest) => { + captured.push(opts); + calls += 1; + if (calls === 1) { + return { output: { task_id: "task-wm", task_status: "PENDING" } }; + } + return { + output: { + task_id: "task-wm", + task_status: "SUCCEEDED", + video_url: "https://example.com/out.mp4", + }, + }; + }; + + await videoGenerate( + env, + { prompt: "A cat walks", "poll-interval": 0 }, + { ...makeCtx(), timeoutSeconds: 5 }, + ); + expect(captured[0]?.body?.parameters?.watermark).toBe(false); +}); + +test("pipeline defaults watermark to true when Profile leaves the compliance default", async () => { + const { env, captured } = makeEnv(true); + await imageGenerate(env, { prompt: "A cat" }, makeCtx()); + expect(captured[0]?.body?.parameters?.watermark).toBe(true); +}); + +test("applyProfileWatermarkToStepInput fills omitted watermark from Settings", () => { + const settings = { watermark: false } as PipelineEnv["settings"]; + expect( + applyProfileWatermarkToStepInput("image/generate", { prompt: "A cat" }, settings).watermark, + ).toBe(false); + expect( + applyProfileWatermarkToStepInput( + "image/generate", + { prompt: "A cat", watermark: true }, + settings, + ).watermark, + ).toBe(true); + expect( + applyProfileWatermarkToStepInput("text/chat", { message: "hi" }, settings).watermark, + ).toBeUndefined(); +}); diff --git a/skills/bailian-cli/reference/config.md b/skills/bailian-cli/reference/config.md index 2c3f6969d..2ca6348b7 100644 --- a/skills/bailian-cli/reference/config.md +++ b/skills/bailian-cli/reference/config.md @@ -88,10 +88,10 @@ bl config list --output json #### Flags -| Flag | Type | Required | Description | -| ----------------- | ------ | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `--key ` | string | yes | Config key (language, base*url, output, output_dir, timeout, api_key, api_key_capabilities, access_token, access_key_id, access_key_secret, security_token, default*\*\_model, workspace_id) | -| `--value ` | string | yes | Value to set | +| Flag | Type | Required | Description | +| ----------------- | ------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `--key ` | string | yes | Config key (language, base*url, output, output_dir, timeout, watermark, api_key, api_key_capabilities, access_token, access_key_id, access_key_secret, security_token, default*\*\_model, workspace_id) | +| `--value ` | string | yes | Value to set | #### Examples @@ -107,6 +107,10 @@ bl config set --key output --value json bl config set --key timeout --value 600 ``` +```bash +bl config set --key watermark --value false +``` + ```bash bl config set --key base_url --value https://dashscope.aliyuncs.com ``` diff --git a/skills/bailian-gen/reference/image.md b/skills/bailian-gen/reference/image.md index 983a78d99..b90506efc 100644 --- a/skills/bailian-gen/reference/image.md +++ b/skills/bailian-gen/reference/image.md @@ -36,7 +36,7 @@ Index: [index.md](index.md) | `--negative-prompt ` | string | no | Negative prompt to exclude unwanted content | | `--function ` | string | no | wanx\*-imageedit function (default: description_edit). Examples: stylization_all, description_edit | | `--prompt-extend ` | boolean | no | Enable prompt extend (true/false). Omit flag to use CLI default (true). | -| `--watermark ` | boolean | no | Enable watermark (true/false). Omit flag to use CLI default (true). | +| `--watermark ` | boolean | no | Enable watermark (true/false). Omit flag to use the Profile setting or CLI default (true). | | `--out-dir ` | string | no | Download images to directory | | `--out-prefix ` | string | no | Filename prefix (default: edited) | | `--async` | switch | no | Return async task id without waiting | @@ -99,7 +99,7 @@ bl image edit --image ./photo.png --prompt "Replace the background with a beach" | `--seed ` | number | no | Random seed for reproducible generation | | `--negative-prompt ` | string | no | Negative prompt to exclude unwanted content | | `--prompt-extend ` | boolean | no | Enable prompt extend (true/false). Omit flag: true for qwen-image sync; parameter omitted on async models (API default). | -| `--watermark ` | boolean | no | Enable watermark (true/false). Omit flag to use CLI default (true). | +| `--watermark ` | boolean | no | Enable watermark (true/false). Omit flag to use the Profile setting or CLI default (true). | | `--async` | switch | no | Return async task id without waiting | | `--concurrent ` | number | no | Run N parallel requests (default: 1) | | `--out-dir ` | string | no | Download images to directory | diff --git a/skills/bailian-gen/reference/video.md b/skills/bailian-gen/reference/video.md index 8e6a21ddf..2f5d90ef6 100644 --- a/skills/bailian-gen/reference/video.md +++ b/skills/bailian-gen/reference/video.md @@ -56,26 +56,26 @@ bl video download --task-id 3b256896-xxxx --out video.mp4 --quiet #### Flags -| Flag | Type | Required | Description | -| -------------------------------- | ------- | -------- | --------------------------------------------------------------------------------------- | -| `--model ` | string | no | Model ID (default: happyhorse-1.0-video-edit) | -| `--video ` | string | yes | Input video URL or local file (mp4/mov, 2-10s) | -| `--prompt ` | string | no | Edit instruction (e.g. "Convert the scene to a claymation style") | -| `--ref-image ` | string | no | Reference image URL (up to 4, comma-separated) | -| `--negative-prompt ` | string | no | Negative prompt to exclude unwanted content | -| `--resolution ` | string | no | Resolution: 720P or 1080P (default: 1080P) | -| `--ratio ` | string | no | Aspect ratio (16:9, 9:16, 1:1, 4:3, 3:4) | -| `--duration ` | number | no | Output video duration in seconds (2-10) | -| `--audio-setting ` | string | no | Audio: auto (default) or origin (keep original) | -| `--prompt-extend ` | boolean | no | Enable prompt extend (true/false). Omit flag to omit the parameter (DashScope default). | -| `--watermark ` | boolean | no | Enable watermark (true/false). Omit flag to use CLI default (true). | -| `--seed ` | number | no | Random seed for reproducible generation | -| `--download ` | string | no | Save video to file on completion | -| `--async` | switch | no | Return async task id without waiting | -| `--concurrent ` | number | no | Run N parallel requests (default: 1) | -| `--poll-interval ` | number | no | Polling interval when waiting (default: 15) | -| `--api-key ` | string | no | API key | -| `--base-url ` | string | no | API base URL | +| Flag | Type | Required | Description | +| -------------------------------- | ------- | -------- | ------------------------------------------------------------------------------------------ | +| `--model ` | string | no | Model ID (default: happyhorse-1.0-video-edit) | +| `--video ` | string | yes | Input video URL or local file (mp4/mov, 2-10s) | +| `--prompt ` | string | no | Edit instruction (e.g. "Convert the scene to a claymation style") | +| `--ref-image ` | string | no | Reference image URL (up to 4, comma-separated) | +| `--negative-prompt ` | string | no | Negative prompt to exclude unwanted content | +| `--resolution ` | string | no | Resolution: 720P or 1080P (default: 1080P) | +| `--ratio ` | string | no | Aspect ratio (16:9, 9:16, 1:1, 4:3, 3:4) | +| `--duration ` | number | no | Output video duration in seconds (2-10) | +| `--audio-setting ` | string | no | Audio: auto (default) or origin (keep original) | +| `--prompt-extend ` | boolean | no | Enable prompt extend (true/false). Omit flag to omit the parameter (DashScope default). | +| `--watermark ` | boolean | no | Enable watermark (true/false). Omit flag to use the Profile setting or CLI default (true). | +| `--seed ` | number | no | Random seed for reproducible generation | +| `--download ` | string | no | Save video to file on completion | +| `--async` | switch | no | Return async task id without waiting | +| `--concurrent ` | number | no | Run N parallel requests (default: 1) | +| `--poll-interval ` | number | no | Polling interval when waiting (default: 15) | +| `--api-key ` | string | no | API key | +| `--base-url ` | string | no | API base URL | #### Examples @@ -117,7 +117,7 @@ bl video edit --video https://example.com/input.mp4 --prompt "Put clothes on the | `--ratio ` | string | no | Aspect ratio (e.g. 16:9, 9:16, 1:1) | | `--duration ` | number | no | Video duration in seconds (default: 5) | | `--prompt-extend ` | boolean | no | Enable prompt extend (true/false). Omit flag to omit the parameter (DashScope default). | -| `--watermark ` | boolean | no | Enable watermark (true/false). Omit flag to use CLI default (true). | +| `--watermark ` | boolean | no | Enable watermark (true/false). Omit flag to use the Profile setting or CLI default (true). | | `--seed ` | number | no | Random seed for reproducible generation | | `--download ` | string | no | Save video to file on completion | | `--file ` | string | no | Reference file URL or local path for file-to-video (wan3.0-video only; mutually exclusive with --image/--last-frame) | @@ -172,7 +172,7 @@ bl video generate --prompt "A cat playing with a ball" --watermark false | `--ratio ` | string | no | Aspect ratio (16:9, 9:16, 1:1) | | `--duration ` | number | no | Video duration in seconds (default: 5) | | `--prompt-extend ` | boolean | no | Enable prompt extend (true/false). Omit flag to omit the parameter (DashScope default). | -| `--watermark ` | boolean | no | Enable watermark (true/false). Omit flag to use CLI default (true). | +| `--watermark ` | boolean | no | Enable watermark (true/false). Omit flag to use the Profile setting or CLI default (true). | | `--seed ` | number | no | Random seed for reproducible generation | | `--download ` | string | no | Save video to file on completion | | `--async` | switch | no | Return async task id without waiting |