Screen-VLA / components /TaskSegmentCard.tsx
Gemini
VLA Data Generator - Complete TypeScript/React app with backend
256cef9
import React, { useState, useEffect } from 'react';
import { TaskSegment, Interaction, InteractionType } from '../types';
import { MousePointerClick, Type, Pencil } from './Icons';
type HighlightPoint = { x: number; y: number; isEditing: boolean } | null;
type CoordinatePickerCallback = ((coords: { x: number; y: number }) => void) | null;
const interactionIcons: Record<InteractionType, React.ReactNode> = {
click: <MousePointerClick className="w-4 h-4 text-sky-400" />,
type: <Type className="w-4 h-4 text-lime-400" />,
};
interface TaskSegmentCardProps {
task: TaskSegment;
videoDuration: number;
totalFrames: number;
onSeekToTime?: (time: number) => void;
onUpdateInteraction: (taskId: number, interactionIndex: number, updatedInteraction: Interaction) => void;
onHighlightPoint: (point: HighlightPoint) => void;
onSetCoordinatePicker: (callback: CoordinatePickerCallback) => void;
}
const formatTime = (seconds: number) => {
if (isNaN(seconds) || seconds < 0) return '0.0s';
return `${seconds.toFixed(1)}s`;
};
const formatCoords = (x?: number, y?: number) => {
if (x === undefined || y === undefined) return '';
return `(x: ${x.toFixed(2)}, y: ${y.toFixed(2)})`;
}
export const TaskSegmentCard: React.FC<TaskSegmentCardProps> = ({ task, videoDuration, totalFrames, onSeekToTime, onUpdateInteraction, onHighlightPoint, onSetCoordinatePicker }) => {
const [editingIndex, setEditingIndex] = useState<number | null>(null);
const [editingInteractionType, setEditingInteractionType] = useState<InteractionType | null>(null);
const [editDetails, setEditDetails] = useState('');
const [editTime, setEditTime] = useState('');
const [editX, setEditX] = useState('');
const [editY, setEditY] = useState('');
// Live seek video when editing timestamp
useEffect(() => {
if (editingIndex !== null && onSeekToTime) {
const timeInSeconds = parseFloat(editTime);
if (!isNaN(timeInSeconds) && timeInSeconds >= 0 && timeInSeconds <= videoDuration) {
onSeekToTime(timeInSeconds);
}
}
}, [editTime, editingIndex, onSeekToTime, videoDuration]);
// Live update highlight marker when editing coordinates
useEffect(() => {
if (editingInteractionType === 'click' && editingIndex !== null) {
const x = parseFloat(editX);
const y = parseFloat(editY);
if (!isNaN(x) && !isNaN(y)) {
onHighlightPoint({ x, y, isEditing: true });
}
}
}, [editX, editY, editingInteractionType, editingIndex, onHighlightPoint]);
const calculateTime = (frameIndex: number): number | null => {
if (!videoDuration || !totalFrames || videoDuration === 0 || totalFrames === 0) return null;
return (frameIndex / totalFrames) * videoDuration;
};
const handleInteractionClick = (interaction: Interaction) => {
const time = calculateTime(interaction.frameIndex);
if (time !== null && onSeekToTime) {
onSeekToTime(time);
}
};
const handleEditClick = (interaction: Interaction, index: number) => {
setEditingIndex(index);
setEditingInteractionType(interaction.type);
setEditDetails(interaction.details);
const time = calculateTime(interaction.frameIndex);
setEditTime(time !== null ? time.toFixed(1) : '');
if (interaction.type === 'click') {
setEditX(interaction.x?.toFixed(4) || '0.5');
setEditY(interaction.y?.toFixed(4) || '0.5');
onSetCoordinatePicker((coords) => {
setEditX(coords.x.toFixed(4));
setEditY(coords.y.toFixed(4));
});
}
};
const handleCancelEdit = () => {
setEditingIndex(null);
setEditingInteractionType(null);
setEditDetails('');
setEditTime('');
setEditX('');
setEditY('');
onHighlightPoint(null);
onSetCoordinatePicker(null);
};
const handleSaveEdit = () => {
if (editingIndex === null) return;
const timeInSeconds = parseFloat(editTime);
if (isNaN(timeInSeconds) || !videoDuration || !totalFrames) {
console.error("Cannot save edit due to invalid time or video data.");
return;
}
const newFrameIndex = Math.round((timeInSeconds / videoDuration) * totalFrames);
const originalInteraction = task.interactions[editingIndex];
const updatedInteraction: Interaction = {
...originalInteraction,
details: editDetails,
frameIndex: newFrameIndex,
};
if (updatedInteraction.type === 'click') {
updatedInteraction.x = parseFloat(editX) || 0;
updatedInteraction.y = parseFloat(editY) || 0;
}
onUpdateInteraction(task.id, editingIndex, updatedInteraction);
handleCancelEdit();
};
const handleInteractionMouseEnter = (interaction: Interaction) => {
if (interaction.type === 'click' && interaction.x !== undefined && interaction.y !== undefined) {
onHighlightPoint({ x: interaction.x, y: interaction.y, isEditing: false });
}
};
const handleInteractionMouseLeave = () => {
onHighlightPoint(null);
};
const segmentStartTime = formatTime(calculateTime(task.startFrame) ?? 0);
const segmentEndTime = formatTime(calculateTime(task.endFrame) ?? 0);
return (
<div className="bg-slate-900/70 border border-slate-700 rounded-xl p-4 transition-all hover:border-slate-600">
<div className="flex items-start gap-4">
<div className="flex-shrink-0 bg-slate-700 text-indigo-300 font-bold text-sm w-8 h-8 flex items-center justify-center rounded-full">
{task.id}
</div>
<div className="flex-grow">
<div className="flex justify-between items-start gap-2">
<p className="font-semibold text-slate-200 pr-2">{task.description}</p>
{(calculateTime(task.startFrame) !== null) && (
<span className="text-xs font-mono text-slate-400 bg-slate-800 px-2 py-1 rounded-md whitespace-nowrap">
{segmentStartTime} - {segmentEndTime}
</span>
)}
</div>
{task.interactions && task.interactions.length > 0 && (
<div className="mt-3 space-y-1">
{task.interactions.map((interaction, index) => {
if (editingIndex === index) {
// EDITING VIEW
return (
<div key={index} className="bg-slate-800 p-3 -mx-3 rounded-lg ring-2 ring-indigo-500">
<div className="flex items-start gap-3">
<div className="flex-shrink-0 pt-1">
{interactionIcons[interaction.type] || <div className="w-4 h-4" />}
</div>
<div className="flex-grow space-y-3">
<div>
<label className="text-xs text-slate-400">Description</label>
<input
type="text"
value={editDetails}
onChange={(e) => setEditDetails(e.target.value)}
className="w-full bg-slate-900 border border-slate-600 rounded-md px-2 py-1 text-sm text-slate-200 focus:ring-1 focus:ring-indigo-400 focus:border-indigo-400"
/>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
<div>
<label className="text-xs text-slate-400">Timestamp (s)</label>
<input
type="number"
step="0.1"
value={editTime}
onChange={(e) => setEditTime(e.target.value)}
className="w-full bg-slate-900 border border-slate-600 rounded-md px-2 py-1 text-sm text-slate-200 focus:ring-1 focus:ring-indigo-400 focus:border-indigo-400"
/>
</div>
{interaction.type === 'click' && (
<>
<div>
<label className="text-xs text-slate-400">Coord. X</label>
<input
type="number"
step="0.01"
value={editX}
onChange={(e) => setEditX(e.target.value)}
className="w-full bg-slate-900 border border-slate-600 rounded-md px-2 py-1 text-sm text-slate-200 focus:ring-1 focus:ring-indigo-400 focus:border-indigo-400"
/>
</div>
<div>
<label className="text-xs text-slate-400">Coord. Y</label>
<input
type="number"
step="0.01"
value={editY}
onChange={(e) => setEditY(e.target.value)}
className="w-full bg-slate-900 border border-slate-600 rounded-md px-2 py-1 text-sm text-slate-200 focus:ring-1 focus:ring-indigo-400 focus:border-indigo-400"
/>
</div>
</>
)}
</div>
{interaction.type === 'click' && (
<p className="text-xs text-slate-400 italic">Click on the video player to update coordinates.</p>
)}
</div>
</div>
<div className="flex justify-end items-center gap-2 mt-3">
<button onClick={handleCancelEdit} className="px-3 py-1 text-sm font-semibold text-slate-300 hover:bg-slate-700 rounded-md">Cancel</button>
<button onClick={handleSaveEdit} className="px-3 py-1 text-sm font-semibold text-white bg-indigo-600 hover:bg-indigo-500 rounded-md">Save</button>
</div>
</div>
);
}
// DISPLAY VIEW
const interactionTime = calculateTime(interaction.frameIndex);
const interactionCoords = formatCoords(interaction.x, interaction.y);
return (
<div
key={index}
onMouseEnter={() => handleInteractionMouseEnter(interaction)}
onMouseLeave={handleInteractionMouseLeave}
className="group flex items-start gap-3 p-1.5 -mx-1.5 rounded-md transition-colors hover:bg-slate-800/60"
>
<div
className="flex-shrink-0 pt-1 cursor-pointer"
onClick={() => handleInteractionClick(interaction)}
role="button"
tabIndex={0}
onKeyPress={(e) => (e.key === 'Enter' || e.key === ' ') && handleInteractionClick(interaction)}
>
{interactionIcons[interaction.type] || <div className="w-4 h-4" />}
</div>
<div
className="flex-grow text-sm text-slate-400 cursor-pointer"
onClick={() => handleInteractionClick(interaction)}
role="button"
>
<span>{interaction.details}</span>
{interaction.type === 'click' && interactionCoords && (
<span className="ml-2 font-mono text-xs text-cyan-400">{interactionCoords}</span>
)}
</div>
<div className="flex items-center gap-2">
{interactionTime !== null && (
<span className="text-xs font-mono text-slate-500 whitespace-nowrap hidden group-hover:inline">
(~{formatTime(interactionTime)})
</span>
)}
<button
onClick={() => handleEditClick(interaction, index)}
className="hidden group-hover:block p-1 text-slate-500 hover:text-slate-300 transition-colors"
aria-label="Edit interaction"
>
<Pencil className="w-3.5 h-3.5" />
</button>
</div>
</div>
);
})}
</div>
)}
</div>
</div>
</div>
);
};