Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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...",
Expand All @@ -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,
Expand Down
67 changes: 63 additions & 4 deletions apps/internal-site/components/web-uploader/source-scanner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")],
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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"]);
Expand All @@ -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"]);
Expand All @@ -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",
);
Expand All @@ -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(
[
Expand Down
74 changes: 62 additions & 12 deletions apps/internal-site/components/web-uploader/source-scanner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -39,6 +42,9 @@ const MATCH_KEY_VARIANTS = [
"out",
"output",
"rip",
"flt",
"filter",
"filtered",
"misc",
"heatmap",
"nodeband",
Expand All @@ -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 = /(?<prefix>.+?)[_\-.](?<frame>\d+)(?:[_\-.](?<variant>[^_\-.]+))?$/;
const FILENAME_RE = /(?<prefix>.+?)[_\-. ]+(?<frame>\d+)(?:[_\-. ]+(?<variant>[^_\-. ]+))?$/;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Strip flt when grouping parsed flat variants

With the new space-tolerant flat parser, names like clip - 001 - src.png, clip - 001 - rip.png, and clip - 001 - flt.png are accepted, but matchKeyForName only strips variants listed in MATCH_KEY_VARIANTS; since flt is not listed, the flt candidate keeps the key clip-001-flt while src/rip use clip-001, producing a separate before-count error instead of a misc asset. Add newly supported explicit comparison variants such as flt to the match-key suffix list.

Useful? React with 👍 / 👎.

const FALLBACK_FILENAME_RE = /^(?<frame>\d+)(?<variant>[A-Za-z][A-Za-z0-9]*)$/;
const STRUCTURED_SOURCE_FILENAME_RE =
/^(?:(?<fps>\d{2})_)?(?<title>.+)_(?<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("|")}))+$`,
Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -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,
Expand All @@ -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}`;
Expand All @@ -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,
Expand Down Expand Up @@ -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()}`;
}

Expand All @@ -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"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep heatmap reference aligned with primary after label

When the only comparison file is an explicit rip variant (for example ...-src.png plus ...-rip.png), this now labels the primary after asset Rip, but both scan paths still initialize heatmapReferenceLabel to "After". ensureGeneratedFrames passes that stale label into generateUploadFrames, which calls heatmapAfterAsset and throws because no asset is labeled After; with only one comparison option the selector is hidden, so the upload cannot proceed unless the operator guesses to rename the column. Initialize the heatmap reference from the selected after label when preserving variant labels.

Useful? React with 👍 / 👎.

: kind === "heatmap"
? "Heatmap"
: variantLabel(candidate.variant) || "Misc";
Expand Down Expand Up @@ -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}`;
Comment on lines +446 to +447
}

return candidate.title;
const volume = volumeLabel(candidate.rootHint);
return volume ? `${volume}-${candidate.title}` : candidate.title;
}

function structuredEpisodeWidth(candidates: SourceCandidate[]) {
Expand Down Expand Up @@ -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[]>();
Expand Down Expand Up @@ -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,
};
Expand Down Expand Up @@ -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,
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
import {
Alert,
Box,
Button,
Chip,
Collapse,
FormControl,
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -553,6 +555,7 @@ export function PairingPreviewPanel({
hasBlockingIssues,
onExpandedFrameChange,
onHeatmapReferenceChange,
onFallbackFrameTitles,
onRenameColumn,
onReorder,
}: PairingPreviewPanelProps) {
Expand Down Expand Up @@ -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
Expand Down
Loading