merge dev & fix conflict

This commit is contained in:
moux1024 2025-09-23 14:26:47 +08:00
commit 8daf561674
27 changed files with 742 additions and 805 deletions

View File

@ -4,7 +4,7 @@ NEXT_PUBLIC_BASE_URL = https://77.smartvideo.py.qikongjian.com
NEXT_PUBLIC_CUT_URL = https://77.smartcut.py.qikongjian.com
# 失败率
NEXT_PUBLIC_ERROR_CONFIG = 0.1
NEXT_PUBLIC_ERROR_CONFIG = 0.5
# Google OAuth配置
NEXT_PUBLIC_GOOGLE_CLIENT_ID=847079918888-o1nne8d3ij80dn20qurivo987pv07225.apps.googleusercontent.com
NEXT_PUBLIC_GOOGLE_REDIRECT_URI=https://www.movieflow.net/api/auth/google/callback

View File

@ -367,6 +367,8 @@ export interface VideoFlowProjectResponse {
final_simple_video: string;
/** 最终视频 */
final_video: string;
/** 画面比例 */
aspect_ratio: string;
}
/**
*

View File

@ -1,6 +1,7 @@
import { post } from './request';
import { ApiResponse } from './common';
import { Character } from './video_flow';
import { AspectRatioValue } from '@/components/ChatInputBox/AspectRatioSelector';
// 创建剧集的数据类型
export interface CreateScriptEpisodeRequest {
@ -78,6 +79,7 @@ interface MovieProject {
last_message: string;
updated_at: string;
created_at: string;
aspect_ratio: AspectRatioValue;
}
interface ListMovieProjectsResponse {

View File

@ -1,244 +1,10 @@
import { NextRequest, NextResponse } from 'next/server';
import type { OAuthCallbackParams } from '@/app/types/google-oauth';
/**
* Google OAuth回调处理API
* Google OAuth返回的授权码
* POST /users/oauth/callback
* Google GET
*/
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { code, state, inviteCode } = body;
// 验证必需参数
if (!code || !state) {
return NextResponse.json(
{
success: false,
message: 'Missing required parameters: code and state'
},
{ status: 400 }
);
}
// 开发模式使用测试环境的OAuth处理
const isDevelopment = process.env.NODE_ENV === 'development';
const useTestEnv = isDevelopment; // 开发环境默认使用测试环境
if (useTestEnv) {
console.log('🧪 开发模式使用模拟OAuth响应');
// 解析state参数获取origin信息
let stateData: any = {};
try {
stateData = JSON.parse(state);
} catch (e) {
console.warn('无法解析state参数:', state);
}
// 模拟成功的OAuth响应
const mockResponse = {
success: true,
data: {
token: 'dev-mock-token-' + Date.now(),
user: {
userId: 'dev-user-' + Math.random().toString(36).substr(2, 9),
userName: 'Development User',
name: 'Dev User',
email: 'dev@movieflow.com',
authType: 'GOOGLE',
isNewUser: false
},
userInfo: {
userId: 'dev-user-' + Math.random().toString(36).substr(2, 9),
userName: 'Development User',
name: 'Dev User',
email: 'dev@movieflow.com',
authType: 'GOOGLE',
isNewUser: false
},
message: 'Development mode - Google authentication simulated'
}
};
console.log('返回模拟OAuth响应:', mockResponse);
return NextResponse.json(mockResponse);
}
// 解析state参数
let stateData: any = {};
try {
stateData = JSON.parse(state);
} catch (e) {
console.warn('无法解析state参数:', state);
return NextResponse.json(
{
success: false,
message: 'Invalid state parameter'
},
{ status: 400 }
);
}
console.log('Google OAuth回调处理开始:', {
codeLength: code.length,
stateData,
inviteCode
});
// 第一步使用authorization code向Google换取access token和id_token
const googleClientId = process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID || '847079918888-o1nne8d3ij80dn20qurivo987pv07225.apps.googleusercontent.com';
const googleClientSecret = process.env.GOOGLE_CLIENT_SECRET || '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';
if (!googleClientSecret) {
console.error('Google Client Secret未配置');
return NextResponse.json(
{
success: false,
message: 'Google Client Secret not configured'
},
{ status: 500 }
);
}
console.log('开始向Google交换token...');
// 创建fetch配置包含超时和重试机制
const fetchOptions = {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
code,
client_id: googleClientId,
client_secret: googleClientSecret,
redirect_uri: getRedirectUri(request),
grant_type: 'authorization_code',
}),
// 增加超时时间到30秒
signal: AbortSignal.timeout(30000)
};
let tokenResponse;
try {
tokenResponse = await fetch('https://oauth2.googleapis.com/token', fetchOptions);
} catch (fetchError: any) {
console.error('Google API连接失败:', fetchError.message);
// 如果是超时错误,提供更友好的错误信息
if (fetchError.name === 'TimeoutError' || fetchError.code === 'UND_ERR_CONNECT_TIMEOUT') {
return NextResponse.json(
{
success: false,
message: 'Google authentication service is temporarily unavailable. Please check your network connection and try again.'
},
{ status: 503 }
);
}
throw fetchError;
}
console.log('Google token exchange响应状态:', tokenResponse.status);
if (!tokenResponse.ok) {
const errorText = await tokenResponse.text();
console.error('Google token exchange failed:', errorText);
return NextResponse.json(
{
success: false,
message: 'Failed to exchange authorization code for tokens'
},
{ status: 400 }
);
}
const tokenData = await tokenResponse.json();
console.log('Google token exchange成功获得token');
const { id_token } = tokenData;
if (!id_token) {
console.error('No id_token received from Google');
return NextResponse.json(
{
success: false,
message: 'No id_token received from Google'
},
{ status: 400 }
);
}
// 第二步使用id_token调用Java后端
const javaBaseUrl = process.env.NEXT_PUBLIC_JAVA_URL || 'https://auth.test.movieflow.ai';
console.log('开始调用Java后端:', javaBaseUrl);
const backendResponse = await fetch(`${javaBaseUrl}/api/auth/google/login`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify({
idToken: id_token, // 使用从Google获取的id_token
action: 'auto', // 自动判断登录或注册
inviteCode: inviteCode || stateData.inviteCode || undefined
})
});
console.log('Java后端响应状态:', backendResponse.status);
const backendResult = await backendResponse.json();
if (!backendResponse.ok || !backendResult.success) {
console.error('Java后端处理Google OAuth失败:', backendResult);
return NextResponse.json(
{
success: false,
message: backendResult.message || 'Google authentication failed'
},
{ status: backendResponse.status || 500 }
);
}
console.log('Google OAuth认证成功:', {
userId: backendResult.data?.user?.userId,
email: backendResult.data?.user?.email
});
// 返回成功结果
return NextResponse.json({
success: true,
data: {
token: backendResult.data.token,
user: backendResult.data.userInfo || backendResult.data.user,
message: 'Google authentication successful'
}
});
} catch (error: any) {
console.error('Google OAuth回调处理错误:', error);
// 检查是否是网络连接错误
if (error.code === 'ECONNREFUSED' || error.message?.includes('fetch failed')) {
console.error('网络连接失败可能是Java后端服务不可用');
return NextResponse.json(
{
success: false,
message: 'Backend service unavailable. Please try again later.'
},
{ status: 503 }
);
}
return NextResponse.json(
{
success: false,
message: error.message || 'Internal server error during Google OAuth callback'
},
{ status: 500 }
);
}
}
/**
* redirect_uri
@ -271,11 +37,11 @@ export async function GET(request: NextRequest) {
if (!code || !state) {
return NextResponse.json(
{
success: false,
message: 'Missing required parameters: code and state'
},
{ status: 400 }
{
success: false,
message: 'Missing required parameters: code and state'
},
{ status: 400 }
);
}

View File

@ -151,7 +151,7 @@ export default function SharePage(): JSX.Element {
const toggleRow = React.useCallback((id: string) => {
setExpandedRowIds((prev) => {
setExpandedRowIds((prev: Set<string>) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
@ -185,45 +185,62 @@ export default function SharePage(): JSX.Element {
<span className="text-sm font-medium text-custom-blue/50">Step 1</span>
<span className="rounded bg-white/10 px-2 py-0.5 text-xs text-white bg-custom-purple/50">Share</span>
</div>
<p data-alt="step-desc" className="mt-2 text-sm text-white/70">Copy your invitation code and share it with friends.</p>
<p data-alt="step-desc" className="mt-2 text-sm text-white/70">Copy your invitation link and share it with friends.</p>
</li>
<li data-alt="step" className="rounded-md border border-white/20 p-4">
<div data-alt="step-header" className="flex items-center justify-between">
<span className="text-sm font-medium text-custom-blue/50">Step 2</span>
<span className="rounded bg-white/10 px-2 py-0.5 text-xs text-white bg-custom-purple/50">Register</span>
</div>
<p data-alt="step-desc" className="mt-2 text-sm text-white/70">Friends register and enter your invitation code.</p>
<p data-alt="step-desc" className="mt-2 text-sm text-white/70">Friends click the link and register directly.</p>
</li>
<li data-alt="step" className="rounded-md border border-white/20 p-4">
<div data-alt="step-header" className="flex items-center justify-between">
<span className="text-sm font-medium text-custom-blue/50">Step 3</span>
<span className="rounded bg-white/10 px-2 py-0.5 text-xs text-white bg-custom-purple/50">Reward</span>
</div>
<p data-alt="step-desc" className="mt-2 text-sm text-white/70">You both receive rewards after successful registration.</p>
<p data-alt="step-desc" className="mt-2 text-sm text-white/70">You both receive rewards after your friend activates their account.</p>
</li>
</ol>
</section>
{/* Section 2: My Invitation Code */}
<section data-alt="my-invite-code" className="mb-8 rounded-lg border border-white/20 bg-black p-6 shadow-sm">
<div data-alt="code-panel" className="mt-4 grid gap-6 sm:grid-cols-4">
<div data-alt="code-box" className="sm:col-span-2">
<h2 data-alt="section-title" className="text-lg font-medium text-white">My Invitation Code</h2>
{/* Section 2: My Invitation Link */}
<section data-alt="my-invite-link" className="mb-8 rounded-lg border border-white/20 bg-black p-6 shadow-sm">
<div data-alt="link-panel" className="mt-4 grid gap-6 sm:grid-cols-4">
<div data-alt="link-box" className="sm:col-span-2">
<h2 data-alt="section-title" className="text-lg font-medium text-white">My Invitation Link</h2>
<div className="flex items-center gap-3">
<div data-alt="code" className="rounded-md border border-white/20 bg-white/10 px-4 py-2 text-lg font-semibold tracking-wider text-white">
{inviteCode}
<div data-alt="link-container" className="relative w-full max-w-2xl overflow-hidden rounded-md border border-white/20 bg-white/10">
<div
data-alt="link-content"
className="relative px-4 py-2 text-sm font-mono text-white/90 whitespace-nowrap overflow-hidden"
style={{
background: 'linear-gradient(90deg, rgba(255,255,255,0.9) 0%, rgba(255,255,255,0.7) 50%, rgba(255,255,255,0.3) 70%, rgba(255,255,255,0.1) 90%, rgba(255,255,255,0) 100%)',
WebkitBackgroundClip: 'text',
backgroundClip: 'text',
maskImage: 'linear-gradient(90deg, rgba(0,0,0,1) 0%, rgba(0,0,0,1) 70%, rgba(0,0,0,0.5) 85%, rgba(0,0,0,0) 100%)',
WebkitMaskImage: 'linear-gradient(90deg, rgba(0,0,0,1) 0%, rgba(0,0,0,1) 70%, rgba(0,0,0,0.5) 85%, rgba(0,0,0,0) 100%)'
}}
>
{inviteCode ? `${'https://www.movieflow.ai'}/signup?inviteCode=${inviteCode}` : '-'}
</div>
{/* 右侧渐变遮挡 */}
<div
data-alt="right-mask"
className="absolute right-0 top-0 h-full w-16 bg-gradient-to-l from-black/80 to-transparent pointer-events-none"
/>
</div>
<button
data-alt="copy-button"
className="inline-flex h-9 items-center justify-center rounded-full bg-gradient-to-r from-custom-blue to-custom-purple px-3 text-sm font-medium text-black/90 hover:opacity-90 active:translate-y-px"
className="inline-flex h-9 items-center justify-center rounded-full bg-gradient-to-r from-custom-blue to-custom-purple px-3 text-sm font-medium text-black/90 hover:opacity-90 active:translate-y-px flex-shrink-0"
onClick={handleCopy}
type="button"
aria-label="Copy invitation code"
aria-label="Copy invitation link"
>
{copyState === 'copied' ? 'Copied' : copyState === 'error' ? 'Failed' : 'Copy'}
</button>
</div>
<p data-alt="hint" className="mt-2 text-xs text-white/60">Share this code. Your friends can enter it during registration.</p>
<p data-alt="hint" className="mt-2 text-xs text-white/60">Share this link. Your friends can register directly through it.</p>
</div>
<div data-alt="total-credits" className="flex flex-col items-start justify-center rounded-md border border-white/20 bg-white/5 p-4">
<span className="text-sm text-white/70">Total Credits</span>
@ -233,7 +250,7 @@ export default function SharePage(): JSX.Element {
<div data-alt="invited-count" className="flex flex-col items-start justify-center rounded-md border border-white/20 bg-white/5 p-4">
<span className="text-sm text-white/70">Invited Friends</span>
<span className="mt-1 text-2xl font-semibold text-transparent bg-clip-text bg-gradient-to-r from-custom-blue to-custom-purple">{invitedCount}</span>
<span className="mt-2 text-xs text-white/60">Points detail will be available soon.</span>
<span className="mt-2 text-xs text-white/60">Point details will be available soon.</span>
</div>
</div>
</section>
@ -245,9 +262,9 @@ export default function SharePage(): JSX.Element {
<div data-alt="pagination" className="flex items-center gap-2">
<button
data-alt="prev-page"
className="inline-flex h-8 items-center justify-center rounded border border-gray-300 bg-white px-2 text-sm text-gray-700 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50"
className="inline-flex h-6 items-center justify-center rounded border border-gray-300 bg-white px-2 text-sm text-gray-700 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50"
type="button"
onClick={() => setPageIndex((p) => Math.max(0, p - 1))}
onClick={() => setPageIndex((p: number) => Math.max(0, p - 1))}
disabled={!pagination.has_prev}
aria-label="Previous page"
>
@ -258,9 +275,9 @@ export default function SharePage(): JSX.Element {
</span>
<button
data-alt="next-page"
className="inline-flex h-8 items-center justify-center rounded border border-gray-300 bg-white px-2 text-sm text-gray-700 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50"
className="inline-flex h-6 items-center justify-center rounded border border-gray-300 bg-white px-2 text-sm text-gray-700 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50"
type="button"
onClick={() => setPageIndex((p) => Math.min(totalPages - 1, p + 1))}
onClick={() => setPageIndex((p: number) => Math.min(totalPages - 1, p + 1))}
disabled={!pagination.has_next}
aria-label="Next page"
>
@ -279,7 +296,7 @@ export default function SharePage(): JSX.Element {
</tr>
</thead>
<tbody data-alt="table-body" className="divide-y divide-white/10 bg-black">
{pagedRecords.map((r) => {
{pagedRecords.map((r: any) => {
const inviteRewardDisplay = r.reward_status === 1 ? r.activation_credits : 0;
const payRewardDisplay = r.first_payment_credits;
const totalReward = inviteRewardDisplay + payRewardDisplay;
@ -321,7 +338,7 @@ export default function SharePage(): JSX.Element {
</tr>
<tr>
<td className="px-2 py-2">{payRewardDisplay ? payRewardDisplay : 'Unpaid'}</td>
<td className="px-2 py-2">First Pay Reward</td>
<td className="px-2 py-2">First Payment Reward</td>
</tr>
</tbody>
</table>

View File

@ -78,60 +78,126 @@ export default function OAuthCallback() {
console.log('State数据:', stateData);
console.log('最终使用的邀请码:', finalInviteCode);
// 调用后端处理授权码 - 直接调用Java后端
const JAVA_BASE_URL = process.env.NEXT_PUBLIC_JAVA_URL || 'https://auth.test.movieflow.ai';
// 直接处理 OAuth 回调两步流程Java验证 + Python注册
console.log('开始直接处理 OAuth 回调,无需经过 API 路由');
console.log('调用Java后端OAuth接口:', `${JAVA_BASE_URL}/api/auth/google/callback`);
// 第一步调用Java验证接口只验证不创建用户
const javaBaseUrl = 'https://auth.test.movieflow.ai';
console.log('🔧 调用 Java 验证接口:', javaBaseUrl);
const response = await fetch(`${JAVA_BASE_URL}/api/auth/google/callback`, {
const verifyResponse = await fetch(`${javaBaseUrl}/api/auth/google/callback`, {
method: 'POST',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify({
code: params.code,
state: params.state,
...(finalInviteCode && { inviteCode: finalInviteCode })
code: params.code, // Google authorization code
state: params.state, // state参数
inviteCode: finalInviteCode, // 邀请码
skipUserCreation: true // 🔑 关键:只验证不创建用户
})
});
if (!response.ok) {
// 处理HTTP错误状态
const errorText = await response.text();
console.error('OAuth API调用失败:', response.status, errorText);
throw new Error(`OAuth API调用失败 (${response.status}): ${errorText}`);
console.log('Java验证接口响应状态:', verifyResponse.status);
const verifyResult = await verifyResponse.json();
if (!verifyResponse.ok || !verifyResult.success) {
console.error('Java验证接口处理失败:', verifyResult);
throw new Error(verifyResult.message || 'Google token verification failed');
}
const result = await response.json();
console.log('OAuth API响应:', result);
console.log('Google Token验证成功:', {
email: verifyResult.data?.email,
name: verifyResult.data?.name
});
if (result.success) {
console.log('Google登录成功:', result);
setStatus("success");
setMessage("Login successful! Redirecting to dashboard...");
// 第二步调用Python注册接口进行用户创建和积分发放
const smartvideoBaseUrl = process.env.NEXT_PUBLIC_BASE_URL || 'https://77.smartvideo.py.qikongjian.com';
console.log('🔧 调用 Python 注册接口:', smartvideoBaseUrl);
// 保存用户信息到localStorage (使用认证库的统一键名)
if (result.data?.user) {
localStorage.setItem('currentUser', JSON.stringify(result.data.user));
}
if (result.data?.token) {
localStorage.setItem('token', result.data.token);
}
const registerResponse = await fetch(`${smartvideoBaseUrl}/api/user_fission/register_with_invite`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify({
email: verifyResult.data.email,
name: verifyResult.data.name,
auth_type: 'GOOGLE',
google_user_info: {
email: verifyResult.data.email,
name: verifyResult.data.name,
picture: verifyResult.data.picture || '',
googleId: verifyResult.data.googleId || verifyResult.data.id || '',
verified: verifyResult.data.verified || true,
inviteCode: finalInviteCode
},
invite_code: finalInviteCode
})
});
// 2秒后跳转到主页
setTimeout(() => {
// 修复: Google登录成功后应该跳转到主页面而不是来源页面
const returnUrl = '/movies';
window.location.href = returnUrl;
}, 2000);
} else {
throw new Error(result.message || 'Google登录失败');
console.log('Python注册接口响应状态:', registerResponse.status);
const registerResult = await registerResponse.json();
if (!registerResponse.ok || !registerResult.successful) {
console.error('Python注册接口处理失败:', registerResult);
throw new Error(registerResult.message || 'User registration failed');
}
console.log('Google OAuth注册成功:', {
userId: registerResult.data?.user_id,
email: registerResult.data?.email
});
// 处理成功结果
console.log('Google登录成功:', registerResult);
setStatus("success");
setMessage("Login successful! Redirecting to dashboard...");
// 保存用户信息到localStorage
const userData = {
userId: registerResult.data.user_id,
userName: registerResult.data.name,
name: registerResult.data.name,
email: registerResult.data.email,
authType: registerResult.data.auth_type || 'GOOGLE',
isNewUser: true,
inviteCode: registerResult.data.invite_code
};
localStorage.setItem('currentUser', JSON.stringify(userData));
if (registerResult.data.token) {
localStorage.setItem('token', registerResult.data.token);
}
// 2秒后跳转到主页
setTimeout(() => {
const returnUrl = '/movies';
window.location.href = returnUrl;
}, 2000);
} catch (error: any) {
console.error("OAuth callback error:", error);
// 检查是否是网络连接错误
if (error.code === 'ECONNREFUSED' || error.message?.includes('fetch failed')) {
console.error('网络连接失败,可能是后端服务不可用');
setStatus("error");
setMessage('Backend service unavailable. Please try again later.');
return;
}
// 检查是否是 JSON 解析错误
if (error.message?.includes('JSON') || error.name === 'SyntaxError') {
console.error('响应数据解析失败:', error);
setStatus("error");
setMessage('Invalid response format from backend services');
return;
}
// 处理邮箱冲突错误
if (error.type === 'EMAIL_CONFLICT') {
setStatus("conflict");
setMessage(error.message);

View File

@ -50,7 +50,7 @@ import { PcTemplateModal } from "./PcTemplateModal";
import { H5TemplateDrawer } from "./H5TemplateDrawer";
import { PcPhotoStoryModal } from "./PcPhotoStoryModal";
import { H5PhotoStoryDrawer } from "./H5PhotoStoryDrawer";
import { AspectRatioSelector } from "./AspectRatioSelector";
import { AspectRatioSelector, AspectRatioValue } from "./AspectRatioSelector";
const LauguageOptions = [
{ value: "english", label: "English", isVip: false, code:'EN' },
@ -131,7 +131,7 @@ export function ChatInputBox({ noData }: { noData: boolean }) {
language: string;
videoDuration: string;
expansion_mode: boolean;
aspect_ratio: "VIDEO_ASPECT_RATIO_LANDSCAPE" | "VIDEO_ASPECT_RATIO_PORTRAIT";
aspect_ratio: AspectRatioValue;
};
const [configOptions, setConfigOptions] = useState<ConfigOptions>({
@ -469,15 +469,15 @@ export function ChatInputBox({ noData }: { noData: boolean }) {
</Dropdown>
{/* 分隔线(移动端隐藏,避免拥挤) */}
{/* <div className="hidden sm:block w-px h-4 bg-white/[0.20]"></div> */}
<div className="hidden sm:block w-px h-4 bg-white/[0.20]"></div>
{/* 横/竖屏选择 上线暂时不开放 */}
{/* <AspectRatioSelector
{/* 横/竖屏选择 */}
<AspectRatioSelector
value={configOptions.aspect_ratio}
onChange={(v) => onConfigChange('aspect_ratio', v)}
placement="top"
className={`${isMobile ? '!px-1' : ''}`}
/> */}
/>
</div>
{/* 右侧Action按钮 */}

View File

@ -359,12 +359,12 @@ export const H5PhotoStoryDrawer = ({
<div data-alt="bottom-action-bar" className="sticky bottom-0 left-0 right-0 backdrop-blur border-t border-white/10 px-3 py-2">
<div className="flex items-center justify-end gap-2">
{/* 横/竖屏选择 上线暂时不开放 */}
{/* <AspectRatioSelector
{/* 横/竖屏选择 */}
<AspectRatioSelector
value={aspectUI}
onChange={setAspectUI}
placement="top"
/> */}
/>
{!hasAnalyzed ? (
<Tooltip title={activeImageUrl ? "Analyze image content" : "Please upload an image first"} placement="top">
<div>

View File

@ -556,12 +556,12 @@ export const H5TemplateDrawer = ({
/>
</div>
)}
{/* 横/竖屏选择 上线暂时不开放 */}
{/* <AspectRatioSelector
{/* 横/竖屏选择 */}
<AspectRatioSelector
value={aspectUI}
onChange={setAspectUI}
placement="top"
/> */}
/>
<ActionButton
isCreating={isTemplateCreating || localLoading > 0}
handleCreateVideo={handleConfirm}

View File

@ -321,12 +321,12 @@ export const PcPhotoStoryModal = ({
placeholder="Share your creative ideas about the image and let AI create a movie story for you..."
/>
<div className="absolute bottom-1 right-0 flex gap-2 items-center">
{/* 横/竖屏选择 上线暂时不开放 */}
{/* <AspectRatioSelector
{/* 横/竖屏选择 */}
<AspectRatioSelector
value={aspectUI}
onChange={setAspectUI}
placement="top"
/> */}
/>
{!hasAnalyzed ? (
<Tooltip title={activeImageUrl ? "Analyze image content" : "Please upload an image first"} placement="top">
<ActionButton

View File

@ -707,12 +707,12 @@ export const PcTemplateModal = ({
/>
</div>
)}
{/* 横/竖屏选择 上线暂时不开放 */}
{/* <AspectRatioSelector
{/* 横/竖屏选择 */}
<AspectRatioSelector
value={aspectUI}
onChange={setAspectUI}
placement="top"
/> */}
/>
<ActionButton
isCreating={isTemplateCreating || localLoading > 0}
handleCreateVideo={handleConfirm}

View File

@ -4,6 +4,8 @@ import { MessageBlock } from "./types";
import { useUploadFile } from "@/app/service/domain/service";
import { motion, AnimatePresence } from "framer-motion";
import { QuickActionTags, QuickAction } from "./QuickActionTags";
import { useDeviceType } from '@/hooks/useDeviceType';
// 防抖函数
function debounce<T extends (...args: any[]) => void>(func: T, wait: number) {
@ -35,6 +37,7 @@ export function InputBar({ onSend, setVideoPreview, initialVideoUrl, initialVide
const [videoUrl, setVideoUrl] = useState<string | null>(initialVideoUrl || null);
const [videoId, setVideoId] = useState<string | null>(initialVideoId || null);
const [isMultiline, setIsMultiline] = useState(false);
const { isMobile, isTablet, isDesktop } = useDeviceType();
const textareaRef = useRef<HTMLTextAreaElement>(null);
const { uploadFile } = useUploadFile();
@ -174,7 +177,7 @@ export function InputBar({ onSend, setVideoPreview, initialVideoUrl, initialVide
};
return (
<div data-alt="input-bar">
<div data-alt="input-bar" className={`${isMobile ? 'absolute bottom-0 left-0 right-0 bg-[#141414]' : ''}`}>
{/* 媒体预览 */}
<div className="px-3 pt-3 flex gap-2" data-alt="media-preview">
{/* 图片预览 */}

View File

@ -5,6 +5,7 @@ import { bubbleVariants, hhmm } from "./utils";
import { ProgressBar } from "./ProgressBar";
import { Loader2, AlertCircle, CheckCircle2 } from "lucide-react";
import { Image } from 'antd';
import { useDeviceType } from '@/hooks/useDeviceType';
interface MessageRendererProps {
msg: ChatMessage;
@ -15,6 +16,7 @@ export function MessageRenderer({ msg, sendMessage }: MessageRendererProps) {
// Decide bubble style
const isUser = msg.role === "user";
const isSystem = msg.role === "system";
const { isMobile, isTablet, isDesktop } = useDeviceType();
const bubbleClass = useMemo(() => {
if (isSystem) return "bg-[#281c1459] text-white";
@ -78,7 +80,7 @@ export function MessageRenderer({ msg, sendMessage }: MessageRendererProps) {
data-alt="message-bubble"
key={msg.id}
>
<div className={`max-w-[75%] rounded-2xl shadow-md p-3 ${bubbleClass}`}>
<div className={`${isMobile ? 'max-w-full' : 'max-w-[75%]'} rounded-2xl shadow-md p-3 ${bubbleClass}`}>
{/* Header */}
{/* <div className="flex items-center gap-2 text-[11px] opacity-80 mb-1">
{badge}

View File

@ -7,6 +7,7 @@ import { useMessages } from "./useMessages";
import { DateDivider } from "./DateDivider";
import { LoadMoreButton } from "./LoadMoreButton";
import { ChatMessage } from "./types";
import { useDeviceType } from '@/hooks/useDeviceType';
interface SmartChatBoxProps {
isSmartChatBoxOpen: boolean;
@ -51,7 +52,7 @@ export default function SmartChatBox({
// 消息列表引用
const listRef = useRef<HTMLDivElement>(null);
const [isAtBottom, setIsAtBottom] = useState(true);
const { isMobile, isTablet, isDesktop } = useDeviceType();
// 检查是否滚动到底部
const checkIfAtBottom = useCallback(() => {
if (listRef.current) {
@ -148,9 +149,9 @@ export default function SmartChatBox({
}, [messages]);
return (
<div className="h-full w-full text-gray-100 flex flex-col" data-alt="smart-chat-box">
<div className={`${isMobile ? 'z-[49]' : 'h-full'} w-full text-gray-100 flex flex-col`} data-alt="smart-chat-box">
{/* Header */}
<div className="px-4 py-3 border-b border-white/10 flex items-center justify-between" data-alt="chat-header">
<div className={`px-4 py-3 border-b border-white/10 flex items-center justify-between ${isMobile ? 'sticky top-0 bg-[#141414] z-[1]' : ''}`} data-alt="chat-header">
<div className="font-semibold flex items-center gap-2">
<span>Chat</span>
{/* System push toggle */}
@ -159,7 +160,7 @@ export default function SmartChatBox({
unCheckedChildren="Off"
checked={systemPush}
onChange={toggleSystemPush}
className="ml-2 "
className="ml-2"
/>
</div>
<div className="text-xs opacity-70">

View File

@ -199,13 +199,13 @@ export default function CreateToVideo2() {
onMouseLeave={() => handleMouseLeave(project.project_id)}
data-alt="project-card"
>
{/* 视频/图片区域 */}
<div className="relative aspect-video" onClick={() => router.push(`/movies/work-flow?episodeId=${project.project_id}`)}>
{/* 视频/图片区域 */}
<div className="relative w-full pb-[56.25%]" onClick={() => router.push(`/movies/work-flow?episodeId=${project.project_id}`)}>
{(project.final_video_url || project.final_simple_video_url || project.video_urls) ? (
<video
ref={(el) => setVideoRef(project.project_id, el)}
src={project.final_video_url || project.final_simple_video_url || project.video_urls}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
className="absolute inset-0 w-full h-full object-contain group-hover:scale-105 transition-transform duration-500"
muted
loop
playsInline
@ -216,7 +216,7 @@ export default function CreateToVideo2() {
/>
) : (
<div
className="w-full h-full bg-cover bg-center group-hover:scale-105 transition-transform duration-500"
className="absolute inset-0 w-full h-full bg-contain bg-center bg-no-repeat group-hover:scale-105 transition-transform duration-500"
style={{
backgroundImage: `url(${cover_image1.src})`,
}}

View File

@ -20,8 +20,7 @@ import { showEditingNotification } from "@/components/pages/work-flow/editing-no
// import { AIEditingIframeButton } from './work-flow/ai-editing-iframe';
import { exportVideoWithRetry } from '@/utils/export-service';
import { getFirstFrame } from '@/utils/tools';
// 临时禁用视频编辑功能
// import { EditPoint as EditPointType } from './work-flow/video-edit/types';
import { EditPoint as EditPointType } from './work-flow/video-edit/types';
import { AIEditingIframeButton } from './work-flow/ai-editing-iframe';
import { useDeviceType } from '@/hooks/useDeviceType';
import { H5ProgressToastProvider, useH5ProgressToast } from '@/components/ui/h5-progress-toast';
@ -223,7 +222,8 @@ const WorkFlow = React.memo(function WorkFlow() {
showGotoCutButton,
generateEditPlan,
handleRetryVideo,
isShowAutoEditing
isShowAutoEditing,
aspectRatio
} = useWorkflowData({
onEditPlanGenerated: handleEditPlanGenerated,
editingStatus: editingStatus,
@ -235,6 +235,11 @@ const WorkFlow = React.memo(function WorkFlow() {
toggleVideoPlay,
} = usePlaybackControls(taskObject.videos.data, taskObject.currentStage);
useEffect(() => {
if (isMobile) {
setIsSmartChatBoxOpen(false);
}
}, [isMobile]);
useEffect(() => {
console.log('changedIndex_work-flow', currentSketchIndex, taskObject);
}, [currentSketchIndex, taskObject]);
@ -303,9 +308,8 @@ const WorkFlow = React.memo(function WorkFlow() {
// setAiEditingInProgress(false); // 已移除该状态变量
}, []);
// 临时禁用视频编辑功能
// 视频编辑描述提交处理函数
/*const handleVideoEditDescriptionSubmit = useCallback((editPoint: EditPointType, description: string) => {
const handleVideoEditDescriptionSubmit = useCallback((editPoint: EditPointType, description: string) => {
console.log('🎬 视频编辑描述提交:', { editPoint, description });
// 构造编辑消息发送到SmartChatBox
@ -333,7 +337,7 @@ Please process this video editing request.`;
description: `Your edit request for timestamp ${Math.floor(editPoint.timestamp)}s has been submitted successfully.`,
duration: 3
});
}, [currentSketchIndex, isSmartChatBoxOpen]);*/
}, [currentSketchIndex, isSmartChatBoxOpen]);
// 测试导出接口的处理函数(使用封装的导出服务)
const handleTestExport = useCallback(async () => {
@ -520,7 +524,9 @@ Please process this video editing request.`;
isSmartChatBoxOpen={isSmartChatBoxOpen}
onRetryVideo={(video_id) => handleRetryVideo(video_id)}
onSelectView={(view) => setSelectedView(view)}
// 临时禁用视频编辑功能: enableVideoEdit={true} onVideoEditDescriptionSubmit={handleVideoEditDescriptionSubmit}
enableVideoEdit={true}
onVideoEditDescriptionSubmit={handleVideoEditDescriptionSubmit}
projectId={episodeId}
/>
)}
</div>
@ -537,6 +543,7 @@ Please process this video editing request.`;
onRetryVideo={handleRetryVideo}
className={isDesktop ? 'auto-cols-[20%]' : (isTablet ? 'auto-cols-[25%]' : 'auto-cols-[40%]')}
selectedView={selectedView}
aspectRatio={aspectRatio}
/>
</div>
)}
@ -611,6 +618,14 @@ Please process this video editing request.`;
<div
className="fixed right-[1rem] bottom-[10rem] z-[49]"
>
{isMobile ? (
<GlassIconButton
icon={Bot}
size='md'
onClick={() => setIsSmartChatBoxOpen(true)}
className="backdrop-blur-lg"
/>
) : (
<Tooltip title="Open chat" placement="left">
<GlassIconButton
icon={Bot}
@ -619,12 +634,13 @@ Please process this video editing request.`;
className="backdrop-blur-lg"
/>
</Tooltip>
)}
</div>
{/* 智能对话弹窗 */}
<Drawer
width="25%"
placement="right"
width={isMobile ? '100vw' : '25%'}
placement={isMobile ? 'bottom' : 'right'}
closable={false}
maskClosable={false}
open={isSmartChatBoxOpen}
@ -636,15 +652,18 @@ Please process this video editing request.`;
className="backdrop-blur-lg bg-black/30 border border-white/20 shadow-xl"
style={{
backgroundColor: 'transparent',
borderBottomLeftRadius: 10,
borderTopLeftRadius: 10,
...(isMobile
? { borderTopLeftRadius: 10, borderTopRightRadius: 10 }
: { borderBottomLeftRadius: 10, borderTopLeftRadius: 10 }),
overflow: 'hidden',
}}
styles={{
body: {
backgroundColor: 'transparent',
padding: 0,
},
maxHeight: '100vh',
overflow: 'auto',
}
}}
onClose={() => setIsSmartChatBoxOpen(false)}
>

View File

@ -43,6 +43,12 @@ interface H5MediaViewerProps {
onRetryVideo?: (video_id: string) => void;
/** 切换选择视图final 或 video */
onSelectView?: (view: 'final' | 'video') => void;
/** 启用视频编辑功能 */
enableVideoEdit?: boolean;
/** 视频编辑描述提交回调 */
onVideoEditDescriptionSubmit?: (editPoint: any, description: string) => void;
/** 项目ID */
projectId?: string;
}
/**
@ -67,7 +73,10 @@ export function H5MediaViewer({
onGotoCut,
isSmartChatBoxOpen,
onRetryVideo,
onSelectView
onSelectView,
enableVideoEdit,
onVideoEditDescriptionSubmit,
projectId
}: H5MediaViewerProps) {
const carouselRef = useRef<CarouselRef>(null);
const videoRefs = useRef<Array<HTMLVideoElement | null>>([]);
@ -155,7 +164,7 @@ export function H5MediaViewer({
// 渲染视频 slide
const renderVideoSlides = () => (
<div data-alt="carousel-wrapper" className="relative w-full aspect-video min-h-[200px] overflow-hidden rounded-lg">
<div data-alt="carousel-wrapper" className="relative w-full aspect-auto min-h-[200px] overflow-hidden rounded-lg">
<Carousel
ref={carouselRef}
key={`h5-carousel-video-${stage}-${videoUrls.length}`}
@ -178,7 +187,7 @@ export function H5MediaViewer({
<>
<video
ref={(el) => (videoRefs.current[idx] = el)}
className="w-full h-full object-cover [transform:translateZ(0)] [backface-visibility:hidden] [will-change:transform] bg-black"
className="w-full h-full object-contain [transform:translateZ(0)] [backface-visibility:hidden] [will-change:transform] bg-black"
src={url}
preload="metadata"
playsInline
@ -239,7 +248,7 @@ export function H5MediaViewer({
</div>
</>
) : (
<div className="w-full aspect-video min-h-[200px] flex items-center justify-center bg-black/10 relative" data-alt="video-status">
<div className="w-full aspect-auto min-h-[200px] flex items-center justify-center bg-black/10 relative" data-alt="video-status">
{status === 0 && (
<span className="text-blue-500 text-base">Generating...</span>
)}
@ -276,7 +285,7 @@ export function H5MediaViewer({
// 渲染图片 slide
const renderImageSlides = () => (
<div data-alt="carousel-wrapper" className="relative w-full aspect-video min-h-[200px] overflow-hidden rounded-lg">
<div data-alt="carousel-wrapper" className="relative w-full aspect-auto min-h-[200px] overflow-hidden rounded-lg">
<Carousel
ref={carouselRef}
key={`h5-carousel-image-${stage}-${imageUrls.length}`}
@ -290,7 +299,7 @@ export function H5MediaViewer({
>
{imageUrls.map((url, idx) => (
<div key={`h5-image-${idx}`} data-alt="image-slide" className="relative w-full h-full">
<img src={url} alt="scene" className="w-full h-full object-cover" />
<img src={url} alt="scene" className="w-full h-full object-contain" />
</div>
))}
</Carousel>
@ -407,7 +416,7 @@ export function H5MediaViewer({
data-alt="final-thumb-item"
aria-label="Select final video"
>
<img src={getFirstFrame(taskObject.final.url)} alt="final" className="w-full h-auto object-cover" />
<img src={getFirstFrame(taskObject.final.url)} alt="final" className="w-full h-auto object-contain" />
<div className="text-[10px] text-white/80 text-center py-0.5">Final</div>
</button>
</div>

View File

@ -2,7 +2,7 @@
import React, { useRef, useEffect, useState, SetStateAction, useMemo } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { Edit3, Play, Pause, Volume2, VolumeX, Maximize, Minimize, Loader2, X, Scissors, RotateCcw, MessageCircleMore, Download, ArrowDownWideNarrow, CircleAlert /*, PenTool*/ } from 'lucide-react';
import { Edit3, Play, Pause, Volume2, VolumeX, Maximize, Minimize, Loader2, X, Scissors, RotateCcw, MessageCircleMore, Download, ArrowDownWideNarrow, CircleAlert, PenTool } from 'lucide-react';
import { ProgressiveReveal, presets } from '@/components/ui/progressive-reveal';
import { GlassIconButton } from '@/components/ui/glass-icon-button';
import { ScriptRenderer } from '@/components/script-renderer/ScriptRenderer';
@ -12,9 +12,8 @@ import ScriptLoading from './script-loading';
import { TaskObject } from '@/api/DTO/movieEdit';
import { Button, Tooltip } from 'antd';
import { downloadVideo, downloadAllVideos, getFirstFrame } from '@/utils/tools';
// 临时禁用视频编辑功能
// import { VideoEditOverlay } from './video-edit/VideoEditOverlay';
// import { EditPoint as EditPointType } from './video-edit/types';
import { VideoEditOverlay } from './video-edit/VideoEditOverlay';
import { EditPoint as EditPointType } from './video-edit/types';
interface MediaViewerProps {
taskObject: TaskObject;
@ -35,9 +34,9 @@ interface MediaViewerProps {
onGotoCut: () => void;
isSmartChatBoxOpen: boolean;
onRetryVideo?: (video_id: string) => void;
// 临时禁用视频编辑功能
// enableVideoEdit?: boolean;
// onVideoEditDescriptionSubmit?: (editPoint: EditPointType, description: string) => void;
enableVideoEdit?: boolean;
onVideoEditDescriptionSubmit?: (editPoint: EditPointType, description: string) => void;
projectId?: string;
}
export const MediaViewer = React.memo(function MediaViewer({
@ -58,10 +57,10 @@ export const MediaViewer = React.memo(function MediaViewer({
showGotoCutButton,
onGotoCut,
isSmartChatBoxOpen,
onRetryVideo
// 临时禁用视频编辑功能
// enableVideoEdit = true,
// onVideoEditDescriptionSubmit
onRetryVideo,
enableVideoEdit = true,
onVideoEditDescriptionSubmit,
projectId
}: MediaViewerProps) {
const mainVideoRef = useRef<HTMLVideoElement>(null);
const finalVideoRef = useRef<HTMLVideoElement>(null);
@ -78,8 +77,7 @@ export const MediaViewer = React.memo(function MediaViewer({
const [toosBtnRight, setToodsBtnRight] = useState('1rem');
const [isLoadingDownloadBtn, setIsLoadingDownloadBtn] = useState(false);
const [isLoadingDownloadAllVideosBtn, setIsLoadingDownloadAllVideosBtn] = useState(false);
// 临时禁用视频编辑功能
// const [isVideoEditMode, setIsVideoEditMode] = useState(false);
const [isVideoEditMode, setIsVideoEditMode] = useState(false);
useEffect(() => {
if (isSmartChatBoxOpen) {
@ -178,7 +176,7 @@ export const MediaViewer = React.memo(function MediaViewer({
return (
<video
ref={finalVideoRef}
className="w-full h-full object-cover rounded-lg"
className="w-full h-full object-contain rounded-lg"
src={taskObject.final.url}
autoPlay={isFinalVideoPlaying}
loop
@ -347,7 +345,7 @@ export const MediaViewer = React.memo(function MediaViewer({
transition={{ duration: 0.8, ease: "easeInOut" }}
>
<video
className="w-full h-full rounded-lg object-cover object-center"
className="w-full h-full rounded-lg object-contain object-center"
src={taskObject.final.url}
loop
playsInline
@ -492,7 +490,7 @@ export const MediaViewer = React.memo(function MediaViewer({
<video
ref={mainVideoRef}
key={taskObject.videos.data[currentSketchIndex].urls[0]}
className="w-full h-full rounded-lg object-cover object-center relative z-10"
className="w-full h-full rounded-lg object-contain object-center relative z-10"
src={taskObject.videos.data[currentSketchIndex].urls[0]}
poster={getFirstFrame(taskObject.videos.data[currentSketchIndex].urls[0])}
preload="none"
@ -506,11 +504,10 @@ export const MediaViewer = React.memo(function MediaViewer({
}}
/>
{/* 临时禁用视频编辑功能 */}
{/* 视频编辑覆盖层 */}
{/*enableVideoEdit && isVideoEditMode && (
{enableVideoEdit && isVideoEditMode && (
<VideoEditOverlay
projectId={taskObject.project_id || ''}
projectId={projectId || ''}
userId={JSON.parse(localStorage.getItem("currentUser") || '{}').id || 0}
currentVideo={{
id: taskObject.videos.data[currentSketchIndex].video_id,
@ -522,16 +519,15 @@ export const MediaViewer = React.memo(function MediaViewer({
onDescriptionSubmit={onVideoEditDescriptionSubmit}
className="rounded-lg"
/>
)*/}
)}
</motion.div>
{/* 跳转剪辑按钮 */}
<div className="absolute top-4 right-4 z-[21] flex items-center gap-2 transition-right duration-100" style={{
right: toosBtnRight
}}>
{/* 临时禁用视频编辑功能 */}
{/* 视频编辑模式切换按钮 */}
{/*enableVideoEdit && (
{/* 视频编辑模式切换按钮 - 临时注释 */}
{/* {enableVideoEdit && (
<Tooltip placement="top" title={isVideoEditMode ? "Exit edit mode" : "Enter edit mode"}>
<GlassIconButton
icon={PenTool}
@ -540,7 +536,7 @@ export const MediaViewer = React.memo(function MediaViewer({
className={isVideoEditMode ? 'bg-blue-500/20 border-blue-500/50' : ''}
/>
</Tooltip>
)*/}
)} */}
{/* 添加到chat去编辑 按钮 */}
<Tooltip placement="top" title="Edit video with chat">
<GlassIconButton icon={MessageCircleMore} size='sm' onClick={() => {
@ -659,7 +655,7 @@ export const MediaViewer = React.memo(function MediaViewer({
<motion.img
key={currentSketch.url}
src={currentSketch.url}
className="w-full h-full rounded-lg object-cover"
className="w-full h-full rounded-lg object-contain"
// 用 circle clip-path 实现“扩散”
initial={{ clipPath: "circle(0% at 50% 50%)" }}
animate={{ clipPath: "circle(150% at 50% 50%)" }}

View File

@ -7,6 +7,7 @@ import { ProgressiveReveal, presets } from '@/components/ui/progressive-reveal';
import { Loader2, X, SquareUserRound, MapPinHouse, Clapperboard, Video, RotateCcw, CircleAlert } from 'lucide-react';
import { TaskObject } from '@/api/DTO/movieEdit';
import { getFirstFrame } from '@/utils/tools';
import { AspectRatioValue } from '@/components/ChatInputBox/AspectRatioSelector';
interface ThumbnailGridProps {
isDisabledFocus: boolean;
@ -16,6 +17,7 @@ interface ThumbnailGridProps {
onRetryVideo: (video_id: string) => void;
className: string;
selectedView?: 'final' | 'video' | null;
aspectRatio: AspectRatioValue;
}
/**
@ -28,7 +30,8 @@ export function ThumbnailGrid({
onSketchSelect,
onRetryVideo,
className,
selectedView
selectedView,
aspectRatio
}: ThumbnailGridProps) {
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
@ -186,8 +189,10 @@ export function ThumbnailGrid({
return (
<div
key={`video-${urls}-${index}`}
className={`relative aspect-video rounded-lg overflow-hidden
${(currentSketchIndex === index && !disabled && selectedView !== 'final') ? 'ring-2 ring-blue-500 z-10' : 'hover:ring-2 hover:ring-blue-500/50'}`}
className={`relative aspect-auto rounded-lg overflow-hidden
${(currentSketchIndex === index && !disabled && selectedView !== 'final') ? 'ring-2 ring-blue-500 z-10' : 'hover:ring-2 hover:ring-blue-500/50'}
${aspectRatio === 'VIDEO_ASPECT_RATIO_LANDSCAPE' ? 'min-w-[210px]' : 'min-w-[70px]'}
`}
onClick={() => !isDragging && !disabled && onSketchSelect(index)}
>
@ -208,7 +213,7 @@ export function ThumbnailGrid({
{taskObject.videos.data[index].urls && taskObject.videos.data[index].urls.length > 0 ? (
// <video
// className="w-full h-full object-cover"
// className="w-full h-full object-contain"
// src={taskObject.videos.data[index].urls[0]}
// playsInline
// loop
@ -220,14 +225,14 @@ export function ThumbnailGrid({
onMouseLeave={() => handleMouseLeave(index)}
>
<img
className="w-full h-full object-cover"
className="w-full h-full object-contain"
src={getFirstFrame(taskObject.videos.data[index].urls[0])}
draggable="false"
alt="video thumbnail"
/>
{hoveredIndex === index && (
<video
className="absolute inset-0 w-full h-full object-cover"
className="absolute inset-0 w-full h-full object-contain"
src={taskObject.videos.data[index].urls[0]}
autoPlay
muted
@ -249,7 +254,7 @@ export function ThumbnailGrid({
<div className='absolute bottom-0 left-0 right-0 p-2'>
<div className="inline-flex items-center px-2 py-1 rounded-full bg-green-500/20 backdrop-blur-sm">
<Video className="w-3 h-3 text-green-400 mr-1" />
<span className="text-xs text-green-400">Shot {index + 1}</span>
<span className="text-xs text-green-400">{index + 1}</span>
</div>
</div>
@ -269,8 +274,10 @@ export function ThumbnailGrid({
return (
<div
key={sketch.uniqueId || `sketch-${sketch.url}-${index}`}
className={`relative aspect-video rounded-lg overflow-hidden
${currentSketchIndex === index ? 'ring-2 ring-blue-500 z-10' : 'hover:ring-2 hover:ring-blue-500/50'}`}
className={`relative aspect-auto rounded-lg overflow-hidden
${currentSketchIndex === index ? 'ring-2 ring-blue-500 z-10' : 'hover:ring-2 hover:ring-blue-500/50'}
${aspectRatio === 'VIDEO_ASPECT_RATIO_LANDSCAPE' ? 'min-w-[210px]' : 'min-w-[70px]'}
`}
onClick={() => !isDragging && onSketchSelect(index)}
>
@ -293,7 +300,7 @@ export function ThumbnailGrid({
{(sketch.status === 1) && (
<div className="w-full h-full transform hover:scale-105 transition-transform duration-500">
<img
className="w-full h-full object-cover select-none"
className="w-full h-full object-contain select-none"
src={sketch.url}
draggable="false"
alt={sketch.type ? String(sketch.type) : 'sketch'}
@ -319,7 +326,7 @@ export function ThumbnailGrid({
{(!sketch.type || sketch.type === 'shot_sketch') && (
<div className="inline-flex items-center px-2 py-1 rounded-full bg-cyan-500/20 backdrop-blur-sm">
<Clapperboard className="w-3 h-3 text-cyan-400 mr-1" />
<span className="text-xs text-cyan-400">Shot {index + 1}</span>
<span className="text-xs text-cyan-400">{index + 1}</span>
</div>
)}
</div>
@ -337,7 +344,7 @@ export function ThumbnailGrid({
<div
ref={thumbnailsRef}
tabIndex={0}
className={`w-full h-full grid grid-flow-col gap-2 overflow-x-auto hide-scrollbar px-1 py-1 cursor-grab active:cursor-grabbing focus:outline-none select-none ${className}`}
className={`w-full h-full grid grid-flow-col gap-2 overflow-x-auto hide-scrollbar px-1 py-1 cursor-grab active:cursor-grabbing focus:outline-none select-none ${className} auto-cols-max`}
autoFocus
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}

View File

@ -6,6 +6,7 @@ import { detailScriptEpisodeNew, getScriptTitle, getRunningStreamData, pauseMovi
import { useScriptService } from "@/app/service/Interaction/ScriptService";
import { useUpdateEffect } from '@/app/hooks/useUpdateEffect';
import { LOADING_TEXT_MAP, TaskObject, Status, Stage } from '@/api/DTO/movieEdit';
import { AspectRatioValue } from '@/components/ChatInputBox/AspectRatioSelector';
interface UseWorkflowDataProps {
onEditPlanGenerated?: () => void;
@ -91,6 +92,7 @@ export function useWorkflowData({ onEditPlanGenerated, editingStatus, onExportFa
const [isLoadingGenerateEditPlan, setIsLoadingGenerateEditPlan] = useState(false);
const [state, setState] = useState({
mode: 'automatic' as 'automatic' | 'manual' | 'auto',
aspectRatio: 'VIDEO_ASPECT_RATIO_LANDSCAPE' as AspectRatioValue,
originalText: '',
isLoading: true
});
@ -529,6 +531,7 @@ export function useWorkflowData({ onEditPlanGenerated, editingStatus, onExportFa
try {
setState({
mode: 'automatic' as 'automatic' | 'manual' | 'auto',
aspectRatio: 'VIDEO_ASPECT_RATIO_LANDSCAPE' as AspectRatioValue,
originalText: '',
isLoading: true
});
@ -540,7 +543,7 @@ export function useWorkflowData({ onEditPlanGenerated, editingStatus, onExportFa
throw new Error(response.message);
}
const { status, data, tags, mode, original_text, title, name, final_simple_video, final_video } = response.data;
const { status, data, tags, mode, original_text, aspect_ratio, name, final_simple_video, final_video } = response.data;
const { current: taskCurrent } = tempTaskObject;
@ -681,6 +684,7 @@ export function useWorkflowData({ onEditPlanGenerated, editingStatus, onExportFa
setState({
mode: mode as 'automatic' | 'manual' | 'auto',
aspectRatio: aspect_ratio as AspectRatioValue,
originalText: original_text,
isLoading: false
});
@ -701,6 +705,7 @@ export function useWorkflowData({ onEditPlanGenerated, editingStatus, onExportFa
setNeedStreamData(true);
setState({
mode: 'automatic' as 'automatic' | 'manual' | 'auto',
aspectRatio: 'VIDEO_ASPECT_RATIO_LANDSCAPE' as AspectRatioValue,
originalText: '',
isLoading: false
});
@ -781,6 +786,7 @@ export function useWorkflowData({ onEditPlanGenerated, editingStatus, onExportFa
showGotoCutButton: (canGoToCut && (isGenerateEditPlan || taskObject.currentStage === 'final_video') || isShowError) ? true : false,
generateEditPlan: openEditPlan,
handleRetryVideo,
isShowAutoEditing: canGoToCut && taskObject.currentStage !== 'final_video' && isGenerateEditPlan && !isShowError ? true : false
isShowAutoEditing: canGoToCut && taskObject.currentStage !== 'final_video' && isGenerateEditPlan && !isShowError ? true : false,
aspectRatio: state.aspectRatio
};
}

View File

@ -69,7 +69,7 @@ function calculateCurvePath({
export function calculateInputPosition(
editPointPosition: { x: number; y: number },
containerSize: { width: number; height: number },
inputBoxSize: { width: number; height: number } = { width: 200, height: 80 }
inputBoxSize: { width: number; height: number } = { width: 300, height: 50 }
): InputBoxPosition {
const { x: pointX, y: pointY } = editPointPosition;
const { width: containerWidth, height: containerHeight } = containerSize;
@ -151,26 +151,79 @@ export const EditConnection: React.FC<EditConnectionProps> = ({
animated = true
}) => {
const {
color = 'rgba(255, 255, 255, 0.8)',
color = 'rgba(255, 255, 255, 0.9)', // White color to match the reference image
strokeWidth = 2,
dashArray = '5,5'
dashArray = '8,4' // Dashed line to match the reference image
} = style;
// 计算路径
// 计算箭头几何参数
const arrowSize = 8;
const arrowHalfHeight = 4;
// 计算连接方向和角度
const connectionVector = useMemo(() => {
const dx = endPoint.x - startPoint.x;
const dy = endPoint.y - startPoint.y;
const length = Math.sqrt(dx * dx + dy * dy);
return {
dx: dx / length,
dy: dy / length,
angle: Math.atan2(dy, dx)
};
}, [startPoint, endPoint]);
// 计算箭头的正确位置和线条终点
const arrowGeometry = useMemo(() => {
const { dx, dy, angle } = connectionVector;
// 箭头尖端位置原endPoint
const arrowTip = { x: endPoint.x, y: endPoint.y };
// 箭头底部中心点(线条应该连接到这里)
const arrowBase = {
x: endPoint.x - dx * arrowSize,
y: endPoint.y - dy * arrowSize
};
// 计算箭头三角形的三个顶点
const perpX = -dy; // 垂直向量X
const perpY = dx; // 垂直向量Y
const arrowPoints = [
arrowTip, // 尖端
{
x: arrowBase.x + perpX * arrowHalfHeight,
y: arrowBase.y + perpY * arrowHalfHeight
},
{
x: arrowBase.x - perpX * arrowHalfHeight,
y: arrowBase.y - perpY * arrowHalfHeight
}
];
return {
tip: arrowTip,
base: arrowBase,
points: arrowPoints,
angle
};
}, [endPoint, connectionVector, arrowSize, arrowHalfHeight]);
// 计算路径(线条终止于箭头底部中心)
const path = useMemo(() =>
calculateCurvePath({
start: startPoint,
end: endPoint,
end: arrowGeometry.base, // 连接到箭头底部中心而不是尖端
containerSize,
curvature
}), [startPoint, endPoint, containerSize, curvature]);
}), [startPoint, arrowGeometry.base, containerSize, curvature]);
// 计算路径长度用于动画
const pathLength = useMemo(() => {
const dx = endPoint.x - startPoint.x;
const dy = endPoint.y - startPoint.y;
const dx = arrowGeometry.base.x - startPoint.x;
const dy = arrowGeometry.base.y - startPoint.y;
return Math.sqrt(dx * dx + dy * dy) * 1.2; // 弧线比直线长约20%
}, [startPoint, endPoint]);
}, [startPoint, arrowGeometry.base]);
return (
<svg
@ -179,7 +232,7 @@ export const EditConnection: React.FC<EditConnectionProps> = ({
height={containerSize.height}
style={{ zIndex: 10 }}
>
{/* 连接线路径 */}
{/* Curved dashed line - properly aligned to arrow base center */}
<motion.path
d={path}
fill="none"
@ -201,15 +254,13 @@ export const EditConnection: React.FC<EditConnectionProps> = ({
opacity: { duration: 0.3 }
} : {}}
style={{
filter: 'drop-shadow(0 2px 4px rgba(0, 0, 0, 0.2))'
filter: 'drop-shadow(2px 2px 4px rgba(0, 0, 0, 0.8))'
}}
/>
{/* 连接线末端的小圆点 */}
<motion.circle
cx={endPoint.x}
cy={endPoint.y}
r={3}
{/* Properly aligned arrow head with geometric precision */}
<motion.polygon
points={arrowGeometry.points.map(p => `${p.x},${p.y}`).join(' ')}
fill={color}
initial={animated ? {
scale: 0,
@ -227,32 +278,32 @@ export const EditConnection: React.FC<EditConnectionProps> = ({
damping: 25
} : {}}
style={{
filter: 'drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3))'
filter: 'drop-shadow(2px 2px 4px rgba(0, 0, 0, 0.8))'
}}
/>
{/* 动画流动效果(可选) */}
{animated && (
<motion.circle
r={2}
fill="rgba(255, 255, 255, 0.9)"
initial={{ opacity: 0 }}
animate={{
opacity: [0, 1, 0],
offsetDistance: ["0%", "100%"]
}}
transition={{
duration: 2,
repeat: Infinity,
ease: "linear",
delay: 0.6
}}
style={{
offsetPath: `path('${path}')`,
offsetRotate: "0deg"
}}
/>
{/* Debug visualization (remove in production) */}
{process.env.NODE_ENV === 'development' && (
<>
{/* Arrow base center point */}
<circle
cx={arrowGeometry.base.x}
cy={arrowGeometry.base.y}
r={1}
fill="red"
opacity={0.5}
/>
{/* Arrow tip point */}
<circle
cx={arrowGeometry.tip.x}
cy={arrowGeometry.tip.y}
r={1}
fill="blue"
opacity={0.5}
/>
</>
)}
</svg>
);
};

View File

@ -101,7 +101,7 @@ export const EditDescription: React.FC<EditDescriptionProps> = ({
<AnimatePresence>
{editPoint.description && editPoint.status !== EditPointStatus.PENDING && (
<>
{/* 连接线 */}
{/* White dashed connection line to match reference image */}
<motion.svg
className="absolute pointer-events-none"
style={{
@ -118,30 +118,41 @@ export const EditDescription: React.FC<EditDescriptionProps> = ({
>
<motion.path
d={connectionPath}
stroke={statusColor}
stroke="rgba(255, 255, 255, 0.9)"
strokeWidth={2}
fill="none"
strokeDasharray="4,4"
strokeDasharray="8,4"
strokeLinecap="round"
initial={{ pathLength: 0, opacity: 0 }}
animate={{
pathLength: 1,
opacity: 0.8,
strokeDashoffset: [0, -8]
opacity: 1
}}
exit={{ pathLength: 0, opacity: 0 }}
transition={{
pathLength: { duration: 0.8, ease: "easeOut" },
opacity: { duration: 0.5 },
strokeDashoffset: {
duration: 2,
repeat: Infinity,
ease: "linear"
}
opacity: { duration: 0.5 }
}}
style={{
filter: 'drop-shadow(2px 2px 4px rgba(0, 0, 0, 0.8))'
}}
/>
{/* Arrow head */}
<motion.polygon
points={`${connectionEnd.x},${connectionEnd.y} ${connectionEnd.x-8},${connectionEnd.y-4} ${connectionEnd.x-8},${connectionEnd.y+4}`}
fill="rgba(255, 255, 255, 0.9)"
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0, opacity: 0 }}
transition={{ delay: 0.4, duration: 0.3 }}
style={{
filter: 'drop-shadow(2px 2px 4px rgba(0, 0, 0, 0.8))'
}}
/>
</motion.svg>
{/* 描述内容框 */}
{/* Consistent white text display matching EditInput component */}
<motion.div
className="absolute cursor-pointer group"
data-edit-description="true"
@ -149,8 +160,6 @@ export const EditDescription: React.FC<EditDescriptionProps> = ({
left: position.x,
top: position.y,
zIndex: 25,
maxWidth: '300px',
minWidth: '200px'
}}
initial={{
opacity: 0,
@ -174,98 +183,67 @@ export const EditDescription: React.FC<EditDescriptionProps> = ({
duration: 0.4
}}
onClick={() => onClick?.(editPoint)}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
{/* 玻璃态背景 */}
<div className="relative bg-white/90 dark:bg-gray-800/90 backdrop-blur-md rounded-lg shadow-lg border border-white/20 dark:border-gray-700/30 overflow-hidden">
{/* 状态指示条 */}
{/* White text display with exact same styling as EditInput */}
<div className="flex items-center">
<div
className="h-1 w-full"
style={{ backgroundColor: statusColor }}
/>
{/* 内容区域 */}
<div className="p-3">
{/* 状态标签 */}
{statusText && (
<div className="flex items-center gap-2 mb-2">
<div
className="w-2 h-2 rounded-full"
style={{ backgroundColor: statusColor }}
/>
<span
className="text-xs font-medium"
style={{ color: statusColor }}
>
{statusText}
</span>
</div>
)}
{/* 描述文本 */}
<div className="text-sm text-gray-800 dark:text-gray-200 leading-relaxed">
{editPoint.description}
</div>
{/* 时间戳 */}
<div className="text-xs text-gray-500 dark:text-gray-400 mt-2 flex items-center justify-between">
<span>
{Math.floor(editPoint.timestamp)}s
</span>
<span>
{new Date(editPoint.updatedAt).toLocaleTimeString()}
</span>
</div>
className="text-white font-bold text-lg tracking-wide uppercase"
style={{
textShadow: '2px 2px 4px rgba(0, 0, 0, 0.8)',
fontFamily: 'Arial, sans-serif',
letterSpacing: '0.1em'
}}
>
{editPoint.description}
</div>
{/* 悬停时显示的操作按钮 */}
<motion.div
className="absolute top-2 right-2 flex gap-1 opacity-0 group-hover:opacity-100"
initial={{ opacity: 0 }}
animate={{ opacity: 0 }}
whileHover={{ opacity: 1 }}
transition={{ duration: 0.2 }}
>
{/* Interactive edit/delete buttons on hover */}
<div className="ml-3 opacity-0 group-hover:opacity-100 transition-opacity duration-200 flex items-center gap-2">
{onEdit && (
<button
className="w-6 h-6 rounded-full bg-blue-500/80 hover:bg-blue-500 text-white text-xs flex items-center justify-center transition-colors"
<motion.button
onClick={(e) => {
e.stopPropagation();
onEdit(editPoint.id);
}}
title="编辑"
className="w-6 h-6 rounded-full bg-white/20 hover:bg-white/30 backdrop-blur-sm flex items-center justify-center text-white text-xs transition-colors"
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
title="Edit description"
>
</button>
</motion.button>
)}
{onDelete && (
<button
className="w-6 h-6 rounded-full bg-red-500/80 hover:bg-red-500 text-white text-xs flex items-center justify-center transition-colors"
<motion.button
onClick={(e) => {
e.stopPropagation();
onDelete(editPoint.id);
}}
title="删除"
className="w-6 h-6 rounded-full bg-red-500/20 hover:bg-red-500/30 backdrop-blur-sm flex items-center justify-center text-white text-xs transition-colors"
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
title="Delete edit point"
>
🗑
</button>
</motion.button>
)}
</motion.div>
{/* 装饰性光效 */}
<div className="absolute inset-0 bg-gradient-to-br from-white/10 to-transparent pointer-events-none" />
</div>
</div>
{/* 连接点指示器 */}
<div
className="absolute w-2 h-2 rounded-full border-2 border-white shadow-sm"
style={{
backgroundColor: statusColor,
left: connectionEnd.x - position.x - 4,
top: connectionEnd.y - position.y - 4,
}}
/>
{/* Status indicator for processing states */}
{editPoint.status === EditPointStatus.PROCESSING && (
<div className="mt-1 flex items-center">
<div className="w-2 h-2 bg-blue-400 rounded-full animate-pulse mr-2"></div>
<span className="text-white/70 text-xs uppercase tracking-wide">Processing...</span>
</div>
)}
{editPoint.status === EditPointStatus.FAILED && (
<div className="mt-1 flex items-center">
<div className="w-2 h-2 bg-red-400 rounded-full mr-2"></div>
<span className="text-white/70 text-xs uppercase tracking-wide">Failed - Click to retry</span>
</div>
)}
</motion.div>
</>
)}

View File

@ -37,11 +37,10 @@ export const EditInput: React.FC<EditInputProps> = ({
isSubmitting = false,
onSubmit,
onCancel,
size = { width: 280, height: 120 },
placeholder = "Describe your edit request..."
size = { width: 300, height: 50 },
placeholder = "Describe your edit..."
}) => {
const [description, setDescription] = useState(editPoint.description || '');
const [isFocused, setIsFocused] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
@ -116,7 +115,6 @@ export const EditInput: React.FC<EditInputProps> = ({
style={{
left: position.x,
top: position.y,
width: size.width,
}}
initial={{
opacity: 0,
@ -140,105 +138,33 @@ export const EditInput: React.FC<EditInputProps> = ({
duration: 0.3
}}
>
{/* 玻璃态背景容器 */}
<div className="bg-black/60 backdrop-blur-md rounded-lg border border-white/20 shadow-2xl overflow-hidden">
{/* 头部 */}
<div className="px-3 py-2 border-b border-white/10 flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-blue-400"></div>
<span className="text-xs text-white/80 font-medium">
Edit Request
</span>
<span className="text-xs text-white/50">
{Math.floor(editPoint.timestamp)}s
</span>
</div>
<button
onClick={onCancel}
className="w-5 h-5 rounded-full bg-white/10 hover:bg-white/20 flex items-center justify-center text-white/70 hover:text-white transition-colors"
disabled={isSubmitting}
>
<X size={12} />
</button>
</div>
{/* Input interface - focused on input functionality only */}
<div className="flex items-center bg-white/90 backdrop-blur-sm rounded-lg px-3 py-2 shadow-lg">
<input
ref={textareaRef as any}
type="text"
value={description}
onChange={(e) => setDescription(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={placeholder}
disabled={isSubmitting}
className="flex-1 bg-transparent text-gray-800 placeholder-gray-400 text-sm border-none outline-none min-w-[200px]"
autoFocus
/>
{/* 输入区域 */}
<div className="p-3">
<textarea
ref={textareaRef}
value={description}
onChange={(e) => setDescription(e.target.value)}
onKeyDown={handleKeyDown}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
placeholder={placeholder}
disabled={isSubmitting}
className="w-full bg-transparent text-white placeholder-white/40 text-sm resize-none border-none outline-none min-h-[60px] max-h-[120px]"
style={{ lineHeight: '1.4' }}
/>
</div>
{/* 底部操作区 */}
<div className="px-3 py-2 border-t border-white/10 flex items-center justify-between">
<div className="flex items-center gap-2 text-xs text-white/50">
<span>Ctrl+Enter to submit</span>
<span></span>
<span>Esc to cancel</span>
</div>
<div className="flex items-center gap-2">
{/* 字符计数 */}
<span className={`text-xs ${
description.length > 500 ? 'text-red-400' : 'text-white/50'
}`}>
{description.length}/500
</span>
{/* 提交按钮 */}
<motion.button
onClick={handleSubmit}
disabled={!description.trim() || isSubmitting || description.length > 500}
className="flex items-center gap-1 px-3 py-1.5 bg-blue-500 hover:bg-blue-600 disabled:bg-gray-500 disabled:cursor-not-allowed text-white text-xs rounded-md transition-colors"
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
>
{isSubmitting ? (
<>
<Loader2 size={12} className="animate-spin" />
<span>Submitting...</span>
</>
) : (
<>
<Send size={12} />
<span>Submit</span>
</>
)}
</motion.button>
</div>
</div>
{/* 聚焦时的发光边框 */}
<AnimatePresence>
{isFocused && (
<motion.div
className="absolute inset-0 rounded-lg border-2 border-blue-400/50 pointer-events-none"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
/>
{/* Submit button */}
<button
onClick={handleSubmit}
disabled={!description.trim() || isSubmitting}
className="ml-2 w-8 h-8 rounded-full bg-blue-500 hover:bg-blue-600 disabled:bg-gray-400 disabled:cursor-not-allowed text-white flex items-center justify-center flex-shrink-0"
>
{isSubmitting ? (
<div className="w-3 h-3 border border-white border-t-transparent rounded-full animate-spin"></div>
) : (
<span className="text-xs"></span>
)}
</AnimatePresence>
</button>
</div>
{/* 输入框指示箭头(可选) */}
<div
className="absolute w-3 h-3 bg-black/60 border-l border-t border-white/20 transform rotate-45"
style={{
left: 12,
top: -6,
}}
/>
</motion.div>
)}
</AnimatePresence>

View File

@ -71,44 +71,7 @@ export const EditPoint: React.FC<EditPointProps> = ({
onEdit(editPoint.id);
}, [onEdit, editPoint.id]);
// 根据状态获取颜色
const getStatusColor = useCallback(() => {
switch (editPoint.status) {
case EditPointStatus.PENDING:
return '#f59e0b'; // 黄色
case EditPointStatus.EDITED:
return '#10b981'; // 绿色
case EditPointStatus.PROCESSING:
return '#3b82f6'; // 蓝色
case EditPointStatus.COMPLETED:
return '#059669'; // 深绿色
case EditPointStatus.FAILED:
return '#ef4444'; // 红色
default:
return color;
}
}, [editPoint.status, color]);
// 根据状态获取图标
const getStatusIcon = useCallback(() => {
switch (editPoint.status) {
case EditPointStatus.PENDING:
return Edit3;
case EditPointStatus.EDITED:
return Check;
case EditPointStatus.PROCESSING:
return Loader2;
case EditPointStatus.COMPLETED:
return Check;
case EditPointStatus.FAILED:
return X;
default:
return Edit3;
}
}, [editPoint.status]);
const StatusIcon = getStatusIcon();
const statusColor = getStatusColor();
// Simplified for the image design - just use blue color
return (
<motion.div
@ -119,7 +82,7 @@ export const EditPoint: React.FC<EditPointProps> = ({
top: absolutePosition.y - size / 2,
}}
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
animate={{ scale: 1, opacity: 0 }} // Make invisible to match reference image
exit={{ scale: 0, opacity: 0 }}
transition={{
type: "spring",
@ -129,136 +92,17 @@ export const EditPoint: React.FC<EditPointProps> = ({
}}
onClick={handleClick}
>
{/* 脉冲动画背景 */}
<AnimatePresence>
{(editPoint.status === EditPointStatus.PENDING || isSelected) && (
<motion.div
className="absolute inset-0 rounded-full"
style={{
backgroundColor: pulseColor,
width: size * 3,
height: size * 3,
left: -size,
top: -size,
}}
initial={{ scale: 0, opacity: 0 }}
animate={{
scale: [1, 1.5, 1],
opacity: [0.6, 0.2, 0.6],
}}
exit={{ scale: 0, opacity: 0 }}
transition={{
duration: 2,
repeat: Infinity,
ease: "easeInOut"
}}
/>
)}
</AnimatePresence>
{/* 主编辑点 */}
{/* Invisible edit point - just for click handling */}
<motion.div
className="relative rounded-full flex items-center justify-center shadow-lg backdrop-blur-sm"
className="relative rounded-full"
style={{
width: size,
height: size,
backgroundColor: statusColor,
border: `2px solid rgba(255, 255, 255, 0.3)`,
width: size * 2, // Larger click area
height: size * 2,
backgroundColor: 'transparent', // Invisible
}}
whileHover={{ scale: 1.2 }}
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
animate={editPoint.status === EditPointStatus.PROCESSING ? {
rotate: 360,
transition: { duration: 1, repeat: Infinity, ease: "linear" }
} : {}}
>
<StatusIcon
size={size * 0.5}
color="white"
className={editPoint.status === EditPointStatus.PROCESSING ? "animate-spin" : ""}
/>
</motion.div>
{/* 选中状态的操作按钮 */}
<AnimatePresence>
{isSelected && editPoint.status !== EditPointStatus.PROCESSING && (
<motion.div
className="absolute flex gap-1"
style={{
left: size + 8,
top: -4,
}}
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -10 }}
transition={{ duration: 0.2 }}
>
{/* 编辑按钮 */}
<motion.button
className="w-6 h-6 rounded-full bg-blue-500/80 backdrop-blur-sm flex items-center justify-center text-white hover:bg-blue-600/80 transition-colors"
onClick={handleEdit}
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
title="编辑描述"
>
<Edit3 size={12} />
</motion.button>
{/* 删除按钮 */}
<motion.button
className="w-6 h-6 rounded-full bg-red-500/80 backdrop-blur-sm flex items-center justify-center text-white hover:bg-red-600/80 transition-colors"
onClick={handleDelete}
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
title="删除编辑点"
>
<X size={12} />
</motion.button>
</motion.div>
)}
</AnimatePresence>
{/* 状态提示文本 */}
<AnimatePresence>
{isSelected && editPoint.description && (
<motion.div
className="absolute whitespace-nowrap text-xs text-white bg-black/60 backdrop-blur-sm rounded px-2 py-1 pointer-events-none"
style={{
left: size + 8,
top: size + 8,
maxWidth: 200,
}}
initial={{ opacity: 0, y: -5 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -5 }}
transition={{ duration: 0.2 }}
>
{editPoint.description.length > 50
? `${editPoint.description.substring(0, 50)}...`
: editPoint.description
}
</motion.div>
)}
</AnimatePresence>
{/* 时间戳显示 */}
<AnimatePresence>
{isSelected && (
<motion.div
className="absolute text-xs text-white/70 bg-black/40 backdrop-blur-sm rounded px-1 py-0.5 pointer-events-none"
style={{
left: -20,
top: size + 8,
}}
initial={{ opacity: 0, y: -5 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -5 }}
transition={{ duration: 0.2 }}
>
{Math.floor(editPoint.timestamp)}s
</motion.div>
)}
</AnimatePresence>
/>
</motion.div>
);
};

View File

@ -275,7 +275,8 @@ export const VideoEditOverlay: React.FC<VideoEditOverlayProps> = ({
<AnimatePresence>
{editPoints.map(editPoint => {
const elementPosition = elementPositions[editPoint.id];
// 只显示已提交且有描述的编辑点
// 只显示已提交且有描述的编辑点,且不在输入模式
if (
!editPoint.description ||
editPoint.description.trim() === '' ||

View File

@ -18,20 +18,20 @@ import { createEditPoint, updateEditPoint, deleteEditPoint, getEditPoints } from
import { debounce } from './utils';
/**
*
* -
*/
const DEFAULT_CONFIG: VideoEditConfig = {
enabled: true,
maxEditPoints: 10,
connectionStyle: {
color: 'rgba(255, 255, 255, 0.8)',
color: 'rgba(255, 255, 255, 0.9)', // 白色连接线匹配参考图像
strokeWidth: 2,
dashArray: '5,5'
dashArray: '8,4' // 虚线样式匹配参考图像
},
pointStyle: {
size: 12,
color: '#3b82f6',
pulseColor: 'rgba(59, 130, 246, 0.3)'
color: 'transparent', // 透明编辑点匹配参考图像
pulseColor: 'transparent' // 透明脉冲动画
}
};

241
docs/jiekou.md Normal file
View File

@ -0,0 +1,241 @@
# Google OAuth 前端改造指南
## 📋 改造目标
让Google OAuth注册用户也能享受积分发放和邀请奖励功能。
## 🔄 新的流程设计
### **改造后流程**
```
1. 前端接收Google OAuth回调
2. 前端 → Java验证接口只验证不创建用户
3. 前端 → Python注册接口创建用户 + 积分发放)
4. 返回完整的用户信息和token
```
---
## 🛠️ 前端修改步骤
### 📍 修改文件
**文件**: `video-flow/app/api/auth/google/callback/route.ts`
### 🔧 核心修改
#### **第一步调用Java验证接口**
```typescript
// 调用Java callback进行Google Token验证不创建用户
const javaBaseUrl = process.env.NEXT_PUBLIC_JAVA_URL || 'https://auth.test.movieflow.ai';
const verifyResponse = await fetch(`${javaBaseUrl}/api/auth/google/callback`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify({
code: code, // Google authorization code
state: state, // state参数
inviteCode: inviteCode, // 邀请码
skipUserCreation: true // 🔑 关键:只验证不创建用户
})
});
const verifyResult = await verifyResponse.json();
```
#### **第二步调用Python注册接口**
```typescript
// 调用Python注册接口进行用户创建和积分发放
const smartvideoBaseUrl = process.env.NEXT_PUBLIC_SMARTVIDEO_URL || 'https://smartvideo.test.movieflow.ai';
const registerResponse = await fetch(`${smartvideoBaseUrl}/api/user_fission/register_with_invite`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify({
email: verifyResult.data.email,
name: verifyResult.data.name,
auth_type: 'GOOGLE',
google_user_info: verifyResult.data,
invite_code: inviteCode || stateData.inviteCode || undefined
})
});
const registerResult = await registerResponse.json();
```
#### **第三步:返回统一格式结果**
```typescript
return NextResponse.json({
success: true,
data: {
token: registerResult.data.token,
user: {
userId: registerResult.data.user_id,
userName: registerResult.data.name,
name: registerResult.data.name,
email: registerResult.data.email,
authType: registerResult.data.auth_type,
isNewUser: true
},
message: 'Google OAuth registration successful with credit rewards'
}
});
```
---
## 📚 后端接口文档
### 🔵 Java验证接口
#### **接口地址**
```
POST /api/auth/google/callback
```
#### **请求参数**
```typescript
{
code: string, // Google authorization code
state: string, // state参数可包含邀请码信息
inviteCode?: string, // 邀请码(可选)
skipUserCreation: boolean // true=只验证不创建用户
}
```
#### **成功响应**
```typescript
{
success: true,
message: "Google Token验证成功",
data: {
email: string, // 用户邮箱
name: string, // 用户姓名
picture: string, // 头像URL
googleId: string, // Google用户ID
verified: boolean, // 是否已验证
inviteCode: string // 邀请码(如果有)
}
}
```
#### **错误响应**
```typescript
{
success: false,
error: "TOKEN_EXCHANGE_FAILED" | "INVALID_ID_TOKEN",
message: string
}
```
---
### 🔵 Python注册接口
#### **接口地址**
```
POST /api/user_fission/register_with_invite
```
#### **请求参数**
```typescript
{
email: string, // 用户邮箱
name?: string, // 用户姓名(可选)
auth_type: "GOOGLE", // 认证类型固定为GOOGLE
google_user_info: { // Google用户信息来自Java验证接口
email: string,
name: string,
picture: string,
googleId: string,
verified: boolean,
inviteCode?: string
},
invite_code?: string // 邀请码(可选)
}
```
#### **成功响应**
```typescript
{
successful: true, // 注意:是 successful 不是 success
message: "Google OAuth注册成功",
data: {
user_id: string, // 用户IDUUID格式
email: string, // 用户邮箱
name: string, // 用户姓名
auth_type: "GOOGLE", // 认证类型
invite_code?: string, // 邀请码
token?: string // 认证token如果有
}
}
```
#### **错误响应**
```typescript
{
successful: false,
message: string, // 错误信息
detail?: string // 详细错误信息
}
```
---
## 🔧 环境变量配置
确保以下环境变量已配置:
```bash
# Java认证服务地址
NEXT_PUBLIC_JAVA_URL=https://auth.test.movieflow.ai
# Python积分服务地址
NEXT_PUBLIC_SMARTVIDEO_URL=https://smartvideo.test.movieflow.ai
```
---
## ⚠️ 重要注意事项
### 🔍 **关键变更**
1. **接口变更**: `/api/auth/google/login``/api/auth/google/callback`
2. **参数变更**:
- 移除: `idToken`, `action`
- 新增: `code`, `state`, `skipUserCreation: true`
3. **响应字段**: Python返回 `successful`Java返回 `success`
4. **用户ID字段**: Python返回 `user_id`,前端映射为 `userId`
### 🚨 **错误处理**
- Java验证失败 → 停止流程,返回错误
- Python注册失败 → 返回详细错误信息
- 网络异常 → 提供用户友好提示
### 🧪 **测试检查点**
- [ ] Google OAuth验证是否成功
- [ ] 用户是否正确创建并获得积分
- [ ] 邀请码功能是否正常
- [ ] 错误处理是否完善
- [ ] 前端页面跳转是否正常
---
## 🎯 预期效果
改造完成后Google OAuth用户将获得
**注册奖励积分**
**邀请码奖励功能**
**邀请人奖励积分**
**统一的用户体验**
✅ **完整的积分记录**
---
*文档版本: v2.0*
*更新时间: 2025-01-22*
*适用范围: video-flow前端项目*