diff --git a/frontend/package.json b/frontend/package.json index c81cb8d..bbf4c45 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,6 +9,7 @@ "lint": "eslint .", "test:clip-drafts": "node scripts/smoke-clip-drafts.mjs", "test:clip-batch-export": "node scripts/smoke-clip-batch-export.mjs", + "test:clip-prepare-export": "node scripts/smoke-clip-prepare-export.mjs", "test:clip-publishing": "node scripts/smoke-clip-publishing.mjs", "test:clip-presentation": "node scripts/smoke-clip-presentation.mjs", "test:home-onboarding": "node scripts/smoke-home-onboarding.mjs", diff --git a/frontend/scripts/smoke-clip-prepare-export.mjs b/frontend/scripts/smoke-clip-prepare-export.mjs new file mode 100644 index 0000000..64e5612 --- /dev/null +++ b/frontend/scripts/smoke-clip-prepare-export.mjs @@ -0,0 +1,47 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, resolve } from 'node:path'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const panelSource = readFileSync(resolve(__dirname, '../src/components/AIPanel.tsx'), 'utf8'); + +assert.match(panelSource, /const \[advancedExportOpen, setAdvancedExportOpen\] = useState\(false\)/); +assert.match(panelSource, /Advanced export settings/); +assert.match(panelSource, /aria-controls=\{`advanced-export-settings-\$\{draft\.id\}`\}/); +assert.match(panelSource, /advancedExportOpen && \(/); +assert.match(panelSource, /label="Frame"/); +assert.match(panelSource, /label="Captions"/); +assert.match(panelSource, /label="Style"/); +assert.match(panelSource, /label="Resolution"/); +assert.match(panelSource, /label="Format"/); +assert.match(panelSource, /Enhance audio<\/span>/); +assert.match(panelSource, /(null); const [clipExportJobs, setClipExportJobs] = useState>({}); + const [clipExportOutputs, setClipExportOutputs] = useState>({}); const [isBatchExporting, setBatchExporting] = useState(false); const [batchExportProgress, setBatchExportProgress] = useState({ processed: 0, @@ -832,6 +833,11 @@ export default function AIPanel({ mode = 'general' }: { mode?: AIPanelMode }) { const exportBusy = isBatchExporting || exportingDraftId !== null; const stopBatchExportRef = useRef(false); const [publishingCopyDraftId, setPublishingCopyDraftId] = useState(null); + + useEffect(() => { + setClipExportOutputs({}); + }, [clipWorkspaceEpoch]); + const exportCandidateDrafts = useMemo( () => getClipBatchExportCandidates(clipDrafts, words, videoPath), [clipDrafts, videoPath, words], @@ -1079,6 +1085,7 @@ export default function AIPanel({ mode = 'general' }: { mode?: AIPanelMode }) { exportedAt: new Date().toISOString(), lastError: undefined, }); + setClipExportOutputs((current) => ({ ...current, [settings.id!]: output })); } if (!silent) { setCreatorNotice({ @@ -1155,6 +1162,7 @@ export default function AIPanel({ mode = 'general' }: { mode?: AIPanelMode }) { exportedAt: new Date().toISOString(), lastError: undefined, }); + setClipExportOutputs((current) => ({ ...current, [draft.id]: output })); setCreatorNotice({ tone: 'success', title: 'Clip exported', @@ -1385,7 +1393,11 @@ export default function AIPanel({ mode = 'general' }: { mode?: AIPanelMode }) { ].filter(Boolean); setCreatorNotice({ tone: failedCount > 0 || manifestWarning ? 'warning' : 'success', - title: stopped ? 'Export stopped' : 'Export finished', + title: successCount > 0 + ? `${successCount} clip${successCount === 1 ? '' : 's'} ready` + : stopped + ? 'Export stopped' + : 'Export finished', message: `${successCount} exported, ${failedCount} failed.${stopped ? ` ${remainingDrafts.length} remaining.` : ''}${manifestPath ? ` Manifest saved to ${manifestPath}.` : ''}`, technicalDetails: warningDetails.length > 0 ? warningDetails.join('\n') : undefined, onDismiss: () => setCreatorNotice(null), @@ -2004,6 +2016,7 @@ export default function AIPanel({ mode = 'general' }: { mode?: AIPanelMode }) { isExporting={exportingDraftId === draft.id} exportBusy={exportBusy} exportJob={clipExportJobs[draft.id]} + exportResult={draft.exportPath ? clipExportOutputs[draft.id] : undefined} backendUrl={backendUrl} backgroundCapabilities={backgroundCapabilities} transcriptSnippet={getClipTranscript(words, draft)} @@ -2126,6 +2139,7 @@ function ClipDraftCard({ exportBusy, isGeneratingPublishingCopy, exportJob, + exportResult, backendUrl, backgroundCapabilities, transcriptSnippet, @@ -2154,6 +2168,7 @@ function ClipDraftCard({ exportBusy: boolean; isGeneratingPublishingCopy: boolean; exportJob?: ExportJob; + exportResult?: ClipExportOutput; backendUrl: string; backgroundCapabilities: BackgroundCapabilities | null; transcriptSnippet: string; @@ -2177,6 +2192,8 @@ function ClipDraftCard({ onDuplicate: () => void; onRemove: () => void; }) { + const [advancedExportOpen, setAdvancedExportOpen] = useState(false); + const [publishingCopyOpen, setPublishingCopyOpen] = useState(false); const exportActive = exportJob?.status === 'queued' || exportJob?.status === 'running' || exportJob?.status === 'canceling'; const status = draft.status || 'draft'; const exportRetryable = status === 'failed' || exportJob?.status === 'failed' || exportJob?.status === 'canceled'; @@ -2193,6 +2210,7 @@ function ClipDraftCard({ ); const hookFrames = buildHookFrameCandidates(draft); const selectedHookFrame = getSelectedHookFrame(draft); + const exportWarnings = exportResult?.warnings || []; return (
@@ -2204,7 +2222,7 @@ function ClipDraftCard({ />
- {draft.titleSuggestions && draft.titleSuggestions.length > 0 && ( + {publishingCopyOpen && draft.titleSuggestions && draft.titleSuggestions.length > 0 && (
Title suggestions
@@ -2226,15 +2244,43 @@ function ClipDraftCard({ {Math.round(draft.endTime - draft.startTime)}s
-
-
- Publishing copy +
+
- {!publishingCopyState.ready && ( -
Missing: {publishingCopyState.missingFields.join(', ')}
+ + {publishingCopyOpen && ( +
+
+ {publishingCopyState.ready + ? 'Publishing copy is ready, but it is not required to export this clip.' + : `Optional fields missing: ${publishingCopyState.missingFields.join(', ')}`} +
+
+ + +
+
)}
@@ -2255,24 +2301,66 @@ function ClipDraftCard({ )}
{draft.exportPath && ( -
-
Exported: {draft.exportPath}
- {window.electronAPI ? ( - - ) : ( - - Download clip - +
+
+
+
Clip ready
+
{draft.title}
+
+ +
+
+
Video output
+
{getFileNameFromPath(draft.exportPath, `${draft.title || 'scriptcut_clip'}.${draft.format}`)}
+
+ {window.electronAPI ? ( + + ) : ( + + Download clip + + )} +
+
+ {exportResult?.srtPath && ( +
+
SRT sidecar
+
{getFileNameFromPath(exportResult.srtPath, 'captions.srt')}
+
+ {window.electronAPI ? ( + + ) : ( + + Download SRT + + )} +
+
+ )} + {exportWarnings.length > 0 && ( +
+
Export note
+ {exportWarnings.map((warning) =>
{warning}
)} +
)}
)} @@ -2320,7 +2408,7 @@ function ClipDraftCard({ onChange={(endTime) => onTrim({ endTime: Math.max(draft.startTime + 0.25, endTime) })} />
-
+
- onChange({ resolution: resolution as ClipDraft['resolution'] })} - options={[ - { value: '720p', label: '720p' }, - { value: '1080p', label: '1080p' }, - { value: '4k', label: '4K' }, - ]} - /> - onChange({ format: format as ClipDraft['format'] })} - options={[ - { value: 'mp4', label: 'MP4' }, - { value: 'mov', label: 'MOV' }, - { value: 'webm', label: 'WebM' }, - ]} - />
{draft.aspectRatio !== 'source' && ( onChange({ captionStyle })} /> )} - - +
+ + {advancedExportOpen && ( +
+
+ onChange({ resolution: resolution as ClipDraft['resolution'] })} + options={[ + { value: '720p', label: '720p' }, + { value: '1080p', label: '1080p' }, + { value: '4k', label: '4K' }, + ]} + /> + onChange({ format: format as ClipDraft['format'] })} + options={[ + { value: 'mp4', label: 'MP4' }, + { value: 'mov', label: 'MOV' }, + { value: 'webm', label: 'WebM' }, + ]} + /> +
+ + +
+ )} +

{draft.reason}

{(isExporting || exportRetryable) && exportJob && (
@@ -2431,6 +2537,8 @@ function ClipDraftCard({ {exportJob.error &&
{exportJob.error}
}
)} + {publishingCopyOpen && ( + <>
Transcript @@ -2581,6 +2689,8 @@ function ClipDraftCard({ })}
+ + )}
{isSuggested ? ( - -