Compare commits

...
Author SHA1 Message Date
总工andmultica-agent 48e16ae443 feat(studio): image result deletion and video history click-to-play (HEL-641)
Co-authored-by: multica-agent <github@multica.ai>
2026-09-29 17:48:00 +08:00
8 changed files with 218 additions and 73 deletions
@@ -706,6 +706,28 @@
const selectedEntry = $derived(
doneEntries.find((e) => e.key === selectedKey) ?? doneEntries[0] ?? null,
);
const selectedJobId = $derived(selectedEntry?.job.id ?? null);
/**
* Clicking a history row: a finished job loads into the preview
* (player / image), anything else just reports its live status —
* never a silent jump to the newest finished clip.
*/
function selectFromHistory(job: Job) {
const st = jobStatusInfo(job);
if (st.tone === 'ok' && job.output_paths.length) {
selectedKey = `${job.id}:${job.output_paths[0]}`;
return;
}
toast.info(st.detail && st.detail !== st.label ? `${st.label} · ${st.detail}` : st.label);
}
function histRowKey(e: KeyboardEvent, job: Job) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
selectFromHistory(job);
}
}
function summaryOf(job: Job): string {
const parts: string[] = [];
@@ -758,6 +780,26 @@
let curSec = $state(0);
let durSec = $state(0);
// Switching the selected video must stop the old clip and rewind it,
// never keep playing the previous audio/progress into the new one.
// Runs BEFORE the DOM swaps the src so the reset cannot clobber the
// duration/metadata events of the clip being loaded in.
$effect.pre(() => {
void selectedEntry?.key;
const v = videoEl;
if (v) {
try {
v.pause();
v.currentTime = 0;
} catch {
/* no loaded metadata yet — src change already reset it */
}
}
playing = false;
curSec = 0;
durSec = 0;
});
function fmtClock(s: number): string {
if (!Number.isFinite(s)) return '0:00';
const m = Math.floor(s / 60);
@@ -785,13 +827,16 @@
else void playerBox?.requestFullscreen();
}
// First/last frame strip for the selected video job
// First/last frame + reference strip for the selected video job.
// Ref workflows come in two shapes: an autogrow image_list, or several
// plain image inputs — both are shown as reference images.
const selectedFrames = $derived.by(() => {
if (kind !== 'video' || !selectedEntry) return [];
const wf = ($workflowsQuery.data ?? []).find((w) => w.id === selectedEntry.job.workflow_id);
if (!wf) return [];
const vals = (selectedEntry.job.payload?.input_values ?? {}) as Record<string, unknown>;
const out: Array<{ label: string; path: string; ref?: boolean }> = [];
let refIdx = 0;
for (const inp of wf.input_schema ?? []) {
const h = `${inp.role ?? ''} ${inp.label}`.toLowerCase();
const v = vals[inp.nodeId];
@@ -805,6 +850,10 @@
if (typeof v !== 'string' || !v) continue;
if (/first|start|首帧/.test(h)) out.push({ label: t('studio.slotFirst'), path: v });
else if (/last|end|尾帧/.test(h)) out.push({ label: t('studio.slotLast'), path: v });
else if (isMedia(inp) && inp.kind === 'image' && !/mask|遮罩/.test(h)) {
refIdx += 1;
out.push({ label: t('studio.slotRefN', { n: refIdx }), path: v, ref: true });
}
}
return out;
});
@@ -852,12 +901,31 @@
}
const deleteTarget = $derived(kindJobs.find((j) => j.id === deleteJobId) ?? null);
// The dialog names the exact job and — when it produced several
// outputs — says plainly that ALL of them go away with it (the
// backend only deletes whole jobs, never a single output file).
const deleteTitle = $derived(
deleteTarget ? t('studio.delTitleJob', { name: wfName(deleteTarget) }) : t('studio.delTitle'),
);
const deleteMessage = $derived.by(() => {
const n = deleteTarget?.output_paths?.length ?? 0;
return n > 1 ? t('studio.delConfirmMulti', { n }) : t('studio.delConfirm');
});
function askDeleteJob(jobId: number) {
deleteJobId = jobId;
deleteOpen = true;
}
async function doDelete() {
if (!deleteJobId) return;
try {
await playgroundApi.deleteJob(deleteJobId);
submittedIds.delete(deleteJobId);
if (selectedEntry?.job.id === deleteJobId) selectedKey = null;
toast.success(t('studio.deletedToast'));
await client.invalidateQueries({ queryKey: ['playground-jobs'] });
await client.invalidateQueries({ queryKey: ['jobs'] });
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
} finally {
@@ -1502,11 +1570,20 @@
<Icon name="upload" size={13} />
{t('studio.sendToImg2Img')}
</Button>
<a class="a-btn secondary sm" href={assetUrl(selectedEntry.path)} download={downloadName(selectedEntry.path)}>
<Icon name="download" size={13} />
{t('studio.download')}
</a>
</span>
<a class="a-btn secondary sm" href={assetUrl(selectedEntry.path)} download={downloadName(selectedEntry.path)}>
<Icon name="download" size={13} />
{t('studio.download')}
</a>
<Button
variant="danger"
size="sm"
title={t('studio.deleteBtn')}
onclick={() => askDeleteJob(selectedEntry!.job.id)}
>
<Icon name="trash" size={13} />
{t('studio.deleteBtn')}
</Button>
</span>
</div>
{/if}
{:else}
@@ -1521,7 +1598,8 @@
<h2>{t('studio.video.resultsTitle')}</h2>
{#if selectedEntry}
<span class="panel-hint">
{t('studio.lastDone', {
{wfName(selectedEntry.job)} ·
{t('studio.doneAt', {
time: fmtTime(parseDbTs(selectedEntry.job.completed_at ?? selectedEntry.job.created_at)),
})}
</span>
@@ -1594,11 +1672,20 @@
<Icon name="retry" size={13} />
{t('studio.retryLink')}
</Button>
<a class="a-btn secondary sm" href={assetUrl(selectedEntry.path)} download={downloadName(selectedEntry.path)}>
<Icon name="download" size={13} />
{t('studio.downloadMp4')}
</a>
</span>
<a class="a-btn secondary sm" href={assetUrl(selectedEntry.path)} download={downloadName(selectedEntry.path)}>
<Icon name="download" size={13} />
{t('studio.downloadMp4')}
</a>
<Button
variant="danger"
size="sm"
title={t('studio.deleteBtn')}
onclick={() => askDeleteJob(selectedEntry!.job.id)}
>
<Icon name="trash" size={13} />
{t('studio.deleteBtn')}
</Button>
</span>
</div>
{:else}
<p class="empty-line">{t('studio.historyEmptyVideo')}</p>
@@ -1619,59 +1706,73 @@
{kind === 'image' ? t('studio.historyEmptyImage') : t('studio.historyEmptyVideo')}
</p>
{:else}
<div class="history-list">
{#each kindJobs as job (job.id)}
{@const st = jobStatusInfo(job)}
<div class="hist-row" class:failed={job.status === 'failed'} class:video={kind === 'video'}>
<div class="hist-thumb" class:video={kind === 'video'}>
{#if job.output_paths?.[0]}
{#if kind === 'image'}
<img src={assetUrl(job.output_paths[0])} alt={wfName(job)} loading="lazy" />
{:else}
<video
src={assetUrl(job.output_paths[0])}
preload="metadata"
muted
onloadedmetadata={(e) => noteDuration(job.output_paths![0], e)}
></video>
{#if durations[job.output_paths[0]]}
<span class="dur mono">{fmtClock(durations[job.output_paths[0]])}</span>
{/if}
{/if}
{:else if job.status === 'failed'}
<span class="thumb-fallback failed"><Icon name="alert" size={14} /></span>
<div class="history-list">
{#each kindJobs as job (job.id)}
{@const st = jobStatusInfo(job)}
<div
class="hist-row"
class:failed={job.status === 'failed'}
class:video={kind === 'video'}
class:selected={selectedJobId === job.id}
role="button"
tabindex={0}
aria-label={`${wfName(job)} · ${st.label}`}
aria-current={selectedJobId === job.id ? 'true' : undefined}
onclick={() => selectFromHistory(job)}
onkeydown={(e) => histRowKey(e, job)}
>
<div class="hist-thumb" class:video={kind === 'video'}>
{#if job.output_paths?.[0]}
{#if kind === 'image'}
<img src={assetUrl(job.output_paths[0])} alt={wfName(job)} loading="lazy" />
{:else}
<span class="thumb-fallback"><Icon name={kind === 'image' ? 'image' : 'film'} size={14} /></span>
{/if}
</div>
<div class="hist-body">
<div class="hist-name" title={wfName(job)}>{wfName(job)}</div>
<div class="hist-meta mono">{summaryOf(job)}</div>
<div class="hist-status">
{#if job.status !== 'failed'}
<span class="st {st.tone}" title={st.detail}>{st.label}</span>
{:else}
<button
type="button"
class="st fail-link"
disabled={retryingId === job.id}
onclick={() => retryJob(job)}
>
{t('studio.retryLink')}
</button>
<video
src={assetUrl(job.output_paths[0])}
preload="metadata"
muted
onloadedmetadata={(e) => noteDuration(job.output_paths![0], e)}
></video>
{#if durations[job.output_paths[0]]}
<span class="dur mono">{fmtClock(durations[job.output_paths[0]])}</span>
{/if}
{/if}
{:else if job.status === 'failed'}
<span class="thumb-fallback failed"><Icon name="alert" size={14} /></span>
{:else}
<span class="thumb-fallback"><Icon name={kind === 'image' ? 'image' : 'film'} size={14} /></span>
{/if}
</div>
<div class="hist-body">
<div class="hist-name" title={wfName(job)}>{wfName(job)}</div>
<div class="hist-meta mono">{summaryOf(job)}</div>
<div class="hist-status">
{#if job.status !== 'failed'}
<span class="st {st.tone}" title={st.detail}>{st.label}</span>
{:else}
<button
type="button"
class="hist-del"
title={t('studio.deleteBtn')}
onclick={() => {
deleteJobId = job.id;
deleteOpen = true;
class="st fail-link"
disabled={retryingId === job.id}
onclick={(e) => {
e.stopPropagation();
retryJob(job);
}}
>
<Icon name="trash" size={13} />
{t('studio.retryLink')}
</button>
</div>
{/if}
<button
type="button"
class="hist-del"
title={t('studio.deleteBtn')}
onclick={(e) => {
e.stopPropagation();
askDeleteJob(job.id);
}}
>
<Icon name="trash" size={13} />
</button>
</div>
{#if job.status === 'failed' && job.error}
<div class="hist-error">{job.error}</div>
{:else if st.tone === 'fail' || (job.status === 'done' && job.phase === 'dry_run')}
@@ -1711,9 +1812,10 @@
<ConfirmDialog
bind:open={deleteOpen}
title={t('studio.delTitle')}
message={t('studio.delConfirm')}
title={deleteTitle}
message={deleteMessage}
confirmLabel={t('studio.deleteBtn')}
cancelLabel={t('common.cancel')}
danger
onconfirm={doDelete}
oncancel={() => {
@@ -2295,10 +2397,23 @@
border-radius: 8px;
min-height: 44px;
align-items: center;
cursor: pointer;
}
.hist-row:hover {
background: var(--surface-2);
}
.hist-row:focus-visible {
outline: 1px solid var(--accent);
outline-offset: -1px;
}
/* The row loaded into the preview stays marked, and its delete stays visible. */
.hist-row.selected {
background: var(--surface-2);
box-shadow: inset 2px 0 0 var(--accent);
}
.hist-row.selected .hist-del {
opacity: 1;
}
.hist-row.failed {
background: var(--error-dim);
}
@@ -2424,6 +2539,15 @@
.hist-row:hover .hist-del {
opacity: 1;
}
.hist-del:focus-visible {
opacity: 1;
}
/* Touch devices have no hover to reveal the delete affordance. */
@media (hover: none) {
.hist-del {
opacity: 1;
}
}
.hist-del:hover {
color: var(--error);
background: var(--error-dim);
+5 -2
View File
@@ -1462,7 +1462,7 @@ export const de: Dict = {
'studio.image.historyTitle': 'Bildverlauf',
'studio.video.historyTitle': 'Videoverlauf',
'studio.historyCount': '{n} gesamt',
'studio.lastDone': 'Zuletzt fertig · {time}',
'studio.doneAt': 'Fertiggestellt um {time}',
'studio.kindImageText': 'Bild',
'studio.kindVideoText': 'Video',
'studio.loading': 'Lädt…',
@@ -1578,7 +1578,10 @@ export const de: Dict = {
'studio.cancelToast': 'Abbruch angefordert',
'studio.deleteBtn': 'Löschen',
'studio.delTitle': 'Aufgabeneintrag löschen',
'studio.delConfirm': 'Die Ergebnisdateien werden mitgelöscht. Löschen?',
'studio.delConfirm': 'Beim Löschen werden auch die Ergebnisdateien dieses Auftrags entfernt; der Verlauf ist nicht wiederherstellbar. Löschen?',
'studio.delTitleJob': 'Auftrag „{name}“ löschen',
'studio.delConfirmMulti': 'Dieser Auftrag hat {n} Ergebnisse. Beim Löschen werden der gesamte Auftragsdatensatz und alle {n} Ergebnisdateien entfernt; dies ist unwiderruflich. Löschen?',
'studio.deletedToast': 'Auftrag und zugehörige Ergebnisdateien gelöscht',
'studio.wfEmptyTitle': 'Noch keine {kind}-Workflows',
'studio.wfEmptyBody': 'Entwicklermodus in ComfyUI aktivieren, Workflow als API-JSON exportieren und auf der Seite ComfyUI-Verbindungen als {kind} importieren.',
'studio.wfEmptyGo': 'In ComfyUI-Verbindungen importieren',
+5 -2
View File
@@ -1461,7 +1461,7 @@ export const en = {
'studio.image.historyTitle': 'Image history',
'studio.video.historyTitle': 'Video history',
'studio.historyCount': '{n} total',
'studio.lastDone': 'Last done · {time}',
'studio.doneAt': 'Done at {time}',
'studio.kindImageText': 'image',
'studio.kindVideoText': 'video',
'studio.loading': 'Loading…',
@@ -1577,7 +1577,10 @@ export const en = {
'studio.cancelToast': 'Cancel requested',
'studio.deleteBtn': 'Delete',
'studio.delTitle': 'Delete task record',
'studio.delConfirm': 'The result files will be removed together with the record. Delete it?',
'studio.delConfirm': 'Deleting also removes this job’s result files; the history cannot be restored. Delete?',
'studio.delTitleJob': 'Delete job “{name}”',
'studio.delConfirmMulti': 'This job produced {n} results. Deleting removes the whole job record and all {n} result files; this cannot be undone. Delete?',
'studio.deletedToast': 'Job deleted together with its result files',
'studio.wfEmptyTitle': 'No {kind} workflows yet',
'studio.wfEmptyBody': "Enable ComfyUI's developer mode, export the workflow as API JSON, then import it on the ComfyUI Connections page as {kind}.",
'studio.wfEmptyGo': 'Import in ComfyUI Connections',
+5 -2
View File
@@ -1462,7 +1462,7 @@ export const es: Dict = {
'studio.image.historyTitle': 'Historial de imágenes',
'studio.video.historyTitle': 'Historial de vídeos',
'studio.historyCount': '{n} en total',
'studio.lastDone': 'Último · {time}',
'studio.doneAt': 'Completado a las {time}',
'studio.kindImageText': 'imagen',
'studio.kindVideoText': 'vídeo',
'studio.loading': 'Cargando…',
@@ -1578,7 +1578,10 @@ export const es: Dict = {
'studio.cancelToast': 'Cancelación solicitada',
'studio.deleteBtn': 'Eliminar',
'studio.delTitle': 'Eliminar registro de tarea',
'studio.delConfirm': 'Los archivos de resultados se eliminarán junto con el registro. ¿Eliminar?',
'studio.delConfirm': 'Al eliminar también se quitan los archivos de resultados de esta tarea; el historial no se puede restaurar. ¿Eliminar?',
'studio.delTitleJob': 'Eliminar tarea «{name}»',
'studio.delConfirmMulti': 'Esta tarea produjo {n} resultados. Al eliminarla se quita el registro completo y los {n} archivos de resultados; no se puede deshacer. ¿Eliminar?',
'studio.deletedToast': 'Tarea eliminada junto con sus archivos de resultados',
'studio.wfEmptyTitle': 'Aún no hay flujos de {kind}',
'studio.wfEmptyBody': 'Activa el modo desarrollador de ComfyUI, exporta el flujo como JSON API e impórtalo en Conexiones ComfyUI como {kind}.',
'studio.wfEmptyGo': 'Importar en Conexiones ComfyUI',
+5 -2
View File
@@ -1462,7 +1462,7 @@ export const fr: Dict = {
'studio.image.historyTitle': 'Historique images',
'studio.video.historyTitle': 'Historique vidéos',
'studio.historyCount': '{n} au total',
'studio.lastDone': 'Dernier · {time}',
'studio.doneAt': 'Terminé à {time}',
'studio.kindImageText': 'image',
'studio.kindVideoText': 'vidéo',
'studio.loading': 'Chargement…',
@@ -1578,7 +1578,10 @@ export const fr: Dict = {
'studio.cancelToast': 'Annulation demandée',
'studio.deleteBtn': 'Supprimer',
'studio.delTitle': 'Supprimer la tâche',
'studio.delConfirm': 'Les fichiers de résultats seront supprimés avec la tâche. Supprimer ?',
'studio.delConfirm': 'La suppression retire aussi les fichiers de résultats de cette tâche ; l’historique est irrécupérable. Supprimer ?',
'studio.delTitleJob': 'Supprimer la tâche « {name} »',
'studio.delConfirmMulti': 'Cette tâche a produit {n} résultats. La suppression retire la tâche entière et ses {n} fichiers de résultats ; action irréversible. Supprimer ?',
'studio.deletedToast': 'Tâche supprimée avec ses fichiers de résultats',
'studio.wfEmptyTitle': 'Pas encore de workflows {kind}',
'studio.wfEmptyBody': 'Activez le mode développeur de ComfyUI, exportez le workflow en JSON API, puis importez-le dans Connexions ComfyUI comme {kind}.',
'studio.wfEmptyGo': 'Importer dans Connexions ComfyUI',
+5 -2
View File
@@ -1462,7 +1462,7 @@ export const ja: Dict = {
'studio.image.historyTitle': '画像生成履歴',
'studio.video.historyTitle': '動画生成履歴',
'studio.historyCount': '計 {n} 回',
'studio.lastDone': '最終完了 · {time}',
'studio.doneAt': '完了 {time}',
'studio.kindImageText': '画像',
'studio.kindVideoText': '動画',
'studio.loading': '読み込み中…',
@@ -1578,7 +1578,10 @@ export const ja: Dict = {
'studio.cancelToast': 'キャンセルを要求しました',
'studio.deleteBtn': '削除',
'studio.delTitle': 'タスク記録を削除',
'studio.delConfirm': '結果ファイルも一緒に削除され、履歴は復元できません。削除しますか?',
'studio.delConfirm': '削除するとこのタスクの結果ファイルも削除され、履歴は元に戻せません。削除しますか?',
'studio.delTitleJob': 'タスク「{name}」を削除',
'studio.delConfirmMulti': 'このタスクには結果が {n} 件あります。削除するとタスク記録と {n} 件すべての結果ファイルが失われ、元に戻せません。削除しますか?',
'studio.deletedToast': 'タスクと結果ファイルを削除しました',
'studio.wfEmptyTitle': '{kind}ワークフローがまだありません',
'studio.wfEmptyBody': 'ComfyUI の開発者モードで API 形式 JSON を書き出し、「ComfyUI 接続」ページで{kind}として取り込んでください。',
'studio.wfEmptyGo': '「ComfyUI 接続」で取り込む',
+5 -2
View File
@@ -1462,7 +1462,7 @@ export const ko: Dict = {
'studio.image.historyTitle': '이미지 생성 기록',
'studio.video.historyTitle': '영상 생성 기록',
'studio.historyCount': '총 {n}회',
'studio.lastDone': '최근 완료 · {time}',
'studio.doneAt': '완료 {time}',
'studio.kindImageText': '이미지',
'studio.kindVideoText': '비디오',
'studio.loading': '불러오는 중…',
@@ -1578,7 +1578,10 @@ export const ko: Dict = {
'studio.cancelToast': '취소를 요청했습니다',
'studio.deleteBtn': '삭제',
'studio.delTitle': '작업 기록 삭제',
'studio.delConfirm': '결과 파일도 함께 삭제되며 기록은 복구할 수 없습니다. 삭제할까요?',
'studio.delConfirm': '삭제 시 이 작업의 결과 파일도 함께 제거되며 기록은 복구할 수 없습니다. 삭제할까요?',
'studio.delTitleJob': '작업 «{name}» 삭제',
'studio.delConfirmMulti': '이 작업에는 결과가 {n}개 있습니다. 삭제하면 작업 기록과 결과 파일 {n}개 모두 제거되며 복구할 수 없습니다. 삭제할까요?',
'studio.deletedToast': '작업과 결과 파일을 삭제했습니다',
'studio.wfEmptyTitle': '{kind} 워크플로가 아직 없습니다',
'studio.wfEmptyBody': 'ComfyUI 개발자 모드를 켜고 API 형식 JSON으로 내보낸 뒤, ComfyUI 연결 페이지에서 {kind}(으)로 가져오세요.',
'studio.wfEmptyGo': 'ComfyUI 연결에서 가져오기',
+4 -1
View File
@@ -1461,7 +1461,7 @@ export const zh: Dict = {
'studio.image.historyTitle': '生图历史',
'studio.video.historyTitle': '生视频历史',
'studio.historyCount': '共 {n} 次',
'studio.lastDone': '最近完成 · {time}',
'studio.doneAt': '完成于 {time}',
'studio.kindImageText': '图片',
'studio.kindVideoText': '视频',
'studio.loading': '正在加载…',
@@ -1579,6 +1579,9 @@ export const zh: Dict = {
'studio.deleteBtn': '删除',
'studio.delTitle': '删除任务记录',
'studio.delConfirm': '删除后该任务的结果文件会一并移除,历史不可恢复。确定删除?',
'studio.delTitleJob': '删除任务「{name}」',
'studio.delConfirmMulti': '该任务共生成 {n} 个结果,删除将移除整条任务记录及其全部 {n} 个结果文件,历史不可恢复。确定删除?',
'studio.deletedToast': '已删除该任务及其结果文件',
'studio.wfEmptyTitle': '还没有{kind}工作流',
'studio.wfEmptyBody': '在 ComfyUI 里启用开发者模式,用「导出(API 格式)」得到 JSON,再到「ComfyUI 连接」页导入并分类为{kind}。',
'studio.wfEmptyGo': '去「ComfyUI 连接」导入',