diff --git a/apps/internal-site/components/web-uploader/web-upload-pairing-preview.tsx b/apps/internal-site/components/web-uploader/web-upload-pairing-preview.tsx index 0d95ae1..4950225 100644 --- a/apps/internal-site/components/web-uploader/web-upload-pairing-preview.tsx +++ b/apps/internal-site/components/web-uploader/web-upload-pairing-preview.tsx @@ -29,6 +29,7 @@ import { import { Alert, Box, + Button, Chip, Collapse, FormControl, @@ -40,11 +41,7 @@ import { Tooltip, Typography, } from "@mui/material"; -import type { - BrowserUploadFile, - WebUploadFramePlan, - WebUploadPlan, -} from "./web-upload-types"; +import type { BrowserUploadFile, WebUploadFramePlan, WebUploadPlan } from "./web-upload-types"; import { webUploadColors, webUploadMotion, @@ -82,6 +79,7 @@ interface PairingPreviewPanelProps { hasBlockingIssues: boolean; onExpandedFrameChange: (frameId: string | null) => void; onHeatmapReferenceChange: (label: string) => void; + onToggleTitleDisplayMode: () => void; onRenameColumn: (column: UploadPlanImageColumn, nextLabel: string) => void; onReorder: (activeFrameId: string, overFrameId: string | null) => void; } @@ -97,13 +95,7 @@ function alternateAssetForLabel(frame: WebUploadFramePlan | null, label: string) return frame?.misc.find((asset) => asset.label === label) ?? null; } -function SmallLazyThumbnail({ - alt, - source, -}: { - alt: string; - source: BrowserUploadFile | null; -}) { +function SmallLazyThumbnail({ alt, source }: { alt: string; source: BrowserUploadFile | null }) { const rootRef = useRef(null); const [isVisible, setIsVisible] = useState(false); const [url, setUrl] = useState(null); @@ -213,21 +205,27 @@ function ImageCell({ muted = false, path, source }: ImageCellProps) { function IssueStatus({ row }: { row: FramePreviewRow }) { if (row.hasError) { - return ; + return ( + + ⛔ + + ); } if (row.hasWarning) { - return ⚠️; + return ( + + ⚠️ + + ); } - return ; + return ( + + ✅ + + ); } -function ExpandedPreview({ - frame, - urls, -}: { - frame: WebUploadFramePlan; - urls: PreviewUrls | null; -}) { +function ExpandedPreview({ frame, urls }: { frame: WebUploadFramePlan; urls: PreviewUrls | null }) { return ( item.label === label); const alternateAsset = alternateAssetForLabel(previewFrame, label); return ( - + {previewFrame ? ( - + ) : null} ); @@ -553,6 +545,7 @@ export function PairingPreviewPanel({ hasBlockingIssues, onExpandedFrameChange, onHeatmapReferenceChange, + onToggleTitleDisplayMode, onRenameColumn, onReorder, }: PairingPreviewPanelProps) { @@ -663,6 +656,21 @@ export function PairingPreviewPanel({ ) : null} + : } label={`${planView.healthyPairCount} / ${planView.frames.length}`} @@ -725,7 +733,9 @@ export function PairingPreviewPanel({ onRenameColumn({ kind: "misc", label }, nextLabel)} + onRename={(_label, nextLabel) => + onRenameColumn({ kind: "misc", label }, nextLabel) + } /> ))} @@ -758,7 +768,10 @@ export function PairingPreviewPanel({ {planView?.issues.length ? ( - + {planView.issues.slice(0, 3).map((issue, index) => ( {issue.message} diff --git a/apps/internal-site/components/web-uploader/web-upload-view-model.test.ts b/apps/internal-site/components/web-uploader/web-upload-view-model.test.ts index cfb0dc7..7224070 100644 --- a/apps/internal-site/components/web-uploader/web-upload-view-model.test.ts +++ b/apps/internal-site/components/web-uploader/web-upload-view-model.test.ts @@ -3,6 +3,7 @@ import { buildPlanView, compactUploadFilename, frameIdForFrame, + getNextUploadPlanTitleDisplayMode, getUploadPlanHeatmapReferenceOptions, renameUploadPlanAssetLabel, reorderUploadPlan, @@ -72,11 +73,7 @@ describe("web upload view model", () => { const reordered = reorderUploadPlan(originalPlan, activeId, overId); expect(reordered).not.toBeNull(); - expect(reordered?.frames.map((item) => item.title)).toEqual([ - "Frame 3", - "Frame 1", - "Frame 2", - ]); + expect(reordered?.frames.map((item) => item.title)).toEqual(["Frame 3", "Frame 1", "Frame 2"]); expect(buildPlanView(reordered!).frames.map((item) => item.title)).toEqual([ "Frame 3", "Frame 1", @@ -98,9 +95,39 @@ describe("web upload view model", () => { it("ignores unknown drag ids", () => { const originalPlan = plan(); - expect(reorderUploadPlan(originalPlan, "missing", frameIdForFrame(originalPlan.frames[0]))).toBeNull(); - expect(reorderUploadPlan(originalPlan, frameIdForFrame(originalPlan.frames[0]), "missing")).toBeNull(); - expect(reorderUploadPlan(originalPlan, frameIdForFrame(originalPlan.frames[0]), null)).toBeNull(); + expect( + reorderUploadPlan(originalPlan, "missing", frameIdForFrame(originalPlan.frames[0])), + ).toBeNull(); + expect( + reorderUploadPlan(originalPlan, frameIdForFrame(originalPlan.frames[0]), "missing"), + ).toBeNull(); + expect( + reorderUploadPlan(originalPlan, frameIdForFrame(originalPlan.frames[0]), null), + ).toBeNull(); + }); + + it("switches to full frame titles and restores the original automatic titles", () => { + const originalPlan = plan(); + originalPlan.frames[0] = { + ...originalPlan.frames[0], + title: "12-27240", + caption: "WatanaRe Anime Vol1 / 24 fps / frame 27240", + }; + + const autoView = buildPlanView(originalPlan, "auto"); + const fullMode = getNextUploadPlanTitleDisplayMode(autoView.titleDisplayMode); + const fullView = buildPlanView(originalPlan, fullMode); + const restoredMode = getNextUploadPlanTitleDisplayMode(fullView.titleDisplayMode); + const restoredView = buildPlanView(originalPlan, restoredMode); + + expect(autoView.frames[0]).toMatchObject({ + title: "12-27240", + autoTitle: "12-27240", + fullTitle: "WatanaRe Anime Vol1-27240", + }); + expect(fullView.frames[0].title).toBe("WatanaRe Anime Vol1-27240"); + expect(originalPlan.frames[0].title).toBe("12-27240"); + expect(restoredView.frames[0].title).toBe("12-27240"); }); it("exposes up to three alternate after columns for preview", () => { @@ -158,11 +185,7 @@ describe("web upload view model", () => { const renamed = renameUploadPlanAssetLabel(originalPlan, { kind: "after" }, "Output"); - expect(renamed?.frames.map((item) => item.after.label)).toEqual([ - "Output", - "Output", - "Output", - ]); + expect(renamed?.frames.map((item) => item.after.label)).toEqual(["Output", "Output", "Output"]); expect(renamed?.heatmapReferenceLabel).toBe("Output"); expect(getUploadPlanHeatmapReferenceOptions(renamed!)).toEqual(["Output"]); }); @@ -180,8 +203,12 @@ describe("web upload view model", () => { expect(renameUploadPlanAssetLabel(originalPlan, { kind: "before" }, "")).toBeNull(); expect(renameUploadPlanAssetLabel(originalPlan, { kind: "before" }, "After")).toBeNull(); expect(renameUploadPlanAssetLabel(originalPlan, { kind: "after" }, "Rip")).toBeNull(); - expect(renameUploadPlanAssetLabel(originalPlan, { kind: "misc", label: "Rip" }, "Degrain")).toBeNull(); - expect(renameUploadPlanAssetLabel(originalPlan, { kind: "misc", label: "Rip" }, "Heatmap")).toBeNull(); + expect( + renameUploadPlanAssetLabel(originalPlan, { kind: "misc", label: "Rip" }, "Degrain"), + ).toBeNull(); + expect( + renameUploadPlanAssetLabel(originalPlan, { kind: "misc", label: "Rip" }, "Heatmap"), + ).toBeNull(); }); it("only exposes global heatmap references available on every frame", () => { diff --git a/apps/internal-site/components/web-uploader/web-upload-view-model.ts b/apps/internal-site/components/web-uploader/web-upload-view-model.ts index a332eda..7f4fa2b 100644 --- a/apps/internal-site/components/web-uploader/web-upload-view-model.ts +++ b/apps/internal-site/components/web-uploader/web-upload-view-model.ts @@ -1,13 +1,13 @@ -import type { - WebUploadFramePlan, - WebUploadIssue, - WebUploadPlan, -} from "./web-upload-types"; +import type { WebUploadFramePlan, WebUploadIssue, WebUploadPlan } from "./web-upload-types"; + +export type UploadPlanTitleDisplayMode = "auto" | "full"; export interface FramePreviewRow { frameId: string; order: number; title: string; + autoTitle: string; + fullTitle: string; beforePath: string; afterPath: string; alternateAfter: Array<{ label: string; path: string }>; @@ -20,6 +20,7 @@ export interface FramePreviewRow { export interface PlanView { sourceRootName: string; + titleDisplayMode: UploadPlanTitleDisplayMode; suggestedGroupSlug: string; suggestedGroupTitle: string; beforeLabel: string; @@ -87,6 +88,40 @@ function orderedComparisonLabels(frame: WebUploadFramePlan) { return [...new Set(labels)]; } +function titleFromStructuredCaption(caption: string) { + const match = /^(?.+?) \/ (?:(?<fps>[^/]+) fps \/ )?frame (?<frame>\d+)$/.exec(caption); + if (!match?.groups) { + return null; + } + + const frameNumber = String(Number(match.groups.frame) || 0); + return `${match.groups.title}-${frameNumber}`; +} + +function uploadPlanFrameFullTitle(frame: WebUploadFramePlan) { + return titleFromStructuredCaption(frame.caption) ?? frame.title; +} + +export function getUploadPlanFrameDisplayTitles( + frame: WebUploadFramePlan, + mode: UploadPlanTitleDisplayMode, +) { + const autoTitle = frame.title; + const fullTitle = uploadPlanFrameFullTitle(frame); + + return { + title: mode === "full" ? fullTitle : autoTitle, + autoTitle, + fullTitle, + }; +} + +export function getNextUploadPlanTitleDisplayMode( + mode: UploadPlanTitleDisplayMode, +): UploadPlanTitleDisplayMode { + return mode === "full" ? "auto" : "full"; +} + export function getUploadPlanHeatmapReferenceOptions(plan: WebUploadPlan) { if (plan.frames.length === 0) { return []; @@ -108,7 +143,10 @@ export function getUploadPlanHeatmapReferenceOptions(plan: WebUploadPlan) { return orderedComparisonLabels(firstFrame).filter((label) => commonLabels.has(label)); } -export function buildPlanView(plan: WebUploadPlan): PlanView { +export function buildPlanView( + plan: WebUploadPlan, + titleDisplayMode: UploadPlanTitleDisplayMode = "auto", +): PlanView { const heatmapReferenceOptions = getUploadPlanHeatmapReferenceOptions(plan); const frames = plan.frames.map((frame) => { const issueState = frameIssueState(frame, plan.issues); @@ -120,10 +158,12 @@ export function buildPlanView(plan: WebUploadPlan): PlanView { path: asset.source.relativePath, })); + const titles = getUploadPlanFrameDisplayTitles(frame, titleDisplayMode); + return { frameId: frameIdForFrame(frame), order: frame.order, - title: frame.title, + ...titles, beforePath: frame.before.source.relativePath, afterPath: frame.after.source.relativePath, alternateAfter, @@ -135,6 +175,7 @@ export function buildPlanView(plan: WebUploadPlan): PlanView { return { sourceRootName: plan.sourceRootName, + titleDisplayMode, suggestedGroupSlug: plan.suggestedGroupSlug, suggestedGroupTitle: plan.suggestedGroupTitle, beforeLabel: plan.frames[0]?.before.label ?? "Before", @@ -178,19 +219,24 @@ export function renameUploadPlanAssetLabel( const currentLabelSet = new Set(currentColumnLabels.map((label) => label.toLowerCase())); const existingLabels = new Set( plan.frames - .flatMap((frame) => [frame.before.label, frame.after.label, ...frame.misc.map((asset) => asset.label)]) + .flatMap((frame) => [ + frame.before.label, + frame.after.label, + ...frame.misc.map((asset) => asset.label), + ]) .filter((label) => !currentLabelSet.has(label.toLowerCase())) .map((label) => label.toLowerCase()), ); // Column labels drive table headers and the global heatmap selector, so keep them unique. The // target role is explicit because older scans can contain a misc column also named "After". - if (normalizedLabel.toLowerCase() === "heatmap" || existingLabels.has(normalizedLabel.toLowerCase())) { + if ( + normalizedLabel.toLowerCase() === "heatmap" || + existingLabels.has(normalizedLabel.toLowerCase()) + ) { return null; } const hasBuiltInColumnWithCurrentLabel = plan.frames.some( - (frame) => - frame.before.label === currentLabel || - frame.after.label === currentLabel, + (frame) => frame.before.label === currentLabel || frame.after.label === currentLabel, ); const shouldRenameHeatmapReference = plan.heatmapReferenceLabel === currentLabel && @@ -198,20 +244,13 @@ export function renameUploadPlanAssetLabel( return { ...plan, - heatmapReferenceLabel: - shouldRenameHeatmapReference - ? normalizedLabel - : plan.heatmapReferenceLabel, + heatmapReferenceLabel: shouldRenameHeatmapReference + ? normalizedLabel + : plan.heatmapReferenceLabel, frames: plan.frames.map((frame) => ({ ...frame, - before: - column.kind === "before" - ? { ...frame.before, label: normalizedLabel } - : frame.before, - after: - column.kind === "after" - ? { ...frame.after, label: normalizedLabel } - : frame.after, + before: column.kind === "before" ? { ...frame.before, label: normalizedLabel } : frame.before, + after: column.kind === "after" ? { ...frame.after, label: normalizedLabel } : frame.after, misc: frame.misc.map((asset) => column.kind === "misc" && asset.label === column.label ? { ...asset, label: normalizedLabel } @@ -221,10 +260,7 @@ export function renameUploadPlanAssetLabel( }; } -export function setUploadPlanHeatmapReference( - plan: WebUploadPlan, - nextLabel: string, -) { +export function setUploadPlanHeatmapReference(plan: WebUploadPlan, nextLabel: string) { if ( plan.heatmapReferenceLabel === nextLabel || !getUploadPlanHeatmapReferenceOptions(plan).includes(nextLabel) diff --git a/apps/internal-site/components/web-uploader/web-upload-workbench.tsx b/apps/internal-site/components/web-uploader/web-upload-workbench.tsx index 0d3cfbd..2cf0fc7 100644 --- a/apps/internal-site/components/web-uploader/web-upload-workbench.tsx +++ b/apps/internal-site/components/web-uploader/web-upload-workbench.tsx @@ -1,12 +1,6 @@ "use client"; -import { - type ChangeEvent, - useEffect, - useMemo, - useRef, - useState, -} from "react"; +import { type ChangeEvent, useEffect, useMemo, useRef, useState } from "react"; import { ArrowBack, CloudUpload, @@ -59,8 +53,10 @@ import { buildPlanView, renameUploadPlanAssetLabel, reorderUploadPlan, + getNextUploadPlanTitleDisplayMode, setUploadPlanHeatmapReference, type PlanView, + type UploadPlanTitleDisplayMode, type UploadPlanImageColumn, } from "./web-upload-view-model"; @@ -146,9 +142,7 @@ function getCaseInput( }; } -async function readDirectoryHandle( - handle: BrowserDirectoryHandle, -): Promise<BrowserUploadFile[]> { +async function readDirectoryHandle(handle: BrowserDirectoryHandle): Promise<BrowserUploadFile[]> { const entries: BrowserUploadFile[] = []; async function walk(directory: BrowserDirectoryHandle, prefix: string) { @@ -223,8 +217,7 @@ function UploadQueue({ snapshot }: { snapshot: UploadRunnerSnapshot }) { return ( <Stack spacing={0.75}> {snapshot.frames.slice(0, UPLOAD_QUEUE_VISIBLE_LIMIT).map((frame) => { - const progress = - frame.totalFiles > 0 ? (frame.completedFiles / frame.totalFiles) * 100 : 0; + const progress = frame.totalFiles > 0 ? (frame.completedFiles / frame.totalFiles) * 100 : 0; return ( <Box key={frame.frameOrder} @@ -351,7 +344,11 @@ function uploadStageCopy({ }; } -function UploadMetric({ label, value, tone = "default" }: { +function UploadMetric({ + label, + value, + tone = "default", +}: { label: string; value: number | string; tone?: "default" | "warning"; @@ -486,10 +483,7 @@ function UploadDetails({ * Presents upload as a focused workbench. Heavy File/Blob data stays in refs and the upload runner; * React only keeps compact render models so large directories do not become component state. */ -export function WebUploadWorkbench({ - cases, - initialCaseSlug, -}: WebUploadWorkbenchProps) { +export function WebUploadWorkbench({ cases, initialCaseSlug }: WebUploadWorkbenchProps) { const router = useRouter(); const inputRef = useRef<HTMLInputElement | null>(null); const planRef = useRef<WebUploadPlan | null>(null); @@ -497,8 +491,7 @@ export function WebUploadWorkbench({ const runnerRef = useRef<WebUploadRunner | null>(null); const unsubscribeRunnerRef = useRef<(() => void) | null>(null); const generationAbortRef = useRef<AbortController | null>(null); - const { dismissNotification, notifications, pushNotification } = - useAppNotifications(); + const { dismissNotification, notifications, pushNotification } = useAppNotifications(); const [selectedCaseSlug, setSelectedCaseSlug] = useState(() => { if (initialCaseSlug && cases.some((item) => item.slug === initialCaseSlug)) { return initialCaseSlug; @@ -516,13 +509,11 @@ export function WebUploadWorkbench({ description: "", defaultMode: "before-after" as ViewerMode, }); + const [titleDisplayMode, setTitleDisplayMode] = useState<UploadPlanTitleDisplayMode>("auto"); const [planView, setPlanView] = useState<PlanView | null>(null); - const [generationProgress, setGenerationProgress] = - useState<GenerationProgress | null>(null); + const [generationProgress, setGenerationProgress] = useState<GenerationProgress | null>(null); const [expandedFrameId, setExpandedFrameId] = useState<string | null>(null); - const [snapshot, setSnapshot] = useState<UploadRunnerSnapshot>(() => - buildInitialSnapshot(), - ); + const [snapshot, setSnapshot] = useState<UploadRunnerSnapshot>(() => buildInitialSnapshot()); // Failed uploads can be resumed against the existing server job. Keep metadata locked there too // so visible inputs cannot drift away from the payload already owned by the runner. @@ -535,9 +526,7 @@ export function WebUploadWorkbench({ const hasBlockingIssues = Boolean(planView && planView.errorCount > 0); const canStart = Boolean(planView && planRef.current && !hasBlockingIssues); const canAbandon = - Boolean(planRef.current) && - snapshot.stage !== "idle" && - snapshot.stage !== "completed"; + Boolean(planRef.current) && snapshot.stage !== "idle" && snapshot.stage !== "completed"; const overallProgress = snapshot.totalFiles > 0 ? (snapshot.completedFiles / snapshot.totalFiles) * 100 : 0; @@ -587,7 +576,8 @@ export function WebUploadWorkbench({ slug: plan.suggestedGroupSlug, title: plan.suggestedGroupTitle, })); - setPlanView(buildPlanView(plan)); + setTitleDisplayMode("auto"); + setPlanView(buildPlanView(plan, "auto")); pushNotification( plan.issues.some((issue) => issue.severity === "error") ? "检查发现阻塞问题,请先修正文件夹。" @@ -616,11 +606,9 @@ export function WebUploadWorkbench({ if (error instanceof DOMException && error.name === "AbortError") { return; } - pushNotification( - error instanceof Error ? error.message : "读取目录失败。", - "error", - { key: "web-upload-directory-error" }, - ); + pushNotification(error instanceof Error ? error.message : "读取目录失败。", "error", { + key: "web-upload-directory-error", + }); } } @@ -737,6 +725,7 @@ export function WebUploadWorkbench({ planRef.current = null; generatedFramesRef.current = null; setPlanView(null); + setTitleDisplayMode("auto"); setGenerationProgress(null); setExpandedFrameId(null); setSnapshot(buildInitialSnapshot()); @@ -791,7 +780,7 @@ export function WebUploadWorkbench({ // Generated blobs embed frame order in upload descriptors, so any pre-upload reorder must // invalidate cached generation output before the user starts the final upload. generatedFramesRef.current = null; - setPlanView(buildPlanView(reorderedPlan)); + setPlanView(buildPlanView(reorderedPlan, titleDisplayMode)); } function applyPlanUpdate(nextPlan: WebUploadPlan | null) { @@ -803,7 +792,7 @@ export function WebUploadWorkbench({ // Generated blobs carry asset labels and heatmap descriptors, so any metadata-level plan edit // before upload must invalidate the cached generation output. generatedFramesRef.current = null; - setPlanView(buildPlanView(nextPlan)); + setPlanView(buildPlanView(nextPlan, titleDisplayMode)); } function renamePairingColumn(column: UploadPlanImageColumn, nextLabel: string) { @@ -824,6 +813,19 @@ export function WebUploadWorkbench({ applyPlanUpdate(setUploadPlanHeatmapReference(plan, nextLabel)); } + function toggleTitleDisplayMode() { + const plan = planRef.current; + if (!plan || snapshot.stage !== "scanned") { + return; + } + + const nextMode = getNextUploadPlanTitleDisplayMode(titleDisplayMode); + setTitleDisplayMode(nextMode); + // The title toggle is preview-only: keep the upload plan's auto titles intact so switching back + // cannot lose the scanner's compact frame labels. + setPlanView(buildPlanView(plan, nextMode)); + } + return ( <> <Stack spacing={{ xs: 3.1, md: 4.2 }}> @@ -842,11 +844,7 @@ export function WebUploadWorkbench({ borderColor: "divider", }} > - <Typography - variant="h2" - component="h1" - sx={{ lineHeight: 1, textWrap: "balance" }} - > + <Typography variant="h2" component="h1" sx={{ lineHeight: 1, textWrap: "balance" }}> 上传对比 </Typography> <Stack @@ -890,9 +888,7 @@ export function WebUploadWorkbench({ variant="contained" startIcon={snapshot.stage === "failed" ? <Refresh /> : <CloudUpload />} disabled={ - !canStart || - snapshot.stage === "generating" || - snapshot.stage === "completed" + !canStart || snapshot.stage === "generating" || snapshot.stage === "completed" } onClick={startOrResumeUpload} sx={{ @@ -1052,7 +1048,12 @@ export function WebUploadWorkbench({ <Paper elevation={0} sx={webUploadPanelSx}> <Stack spacing={1.35}> - <Stack direction="row" alignItems="center" justifyContent="space-between" spacing={1}> + <Stack + direction="row" + alignItems="center" + justifyContent="space-between" + spacing={1} + > <Typography variant="h6">上传详情</Typography> <Chip label={stageLabel(snapshot.stage)} @@ -1086,6 +1087,7 @@ export function WebUploadWorkbench({ onExpandedFrameChange={setExpandedFrameId} onHeatmapReferenceChange={changeHeatmapReference} onRenameColumn={renamePairingColumn} + onToggleTitleDisplayMode={toggleTitleDisplayMode} onReorder={reorderPairingRows} /> </Box>