Skip to content
Merged
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
120 changes: 86 additions & 34 deletions src/components/generation/EnhancePanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ interface ResultEntry {
durationSec: number;
peaks: number[];
timestamp: number;
status: 'generating' | 'ready' | 'error';
error?: string;
}

type ABSide = 'A' | 'B';
Expand Down Expand Up @@ -160,6 +162,9 @@ export function EnhancePanel() {
// Quick Styles section
const [quickStylesOpen, setQuickStylesOpen] = useState(false);

// Local guard against rapid Generate clicks (supplements store-level isGenerating)
const [isSubmitting, setIsSubmitting] = useState(false);

// Playback
const playback = useEnhancePlayback();

Expand Down Expand Up @@ -331,17 +336,27 @@ export function EnhancePanel() {
}
}
}
if (!audioKey) return;
if (!audioKey) {
setResults((prev) => prev.map((r) =>
r.id === resultId ? { ...r, status: 'error' as const, error: 'No audio key found for result' } : r,
));
return;
}

try {
const buffer = await playback.loadBuffer(audioKey);
if (!buffer) return;
if (!buffer) {
setResults((prev) => prev.map((r) =>
r.id === resultId ? { ...r, status: 'error' as const, error: 'Failed to load audio buffer' } : r,
));
return;
}
const peaks = computeWaveformPeaks(buffer, 60);
const dur = buffer.duration;
const finalClipId = updatedClip?.id ?? originalClipId;
setResults((prev) => prev.map((r) =>
r.id === resultId
? { ...r, clipId: finalClipId, audioKey, peaks, duration: formatDuration(dur), durationSec: dur }
? { ...r, clipId: finalClipId, audioKey, peaks, duration: formatDuration(dur), durationSec: dur, status: 'ready' as const }
: r,
));
// Auto-select first result
Expand All @@ -350,14 +365,18 @@ export function EnhancePanel() {
if (prev === 0) return Math.max(0, results.length); // point to new entry
return prev;
});
} catch {
// Audio decode failed — leave duration as --:--
} catch (err) {
const message = err instanceof Error ? err.message : 'Audio decode failed';
setResults((prev) => prev.map((r) =>
r.id === resultId ? { ...r, status: 'error' as const, error: message } : r,
));
}
}, [playback, results.length, enhancerTarget]);

// Cover generation
const handleCoverGenerate = useCallback(async () => {
if (!enhancerTarget || isGenerating) return;
if (!enhancerTarget || isGenerating || isSubmitting) return;
setIsSubmitting(true);
const coverStrength = CONSISTENCY_VALUES[consistency];
const resultId = `result-${Date.now()}`;
setResults((prev) => [...prev, {
Expand All @@ -369,22 +388,33 @@ export function EnhancePanel() {
durationSec: 0,
peaks: [],
timestamp: Date.now(),
status: 'generating',
}]);
const newClipId = await generateCoverClip({
clipId: enhancerTarget.clipId,
caption,
lyrics,
coverStrength,
createNew,
sourceAudioOverride: chainedSourceAudioKey || undefined,
});
// After generation, try to load the result audio to get peaks/duration
await finalizeResult(resultId, enhancerTarget.clipId, newClipId);
}, [enhancerTarget, caption, lyrics, consistency, createNew, isGenerating, chainedSourceAudioKey, finalizeResult]);
try {
const newClipId = await generateCoverClip({
clipId: enhancerTarget.clipId,
caption,
lyrics,
coverStrength,
createNew,
sourceAudioOverride: chainedSourceAudioKey || undefined,
});
// After generation, try to load the result audio to get peaks/duration
await finalizeResult(resultId, enhancerTarget.clipId, newClipId);
} catch (err) {
const message = err instanceof Error ? err.message : 'Enhancement failed';
setResults((prev) => prev.map((r) =>
r.id === resultId ? { ...r, status: 'error' as const, error: message } : r,
));
} finally {
setIsSubmitting(false);
}
}, [enhancerTarget, caption, lyrics, consistency, createNew, isGenerating, isSubmitting, chainedSourceAudioKey, finalizeResult]);

// Repaint generation
const handleRepaintGenerate = useCallback(async () => {
if (!enhancerTarget || isGenerating) return;
if (!enhancerTarget || isGenerating || isSubmitting) return;
setIsSubmitting(true);
const resultId = `result-${Date.now()}`;
setResults((prev) => [...prev, {
id: resultId,
Expand All @@ -395,19 +425,29 @@ export function EnhancePanel() {
durationSec: 0,
peaks: [],
timestamp: Date.now(),
status: 'generating',
}]);
const newClipId = await generateRepaintClip({
clipId: enhancerTarget.clipId,
repaintStart: selStart,
repaintEnd: selEnd,
prompt,
globalCaption: globalCaption || undefined,
repaintMode,
repaintStrength,
sourceAudioOverride: chainedSourceAudioKey || undefined,
});
await finalizeResult(resultId, enhancerTarget.clipId, newClipId);
}, [enhancerTarget, selStart, selEnd, prompt, globalCaption, repaintMode, repaintStrength, isGenerating, chainedSourceAudioKey, finalizeResult]);
try {
const newClipId = await generateRepaintClip({
clipId: enhancerTarget.clipId,
repaintStart: selStart,
repaintEnd: selEnd,
prompt,
globalCaption: globalCaption || undefined,
repaintMode,
repaintStrength,
sourceAudioOverride: chainedSourceAudioKey || undefined,
});
await finalizeResult(resultId, enhancerTarget.clipId, newClipId);
} catch (err) {
Comment on lines +430 to +442
Copy link

Copilot AI Mar 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same issue as cover: generateRepaintClip typically resolves without throwing even when it fails, so this catch block won’t run and the optimistic result may never transition out of status='generating'. Please propagate success/failure from generateRepaintClip (return/throw) or explicitly check the updated clip/job state after awaiting it, then set status to 'ready' or 'error' accordingly.

Copilot uses AI. Check for mistakes.
const message = err instanceof Error ? err.message : 'Repaint failed';
setResults((prev) => prev.map((r) =>
r.id === resultId ? { ...r, status: 'error' as const, error: message } : r,
));
} finally {
setIsSubmitting(false);
}
}, [enhancerTarget, selStart, selEnd, prompt, globalCaption, repaintMode, repaintStrength, isGenerating, isSubmitting, chainedSourceAudioKey, finalizeResult]);

const handleGenerate = mode === 'cover' ? handleCoverGenerate : handleRepaintGenerate;

Expand Down Expand Up @@ -560,7 +600,7 @@ export function EnhancePanel() {
const coverSupported = modelSupportsTaskType('cover');
const repaintSupported = modelSupportsTaskType('repaint');
const modeSupported = mode === 'cover' ? coverSupported : repaintSupported;
const canGenerate = hasAudio && modeSupported && !isGenerating && !!(clip && track);
const canGenerate = hasAudio && modeSupported && !isGenerating && !isSubmitting && !!(clip && track);

const clipStart = clip?.startTime ?? 0;
const clipEnd = (clip?.startTime ?? 0) + (clip?.duration ?? 0);
Expand Down Expand Up @@ -1055,7 +1095,7 @@ export function EnhancePanel() {
: 'bg-[#2a2a2e] text-zinc-500 cursor-not-allowed'
}`}
>
{isGenerating
{isGenerating || isSubmitting
? (mode === 'cover' ? 'Enhancing...' : 'Repainting...')
: (mode === 'cover' ? 'Enhance' : 'Repaint Selection')
}
Expand Down Expand Up @@ -1094,6 +1134,15 @@ export function EnhancePanel() {
}`}
>
<div className="flex items-center gap-2 px-2 py-2">
{r.status === 'generating' ? (
<div className="w-6 h-6 flex items-center justify-center flex-shrink-0">
<div className="w-4 h-4 border-2 border-zinc-600 border-t-teal-400 rounded-full animate-spin" />
</div>
) : r.status === 'error' ? (
<div className="w-6 h-6 flex items-center justify-center rounded-full bg-red-900/50 text-red-400 flex-shrink-0">
<svg className="w-3 h-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><path d="M18 6L6 18M6 6l12 12" strokeLinecap="round" /></svg>
</div>
) : (
<button
data-testid={`result-play-btn-${idx}`}
onClick={(e) => { e.stopPropagation(); handleResultPlay(r.id, r.audioKey); }}
Expand All @@ -1110,14 +1159,17 @@ export function EnhancePanel() {
<svg className="w-3 h-3" viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z" /></svg>
)}
</button>
)}
<div className="flex-1 min-w-0">
<p className="text-[11px] text-zinc-300 truncate">
<p className={`text-[11px] truncate ${r.status === 'error' ? 'text-red-400' : 'text-zinc-300'}`}>
{r.title}
{canAB && isSelected && (
<span className={`ml-1 ${abSide === 'B' ? 'text-violet-400 font-bold' : 'text-zinc-600'}`}>B</span>
)}
</p>
<p className="text-[10px] text-zinc-600">{r.duration}</p>
<p className="text-[10px] text-zinc-600">
{r.status === 'generating' ? 'Generating...' : r.status === 'error' ? (r.error ?? 'Failed') : r.duration}
</p>
Comment on lines 1137 to +1172
Copy link

Copilot AI Mar 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are existing component tests for EnhancePanel, but none currently assert the new ResultEntry status transitions/UI (spinner while generating, error message on failure, ready state after finalize). Please add tests that mock a failed generation outcome and verify the panel updates the corresponding result row (and likewise for the generating → ready path).

Copilot uses AI. Check for mistakes.
</div>
{r.audioKey && (
<button
Expand Down
62 changes: 61 additions & 1 deletion src/components/generation/__tests__/EnhancePanel.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { EnhancePanel } from '../EnhancePanel';
import { useProjectStore } from '../../../store/projectStore';
import { useUIStore } from '../../../store/uiStore';
Expand All @@ -8,6 +8,7 @@

const mockGenerateCoverClip = vi.fn().mockResolvedValue(undefined);
const mockGenerateRepaintClip = vi.fn().mockResolvedValue(undefined);

vi.mock('../../../services/generationPipeline', () => ({
generateCoverClip: (...args: unknown[]) => mockGenerateCoverClip(...args),
generateRepaintClip: (...args: unknown[]) => mockGenerateRepaintClip(...args),
Expand Down Expand Up @@ -746,7 +747,7 @@
});
const callArgs = mockGenerateCoverClip.mock.calls[0][0];
expect(callArgs.clipId).toBe(clip.id);
expect(callArgs.sourceAudioOverride).toBeUndefined();

Check failure on line 750 in src/components/generation/__tests__/EnhancePanel.test.tsx

View workflow job for this annotation

GitHub Actions / unit-test

src/components/generation/__tests__/EnhancePanel.test.tsx > EnhancePanel version tree UI > passes correct coverStrength for each consistency level (high=0.25, medium=0.5, low=0.75)

TypeError: [Function generateCoverClip] is not a spy or a call to a spy! ❯ src/components/generation/__tests__/EnhancePanel.test.tsx:750:31
});

it('passes sourceAudioOverride to generateRepaintClip', async () => {
Expand Down Expand Up @@ -791,3 +792,62 @@
expect(await mockGenerateCoverClip.mock.results[0].value).toBe(expectedNewClipId);
});
});

describe('EnhancePanel result entry states', () => {
beforeEach(() => {
vi.clearAllMocks();
useGenerationStore.setState({ isGenerating: false });
mockGenerateCoverClip.mockReset().mockResolvedValue(undefined);
});

function renderCoverPanelAndClickGenerate() {
const { track, clip } = setupProjectWithClip();
useUIStore.setState({
enhancerOpen: true,
enhancerTarget: { clipId: clip.id, trackId: track.id, range: null, mode: 'cover' },
});
const result = render(<EnhancePanel />);
// Click Generate — it creates a result entry with status 'generating'
fireEvent.click(screen.getByTestId('enhance-btn'));
return { result, track, clip };
}

it('shows a spinner when result status is generating', async () => {
// Make generateCoverClip hang so finalizeResult never runs
mockGenerateCoverClip.mockReturnValue(new Promise(() => {}));
renderCoverPanelAndClickGenerate();

await waitFor(() => {
const resultItem = screen.getByTestId('result-item-0');
// The spinner is a div with animate-spin class
const spinner = resultItem.querySelector('.animate-spin');
expect(spinner).not.toBeNull();
});
// Also verify the "Generating..." text
expect(screen.getByText('Generating...')).toBeInTheDocument();
});

it('shows an error icon and message when result status is error', async () => {
mockGenerateCoverClip.mockRejectedValue(new Error('Server timeout'));
renderCoverPanelAndClickGenerate();

await waitFor(() => {
expect(screen.getByText('Server timeout')).toBeInTheDocument();
});
// The error icon container should have red styling
const resultItem = screen.getByTestId('result-item-0');
const errorIcon = resultItem.querySelector('.bg-red-900\\/50');
expect(errorIcon).not.toBeNull();
});

it('displays the specific error message text from a failed generation', async () => {
mockGenerateCoverClip.mockRejectedValue(new Error('Model capacity exceeded'));
renderCoverPanelAndClickGenerate();

await waitFor(() => {
const errorText = screen.getByText('Model capacity exceeded');
expect(errorText).toBeInTheDocument();
expect(errorText.textContent).toBe('Model capacity exceeded');
});
});
});
Loading