From 333d39944a2a666842929086a37329788eaa981b Mon Sep 17 00:00:00 2001 From: CircleCrop <54088075+CircleCrop@users.noreply.github.com> Date: Mon, 13 Jul 2026 23:15:00 +0800 Subject: [PATCH] refactor: share upload filename parser --- .../web-uploader/filename-parser.test.ts | 21 ++++++++++++ .../web-uploader/filename-parser.ts | 34 +++++++++++++++++++ .../web-uploader/source-scanner.test.ts | 25 ++++++++++++++ .../components/web-uploader/source-scanner.ts | 13 ++++--- .../web-upload-view-model.test.ts | 8 +++++ .../web-uploader/web-upload-view-model.ts | 13 +++++++ 6 files changed, 107 insertions(+), 7 deletions(-) create mode 100644 apps/internal-site/components/web-uploader/filename-parser.test.ts create mode 100644 apps/internal-site/components/web-uploader/filename-parser.ts diff --git a/apps/internal-site/components/web-uploader/filename-parser.test.ts b/apps/internal-site/components/web-uploader/filename-parser.test.ts new file mode 100644 index 0000000..cde3aad --- /dev/null +++ b/apps/internal-site/components/web-uploader/filename-parser.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; +import { parseUploadFilenameStem } from "./filename-parser"; + +describe("parseUploadFilenameStem", () => { + it.each([ + ["clip - 0001 - before", { prefix: "clip", frame: "0001", variant: "before" }], + ["clip-0002-after", { prefix: "clip", frame: "0002", variant: "after" }], + ["clip_0003_src", { prefix: "clip", frame: "0003", variant: "src" }], + ["clip.0004.output", { prefix: "clip", frame: "0004", variant: "output" }], + ])("parses %s", (stem, expected) => { + expect(parseUploadFilenameStem(stem)).toEqual(expected); + }); + + it("defaults the variant to null when only prefix and frame are present", () => { + expect(parseUploadFilenameStem("clip_0005")).toEqual({ + prefix: "clip", + frame: "0005", + variant: null, + }); + }); +}); diff --git a/apps/internal-site/components/web-uploader/filename-parser.ts b/apps/internal-site/components/web-uploader/filename-parser.ts new file mode 100644 index 0000000..337d69b --- /dev/null +++ b/apps/internal-site/components/web-uploader/filename-parser.ts @@ -0,0 +1,34 @@ +export interface UploadFilenameParts { + prefix: string; + frame: string; + variant: string | null; +} + +const FILENAME_SEPARATOR_RE = String.raw`(?:\s+-\s+|[_\-.])`; +const UPLOAD_FILENAME_RE = new RegExp( + String.raw`^(?.+?)${FILENAME_SEPARATOR_RE}(?\d+)(?:${FILENAME_SEPARATOR_RE}(?[^_\-.]+?))?$`, +); + +/** + * Parses the shared flat upload filename shape so scanner grouping and UI labels do not drift. + * The spaced-dash form is intentionally strict (`name - 001`) to avoid treating prose hyphens as separators. + */ +export function parseUploadFilenameStem(stem: string): UploadFilenameParts | null { + const match = UPLOAD_FILENAME_RE.exec(stem.trim()); + if (!match?.groups) { + return null; + } + + const prefix = match.groups.prefix.trim(); + const frame = match.groups.frame.trim(); + const variant = match.groups.variant?.trim() ?? null; + if (!prefix || !frame || variant === "") { + return null; + } + + return { + prefix, + frame, + variant, + }; +} 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..eb07c7d 100644 --- a/apps/internal-site/components/web-uploader/source-scanner.test.ts +++ b/apps/internal-site/components/web-uploader/source-scanner.test.ts @@ -30,6 +30,31 @@ describe("scanBrowserUploadFiles", () => { expect(plan.issues).toEqual([]); }); + it("pairs flat files across supported filename separators", () => { + const plan = scanBrowserUploadFiles( + [ + image("sample/clip - 001 - before.png"), + image("sample/clip - 001 - after.png"), + image("sample/clip-002-before.png"), + image("sample/clip-002-after.png"), + image("sample/clip_003_before.png"), + image("sample/clip_003_after.png"), + image("sample/clip.004.before.png"), + image("sample/clip.004.after.png"), + ], + "sample", + ); + + expect(plan.frames).toHaveLength(4); + expect(plan.frames.map((frame) => frame.title)).toEqual([ + "00_00_1", + "00_00_2", + "00_00_3", + "00_00_4", + ]); + expect(plan.issues).toEqual([]); + }); + it("keeps same-number flat frames separate when their prefixes differ", () => { 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..1ff611d 100644 --- a/apps/internal-site/components/web-uploader/source-scanner.ts +++ b/apps/internal-site/components/web-uploader/source-scanner.ts @@ -7,6 +7,7 @@ import type { WebUploadPlan, } from "./web-upload-types"; import { cjkKebabCase } from "@magic-compare/shared-utils"; +import { parseUploadFilenameStem } from "./filename-parser"; const SUPPORTED_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".webp", ".avif", ".svg"]); const SOURCE_VARIANTS = new Set(["src", "source", "ori", "origin", "before"]); @@ -53,7 +54,6 @@ 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 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; @@ -274,12 +274,11 @@ function parseCandidate(entry: BrowserUploadFile, variantOverride?: string): Sou }; } - const match = FILENAME_RE.exec(pathStem); - if (match?.groups) { - 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 parsedFilename = parseUploadFilenameStem(pathStem); + if (parsedFilename) { + const prefix = parsedFilename.prefix; + const frameNumber = Number(parsedFilename.frame.replace(/^0+/, "") || "0"); + const variant = (variantOverride ?? parsedFilename.variant ?? "output").trim().toLowerCase(); const fps = extractFps(pathStem); const episode = extractEpisode(prefix); const title = `${fps}_${episode}_${frameNumber}`; 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..2c937e7 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, + fullFrameTitleFromSourcePath, getUploadPlanHeatmapReferenceOptions, renameUploadPlanAssetLabel, reorderUploadPlan, @@ -210,6 +211,13 @@ describe("web upload view model", () => { expect(setUploadPlanHeatmapReference(nextPlan!, "Deband")).toBeNull(); }); + it("builds full frame titles from the shared filename parser", () => { + expect(fullFrameTitleFromSourcePath("before/clip - 0001 - source.png")).toBe("clip - 0001"); + expect(fullFrameTitleFromSourcePath("before/clip-0002-source.png")).toBe("clip - 0002"); + expect(fullFrameTitleFromSourcePath("before/clip_0003_source.png")).toBe("clip - 0003"); + expect(fullFrameTitleFromSourcePath("before/clip.0004.source.png")).toBe("clip - 0004"); + }); + 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..7b056ae 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,3 +1,4 @@ +import { parseUploadFilenameStem } from "./filename-parser"; import type { WebUploadFramePlan, WebUploadIssue, @@ -47,6 +48,18 @@ function stableHash(value: string) { return (hash >>> 0).toString(36); } +function stemFromSourcePath(path: string) { + const fileName = path.split("/").filter(Boolean).at(-1) ?? path; + const dotIndex = fileName.lastIndexOf("."); + return dotIndex === -1 ? fileName : fileName.slice(0, dotIndex); +} + +export function fullFrameTitleFromSourcePath(path: string) { + const pathStem = stemFromSourcePath(path); + const parsedFilename = parseUploadFilenameStem(pathStem); + return parsedFilename ? `${parsedFilename.prefix} - ${parsedFilename.frame}` : pathStem; +} + export function frameIdForFrame(frame: WebUploadFramePlan) { return `frame-${stableHash( `${frame.title}|${frame.before.source.relativePath}|${frame.after.source.relativePath}`,