20260429_232813-fix: video frame display pipeline — default project seed, presigned URLs, Canvas/FrameTimeline real frames, upload-parse flow
This commit is contained in:
@@ -7,9 +7,10 @@ import { cn } from '../lib/utils';
|
||||
|
||||
interface CanvasAreaProps {
|
||||
activeTool: string;
|
||||
frameUrl: string;
|
||||
}
|
||||
|
||||
export function CanvasArea({ activeTool }: CanvasAreaProps) {
|
||||
export function CanvasArea({ activeTool, frameUrl }: CanvasAreaProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [stageSize, setStageSize] = useState({ width: 800, height: 600 });
|
||||
const [scale, setScale] = useState(1);
|
||||
@@ -28,8 +29,8 @@ export function CanvasArea({ activeTool }: CanvasAreaProps) {
|
||||
|
||||
const effectiveTool = activeTool || storeActiveTool;
|
||||
|
||||
// We load a mock image representing a frame
|
||||
const [image] = useImage('https://images.unsplash.com/photo-1549317661-bd32c8ce0be2?q=80&w=2070&auto=format&fit=crop');
|
||||
// Load the actual frame image
|
||||
const [image] = useImage(frameUrl || '');
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
@@ -87,7 +88,7 @@ export function CanvasArea({ activeTool }: CanvasAreaProps) {
|
||||
setIsInferencing(true);
|
||||
try {
|
||||
const result = await predictMask({
|
||||
imageUrl: 'https://images.unsplash.com/photo-1549317661-bd32c8ce0be2?q=80&w=2070&auto=format&fit=crop',
|
||||
imageUrl: frameUrl || '',
|
||||
points: promptPoints?.map((p) => ({ x: p.x, y: p.y, type: p.type })),
|
||||
box: promptBox,
|
||||
});
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Play, Pause } from 'lucide-react';
|
||||
import { cn } from '../lib/utils';
|
||||
import { useStore } from '../store/useStore';
|
||||
|
||||
export function FrameTimeline() {
|
||||
const [currentFrame, setCurrentFrame] = useState(142);
|
||||
const totalFrames = 2400;
|
||||
const frames = useStore((state) => state.frames);
|
||||
const currentFrameIndex = useStore((state) => state.currentFrameIndex);
|
||||
const setCurrentFrame = useStore((state) => state.setCurrentFrame);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
|
||||
const totalFrames = frames.length;
|
||||
const currentFrame = totalFrames > 0 ? currentFrameIndex + 1 : 0;
|
||||
|
||||
// show frames around current frame
|
||||
const frameWindow = 20;
|
||||
const frames = Array.from({ length: 41 }, (_, i) => currentFrame - frameWindow + i);
|
||||
const frameWindow = 20;
|
||||
const displayIndices = totalFrames > 0
|
||||
? Array.from({ length: 41 }, (_, i) => currentFrameIndex - frameWindow + i)
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div className="h-32 bg-[#111] border-t border-white/5 flex flex-col shrink-0 z-20">
|
||||
@@ -17,19 +24,20 @@ export function FrameTimeline() {
|
||||
<input
|
||||
type="range"
|
||||
min="1"
|
||||
max={totalFrames}
|
||||
max={Math.max(totalFrames, 1)}
|
||||
value={currentFrame}
|
||||
onChange={(e) => setCurrentFrame(parseInt(e.target.value))}
|
||||
onChange={(e) => setCurrentFrame(parseInt(e.target.value) - 1)}
|
||||
className="w-full absolute inset-0 opacity-0 cursor-ew-resize z-20"
|
||||
disabled={totalFrames === 0}
|
||||
/>
|
||||
<div className="h-1 bg-white/10 w-full relative group-hover:h-2 transition-all">
|
||||
<div
|
||||
className="h-full bg-cyan-500 absolute left-0"
|
||||
style={{ width: `${(currentFrame / totalFrames) * 100}%` }}
|
||||
style={{ width: `${totalFrames > 0 ? (currentFrame / totalFrames) * 100 : 0}%` }}
|
||||
/>
|
||||
<div
|
||||
className="w-3 h-3 bg-white rounded-full absolute top-1/2 -translate-y-1/2 -ml-1.5 shadow-sm transform scale-0 group-hover:scale-100 transition-transform shadow-cyan-500/50"
|
||||
style={{ left: `${(currentFrame / totalFrames) * 100}%` }}
|
||||
style={{ left: `${totalFrames > 0 ? (currentFrame / totalFrames) * 100 : 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -49,27 +57,42 @@ export function FrameTimeline() {
|
||||
className="flex-1 flex gap-px flex-nowrap items-center overflow-hidden justify-center"
|
||||
style={{ WebkitMaskImage: 'linear-gradient(to right, transparent, black 10%, black 90%, transparent)' }}
|
||||
>
|
||||
{frames.map((frameIdx) => {
|
||||
if (frameIdx < 1 || frameIdx > totalFrames) {
|
||||
return <div key={`empty-${frameIdx}`} className="w-16 h-12 opacity-0 shrink-0" />
|
||||
}
|
||||
const isCurrent = frameIdx === currentFrame;
|
||||
return (
|
||||
<div
|
||||
key={frameIdx}
|
||||
onClick={() => setCurrentFrame(frameIdx)}
|
||||
className={cn(
|
||||
"relative shrink-0 rounded-sm transition-all cursor-pointer flex items-center justify-center overflow-hidden group mx-0.5",
|
||||
isCurrent ? "w-28 h-16 border-2 border-cyan-500 bg-gray-700 shadow-[0_0_15px_rgba(6,182,212,0.3)] z-10" : "w-16 h-12 border border-white/5 bg-gray-800/50 opacity-40 hover:opacity-100"
|
||||
)}
|
||||
>
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-gray-800 to-gray-900 opacity-20" />
|
||||
<span className={cn("text-[9px] font-mono text-white text-center z-10", isCurrent ? "text-[10px] font-bold" : "")}>
|
||||
{frameIdx.toString().padStart(4, '0')}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{totalFrames === 0 ? (
|
||||
<div className="text-xs text-gray-600 font-mono">暂无帧数据</div>
|
||||
) : (
|
||||
displayIndices.map((idx) => {
|
||||
if (idx < 0 || idx >= totalFrames) {
|
||||
return <div key={`empty-${idx}`} className="w-16 h-12 opacity-0 shrink-0" />
|
||||
}
|
||||
const frame = frames[idx];
|
||||
const isCurrent = idx === currentFrameIndex;
|
||||
return (
|
||||
<div
|
||||
key={frame.id}
|
||||
onClick={() => setCurrentFrame(idx)}
|
||||
className={cn(
|
||||
"relative shrink-0 rounded-sm transition-all cursor-pointer flex items-center justify-center overflow-hidden group mx-0.5",
|
||||
isCurrent ? "w-28 h-16 border-2 border-cyan-500 bg-gray-700 shadow-[0_0_15px_rgba(6,182,212,0.3)] z-10" : "w-16 h-12 border border-white/5 bg-gray-800/50 opacity-40 hover:opacity-100"
|
||||
)}
|
||||
>
|
||||
{frame.url ? (
|
||||
<img
|
||||
src={frame.url}
|
||||
alt={`frame-${idx}`}
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
draggable={false}
|
||||
/>
|
||||
) : (
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-gray-800 to-gray-900 opacity-20" />
|
||||
)}
|
||||
<span className={cn("text-[9px] font-mono text-white text-center z-10 drop-shadow", isCurrent ? "text-[10px] font-bold" : "")}>
|
||||
{(idx + 1).toString().padStart(4, '0')}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="w-48 text-right shrink-0">
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useState, useEffect, useRef } from 'react';
|
||||
import { UploadCloud, Film, Settings2, MoreHorizontal, Plus, Loader2 } from 'lucide-react';
|
||||
import { cn } from '../lib/utils';
|
||||
import { useStore } from '../store/useStore';
|
||||
import { getProjects, createProject, uploadMedia } from '../lib/api';
|
||||
import { getProjects, createProject, uploadMedia, parseMedia } from '../lib/api';
|
||||
import type { Project } from '../store/useStore';
|
||||
|
||||
interface ProjectLibraryProps {
|
||||
@@ -92,15 +92,17 @@ export function ProjectLibrary({ onProjectSelect }: ProjectLibraryProps) {
|
||||
if (!file) return;
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const result = await uploadMedia(file);
|
||||
alert(`上传成功: ${file.name}\n已保存至: ${result.file_url}`);
|
||||
// 上传成功后创建新项目
|
||||
// 1. 创建项目
|
||||
const newProject = await createProject({
|
||||
name: file.name,
|
||||
description: `导入于 ${new Date().toLocaleString()}`,
|
||||
});
|
||||
addProject(newProject);
|
||||
// 刷新项目列表
|
||||
// 2. 带 project_id 上传
|
||||
const result = await uploadMedia(file, String(newProject.id));
|
||||
// 3. 触发帧解析
|
||||
await parseMedia(String(newProject.id));
|
||||
alert(`上传成功: ${file.name}\n已保存至: ${result.url}`);
|
||||
// 4. 刷新项目列表
|
||||
const data = await getProjects();
|
||||
setProjects(data);
|
||||
} catch (err) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import React, { useEffect } from 'react';
|
||||
import { useStore } from '../store/useStore';
|
||||
import { getProjectFrames, parseMedia } from '../lib/api';
|
||||
import { CanvasArea } from './CanvasArea';
|
||||
import { ToolsPalette } from './ToolsPalette';
|
||||
import { OntologyInspector } from './OntologyInspector';
|
||||
@@ -8,6 +9,61 @@ import { FrameTimeline } from './FrameTimeline';
|
||||
export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void }) {
|
||||
const activeTool = useStore((state) => state.activeTool);
|
||||
const setActiveTool = useStore((state) => state.setActiveTool);
|
||||
const currentProject = useStore((state) => state.currentProject);
|
||||
const frames = useStore((state) => state.frames);
|
||||
const currentFrameIndex = useStore((state) => state.currentFrameIndex);
|
||||
const setFrames = useStore((state) => state.setFrames);
|
||||
const setCurrentFrame = useStore((state) => state.setCurrentFrame);
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentProject?.id) return;
|
||||
let cancelled = false;
|
||||
|
||||
const loadFrames = async () => {
|
||||
try {
|
||||
const data = await getProjectFrames(String(currentProject.id));
|
||||
if (cancelled) return;
|
||||
|
||||
if (data.length === 0 && currentProject.video_path) {
|
||||
// No frames yet but video exists → trigger parsing
|
||||
try {
|
||||
await parseMedia(String(currentProject.id));
|
||||
if (cancelled) return;
|
||||
const fresh = await getProjectFrames(String(currentProject.id));
|
||||
if (cancelled) return;
|
||||
setFrames(fresh.map((f) => ({
|
||||
id: String(f.id),
|
||||
projectId: String(f.project_id),
|
||||
index: f.frame_index,
|
||||
url: f.image_url,
|
||||
width: f.width ?? 0,
|
||||
height: f.height ?? 0,
|
||||
})));
|
||||
setCurrentFrame(0);
|
||||
} catch (err) {
|
||||
console.error('Parse failed:', err);
|
||||
}
|
||||
} else {
|
||||
setFrames(data.map((f) => ({
|
||||
id: String(f.id),
|
||||
projectId: String(f.project_id),
|
||||
index: f.frame_index,
|
||||
url: f.image_url,
|
||||
width: f.width ?? 0,
|
||||
height: f.height ?? 0,
|
||||
})));
|
||||
setCurrentFrame(0);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load frames:', err);
|
||||
}
|
||||
};
|
||||
|
||||
loadFrames();
|
||||
return () => { cancelled = true; };
|
||||
}, [currentProject?.id, setFrames, setCurrentFrame]);
|
||||
|
||||
const currentFrameUrl = frames[currentFrameIndex]?.url || '';
|
||||
|
||||
return (
|
||||
<div className="w-full h-full flex flex-col bg-[#0a0a0a]">
|
||||
@@ -16,7 +72,7 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
|
||||
<div className="flex items-center gap-4">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-widest text-gray-400">核心分割工作区</h2>
|
||||
<div className="h-4 w-px bg-white/10"></div>
|
||||
<span className="text-sm text-white font-mono">Autonomous_Nav_Cam_Left.mp4</span>
|
||||
<span className="text-sm text-white font-mono">{currentProject?.name || '未选择项目'}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-1.5 text-[10px] uppercase font-medium">
|
||||
@@ -37,7 +93,7 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
|
||||
|
||||
<div className="flex-1 relative flex items-center justify-center p-8 bg-[#151515] overflow-hidden">
|
||||
<div className="relative w-full h-full bg-[#1e1e1e] border border-white/5 shadow-2xl rounded-sm">
|
||||
<CanvasArea activeTool={activeTool} />
|
||||
<CanvasArea activeTool={activeTool} frameUrl={currentFrameUrl} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -42,7 +42,17 @@ export async function login(username: string, password: string): Promise<{ token
|
||||
// Projects
|
||||
export async function getProjects(): Promise<Project[]> {
|
||||
const response = await apiClient.get('/api/projects');
|
||||
return response.data;
|
||||
return response.data.map((p: any) => ({
|
||||
id: String(p.id),
|
||||
name: p.name,
|
||||
description: p.description,
|
||||
status: p.status,
|
||||
frames: p.frame_count ?? 0,
|
||||
fps: '30FPS',
|
||||
createdAt: p.created_at,
|
||||
updatedAt: p.updated_at,
|
||||
video_path: p.video_path,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function createProject(payload: {
|
||||
@@ -50,7 +60,18 @@ export async function createProject(payload: {
|
||||
description?: string;
|
||||
}): Promise<Project> {
|
||||
const response = await apiClient.post('/api/projects', payload);
|
||||
return response.data;
|
||||
const p = response.data;
|
||||
return {
|
||||
id: String(p.id),
|
||||
name: p.name,
|
||||
description: p.description,
|
||||
status: p.status,
|
||||
frames: p.frame_count ?? 0,
|
||||
fps: '30FPS',
|
||||
createdAt: p.created_at,
|
||||
updatedAt: p.updated_at,
|
||||
video_path: p.video_path,
|
||||
};
|
||||
}
|
||||
|
||||
export async function updateProject(id: string, payload: Partial<Project>): Promise<Project> {
|
||||
@@ -102,6 +123,30 @@ export async function uploadMedia(file: File, projectId?: string): Promise<{ url
|
||||
return { url: file_url, id: object_name };
|
||||
}
|
||||
|
||||
export async function getProjectFrames(projectId: string): Promise<Array<{
|
||||
id: number;
|
||||
project_id: number;
|
||||
frame_index: number;
|
||||
image_url: string;
|
||||
width: number | null;
|
||||
height: number | null;
|
||||
}>> {
|
||||
const response = await apiClient.get(`/api/projects/${projectId}/frames`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function parseMedia(projectId: string): Promise<{
|
||||
project_id: number;
|
||||
frames_extracted: number;
|
||||
status: string;
|
||||
message: string;
|
||||
}> {
|
||||
const response = await apiClient.post('/api/media/parse', null, {
|
||||
params: { project_id: projectId },
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// AI Prediction
|
||||
export async function predictMask(payload: {
|
||||
imageUrl: string;
|
||||
|
||||
@@ -8,6 +8,7 @@ export interface Project {
|
||||
fps?: string;
|
||||
frames?: number;
|
||||
thumbnail?: string;
|
||||
video_path?: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user