fix: align model provider configuration
This commit is contained in:
@@ -1,10 +1,10 @@
|
||||
# GPT 最高规格 API。没填 OPENAI_API_KEY 时图片/素材包生成走 mock 占位图。
|
||||
OPENAI_API_KEY=
|
||||
GPT_TEXT_MODEL=gpt-5.5
|
||||
GPT_IMAGE_MODEL=image-2
|
||||
GPT_IMAGE_MODEL=gpt-image-2
|
||||
GPT_API_BASE=https://api.openai.com/v1
|
||||
|
||||
# 视频生成固定走 Seedance。未配置 Key 时 /api/video/generate 返回 503。
|
||||
SEEDANCE_API_KEY=
|
||||
SEEDANCE_MODEL=seedance-1-0-pro
|
||||
SEEDANCE_MODEL=doubao-seedance-2-0-260128
|
||||
SEEDANCE_API_BASE=https://ark.cn-beijing.volces.com/api/v3
|
||||
|
||||
4
RULES.md
4
RULES.md
@@ -23,10 +23,10 @@
|
||||
## 环境变量
|
||||
- `OPENAI_API_KEY` — GPT API Key;文本/结构化/图片生成统一走 GPT 最高规格配置
|
||||
- `GPT_TEXT_MODEL` — 默认 `gpt-5.5`,用于角色设定等结构化输出
|
||||
- `GPT_IMAGE_MODEL` — 默认 `image-2`,用于意向图和三类素材包图片生成
|
||||
- `GPT_IMAGE_MODEL` — 默认 `gpt-image-2`,用于意向图和三类素材包图片生成
|
||||
- `GPT_API_BASE` — 默认 `https://api.openai.com/v1`
|
||||
- `SEEDANCE_API_KEY` — Seedance 视频生成 Key;未配置时视频接口返回 503
|
||||
- `SEEDANCE_MODEL` — 默认 `seedance-1-0-pro`
|
||||
- `SEEDANCE_MODEL` — 默认 `doubao-seedance-2-0-260128`
|
||||
- `SEEDANCE_API_BASE` — 默认 `https://ark.cn-beijing.volces.com/api/v3`
|
||||
- 配置位置:`.env.local`(gitignored),参考 `.env.local.example`
|
||||
- 图片生成未配置 GPT Key 时回退 mock(SVG 占位图),视频生成不 mock,必须配置 Seedance Key
|
||||
|
||||
@@ -15,6 +15,7 @@ import type {
|
||||
PackKind,
|
||||
VideoGenerationResponse,
|
||||
} from '@/lib/types';
|
||||
import type { VIDEO_TEMPLATES } from '@/lib/templates';
|
||||
|
||||
export default function Home() {
|
||||
const [sessions, setSessions] = useState<GenSession[]>([]);
|
||||
@@ -146,22 +147,24 @@ export default function Home() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleGenerateVideo(image: GenImage, promptTemplate: string) {
|
||||
async function handleGenerateVideo(image: GenImage, template: typeof VIDEO_TEMPLATES[number]) {
|
||||
if (!current || videoLoading) return;
|
||||
setVideoLoading(true);
|
||||
try {
|
||||
const character = current.characterSpec
|
||||
? `${current.characterSpec.name},${current.characterSpec.oneLiner}`
|
||||
: current.prompt;
|
||||
const prompt = template.promptTemplate.replace('{character}', character);
|
||||
const r = await fetch('/api/video/generate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
prompt: promptTemplate.replace('{character}', character),
|
||||
prompt,
|
||||
imageUrl: image.url,
|
||||
duration: 6,
|
||||
ratio: '16:9',
|
||||
resolution: '1080p',
|
||||
duration: template.duration,
|
||||
ratio: template.ratio,
|
||||
generateAudio: true,
|
||||
watermark: false,
|
||||
}),
|
||||
});
|
||||
if (!r.ok) {
|
||||
|
||||
@@ -266,7 +266,7 @@ function TextTemplateSection() {
|
||||
function VideoSection({ videoLoading, primaryImage, onGenerateVideo }: {
|
||||
videoLoading: boolean;
|
||||
primaryImage: GenImage;
|
||||
onGenerateVideo: (image: GenImage, promptTemplate: string) => void;
|
||||
onGenerateVideo: (image: GenImage, template: typeof VIDEO_TEMPLATES[number]) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [showPromptId, setShowPromptId] = useState<string | null>(null);
|
||||
@@ -331,7 +331,7 @@ function VideoSection({ videoLoading, primaryImage, onGenerateVideo }: {
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onGenerateVideo(primaryImage, template.promptTemplate)}
|
||||
onClick={() => onGenerateVideo(primaryImage, template)}
|
||||
disabled={videoLoading}
|
||||
className="self-center btn btn-primary text-[11px] px-3 py-1.5 disabled:opacity-40"
|
||||
>
|
||||
@@ -401,7 +401,7 @@ export default function PackPanel({
|
||||
onGenerate: (image: GenImage, kind: PackKind) => void;
|
||||
onGenerateAll: (image: GenImage) => void;
|
||||
onLockCharacter: (image: GenImage) => void;
|
||||
onGenerateVideo: (image: GenImage, prompt: string) => void;
|
||||
onGenerateVideo: (image: GenImage, template: typeof VIDEO_TEMPLATES[number]) => void;
|
||||
}) {
|
||||
const [activeNav, setActiveNav] = useState('pack-patent');
|
||||
const selectedImages = session.images.filter(image => image.status === 'selected');
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { GenImage } from './types';
|
||||
export type Provider = 'mock' | 'gpt';
|
||||
|
||||
export const GPT_TEXT_MODEL = process.env.GPT_TEXT_MODEL || 'gpt-5.5';
|
||||
export const GPT_IMAGE_MODEL = process.env.GPT_IMAGE_MODEL || 'image-2';
|
||||
export const GPT_IMAGE_MODEL = process.env.GPT_IMAGE_MODEL || 'gpt-image-2';
|
||||
const GPT_API_BASE = process.env.GPT_API_BASE || 'https://api.openai.com/v1';
|
||||
|
||||
export function detectProvider(): Provider {
|
||||
|
||||
@@ -179,9 +179,16 @@ export type LockCharacterResponse = {
|
||||
export type VideoGenerationRequest = {
|
||||
prompt: string;
|
||||
imageUrl?: string;
|
||||
references?: Array<{
|
||||
type: 'image_url' | 'video_url' | 'audio_url';
|
||||
url: string;
|
||||
role?: 'reference_image' | 'reference_video' | 'reference_audio';
|
||||
}>;
|
||||
duration?: number;
|
||||
ratio?: '16:9' | '9:16' | '1:1' | '4:3' | '3:4';
|
||||
resolution?: '720p' | '1080p';
|
||||
generateAudio?: boolean;
|
||||
watermark?: boolean;
|
||||
};
|
||||
|
||||
export type VideoGenerationResponse = {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { VideoGenerationRequest, VideoGenerationResponse } from './types';
|
||||
|
||||
export const SEEDANCE_MODEL = process.env.SEEDANCE_MODEL || 'seedance-1-0-pro';
|
||||
export const SEEDANCE_MODEL = process.env.SEEDANCE_MODEL || 'doubao-seedance-2-0-260128';
|
||||
const SEEDANCE_API_BASE = process.env.SEEDANCE_API_BASE || 'https://ark.cn-beijing.volces.com/api/v3';
|
||||
|
||||
function durationOrDefault(duration?: number): number {
|
||||
return Math.min(Math.max(duration ?? 6, 3), 10);
|
||||
return Math.min(Math.max(duration ?? 6, 3), 15);
|
||||
}
|
||||
|
||||
function normalizeStatus(status?: string): VideoGenerationResponse['status'] {
|
||||
@@ -14,15 +14,49 @@ function normalizeStatus(status?: string): VideoGenerationResponse['status'] {
|
||||
return 'submitted';
|
||||
}
|
||||
|
||||
function publicUrlOrUndefined(url?: string): string | undefined {
|
||||
if (!url) return undefined;
|
||||
if (!/^https?:\/\//i.test(url)) return undefined;
|
||||
if (/^https?:\/\/(localhost|127\.0\.0\.1|\[::1\])/i.test(url)) return undefined;
|
||||
return url;
|
||||
}
|
||||
|
||||
function buildContent(opts: VideoGenerationRequest): Array<Record<string, unknown>> {
|
||||
const content: Array<Record<string, unknown>> = [{ type: 'text', text: opts.prompt.trim() }];
|
||||
const refs = [...(opts.references ?? [])];
|
||||
if (opts.imageUrl) refs.unshift({ type: 'image_url', url: opts.imageUrl, role: 'reference_image' });
|
||||
|
||||
for (const ref of refs) {
|
||||
const url = publicUrlOrUndefined(ref.url);
|
||||
if (!url) continue;
|
||||
if (ref.type === 'image_url') {
|
||||
content.push({ type: 'image_url', image_url: { url }, role: ref.role ?? 'reference_image' });
|
||||
}
|
||||
if (ref.type === 'video_url') {
|
||||
content.push({ type: 'video_url', video_url: { url }, role: ref.role ?? 'reference_video' });
|
||||
}
|
||||
if (ref.type === 'audio_url') {
|
||||
content.push({ type: 'audio_url', audio_url: { url }, role: ref.role ?? 'reference_audio' });
|
||||
}
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
export async function generateSeedanceVideo(opts: VideoGenerationRequest): Promise<VideoGenerationResponse> {
|
||||
const key = process.env.SEEDANCE_API_KEY;
|
||||
if (!key) throw new Error('SEEDANCE_API_KEY missing');
|
||||
if (!opts.prompt?.trim()) throw new Error('prompt required');
|
||||
|
||||
const content: Array<Record<string, unknown>> = [{ type: 'text', text: opts.prompt.trim() }];
|
||||
if (opts.imageUrl) {
|
||||
content.push({ type: 'image_url', image_url: { url: opts.imageUrl } });
|
||||
}
|
||||
const content = buildContent(opts);
|
||||
const body: Record<string, unknown> = {
|
||||
model: SEEDANCE_MODEL,
|
||||
content,
|
||||
generate_audio: opts.generateAudio ?? true,
|
||||
ratio: opts.ratio || '16:9',
|
||||
duration: durationOrDefault(opts.duration),
|
||||
watermark: opts.watermark ?? false,
|
||||
};
|
||||
if (opts.resolution) body.resolution = opts.resolution;
|
||||
|
||||
const res = await fetch(`${SEEDANCE_API_BASE}/contents/generations/tasks`, {
|
||||
method: 'POST',
|
||||
@@ -30,13 +64,7 @@ export async function generateSeedanceVideo(opts: VideoGenerationRequest): Promi
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${key}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: SEEDANCE_MODEL,
|
||||
content,
|
||||
duration: durationOrDefault(opts.duration),
|
||||
ratio: opts.ratio || '16:9',
|
||||
resolution: opts.resolution || '1080p',
|
||||
}),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error(`Seedance ${res.status}: ${await res.text()}`);
|
||||
|
||||
Reference in New Issue
Block a user