Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .agents/react-doctor/AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# React Doctor

Run after making React changes to catch issues early. Use when reviewing code, finishing a feature, or fixing bugs in a React project.

Scans your React codebase for security, performance, correctness, and architecture issues. Outputs a 0-100 score with actionable diagnostics.

## Usage

```bash
npx -y react-doctor@latest . --verbose --diff
```

## Workflow

Run after making changes to catch issues early. Fix errors first, then re-run to verify the score improved.
19 changes: 19 additions & 0 deletions .agents/react-doctor/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
name: react-doctor
description: Run after making React changes to catch issues early. Use when reviewing code, finishing a feature, or fixing bugs in a React project.
version: 1.0.0
---

# React Doctor

Scans your React codebase for security, performance, correctness, and architecture issues. Outputs a 0-100 score with actionable diagnostics.

## Usage

```bash
npx -y react-doctor@latest . --verbose --diff
```

## Workflow

Run after making changes to catch issues early. Fix errors first, then re-run to verify the score improved.
2 changes: 1 addition & 1 deletion frontend/src/components/DenoisingStepsSlider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@ export function DenoisingStepsSlider({
<div className="space-y-3">
{localValue.map((stepValue, index) => (
<SliderWithInput
key={index}
key={`step-${index}-${localValue.length}`}
label={`Step ${index + 1}`}
labelClassName="text-xs text-muted-foreground w-12"
value={stepValue}
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/ImageManager.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ export function ImageManager({

{images.map((imagePath, index) => (
<div
key={index}
key={imagePath || `image-${index}`}
className="aspect-square border rounded-lg overflow-hidden relative group"
>
<img
Expand Down
5 changes: 4 additions & 1 deletion frontend/src/components/PromptInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -229,7 +229,10 @@ export function PromptInput({
<div className={`space-y-3 ${className}`}>
{managedPrompts.map((prompt, index) => {
return (
<div key={index} className="space-y-2">
<div
key={`prompt-${index}-${managedPrompts.length}`}
className="space-y-2"
>
<div className="flex items-start bg-card border border-border rounded-lg px-4 py-3 gap-3">
<PromptField
prompt={prompt}
Expand Down
68 changes: 45 additions & 23 deletions frontend/src/components/PromptTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -205,14 +205,18 @@ export function PromptTimeline({
const timelineRef = useRef<HTMLDivElement>(null);
const [timelineWidth, setTimelineWidth] = useState(800);
const [visibleStartTime, setVisibleStartTime] = useState(0);
const [visibleEndTime, setVisibleEndTime] = useState(
DEFAULT_VISIBLE_END_TIME
);
const [zoomLevel, setZoomLevel] = useState(1);

// Check if live mode is active
const isLive = useMemo(() => prompts.some(p => p.isLive), [prompts]);

// Calculate timeline metrics - simple derivations, no memoization needed
const pixelsPerSecond = BASE_PIXELS_PER_SECOND * zoomLevel;
const visibleTimeRange = timelineWidth / pixelsPerSecond;

// Derive visible end time from start time and time range (no state needed)
const visibleEndTime = visibleStartTime + visibleTimeRange;

// Memoized filtered prompts for better performance
const visiblePrompts = useMemo(() => {
return prompts.filter(
Expand All @@ -223,16 +227,6 @@ export function PromptTimeline({
);
}, [prompts, visibleStartTime, visibleEndTime]);

// Calculate timeline metrics
const pixelsPerSecond = useMemo(
() => BASE_PIXELS_PER_SECOND * zoomLevel,
[zoomLevel]
);
const visibleTimeRange = useMemo(
() => timelineWidth / pixelsPerSecond,
[timelineWidth, pixelsPerSecond]
);

// Scroll timeline to show a specific time
const scrollToTime = useCallback(
(time: number) => {
Expand All @@ -254,15 +248,9 @@ export function PromptTimeline({
// Reset UI state
setTimelineWidth(TIMELINE_RESET_STATE.timelineWidth);
setVisibleStartTime(TIMELINE_RESET_STATE.visibleStartTime);
setVisibleEndTime(TIMELINE_RESET_STATE.visibleEndTime);
setZoomLevel(TIMELINE_RESET_STATE.zoomLevel);
}, []);

// Update visible end time when zoom level or timeline width changes
useEffect(() => {
setVisibleEndTime(visibleStartTime + visibleTimeRange);
}, [visibleStartTime, visibleTimeRange]);

// Auto-scroll timeline during playback to follow the red line
useEffect(() => {
// Don't auto-scroll if user is manually dragging or not playing
Expand Down Expand Up @@ -509,13 +497,25 @@ export function PromptTimeline({
const maxEndTime = Math.max(
...importedPrompts.map((p: TimelinePrompt) => p.endTime || 0)
);
// Set visible end time to show all prompts, with minimum of default visible range
const newVisibleEndTime = Math.max(
// Calculate the target visible end time to show all prompts
const targetVisibleEndTime = Math.max(
maxEndTime + 2, // Add 2 seconds buffer
DEFAULT_VISIBLE_END_TIME
);
// Adjust zoom level to fit the range (visibleEndTime = visibleStartTime + timelineWidth / pixelsPerSecond)
// pixelsPerSecond = BASE_PIXELS_PER_SECOND * zoomLevel
// targetVisibleEndTime = 0 + timelineWidth / (BASE_PIXELS_PER_SECOND * newZoom)
// newZoom = timelineWidth / (BASE_PIXELS_PER_SECOND * targetVisibleEndTime)
const newZoom = Math.max(
MIN_ZOOM_LEVEL,
Math.min(
MAX_ZOOM_LEVEL,
timelineWidth /
(BASE_PIXELS_PER_SECOND * targetVisibleEndTime)
)
);
setVisibleStartTime(0);
setVisibleEndTime(newVisibleEndTime);
setZoomLevel(newZoom);
}

// Import settings if available and callback is provided
Expand Down Expand Up @@ -550,6 +550,7 @@ export function PromptTimeline({
resetTimelineUI,
onTimeChange,
_onPromptSubmit,
timelineWidth,
]
);

Expand Down Expand Up @@ -820,6 +821,8 @@ export function PromptTimeline({

{/* Timeline track */}
<div
role="application"
aria-label="Timeline track"
className="relative bg-muted rounded-lg border overflow-hidden cursor-grab w-full"
style={{ height: "80px" }} // Compact height for timeline display
onClick={handleTimelineClick}
Expand Down Expand Up @@ -908,6 +911,10 @@ export function PromptTimeline({
return (
<div
key={prompt.id}
role="button"
tabIndex={isEditable ? 0 : -1}
aria-label={`Prompt: ${prompt.text || "blend"}`}
aria-pressed={isSelected}
className={`absolute border rounded px-2 py-1 transition-colors ${
isEditable ? "cursor-pointer" : "cursor-default"
} ${
Expand All @@ -929,11 +936,23 @@ export function PromptTimeline({
boxColor
)}
onClick={e => handlePromptClick(e, prompt)}
onKeyDown={e => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handlePromptClick(
e as unknown as React.MouseEvent,
prompt
);
}
}}
>
{/* Resize handles */}
{!isPlaying && !isLivePrompt && (
<>
<div
role="separator"
aria-orientation="vertical"
aria-label="Resize left edge"
className="absolute top-0 bottom-0 w-2 -left-1 z-40"
style={{ cursor: "col-resize" }}
onMouseDown={e =>
Expand All @@ -947,6 +966,9 @@ export function PromptTimeline({
}
/>
<div
role="separator"
aria-orientation="vertical"
aria-label="Resize right edge"
className="absolute top-0 bottom-0 w-2 -right-1 z-40"
style={{ cursor: "col-resize" }}
onMouseDown={e =>
Expand All @@ -967,7 +989,7 @@ export function PromptTimeline({
// Display blend prompts vertically
prompt.prompts.map((promptItem, idx) => (
<div
key={idx}
key={`${prompt.id}-blend-${idx}`}
className="text-xs text-white font-medium truncate"
>
{promptItem.text} ({promptItem.weight}%)
Expand Down
20 changes: 10 additions & 10 deletions frontend/src/components/StatusBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,14 @@ interface StatusBarProps {
bitrate?: number;
}

export function StatusBar({ className = "", fps, bitrate }: StatusBarProps) {
const MetricItem = ({
label,
value,
unit = "",
}: {
label: string;
value: number | string;
unit?: string;
}) => (
interface MetricItemProps {
label: string;
value: number | string;
unit?: string;
}

function MetricItem({ label, value, unit = "" }: MetricItemProps) {
return (
<div className="flex items-center gap-1 text-xs">
<span className="font-medium">{label}:</span>
<span className="font-mono">
Expand All @@ -22,7 +20,9 @@ export function StatusBar({ className = "", fps, bitrate }: StatusBarProps) {
</span>
</div>
);
}

export function StatusBar({ className = "", fps, bitrate }: StatusBarProps) {
const formatBitrate = (bps?: number): string => {
if (bps === undefined || bps === 0) return "N/A";

Expand Down
5 changes: 4 additions & 1 deletion frontend/src/components/TimelinePromptEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -248,7 +248,10 @@ export function TimelinePromptEditor({
<div className={`space-y-3 ${className}`}>
{prompts.map((promptItem, index) => {
return (
<div key={index} className="space-y-2">
<div
key={`timeline-prompt-${index}-${prompts.length}`}
className="space-y-2"
>
<div className="flex items-start bg-card border border-border rounded-lg px-4 py-3 gap-3">
<PromptField
prompt={promptItem}
Expand Down
9 changes: 9 additions & 0 deletions frontend/src/components/VideoOutput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -163,8 +163,17 @@ export function VideoOutput({
{remoteStream ? (
<div
ref={containerRef}
role="button"
tabIndex={0}
aria-label="Video output - click to toggle play/pause"
className="relative w-full h-full cursor-pointer flex items-center justify-center"
onClick={handleVideoClick}
onKeyDown={e => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handleVideoClick();
}
}}
>
<video
ref={videoRef}
Expand Down
21 changes: 15 additions & 6 deletions frontend/src/components/ui/debounced-slider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,12 @@ export function DebouncedSlider({
className = "",
debounceMs = 100,
}: DebouncedSliderProps) {
const [localValue, setLocalValue] = useState<number[]>(value);
const commitTimeoutRef = useRef<number | null>(null);
const [localValue, setLocalValue] = useState<number[]>(value);
const [isInteracting, setIsInteracting] = useState(false);

// Sync with external value changes
useEffect(() => {
setLocalValue(value);
}, [value]);
// Sync with external value changes only when not actively interacting
const displayValue = isInteracting ? localValue : value;

// Cleanup timeout on unmount
useEffect(() => {
Expand All @@ -48,6 +47,7 @@ export function DebouncedSlider({
}, []);

const handleValueChange = (newValue: number[]) => {
setIsInteracting(true);
setLocalValue(newValue);
onValueChange(newValue);

Expand All @@ -59,13 +59,22 @@ export function DebouncedSlider({

commitTimeoutRef.current = setTimeout(() => {
onValueCommit(newValue);
setIsInteracting(false);
}, debounceMs);
} else {
// No commit callback, reset interaction state after debounce
if (commitTimeoutRef.current) {
clearTimeout(commitTimeoutRef.current);
}
commitTimeoutRef.current = setTimeout(() => {
setIsInteracting(false);
}, debounceMs);
}
};

return (
<Slider
value={localValue}
value={displayValue}
onValueChange={handleValueChange}
min={min}
max={max}
Expand Down
18 changes: 18 additions & 0 deletions frontend/src/components/ui/play-overlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,17 @@ export function PlayOverlay({
if (variant === "themed") {
return (
<div
role="button"
tabIndex={onClick ? 0 : -1}
aria-label={isPlaying ? "Pause" : "Play"}
className={`${sizes.circle} rounded-full border-2 border-input bg-background hover:bg-accent transition-colors flex items-center justify-center cursor-pointer shadow-lg ${className}`}
onClick={onClick}
onKeyDown={e => {
if (onClick && (e.key === "Enter" || e.key === " ")) {
e.preventDefault();
onClick();
}
}}
>
{isPlaying ? (
<Pause className={`${sizes.icon} text-foreground`} />
Expand All @@ -53,8 +62,17 @@ export function PlayOverlay({
// Default variant - semi-transparent black background with white icons
return (
<div
role="button"
tabIndex={onClick ? 0 : -1}
aria-label={isPlaying ? "Pause" : "Play"}
className={`bg-black/50 rounded-full ${sizes.padding} transition-colors hover:bg-black/60 cursor-pointer ${className}`}
onClick={onClick}
onKeyDown={e => {
if (onClick && (e.key === "Enter" || e.key === " ")) {
e.preventDefault();
onClick();
}
}}
>
{isPlaying ? (
<Pause className={`${sizes.icon} text-white`} />
Expand Down
Loading