feat(config): add profile-level watermark setting

This commit is contained in:
若麒
2026-09-08 16:33:03 +08:00
parent 2767491ce6
commit 3477a08dad
23 changed files with 206 additions and 42 deletions
+3 -2
View File
@@ -12,9 +12,9 @@ export default defineCommand({
valueHint: "<key>",
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",
],
@@ -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<string, string[]> = {
};
// Keys the UI renders as a true/false dropdown and stores as a boolean.
export const UI_BOOLEAN_KEYS = new Set<string>(["telemetry"]);
export const UI_BOOLEAN_KEYS = new Set<string>(["telemetry", "watermark"]);
// Default model each `default_*_model` key falls back to when left unset. These
// mirror the inline `|| "<model>"` 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("[")) {
@@ -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,
};
+1 -1
View File
@@ -207,7 +207,7 @@ export default defineCommand({
"prompt-extend",
);
const watermark = resolveWatermark(flags.watermark);
const watermark = resolveWatermark(flags.watermark, settings.watermark);
const parameters: NonNullable<DashScopeImageRequest["parameters"]> = {
size: resolveImageSize(flags.size, route.sizeProfile),
@@ -185,7 +185,7 @@ export default defineCommand({
"prompt-extend",
);
const watermark = resolveWatermark(flags.watermark);
const watermark = resolveWatermark(flags.watermark, settings.watermark);
const parameters: NonNullable<DashScopeImageRequest["parameters"]> = {
size,
+1 -1
View File
@@ -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,
@@ -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 = {
+1 -1
View File
@@ -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,
@@ -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);
});
@@ -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");
@@ -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<string, unknown>
>;
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 () => {
@@ -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 });
}
});
}
});
+1
View File
@@ -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,
+4
View File
@@ -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;
+3 -3
View File
@@ -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;
}
@@ -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" };
+1
View File
@@ -28,6 +28,7 @@ function makeSettings(configName?: string): Settings {
output: "json",
outputExplicit: false,
timeout: 30,
watermark: true,
verbose: false,
quiet: true,
dryRun: false,
+4
View File
@@ -31,6 +31,7 @@ function testDeps(identity: Partial<Identity> = {}): {
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", () => {
+1
View File
@@ -23,6 +23,7 @@ function testDeps(overrides?: Partial<Settings>): { identity: Identity; settings
output: "json",
outputExplicit: true,
timeout: 5,
watermark: true,
verbose: false,
quiet: true,
dryRun: false,
@@ -1,8 +1,9 @@
/** Shared --foo <bool> 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.). */
+8 -4
View File
@@ -88,10 +88,10 @@ bl config list --output json
#### Flags
| Flag | Type | Required | Description |
| ----------------- | ------ | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `--key <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 <value>` | string | yes | Value to set |
| Flag | Type | Required | Description |
| ----------------- | ------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `--key <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 <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
```
+2 -2
View File
@@ -36,7 +36,7 @@ Index: [index.md](index.md)
| `--negative-prompt <text>` | string | no | Negative prompt to exclude unwanted content |
| `--function <name>` | string | no | wanx\*-imageedit function (default: description_edit). Examples: stylization_all, description_edit |
| `--prompt-extend <bool>` | boolean | no | Enable prompt extend (true/false). Omit flag to use CLI default (true). |
| `--watermark <bool>` | boolean | no | Enable watermark (true/false). Omit flag to use CLI default (true). |
| `--watermark <bool>` | boolean | no | Enable watermark (true/false). Omit flag to use the Profile setting or CLI default (true). |
| `--out-dir <dir>` | string | no | Download images to directory |
| `--out-prefix <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 <n>` | number | no | Random seed for reproducible generation |
| `--negative-prompt <text>` | string | no | Negative prompt to exclude unwanted content |
| `--prompt-extend <bool>` | boolean | no | Enable prompt extend (true/false). Omit flag: true for qwen-image sync; parameter omitted on async models (API default). |
| `--watermark <bool>` | boolean | no | Enable watermark (true/false). Omit flag to use CLI default (true). |
| `--watermark <bool>` | 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 <n>` | number | no | Run N parallel requests (default: 1) |
| `--out-dir <dir>` | string | no | Download images to directory |
+22 -22
View File
@@ -56,26 +56,26 @@ bl video download --task-id 3b256896-xxxx --out video.mp4 --quiet
#### Flags
| Flag | Type | Required | Description |
| -------------------------------- | ------- | -------- | --------------------------------------------------------------------------------------- |
| `--model <model>` | string | no | Model ID (default: happyhorse-1.0-video-edit) |
| `--video <url>` | string | yes | Input video URL or local file (mp4/mov, 2-10s) |
| `--prompt <text>` | string | no | Edit instruction (e.g. "Convert the scene to a claymation style") |
| `--ref-image <url>` | string | no | Reference image URL (up to 4, comma-separated) |
| `--negative-prompt <text>` | string | no | Negative prompt to exclude unwanted content |
| `--resolution <res>` | string | no | Resolution: 720P or 1080P (default: 1080P) |
| `--ratio <ratio>` | string | no | Aspect ratio (16:9, 9:16, 1:1, 4:3, 3:4) |
| `--duration <seconds>` | number | no | Output video duration in seconds (2-10) |
| `--audio-setting <auto\|origin>` | string | no | Audio: auto (default) or origin (keep original) |
| `--prompt-extend <bool>` | boolean | no | Enable prompt extend (true/false). Omit flag to omit the parameter (DashScope default). |
| `--watermark <bool>` | boolean | no | Enable watermark (true/false). Omit flag to use CLI default (true). |
| `--seed <n>` | number | no | Random seed for reproducible generation |
| `--download <path>` | string | no | Save video to file on completion |
| `--async` | switch | no | Return async task id without waiting |
| `--concurrent <n>` | number | no | Run N parallel requests (default: 1) |
| `--poll-interval <seconds>` | number | no | Polling interval when waiting (default: 15) |
| `--api-key <key>` | string | no | API key |
| `--base-url <url>` | string | no | API base URL |
| Flag | Type | Required | Description |
| -------------------------------- | ------- | -------- | ------------------------------------------------------------------------------------------ |
| `--model <model>` | string | no | Model ID (default: happyhorse-1.0-video-edit) |
| `--video <url>` | string | yes | Input video URL or local file (mp4/mov, 2-10s) |
| `--prompt <text>` | string | no | Edit instruction (e.g. "Convert the scene to a claymation style") |
| `--ref-image <url>` | string | no | Reference image URL (up to 4, comma-separated) |
| `--negative-prompt <text>` | string | no | Negative prompt to exclude unwanted content |
| `--resolution <res>` | string | no | Resolution: 720P or 1080P (default: 1080P) |
| `--ratio <ratio>` | string | no | Aspect ratio (16:9, 9:16, 1:1, 4:3, 3:4) |
| `--duration <seconds>` | number | no | Output video duration in seconds (2-10) |
| `--audio-setting <auto\|origin>` | string | no | Audio: auto (default) or origin (keep original) |
| `--prompt-extend <bool>` | boolean | no | Enable prompt extend (true/false). Omit flag to omit the parameter (DashScope default). |
| `--watermark <bool>` | boolean | no | Enable watermark (true/false). Omit flag to use the Profile setting or CLI default (true). |
| `--seed <n>` | number | no | Random seed for reproducible generation |
| `--download <path>` | string | no | Save video to file on completion |
| `--async` | switch | no | Return async task id without waiting |
| `--concurrent <n>` | number | no | Run N parallel requests (default: 1) |
| `--poll-interval <seconds>` | number | no | Polling interval when waiting (default: 15) |
| `--api-key <key>` | string | no | API key |
| `--base-url <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 <ratio>` | string | no | Aspect ratio (e.g. 16:9, 9:16, 1:1) |
| `--duration <seconds>` | number | no | Video duration in seconds (default: 5) |
| `--prompt-extend <bool>` | boolean | no | Enable prompt extend (true/false). Omit flag to omit the parameter (DashScope default). |
| `--watermark <bool>` | boolean | no | Enable watermark (true/false). Omit flag to use CLI default (true). |
| `--watermark <bool>` | boolean | no | Enable watermark (true/false). Omit flag to use the Profile setting or CLI default (true). |
| `--seed <n>` | number | no | Random seed for reproducible generation |
| `--download <path>` | string | no | Save video to file on completion |
| `--file <url-or-path>` | 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 <ratio>` | string | no | Aspect ratio (16:9, 9:16, 1:1) |
| `--duration <seconds>` | number | no | Video duration in seconds (default: 5) |
| `--prompt-extend <bool>` | boolean | no | Enable prompt extend (true/false). Omit flag to omit the parameter (DashScope default). |
| `--watermark <bool>` | boolean | no | Enable watermark (true/false). Omit flag to use CLI default (true). |
| `--watermark <bool>` | boolean | no | Enable watermark (true/false). Omit flag to use the Profile setting or CLI default (true). |
| `--seed <n>` | number | no | Random seed for reproducible generation |
| `--download <path>` | string | no | Save video to file on completion |
| `--async` | switch | no | Return async task id without waiting |