forked from 77media/video-flow
又推进图片故事
This commit is contained in:
parent
76169e790d
commit
4904c29ce6
@ -19,7 +19,7 @@ export interface CharacterAnalysis {
|
|||||||
/** 角色名称 */
|
/** 角色名称 */
|
||||||
role_name: string;
|
role_name: string;
|
||||||
/** 角色区域 */
|
/** 角色区域 */
|
||||||
region: CharacterRegion;
|
region: CharacterRegion|null;
|
||||||
/** 角色头像URL(可选,用于存储裁剪后的头像) */
|
/** 角色头像URL(可选,用于存储裁剪后的头像) */
|
||||||
avatarUrl?: string;
|
avatarUrl?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -27,7 +27,7 @@ export const actionTemplateStory = async (template: StoryTemplateEntity) => {
|
|||||||
* AI分析图片,生成分析结果
|
* AI分析图片,生成分析结果
|
||||||
*/
|
*/
|
||||||
export const AIGenerateImageStory = async (request: {
|
export const AIGenerateImageStory = async (request: {
|
||||||
imageUrl: string;
|
image_url: string;
|
||||||
user_text: string;
|
user_text: string;
|
||||||
}) => {
|
}) => {
|
||||||
return await post<ApiResponse<MovieStartDTO>>(
|
return await post<ApiResponse<MovieStartDTO>>(
|
||||||
|
|||||||
@ -145,4 +145,33 @@ body {
|
|||||||
outline: none !important;
|
outline: none !important;
|
||||||
outline-offset: 0 !important;
|
outline-offset: 0 !important;
|
||||||
box-shadow: none !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 { ImageStoryEntity } from "../domain/Entities";
|
||||||
import { useUploadFile } from "../domain/service";
|
import { useUploadFile } from "../domain/service";
|
||||||
import { ImageStoryUseCase } from "../usecase/imageStoryUseCase";
|
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";
|
import { CharacterAnalysis } from "@/api/DTO/movie_start_dto";
|
||||||
|
|
||||||
interface UseImageStoryService {
|
interface UseImageStoryService {
|
||||||
@ -24,9 +24,9 @@ interface UseImageStoryService {
|
|||||||
/** 计算后的角色头像数据 */
|
/** 计算后的角色头像数据 */
|
||||||
avatarComputed: Array<{ name: string; url: string }>;
|
avatarComputed: Array<{ name: string; url: string }>;
|
||||||
/** 上传图片并分析 */
|
/** 上传图片并分析 */
|
||||||
uploadAndAnalyzeImage: (imageUrl: string) => Promise<void>;
|
uploadAndAnalyzeImage: () => Promise<void>;
|
||||||
/** 触发文件选择并自动分析 */
|
/** 触发文件选择 */
|
||||||
triggerFileSelectionAndAnalyze: () => Promise<void>;
|
triggerFileSelection: () => Promise<void>;
|
||||||
/** 触发生成剧本函数 */
|
/** 触发生成剧本函数 */
|
||||||
generateScript: () => Promise<string>;
|
generateScript: () => Promise<string>;
|
||||||
/** 更新故事类型 */
|
/** 更新故事类型 */
|
||||||
@ -35,10 +35,11 @@ interface UseImageStoryService {
|
|||||||
updateStoryContent: (content: string) => void;
|
updateStoryContent: (content: string) => void;
|
||||||
/** 更新角色名称并同步到相关数据 */
|
/** 更新角色名称并同步到相关数据 */
|
||||||
updateCharacterName: (oldName: string, newName: string) => void;
|
updateCharacterName: (oldName: string, newName: string) => void;
|
||||||
|
/** 同步角色名称到故事内容 */
|
||||||
|
syncRoleNameToContent: (oldName: string, newName: string) => void;
|
||||||
/** 重置图片故事数据 */
|
/** 重置图片故事数据 */
|
||||||
resetImageStory: () => void;
|
resetImageStory: (showAnalysisState?: boolean) => void;
|
||||||
/** 完全重置到初始状态(包括预置数据) */
|
setCharactersAnalysis: Dispatch<SetStateAction<CharacterAnalysis[]>>
|
||||||
resetToInitialState: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useImageStoryServiceHook = (): UseImageStoryService => {
|
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分析结果),预置假数据
|
// 故事内容状态(统一管理用户输入和AI分析结果)
|
||||||
const [storyContent, setStoryContent] = useState<string>(
|
const [storyContent, setStoryContent] = useState<string>("");
|
||||||
"在一个遥远的未来城市,机器人与人类共存,但一场突如其来的危机打破了平衡。艾米丽,阿尔法,博士互相帮助,共同解决危机。"
|
|
||||||
);
|
|
||||||
|
|
||||||
// 分析结果状态
|
// 分析结果状态
|
||||||
/** 角色头像及名称,预置假数据 */
|
/** 角色头像及名称 */
|
||||||
const [charactersAnalysis, setCharactersAnalysis] = useState<
|
const [charactersAnalysis, setCharactersAnalysis] = useState<
|
||||||
CharacterAnalysis[]
|
CharacterAnalysis[]
|
||||||
>([
|
>([]);
|
||||||
{
|
/** 分类数组 */
|
||||||
role_name: "艾米丽",
|
const [potentialGenres, setPotentialGenres] = useState<string[]>([]);
|
||||||
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 [selectedCategory, setSelectedCategory] = useState<string>("Auto");
|
const [selectedCategory, setSelectedCategory] = useState<string>("Auto");
|
||||||
|
|
||||||
// 流程状态
|
// 流程状态
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [hasAnalyzed, setHasAnalyzed] = useState(true);
|
const [hasAnalyzed, setHasAnalyzed] = useState(false);
|
||||||
|
|
||||||
// 使用上传文件Hook
|
// 使用上传文件Hook
|
||||||
const { uploadFile } = useUploadFile();
|
const { uploadFile } = useUploadFile();
|
||||||
@ -112,93 +76,111 @@ export const useImageStoryServiceHook = (): UseImageStoryService => {
|
|||||||
/** 图片故事用例实例 */
|
/** 图片故事用例实例 */
|
||||||
const imageStoryUseCase = useMemo(() => new ImageStoryUseCase(), []);
|
const imageStoryUseCase = useMemo(() => new ImageStoryUseCase(), []);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 根据角色区域信息生成头像URL
|
* 根据角色区域信息生成头像URL
|
||||||
* @param character - 角色信息
|
* @param character - 角色信息
|
||||||
* @param imageUrl - 源图片URL
|
* @param imageUrl - 源图片URL
|
||||||
*/
|
*/
|
||||||
const generateAvatarFromRegion = useCallback((character: CharacterAnalysis, imageUrl: string) => {
|
const generateAvatarFromRegion = useCallback(
|
||||||
// 创建图片对象
|
(character: CharacterAnalysis, imageUrl: string) => {
|
||||||
const img = new Image();
|
if (!character.region || !character.region.width || !character.region.height) {
|
||||||
img.crossOrigin = 'anonymous'; // 处理跨域问题
|
return;
|
||||||
|
|
||||||
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 img = new Image();
|
||||||
|
img.crossOrigin = "anonymous"; // 处理跨域问题
|
||||||
|
|
||||||
img.onerror = () => {
|
img.onload = () => {
|
||||||
console.error('加载图片失败:', imageUrl);
|
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;
|
if (cropWidth <= 0 || cropHeight <= 0) {
|
||||||
}, [setCharactersAnalysis]);
|
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
|
* 根据角色框选数据计算头像URL
|
||||||
@ -223,7 +205,7 @@ export const useImageStoryServiceHook = (): UseImageStoryService => {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
name: character.role_name,
|
name: character.role_name,
|
||||||
url: '', // 初始为空,异步生成完成后会更新
|
url: "", // 初始为空,异步生成完成后会更新
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}, [charactersAnalysis, activeImageUrl, generateAvatarFromRegion]);
|
}, [charactersAnalysis, activeImageUrl, generateAvatarFromRegion]);
|
||||||
@ -232,18 +214,19 @@ export const useImageStoryServiceHook = (): UseImageStoryService => {
|
|||||||
* @param {string} imageUrl - 已上传的图片URL
|
* @param {string} imageUrl - 已上传的图片URL
|
||||||
*/
|
*/
|
||||||
const uploadAndAnalyzeImage = useCallback(
|
const uploadAndAnalyzeImage = useCallback(
|
||||||
async (imageUrl: string): Promise<void> => {
|
async (): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
|
console.log('123123123', 123123123)
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
// 调用用例处理图片上传和分析
|
// 调用用例处理图片上传和分析
|
||||||
await imageStoryUseCase.handleImageUpload(imageUrl);
|
await imageStoryUseCase.handleImageUpload(activeImageUrl);
|
||||||
|
|
||||||
// 获取更新后的数据
|
// 获取更新后的数据
|
||||||
const updatedStory = imageStoryUseCase.getStoryLogline();
|
const updatedStory = imageStoryUseCase.storyLogline;
|
||||||
const updatedCharacters = imageStoryUseCase.getCharactersAnalysis();
|
const updatedCharacters = imageStoryUseCase.charactersAnalysis;
|
||||||
const updatedGenres = imageStoryUseCase.getPotentialGenres();
|
const updatedGenres = imageStoryUseCase.potentialGenres;
|
||||||
const updatedImageStory = imageStoryUseCase.getImageStory();
|
const updatedImageStory = imageStoryUseCase.imageStory;
|
||||||
|
|
||||||
// 更新所有响应式状态
|
// 更新所有响应式状态
|
||||||
setCharactersAnalysis(updatedCharacters);
|
setCharactersAnalysis(updatedCharacters);
|
||||||
@ -251,12 +234,8 @@ export const useImageStoryServiceHook = (): UseImageStoryService => {
|
|||||||
setImageStory(updatedImageStory);
|
setImageStory(updatedImageStory);
|
||||||
|
|
||||||
// 将AI分析的故事内容直接更新到统一的故事内容字段
|
// 将AI分析的故事内容直接更新到统一的故事内容字段
|
||||||
setStoryContent(updatedStory || "");
|
updateStoryContent(updatedStory || "");
|
||||||
|
setSelectedCategory("Auto");
|
||||||
// 设置第一个分类为默认选中
|
|
||||||
if (updatedGenres.length > 0) {
|
|
||||||
setSelectedCategory(updatedGenres[0]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 标记已分析
|
// 标记已分析
|
||||||
setHasAnalyzed(true);
|
setHasAnalyzed(true);
|
||||||
@ -267,7 +246,7 @@ export const useImageStoryServiceHook = (): UseImageStoryService => {
|
|||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[imageStoryUseCase]
|
[activeImageUrl, imageStoryUseCase]
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -323,30 +302,47 @@ export const useImageStoryServiceHook = (): UseImageStoryService => {
|
|||||||
*/
|
*/
|
||||||
const updateStoryContent = useCallback((content: string): void => {
|
const updateStoryContent = useCallback((content: string): void => {
|
||||||
setStoryContent(content);
|
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} oldName - 旧的角色名称
|
||||||
* @param {string} newName - 新的角色名称
|
* @param {string} newName - 新的角色名称
|
||||||
*/
|
*/
|
||||||
const updateCharacterName = useCallback((oldName: string, newName: string): void => {
|
const updateCharacterName = useCallback(
|
||||||
// 更新角色分析数据中的名称
|
(oldName: string, newName: string): void => {
|
||||||
setCharactersAnalysis(prev =>
|
// 更新角色分析数据中的名称
|
||||||
prev.map(char =>
|
setCharactersAnalysis((prev) =>
|
||||||
char.role_name === oldName
|
prev.map((char) =>
|
||||||
? { ...char, role_name: newName }
|
char.role_name === oldName ? { ...char, role_name: newName } : char
|
||||||
: char
|
)
|
||||||
)
|
);
|
||||||
);
|
|
||||||
|
|
||||||
// 同步更新故事内容中的角色名称
|
// 同步更新故事内容中的角色名称
|
||||||
setStoryContent(prev => {
|
syncRoleNameToContent(oldName, newName);
|
||||||
// 使用正则表达式进行全局替换,确保大小写匹配
|
},
|
||||||
const regex = new RegExp(`\\b${oldName}\\b`, 'g');
|
[syncRoleNameToContent]
|
||||||
return prev.replace(regex, newName);
|
);
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 重置图片故事数据
|
* 重置图片故事数据
|
||||||
@ -355,8 +351,8 @@ export const useImageStoryServiceHook = (): UseImageStoryService => {
|
|||||||
imageStoryUseCase.resetImageStory();
|
imageStoryUseCase.resetImageStory();
|
||||||
|
|
||||||
// 清理生成的头像URL,避免内存泄漏
|
// 清理生成的头像URL,避免内存泄漏
|
||||||
setCharactersAnalysis(prev => {
|
setCharactersAnalysis((prev) => {
|
||||||
prev.forEach(char => {
|
prev.forEach((char) => {
|
||||||
if (char.avatarUrl) {
|
if (char.avatarUrl) {
|
||||||
URL.revokeObjectURL(char.avatarUrl);
|
URL.revokeObjectURL(char.avatarUrl);
|
||||||
}
|
}
|
||||||
@ -370,76 +366,17 @@ export const useImageStoryServiceHook = (): UseImageStoryService => {
|
|||||||
storyType: "auto",
|
storyType: "auto",
|
||||||
});
|
});
|
||||||
setActiveImageUrl("");
|
setActiveImageUrl("");
|
||||||
setStoryContent("");
|
updateStoryContent("");
|
||||||
setPotentialGenres([]);
|
setPotentialGenres([]);
|
||||||
setSelectedCategory("auto");
|
setSelectedCategory("auto");
|
||||||
setHasAnalyzed(false);
|
setHasAnalyzed(false);
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}, [imageStoryUseCase]);
|
}, [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> => {
|
useCallback(async (): Promise<void> => {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
// 创建文件输入元素
|
// 创建文件输入元素
|
||||||
@ -469,8 +406,6 @@ export const useImageStoryServiceHook = (): UseImageStoryService => {
|
|||||||
imageUrl: uploadedImageUrl,
|
imageUrl: uploadedImageUrl,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// 自动开始分析
|
|
||||||
await uploadAndAnalyzeImage(uploadedImageUrl);
|
|
||||||
}
|
}
|
||||||
resolve();
|
resolve();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -490,7 +425,7 @@ export const useImageStoryServiceHook = (): UseImageStoryService => {
|
|||||||
document.body.appendChild(fileInput);
|
document.body.appendChild(fileInput);
|
||||||
fileInput.click();
|
fileInput.click();
|
||||||
});
|
});
|
||||||
}, [uploadFile, uploadAndAnalyzeImage]);
|
}, [uploadFile]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
imageStory,
|
imageStory,
|
||||||
@ -502,13 +437,14 @@ export const useImageStoryServiceHook = (): UseImageStoryService => {
|
|||||||
isLoading,
|
isLoading,
|
||||||
hasAnalyzed,
|
hasAnalyzed,
|
||||||
avatarComputed,
|
avatarComputed,
|
||||||
|
setCharactersAnalysis,
|
||||||
uploadAndAnalyzeImage,
|
uploadAndAnalyzeImage,
|
||||||
triggerFileSelectionAndAnalyze,
|
triggerFileSelection,
|
||||||
generateScript,
|
generateScript,
|
||||||
updateStoryType,
|
updateStoryType,
|
||||||
updateStoryContent,
|
updateStoryContent,
|
||||||
updateCharacterName,
|
updateCharacterName,
|
||||||
|
syncRoleNameToContent,
|
||||||
resetImageStory,
|
resetImageStory,
|
||||||
resetToInitialState,
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@ -112,7 +112,7 @@ export interface ImageStoryEntity {
|
|||||||
width: number;
|
width: number;
|
||||||
/** 高度 */
|
/** 高度 */
|
||||||
height: number;
|
height: number;
|
||||||
};
|
} | null;
|
||||||
}[];
|
}[];
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -11,7 +11,7 @@ export class ImageStoryUseCase {
|
|||||||
imageStory: Partial<ImageStoryEntity> = {
|
imageStory: Partial<ImageStoryEntity> = {
|
||||||
imageUrl: "",
|
imageUrl: "",
|
||||||
imageStory: "",
|
imageStory: "",
|
||||||
storyType: "auto",
|
storyType: "Auto",
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 故事梗概 */
|
/** 故事梗概 */
|
||||||
@ -31,53 +31,7 @@ export class ImageStoryUseCase {
|
|||||||
|
|
||||||
constructor() {}
|
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 = {
|
this.imageStory = {
|
||||||
imageUrl: "",
|
imageUrl: "",
|
||||||
imageStory: "",
|
imageStory: "",
|
||||||
storyType: "auto",
|
storyType: "Auto",
|
||||||
};
|
};
|
||||||
this.storyLogline = "";
|
this.storyLogline = "";
|
||||||
this.charactersAnalysis = [];
|
this.charactersAnalysis = [];
|
||||||
@ -112,7 +66,7 @@ export class ImageStoryUseCase {
|
|||||||
try {
|
try {
|
||||||
this.isUploading = false; // 图片已上传,设置上传状态为false
|
this.isUploading = false; // 图片已上传,设置上传状态为false
|
||||||
this.isAnalyzing = true;
|
this.isAnalyzing = true;
|
||||||
|
console.log('imageUrl', imageUrl)
|
||||||
// 设置上传后的图片URL
|
// 设置上传后的图片URL
|
||||||
this.setImageStory({ imageUrl });
|
this.setImageStory({ imageUrl });
|
||||||
|
|
||||||
@ -134,10 +88,11 @@ export class ImageStoryUseCase {
|
|||||||
* @returns {Promise<void>}
|
* @returns {Promise<void>}
|
||||||
*/
|
*/
|
||||||
async analyzeImageWithAI(): Promise<void> {
|
async analyzeImageWithAI(): Promise<void> {
|
||||||
|
console.log('this.imageStory.imageUrl', this.imageStory.imageUrl)
|
||||||
try {
|
try {
|
||||||
// 调用AI分析接口
|
// 调用AI分析接口
|
||||||
const response = await AIGenerateImageStory({
|
const response = await AIGenerateImageStory({
|
||||||
imageUrl: this.imageStory.imageUrl || "",
|
image_url: this.imageStory.imageUrl || "",
|
||||||
user_text: this.imageStory.imageStory || "",
|
user_text: this.imageStory.imageStory || "",
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -181,17 +136,17 @@ export class ImageStoryUseCase {
|
|||||||
name: character.role_name,
|
name: character.role_name,
|
||||||
avatar_url: "", // 这里需要根据实际情况设置头像URL
|
avatar_url: "", // 这里需要根据实际情况设置头像URL
|
||||||
region: {
|
region: {
|
||||||
x: character.region.x,
|
x: character.region?.x || 0,
|
||||||
y: character.region.y,
|
y: character.region?.y || 0,
|
||||||
width: character.region.width,
|
width: character.region?.width || 0,
|
||||||
height: character.region.height,
|
height: character.region?.height || 0,
|
||||||
}
|
}
|
||||||
})) || [];
|
})) || [];
|
||||||
|
|
||||||
// 更新ImageStoryEntity
|
// 更新ImageStoryEntity
|
||||||
this.setImageStory({
|
this.setImageStory({
|
||||||
imageAnalysis: data.story_logline || "",
|
imageAnalysis: data.story_logline || "",
|
||||||
storyType: data.potential_genres?.[0] || "auto", // 使用第一个分类作为故事类型
|
storyType: "Auto", // 使用第一个分类作为故事类型
|
||||||
roleImage,
|
roleImage,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -211,22 +166,6 @@ export class ImageStoryUseCase {
|
|||||||
updateStoryContent(storyContent: string): void {
|
updateStoryContent(storyContent: string): void {
|
||||||
this.setImageStory({ imageStory: storyContent });
|
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 - 角色分析数据
|
* @param {CharacterAnalysis[]} characters - 角色分析数据
|
||||||
@ -235,10 +174,10 @@ export class ImageStoryUseCase {
|
|||||||
this.charactersAnalysis = characters.map(character => ({
|
this.charactersAnalysis = characters.map(character => ({
|
||||||
...character,
|
...character,
|
||||||
region: {
|
region: {
|
||||||
x: character.region.x,
|
x: character.region?.x || 0,
|
||||||
y: character.region.y,
|
y: character.region?.y || 0,
|
||||||
width: character.region.width,
|
width: character.region?.width || 0,
|
||||||
height: character.region.height,
|
height: character.region?.height || 0,
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|||||||
@ -30,6 +30,10 @@ import { AudioRecorder } from "./AudioRecorder";
|
|||||||
import { useTemplateStoryServiceHook } from "@/app/service/Interaction/templateStoryService";
|
import { useTemplateStoryServiceHook } from "@/app/service/Interaction/templateStoryService";
|
||||||
import { createScriptEpisodeNew } from "@/api/script_episode";
|
import { createScriptEpisodeNew } from "@/api/script_episode";
|
||||||
import { useRouter } from "next/navigation";
|
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 = `
|
const customAudioPlayerStyles = `
|
||||||
@ -453,7 +457,7 @@ const RenderTemplateStoryMode = ({
|
|||||||
}}
|
}}
|
||||||
footer={null}
|
footer={null}
|
||||||
width="60%"
|
width="60%"
|
||||||
style={{ maxWidth: "800px" }}
|
style={{ maxWidth: "800px", marginTop: "10vh" }}
|
||||||
className="template-modal"
|
className="template-modal"
|
||||||
closeIcon={
|
closeIcon={
|
||||||
<div className="w-6 h-6 bg-white/10 rounded-full flex items-center justify-center hover:bg-white/20 transition-colors">
|
<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 && (
|
{!isExpanded && (
|
||||||
<div className="absolute top-2 right-2 z-10 flex items-center">
|
<div className="absolute top-2 right-2 z-10 flex items-center">
|
||||||
{/* 配置项显示区域 - 从齿轮图标右侧边缘滑入 */}
|
{/* 使用 Dropdown 替代手动控制显示/隐藏 */}
|
||||||
<div
|
<Dropdown
|
||||||
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 ${
|
open={showConfigOptions}
|
||||||
showConfigOptions
|
onOpenChange={setShowConfigOptions}
|
||||||
? " opacity-100"
|
popupRender={() => (
|
||||||
: "opacity-0 pointer-events-none"
|
<div className="bg-white/[0.08] border border-white/[0.12] rounded-lg shadow-[0_8px_32px_rgba(0,0,0,0.3)]">
|
||||||
}`}
|
<ConfigOptions
|
||||||
style={{
|
config={configOptions}
|
||||||
transformOrigin: "left center",
|
onConfigChange={(key, value) =>
|
||||||
willChange: "transform, opacity",
|
setConfigOptions((prev) => ({ ...prev, [key]: value }))
|
||||||
}}
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
placement={"left" as any}
|
||||||
|
trigger={["click"]}
|
||||||
>
|
>
|
||||||
<ConfigOptions
|
{/* 配置项显示控制按钮 - 齿轮图标 */}
|
||||||
config={configOptions}
|
<Tooltip title="config" placement="top">
|
||||||
onConfigChange={(key, value) =>
|
<button
|
||||||
setConfigOptions((prev) => ({ ...prev, [key]: value }))
|
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"
|
||||||
compact={true}
|
>
|
||||||
/>
|
<Settings className="w-4 h-4 text-white/80" />
|
||||||
</div>
|
</button>
|
||||||
|
</Tooltip>
|
||||||
{/* 配置项显示控制按钮 - 齿轮图标,位置完全固定 */}
|
</Dropdown>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -711,6 +711,7 @@ export function ChatInputBox() {
|
|||||||
<ActionButton
|
<ActionButton
|
||||||
isCreating={isCreating}
|
isCreating={isCreating}
|
||||||
handleCreateVideo={handleCreateVideo}
|
handleCreateVideo={handleCreateVideo}
|
||||||
|
icon={<Clapperboard className="w-5 h-5" />}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -739,28 +740,27 @@ export function ChatInputBox() {
|
|||||||
const ActionButton = ({
|
const ActionButton = ({
|
||||||
isCreating,
|
isCreating,
|
||||||
handleCreateVideo,
|
handleCreateVideo,
|
||||||
|
icon,
|
||||||
}: {
|
}: {
|
||||||
isCreating: boolean;
|
isCreating: boolean;
|
||||||
handleCreateVideo: () => void;
|
handleCreateVideo: () => void;
|
||||||
|
icon: React.ReactNode;
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<div className="relative group">
|
<div className="relative group">
|
||||||
<div
|
<div
|
||||||
data-alt="action-button"
|
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 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">
|
<div className="absolute flex items-center justify-center text-white z-[1] opacity-90 rounded-xl inset-0.5 bg-black">
|
||||||
<button
|
<button
|
||||||
name="text"
|
name="text"
|
||||||
className="w-full h-full opacity-90 rounded-xl bg-black flex items-center justify-center"
|
className="w-full h-full opacity-90 rounded-xl bg-black flex items-center justify-center"
|
||||||
onClick={isCreating ? undefined : handleCreateVideo}
|
|
||||||
>
|
>
|
||||||
{isCreating ? (
|
{isCreating ? <Loader2 className="w-5 h-5 animate-spin" /> : icon}
|
||||||
<Loader2 className="w-5 h-5 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Clapperboard className="w-5 h-5" />
|
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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 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 = ({
|
const ConfigOptions = ({
|
||||||
config,
|
config,
|
||||||
onConfigChange,
|
onConfigChange,
|
||||||
compact = false,
|
|
||||||
}: {
|
}: {
|
||||||
config: {
|
config: {
|
||||||
mode: string;
|
mode: string;
|
||||||
@ -785,7 +784,6 @@ const ConfigOptions = ({
|
|||||||
videoDuration: string;
|
videoDuration: string;
|
||||||
};
|
};
|
||||||
onConfigChange: (key: string, value: string) => void;
|
onConfigChange: (key: string, value: string) => void;
|
||||||
compact?: boolean;
|
|
||||||
}) => {
|
}) => {
|
||||||
const configItems = [
|
const configItems = [
|
||||||
{
|
{
|
||||||
@ -828,7 +826,7 @@ const ConfigOptions = ({
|
|||||||
];
|
];
|
||||||
|
|
||||||
return (
|
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) => {
|
{configItems.map((item) => {
|
||||||
const IconComponent = item.icon;
|
const IconComponent = item.icon;
|
||||||
const currentOption = item.options.find(
|
const currentOption = item.options.find(
|
||||||
@ -856,22 +854,12 @@ const ConfigOptions = ({
|
|||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
data-alt={`config-${item.key}`}
|
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 ${
|
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`}
|
||||||
compact ? "px-2 py-1" : ""
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
<IconComponent
|
<IconComponent className={"w-3 h-3"} />
|
||||||
className={`${compact ? "w-3 h-3" : "w-3.5 h-3.5"}`}
|
<span className={"text-xs"}>{currentOption?.label}</span>
|
||||||
/>
|
|
||||||
<span className={`${compact ? "text-xs" : "text-xs"}`}>
|
|
||||||
{currentOption?.label}
|
|
||||||
</span>
|
|
||||||
{currentOption?.isVip && (
|
{currentOption?.isVip && (
|
||||||
<Crown
|
<Crown className={`w-2 h-2 text-yellow-500`} />
|
||||||
className={`${
|
|
||||||
compact ? "w-2 h-2" : "w-2.5 h-2.5"
|
|
||||||
} text-yellow-500`}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</Dropdown>
|
</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变化
|
* 提供图片上传、AI分析和故事生成功能,支持动态UI变化
|
||||||
@ -907,9 +1023,10 @@ const PhotoStoryModal = ({
|
|||||||
updateStoryContent,
|
updateStoryContent,
|
||||||
updateCharacterName,
|
updateCharacterName,
|
||||||
resetImageStory,
|
resetImageStory,
|
||||||
resetToInitialState,
|
triggerFileSelection,
|
||||||
triggerFileSelectionAndAnalyze,
|
|
||||||
avatarComputed,
|
avatarComputed,
|
||||||
|
uploadAndAnalyzeImage,
|
||||||
|
setCharactersAnalysis,
|
||||||
} = useImageStoryServiceHook();
|
} = useImageStoryServiceHook();
|
||||||
|
|
||||||
// 重置状态
|
// 重置状态
|
||||||
@ -921,7 +1038,7 @@ const PhotoStoryModal = ({
|
|||||||
// 处理图片上传
|
// 处理图片上传
|
||||||
const handleImageUpload = async () => {
|
const handleImageUpload = async () => {
|
||||||
try {
|
try {
|
||||||
await triggerFileSelectionAndAnalyze();
|
await triggerFileSelection();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to upload image:", error);
|
console.error("Failed to upload image:", error);
|
||||||
}
|
}
|
||||||
@ -940,7 +1057,7 @@ const PhotoStoryModal = ({
|
|||||||
onCancel={handleClose}
|
onCancel={handleClose}
|
||||||
footer={null}
|
footer={null}
|
||||||
width="80%"
|
width="80%"
|
||||||
style={{ maxWidth: "1000px" }}
|
style={{ maxWidth: "1000px", marginTop: "10vh" }}
|
||||||
className="photo-story-modal"
|
className="photo-story-modal"
|
||||||
closeIcon={
|
closeIcon={
|
||||||
<div className="w-6 h-6 bg-white/10 rounded-full flex items-center justify-center hover:bg-white/20 transition-colors">
|
<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...">
|
<Spin spinning={isLoading} tip="Processing...">
|
||||||
<div className="rounded-2xl">
|
<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" />
|
<ImagePlay className="w-6 h-6 text-blue-400" />
|
||||||
<h2 className="text-xl font-bold text-white">
|
<h2 className="text-xl font-bold text-white">
|
||||||
Photo Story Creation
|
Movie Generation from Image
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="w-full bg-white/[0.05] border border-white/[0.1] rounded-xl p-4 mt-2">
|
||||||
<div className="p-6">
|
<div className="flex items-start gap-4">
|
||||||
<div className="mb-6">
|
{/* 左侧:图片上传 */}
|
||||||
<div className="w-full bg-white/[0.05] border border-white/[0.1] rounded-xl p-4">
|
<div className="flex-shrink-0">
|
||||||
<div className="flex items-start gap-4">
|
<div
|
||||||
{/* 左侧:图片上传 */}
|
data-alt="image-upload-area"
|
||||||
<div className="flex-shrink-0">
|
className={`w-20 h-20 rounded-lg flex flex-col items-center justify-center transition-all duration-300 cursor-pointer ${
|
||||||
<div
|
activeImageUrl
|
||||||
data-alt="image-upload-area"
|
? "border-2 border-white/20 bg-white/[0.05]"
|
||||||
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"
|
: "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}
|
}`}
|
||||||
>
|
onClick={handleImageUpload}
|
||||||
{activeImageUrl ? (
|
>
|
||||||
<div className="relative w-full h-full">
|
{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
|
<img
|
||||||
src={activeImageUrl}
|
src={avatar.url}
|
||||||
alt="Story inspiration"
|
alt={avatar.name}
|
||||||
className="w-full h-full object-cover rounded-lg"
|
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
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
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"
|
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"
|
||||||
data-alt="clear-all-button"
|
|
||||||
>
|
>
|
||||||
<Trash2 className="w-2.5 h-2.5" />
|
<Trash2 className="w-2.5 h-2.5" />
|
||||||
</button>
|
</button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
<div className="relative group">
|
||||||
<div className="text-center text-white/60">
|
<input
|
||||||
<Upload className="w-6 h-6 mx-auto mb-1 opacity-50" />
|
type="text"
|
||||||
<p className="text-xs">Upload</p>
|
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>
|
||||||
|
)}
|
||||||
|
</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>
|
||||||
|
|
||||||
{/* 中间:头像展示(分析后显示) */}
|
|
||||||
{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>
|
||||||
<div className="flex items-start gap-4 mt-2">
|
)}
|
||||||
{/* 文本输入框 */}
|
</div>
|
||||||
<div className="flex-1 min-w-0 relative pr-20">
|
<div className="flex items-start gap-4 mt-2 relative">
|
||||||
<textarea
|
{/* 文本输入框 */}
|
||||||
data-alt="story-description-input"
|
<RoleHighlightEditor
|
||||||
value={storyContent}
|
content={storyContent}
|
||||||
onChange={(e) => updateStoryContent(e.target.value)}
|
onContentChange={updateStoryContent}
|
||||||
placeholder={
|
/>
|
||||||
hasAnalyzed
|
<div className="absolute bottom-1 right-0 flex gap-2">
|
||||||
? "AI analyzed story content..."
|
{!hasAnalyzed ? (
|
||||||
: "Describe your story idea..."
|
// 分析按钮 - 使用ActionButton样式
|
||||||
}
|
<Tooltip
|
||||||
className="w-full bg-transparent border-none outline-none resize-none text-white placeholder:text-white/40 text-sm leading-relaxed"
|
title={
|
||||||
style={{ minHeight: "60px" }}
|
activeImageUrl
|
||||||
onInput={(e) => {
|
? "Analyze image content"
|
||||||
const target = e.target as HTMLTextAreaElement;
|
: "Please upload an image first"
|
||||||
target.style.height = "auto";
|
}
|
||||||
target.style.height =
|
placement="top"
|
||||||
Math.max(target.scrollHeight, 60) + "px";
|
>
|
||||||
}}
|
<ActionButton
|
||||||
|
isCreating={isLoading}
|
||||||
|
handleCreateVideo={uploadAndAnalyzeImage}
|
||||||
|
icon={<Sparkles className="w-5 h-5" />}
|
||||||
/>
|
/>
|
||||||
<div className="absolute bottom-1 right-0 flex gap-2">
|
</Tooltip>
|
||||||
{!hasAnalyzed ? (
|
) : (
|
||||||
// 分析按钮 - 使用ActionButton样式
|
<>
|
||||||
<Tooltip title="Analyze image content" placement="top">
|
{/* Action按钮 - 使用ActionButton样式 */}
|
||||||
<div className="relative group">
|
<Tooltip title="Confirm story creation" placement="top">
|
||||||
<div
|
<ActionButton
|
||||||
data-alt="analyze-button"
|
isCreating={isLoading}
|
||||||
className="relative w-12 h-12 opacity-90 overflow-hidden rounded-xl bg-black z-10"
|
handleCreateVideo={handleConfirm}
|
||||||
>
|
icon={<Clapperboard className="w-5 h-5" />}
|
||||||
<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">
|
</Tooltip>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,8 +1,5 @@
|
|||||||
import { Node, mergeAttributes } from '@tiptap/core'
|
import { Node, mergeAttributes } from '@tiptap/core'
|
||||||
import { ReactNodeViewRenderer, NodeViewWrapper, ReactNodeViewProps } from '@tiptap/react'
|
import { ReactNodeViewRenderer, NodeViewWrapper, ReactNodeViewProps } from '@tiptap/react'
|
||||||
import { motion, AnimatePresence } from 'framer-motion'
|
|
||||||
import { useState } from 'react'
|
|
||||||
import { Check } from 'lucide-react'
|
|
||||||
|
|
||||||
interface HighlightTextAttributes {
|
interface HighlightTextAttributes {
|
||||||
type: string;
|
type: string;
|
||||||
@ -17,30 +14,20 @@ interface HighlightTextOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function HighlightText(props: ReactNodeViewProps) {
|
export function HighlightText(props: ReactNodeViewProps) {
|
||||||
const { text: initialText, color } = props.node.attrs as HighlightTextAttributes
|
const { type, text: initialText, color } = props.node.attrs as HighlightTextAttributes
|
||||||
const [text, setText] = useState(initialText)
|
const isRoleName = type === 'role'
|
||||||
|
|
||||||
const handleInput = (e: React.FormEvent<HTMLSpanElement>) => {
|
|
||||||
const newText = e.currentTarget.textContent || ''
|
|
||||||
setText(newText)
|
|
||||||
// 通知Tiptap更新内容
|
|
||||||
props.updateAttributes({
|
|
||||||
text: newText
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NodeViewWrapper
|
<NodeViewWrapper
|
||||||
as="span"
|
as="span"
|
||||||
data-alt="highlight-text"
|
data-alt="highlight-text"
|
||||||
contentEditable={true}
|
contentEditable={!isRoleName}
|
||||||
suppressContentEditableWarning={true}
|
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>
|
</NodeViewWrapper>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -70,4 +57,4 @@ export const HighlightTextExtension = Node.create<HighlightTextOptions>({
|
|||||||
addNodeView() {
|
addNodeView() {
|
||||||
return ReactNodeViewRenderer(HighlightText);
|
return ReactNodeViewRenderer(HighlightText);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user