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
58 changes: 53 additions & 5 deletions frontend/scripts/smoke-clip-batch-export.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ const { validateClipDraftForExport } = loadTsModule('../src/utils/clipDrafts.ts'
const {
INTERRUPTED_CLIP_EXPORT_ERROR,
getClipBatchExportCandidates,
getCurrentClipBatchDraftForExport,
getClipBatchProgressSummary,
hasRecoverableClipExports,
recoverInterruptedClipDraft,
Expand Down Expand Up @@ -90,17 +91,35 @@ const candidates = [
draft('exporting', 'exporting'),
draft('exported', 'exported'),
draft('invalid', 'draft', { title: '' }),
draft('invalid-packaged', 'packaged', { title: '' }),
];
assert.deepEqual(
getClipBatchExportCandidates(candidates, words, '/tmp/video.mp4').map((item) => item.id),
['draft', 'packaged', 'failed'],
['packaged', 'failed'],
);
assert.deepEqual(
getClipBatchExportCandidates([candidates[2], candidates[0]], words, '/tmp/video.mp4').map((item) => item.id),
['failed', 'draft'],
['failed'],
);
assert.equal(hasRecoverableClipExports(candidates, words, '/tmp/video.mp4'), true);
assert.equal(validateClipDraftForExport(candidates[6], words, '/tmp/video.mp4').ready, false);
assert.equal(validateClipDraftForExport(candidates[7], words, '/tmp/video.mp4').ready, false);
assert.equal(
getCurrentClipBatchDraftForExport(
[draft('planned', 'draft')],
'planned',
words,
'/tmp/video.mp4',
),
null,
'a planned batch item demoted back to draft must not be submitted from an old packaged snapshot',
);
const currentPrepared = draft('planned', 'packaged', { title: 'Current durable title' });
assert.equal(
getCurrentClipBatchDraftForExport([currentPrepared], 'planned', words, '/tmp/video.mp4'),
currentPrepared,
'batch export must use the current durable prepared draft',
);

const recovered = recoverInterruptedClipDraft(draft('interrupted', 'exporting'));
assert.equal(recovered.status, 'failed');
Expand Down Expand Up @@ -149,6 +168,13 @@ assert.doesNotMatch(serialized, /runtime-video-authority|runtime-srt-authority/)
const reopened = autosave.parseProjectFile(serialized);
assert.equal(reopened.aiWorkspace?.clipDrafts?.[2]?.srtPath, '/tmp/clip.srt');
assert.deepEqual(reopened.aiWorkspace?.clipDrafts?.[2]?.exportWarnings, ['Captions were delivered as an SRT sidecar.']);
const preparedProject = autosave.normalizeProjectFile(project([draft('loads-packaged', 'packaged')]));
assert.equal(preparedProject.aiWorkspace?.clipDrafts?.[0]?.status, 'packaged');
assert.equal(
autosave.parseProjectFile(autosave.serializeProjectFile(preparedProject)).aiWorkspace?.clipDrafts?.[0]?.status,
'packaged',
'prepared clip lifecycle state must survive project v1 save/reopen',
);
assert.equal(autosave.normalizeProjectFile(project(undefined)).version, 1);
assert.equal(autosave.normalizeProjectFile(project([])).schema, 'scriptcut.project.v1');

Expand All @@ -174,22 +200,44 @@ assert.match(panelSource, /for \(let index = 0; index < exportableDrafts.length;
assert.doesNotMatch(panelSource, /Promise\.all\([^)]*export/);
assert.match(panelSource, /const exportBusy = isBatchExporting \|\| exportingDraftId !== null/);
assert.match(panelSource, /exportBusy=\{exportBusy\}/);
assert.match(controlsSource, /disabled=\{exportBusy \|\| readyDraftCount === 0\}/);
assert.match(controlsSource, /disabled=\{exportBusy \|\| exportableDraftCount === 0\}/);
assert.match(cardSource, /disabled=\{!canExport \|\| exportBusy \|\| isExporting \|\| exportActive\}/);
assert.match(cardSource, /disabled=\{exportBusy\}/);
assert.match(cardSource, /disabled=\{!exportValidation\.ready \|\| exportBusy\}/);
assert.doesNotMatch(controlsSource, /useAIStore|useEditorStore|fetch\(|localStorage/, 'prepare/export controls must stay presentational');
assert.ok((panelSource.match(/if \(exportBusy\) return;/g) || []).length >= 3, 'export handlers have defensive busy guards');
assert.match(controlsSource, /onClick=\{onStopBatchExport\}/);
assert.match(panelSource, /for \(let index = 0; index < exportableDrafts.length; index\+\+\)[\s\S]*?await handleExportClip/);
assert.doesNotMatch(panelSource, /\/jobs\/export-batch|\/export\/v2/);
const cancelHandlerSource = panelSource.slice(panelSource.indexOf('const cancelDraftExport'), panelSource.indexOf('const retryDraftExport'));
assert.match(cancelHandlerSource, /\/cancel/);
assert.doesNotMatch(cancelHandlerSource, /status: 'failed'/, 'cancel request must not mark the draft terminal before polling confirms cancellation');
assert.doesNotMatch(cancelHandlerSource, /setExportingDraftId/, 'cancel request must keep exportBusy active until the export poll reaches a terminal state');
assert.match(panelSource, /failedCount/);
assert.match(panelSource, /handleExportClip\(draft, draft, true\)/);
assert.match(panelSource, /if \(stopBatchExportRef\.current\) break/);
assert.match(controlsSource, /Stopping after current clip/);
assert.match(cardSource, /Retry export/);
assert.match(cardSource, /const exportRetryable = status === 'failed'/);
assert.match(panelSource, /fetch\(`\$\{backendUrl\}\/jobs\/\$\{job\.id\}\/retry`/);
assert.match(cardSource, /const exportActive =[\s\S]*?isExporting[\s\S]*?status === 'exporting'/);
assert.match(cardSource, /disabled=\{!exportJobCancelable\}/);
assert.match(panelSource, /import \{ isClipTimelineMutationBlocked, useEditorStore \} from '\.\.\/store\/editorStore';/);
assert.ok(
(panelSource.match(/if \(isClipTimelineMutationBlocked\(\)\) return;/g) || []).length >= 7,
'compound filler/edit-plan timeline actions must not update decisions while a clip export blocks timeline mutation',
);
assert.match(panelSource, /const currentDraft = useAIStore\.getState\(\)\.clipDrafts\.find/);
assert.match(panelSource, /clearClipExportAttempt\(currentDraft\.id\)/);
assert.doesNotMatch(panelSource, /\/jobs\/\$\{job\.id\}\/retry/, 'clip retry must not replay an old backend export target');
assert.match(panelSource, /handleExportClip\(draft, draft, true\)/);
assert.match(panelSource, /handleExportClip\(currentDraft, currentDraft, true\)/);
const handleExportClipSource = panelSource.slice(panelSource.indexOf('const handleExportClip'), panelSource.indexOf('const cancelDraftExport'));
assert.ok(
handleExportClipSource.indexOf("status: 'exporting'") < handleExportClipSource.indexOf('await fetch(`${backendUrl}/jobs/export`'),
'durable exporting claim must happen before the first export request await',
);
assert.match(panelSource, /getCurrentClipBatchDraftForExport\([\s\S]*?plannedDraft\.id/);
assert.match(panelSource, /pausedForDraftChange = true/);
assert.match(panelSource, /Batch paused because a planned clip changed/);
assert.match(panelSource, /outputPath/);
assert.match(panelSource, /srtPath: output\.srtPath/);
assert.match(panelSource, /exportWarnings: output\.warnings/);
Expand Down
92 changes: 92 additions & 0 deletions frontend/scripts/smoke-clip-drafts.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,92 @@ const run = new Function('exports', 'module', 'require', compiled.outputText);
run(module.exports, module, require);

const {
clipDraftPatchInvalidatesPreparation,
getClipTimelineExportFingerprint,
getClipDraftUserEditResult,
getClipTranscript,
getClipDraftReadinessScore,
buildClipExportCaptionWords,
getClipExportSegments,
getWordIndicesForClip,
invalidateClipDraftsForTimelineChange,
normalizeClipDraftRange,
prepareReadyClipDraftsForExport,
validateClipDraftForExport,
} = module.exports;

assert.equal(clipDraftPatchInvalidatesPreparation({ title: 'Updated title' }), true);
assert.equal(clipDraftPatchInvalidatesPreparation({ captions: 'sidecar' }), true);
assert.equal(clipDraftPatchInvalidatesPreparation({ reframe: { x: 40, y: 50 } }), true);
assert.equal(clipDraftPatchInvalidatesPreparation({ hook: 'Publishing hook only' }), false);
assert.equal(clipDraftPatchInvalidatesPreparation({ exportDirectory: '/tmp/exports' }), false);

const packagedEdit = getClipDraftUserEditResult(
{ id: 'packaged', status: 'packaged', exportPath: '/tmp/old.mp4' },
{ title: 'Updated title' },
);
assert.equal(packagedEdit.invalidated, true);
assert.equal(packagedEdit.blocked, false);
assert.equal(packagedEdit.patch.status, 'draft');
assert.equal(packagedEdit.patch.exportPath, undefined);

const failedEdit = getClipDraftUserEditResult(
{ id: 'failed', status: 'failed', lastError: 'Old failure' },
{ resolution: '720p' },
);
assert.equal(failedEdit.invalidated, true);
assert.equal(failedEdit.patch.status, 'draft');
assert.equal(failedEdit.patch.lastError, undefined);

const exportedEdit = getClipDraftUserEditResult(
{ id: 'exported', status: 'exported', exportPath: '/tmp/old.mp4', srtPath: '/tmp/old.srt', exportedAt: '2026-09-07T00:00:00.000Z' },
{ captions: 'none' },
);
assert.equal(exportedEdit.invalidated, true);
assert.equal(exportedEdit.patch.status, 'draft');
assert.equal(exportedEdit.patch.exportPath, undefined);
assert.equal(exportedEdit.patch.srtPath, undefined);
assert.equal(exportedEdit.patch.exportedAt, undefined);

const publishingOnlyEdit = getClipDraftUserEditResult(
{ id: 'exported', status: 'exported', exportPath: '/tmp/clip.mp4' },
{ hook: 'New publishing hook' },
);
assert.equal(publishingOnlyEdit.invalidated, false);
assert.equal(publishingOnlyEdit.blocked, false);
assert.equal(publishingOnlyEdit.patch.hook, 'New publishing hook');

const exportingEdit = getClipDraftUserEditResult(
{ id: 'exporting', status: 'exporting' },
{ aspectRatio: 'square' },
);
assert.equal(exportingEdit.blocked, true);
assert.deepEqual(exportingEdit.patch, {});

const timelineInvalidated = invalidateClipDraftsForTimelineChange([
{ id: 'draft', status: 'draft' },
{ id: 'packaged', status: 'packaged', exportPath: '/tmp/prepared.mp4' },
{ id: 'failed', status: 'failed', lastError: 'Retry me' },
{ id: 'exported', status: 'exported', exportPath: '/tmp/done.mp4', srtPath: '/tmp/done.srt' },
{ id: 'exporting', status: 'exporting' },
]);
assert.deepEqual(timelineInvalidated.map((item) => item.status), ['draft', 'draft', 'draft', 'draft', 'exporting']);
assert.equal(timelineInvalidated[1].exportPath, undefined);
assert.equal(timelineInvalidated[2].lastError, undefined);
assert.equal(timelineInvalidated[3].srtPath, undefined);

const baseTimelineFingerprint = getClipTimelineExportFingerprint([], []);
assert.equal(
getClipTimelineExportFingerprint([], [{ id: 'speaker', kind: 'speaker-label', start: 0, end: 1, wordIndices: [0] }]),
baseTimelineFingerprint,
'speaker labels do not affect exported clip bytes',
);
assert.notEqual(
getClipTimelineExportFingerprint([], [{ id: 'mute', kind: 'mute', start: 0, end: 1, wordIndices: [0] }]),
baseTimelineFingerprint,
'mute edits must invalidate prepared clip output',
);

const words = [
{ word: 'This', start: 0, end: 0.4, confidence: 1 },
{ word: 'is', start: 0.4, end: 0.7, confidence: 1 },
Expand Down Expand Up @@ -61,6 +138,21 @@ assert.equal(validateClipDraftForExport({ ...draft, title: '' }, words, '/tmp/vi
assert.equal(validateClipDraftForExport({ ...draft, status: 'suggested' }, words, '/tmp/video.mp4').ready, false);
assert.equal(validateClipDraftForExport(draft, words, '/tmp/video.mp4').ready, true);

const preparedLifecycle = prepareReadyClipDraftsForExport(
[
draft,
{ ...draft, id: 'invalid-draft', title: '' },
{ ...draft, id: 'already-packaged', status: 'packaged' },
{ ...draft, id: 'failed-draft', status: 'failed', lastError: 'Retry me' },
{ ...draft, id: 'exported-draft', status: 'exported', exportPath: '/tmp/done.mp4' },
],
words,
'/tmp/video.mp4',
);
assert.deepEqual(preparedLifecycle.map((item) => item.status), ['packaged', 'draft', 'packaged', 'failed', 'exported']);
assert.equal(preparedLifecycle[3].lastError, 'Retry me');
assert.equal(preparedLifecycle[4].exportPath, '/tmp/done.mp4');

const clipSegments = getClipExportSegments(
{ startTime: 0, endTime: 1.6 },
[{ id: 'cut_1', start: 0.4, end: 1, wordIndices: [1, 2] }],
Expand Down
14 changes: 14 additions & 0 deletions frontend/scripts/smoke-clip-prepare-export.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,20 @@ assert.match(cardSource, /exported: 'Exported'/);
assert.match(cardSource, /failed: 'Needs retry'/);
assert.doesNotMatch(cardSource, /draft: 'Approved'/);
assert.doesNotMatch(cardSource, /packaged: 'Approved'/);
assert.match(cardSource, /const canPrepare = status === 'draft' && exportValidation\.ready/);
assert.match(cardSource, /const canExport = exportValidation\.ready && status === 'packaged'/);
assert.match(cardSource, /Ready for export/);
assert.match(cardSource, /Retry required/);
assert.match(panelSource, /prepareReadyDraftsForExport/);
assert.match(panelSource, /prepareReadyClipDraftsForExport\(current, words, videoPath\)/);
assert.match(panelSource, /getClipDraftUserEditResult\(draft, patch\)/);
assert.match(panelSource, /if \(edit\.blocked\) return/);
assert.match(panelSource, /if \(edit\.invalidated\)/);
assert.match(panelSource, /clearClipExportAttempt\(draft\.id\)/);
assert.match(panelSource, /exportPath: undefined/);
assert.match(panelSource, /srtPath: undefined/);
assert.match(panelSource, /exportedAt: undefined/);
assert.match(cardSource, /disabled=\{!exportValidation\.ready \|\| exportBusy\}/);

assert.match(panelSource, /const \[clipExportOutputs, setClipExportOutputs\]/);
assert.match(cardSource, /Clip ready/);
Expand Down
13 changes: 10 additions & 3 deletions frontend/scripts/smoke-create-clips-workspace.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,8 @@ assert.doesNotMatch(reviewSource, /Remove/);
assert.match(prepareExportSource, /Export (Ready Clips|all ready clips)/);
assert.match(panelSource, /setClipStage\(discovery\.stage\)/);
assert.match(panelSource, /setClipStage\('prepare'\)/);
assert.match(prepareExportSource, /Review \{readyDraftCount\} ready/);
assert.match(prepareExportSource, /Prepare \{preparableDraftCount\} ready/);
assert.match(panelSource, /status: 'packaged'/);
assert.match(panelSource, /appendDiscoveredClipDrafts/);
assert.match(panelSource, /status: 'draft'/);
assert.match(reviewActionsSource, /['"]speaker-turn['"]/);
Expand Down Expand Up @@ -184,6 +185,7 @@ const suggestion = {
};
const matchingDraft = { ...suggestion, id: 'clip_1', status: 'suggested', format: 'mp4', resolution: '1080p', aspectRatio: 'vertical' };
const approvedDraft = { ...matchingDraft, id: 'clip_2', status: 'draft' };
const packagedDraft = { ...matchingDraft, id: 'clip_prepared', status: 'packaged' };
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 };
Expand All @@ -210,6 +212,8 @@ assert.equal(emptyDiscovery.shortfall, 5);
assert.equal(getInitialClipWorkspaceStage([matchingDraft], []), 'review');
assert.equal(getInitialClipWorkspaceStage([approvedDraft], []), 'prepare');
assert.equal(getInitialClipWorkspaceStage([manualDraft], []), 'prepare');
assert.equal(getInitialClipWorkspaceStage([packagedDraft], []), 'export');
assert.equal(getInitialClipWorkspaceStage([approvedDraft, packagedDraft], []), 'prepare');
assert.equal(getInitialClipWorkspaceStage([exportedDraft], []), 'export');
assert.equal(getInitialClipWorkspaceStage([approvedDraft], [suggestion]), 'prepare');
assert.deepEqual(getNewManualClipDrafts([restoredManualDraft], new Set(['manual-restored'])), []);
Expand Down Expand Up @@ -270,10 +274,13 @@ 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(packagedDraft, 'prepare'), false);
assert.equal(isClipDraftInStage(packagedDraft, 'export'), true);
assert.equal(isClipDraftInStage({ ...approvedDraft, status: 'exporting' }, 'prepare'), false);
assert.equal(isClipDraftInStage({ ...approvedDraft, status: 'failed' }, 'prepare'), false);
assert.equal(isClipDraftInStage({ ...approvedDraft, status: 'failed' }, 'export'), true);
assert.equal(isClipDraftInStage(exportedDraft, 'export'), true);
assert.equal(isClipDraftInStage(approvedDraft, 'export'), false);
assert.equal(isClipDraftInStage(matchingDraft, 'export'), false);
const queueSummary = getClipQueueSummary(
[
Expand All @@ -287,7 +294,7 @@ const queueSummary = getClipQueueSummary(
],
new Set(['failed-retryable']),
);
assert.deepEqual(queueSummary, { suggested: 1, prepare: 2, exporting: 1, retry: 1, exported: 1, failed: 2 });
assert.deepEqual(queueSummary, { suggested: 1, prepare: 1, 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
16 changes: 16 additions & 0 deletions frontend/scripts/smoke-editor-state.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -112,11 +112,27 @@ assert.equal(historyStore.temporal.getState().pastStates.length, 1, 'transient p

const appSource = readFileSync(resolve(__dirname, '../src/App.tsx'), 'utf8');
const editorStoreSource = readFileSync(resolve(__dirname, '../src/store/editorStore.ts'), 'utf8');
const keyboardSource = readFileSync(resolve(__dirname, '../src/hooks/useKeyboardShortcuts.ts'), 'utf8');
assert.match(appSource, /onClick=\{\(\) => void handleOpenFile\(currentWorkflowIntent\)\}/);
assert.doesNotMatch(appSource, /onClick=\{handleOpenFile\}/);
assert.match(editorStoreSource, /partialize:\s*partializeEditorHistory/);
assert.match(editorStoreSource, /equality:\s*editorHistoryEqual/);
assert.doesNotMatch(editorStoreSource, /nextRangeId/);
assert.match(editorStoreSource, /invalidateClipDraftsForTimelineChange/);
assert.match(editorStoreSource, /isClipTimelineMutationBlocked\(\)/);
assert.ok(
(editorStoreSource.match(/invalidateClipPreparationForTimelineChange\(\)/g) || []).length >= 5,
'output-affecting timeline edits and restores must invalidate prepared clip state',
);
assert.match(keyboardSource, /getClipTimelineExportFingerprint/);
assert.ok(
(keyboardSource.match(/isClipTimelineMutationBlocked\(\)/g) || []).length >= 2,
'undo and redo must be blocked while clip export is active',
);
assert.ok(
(keyboardSource.match(/invalidateClipPreparationForTimelineChange\(\)/g) || []).length >= 2,
'undo and redo must invalidate prepared clips when output-affecting timeline state changes',
);
assert.ok(
(editorStoreSource.match(/temporal\.getState\(\)\.clear\(\)/g) || []).length >= 4,
'new media, transcription, project load, and reset must clear cross-project undo history',
Expand Down
Loading