又推进图片故事

This commit is contained in:
海龙 2025-08-18 18:58:15 +08:00
parent 76169e790d
commit 4904c29ce6
8 changed files with 555 additions and 596 deletions

View File

@ -19,7 +19,7 @@ export interface CharacterAnalysis {
/** 角色名称 */
role_name: string;
/** 角色区域 */
region: CharacterRegion;
region: CharacterRegion|null;
/** 角色头像URL可选用于存储裁剪后的头像 */
avatarUrl?: string;
}

View File

@ -27,7 +27,7 @@ export const actionTemplateStory = async (template: StoryTemplateEntity) => {
* AI分析图片
*/
export const AIGenerateImageStory = async (request: {
imageUrl: string;
image_url: string;
user_text: string;
}) => {
return await post<ApiResponse<MovieStartDTO>>(

View File

@ -145,4 +145,33 @@ body {
outline: none !important;
outline-offset: 0 !important;
box-shadow: none !important;
}
/* Tiptap 编辑器焦点样式覆盖 */
.ProseMirror:focus,
.ProseMirror:focus-visible,
.ProseMirror-focused {
outline: none !important;
border: none !important;
box-shadow: none !important;
}
.ProseMirror {
outline: none !important;
border: none !important;
box-shadow: none !important;
}
/* 确保编辑器内容区域没有默认样式 */
.ProseMirror p {
margin: 0;
padding: 0;
}
.ProseMirror p.is-editor-empty:first-child::before {
color: rgba(255, 255, 255, 0.4);
content: attr(data-placeholder);
float: left;
height: 0;
pointer-events: none;
}

View File

@ -1,7 +1,7 @@
import { ImageStoryEntity } from "../domain/Entities";
import { useUploadFile } from "../domain/service";
import { ImageStoryUseCase } from "../usecase/imageStoryUseCase";
import { useState, useCallback, useMemo } from "react";
import { useState, useCallback, useMemo, Dispatch, SetStateAction } from "react";
import { CharacterAnalysis } from "@/api/DTO/movie_start_dto";
interface UseImageStoryService {
@ -24,9 +24,9 @@ interface UseImageStoryService {
/** 计算后的角色头像数据 */
avatarComputed: Array<{ name: string; url: string }>;
/** 上传图片并分析 */
uploadAndAnalyzeImage: (imageUrl: string) => Promise<void>;
/** 触发文件选择并自动分析 */
triggerFileSelectionAndAnalyze: () => Promise<void>;
uploadAndAnalyzeImage: () => Promise<void>;
/** 触发文件选择 */
triggerFileSelection: () => Promise<void>;
/** 触发生成剧本函数 */
generateScript: () => Promise<string>;
/** 更新故事类型 */
@ -35,10 +35,11 @@ interface UseImageStoryService {
updateStoryContent: (content: string) => void;
/** 更新角色名称并同步到相关数据 */
updateCharacterName: (oldName: string, newName: string) => void;
/** 同步角色名称到故事内容 */
syncRoleNameToContent: (oldName: string, newName: string) => void;
/** 重置图片故事数据 */
resetImageStory: () => void;
/** 完全重置到初始状态(包括预置数据) */
resetToInitialState: () => void;
resetImageStory: (showAnalysisState?: boolean) => void;
setCharactersAnalysis: Dispatch<SetStateAction<CharacterAnalysis[]>>
}
export const useImageStoryServiceHook = (): UseImageStoryService => {
@ -49,62 +50,25 @@ export const useImageStoryServiceHook = (): UseImageStoryService => {
});
// 图片相关状态
const [activeImageUrl, setActiveImageUrl] = useState<string>("https://cdn.qikongjian.com/1755316261_gmw4yq.mp4?vframe/jpg/offset/1");
const [activeImageUrl, setActiveImageUrl] = useState<string>("");
// 故事内容状态统一管理用户输入和AI分析结果预置假数据
const [storyContent, setStoryContent] = useState<string>(
"在一个遥远的未来城市,机器人与人类共存,但一场突如其来的危机打破了平衡。艾米丽,阿尔法,博士互相帮助,共同解决危机。"
);
// 故事内容状态统一管理用户输入和AI分析结果
const [storyContent, setStoryContent] = useState<string>("");
// 分析结果状态
/** 角色头像及名称,预置假数据 */
/** 角色头像及名称 */
const [charactersAnalysis, setCharactersAnalysis] = useState<
CharacterAnalysis[]
>([
{
role_name: "艾米丽",
region: {
x: 0.2,
y: 0.2,
width: 0.2,
height: 0.2,
},
id: "1"
},
{
role_name: "阿尔法",
region: {
x: 0.4,
y: 0.4,
width: 0.2,
height: 0.2,
},
id: "2"
},
{
role_name: "博士",
region: {
x: 0.6,
y: 0.6,
width: 0.2,
height: 0.2,
},
id: "3"
},
]);
/** 分类数组,预置假数据 */
const [potentialGenres, setPotentialGenres] = useState<string[]>([
"科幻",
"冒险",
"悬疑",
]);
>([]);
/** 分类数组 */
const [potentialGenres, setPotentialGenres] = useState<string[]>([]);
// 分类状态
const [selectedCategory, setSelectedCategory] = useState<string>("Auto");
// 流程状态
const [isLoading, setIsLoading] = useState(false);
const [hasAnalyzed, setHasAnalyzed] = useState(true);
const [hasAnalyzed, setHasAnalyzed] = useState(false);
// 使用上传文件Hook
const { uploadFile } = useUploadFile();
@ -112,93 +76,111 @@ export const useImageStoryServiceHook = (): UseImageStoryService => {
/** 图片故事用例实例 */
const imageStoryUseCase = useMemo(() => new ImageStoryUseCase(), []);
/**
/**
* URL
* @param character -
* @param imageUrl - URL
*/
const generateAvatarFromRegion = useCallback((character: CharacterAnalysis, imageUrl: string) => {
// 创建图片对象
const img = new Image();
img.crossOrigin = 'anonymous'; // 处理跨域问题
img.onload = () => {
try {
// 根据百分比计算实际的像素坐标
const cropX = Math.round(character.region.x * img.width);
const cropY = Math.round(character.region.y * img.height);
const cropWidth = Math.round(character.region.width * img.width);
const cropHeight = Math.round(character.region.height * img.height);
console.log(cropX, cropY, cropWidth, cropHeight);
// 验证裁剪区域是否有效
if (cropWidth <= 0 || cropHeight <= 0) {
console.error('裁剪区域无效:', { cropWidth, cropHeight });
return;
}
if (cropX + cropWidth > img.width || cropY + cropHeight > img.height) {
console.error('裁剪区域超出图片边界');
return;
}
// 创建canvas元素用于图片裁剪
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (!ctx) {
console.error('无法创建canvas上下文');
return;
}
// 设置canvas尺寸为裁剪后的尺寸
canvas.width = cropWidth;
canvas.height = cropHeight;
// 清除canvas内容
ctx.clearRect(0, 0, cropWidth, cropHeight);
// 在canvas上绘制裁剪后的图片部分
ctx.drawImage(
img,
cropX, cropY, cropWidth, cropHeight, // 源图片裁剪区域
0, 0, cropWidth, cropHeight // 目标canvas区域
);
// 将canvas转换为blob并创建临时URL
canvas.toBlob((blob) => {
if (blob) {
const url = URL.createObjectURL(blob);
console.log('成功生成头像URL:', url, '大小:', blob.size);
// 更新角色头像URL
setCharactersAnalysis(prev =>
prev.map(char =>
char.role_name === character.role_name
? { ...char, avatarUrl: url }
: char
)
);
} else {
console.error('Canvas转Blob失败');
}
}, 'image/jpeg', 0.9);
// 清理canvas
canvas.remove();
} catch (error) {
console.error('生成角色头像失败:', error);
const generateAvatarFromRegion = useCallback(
(character: CharacterAnalysis, imageUrl: string) => {
if (!character.region || !character.region.width || !character.region.height) {
return;
}
};
// 创建图片对象
const img = new Image();
img.crossOrigin = "anonymous"; // 处理跨域问题
img.onerror = () => {
console.error('加载图片失败:', imageUrl);
};
img.onload = () => {
try {
// 根据百分比计算实际的像素坐标
const cropX = Math.round(character.region!.x * img.width);
const cropY = Math.round(character.region!.y * img.height);
const cropWidth = Math.round(character.region!.width * img.width);
const cropHeight = Math.round(character.region!.height * img.height);
console.log(cropX, cropY, cropWidth, cropHeight);
// 开始加载图片
img.src = imageUrl;
}, [setCharactersAnalysis]);
// 验证裁剪区域是否有效
if (cropWidth <= 0 || cropHeight <= 0) {
console.error("裁剪区域无效:", { cropWidth, cropHeight });
return;
}
if (
cropX + cropWidth > img.width ||
cropY + cropHeight > img.height
) {
console.error("裁剪区域超出图片边界");
return;
}
// 创建canvas元素用于图片裁剪
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
if (!ctx) {
console.error("无法创建canvas上下文");
return;
}
// 设置canvas尺寸为裁剪后的尺寸
canvas.width = cropWidth;
canvas.height = cropHeight;
// 清除canvas内容
ctx.clearRect(0, 0, cropWidth, cropHeight);
// 在canvas上绘制裁剪后的图片部分
ctx.drawImage(
img,
cropX,
cropY,
cropWidth,
cropHeight, // 源图片裁剪区域
0,
0,
cropWidth,
cropHeight // 目标canvas区域
);
// 将canvas转换为blob并创建临时URL
canvas.toBlob(
(blob) => {
if (blob) {
const url = URL.createObjectURL(blob);
console.log("成功生成头像URL:", url, "大小:", blob.size);
// 更新角色头像URL
setCharactersAnalysis((prev) =>
prev.map((char) =>
char.role_name === character.role_name
? { ...char, avatarUrl: url }
: char
)
);
} else {
console.error("Canvas转Blob失败");
}
},
"image/jpeg",
0.9
);
// 清理canvas
canvas.remove();
} catch (error) {
console.error("生成角色头像失败:", error);
}
};
img.onerror = () => {
console.error("加载图片失败:", imageUrl);
};
// 开始加载图片
img.src = imageUrl;
},
[setCharactersAnalysis]
);
/**
* URL
@ -223,7 +205,7 @@ export const useImageStoryServiceHook = (): UseImageStoryService => {
return {
name: character.role_name,
url: '', // 初始为空,异步生成完成后会更新
url: "", // 初始为空,异步生成完成后会更新
};
});
}, [charactersAnalysis, activeImageUrl, generateAvatarFromRegion]);
@ -232,18 +214,19 @@ export const useImageStoryServiceHook = (): UseImageStoryService => {
* @param {string} imageUrl - URL
*/
const uploadAndAnalyzeImage = useCallback(
async (imageUrl: string): Promise<void> => {
async (): Promise<void> => {
try {
console.log('123123123', 123123123)
setIsLoading(true);
// 调用用例处理图片上传和分析
await imageStoryUseCase.handleImageUpload(imageUrl);
await imageStoryUseCase.handleImageUpload(activeImageUrl);
// 获取更新后的数据
const updatedStory = imageStoryUseCase.getStoryLogline();
const updatedCharacters = imageStoryUseCase.getCharactersAnalysis();
const updatedGenres = imageStoryUseCase.getPotentialGenres();
const updatedImageStory = imageStoryUseCase.getImageStory();
const updatedStory = imageStoryUseCase.storyLogline;
const updatedCharacters = imageStoryUseCase.charactersAnalysis;
const updatedGenres = imageStoryUseCase.potentialGenres;
const updatedImageStory = imageStoryUseCase.imageStory;
// 更新所有响应式状态
setCharactersAnalysis(updatedCharacters);
@ -251,12 +234,8 @@ export const useImageStoryServiceHook = (): UseImageStoryService => {
setImageStory(updatedImageStory);
// 将AI分析的故事内容直接更新到统一的故事内容字段
setStoryContent(updatedStory || "");
// 设置第一个分类为默认选中
if (updatedGenres.length > 0) {
setSelectedCategory(updatedGenres[0]);
}
updateStoryContent(updatedStory || "");
setSelectedCategory("Auto");
// 标记已分析
setHasAnalyzed(true);
@ -267,7 +246,7 @@ export const useImageStoryServiceHook = (): UseImageStoryService => {
setIsLoading(false);
}
},
[imageStoryUseCase]
[activeImageUrl, imageStoryUseCase]
);
/**
@ -323,30 +302,47 @@ export const useImageStoryServiceHook = (): UseImageStoryService => {
*/
const updateStoryContent = useCallback((content: string): void => {
setStoryContent(content);
}, []);
imageStoryUseCase.updateStoryContent(content);
}, [imageStoryUseCase]);
/**
*
* @param {string} oldName -
* @param {string} newName -
*/
const syncRoleNameToContent = useCallback(
(oldName: string, newName: string) => {
// 更新故事内容中的角色标签
setStoryContent((prev) => {
const regex = new RegExp(`<role_name>${oldName}<\/role_name>`, "g");
const content = prev.replace(regex, `<role_name>${newName}</role_name>`);
imageStoryUseCase.updateStoryContent(content);
return content;
});
},
[imageStoryUseCase]
);
/**
*
* @param {string} oldName -
* @param {string} newName -
*/
const updateCharacterName = useCallback((oldName: string, newName: string): void => {
// 更新角色分析数据中的名称
setCharactersAnalysis(prev =>
prev.map(char =>
char.role_name === oldName
? { ...char, role_name: newName }
: char
)
);
const updateCharacterName = useCallback(
(oldName: string, newName: string): void => {
// 更新角色分析数据中的名称
setCharactersAnalysis((prev) =>
prev.map((char) =>
char.role_name === oldName ? { ...char, role_name: newName } : char
)
);
// 同步更新故事内容中的角色名称
setStoryContent(prev => {
// 使用正则表达式进行全局替换,确保大小写匹配
const regex = new RegExp(`\\b${oldName}\\b`, 'g');
return prev.replace(regex, newName);
});
}, []);
// 同步更新故事内容中的角色名称
syncRoleNameToContent(oldName, newName);
},
[syncRoleNameToContent]
);
/**
*
@ -355,8 +351,8 @@ export const useImageStoryServiceHook = (): UseImageStoryService => {
imageStoryUseCase.resetImageStory();
// 清理生成的头像URL避免内存泄漏
setCharactersAnalysis(prev => {
prev.forEach(char => {
setCharactersAnalysis((prev) => {
prev.forEach((char) => {
if (char.avatarUrl) {
URL.revokeObjectURL(char.avatarUrl);
}
@ -370,76 +366,17 @@ export const useImageStoryServiceHook = (): UseImageStoryService => {
storyType: "auto",
});
setActiveImageUrl("");
setStoryContent("");
updateStoryContent("");
setPotentialGenres([]);
setSelectedCategory("auto");
setHasAnalyzed(false);
setIsLoading(false);
}, [imageStoryUseCase]);
/**
*
*/
const resetToInitialState = useCallback((): void => {
// 清理生成的头像URL避免内存泄漏
setCharactersAnalysis(prev => {
prev.forEach(char => {
if (char.avatarUrl) {
URL.revokeObjectURL(char.avatarUrl);
}
});
return [];
});
// 重置所有状态到初始值
setImageStory({
imageUrl: "",
storyType: "auto",
});
setActiveImageUrl("https://cdn.qikongjian.com/1755316261_gmw4yq.mp4?vframe/jpg/offset/1");
setStoryContent("在一个遥远的未来城市,机器人与人类共存,但一场突如其来的危机打破了平衡。艾米丽,阿尔法,博士互相帮助,共同解决危机。");
setCharactersAnalysis([
{
role_name: "艾米丽",
region: {
x: 20,
y: 20,
width: 20,
height: 20,
},
id: "1"
},
{
role_name: "阿尔法",
region: {
x: 40,
y: 40,
width: 20,
height: 20,
},
id: "2"
},
{
role_name: "博士",
region: {
x: 60,
y: 60,
width: 20,
height: 20,
},
id: "3"
},
]);
setPotentialGenres(["科幻", "冒险", "悬疑"]);
setSelectedCategory("auto");
setHasAnalyzed(true);
setIsLoading(false);
}, []);
/**
*
*/
const triggerFileSelectionAndAnalyze =
const triggerFileSelection =
useCallback(async (): Promise<void> => {
return new Promise((resolve, reject) => {
// 创建文件输入元素
@ -469,8 +406,6 @@ export const useImageStoryServiceHook = (): UseImageStoryService => {
imageUrl: uploadedImageUrl,
}));
// 自动开始分析
await uploadAndAnalyzeImage(uploadedImageUrl);
}
resolve();
} catch (error) {
@ -490,7 +425,7 @@ export const useImageStoryServiceHook = (): UseImageStoryService => {
document.body.appendChild(fileInput);
fileInput.click();
});
}, [uploadFile, uploadAndAnalyzeImage]);
}, [uploadFile]);
return {
imageStory,
@ -502,13 +437,14 @@ export const useImageStoryServiceHook = (): UseImageStoryService => {
isLoading,
hasAnalyzed,
avatarComputed,
setCharactersAnalysis,
uploadAndAnalyzeImage,
triggerFileSelectionAndAnalyze,
triggerFileSelection,
generateScript,
updateStoryType,
updateStoryContent,
updateCharacterName,
syncRoleNameToContent,
resetImageStory,
resetToInitialState,
};
};

View File

@ -112,7 +112,7 @@ export interface ImageStoryEntity {
width: number;
/** 高度 */
height: number;
};
} | null;
}[];
}
/**

View File

@ -11,7 +11,7 @@ export class ImageStoryUseCase {
imageStory: Partial<ImageStoryEntity> = {
imageUrl: "",
imageStory: "",
storyType: "auto",
storyType: "Auto",
};
/** 故事梗概 */
@ -31,53 +31,7 @@ export class ImageStoryUseCase {
constructor() {}
/**
*
* @returns {Partial<ImageStoryEntity>}
*/
getImageStory(): Partial<ImageStoryEntity> {
return { ...this.imageStory };
}
/**
*
* @returns {string}
*/
getStoryLogline(): string {
return this.storyLogline;
}
/**
*
* @returns {CharacterAnalysis[]}
*/
getCharactersAnalysis(): CharacterAnalysis[] {
return [...this.charactersAnalysis];
}
/**
*
* @returns {string[]}
*/
getPotentialGenres(): string[] {
return [...this.potentialGenres];
}
/**
*
* @returns {boolean}
*/
getAnalyzingStatus(): boolean {
return this.isAnalyzing;
}
/**
*
* @returns {boolean}
*/
getUploadingStatus(): boolean {
return this.isUploading;
}
/**
*
@ -94,7 +48,7 @@ export class ImageStoryUseCase {
this.imageStory = {
imageUrl: "",
imageStory: "",
storyType: "auto",
storyType: "Auto",
};
this.storyLogline = "";
this.charactersAnalysis = [];
@ -112,7 +66,7 @@ export class ImageStoryUseCase {
try {
this.isUploading = false; // 图片已上传设置上传状态为false
this.isAnalyzing = true;
console.log('imageUrl', imageUrl)
// 设置上传后的图片URL
this.setImageStory({ imageUrl });
@ -134,10 +88,11 @@ export class ImageStoryUseCase {
* @returns {Promise<void>}
*/
async analyzeImageWithAI(): Promise<void> {
console.log('this.imageStory.imageUrl', this.imageStory.imageUrl)
try {
// 调用AI分析接口
const response = await AIGenerateImageStory({
imageUrl: this.imageStory.imageUrl || "",
image_url: this.imageStory.imageUrl || "",
user_text: this.imageStory.imageStory || "",
});
@ -181,17 +136,17 @@ export class ImageStoryUseCase {
name: character.role_name,
avatar_url: "", // 这里需要根据实际情况设置头像URL
region: {
x: character.region.x,
y: character.region.y,
width: character.region.width,
height: character.region.height,
x: character.region?.x || 0,
y: character.region?.y || 0,
width: character.region?.width || 0,
height: character.region?.height || 0,
}
})) || [];
// 更新ImageStoryEntity
this.setImageStory({
imageAnalysis: data.story_logline || "",
storyType: data.potential_genres?.[0] || "auto", // 使用第一个分类作为故事类型
storyType: "Auto", // 使用第一个分类作为故事类型
roleImage,
});
}
@ -211,22 +166,6 @@ export class ImageStoryUseCase {
updateStoryContent(storyContent: string): void {
this.setImageStory({ imageStory: storyContent });
}
/**
*
* @returns {Array<{key: string, label: string}>
*/
getStoryTypeOptions(): Array<{ key: string; label: string }> {
return [
{ key: "auto", label: "Auto" },
{ key: "adventure", label: "Adventure" },
{ key: "romance", label: "Romance" },
{ key: "mystery", label: "Mystery" },
{ key: "fantasy", label: "Fantasy" },
{ key: "comedy", label: "Comedy" },
];
}
/**
*
* @param {CharacterAnalysis[]} characters -
@ -235,10 +174,10 @@ export class ImageStoryUseCase {
this.charactersAnalysis = characters.map(character => ({
...character,
region: {
x: character.region.x,
y: character.region.y,
width: character.region.width,
height: character.region.height,
x: character.region?.x || 0,
y: character.region?.y || 0,
width: character.region?.width || 0,
height: character.region?.height || 0,
}
}));
}

View File

@ -30,6 +30,10 @@ import { AudioRecorder } from "./AudioRecorder";
import { useTemplateStoryServiceHook } from "@/app/service/Interaction/templateStoryService";
import { createScriptEpisodeNew } from "@/api/script_episode";
import { useRouter } from "next/navigation";
import { EditorContent, useEditor } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import { HighlightTextExtension } from "@/components/ui/main-editor/HighlightText";
import Placeholder from "@tiptap/extension-placeholder";
// 自定义音频播放器样式
const customAudioPlayerStyles = `
@ -453,7 +457,7 @@ const RenderTemplateStoryMode = ({
}}
footer={null}
width="60%"
style={{ maxWidth: "800px" }}
style={{ maxWidth: "800px", marginTop: "10vh" }}
className="template-modal"
closeIcon={
<div className="w-6 h-6 bg-white/10 rounded-full flex items-center justify-center hover:bg-white/20 transition-colors">
@ -591,37 +595,33 @@ export function ChatInputBox() {
{/* 右上角齿轮图标和配置项 */}
{!isExpanded && (
<div className="absolute top-2 right-2 z-10 flex items-center">
{/* 配置项显示区域 - 从齿轮图标右侧边缘滑入 */}
<div
className={`relative bottom-16 flex items-center gap-2 bg-white/[0.18] backdrop-blur-[40px] border border-white/[0.12] rounded-lg p-2 transition-all duration-500 ease-out mr-2 ${
showConfigOptions
? " opacity-100"
: "opacity-0 pointer-events-none"
}`}
style={{
transformOrigin: "left center",
willChange: "transform, opacity",
}}
{/* 使用 Dropdown 替代手动控制显示/隐藏 */}
<Dropdown
open={showConfigOptions}
onOpenChange={setShowConfigOptions}
popupRender={() => (
<div className="bg-white/[0.08] border border-white/[0.12] rounded-lg shadow-[0_8px_32px_rgba(0,0,0,0.3)]">
<ConfigOptions
config={configOptions}
onConfigChange={(key, value) =>
setConfigOptions((prev) => ({ ...prev, [key]: value }))
}
/>
</div>
)}
placement={"left" as any}
trigger={["click"]}
>
<ConfigOptions
config={configOptions}
onConfigChange={(key, value) =>
setConfigOptions((prev) => ({ ...prev, [key]: value }))
}
compact={true}
/>
</div>
{/* 配置项显示控制按钮 - 齿轮图标,位置完全固定 */}
<Tooltip title="config" placement="top">
<button
data-alt="config-toggle-button"
className="flex items-center justify-center w-8 h-8 bg-white/[0.1] hover:bg-white/[0.2] rounded-lg border border-white/[0.2] transition-all duration-200"
onClick={() => setShowConfigOptions(!showConfigOptions)}
>
<Settings className="w-4 h-4 text-white/80" />
</button>
</Tooltip>
{/* 配置项显示控制按钮 - 齿轮图标 */}
<Tooltip title="config" placement="top">
<button
data-alt="config-toggle-button"
className="flex items-center justify-center w-8 h-8 bg-white/[0.1] hover:bg-white/[0.2] rounded-lg border border-white/[0.2] transition-all duration-200"
>
<Settings className="w-4 h-4 text-white/80" />
</button>
</Tooltip>
</Dropdown>
</div>
)}
@ -711,6 +711,7 @@ export function ChatInputBox() {
<ActionButton
isCreating={isCreating}
handleCreateVideo={handleCreateVideo}
icon={<Clapperboard className="w-5 h-5" />}
/>
</div>
</div>
@ -739,28 +740,27 @@ export function ChatInputBox() {
const ActionButton = ({
isCreating,
handleCreateVideo,
icon,
}: {
isCreating: boolean;
handleCreateVideo: () => void;
icon: React.ReactNode;
}) => {
return (
<div className="relative group">
<div
data-alt="action-button"
className="relative w-12 h-12 opacity-90 overflow-hidden rounded-xl bg-black z-10"
className="relative w-12 h-12 opacity-90 cursor-pointer overflow-hidden rounded-xl bg-black z-10"
onClick={isCreating ? undefined : handleCreateVideo}
>
<div className="absolute z-10 -translate-x-12 group-hover:translate-x-12 transition-all duration-700 h-full w-12 bg-gradient-to-r from-gray-500 to-white/10 opacity-30 -skew-x-12"></div>
<div className="absolute flex items-center justify-center text-white z-[1] opacity-90 rounded-xl inset-0.5 bg-black">
<button
name="text"
className="w-full h-full opacity-90 rounded-xl bg-black flex items-center justify-center"
onClick={isCreating ? undefined : handleCreateVideo}
>
{isCreating ? (
<Loader2 className="w-5 h-5 animate-spin" />
) : (
<Clapperboard className="w-5 h-5" />
)}
{isCreating ? <Loader2 className="w-5 h-5 animate-spin" /> : icon}
</button>
</div>
<div className="absolute duration-1000 group-hover:animate-spin w-full h-12 bg-gradient-to-r from-[rgb(106,244,249)] to-[rgb(199,59,255)] blur-[20px]"></div>
@ -776,7 +776,6 @@ const ActionButton = ({
const ConfigOptions = ({
config,
onConfigChange,
compact = false,
}: {
config: {
mode: string;
@ -785,7 +784,6 @@ const ConfigOptions = ({
videoDuration: string;
};
onConfigChange: (key: string, value: string) => void;
compact?: boolean;
}) => {
const configItems = [
{
@ -828,7 +826,7 @@ const ConfigOptions = ({
];
return (
<div className={`flex items-center gap-3 ${compact ? "gap-2" : "mt-3"}`}>
<div className={`flex items-center p-2 gap-2`}>
{configItems.map((item) => {
const IconComponent = item.icon;
const currentOption = item.options.find(
@ -856,22 +854,12 @@ const ConfigOptions = ({
>
<button
data-alt={`config-${item.key}`}
className={`flex items-center gap-2 px-2.5 py-1.5 bg-white/[0.05] backdrop-blur-xl border border-white/[0.1] rounded-lg text-white/80 hover:bg-white/[0.08] hover:border-white/[0.2] transition-all duration-200 ${
compact ? "px-2 py-1" : ""
}`}
className={`flex items-center gap-2 bg-white/[0.05] backdrop-blur-xl border border-white/[0.1] rounded-lg text-white/80 hover:bg-white/[0.08] hover:border-white/[0.2] transition-all duration-200 px-2 py-1`}
>
<IconComponent
className={`${compact ? "w-3 h-3" : "w-3.5 h-3.5"}`}
/>
<span className={`${compact ? "text-xs" : "text-xs"}`}>
{currentOption?.label}
</span>
<IconComponent className={"w-3 h-3"} />
<span className={"text-xs"}>{currentOption?.label}</span>
{currentOption?.isVip && (
<Crown
className={`${
compact ? "w-2 h-2" : "w-2.5 h-2.5"
} text-yellow-500`}
/>
<Crown className={`w-2 h-2 text-yellow-500`} />
)}
</button>
</Dropdown>
@ -881,6 +869,134 @@ const ConfigOptions = ({
);
};
/**
*
* 使 Tiptap
*/
const RoleHighlightEditor = ({
content,
onContentChange,
}: {
content: string;
onContentChange: (content: string) => void;
}) => {
const editor = useEditor({
extensions: [
StarterKit,
HighlightTextExtension,
Placeholder.configure({
placeholder:
"Share your creative ideas about the image and let AI create a movie story for you...",
emptyEditorClass: "is-editor-empty",
}),
],
content: "",
// 简化:移除复杂的 onUpdate 逻辑,只处理基本的文本变化
onUpdate: ({ editor }) => {
const textContent = editor.getText();
if (!textContent.trim()) {
onContentChange("");
return;
}
// 直接传递文本内容,不进行复杂的标签重建
onContentChange(textContent);
},
editorProps: {
handleKeyDown: (view, event) => {
const { from, to } = view.state.selection;
const doc = view.state.doc;
// 检查光标前后是否有角色标签
const textBefore =
from > 0 ? doc.textBetween(Math.max(0, from - 50), from) : "";
const textAfter =
to < doc.content.size
? doc.textBetween(to, Math.min(doc.content.size, to + 50))
: "";
// TODO role id 的结构
const beforeMatch = textBefore.match(/<role_name>[^<]*$/);
const afterMatch = textAfter.match(/^[^>]*<\/role_name>/);
if (beforeMatch || afterMatch) {
if (event.key !== "Backspace" && event.key !== "Delete") {
event.preventDefault();
return true;
}
}
return false;
},
},
immediatelyRender: false,
});
useEffect(() => {
if (editor) {
if (!content || content.trim() === "") {
editor.commands.clearContent(true);
return;
}
// 将带标签的内容转换为高亮显示
const htmlContent = content.replace(
/<role_name>([^<]+)<\/role_name>/g,
'<highlight-text type="role" text="$1" color="blue">$1</highlight-text>'
);
editor.commands.setContent(htmlContent, { emitUpdate: false });
}
}, [content, editor]);
return (
<div className="flex-1 min-w-0 relative pr-20">
<style jsx>{`
.role-name-highlight {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white !important;
padding: 2px 6px;
border-radius: 4px;
font-weight: 500;
margin: 0 2px;
user-select: none;
cursor: default;
pointer-events: none;
}
/* 移除 Tiptap 编辑器的默认焦点样式 */
.ProseMirror:focus {
outline: none !important;
border: none !important;
box-shadow: none !important;
}
.ProseMirror:focus-visible {
outline: none !important;
border: none !important;
box-shadow: none !important;
}
/* 移除编辑器容器的焦点样式 */
.ProseMirror-focused {
outline: none !important;
border: none !important;
box-shadow: none !important;
}
/* 确保编辑器内容区域没有边框和轮廓 */
.ProseMirror {
outline: none !important;
border: none !important;
box-shadow: none !important;
}
`}</style>
<EditorContent
editor={editor}
className="w-full bg-transparent border-none outline-none resize-none text-white placeholder:text-white/40 text-sm leading-relaxed min-h-[60px] focus:outline-none focus:ring-0 focus:border-0"
style={{ outline: "none", border: "none", boxShadow: "none" }}
/>
</div>
);
};
/**
*
* AI分析和故事生成功能UI变化
@ -907,9 +1023,10 @@ const PhotoStoryModal = ({
updateStoryContent,
updateCharacterName,
resetImageStory,
resetToInitialState,
triggerFileSelectionAndAnalyze,
triggerFileSelection,
avatarComputed,
uploadAndAnalyzeImage,
setCharactersAnalysis,
} = useImageStoryServiceHook();
// 重置状态
@ -921,7 +1038,7 @@ const PhotoStoryModal = ({
// 处理图片上传
const handleImageUpload = async () => {
try {
await triggerFileSelectionAndAnalyze();
await triggerFileSelection();
} catch (error) {
console.error("Failed to upload image:", error);
}
@ -940,7 +1057,7 @@ const PhotoStoryModal = ({
onCancel={handleClose}
footer={null}
width="80%"
style={{ maxWidth: "1000px" }}
style={{ maxWidth: "1000px", marginTop: "10vh" }}
className="photo-story-modal"
closeIcon={
<div className="w-6 h-6 bg-white/10 rounded-full flex items-center justify-center hover:bg-white/20 transition-colors">
@ -953,232 +1070,183 @@ const PhotoStoryModal = ({
<Spin spinning={isLoading} tip="Processing...">
<div className="rounded-2xl">
{/* 弹窗头部 */}
<div className="flex items-center gap-3 p-6 border-b border-white/[0.1]">
<div className="flex items-center gap-3 p-2 border-b border-white/[0.1]">
<ImagePlay className="w-6 h-6 text-blue-400" />
<h2 className="text-xl font-bold text-white">
Photo Story Creation
Movie Generation from Image
</h2>
</div>
<div className="p-6">
<div className="mb-6">
<div className="w-full bg-white/[0.05] border border-white/[0.1] rounded-xl p-4">
<div className="flex items-start gap-4">
{/* 左侧:图片上传 */}
<div className="flex-shrink-0">
<div
data-alt="image-upload-area"
className="w-20 h-20 border-2 border-dashed border-white/20 rounded-lg flex flex-col items-center justify-center hover:border-white/40 transition-all duration-300 cursor-pointer bg-white/[0.02] hover:bg-white/[0.05] hover:scale-105"
onClick={handleImageUpload}
>
{activeImageUrl ? (
<div className="relative w-full h-full">
<div className="w-full bg-white/[0.05] border border-white/[0.1] rounded-xl p-4 mt-2">
<div className="flex items-start gap-4">
{/* 左侧:图片上传 */}
<div className="flex-shrink-0">
<div
data-alt="image-upload-area"
className={`w-20 h-20 rounded-lg flex flex-col items-center justify-center transition-all duration-300 cursor-pointer ${
activeImageUrl
? "border-2 border-white/20 bg-white/[0.05]"
: "border-2 border-dashed border-white/20 bg-white/[0.02] hover:border-white/40 hover:bg-white/[0.05] hover:scale-105"
}`}
onClick={handleImageUpload}
>
{activeImageUrl ? (
<div className="relative w-full h-full">
<img
src={activeImageUrl}
alt="Story inspiration"
className="w-full h-full object-cover rounded-lg"
/>
<Tooltip title="Clear all content !!! " placement="top">
<button
onClick={(e) => {
e.stopPropagation();
resetImageStory();
}}
className="absolute top-1 right-1 w-4 h-4 bg-black/60 hover:bg-black/80 rounded-full flex items-center justify-center text-white/80 hover:text-white transition-colors"
data-alt="clear-all-button"
>
<Trash2 className="w-2.5 h-2.5" />
</button>
</Tooltip>
</div>
) : (
<div className="text-center text-white/60">
<Upload className="w-6 h-6 mx-auto mb-1 opacity-50" />
<p className="text-xs">Upload</p>
</div>
)}
</div>
</div>
<div className="flex-1 animate-in fade-in-0 slide-in-from-left-4 duration-300">
{/* 中间:头像展示(分析后显示) */}
{hasAnalyzed && avatarComputed.length > 0 && (
<div className="flex gap-2 n justify-start">
{avatarComputed.map((avatar, index) => (
<div
key={`${avatar.name}-${index}`}
className="flex flex-col items-center"
>
<div className="relative w-14 h-14 rounded-sm overflow-hidden bg-white/[0.05] border border-white/[0.1] mb-2 group">
<img
src={activeImageUrl}
alt="Story inspiration"
className="w-full h-full object-cover rounded-lg"
src={avatar.url}
alt={avatar.name}
className="w-full h-full object-cover"
onError={(e) => {
// 如果裁剪的头像加载失败,回退到原图
const target = e.target as HTMLImageElement;
target.src = activeImageUrl;
}}
/>
<Tooltip title="Clear all content !!! " placement="top">
{/* 删除角色按钮 - 使用Tooltip并调整z-index避免被遮挡 */}
<Tooltip
title="Remove this character from the movie"
placement="top"
>
<button
onClick={(e) => {
e.stopPropagation();
resetImageStory();
resetToInitialState();
// 从角色分析中删除该角色
setCharactersAnalysis((charactersAnalysis) => {
const updatedCharacters =
charactersAnalysis.filter(
(char) => char.role_name !== avatar.name
);
return updatedCharacters;
});
// 从故事内容中删除该角色名称
const updatedStory = storyContent
.replace(
new RegExp(`\\b${avatar.name}\\b`, "g"),
""
)
.replace(/\s+/g, " ")
.trim();
// 更新状态
updateStoryContent(updatedStory);
// 注意:这里需要直接更新 charactersAnalysis但 hook 中没有提供 setter
}}
className="absolute top-1 right-1 w-4 h-4 bg-black/60 hover:bg-black/80 rounded-full flex items-center justify-center text-white/80 hover:text-white transition-colors"
data-alt="clear-all-button"
className="absolute top-1 right-1 w-4 h-4 bg-black/[0.05] border border-black/[0.1] text-white rounded-full flex items-center justify-center transition-colors opacity-0 group-hover:opacity-100 z-10"
>
<Trash2 className="w-2.5 h-2.5" />
</button>
</Tooltip>
</div>
) : (
<div className="text-center text-white/60">
<Upload className="w-6 h-6 mx-auto mb-1 opacity-50" />
<p className="text-xs">Upload</p>
<div className="relative group">
<input
type="text"
defaultValue={avatar.name}
onBlur={(e) => {
const newName = e.target.value.trim();
if (newName && newName !== avatar.name) {
updateCharacterName(avatar.name, newName);
}
}}
className="w-16 text-center text-sm text-white/80 bg-transparent border-none outline-none focus:ring-1 focus:ring-blue-400/50 rounded px-1 py-0.5 transition-all duration-200"
style={{ textAlign: "center" }}
/>
<div className="absolute inset-0 border border-transparent group-hover:border-white/20 rounded transition-all duration-200 pointer-events-none"></div>
</div>
)}
</div>
</div>
))}
</div>
)}
</div>
{/* 右侧:分类选择(分析后显示) */}
{hasAnalyzed && potentialGenres.length > 0 && (
<div className="flex-shrink-0 animate-in fade-in-0 slide-in-from-right-4 duration-300">
<div className="flex gap-2">
{["Auto", ...potentialGenres].map((genre) => (
<button
key={genre}
onClick={() => updateStoryType(genre)}
className={`px-3 py-1.5 text-xs rounded-lg transition-all duration-200 whitespace-nowrap ${
selectedCategory === genre
? "bg-blue-500/20 border border-blue-500/40 text-blue-300"
: "bg-white/[0.05] border border-white/[0.1] text-white/60 hover:bg-white/[0.08] hover:text-white/80"
}`}
>
{genre}
</button>
))}
</div>
{/* 中间:头像展示(分析后显示) */}
{hasAnalyzed && avatarComputed.length > 0 && (
<div className="flex-1 animate-in fade-in-0 slide-in-from-left-4 duration-300">
<div className="flex gap-2 n justify-start">
{avatarComputed.map((avatar, index) => (
<div
key={`${avatar.name}-${index}`}
className="flex flex-col items-center"
>
<div className="relative w-14 h-14 rounded-sm overflow-hidden bg-white/[0.05] border border-white/[0.1] mb-2 group">
<img
src={avatar.url}
alt={avatar.name}
className="w-full h-full object-cover"
onError={(e) => {
// 如果裁剪的头像加载失败,回退到原图
const target = e.target as HTMLImageElement;
target.src = activeImageUrl;
}}
/>
{/* 删除角色按钮 - 使用Tooltip并调整z-index避免被遮挡 */}
<Tooltip title="Remove this character from the movie" placement="top">
<button
onClick={(e) => {
e.stopPropagation();
// 从角色分析中删除该角色
const updatedCharacters =
charactersAnalysis.filter(
(char) => char.role_name !== avatar.name
);
// 从故事内容中删除该角色名称
const updatedStory = storyContent
.replace(
new RegExp(`\\b${avatar.name}\\b`, "g"),
""
)
.replace(/\s+/g, " ")
.trim();
// 更新状态
updateStoryContent(updatedStory);
// 注意:这里需要直接更新 charactersAnalysis但 hook 中没有提供 setter
// 暂时通过重新分析图片来刷新状态
if (activeImageUrl) {
triggerFileSelectionAndAnalyze();
}
}}
className="absolute top-1 right-1 w-4 h-4 bg-black/[0.05] border border-black/[0.1] text-white rounded-full flex items-center justify-center transition-colors opacity-0 group-hover:opacity-100 z-10"
>
<Trash2 className="w-2.5 h-2.5" />
</button>
</Tooltip>
</div>
<div className="relative group">
<input
type="text"
value={avatar.name}
onChange={(e) => {
const newName = e.target.value.trim();
if (newName && newName !== avatar.name) {
updateCharacterName(avatar.name, newName);
}
}}
onBlur={(e) => {
const newName = e.target.value.trim();
if (newName && newName !== avatar.name) {
updateCharacterName(avatar.name, newName);
}
}}
className="w-16 text-center text-sm text-white/80 bg-transparent border-none outline-none focus:ring-1 focus:ring-blue-400/50 rounded px-1 py-0.5 transition-all duration-200"
style={{ textAlign: "center" }}
/>
<div className="absolute inset-0 border border-transparent group-hover:border-white/20 rounded transition-all duration-200 pointer-events-none"></div>
</div>
</div>
))}
</div>
</div>
)}
{/* 右侧:分类选择(分析后显示) */}
{hasAnalyzed && potentialGenres.length > 0 && (
<div className="flex-shrink-0 animate-in fade-in-0 slide-in-from-right-4 duration-300">
<div className="flex gap-2">
{["Auto", ...potentialGenres].map((genre) => (
<button
key={genre}
onClick={() => updateStoryType(genre)}
className={`px-3 py-1.5 text-xs rounded-lg transition-all duration-200 whitespace-nowrap ${
selectedCategory === genre
? "bg-blue-500/20 border border-blue-500/40 text-blue-300"
: "bg-white/[0.05] border border-white/[0.1] text-white/60 hover:bg-white/[0.08] hover:text-white/80"
}`}
>
{genre}
</button>
))}
</div>
</div>
)}
</div>
<div className="flex items-start gap-4 mt-2">
{/* 文本输入框 */}
<div className="flex-1 min-w-0 relative pr-20">
<textarea
data-alt="story-description-input"
value={storyContent}
onChange={(e) => updateStoryContent(e.target.value)}
placeholder={
hasAnalyzed
? "AI analyzed story content..."
: "Describe your story idea..."
}
className="w-full bg-transparent border-none outline-none resize-none text-white placeholder:text-white/40 text-sm leading-relaxed"
style={{ minHeight: "60px" }}
onInput={(e) => {
const target = e.target as HTMLTextAreaElement;
target.style.height = "auto";
target.style.height =
Math.max(target.scrollHeight, 60) + "px";
}}
)}
</div>
<div className="flex items-start gap-4 mt-2 relative">
{/* 文本输入框 */}
<RoleHighlightEditor
content={storyContent}
onContentChange={updateStoryContent}
/>
<div className="absolute bottom-1 right-0 flex gap-2">
{!hasAnalyzed ? (
// 分析按钮 - 使用ActionButton样式
<Tooltip
title={
activeImageUrl
? "Analyze image content"
: "Please upload an image first"
}
placement="top"
>
<ActionButton
isCreating={isLoading}
handleCreateVideo={uploadAndAnalyzeImage}
icon={<Sparkles className="w-5 h-5" />}
/>
<div className="absolute bottom-1 right-0 flex gap-2">
{!hasAnalyzed ? (
// 分析按钮 - 使用ActionButton样式
<Tooltip title="Analyze image content" placement="top">
<div className="relative group">
<div
data-alt="analyze-button"
className="relative w-12 h-12 opacity-90 overflow-hidden rounded-xl bg-black z-10"
>
<div className="absolute z-10 -translate-x-12 group-hover:translate-x-12 transition-all duration-700 h-full w-12 bg-gradient-to-r from-gray-500 to-white/10 opacity-30 -skew-x-12"></div>
<div className="absolute flex items-center justify-center text-white z-[1] opacity-90 rounded-xl inset-0.5 bg-black">
<button
onClick={() => {}} // 分析已经在triggerFileSelectionAndAnalyze中自动完成
disabled={!activeImageUrl || isLoading}
className="w-full h-full opacity-90 rounded-xl bg-black flex items-center justify-center disabled:cursor-not-allowed"
>
{isLoading ? (
<Loader2 className="w-5 h-5 animate-spin" />
) : (
<Sparkles className="w-5 h-5" />
)}
</button>
</div>
<div className="absolute duration-1000 group-hover:animate-spin w-full h-12 bg-gradient-to-r from-[rgb(106,244,249)] to-[rgb(199,59,255)] blur-[20px]"></div>
</div>
</div>
</Tooltip>
) : (
<>
{/* Action按钮 - 使用ActionButton样式 */}
<Tooltip
title="Confirm story creation"
placement="top"
>
<div className="relative group">
<div
data-alt="action-button"
className="relative w-12 h-12 opacity-90 overflow-hidden rounded-xl bg-black z-10"
>
<div className="absolute z-10 -translate-x-12 group-hover:translate-x-12 transition-all duration-700 h-full w-12 bg-gradient-to-r from-gray-500 to-white/10 opacity-30 -skew-x-12"></div>
<div className="absolute flex items-center justify-center text-white z-[1] opacity-90 rounded-xl inset-0.5 bg-black">
<button
onClick={handleConfirm}
className="w-full h-full opacity-90 rounded-xl bg-black flex items-center justify-center"
>
<Clapperboard className="w-5 h-5" />
</button>
</div>
<div className="absolute duration-1000 group-hover:animate-spin w-full h-12 bg-gradient-to-r from-[rgb(106,244,249)] to-[rgb(199,59,255)] blur-[20px]"></div>
</div>
</div>
</Tooltip>
</>
)}
</div>
</div>
</div>
</Tooltip>
) : (
<>
{/* Action按钮 - 使用ActionButton样式 */}
<Tooltip title="Confirm story creation" placement="top">
<ActionButton
isCreating={isLoading}
handleCreateVideo={handleConfirm}
icon={<Clapperboard className="w-5 h-5" />}
/>
</Tooltip>
</>
)}
</div>
</div>
</div>

View File

@ -1,8 +1,5 @@
import { Node, mergeAttributes } from '@tiptap/core'
import { ReactNodeViewRenderer, NodeViewWrapper, ReactNodeViewProps } from '@tiptap/react'
import { motion, AnimatePresence } from 'framer-motion'
import { useState } from 'react'
import { Check } from 'lucide-react'
interface HighlightTextAttributes {
type: string;
@ -17,30 +14,20 @@ interface HighlightTextOptions {
}
export function HighlightText(props: ReactNodeViewProps) {
const { text: initialText, color } = props.node.attrs as HighlightTextAttributes
const [text, setText] = useState(initialText)
const handleInput = (e: React.FormEvent<HTMLSpanElement>) => {
const newText = e.currentTarget.textContent || ''
setText(newText)
// 通知Tiptap更新内容
props.updateAttributes({
text: newText
})
}
const { type, text: initialText, color } = props.node.attrs as HighlightTextAttributes
const isRoleName = type === 'role'
return (
<NodeViewWrapper
as="span"
data-alt="highlight-text"
contentEditable={true}
contentEditable={!isRoleName}
suppressContentEditableWarning={true}
onInput={handleInput}
className={`relative inline text-${color}-400 hover:text-${color}-300 transition-colors duration-200`}
className={`relative inline text-${color}-400 hover:text-${color}-300 transition-colors duration-200 ${
isRoleName ? 'role-name-highlight' : ''
}`}
>
{text}
{/* 暂时空着 为后续可视化文本预留 */}
{initialText}
</NodeViewWrapper>
)
}
@ -70,4 +57,4 @@ export const HighlightTextExtension = Node.create<HighlightTextOptions>({
addNodeView() {
return ReactNodeViewRenderer(HighlightText);
},
});
});