diff --git a/apps/internal-site/components/case-workspace/workspace-action-helpers.ts b/apps/internal-site/components/case-workspace/workspace-action-helpers.ts index ed351cc..56af560 100644 --- a/apps/internal-site/components/case-workspace/workspace-action-helpers.ts +++ b/apps/internal-site/components/case-workspace/workspace-action-helpers.ts @@ -367,6 +367,32 @@ export function deleteWorkspaceGroup( }); } +function canUseBrowserNotifications() { + return typeof window !== "undefined" && "Notification" in window; +} + +function requestBrowserPublicDeployNotificationPermission() { + if (!canUseBrowserNotifications() || Notification.permission !== "default") { + return; + } + + // Permission prompts must be tied to the operator's deploy click; asking after the async deploy + // succeeds can be blocked by browsers because it is no longer in the user-activation window. + void Notification.requestPermission(); +} + +function notifyBrowserPublicDeploySuccess(projectName: string) { + if (!canUseBrowserNotifications() || Notification.permission !== "granted") { + return; + } + + // Browser notifications make long Cloudflare deploys visible even after the operator switches + // tabs; in-app snackbars alone are too easy to miss after a successful public publish. + new Notification("Magic Compare public site deployed", { + body: `Cloudflare Pages project ${projectName} is live with a fresh export.`, + }); +} + /** * Deploy remains single-flight on the client as well as the server lock so repeated taps cannot * queue duplicate Cloudflare deploys before the first request leaves the browser. @@ -385,6 +411,7 @@ export function deployWorkspacePublicSite({ return; } + requestBrowserPublicDeployNotificationPermission(); setIsDeployingPublicSite(true); notifications.pushNotification( "Republishing this case and deploying a fresh public export to Cloudflare Pages...", @@ -406,6 +433,7 @@ export function deployWorkspacePublicSite({ `Deployed fresh static export to Cloudflare Pages project ${result.projectName}.`, "success", ); + notifyBrowserPublicDeploySuccess(result.projectName); }, request: async () => postJson("/api/ops/public-deploy", { caseId: data.id }), startTransition, diff --git a/apps/internal-site/components/web-uploader/source-scanner.test.ts b/apps/internal-site/components/web-uploader/source-scanner.test.ts index 7ed1082..825b9dc 100644 --- a/apps/internal-site/components/web-uploader/source-scanner.test.ts +++ b/apps/internal-site/components/web-uploader/source-scanner.test.ts @@ -65,6 +65,25 @@ describe("scanBrowserUploadFiles", () => { expect(plan.frames[0].misc).toHaveLength(1); }); + it("preserves explicit rip and flt variants inside an after directory", () => { + const plan = scanBrowserUploadFiles( + [ + image("case/before/30_ULTRAMAN_DYNA_BD_BOX_1_00006 - 01917 - src.png"), + image("case/after/30_ULTRAMAN_DYNA_BD_BOX_1_00006 - 01917 - rip.png"), + image("case/after/30_ULTRAMAN_DYNA_BD_BOX_1_00006 - 01917 - flt.png"), + ], + "case", + ); + + expect(plan.frames).toHaveLength(1); + expect(plan.frames[0].after.label).toBe("Rip"); + expect(plan.heatmapReferenceLabel).toBe("Rip"); + expect(plan.frames[0].misc.map((asset) => asset.label)).toEqual(["Flt"]); + expect(plan.frames[0].after.source.relativePath).toBe( + "after/30_ULTRAMAN_DYNA_BD_BOX_1_00006 - 01917 - rip.png", + ); + }); + it("reports an error when a before file has no matching after file", () => { const plan = scanBrowserUploadFiles( [image("before/001.png"), image("before/002.png"), image("after/001.png")], @@ -141,7 +160,7 @@ describe("scanBrowserUploadFiles", () => { ); expect(plan.frames).toHaveLength(1); - expect(plan.frames[0].title).toBe("0-27240"); + expect(plan.frames[0].title).toBe("VOL1-00000-27240"); expect(plan.frames[0].caption).toContain("WATANARE ANIME VOL1"); expect(plan.frames[0].after.label).toBe("After"); expect(plan.frames[0].after.source.relativePath).toBe( @@ -176,7 +195,7 @@ describe("scanBrowserUploadFiles", () => { ); expect(plan.frames).toHaveLength(1); - expect(plan.frames[0].title).toBe("0-27240"); + expect(plan.frames[0].title).toBe("VOL1-00000-27240"); expect(plan.frames[0].caption).toContain("WATANARE ANIME VOL1"); expect(plan.frames[0].after.label).toBe("After"); expect(plan.frames[0].misc.map((asset) => asset.label)).toEqual(["Rip"]); @@ -193,7 +212,7 @@ describe("scanBrowserUploadFiles", () => { ); expect(plan.frames).toHaveLength(1); - expect(plan.frames[0].title).toBe("0-27240"); + expect(plan.frames[0].title).toBe("VOL1-00000-27240"); expect(plan.frames[0].caption).toContain("WATANARE ANIME VOL1"); expect(plan.frames[0].after.label).toBe("After"); expect(plan.frames[0].misc.map((asset) => asset.label)).toEqual(["Rip"]); @@ -209,7 +228,7 @@ describe("scanBrowserUploadFiles", () => { ); expect(plan.frames).toHaveLength(1); - expect(plan.frames[0].title).toBe("0-27240"); + expect(plan.frames[0].title).toBe("VOL1-00000-27240"); expect(plan.frames[0].before.source.relativePath).toBe( "24_WATANARE_ANIME_VOL1_00000.gen.vpy-27240-src.png", ); @@ -218,6 +237,46 @@ describe("scanBrowserUploadFiles", () => { ); }); + + it("pairs spaced dash filenames and infers src/rip/flt columns", () => { + const plan = scanBrowserUploadFiles( + [ + image("sample/30_ULTRAMAN_DYNA_BD_BOX_1_00006 - 01917 - flt.png"), + image("sample/30_ULTRAMAN_DYNA_BD_BOX_1_00006 - 01917 - rip.png"), + image("sample/30_ULTRAMAN_DYNA_BD_BOX_1_00006 - 01917 - src.png"), + image("sample/30_ULTRAMAN_DYNA_BD_BOX_1_00006 - 05455 - flt.png"), + image("sample/30_ULTRAMAN_DYNA_BD_BOX_1_00006 - 05455 - rip.png"), + image("sample/30_ULTRAMAN_DYNA_BD_BOX_1_00006 - 05455 - src.png"), + ], + "sample", + ); + + expect(plan.frames).toHaveLength(2); + expect(plan.frames[0].title).toBe("BOX1-00006-1917"); + expect(plan.frames[0].before.label).toBe("Src"); + expect(plan.frames[0].after.label).toBe("Rip"); + expect(plan.heatmapReferenceLabel).toBe("Rip"); + expect(plan.frames[0].misc.map((asset) => asset.label)).toEqual(["Flt"]); + expect(plan.issues).toEqual([]); + }); + + it("groups flat spaced flt variants with the same frame", () => { + const plan = scanBrowserUploadFiles( + [ + image("sample/clip - 001 - src.png"), + image("sample/clip - 001 - rip.png"), + image("sample/clip - 001 - flt.png"), + ], + "sample", + ); + + expect(plan.frames).toHaveLength(1); + expect(plan.frames[0].after.label).toBe("Rip"); + expect(plan.heatmapReferenceLabel).toBe("Rip"); + expect(plan.frames[0].misc.map((asset) => asset.label)).toEqual(["Flt"]); + expect(plan.issues).toEqual([]); + }); + it("ignores common sidecar and system files", () => { const plan = scanBrowserUploadFiles( [ diff --git a/apps/internal-site/components/web-uploader/source-scanner.ts b/apps/internal-site/components/web-uploader/source-scanner.ts index 3725219..9ce2795 100644 --- a/apps/internal-site/components/web-uploader/source-scanner.ts +++ b/apps/internal-site/components/web-uploader/source-scanner.ts @@ -14,6 +14,9 @@ const PRIMARY_AFTER_VARIANTS = new Set(["after", "out", "output"]); const COMPARISON_VARIANTS = new Set([ ...PRIMARY_AFTER_VARIANTS, "rip", + "flt", + "filter", + "filtered", "deband", "nodeband", "noband", @@ -39,6 +42,9 @@ const MATCH_KEY_VARIANTS = [ "out", "output", "rip", + "flt", + "filter", + "filtered", "misc", "heatmap", "nodeband", @@ -53,10 +59,10 @@ const MATCH_KEY_VARIANTS = [ ]; const IGNORED_BASENAMES = new Set([".ds_store", "thumbs.db"]); const IGNORED_SUFFIXES = new Set([".json", ".yaml", ".yml", ".txt", ".md", ".csv", ".db", ".log"]); -const FILENAME_RE = /(?.+?)[_\-.](?\d+)(?:[_\-.](?[^_\-.]+))?$/; +const FILENAME_RE = /(?.+?)[_\-. ]+(?\d+)(?:[_\-. ]+(?[^_\-. ]+))?$/; const FALLBACK_FILENAME_RE = /^(?\d+)(?[A-Za-z][A-Za-z0-9]*)$/; const STRUCTURED_SOURCE_FILENAME_RE = - /^(?:(?\d{2})_)?(?.+)_(?<episode>\d+)(?:\.(?<sourceMarker>[^-]+))?-(?<frame>\d+)-(?<variant>[^_\-.]+)$/i; + /^(?:(?<fps>\d{2})_)?(?<title>.+)_(?<episode>\d+)(?:\.(?<sourceMarker>[^-]+))?[_\-. ]+(?<frame>\d+)[_\-. ]+(?<variant>[^_\-. ]+)$/i; const GROUP_SUFFIX_NOISE_RE = /(?:[_\-. ]+\d{4,5}[_\-. ]+(?:gen[_\-. ]+vpy|m2ts|mkv|mp4|ts))$/i; const MATCH_KEY_SUFFIX_RE = new RegExp( `(?:[_\\-. ]+(?:${MATCH_KEY_VARIANTS.join("|")}))+$`, @@ -125,6 +131,15 @@ function titleCase(input: string) { .replace(/\b\w/g, (letter) => letter.toUpperCase()); } +function volumeLabel(input: string) { + const match = input.match(/(?:^|[^a-z0-9])(?<kind>vol(?:ume)?|box)[_\-. ]*(?<number>\d+)/i); + if (!match?.groups) { + return null; + } + + return `${match.groups.kind.toUpperCase().startsWith("BOX") ? "BOX" : "VOL"}${Number(match.groups.number)}`; +} + function variantLabel(variant: string) { const normalized = variant.toLowerCase(); if (PRIMARY_AFTER_VARIANTS.has(normalized)) { @@ -261,7 +276,7 @@ function parseCandidate(entry: BrowserUploadFile, variantOverride?: string): Sou return { entry, originalName: basename(entry.relativePath), - variant: (variantOverride ?? structured.variant).trim().toLowerCase(), + variant: (structured.variant ?? variantOverride ?? "output").trim().toLowerCase(), fps: structured.fps, episode: structured.episode, frameNumber: structured.frameNumber, @@ -279,7 +294,7 @@ function parseCandidate(entry: BrowserUploadFile, variantOverride?: string): Sou const prefix = match.groups.prefix; const rawFrame = match.groups.frame; const frameNumber = Number(rawFrame.replace(/^0+/, "") || "0"); - const variant = (variantOverride ?? match.groups.variant ?? "output").trim().toLowerCase(); + const variant = (match.groups.variant ?? variantOverride ?? "output").trim().toLowerCase(); const fps = extractFps(pathStem); const episode = extractEpisode(prefix); const title = `${fps}_${episode}_${frameNumber}`; @@ -305,7 +320,7 @@ function parseCandidate(entry: BrowserUploadFile, variantOverride?: string): Sou return { entry, originalName: basename(entry.relativePath), - variant: (variantOverride ?? fallbackMatch.groups.variant).trim().toLowerCase(), + variant: (fallbackMatch.groups.variant ?? variantOverride).trim().toLowerCase(), fps: "00", episode: "00", frameNumber, @@ -346,7 +361,16 @@ function candidateFrameKey(candidate: SourceCandidate) { } function afterPriority(candidate: SourceCandidate) { - const priority = candidate.variant === "out" ? 0 : candidate.variant === "output" ? 1 : candidate.variant === "after" ? 2 : 3; + const priority = + candidate.variant === "out" + ? 0 + : candidate.variant === "output" + ? 1 + : candidate.variant === "after" + ? 2 + : candidate.variant === "rip" + ? 3 + : 4; return `${priority}:${candidate.variant}:${candidate.originalName.toLowerCase()}`; } @@ -366,12 +390,30 @@ function alternatePriority(candidate: SourceCandidate) { return `${priority}:${candidate.variant}:${candidate.originalName.toLowerCase()}`; } +/** Keeps scanned source column names faithful to filenames when exports use src/source/ori. */ +function sourceVariantLabel(variant: string) { + const normalized = variant.toLowerCase(); + if (normalized === "src") { + return "Src"; + } + if (normalized === "source") { + return "Source"; + } + if (normalized === "ori") { + return "Ori"; + } + if (normalized === "origin") { + return "Origin"; + } + return "Before"; +} + function assetPlan(kind: WebUploadAssetPlan["kind"], candidate: SourceCandidate): WebUploadAssetPlan { const label = kind === "before" - ? "Before" + ? sourceVariantLabel(candidate.variant) : kind === "after" - ? "After" + ? variantLabel(candidate.variant) || "After" : kind === "heatmap" ? "Heatmap" : variantLabel(candidate.variant) || "Misc"; @@ -401,10 +443,12 @@ function formatCandidateFrameTitle( structuredEpisodeWidth, "0", ); - return `${episode}-${candidate.frameNumber}`; + const volume = volumeLabel(candidate.rootHint); + return volume ? `${volume}-${candidate.episode}-${candidate.frameNumber}` : `${episode}-${candidate.frameNumber}`; } - return candidate.title; + const volume = volumeLabel(candidate.rootHint); + return volume ? `${volume}-${candidate.title}` : candidate.title; } function structuredEpisodeWidth(candidates: SourceCandidate[]) { @@ -562,6 +606,12 @@ function parseEntries(entries: BrowserUploadFile[], variantOverride?: string) { return { candidates, ignored }; } +function defaultHeatmapReferenceLabel(frames: WebUploadFramePlan[]) { + // Default heatmap generation must follow the selected primary comparison column; otherwise + // src/rip-only imports keep a stale "After" reference and fail during asset generation. + return frames[0]?.after.label ?? "After"; +} + function buildFlatPlan(sourceRootName: string, entries: BrowserUploadFile[], ignoredFiles: IgnoredUploadFile[]): WebUploadPlan { const parsed = parseEntries(entries); const grouped = new Map<string, SourceCandidate[]>(); @@ -601,7 +651,7 @@ function buildFlatPlan(sourceRootName: string, entries: BrowserUploadFile[], ign suggestedGroupSlug: identity.slug, suggestedGroupTitle: identity.title, frames, - heatmapReferenceLabel: "After", + heatmapReferenceLabel: defaultHeatmapReferenceLabel(frames), ignoredFiles: [...ignoredFiles, ...parsed.ignored], issues, }; @@ -689,7 +739,7 @@ function buildNestedPlan(sourceRootName: string, entries: BrowserUploadFile[], i suggestedGroupSlug: identity.slug, suggestedGroupTitle: identity.title, frames, - heatmapReferenceLabel: "After", + heatmapReferenceLabel: defaultHeatmapReferenceLabel(frames), ignoredFiles: ignored, issues, }; 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..27d322b 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, @@ -82,6 +83,7 @@ interface PairingPreviewPanelProps { hasBlockingIssues: boolean; onExpandedFrameChange: (frameId: string | null) => void; onHeatmapReferenceChange: (label: string) => void; + onFallbackFrameTitles: () => void; onRenameColumn: (column: UploadPlanImageColumn, nextLabel: string) => void; onReorder: (activeFrameId: string, overFrameId: string | null) => void; } @@ -553,6 +555,7 @@ export function PairingPreviewPanel({ hasBlockingIssues, onExpandedFrameChange, onHeatmapReferenceChange, + onFallbackFrameTitles, onRenameColumn, onReorder, }: PairingPreviewPanelProps) { @@ -638,6 +641,15 @@ export function PairingPreviewPanel({ <Typography variant="h6">配对预览</Typography> {planView ? ( <Stack direction="row" alignItems="center" spacing={1} flexWrap="wrap" useFlexGap> + <Button + size="small" + variant="outlined" + onClick={onFallbackFrameTitles} + disabled={!canReorder} + sx={{ height: webUploadSizes.compactControlHeight, borderRadius: webUploadRadii.control }} + > + 使用完整 m2ts-帧号 + </Button> {planView.heatmapReferenceOptions.length > 1 ? ( <FormControl size="small" variant="outlined"> <Select 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..658fa27 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 @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { buildPlanView, compactUploadFilename, + fallbackUploadPlanFrameTitles, frameIdForFrame, getUploadPlanHeatmapReferenceOptions, renameUploadPlanAssetLabel, @@ -210,6 +211,23 @@ describe("web upload view model", () => { expect(setUploadPlanHeatmapReference(nextPlan!, "Deband")).toBeNull(); }); + + it("can fall back frame titles to full m2ts filename and frame number", () => { + const originalPlan = plan(); + originalPlan.frames[0] = { + ...originalPlan.frames[0], + title: "BOX1-00006-1917", + before: asset( + "before", + "30_ULTRAMAN_DYNA_BD_BOX_1_00006 - 01917 - src.png", + ), + }; + + expect(fallbackUploadPlanFrameTitles(originalPlan).frames[0].title).toBe( + "30_ULTRAMAN_DYNA_BD_BOX_1_00006 - 01917", + ); + }); + it("compacts long upload filenames by preserving the head and tail", () => { expect(compactUploadFilename("short.png")).toBe("short.png"); expect(compactUploadFilename("24_WATANARE_ANIME_VOL1_00000.gen.vpy-27240-output.png")).toBe( 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..61bb918 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 @@ -66,6 +66,34 @@ export function compactUploadFilename(path: string, maxLength = 38) { return `${fileName.slice(0, headLength)}${ellipsis}${fileName.slice(-tailLength)}`; } +function stemFromPath(path: string) { + const fileName = path.split("/").filter(Boolean).at(-1) ?? path; + const dotIndex = fileName.lastIndexOf("."); + return dotIndex === -1 ? fileName : fileName.slice(0, dotIndex); +} + +function fullFrameTitleFromSourcePath(path: string) { + const pathStem = stemFromPath(path); + const match = /^(?<prefix>.+?)[_\-. ]+(?<frame>\d+)(?:[_\-. ]+[^_\-. ]+)?$/.exec(pathStem); + if (!match?.groups) { + return pathStem; + } + + return `${match.groups.prefix} - ${match.groups.frame}`; +} + +export function fallbackUploadPlanFrameTitles(plan: WebUploadPlan) { + return { + ...plan, + frames: plan.frames.map((frame) => ({ + ...frame, + // Manual fallback is intentionally explicit because automatic VOL/m2ts shortening can still + // collide on mixed-disc work folders where operators need the full source identity visible. + title: fullFrameTitleFromSourcePath(frame.before.source.relativePath), + })), + }; +} + function frameIssueState(frame: WebUploadFramePlan, issues: WebUploadIssue[]) { const framePaths = new Set([ frame.before.source.relativePath, 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..d40d931 100644 --- a/apps/internal-site/components/web-uploader/web-upload-workbench.tsx +++ b/apps/internal-site/components/web-uploader/web-upload-workbench.tsx @@ -57,6 +57,7 @@ import type { } from "./web-upload-types"; import { buildPlanView, + fallbackUploadPlanFrameTitles, renameUploadPlanAssetLabel, reorderUploadPlan, setUploadPlanHeatmapReference, @@ -815,6 +816,15 @@ export function WebUploadWorkbench({ applyPlanUpdate(renameUploadPlanAssetLabel(plan, column, nextLabel)); } + function fallbackPairingFrameTitles() { + const plan = planRef.current; + if (!plan || snapshot.stage !== "scanned") { + return; + } + + applyPlanUpdate(fallbackUploadPlanFrameTitles(plan)); + } + function changeHeatmapReference(nextLabel: string) { const plan = planRef.current; if (!plan || snapshot.stage !== "scanned") { @@ -1085,6 +1095,7 @@ export function WebUploadWorkbench({ hasBlockingIssues={hasBlockingIssues} onExpandedFrameChange={setExpandedFrameId} onHeatmapReferenceChange={changeHeatmapReference} + onFallbackFrameTitles={fallbackPairingFrameTitles} onRenameColumn={renamePairingColumn} onReorder={reorderPairingRows} />