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
33 changes: 33 additions & 0 deletions frontend/scripts/smoke-create-clips-workspace.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ const projectSchemaSource = readSource('../../shared/project-schema.json');

assert.match(panelSource, /export default function AIPanel\(\{ mode = 'general' \}/);
assert.match(panelSource, /getInitialClipWorkspaceStage\(clipDrafts, clipSuggestions\)/);
assert.match(panelSource, /getNewManualClipDrafts\(clipDrafts, knownClipDraftIdsRef\.current\)/);
assert.match(panelSource, /if \(mode !== 'clips' \|\| newlyAddedManualDrafts\.length === 0\) return/);
assert.match(panelSource, /setClipStage\('prepare'\);\s+setActiveClipDraftId\(draft\.id\);\s+setSelectedWordIndices\(getWordIndicesForClip\(words, draft\)\)/);
assert.match(panelSource, /Find moments with AI/);
assert.match(panelSource, />Find moments<\/h3>/);
assert.match(panelSource, /Choose moments yourself/);
Expand All @@ -43,6 +46,11 @@ assert.match(panelSource, /source: 'ai-director'/);
assert.match(panelSource, /updateClipDraft\(id, \{ status: 'draft', lastError: undefined \}\)/);
assert.match(panelSource, /const pendingReviewItems = useMemo/);
assert.match(panelSource, /Review \$\{pendingReviewItems\.length\}/);
assert.match(panelSource, /Prepare \$\{clipQueueSummary\.prepare\}/);
assert.match(panelSource, /label="Exporting"/);
assert.match(panelSource, /label="Retry"/);
assert.match(panelSource, /label="Failed"/);
assert.match(panelSource, /label="Exported"/);
assert.match(panelSource, /removeMatchingClipSuggestions/);
assert.match(panelSource, /createClipDraft\(clip, 'ai', undefined, false\)/);
assert.match(panelSource, /requestPreviewRange\(previewRange\.start, previewRange\.end\)/);
Expand Down Expand Up @@ -104,8 +112,10 @@ new Function('exports', 'module', 'require', compiled.outputText)(module.exports

const {
getInitialClipWorkspaceStage,
getClipQueueSummary,
getClipPreviewRange,
getClipReviewKey,
getNewManualClipDrafts,
getPendingReviewItems,
getSkippedReviewItems,
getReviewCounts,
Expand All @@ -129,6 +139,8 @@ const approvedDraft = { ...matchingDraft, id: 'clip_2', status: 'draft' };
const exportedDraft = { ...matchingDraft, id: 'clip_3', status: 'exported' };
const manualDraft = { ...matchingDraft, id: 'clip_4', status: 'draft', source: 'speaker-turn' };
const unmatchedSuggestion = { ...suggestion, startWordIndex: 5, endWordIndex: 9 };
const restoredManualDraft = { ...matchingDraft, id: 'manual-restored', status: 'draft', source: 'transcript-selection' };
const newManualDraft = { ...matchingDraft, id: 'manual-new', status: 'draft', source: 'transcript-selection' };

assert.equal(getInitialClipWorkspaceStage([], []), 'find');
const rankedDiscovery = readClipDiscoveryResult({
Expand All @@ -152,6 +164,11 @@ assert.equal(getInitialClipWorkspaceStage([approvedDraft], []), 'prepare');
assert.equal(getInitialClipWorkspaceStage([manualDraft], []), 'prepare');
assert.equal(getInitialClipWorkspaceStage([exportedDraft], []), 'export');
assert.equal(getInitialClipWorkspaceStage([approvedDraft], [suggestion]), 'prepare');
assert.deepEqual(getNewManualClipDrafts([restoredManualDraft], new Set(['manual-restored'])), []);
assert.deepEqual(
getNewManualClipDrafts([restoredManualDraft, newManualDraft], new Set(['manual-restored'])),
[newManualDraft],
);
assert.deepEqual(
getUnmatchedLegacyClipSuggestions([suggestion, unmatchedSuggestion], [matchingDraft]),
[unmatchedSuggestion],
Expand Down Expand Up @@ -205,8 +222,24 @@ assert.equal(getPendingReviewItems([], [suggestion], { [getClipReviewKey(suggest
assert.equal(getSkippedReviewItems([], [suggestion], { [getClipReviewKey(suggestion)]: 'skipped' }).length, 1);
assert.equal(isClipDraftInStage(matchingDraft, 'review'), true);
assert.equal(isClipDraftInStage(approvedDraft, 'prepare'), true);
assert.equal(isClipDraftInStage({ ...approvedDraft, status: 'packaged' }, 'prepare'), true);
assert.equal(isClipDraftInStage({ ...approvedDraft, status: 'exporting' }, 'prepare'), false);
assert.equal(isClipDraftInStage({ ...approvedDraft, status: 'failed' }, 'prepare'), false);
assert.equal(isClipDraftInStage(exportedDraft, 'export'), true);
assert.equal(isClipDraftInStage(matchingDraft, 'export'), false);
const queueSummary = getClipQueueSummary(
[
{ ...matchingDraft, id: 'suggested-1', status: 'suggested' },
{ ...approvedDraft, id: 'draft-1', status: 'draft' },
{ ...approvedDraft, id: 'packaged-1', status: 'packaged' },
{ ...approvedDraft, id: 'exporting-1', status: 'exporting' },
{ ...exportedDraft, id: 'exported-1', status: 'exported' },
{ ...approvedDraft, id: 'failed-retryable', status: 'failed' },
{ ...approvedDraft, id: 'failed-invalid', status: 'failed' },
],
new Set(['failed-retryable']),
);
assert.deepEqual(queueSummary, { suggested: 1, prepare: 2, exporting: 1, retry: 1, exported: 1, failed: 2 });
assert.match(panelSource, /getClipBatchExportCandidates/);
assert.match(panelSource, /updateClipDraft\(id, \{ status: 'draft', lastError: undefined \}\)/);
assert.match(transcriptSource, /Draft clip/);
Expand Down
60 changes: 43 additions & 17 deletions frontend/src/components/AIPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,10 @@ import {
} from '../utils/hookFrames';
import {
getInitialClipWorkspaceStage,
getClipQueueSummary,
getClipPreviewRange,
getClipReviewKey,
getNewManualClipDrafts,
getPendingReviewItems,
getSkippedReviewItems,
isSameClipRange,
Expand Down Expand Up @@ -164,16 +166,6 @@ const SHORTS_DRAFT_DEFAULTS = {

const CLIP_EXPORT_DIRECTORY_KEY = 'scriptcut.clipExport.directory';

function getClipQueueSummary(drafts: ClipDraft[]) {
return {
suggested: drafts.filter((draft) => (draft.status || 'draft') === 'suggested').length,
approved: drafts.filter((draft) => ['draft', 'packaged', 'failed'].includes(draft.status || 'draft')).length,
copyReady: drafts.filter((draft) => getPublishingCopyState(draft).ready).length,
exported: drafts.filter((draft) => (draft.status || 'draft') === 'exported').length,
failed: drafts.filter((draft) => (draft.status || 'draft') === 'failed').length,
};
}

export type AIPanelMode = 'general' | 'clips';

export default function AIPanel({ mode = 'general' }: { mode?: AIPanelMode }) {
Expand Down Expand Up @@ -235,6 +227,7 @@ export default function AIPanel({ mode = 'general' }: { mode?: AIPanelMode }) {
const [activeClipPreviewKey, setActiveClipPreviewKey] = useState<string | null>(null);
const [clipExportDirectory, setClipExportDirectory] = useState(() => window.localStorage.getItem(CLIP_EXPORT_DIRECTORY_KEY) || '');
const [creatorNotice, setCreatorNotice] = useState<CreatorNoticeData | null>(null);
const knownClipDraftIdsRef = useRef(new Set(clipDrafts.map((draft) => draft.id)));
const secondaryToolsVisible =
mode === 'clips' && (showSecondaryTools || activeTab === 'edit' || activeTab === 'filler');
const isCurrentClipWorkspace = useCallback(
Expand Down Expand Up @@ -291,6 +284,17 @@ export default function AIPanel({ mode = 'general' }: { mode?: AIPanelMode }) {
setClipExportDirectory((current) => current || getPathDirectory(videoPath));
}, [videoPath]);

useEffect(() => {
const newlyAddedManualDrafts = getNewManualClipDrafts(clipDrafts, knownClipDraftIdsRef.current);
for (const draft of clipDrafts) knownClipDraftIdsRef.current.add(draft.id);
if (mode !== 'clips' || newlyAddedManualDrafts.length === 0) return;

const draft = newlyAddedManualDrafts[newlyAddedManualDrafts.length - 1];
setClipStage('prepare');
setActiveClipDraftId(draft.id);
setSelectedWordIndices(getWordIndicesForClip(words, draft));
}, [clipDrafts, mode, setSelectedWordIndices, words]);

const reviewedCount = useMemo(() => {
if (!fillerResult) return 0;
return fillerResult.fillerWords.filter(
Expand Down Expand Up @@ -828,11 +832,30 @@ export default function AIPanel({ mode = 'general' }: { mode?: AIPanelMode }) {
const exportBusy = isBatchExporting || exportingDraftId !== null;
const stopBatchExportRef = useRef(false);
const [publishingCopyDraftId, setPublishingCopyDraftId] = useState<string | null>(null);
const clipQueueSummary = useMemo(() => getClipQueueSummary(clipDrafts), [clipDrafts]);
const readyDraftCount = useMemo(
() => getClipBatchExportCandidates(clipDrafts, words, videoPath).length,
const exportCandidateDrafts = useMemo(
() => getClipBatchExportCandidates(clipDrafts, words, videoPath),
[clipDrafts, videoPath, words],
);
const retryableDraftIds = useMemo(
() => new Set(
exportCandidateDrafts
.filter((draft) => (draft.status || 'draft') === 'failed')
.map((draft) => draft.id),
),
[exportCandidateDrafts],
);
const clipQueueSummary = useMemo(
() => ({
...getClipQueueSummary(clipDrafts, retryableDraftIds),
copyReady: clipDrafts.filter((draft) => getPublishingCopyState(draft).ready).length,
}),
[clipDrafts, retryableDraftIds],
);
const approvedReviewCount = useMemo(
() => Object.values(clipReviewDecisions).filter((decision) => decision === 'approved').length,
[clipReviewDecisions],
);
const readyDraftCount = exportCandidateDrafts.length;
const clipStageDrafts = useMemo(
() => clipDrafts.filter((draft) => isClipDraftInStage(draft, clipStage)),
[clipDrafts, clipStage],
Expand Down Expand Up @@ -1816,7 +1839,7 @@ export default function AIPanel({ mode = 'general' }: { mode?: AIPanelMode }) {
{([
{ stage: 'find', label: 'Find' },
{ stage: 'review', label: `Review ${pendingReviewItems.length}` },
{ stage: 'prepare', label: `Prepare ${clipQueueSummary.approved}` },
{ stage: 'prepare', label: `Prepare ${clipQueueSummary.prepare}` },
{ stage: 'export', label: `Export ${readyDraftCount} ready` },
] as Array<{ stage: ClipWorkspaceStage; label: string }>).map(({ stage, label }) => (
<button
Expand All @@ -1834,12 +1857,15 @@ export default function AIPanel({ mode = 'general' }: { mode?: AIPanelMode }) {
</div>

{clipDrafts.length > 0 && (
<div className="grid grid-cols-5 gap-1 text-center text-[10px]">
<div className="grid grid-cols-8 gap-1 text-center text-[10px]">
<QueueStat label="Suggested" value={clipQueueSummary.suggested} />
<QueueStat label="Approved" value={clipQueueSummary.approved} />
<QueueStat label="Prepare" value={clipQueueSummary.prepare} />
<QueueStat label="Copy ready" value={clipQueueSummary.copyReady} />
<QueueStat label="Ready" value={readyDraftCount} />
<QueueStat label="Exporting" value={clipQueueSummary.exporting} />
<QueueStat label="Retry" value={clipQueueSummary.retry} warning={clipQueueSummary.retry > 0} />
<QueueStat label="Failed" value={clipQueueSummary.failed} warning={clipQueueSummary.failed > 0} />
<QueueStat label="Exported" value={clipQueueSummary.exported} />
</div>
)}

Expand Down Expand Up @@ -1876,7 +1902,7 @@ export default function AIPanel({ mode = 'general' }: { mode?: AIPanelMode }) {
<ClipReviewWorkspace
pendingItems={pendingReviewItems}
skippedItems={skippedReviewItems}
approvedClipCount={clipQueueSummary.approved}
approvedClipCount={approvedReviewCount}
activePreviewKey={activeReviewPreviewKey}
words={words}
onPreview={handlePreviewClip}
Expand Down
41 changes: 40 additions & 1 deletion frontend/src/utils/clipWorkspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,45 @@ const REVIEW_STATUSES = new Set<ClipDraftStatus>(['suggested']);
const PREPARE_STATUSES = new Set<ClipDraftStatus>(['draft', 'packaged']);
const EXPORT_STATUSES = new Set<ClipDraftStatus>(['exporting', 'exported', 'failed']);

export type ClipQueueSummary = {
suggested: number;
prepare: number;
exporting: number;
retry: number;
exported: number;
failed: number;
};

export function getClipQueueSummary(
drafts: ClipDraft[],
retryableDraftIds: ReadonlySet<string> = new Set<string>(),
): ClipQueueSummary {
return drafts.reduce<ClipQueueSummary>(
(summary, draft) => {
const status = draft.status || 'draft';
if (status === 'suggested') summary.suggested += 1;
if (PREPARE_STATUSES.has(status)) summary.prepare += 1;
if (status === 'exporting') summary.exporting += 1;
if (status === 'failed') {
summary.failed += 1;
if (retryableDraftIds.has(draft.id)) summary.retry += 1;
}
if (status === 'exported') summary.exported += 1;
return summary;
},
{ suggested: 0, prepare: 0, exporting: 0, retry: 0, exported: 0, failed: 0 },
);
}

export function getNewManualClipDrafts(
drafts: ClipDraft[],
knownDraftIds: ReadonlySet<string>,
): ClipDraft[] {
return drafts.filter(
(draft) => draft.source === 'transcript-selection' && !knownDraftIds.has(draft.id),
);
}

export type ClipReviewItem = ClipSuggestion;

export function getClipReviewKey(
Expand Down Expand Up @@ -225,7 +264,7 @@ export function isSameClipRange(left: Pick<ClipSuggestion, 'startWordIndex' | 'e
export function isClipDraftInStage(draft: ClipDraft, stage: ClipWorkspaceStage) {
const status = draft.status || 'draft';
if (stage === 'review') return status === 'suggested';
if (stage === 'prepare') return !REVIEW_STATUSES.has(status) && status !== 'exported';
if (stage === 'prepare') return PREPARE_STATUSES.has(status);
if (stage === 'export') return !REVIEW_STATUSES.has(status);
return false;
}