feat(media): 对象存储 - 新增腾讯云 COS 与历史凭据兼容

Co-authored-by: MasterChiefCN <49179444+MasterChiefCN@users.noreply.github.com>
Co-authored-by: Codex <codex@openai.com>
Original-PR: https://github.com/ddcat-ai/open-ai-canvas/pull/218
This commit is contained in:
MasterChiefCN
2026-08-14 10:10:15 +08:00
committed by GitHub
parent 7f0229463e
commit 95cb4a39ee
16 changed files with 497 additions and 83 deletions

View File

@@ -7,7 +7,7 @@ import { useUserStore } from "@/stores/use-user-store";
type OSSFormValues = {
enabled?: boolean;
provider: "aliyun";
provider: "aliyun" | "tencent";
region?: string;
endpoint?: string;
bucket?: string;
@@ -24,6 +24,11 @@ export function UserOSSSettingsForm() {
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const savedAt = formatSavedAt(setting?.updatedAt);
const provider = Form.useWatch("provider", form) || "aliyun";
const isTencentCOS = provider === "tencent";
const hasCurrentProviderSecret = Boolean(setting && setting.provider === provider && setting.hasAccessKeySecret);
const accessKeyIdLabel = isTencentCOS ? "SecretId" : "AccessKey ID";
const accessKeySecretLabel = isTencentCOS ? "SecretKey" : "AccessKey Secret";
useEffect(() => {
if (!actor?.id) return;
@@ -35,7 +40,7 @@ export function UserOSSSettingsForm() {
setSetting(data.setting);
form.setFieldsValue(toFormValues(data.setting));
})
.catch((error) => active && message.error(error instanceof Error ? error.message : "读取个人 OSS 配置失败"))
.catch((error) => active && message.error(error instanceof Error ? error.message : "读取个人对象存储配置失败"))
.finally(() => active && setLoading(false));
return () => {
active = false;
@@ -43,7 +48,7 @@ export function UserOSSSettingsForm() {
}, [actor?.id, form, message]);
if (!actor) {
return <div className="rounded-md border border-dashed border-border px-5 py-10 text-center text-sm text-foreground/55">登录后可配置个人 OSS。</div>;
return <div className="rounded-md border border-dashed border-border px-5 py-10 text-center text-sm text-foreground/55">登录后可配置个人对象存储。</div>;
}
const save = async () => {
@@ -62,9 +67,9 @@ export function UserOSSSettingsForm() {
});
setSetting(data.setting);
form.setFieldsValue(toFormValues(data.setting));
message.success(data.setting.enabled ? "个人 OSS 已启用,后续上传将优先使用该存储" : "个人 OSS 已停用,后续上传将使用平台存储");
message.success(data.setting.enabled ? "个人对象存储已启用,后续上传将优先使用该存储" : "个人对象存储已停用,后续上传将使用平台存储");
} catch (error) {
message.error(error instanceof Error ? error.message : "保存个人 OSS 配置失败");
message.error(error instanceof Error ? error.message : "保存个人对象存储配置失败");
} finally {
setSaving(false);
}
@@ -76,9 +81,9 @@ export function UserOSSSettingsForm() {
<div className="min-w-0">
<div className="flex items-center gap-2 text-sm font-semibold">
<Cloud className="size-4" />
我的 OSS
我的对象存储
</div>
<p className="mt-1 max-w-3xl text-xs leading-5 text-foreground/55">启用后,新上传和新生成的媒体优先写入你的 OSS;停用时回退到平台存储。历史资源固定使用创建时的配置版本。</p>
<p className="mt-1 max-w-3xl text-xs leading-5 text-foreground/55">支持阿里云 OSS 与腾讯云 COS。启用后,新上传和新生成的媒体优先写入你的存储桶;停用时回退到平台存储。历史资源固定使用创建时的配置版本。</p>
</div>
<div className="flex shrink-0 gap-2">
<Tag color={setting?.enabled ? "success" : "default"}>{setting?.enabled ? "已启用" : "未启用"}</Tag>
@@ -89,36 +94,41 @@ export function UserOSSSettingsForm() {
</div>
<div className="grid grid-cols-1 gap-x-4 md:grid-cols-2 xl:grid-cols-3">
<Form.Item name="enabled" label="启用个人 OSS" valuePropName="checked" className="mb-3">
<Form.Item name="enabled" label="启用个人对象存储" valuePropName="checked" className="mb-3">
<Switch checkedChildren="启用" unCheckedChildren="停用" />
</Form.Item>
<Form.Item name="provider" label="存储服务" rules={[{ required: true, message: "请选择存储服务" }]} className="mb-3">
<Select options={[{ label: "阿里云 OSS", value: "aliyun" }]} />
<Select
options={[{ label: "阿里云 OSS", value: "aliyun" }, { label: "腾讯云 COS", value: "tencent" }]}
onChange={(nextProvider: OSSFormValues["provider"]) => {
if (nextProvider !== provider) form.setFieldsValue({ region: "", endpoint: "", bucket: "", accessKeyId: "", accessKeySecret: "" });
}}
/>
</Form.Item>
<Form.Item name="region" label="Region" className="mb-3">
<Input spellCheck={false} placeholder="oss-cn-hangzhou" />
<Input spellCheck={false} placeholder={isTencentCOS ? "ap-guangzhou" : "oss-cn-hangzhou"} />
</Form.Item>
<Form.Item name="endpoint" label="Endpoint" className="mb-3">
<Input inputMode="url" spellCheck={false} placeholder="https://oss-cn-hangzhou.aliyuncs.com" />
<Form.Item name="endpoint" label="Endpoint" extra={isTencentCOS ? "可留空,系统会根据 Region 生成标准 COS Endpoint。" : undefined} className="mb-3">
<Input inputMode="url" spellCheck={false} placeholder={isTencentCOS ? "https://cos.ap-guangzhou.myqcloud.com" : "https://oss-cn-hangzhou.aliyuncs.com"} />
</Form.Item>
<Form.Item name="bucket" label="Bucket" className="mb-3">
<Input spellCheck={false} placeholder="my-canvas-assets" />
<Input spellCheck={false} placeholder={isTencentCOS ? "my-canvas-assets-1250000000" : "my-canvas-assets"} />
</Form.Item>
<Form.Item name="pathPrefix" label="路径前缀" className="mb-3">
<Input spellCheck={false} placeholder="infinite-canvas" />
</Form.Item>
<Form.Item name="accessKeyId" label="AccessKey ID" className="mb-3 xl:col-span-1">
<Input autoComplete="off" spellCheck={false} placeholder="阿里云 AccessKey ID" />
<Form.Item name="accessKeyId" label={accessKeyIdLabel} className="mb-3 xl:col-span-1">
<Input autoComplete="off" spellCheck={false} placeholder={isTencentCOS ? "腾讯云 SecretId" : "阿里云 AccessKey ID"} />
</Form.Item>
<Form.Item name="accessKeySecret" label={setting?.hasAccessKeySecret ? "AccessKey Secret(留空保留)" : "AccessKey Secret"} className="mb-3 xl:col-span-2">
<Input.Password autoComplete="new-password" spellCheck={false} placeholder={setting?.hasAccessKeySecret ? "留空保留已加密密钥" : "阿里云 AccessKey Secret"} />
<Form.Item name="accessKeySecret" label={hasCurrentProviderSecret ? `${accessKeySecretLabel}(留空保留)` : accessKeySecretLabel} className="mb-3 xl:col-span-2">
<Input.Password autoComplete="new-password" spellCheck={false} placeholder={hasCurrentProviderSecret ? "留空保留已加密密钥" : isTencentCOS ? "腾讯云 SecretKey" : "阿里云 AccessKey Secret"} />
</Form.Item>
</div>
<div className="mt-2 flex flex-wrap items-center justify-between gap-3 border-t border-border pt-3">
<span className="text-xs text-foreground/50">{savedAt ? `上次保存:${savedAt}` : "尚未保存个人 OSS 配置"}</span>
<span className="text-xs text-foreground/50">{savedAt ? `上次保存:${savedAt}` : "尚未保存个人对象存储配置"}</span>
<Button type="primary" loading={saving} onClick={() => void save()}>
保存个人 OSS
保存个人对象存储
</Button>
</div>
</Form>

View File

@@ -14,7 +14,7 @@ export function resourceStorageLabel(storageKey?: string) {
export function resourceStorageTitle(storageKey?: string) {
const location = resourceStorageLocation(storageKey);
if (location === "oss") return "已上传到 OSS,并以账号资源同步";
if (location === "local") return "保存在当前浏览器本地,通常是 OSS 未启用或上传失败后的降级";
if (location === "oss") return "已上传到对象存储,并以账号资源同步";
if (location === "local") return "保存在当前浏览器本地,通常是对象存储未启用或上传失败后的降级";
return "还没有可同步的资源标识";
}

View File

@@ -45,7 +45,7 @@ const adminNavigation: Array<{ label: string; items: AdminNavigationItem[] }> =
{ path: "/admin/settings/runtime-policy", label: "资源与策略", description: "配额、并发、频控与超时", icon: <Settings2 className="size-4" /> },
{ path: "/admin/settings/access", label: "登录与注册", description: "注册策略与 Linux.do", icon: <ShieldCheck className="size-4" /> },
{ path: "/admin/settings/email", label: "邮件服务", description: "注册验证码 SMTP", icon: <Mail className="size-4" /> },
{ path: "/admin/settings/storage", label: "存储服务", description: "OSS 与资源存储", icon: <HardDrive className="size-4" /> },
{ path: "/admin/settings/storage", label: "存储服务", description: "对象存储与资源存储", icon: <HardDrive className="size-4" /> },
],
},
];

View File

@@ -7,7 +7,7 @@ import { useAdminContext } from "../admin-context";
import { AdminPageFrame } from "../components/admin-shell";
import { configuredSecretText, SettingsSectionCard } from "../components/admin-ui";
type StorageMode = "local" | "oss";
type StorageMode = "local" | "aliyun" | "tencent";
type OSSFormValues = { mode: StorageMode; publicBaseUrl?: string; region?: string; endpoint?: string; bucket?: string; accessKeyId?: string; accessKeySecret?: string; pathPrefix?: string };
export default function StorageSettingsPage() {
@@ -18,27 +18,34 @@ export default function StorageSettingsPage() {
const [saving, setSaving] = useState(false);
const [form] = Form.useForm<OSSFormValues>();
const mode = Form.useWatch("mode", form) || "local";
const isObjectStorage = mode !== "local";
const isTencentCOS = mode === "tencent";
const selectedProviderLabel = isTencentCOS ? "腾讯云 COS" : "阿里云 OSS";
const accessKeyIdLabel = isTencentCOS ? "SecretId" : "AccessKey ID";
const accessKeySecretLabel = isTencentCOS ? "SecretKey" : "AccessKey Secret";
const hasCurrentProviderSecret = Boolean(setting && setting.provider === mode && setting.hasAccessKeySecret);
const userNameById = useMemo(() => new Map(references.users.map((user) => [user.id, user.displayName || user.username])), [references.users]);
useEffect(() => {
void getAdminOSSSetting()
.then(({ setting: value }) => { setSetting(value); form.setFieldsValue(formValues(value)); })
.catch((error) => message.error(error instanceof Error ? error.message : "读取 OSS 配置失败"))
.catch((error) => message.error(error instanceof Error ? error.message : "读取对象存储配置失败"))
.finally(() => setLoading(false));
}, [form, message]);
const save = async () => {
await form.validateFields();
// 本地模式会卸载 OSS 字段,读取完整 store 才能保留已保存的 OSS 配置。
// 本地模式会卸载对象存储字段,读取完整 store 才能保留已保存的配置。
const values = form.getFieldsValue(true);
if (values.mode === "local" && !values.publicBaseUrl?.trim()) return message.error("请填写服务器访问地址");
if (values.mode === "oss" && !values.accessKeySecret?.trim() && !setting?.hasAccessKeySecret) return message.error("请填写 AccessKey Secret");
if (values.mode === "oss" && !values.endpoint?.trim()) return message.error("请填写 OSS Endpoint");
if (values.mode === "oss" && !values.bucket?.trim()) return message.error("请填写 OSS Bucket");
if (values.mode === "oss" && !values.accessKeyId?.trim()) return message.error("请填写 AccessKey ID");
if (values.mode !== "local" && !values.accessKeySecret?.trim() && !hasCurrentProviderSecret) return message.error(`请填写${values.mode === "tencent" ? " SecretKey" : " AccessKey Secret"}`);
if (values.mode === "aliyun" && !values.endpoint?.trim()) return message.error("请填写阿里云 OSS Endpoint");
if (values.mode === "tencent" && !values.endpoint?.trim() && !values.region?.trim()) return message.error("请填写腾讯云 COS Region 或 Endpoint");
if (values.mode !== "local" && !values.bucket?.trim()) return message.error("请填写对象存储 Bucket");
if (values.mode !== "local" && !values.accessKeyId?.trim()) return message.error(`请填写${values.mode === "tencent" ? " SecretId" : " AccessKey ID"}`);
setSaving(true);
try {
const result = await updateAdminOSSSetting({ enabled: values.mode === "oss", provider: "aliyun", region: values.region?.trim() || "", endpoint: values.endpoint?.trim() || "", bucket: values.bucket?.trim() || "", accessKeyId: values.accessKeyId?.trim() || "", accessKeySecret: values.accessKeySecret?.trim() || "", publicBaseUrl: values.publicBaseUrl?.trim() || "", pathPrefix: values.pathPrefix?.trim() || "" });
const result = await updateAdminOSSSetting({ enabled: values.mode !== "local", provider: values.mode === "local" ? setting?.provider || "aliyun" : values.mode, region: values.region?.trim() || "", endpoint: values.endpoint?.trim() || "", bucket: values.bucket?.trim() || "", accessKeyId: values.accessKeyId?.trim() || "", accessKeySecret: values.accessKeySecret?.trim() || "", publicBaseUrl: values.publicBaseUrl?.trim() || "", pathPrefix: values.pathPrefix?.trim() || "" });
setSetting(result.setting);
form.setFieldsValue(formValues(result.setting));
message.success("存储配置已保存");
@@ -59,7 +66,7 @@ export default function StorageSettingsPage() {
icon={<Cloud className="size-4" />}
title="平台存储"
description="选择平台新增媒体资源的默认写入方式。"
status={<Space size={6}><Tag variant="filled" color={setting?.enabled ? "blue" : "default"}>{setting?.enabled ? "阿里云 OSS" : "服务器本地"}</Tag>{setting?.enabled ? <Tag variant="filled" color={setting.hasAccessKeySecret ? "success" : "warning"}>{setting.hasAccessKeySecret ? configuredSecretText : "未保存密钥"}</Tag> : null}</Space>}
status={<Space size={6}><Tag variant="filled" color={setting?.enabled ? "blue" : "default"}>{setting?.enabled ? storageProviderLabel(setting.provider) : "服务器本地"}</Tag>{setting?.enabled ? <Tag variant="filled" color={setting.hasAccessKeySecret ? "success" : "warning"}>{setting.hasAccessKeySecret ? configuredSecretText : "未保存密钥"}</Tag> : null}</Space>}
footer={<><div className="text-xs text-foreground/45">{setting?.updatedAt ? `上次更新:${formatTime(setting.updatedAt)}${setting.updatedBy ? ` · ${userNameById.get(setting.updatedBy) || setting.updatedBy}` : ""}` : "尚未保存平台存储配置"}</div><Button type="primary" loading={saving} onClick={() => void save()}>保存存储配置</Button></>}
>
<Form form={form} layout="vertical" requiredMark={false} disabled={loading}>
@@ -69,18 +76,24 @@ export default function StorageSettingsPage() {
block
options={[
{ label: <span className="inline-flex items-center gap-2"><HardDrive className="size-4" />服务器本地</span>, value: "local" },
{ label: <span className="inline-flex items-center gap-2"><Cloud className="size-4" />阿里云 OSS</span>, value: "oss" },
{ label: <span className="inline-flex items-center gap-2"><Cloud className="size-4" />阿里云 OSS</span>, value: "aliyun" },
{ label: <span className="inline-flex items-center gap-2"><Cloud className="size-4" />腾讯云 COS</span>, value: "tencent" },
]}
onChange={(value) => {
const nextMode = value as StorageMode;
const switchingProvider = nextMode !== "local" && ((mode !== "local" && mode !== nextMode) || (mode === "local" && setting?.provider !== nextMode));
if (switchingProvider) form.setFieldsValue({ region: "", endpoint: "", bucket: "", accessKeyId: "", accessKeySecret: "" });
}}
/>
</Form.Item>
{mode === "oss" ? (
{isObjectStorage ? (
<>
<Form.Item name="region" label="Region"><Input autoComplete="off" placeholder="例如:oss-cn-hangzhou" /></Form.Item>
<Form.Item name="endpoint" label="Endpoint"><Input autoComplete="off" placeholder="https://oss-cn-hangzhou.aliyuncs.com" /></Form.Item>
<Form.Item name="bucket" label="Bucket"><Input autoComplete="off" placeholder="例如:my-canvas-assets" /></Form.Item>
<Form.Item name="region" label="Region"><Input autoComplete="off" placeholder={isTencentCOS ? "例如:ap-guangzhou" : "例如:oss-cn-hangzhou"} /></Form.Item>
<Form.Item name="endpoint" label="Endpoint" extra={isTencentCOS ? "可留空,系统会根据 Region 生成标准 COS Endpoint。" : undefined}><Input autoComplete="off" placeholder={isTencentCOS ? "https://cos.ap-guangzhou.myqcloud.com" : "https://oss-cn-hangzhou.aliyuncs.com"} /></Form.Item>
<Form.Item name="bucket" label="Bucket"><Input autoComplete="off" placeholder={isTencentCOS ? "例如:my-canvas-assets-1250000000" : "例如:my-canvas-assets"} /></Form.Item>
<Form.Item name="pathPrefix" label="路径前缀"><Input autoComplete="off" placeholder="例如:uploads/infinite-canvas" /></Form.Item>
<Form.Item name="accessKeyId" label="AccessKey ID"><Input autoComplete="off" placeholder="阿里云 AccessKey ID" /></Form.Item>
<Form.Item name="accessKeySecret" label={setting?.hasAccessKeySecret ? `AccessKey Secret(${configuredSecretText})` : "AccessKey Secret"}><Input.Password autoComplete="new-password" placeholder={setting?.hasAccessKeySecret ? "留空保留原密钥" : "阿里云 AccessKey Secret"} /></Form.Item>
<Form.Item name="accessKeyId" label={accessKeyIdLabel}><Input autoComplete="off" placeholder={isTencentCOS ? "腾讯云 SecretId" : "阿里云 AccessKey ID"} /></Form.Item>
<Form.Item name="accessKeySecret" label={hasCurrentProviderSecret ? `${accessKeySecretLabel}(${configuredSecretText})` : accessKeySecretLabel}><Input.Password autoComplete="new-password" placeholder={hasCurrentProviderSecret ? "留空保留原密钥" : isTencentCOS ? "腾讯云 SecretKey" : "阿里云 AccessKey Secret"} /></Form.Item>
</>
) : (
<>
@@ -111,12 +124,13 @@ export default function StorageSettingsPage() {
</div>
</Form>
</SettingsSectionCard>
<div className="grid border-y border-border text-xs text-foreground/55 sm:grid-cols-3 sm:divide-x sm:divide-border"><Notice icon={mode === "oss" ? <Cloud className="size-3.5" /> : <HardDrive className="size-3.5" />} text={mode === "oss" ? "新资源写入阿里云 OSS" : "新资源写入服务器数据卷"} /><Notice icon={<ShieldCheck className="size-3.5" />} text="历史资源位置保持不变" /><Notice icon={<KeyRound className="size-3.5" />} text="外部链接仅在签名有效期内可用" /></div>
<div className="grid border-y border-border text-xs text-foreground/55 sm:grid-cols-3 sm:divide-x sm:divide-border"><Notice icon={isObjectStorage ? <Cloud className="size-3.5" /> : <HardDrive className="size-3.5" />} text={isObjectStorage ? `新资源写入${selectedProviderLabel}` : "新资源写入服务器数据卷"} /><Notice icon={<ShieldCheck className="size-3.5" />} text="历史资源位置保持不变" /><Notice icon={<KeyRound className="size-3.5" />} text="外部链接仅在签名有效期内可用" /></div>
</div>
</AdminPageFrame>
);
}
function formValues(setting?: AdminOSSSetting | null): OSSFormValues { return { mode: setting?.enabled ? "oss" : "local", publicBaseUrl: setting?.publicBaseUrl || "", region: setting?.region || "", endpoint: setting?.endpoint || "", bucket: setting?.bucket || "", accessKeyId: setting?.accessKeyId || "", accessKeySecret: "", pathPrefix: setting?.pathPrefix || "" }; }
function formValues(setting?: AdminOSSSetting | null): OSSFormValues { return { mode: setting?.enabled ? setting.provider === "tencent" ? "tencent" : "aliyun" : "local", publicBaseUrl: setting?.publicBaseUrl || "", region: setting?.region || "", endpoint: setting?.endpoint || "", bucket: setting?.bucket || "", accessKeyId: setting?.accessKeyId || "", accessKeySecret: "", pathPrefix: setting?.pathPrefix || "" }; }
function storageProviderLabel(provider?: AdminOSSSetting["provider"]) { return provider === "tencent" ? "腾讯云 COS" : "阿里云 OSS"; }
function formatTime(value?: string) { return value ? new Date(value).toLocaleString("zh-CN", { hour12: false }) : "--"; }
function Notice({ icon, text }: { icon: ReactNode; text: string }) { return <div className="flex items-center gap-2 px-3 py-2.5"><span className="text-foreground/40">{icon}</span><span>{text}</span></div>; }

View File

@@ -34,7 +34,7 @@ const configSections: Array<{ key: ConfigSectionKey; label: string; description:
{ key: "models", label: "模型选择", description: "按领域选择默认模型", icon: <Boxes className="size-4" /> },
{ key: "preferences", label: "生成偏好", description: "画布、视频与音频默认值", icon: <SlidersHorizontal className="size-4" /> },
{ key: "prompts", label: "提示词偏好", description: "按任务定制平台模板", icon: <MessageSquareText className="size-4" /> },
{ key: "storage", label: "我的 OSS", description: "管理个人媒体存储", icon: <Cloud className="size-4" /> },
{ key: "storage", label: "我的对象存储", description: "管理个人媒体存储", icon: <Cloud className="size-4" /> },
];
type UserChannelConnection = "openai" | "gemini";
@@ -527,7 +527,7 @@ export default function SettingsPage() {
label: (
<span className="inline-flex items-center gap-2">
<Cloud className="size-4" />
我的 OSS
我的对象存储
</span>
),
children: (

View File

@@ -249,7 +249,7 @@ export type UserPromptPreference = {
export type AdminOSSSetting = {
enabled: boolean;
provider: "aliyun";
provider: "aliyun" | "tencent";
region: string;
endpoint: string;
bucket: string;

View File

@@ -25,7 +25,7 @@ export type RemoteResource = {
export type UserOSSSetting = {
enabled: boolean;
provider: "aliyun";
provider: "aliyun" | "tencent";
region: string;
endpoint: string;
bucket: string;
@@ -112,10 +112,10 @@ export async function getResourceOSSUrl(storageKey?: string) {
if (!id) throw new Error("当前媒体尚未上传到后端资源存储");
try {
const data = await request<{ url: string }>(api.get(`/resources/${encodeURIComponent(id)}/oss-url`));
if (!data.url) throw new Error("后端未返回 OSS 地址");
if (!data.url) throw new Error("后端未返回对象存储地址");
return data.url;
} catch (error) {
if (axios.isAxiosError<BackendEnvelope<unknown>>(error)) throw new Error(error.response?.data.msg || error.message || "获取 OSS 地址失败");
if (axios.isAxiosError<BackendEnvelope<unknown>>(error)) throw new Error(error.response?.data.msg || error.message || "获取对象存储地址失败");
throw error;
}
}

View File

@@ -157,7 +157,7 @@ async function pollVideoGenerationsTask(config: ResolvedAiConfig, task: VideoGen
async function resolveVideoGenerationsUrl(value: string | undefined, storageKey?: string) {
if (storageKey?.startsWith("resource:")) return getResourceOSSUrl(storageKey);
if (isPublicMediaUrl(value || "")) return String(value);
throw new Error("NewAPI Video Generations 的参考素材需要公网 URL;请先把素材保存到 OSS");
throw new Error("NewAPI Video Generations 的参考素材需要公网 URL;请先把素材保存到对象存储");
}
type GeminiVeoOperation = {
@@ -488,7 +488,7 @@ async function buildVolcengineArkContent(prompt: string, references: ReferenceIm
async function resolveVolcengineArkReferenceUrl(value: string | undefined, storageKey?: string) {
if (storageKey?.startsWith("resource:")) return getResourceOSSUrl(storageKey);
if (isPublicMediaUrl(value || "") || String(value || "").startsWith("asset://")) return String(value);
throw new Error("火山方舟视频参考素材需要公网 URL 或 asset:// 素材 ID;请先将本地素材保存到 OSS");
throw new Error("火山方舟视频参考素材需要公网 URL 或 asset:// 素材 ID;请先将本地素材保存到对象存储");
}
async function buildSeedanceVideosPayload(config: AiConfig, model: string, prompt: string, references: ReferenceImage[], videoReferences: ReferenceVideo[], audioReferences: ReferenceAudio[]) {