Commit 175f406c authored by yaoke.yk's avatar yaoke.yk

项目编辑风格和三视图

parent 1e9c434e
...@@ -9,6 +9,14 @@ import { useTeamMembers } from "../../hooks/useTeam"; ...@@ -9,6 +9,14 @@ import { useTeamMembers } from "../../hooks/useTeam";
import { projectsApi } from "../../lib/api/projects"; import { projectsApi } from "../../lib/api/projects";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import type { ProjectDTO } from "../../lib/api/projects"; import type { ProjectDTO } from "../../lib/api/projects";
import {
aspectRatioOptions,
getAspectRatioLabel,
getProjectStyleLabel,
getResolutionLabel,
projectStyleOptions,
resolutionOptions,
} from "../../lib/projectStyles";
// ── Default cover generator ───────────────────────────────────────────────── // ── Default cover generator ─────────────────────────────────────────────────
function getProjectGradient(name: string): string { function getProjectGradient(name: string): string {
...@@ -48,6 +56,9 @@ function EditModal({ ...@@ -48,6 +56,9 @@ function EditModal({
const qc = useQueryClient(); const qc = useQueryClient();
const updateProject = useUpdateProject(project.id); const updateProject = useUpdateProject(project.id);
const [name, setName] = useState(project.name); const [name, setName] = useState(project.name);
const [style, setStyle] = useState(project.style ?? "");
const [aspectRatio, setAspectRatio] = useState(project.aspectRatio ?? "");
const [resolution, setResolution] = useState(project.resolution ?? "");
const [coverFile, setCoverFile] = useState<File | null>(null); const [coverFile, setCoverFile] = useState<File | null>(null);
const [coverPreview, setCoverPreview] = useState<string>(project.coverUrl ?? ""); const [coverPreview, setCoverPreview] = useState<string>(project.coverUrl ?? "");
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
...@@ -64,11 +75,12 @@ function EditModal({ ...@@ -64,11 +75,12 @@ function EditModal({
if (!name.trim()) return; if (!name.trim()) return;
setSaving(true); setSaving(true);
try { try {
// Update name if changed await updateProject.mutateAsync({
if (name.trim() !== project.name) { name: name.trim(),
await updateProject.mutateAsync({ name: name.trim() }); style: style || undefined,
} aspectRatio: aspectRatio || undefined,
// Upload new cover if selected resolution: resolution || undefined,
});
if (coverFile) { if (coverFile) {
await projectsApi.uploadAsset(project.id, coverFile, "cover"); await projectsApi.uploadAsset(project.id, coverFile, "cover");
qc.invalidateQueries({ queryKey: ["projects"] }); qc.invalidateQueries({ queryKey: ["projects"] });
...@@ -137,6 +149,54 @@ function EditModal({ ...@@ -137,6 +149,54 @@ function EditModal({
maxLength={200} maxLength={200}
/> />
</div> </div>
<div className="grid grid-cols-3 gap-3">
<div>
<label className="block text-xs text-muted-foreground mb-2">视觉风格</label>
<select
value={style}
onChange={(e) => setStyle(e.target.value)}
className="w-full px-3 py-2.5 rounded-lg border border-border bg-background text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary/20"
>
<option value="">未设置</option>
{projectStyleOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
<div>
<label className="block text-xs text-muted-foreground mb-2">画幅比例</label>
<select
value={aspectRatio}
onChange={(e) => setAspectRatio(e.target.value)}
className="w-full px-3 py-2.5 rounded-lg border border-border bg-background text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary/20"
>
<option value="">未设置</option>
{aspectRatioOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label} {option.helper ?? ""}
</option>
))}
</select>
</div>
<div>
<label className="block text-xs text-muted-foreground mb-2">清晰度</label>
<select
value={resolution}
onChange={(e) => setResolution(e.target.value)}
className="w-full px-3 py-2.5 rounded-lg border border-border bg-background text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary/20"
>
<option value="">未设置</option>
{resolutionOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label} {option.helper ?? ""}
</option>
))}
</select>
</div>
</div>
</div> </div>
{/* Footer */} {/* Footer */}
...@@ -249,7 +309,11 @@ export function Dashboard() { ...@@ -249,7 +309,11 @@ export function Dashboard() {
updatedAt: p.updatedAt?.slice(0, 10) ?? "", updatedAt: p.updatedAt?.slice(0, 10) ?? "",
createdAt: p.createdAt?.slice(0, 10) ?? "", createdAt: p.createdAt?.slice(0, 10) ?? "",
coverUrl: p.coverUrl, coverUrl: p.coverUrl,
tags: p.style ? [p.style] : [], tags: [
{ key: "style", label: getProjectStyleLabel(p.style) },
{ key: "aspectRatio", label: getAspectRatioLabel(p.aspectRatio) },
{ key: "resolution", label: getResolutionLabel(p.resolution) },
].filter((tag) => tag.label !== "未设置"),
})); }));
const filtered = projects.filter((p) => { const filtered = projects.filter((p) => {
...@@ -435,7 +499,9 @@ export function Dashboard() { ...@@ -435,7 +499,9 @@ export function Dashboard() {
{project.tags.length > 0 && ( {project.tags.length > 0 && (
<div className="flex flex-wrap gap-1.5 mb-3"> <div className="flex flex-wrap gap-1.5 mb-3">
{project.tags.slice(0, 3).map((tag) => ( {project.tags.slice(0, 3).map((tag) => (
<span key={tag} className="px-2 py-0.5 rounded bg-muted text-xs text-foreground">{tag}</span> <span key={tag.key} className="px-2 py-0.5 rounded bg-muted text-xs text-foreground">
{tag.label}
</span>
))} ))}
</div> </div>
)} )}
......
This diff is collapsed.
...@@ -271,6 +271,9 @@ function CharactersTab({ ...@@ -271,6 +271,9 @@ function CharactersTab({
onNavigateChars: () => void; onNavigateChars: () => void;
onNavigateScenes: () => void; onNavigateScenes: () => void;
}) { }) {
const getMainCharacterImage = (char: import("../../lib/api/ai").Character) =>
char.frontImageUrl ?? char.imageUrl ?? char.sideImageUrl ?? char.backImageUrl ?? null;
return ( return (
<div className="space-y-8"> <div className="space-y-8">
{/* Characters */} {/* Characters */}
...@@ -306,8 +309,8 @@ function CharactersTab({ ...@@ -306,8 +309,8 @@ function CharactersTab({
className="rounded-xl border border-border bg-card p-4 text-center cursor-pointer hover:border-primary transition-colors" className="rounded-xl border border-border bg-card p-4 text-center cursor-pointer hover:border-primary transition-colors"
> >
<div className="w-20 h-20 rounded-full overflow-hidden mx-auto mb-3 bg-muted flex items-center justify-center"> <div className="w-20 h-20 rounded-full overflow-hidden mx-auto mb-3 bg-muted flex items-center justify-center">
{char.imageUrl ? ( {getMainCharacterImage(char) ? (
<img src={char.imageUrl} alt={char.name} className="w-full h-full object-cover" /> <img src={getMainCharacterImage(char) ?? ""} alt={char.name} className="w-full h-full object-cover" />
) : ( ) : (
<ImageIcon className="w-8 h-8 text-muted-foreground" /> <ImageIcon className="w-8 h-8 text-muted-foreground" />
)} )}
......
...@@ -5,6 +5,14 @@ import { ...@@ -5,6 +5,14 @@ import {
} from "lucide-react"; } from "lucide-react";
import { useTeamMembers, useUpdateMember, useRemoveMember, useAddMember } from "../../hooks/useTeam"; import { useTeamMembers, useUpdateMember, useRemoveMember, useAddMember } from "../../hooks/useTeam";
import { useProject, useUpdateProject } from "../../hooks/useProjects"; import { useProject, useUpdateProject } from "../../hooks/useProjects";
import {
aspectRatioOptions,
getAspectRatioLabel,
getProjectStyleLabel,
getResolutionLabel,
projectStyleOptions,
resolutionOptions,
} from "../../lib/projectStyles";
const ROLES = ["owner", "admin", "member"] as const; const ROLES = ["owner", "admin", "member"] as const;
type Role = typeof ROLES[number]; type Role = typeof ROLES[number];
...@@ -37,10 +45,22 @@ export function ProjectSettings() { ...@@ -37,10 +45,22 @@ export function ProjectSettings() {
const [inviteError, setInviteError] = useState(""); const [inviteError, setInviteError] = useState("");
const [editingProject, setEditingProject] = useState(false); const [editingProject, setEditingProject] = useState(false);
const [projectForm, setProjectForm] = useState({ name: "", description: "" }); const [projectForm, setProjectForm] = useState({
name: "",
description: "",
style: "",
aspectRatio: "",
resolution: "",
});
const openEditProject = () => { const openEditProject = () => {
setProjectForm({ name: project?.name ?? "", description: project?.description ?? "" }); setProjectForm({
name: project?.name ?? "",
description: project?.description ?? "",
style: project?.style ?? "",
aspectRatio: project?.aspectRatio ?? "",
resolution: project?.resolution ?? "",
});
setEditingProject(true); setEditingProject(true);
}; };
......
import { useState, useEffect } from "react"; import { useState, useEffect, useMemo } from "react";
import { useParams, useNavigate } from "react-router"; import { useParams, useNavigate } from "react-router";
import { import {
Plus, Wand2, Play, Trash2, Copy, Plus, Wand2, Play, Trash2, Copy,
...@@ -69,6 +69,9 @@ export function StoryboardWorkspace() { ...@@ -69,6 +69,9 @@ export function StoryboardWorkspace() {
const [generatingVideoId, setGeneratingVideoId] = useState<string | null>(null); const [generatingVideoId, setGeneratingVideoId] = useState<string | null>(null);
const [batchGenerating, setBatchGenerating] = useState(false); const [batchGenerating, setBatchGenerating] = useState(false);
const getCharacterPrimaryImage = (character: Character) =>
character.frontImageUrl ?? character.imageUrl ?? character.sideImageUrl ?? character.backImageUrl ?? null;
// Reset selection when episode changes // Reset selection when episode changes
useEffect(() => { useEffect(() => {
setSelected(null); setSelected(null);
...@@ -171,11 +174,58 @@ export function StoryboardWorkspace() { ...@@ -171,11 +174,58 @@ export function StoryboardWorkspace() {
setPromptChanged(true); setPromptChanged(true);
}; };
// 所有有 imageTosKey 的角色/场景,按顺序编号为 图1, 图2... // 所有可引用的角色/场景参考图,角色支持正面/侧面/背面三视图。
const refImages: Array<{ label: string; name: string; imageUrl: string | null; imageTosKey: string }> = [ const refImages = useMemo<Array<{
...characters.filter((c) => c.imageTosKey).map((c) => ({ label: c.name, name: c.name, imageUrl: c.imageUrl, imageTosKey: c.imageTosKey! })), label: string;
...scenes.filter((s) => s.imageTosKey).map((s) => ({ label: s.name, name: s.name, imageUrl: s.imageUrl, imageTosKey: s.imageTosKey! })), name: string;
]; imageUrl: string | null;
imageTosKey: string;
kind: "character" | "scene";
viewLabel?: string;
}>>(() => {
const characterRefImages = characters.flatMap((character) => {
const views = [
{
viewLabel: "正面",
imageUrl: character.frontImageUrl ?? character.imageUrl ?? null,
imageTosKey: character.frontImageTosKey ?? character.imageTosKey ?? null,
},
{
viewLabel: "侧面",
imageUrl: character.sideImageUrl ?? null,
imageTosKey: character.sideImageTosKey ?? null,
},
{
viewLabel: "背面",
imageUrl: character.backImageUrl ?? null,
imageTosKey: character.backImageTosKey ?? null,
},
];
return views
.filter((view) => !!view.imageTosKey)
.map((view) => ({
label: `${character.name}·${view.viewLabel}`,
name: character.name,
imageUrl: view.imageUrl,
imageTosKey: view.imageTosKey!,
kind: "character" as const,
viewLabel: view.viewLabel,
}));
});
const sceneRefImages = scenes
.filter((scene) => !!scene.imageTosKey)
.map((scene) => ({
label: scene.name,
name: scene.name,
imageUrl: scene.imageUrl,
imageTosKey: scene.imageTosKey!,
kind: "scene" as const,
}));
return [...characterRefImages, ...sceneRefImages];
}, [characters, scenes]);
const insertMention = (name: string) => { const insertMention = (name: string) => {
const mention = `@${name}`; const mention = `@${name}`;
...@@ -459,7 +509,7 @@ export function StoryboardWorkspace() { ...@@ -459,7 +509,7 @@ export function StoryboardWorkspace() {
onClick={() => insertMention(c.name)} onClick={() => insertMention(c.name)}
className="flex items-center gap-1 px-2 py-0.5 rounded-full bg-violet-50 dark:bg-violet-900/20 border border-violet-200 dark:border-violet-700 text-[11px] text-violet-700 dark:text-violet-300 hover:bg-violet-100 transition" className="flex items-center gap-1 px-2 py-0.5 rounded-full bg-violet-50 dark:bg-violet-900/20 border border-violet-200 dark:border-violet-700 text-[11px] text-violet-700 dark:text-violet-300 hover:bg-violet-100 transition"
> >
{c.imageUrl && <img src={c.imageUrl} className="w-3.5 h-3.5 rounded-full object-cover" />} {getCharacterPrimaryImage(c) && <img src={getCharacterPrimaryImage(c) ?? ""} className="w-3.5 h-3.5 rounded-full object-cover" />}
@{c.name} @{c.name}
</button> </button>
))} ))}
...@@ -480,7 +530,9 @@ export function StoryboardWorkspace() { ...@@ -480,7 +530,9 @@ export function StoryboardWorkspace() {
{/* @图N:参考图面板 */} {/* @图N:参考图面板 */}
{refImages.length > 0 && ( {refImages.length > 0 && (
<div className="mt-3"> <div className="mt-3">
<div className="text-[11px] text-muted-foreground mb-1.5">@图N 引用参考图(生成视频时跳过文生图,直接用此图)</div> <div className="text-[11px] text-muted-foreground mb-1.5">
@图N 引用参考图。角色支持正面、侧面、背面三视图,生成视频时会直接使用所选参考图。
</div>
<div className="flex gap-2 flex-wrap"> <div className="flex gap-2 flex-wrap">
{refImages.map((img, idx) => ( {refImages.map((img, idx) => (
<button <button
...@@ -488,18 +540,25 @@ export function StoryboardWorkspace() { ...@@ -488,18 +540,25 @@ export function StoryboardWorkspace() {
onClick={() => insertRefImage(idx)} onClick={() => insertRefImage(idx)}
className="flex flex-col items-center gap-0.5 group" className="flex flex-col items-center gap-0.5 group"
title={`点击插入 @图${idx + 1}(${img.label})`} title={`点击插入 @图${idx + 1}(${img.label})`}
> >
<div className="w-12 h-12 rounded-lg overflow-hidden border border-border group-hover:border-primary transition bg-muted"> <div className="w-12 h-12 rounded-lg overflow-hidden border border-border group-hover:border-primary transition bg-muted">
{img.imageUrl ? ( {img.imageUrl ? (
<img src={img.imageUrl} alt={img.label} className="w-full h-full object-cover" /> <img src={img.imageUrl} alt={img.label} className="w-full h-full object-cover" />
) : ( ) : (
<div className="w-full h-full flex items-center justify-center text-muted-foreground text-[10px]">无图</div> <div className="w-full h-full flex items-center justify-center text-muted-foreground text-[10px]">无图</div>
)} )}
</div> </div>
<span className="text-[10px] text-primary font-medium">@图{idx + 1}</span> <span className="text-[10px] text-primary font-medium">@图{idx + 1}</span>
<span className="text-[9px] text-muted-foreground leading-none max-w-[48px] truncate">{img.label}</span> <span className="text-[9px] text-muted-foreground leading-none max-w-[56px] truncate">{img.label}</span>
</button> <span
))} className={`text-[9px] leading-none ${
img.kind === "character" ? "text-violet-600" : "text-sky-600"
}`}
>
{img.kind === "character" ? img.viewLabel : "场景"}
</span>
</button>
))}
</div> </div>
</div> </div>
)} )}
......
import { useState } from "react"; import { useState } from "react";
import { useNavigate, useParams } from "react-router"; import { useNavigate, useParams } from "react-router";
import { Check, ArrowRight, Loader2 } from "lucide-react"; import { ArrowRight, Check, Loader2 } from "lucide-react";
import { useUpdateProject } from "../../hooks/useProjects"; import { useUpdateProject } from "../../hooks/useProjects";
import { projectStyleOptions } from "../../lib/projectStyles";
const styles = [
{
id: "urban",
name: "都市情感",
description: "现代都市背景,情感纠葛,职场爱情",
image: "https://images.unsplash.com/photo-1449824913935-59a10b8d2000?w=400&h=300&fit=crop",
},
{
id: "fantasy",
name: "玄幻修仙",
description: "仙侠世界观,修炼升级,恢弘场景",
image: "https://images.unsplash.com/photo-1518709268805-4e9042af9f23?w=400&h=300&fit=crop",
},
{
id: "romance",
name: "甜宠恋爱",
description: "校园青春,浪漫爱情,轻松甜蜜",
image: "https://images.unsplash.com/photo-1522529599102-193c0d76b5b6?w=400&h=300&fit=crop",
},
{
id: "historical",
name: "古装宫廷",
description: "古代背景,宫廷权谋,历史传奇",
image: "https://images.unsplash.com/photo-1548198264-8fc644a0c8f1?w=400&h=300&fit=crop",
},
{
id: "suspense",
name: "悬疑推理",
description: "烧脑剧情,逻辑推理,紧张刺激",
image: "https://images.unsplash.com/photo-1516450360452-9312f5e86fc7?w=400&h=300&fit=crop",
},
{
id: "comedy",
name: "轻喜剧",
description: "幽默搞笑,轻松愉快,生活趣事",
image: "https://images.unsplash.com/photo-1511632765486-a01980e01a18?w=400&h=300&fit=crop",
},
];
export function StyleSelection() { export function StyleSelection() {
const navigate = useNavigate(); const navigate = useNavigate();
...@@ -56,58 +18,57 @@ export function StyleSelection() { ...@@ -56,58 +18,57 @@ export function StyleSelection() {
return ( return (
<div className="h-full overflow-auto bg-background p-6"> <div className="h-full overflow-auto bg-background p-6">
<div className="max-w-6xl mx-auto"> <div className="mx-auto max-w-6xl">
{/* Header */}
<div className="mb-8 text-center"> <div className="mb-8 text-center">
<h1 className="text-3xl font-semibold text-foreground mb-2">选择剧集风格</h1> <h1 className="mb-2 text-3xl font-semibold text-foreground">选择项目视觉风格</h1>
<p className="text-sm text-muted-foreground">选择一个风格,AI将根据风格生成相应的大纲和场景</p> <p className="text-sm text-muted-foreground">
这里选择的是视觉呈现方式,不是题材类型。AI 会根据所选风格影响人物、场景与后续图像生成。
</p>
</div> </div>
{/* Style Grid */} <div className="mb-8 grid grid-cols-3 gap-4">
<div className="grid grid-cols-3 gap-4 mb-8"> {projectStyleOptions.map((style) => (
{styles.map((style) => (
<button <button
key={style.id} key={style.value}
onClick={() => setSelectedStyle(style.id)} onClick={() => setSelectedStyle(style.value)}
className={`group relative rounded-xl overflow-hidden transition-all border ${ className={`group relative overflow-hidden rounded-xl border transition-all ${
selectedStyle === style.id selectedStyle === style.value
? "border-primary shadow-md" ? "border-primary shadow-md"
: "border-border hover:border-primary/50" : "border-border hover:border-primary/50"
}`} }`}
> >
<div className="aspect-[4/3] relative overflow-hidden"> <div className="relative aspect-[4/3] overflow-hidden">
<img <img
src={style.image} src={style.image}
alt={style.name} alt={style.label}
className="w-full h-full object-cover group-hover:scale-105 transition-transform" className="h-full w-full object-cover transition-transform group-hover:scale-105"
/> />
{selectedStyle === style.id && ( {selectedStyle === style.value && (
<div className="absolute top-3 right-3 w-6 h-6 rounded-full bg-primary flex items-center justify-center"> <div className="absolute right-3 top-3 flex h-6 w-6 items-center justify-center rounded-full bg-primary">
<Check className="w-4 h-4 text-white" /> <Check className="h-4 w-4 text-white" />
</div> </div>
)} )}
</div> </div>
<div className="p-4 bg-card"> <div className="bg-card p-4 text-left">
<h3 className="font-medium text-foreground mb-1">{style.name}</h3> <h3 className="mb-1 font-medium text-foreground">{style.label}</h3>
<p className="text-sm text-muted-foreground">{style.description}</p> <p className="text-sm text-muted-foreground">{style.description}</p>
</div> </div>
</button> </button>
))} ))}
</div> </div>
{/* Continue Button */}
<button <button
onClick={handleContinue} onClick={handleContinue}
disabled={!selectedStyle || updateProject.isPending} disabled={!selectedStyle || updateProject.isPending}
className="w-full py-3 rounded-xl bg-gradient-to-r from-[#5b5ff9] to-[#8b5ff9] text-white flex items-center justify-center gap-2 hover:shadow-md transition-all disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:shadow-none" className="flex w-full items-center justify-center gap-2 rounded-xl bg-gradient-to-r from-[#5b5ff9] to-[#8b5ff9] py-3 text-white transition-all hover:shadow-md disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:shadow-none"
> >
{updateProject.isPending ? ( {updateProject.isPending ? (
<Loader2 className="w-5 h-5 animate-spin" /> <Loader2 className="h-5 w-5 animate-spin" />
) : ( ) : (
<> <>
<span>继续</span> <span>继续</span>
<ArrowRight className="w-5 h-5" /> <ArrowRight className="h-5 w-5" />
</> </>
)} )}
</button> </button>
......
...@@ -22,7 +22,11 @@ export function useGenerateCharacterImage(projectId: string) { ...@@ -22,7 +22,11 @@ export function useGenerateCharacterImage(projectId: string) {
} }
export function useUploadCharacterImage(projectId: string) { export function useUploadCharacterImage(projectId: string) {
const qc = useQueryClient(); const qc = useQueryClient();
return useMutation({ mutationFn: ({ id, file }: { id: string; file: File }) => aiApi.uploadCharacterImage(projectId, id, file), onSuccess: () => qc.invalidateQueries({ queryKey: charactersKey(projectId) }) }); return useMutation({
mutationFn: ({ id, file, viewType }: { id: string; file: File; viewType: "front" | "side" | "back" }) =>
aiApi.uploadCharacterImage(projectId, id, file, viewType),
onSuccess: () => qc.invalidateQueries({ queryKey: charactersKey(projectId) }),
});
} }
export function useDeleteCharacter(projectId: string) { export function useDeleteCharacter(projectId: string) {
const qc = useQueryClient(); const qc = useQueryClient();
......
...@@ -56,6 +56,12 @@ export interface Character { ...@@ -56,6 +56,12 @@ export interface Character {
imagePrompt: string; imagePrompt: string;
imageUrl: string | null; imageUrl: string | null;
imageTosKey: string | null; imageTosKey: string | null;
frontImageUrl?: string | null;
frontImageTosKey?: string | null;
sideImageUrl?: string | null;
sideImageTosKey?: string | null;
backImageUrl?: string | null;
backImageTosKey?: string | null;
status: string; status: string;
createdAt: string; createdAt: string;
} }
...@@ -160,13 +166,26 @@ export const aiApi = { ...@@ -160,13 +166,26 @@ export const aiApi = {
return r.data.data; return r.data.data;
}, },
generateCharacterImage: async (projectId: string, characterId: string): Promise<Character> => { generateCharacterImage: async (projectId: string, characterId: string): Promise<Character> => {
const r = await apiClient.post(`/projects/${projectId}/characters/${characterId}/generate-image`); const r = await apiClient.post(
`/projects/${projectId}/characters/${characterId}/generate-image`,
undefined,
{ timeout: 180_000 }
);
return r.data.data; return r.data.data;
}, },
uploadCharacterImage: async (projectId: string, characterId: string, file: File): Promise<Character> => { uploadCharacterImage: async (
projectId: string,
characterId: string,
file: File,
viewType: "front" | "side" | "back" = "front"
): Promise<Character> => {
const form = new FormData(); const form = new FormData();
form.append("file", file); form.append("file", file);
const r = await apiClient.post(`/projects/${projectId}/characters/${characterId}/upload-image`, form, { headers: { "Content-Type": undefined } }); const r = await apiClient.post(
`/projects/${projectId}/characters/${characterId}/upload-image?viewType=${viewType}`,
form,
{ headers: { "Content-Type": undefined } }
);
return r.data.data; return r.data.data;
}, },
deleteCharacter: async (projectId: string, characterId: string): Promise<void> => { deleteCharacter: async (projectId: string, characterId: string): Promise<void> => {
...@@ -187,7 +206,11 @@ export const aiApi = { ...@@ -187,7 +206,11 @@ export const aiApi = {
return r.data.data; return r.data.data;
}, },
generateSceneImage: async (projectId: string, sceneId: string): Promise<Scene> => { generateSceneImage: async (projectId: string, sceneId: string): Promise<Scene> => {
const r = await apiClient.post(`/projects/${projectId}/scenes/${sceneId}/generate-image`); const r = await apiClient.post(
`/projects/${projectId}/scenes/${sceneId}/generate-image`,
undefined,
{ timeout: 120_000 }
);
return r.data.data; return r.data.data;
}, },
uploadSceneImage: async (projectId: string, sceneId: string, file: File): Promise<Scene> => { uploadSceneImage: async (projectId: string, sceneId: string, file: File): Promise<Scene> => {
......
...@@ -3,12 +3,17 @@ import { apiClient } from "./client"; ...@@ -3,12 +3,17 @@ import { apiClient } from "./client";
export interface ProjectCreatePayload { export interface ProjectCreatePayload {
name: string; name: string;
description?: string; description?: string;
style?: string;
aspectRatio?: string;
resolution?: string;
} }
export interface ProjectUpdatePayload { export interface ProjectUpdatePayload {
name?: string; name?: string;
description?: string; description?: string;
style?: string; style?: string;
aspectRatio?: string;
resolution?: string;
} }
export interface ProjectDTO { export interface ProjectDTO {
...@@ -17,6 +22,8 @@ export interface ProjectDTO { ...@@ -17,6 +22,8 @@ export interface ProjectDTO {
description?: string; description?: string;
status: "draft" | "processing" | "completed" | "failed"; status: "draft" | "processing" | "completed" | "failed";
style?: string; style?: string;
aspectRatio?: string;
resolution?: string;
coverUrl?: string; coverUrl?: string;
assetCount: number; assetCount: number;
createdAt: string; createdAt: string;
......
export interface SelectOption {
value: string;
label: string;
helper?: string;
}
export interface ProjectStyleOption {
value: string;
label: string;
description: string;
image: string;
}
export const aspectRatioOptions: SelectOption[] = [
{ value: "16:9", label: "16:9", helper: "横屏" },
{ value: "9:16", label: "9:16", helper: "竖屏" },
{ value: "1:1", label: "1:1", helper: "方屏" },
];
export const resolutionOptions: SelectOption[] = [
{ value: "720p", label: "720p", helper: "标清" },
{ value: "1080p", label: "1080p", helper: "高清" },
{ value: "4K", label: "4K", helper: "超清" },
];
export const projectStyleOptions: ProjectStyleOption[] = [
{
value: "live_action_drama",
label: "真人短剧视觉",
description: "真人演员质感,贴近短剧成片观感,服装与人物比例更写实。",
image: "https://images.unsplash.com/photo-1515886657613-9f3515b0c78f?w=400&h=300&fit=crop",
},
{
value: "japanese_anime",
label: "2D日漫",
description: "日系动画角色设计,线条干净,赛璐璐上色,角色辨识度高。",
image: "https://images.unsplash.com/photo-1519608487953-e999c86e7455?w=400&h=300&fit=crop",
},
{
value: "korean_webtoon",
label: "2D韩漫都市",
description: "韩漫都市感,时装感更强,光影柔和,适合情感与都市题材。",
image: "https://images.unsplash.com/photo-1480714378408-67cf0d13bc1b?w=400&h=300&fit=crop",
},
{
value: "chinese_anime",
label: "2D国漫",
description: "国漫人物设定,造型更利落,兼顾东方气质与动画表现力。",
image: "https://images.unsplash.com/photo-1514539079130-25950c84af65?w=400&h=300&fit=crop",
},
{
value: "chinese_fantasy_3d",
label: "3D国风仙侠",
description: "三维国风与仙侠质感,服饰层次更丰富,适合奇幻与古风项目。",
image: "https://images.unsplash.com/photo-1506744038136-46273834b3fb?w=400&h=300&fit=crop",
},
{
value: "cg_cinematic",
label: "CG电影感",
description: "高质感 CG 角色设定,偏电影海报与游戏宣传级别的视觉效果。",
image: "https://images.unsplash.com/photo-1516035069371-29a1b244cc32?w=400&h=300&fit=crop",
},
];
const projectStyleLabelMap = new Map(projectStyleOptions.map((option) => [option.value, option.label]));
const aspectRatioLabelMap = new Map(
aspectRatioOptions.map((option) => [option.value, option.helper ? `${option.label} ${option.helper}` : option.label])
);
const resolutionLabelMap = new Map(
resolutionOptions.map((option) => [option.value, option.helper ? `${option.label} ${option.helper}` : option.label])
);
export function getProjectStyleLabel(style?: string | null): string {
if (!style) return "未设置";
return projectStyleLabelMap.get(style) ?? style;
}
export function getAspectRatioLabel(aspectRatio?: string | null): string {
if (!aspectRatio) return "未设置";
return aspectRatioLabelMap.get(aspectRatio) ?? aspectRatio;
}
export function getResolutionLabel(resolution?: string | null): string {
if (!resolution) return "未设置";
return resolutionLabelMap.get(resolution) ?? resolution;
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment