From 2b4208fb41449352558f9ac859d75c3c12e4baea Mon Sep 17 00:00:00 2001 From: Xin Wang Date: Tue, 1 Jul 2025 17:47:11 +0800 Subject: [PATCH] =?UTF-8?q?=E8=B0=83=E7=94=A8=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/common.ts | 211 ++++++++++++++++++ api/script_episode.ts | 13 +- api/script_project.ts | 8 +- api/video_flow.ts | 69 ++++++ app/page.tsx | 3 +- components/pages/create-to-video.tsx | 32 ++- .../pages/{homepage.tsx => home-page2.tsx} | 2 +- components/pages/style/create-to-video2.css | 132 +++++++++++ .../style/{home-page.css => home-page2.css} | 0 components/pages/style/top-bar.css | 100 ++++----- 10 files changed, 495 insertions(+), 75 deletions(-) create mode 100644 api/common.ts create mode 100644 api/video_flow.ts rename components/pages/{homepage.tsx => home-page2.tsx} (99%) create mode 100644 components/pages/style/create-to-video2.css rename components/pages/style/{home-page.css => home-page2.css} (100%) diff --git a/api/common.ts b/api/common.ts new file mode 100644 index 0000000..e5f9c27 --- /dev/null +++ b/api/common.ts @@ -0,0 +1,211 @@ +// Common API 相关接口 +import { BASE_URL } from './constants' + +export interface ApiResponse { + code: number + successful: boolean + msg: string + data: T +} + +export interface TokenResponse { + code: number + message: string + data: { + token: string + } + successfull: boolean +} + +export interface TokenWithDomainResponse { + code: number + message: string + data: { + token: string + domain: string + } + successfull: boolean +} + +export interface QiniuUploadResponse { + hash: string + key: string + url?: string +} + + + +// 获取七牛云上传token +export const getUploadToken = async (timeoutMs: number = 10000): Promise => { + // 添加超时控制 + const controller = new AbortController() + const timeoutId = setTimeout(() => { + console.log(`请求超时(${timeoutMs / 1000}秒),正在中断请求...`) + controller.abort() + }, timeoutMs) + + try { + const response = await fetch(`${BASE_URL}/common/get-upload-token`, { + method: "GET", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + }, + signal: controller.signal, + mode: "cors", + }) + + // 请求完成后清除超时 + clearTimeout(timeoutId) + + if (!response.ok) { + const errorText = await response.text() + console.error("获取token响应错误:", response.status, errorText) + throw new Error(`获取token失败: ${response.status} ${response.statusText}`) + } + + const result: TokenResponse = await response.json() + console.log("获取token响应:", result) + + if (result.code === 0 && result.successfull && result.data.token) { + console.log("成功获取token") + return result.data.token + } else { + throw new Error(result.message || "获取token失败,服务器未返回有效token") + } + } catch (error) { + clearTimeout(timeoutId) + console.error("获取上传token失败:", error) + + if (error instanceof Error) { + if (error.name === "AbortError") { + throw new Error("请求超时,请检查网络连接") + } else if (error.message.includes("Failed to fetch")) { + throw new Error("网络连接失败,可能是CORS策略限制或服务器不可达") + } + } + + throw error + } +} + +// 获取七牛云上传token(包含domain) +export const getUploadTokenWithDomain = async (timeoutMs: number = 10000): Promise<{ token: string, domain: string }> => { + // 添加超时控制 + const controller = new AbortController() + const timeoutId = setTimeout(() => { + console.log(`请求超时(${timeoutMs / 1000}秒),正在中断请求...`) + controller.abort() + }, timeoutMs) + + try { + const response = await fetch(`${BASE_URL}/common/get-upload-token`, { + method: "GET", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + }, + signal: controller.signal, + mode: "cors", + }) + + // 请求完成后清除超时 + clearTimeout(timeoutId) + + if (!response.ok) { + const errorText = await response.text() + console.error("获取token响应错误:", response.status, errorText) + throw new Error(`获取token失败: ${response.status} ${response.statusText}`) + } + + const result: TokenWithDomainResponse | TokenResponse = await response.json() + console.log("获取token响应:", result) + + if (result.code === 0 && result.successfull && result.data.token) { + console.log("成功获取token") + // Support both old and new API response formats + const domain = 'domain' in result.data ? result.data.domain : 'cdn.qikongjian.com' + return { + token: result.data.token, + domain: domain + } + } else { + throw new Error(result.message || "获取token失败,服务器未返回有效token") + } + } catch (error) { + clearTimeout(timeoutId) + console.error("获取上传token失败:", error) + + if (error instanceof Error) { + if (error.name === "AbortError") { + throw new Error("请求超时,请检查网络连接") + } else if (error.message.includes("Failed to fetch")) { + throw new Error("网络连接失败,可能是CORS策略限制或服务器不可达") + } + } + + throw error + } +} + +// 生成唯一文件名 +export const generateUniqueFileName = (originalName: string): string => { + const timestamp = Date.now() + const randomStr = Math.random().toString(36).substring(2, 8) + const extension = originalName.split(".").pop() + return `videos/${timestamp}_${randomStr}.${extension}` +} + +// 七牛云上传 +export const uploadToQiniu = async ( + file: File, + token: string, + onProgress?: (progress: number) => void +): Promise => { + const uniqueFileName = generateUniqueFileName(file.name) + + const formData = new FormData() + formData.append("token", token) + formData.append("key", uniqueFileName) + formData.append("file", file) + + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest() + + xhr.upload.addEventListener("progress", (event) => { + if (event.lengthComputable && onProgress) { + const progress = Math.round((event.loaded / event.total) * 100) + onProgress(progress) + } + }) + + xhr.addEventListener("load", () => { + if (xhr.status >= 200 && xhr.status < 300) { + try { + const response: QiniuUploadResponse = JSON.parse(xhr.responseText) + const qiniuUrl = `https://cdn.qikongjian.com/${response.key || uniqueFileName}` + console.log("七牛云上传成功:", response) + resolve(qiniuUrl) + } catch (error) { + console.error("解析响应失败:", error, "原始响应:", xhr.responseText) + reject(new Error(`解析上传响应失败: ${xhr.responseText}`)) + } + } else { + console.error("七牛云上传失败:", xhr.status, xhr.statusText, xhr.responseText) + reject(new Error(`上传失败: ${xhr.status} ${xhr.statusText}`)) + } + }) + + xhr.addEventListener("error", (e) => { + console.error("上传网络错误:", e) + reject(new Error("网络错误,上传失败")) + }) + + xhr.addEventListener("abort", () => { + reject(new Error("上传被取消")) + }) + + xhr.open("POST", "https://up-z2.qiniup.com") + xhr.send(formData) + }) +} \ No newline at end of file diff --git a/api/script_episode.ts b/api/script_episode.ts index 058f6c5..773c641 100644 --- a/api/script_episode.ts +++ b/api/script_episode.ts @@ -1,4 +1,5 @@ import { post } from './request'; +import { ApiResponse } from './common'; // 创建剧集的数据类型 export interface CreateScriptEpisodeRequest { @@ -33,15 +34,7 @@ export interface ScriptEpisode { created_at: string; } -// 创建剧集响应格式 -export interface EpisodeApiResponse { - code: number; - msg: string; - data: T; - successfull: boolean; -} - // 创建剧集 -export const createScriptEpisode = async (data: CreateScriptEpisodeRequest): Promise> => { - return post>('/script_episode/create', data); +export const createScriptEpisode = async (data: CreateScriptEpisodeRequest): Promise> => { + return post>('/script_episode/create', data); }; \ No newline at end of file diff --git a/api/script_project.ts b/api/script_project.ts index db005b6..615e99e 100644 --- a/api/script_project.ts +++ b/api/script_project.ts @@ -1,4 +1,5 @@ import { post } from './request'; +import { ApiResponse } from './common'; // 创建剧本项目的数据类型 export interface CreateScriptProjectRequest { @@ -17,13 +18,6 @@ export interface CreateScriptProjectRequest { resolution?: number; } -// API响应类型 -export interface ApiResponse { - code: number; - message: string; - data: T; -} - // 创建剧本项目响应数据类型 export interface ScriptProject { id: number; diff --git a/api/video_flow.ts b/api/video_flow.ts new file mode 100644 index 0000000..ddde4e8 --- /dev/null +++ b/api/video_flow.ts @@ -0,0 +1,69 @@ +import { post } from './request'; +import { ProjectTypeEnum } from './enums'; +import { ApiResponse } from './common'; + +// 剧本转分镜头请求接口 +export interface ScriptToSceneRequest { + script: string; + project_type: ProjectTypeEnum.SCRIPT_TO_VIDEO; +} + +// 视频转分镜头请求接口 +export interface VideoToSceneRequest { + video_url: string; + project_type: ProjectTypeEnum.VIDEO_TO_VIDEO; +} + +// 转换分镜头请求类型 +export type ConvertScenePromptRequest = ScriptToSceneRequest | VideoToSceneRequest; + +// 转换分镜头响应数据接口 +export interface ConvertScenePromptData { + task_id: string; + status: string; + shots_count?: number; // 剧本模式返回的分镜头数量 + video_url?: string; // 视频模式返回的视频链接 + estimated_time: number; // 预估处理时间(秒) +} + +// 转换分镜头响应接口 +export type ConvertScenePromptResponse = ApiResponse; + +/** + * 将剧本或视频转换为分镜头提示词 + * @param request - 请求参数,根据 project_type 自动判断是剧本还是视频模式 + * @returns Promise + */ +export const convertScenePrompt = async ( + request: ConvertScenePromptRequest +): Promise => { + return post('/video_flow/convert-scene-prompt', request); +}; + +/** + * 剧本转分镜头提示词 + * @param script - 剧本内容 + * @returns Promise + */ +export const convertScriptToScene = async ( + script: string +): Promise => { + return convertScenePrompt({ + script, + project_type: ProjectTypeEnum.SCRIPT_TO_VIDEO + }); +}; + +/** + * 视频转分镜头提示词 + * @param video_url - 视频链接 + * @returns Promise + */ +export const convertVideoToScene = async ( + video_url: string +): Promise => { + return convertScenePrompt({ + video_url, + project_type: ProjectTypeEnum.VIDEO_TO_VIDEO + }); +}; diff --git a/app/page.tsx b/app/page.tsx index 6cde325..2976667 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,6 +1,5 @@ import { DashboardLayout } from '@/components/layout/dashboard-layout'; -// import { HomePage } from '@/components/pages/home-page'; -import { HomePage2 } from '@/components/pages/homepage'; +import { HomePage2 } from '@/components/pages/home-page2'; import OAuthCallbackHandler from '@/components/ui/oauth-callback-handler'; export default function Home() { diff --git a/components/pages/create-to-video.tsx b/components/pages/create-to-video.tsx index 050dbf4..2e3386f 100644 --- a/components/pages/create-to-video.tsx +++ b/components/pages/create-to-video.tsx @@ -8,7 +8,7 @@ import { useRouter } from 'next/navigation'; import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger, SheetClose } from "@/components/ui/sheet"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; -import './style/create-to-video.css'; +import './style/create-to-video2.css'; import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable"; import { Skeleton } from "@/components/ui/skeleton"; import LiquidGlass from '@/plugins/liquid-glass/index' @@ -19,11 +19,15 @@ import dynamic from 'next/dynamic'; import { ProjectTypeEnum, ModeEnum, ResolutionEnum } from "@/api/enums"; import { createScriptProject, CreateScriptProjectRequest } from "@/api/script_project"; import { createScriptEpisode, CreateScriptEpisodeRequest } from "@/api/script_episode"; +import { getUploadTokenWithDomain, uploadToQiniu } from "@/api/common"; const JoyrideNoSSR = dynamic(() => import('react-joyride'), { ssr: false, }); +// 导入Step类型 +import type { Step } from 'react-joyride'; + // 添加自定义滚动条样式 const scrollbarStyles = ` .custom-scrollbar::-webkit-scrollbar { @@ -55,6 +59,7 @@ export function CreateToVideo2() { const [isClient, setIsClient] = useState(false); const [isExpanded, setIsExpanded] = useState(false); const [videoUrl, setVideoUrl] = useState(''); + const [isUploading, setIsUploading] = useState(false); const containerRef = useRef(null); const [activeTab, setActiveTab] = useState('script'); const [isFocus, setIsFocus] = useState(false); @@ -64,16 +69,33 @@ export function CreateToVideo2() { const editorRef = useRef(null); const [runTour, setRunTour] = useState(true); - const handleUploadVideo = () => { + const handleUploadVideo = async () => { console.log('upload video'); // 打开文件选择器 const input = document.createElement('input'); input.type = 'file'; input.accept = 'video/*'; - input.onchange = (e) => { + input.onchange = async (e) => { const file = (e.target as HTMLInputElement).files?.[0]; if (file) { - setVideoUrl(URL.createObjectURL(file)); + try { + setIsUploading(true); + + // 获取上传token + const { token } = await getUploadTokenWithDomain(); + + // 上传到七牛云 + const videoUrl = await uploadToQiniu(file, token); + + // 上传成功,设置视频URL + setVideoUrl(videoUrl); + console.log('视频上传成功:', videoUrl); + } catch (error) { + console.error('上传错误:', error); + alert('上传失败,请稍后重试'); + } finally { + setIsUploading(false); + } } } input.click(); @@ -111,7 +133,7 @@ export function CreateToVideo2() { alert(`创建剧集失败: ${episodeResponse.msg}`); } } else { - alert(`创建项目失败: ${projectResponse.message}`); + alert(`创建项目失败: ${projectResponse.msg}`); } } catch (error) { alert("创建项目时发生错误,请稍后重试"); diff --git a/components/pages/homepage.tsx b/components/pages/home-page2.tsx similarity index 99% rename from components/pages/homepage.tsx rename to components/pages/home-page2.tsx index baaa80b..be9b0a7 100644 --- a/components/pages/homepage.tsx +++ b/components/pages/home-page2.tsx @@ -2,7 +2,7 @@ import { useState, useRef } from "react"; import { Plus, Table, AlignHorizontalSpaceAround, Loader2 } from "lucide-react"; -import "./style/home-page.css"; +import "./style/home-page2.css"; import { useRouter } from "next/navigation"; import { VideoScreenLayout } from '@/components/video-screen-layout'; import { VideoGridLayout } from '@/components/video-grid-layout'; diff --git a/components/pages/style/create-to-video2.css b/components/pages/style/create-to-video2.css new file mode 100644 index 0000000..50f5452 --- /dev/null +++ b/components/pages/style/create-to-video2.css @@ -0,0 +1,132 @@ +.video-tool-component { + position: fixed; + left: 50%; + bottom: 1rem; + --tw-translate-x: calc(-50% + 34.5px); + transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); +} + +.video-storyboard-tools { + border: 1px solid rgba(255, 255, 255, .2); + box-shadow: 0 4px 20px #0009; +} + +.video-storyboard-tools .tool-submit-button { + display: flex; + height: 36px; + width: 120px; + cursor: pointer; + align-items: center; + justify-content: center; + gap: 2px; + border-radius: 10px; + font-size: .875rem; + line-height: 1.25rem; + font-weight: 600; + --tw-text-opacity: 1; + color: rgb(29 33 41 / var(--tw-text-opacity)); + background-color: #fff; +} + +.video-storyboard-tools .tool-submit-button.disabled { + background-color: #fff; + opacity: .3; + cursor: not-allowed; +} + +.storyboard-tools-tab { + border-radius: 16px 16px 0 0; + background: #ffffff0d; +} + +.storyboard-tools-tab .tab-item { + position: relative; + cursor: pointer; + --tw-text-opacity: 1; + color: rgb(255 255 255 / var(--tw-text-opacity)); + line-height: 32px; +} + +.storyboard-tools-tab .tab-item.active, +.storyboard-tools-tab .tab-item.active span { + opacity: 1; +} + +.storyboard-tools-tab .tab-item.active:after { + content: ""; + position: absolute; + bottom: -8px; + left: 50%; + width: 30px; + height: 2px; + border-radius: 2px; + background-color: #fff; + transform: translate(-50%); +} + +.video-prompt-editor .editor-content { + line-height: 26px; + outline: none; + white-space: pre-wrap; +} + +.video-prompt-editor .editor-content[contenteditable] { + display: inline-block; + width: 100%; +} + +.video-prompt-editor.focus { + background-color: #ffffff0d; +} + +.underline { + text-decoration-line: underline; +} + +.tool-scroll-box-content { + scrollbar-width: none; + -ms-overflow-style: none; +} + +.tool-operation-button { + display: flex; + height: 36px; + cursor: pointer; + align-items: center; + gap: 6px; + border-radius: 20px; + padding-left: 16px; + padding-right: 16px; + font-size: .875rem; + line-height: 1.25rem; + background-color: #ffffff0d; +} + +.tool-operation-button:hover { + background-color: #ffffff1a; +} + +/* 下拉菜单样式 */ +.mode-dropdown.ant-dropdown .ant-dropdown-menu { + background: rgba(25, 27, 30, 0.95); + backdrop-filter: blur(15px); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 12px; + padding: 8px; + min-width: 280px; +} + +.mode-dropdown.ant-dropdown .ant-dropdown-menu-item { + padding: 12px; + border-radius: 8px; + color: rgba(255, 255, 255, 0.9); + transition: all 0.3s ease; +} + +.mode-dropdown.ant-dropdown .ant-dropdown-menu-item:hover { + background: rgba(255, 255, 255, 0.1); +} + +.mode-dropdown.ant-dropdown .ant-dropdown-menu-item-selected { + background: rgba(255, 255, 255, 0.1); +} \ No newline at end of file diff --git a/components/pages/style/home-page.css b/components/pages/style/home-page2.css similarity index 100% rename from components/pages/style/home-page.css rename to components/pages/style/home-page2.css diff --git a/components/pages/style/top-bar.css b/components/pages/style/top-bar.css index 5a437e6..5424fe6 100644 --- a/components/pages/style/top-bar.css +++ b/components/pages/style/top-bar.css @@ -1,55 +1,55 @@ .header { - pointer-events: none; - transition: color .2s; -} -@keyframes btnAnimation2 { - 0% { - transform: translate(0, 0); + pointer-events: none; + transition: color .2s; } - - 100% { - transform: translate(0, 100%); + @keyframes btnAnimation2 { + 0% { + transform: translate(0, 0); + } + + 100% { + transform: translate(0, 100%); + } } -} - -@media (pointer: fine) { - .roll.on .translate { - animation-name: btnAnimation2; - animation-play-state: running; - animation-iteration-count: 1; - animation-duration: .4s; - animation-timing-function: cubic-bezier(.16, .03, .08, 1.55); + + @media (pointer: fine) { + .roll.on .translate { + animation-name: btnAnimation2; + animation-play-state: running; + animation-iteration-count: 1; + animation-duration: .4s; + animation-timing-function: cubic-bezier(.16, .03, .08, 1.55); + } } -} - -.roll .translate>span:last-child { - position: absolute; - bottom: 100%; - left: 0; -} - -.logo { - display: block; - height: 100%; - object-fit: contain; -} - -.header-wrapper .logo, -.roll2>span .logo, -.roll3>span .logo { - height: 22px; -} - -.roll .translate { - display: inline-block; -} - -.header-wrapper, -.header .link-logo { - pointer-events: initial; - overflow: hidden; -} -.header button { - pointer-events: initial; -} \ No newline at end of file + + .roll .translate>span:last-child { + position: absolute; + bottom: 100%; + left: 0; + } + + .logo { + display: block; + height: 100%; + object-fit: contain; + } + + .header-wrapper .logo, + .roll2>span .logo, + .roll3>span .logo { + height: 22px; + } + + .roll .translate { + display: inline-block; + } + + .header-wrapper, + .header .link-logo { + pointer-events: initial; + overflow: hidden; + } + .header button { + pointer-events: initial; + } \ No newline at end of file