forked from 77media/video-flow
又推进图片故事
This commit is contained in:
parent
76169e790d
commit
4904c29ce6
@ -19,7 +19,7 @@ export interface CharacterAnalysis {
|
||||
/** 角色名称 */
|
||||
role_name: string;
|
||||
/** 角色区域 */
|
||||
region: CharacterRegion;
|
||||
region: CharacterRegion|null;
|
||||
/** 角色头像URL(可选,用于存储裁剪后的头像) */
|
||||
avatarUrl?: string;
|
||||
}
|
||||
|
||||
@ -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>>(
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@ -112,7 +112,7 @@ export interface ImageStoryEntity {
|
||||
width: number;
|
||||
/** 高度 */
|
||||
height: number;
|
||||
};
|
||||
} | null;
|
||||
}[];
|
||||
}
|
||||
/**
|
||||
|
||||
@ -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,
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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);
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user