diff --git a/src/features/resources/batchUpload.js b/src/features/resources/batchUpload.js
index b59f61e..69f6320 100644
--- a/src/features/resources/batchUpload.js
+++ b/src/features/resources/batchUpload.js
@@ -1,4 +1,4 @@
-import { profileCardMetadataJSON } from "@/features/resources/profileCardLayout.js";
+import { resourceMetadataJSON } from "@/features/resources/resourceMetadata.js";
export const resourceBatchUploadSize = 10;
@@ -109,7 +109,7 @@ export function resourcePlanToPayload(item) {
badgeKind: item.resourceType === "badge" ? "normal" : undefined,
coinPrice,
managerGrantEnabled: true,
- metadataJson: item.resourceType === "profile_card" ? profileCardMetadataJSON(item.metadataJson) : undefined,
+ metadataJson: resourceMetadataJSON(item.resourceType, item.metadataJson),
name: item.name,
previewUrl: item.coverUrl,
priceType: coinPrice > 0 ? "coin" : "free",
diff --git a/src/features/resources/batchUpload.test.js b/src/features/resources/batchUpload.test.js
index 289931d..ec97624 100644
--- a/src/features/resources/batchUpload.test.js
+++ b/src/features/resources/batchUpload.test.js
@@ -88,6 +88,66 @@ test("silently ignores invalid and unpaired material", () => {
expect(plan.resources[0]).toMatchObject({ name: "星光", resourceCode: "星光", resourceType: "avatar_frame" });
});
+test("keeps mp4 alpha layout metadata for gift and vehicle batch payloads", () => {
+ const giftPayload = resourcePlanToPayload({
+ animationUrl: "https://media.haiyihy.com/resource/gift.mp4",
+ coverUrl: "cover",
+ metadataJson: JSON.stringify({ mp4_alpha_layout: leftAlphaRightRgbLayout() }),
+ name: "Love",
+ price: 30000,
+ resourceCode: "love",
+ resourceType: "gift",
+ });
+ const vehiclePayload = resourcePlanToPayload({
+ animationUrl: "https://media.haiyihy.com/resource/vehicle.mp4",
+ coverUrl: "cover",
+ metadataJson: JSON.stringify({ mp4_alpha_layout: leftAlphaRightRgbLayout() }),
+ name: "Knight",
+ price: 49000,
+ resourceCode: "knight",
+ resourceType: "vehicle",
+ });
+
+ expect(JSON.parse(giftPayload.metadataJson)).toMatchObject({
+ animation_format: "mp4",
+ mp4_alpha_layout: {
+ alpha_frame: [0, 0, 750, 1334],
+ alpha_layout: "alpha_left_rgb_right",
+ rgb_frame: [750, 0, 750, 1334],
+ },
+ });
+ expect(JSON.parse(vehiclePayload.metadataJson).mp4_alpha_layout.alpha_layout).toBe("alpha_left_rgb_right");
+});
+
+test("keeps profile card layout while adding mp4 alpha layout", () => {
+ const payload = resourcePlanToPayload({
+ animationUrl: "https://media.haiyihy.com/resource/profile-card.mp4",
+ coverUrl: "cover",
+ metadataJson: JSON.stringify({
+ mp4_alpha_layout: leftAlphaRightRgbLayout(),
+ profile_card_layout: {
+ color_content_width: 750,
+ content_bottom: 1666,
+ content_height: 1550,
+ content_height_ratio: 0.922619,
+ content_top: 117,
+ content_top_ratio: 0.069643,
+ detect_version: 1,
+ source_height: 1680,
+ source_width: 1136,
+ },
+ }),
+ name: "Night",
+ price: 0,
+ resourceCode: "night",
+ resourceType: "profile_card",
+ });
+ const metadata = JSON.parse(payload.metadataJson);
+
+ expect(metadata.profile_card_layout.content_top).toBe(117);
+ expect(metadata.mp4_alpha_layout.rgb_frame).toEqual([750, 0, 750, 1334]);
+});
+
test("translates chinese names to english resource codes with API fallback", async () => {
vi.spyOn(Math, "random")
.mockReturnValueOnce(0)
@@ -170,3 +230,15 @@ test("reports translate progress after suffix is added", async () => {
function file(name) {
return new File(["payload"], name, { type: "image/png" });
}
+
+function leftAlphaRightRgbLayout() {
+ return {
+ alpha_frame: [0, 0, 750, 1334],
+ alpha_layout: "alpha_left_rgb_right",
+ confirmed: true,
+ detect_version: 1,
+ rgb_frame: [750, 0, 750, 1334],
+ video_h: 1334,
+ video_w: 1500,
+ };
+}
diff --git a/src/features/resources/hooks/useResourcePages.js b/src/features/resources/hooks/useResourcePages.js
index a3030ce..c7f3ee2 100644
--- a/src/features/resources/hooks/useResourcePages.js
+++ b/src/features/resources/hooks/useResourcePages.js
@@ -40,7 +40,7 @@ import {
resourceShopSellableTypes,
} from "@/features/resources/constants.js";
import { useResourceAbilities } from "@/features/resources/permissions.js";
-import { profileCardMetadataJSON } from "@/features/resources/profileCardLayout.js";
+import { resourceMetadataJSON } from "@/features/resources/resourceMetadata.js";
import {
emojiPackFormSchema,
giftFormSchema,
@@ -73,7 +73,10 @@ const emptyResourceForm = (resource = {}) => ({
: "normal",
coinPrice: resource.coinPrice === 0 || resource.coinPrice ? String(resource.coinPrice) : "",
enabled: resource.status ? resource.status === "active" : true,
- levelTrack: resource.resourceType === "badge" ? resource.levelTrack || levelTrackFromMetadata(resource.metadataJson) || "" : "",
+ levelTrack:
+ resource.resourceType === "badge"
+ ? resource.levelTrack || levelTrackFromMetadata(resource.metadataJson) || ""
+ : "",
managerGrantEnabled:
resource.managerGrantEnabled === undefined || resource.managerGrantEnabled === null
? true
@@ -166,7 +169,11 @@ export function applyGiftPriceDefaults(form, coinPrice) {
export function cpRelationTypeFromPresentationJson(presentationJson) {
const presentation = parseGiftPresentationObject(presentationJson);
const relationType = cpPresentationRelationKeys
- .map((key) => String(presentation?.[key] || "").trim().toLowerCase())
+ .map((key) =>
+ String(presentation?.[key] || "")
+ .trim()
+ .toLowerCase(),
+ )
.find((value) => cpRelationTypeValues.has(value));
return relationType || "";
}
@@ -252,7 +259,9 @@ function normalizedCPRelationType(value) {
}
function validCPRelationType(value) {
- const relationType = String(value || "").trim().toLowerCase();
+ const relationType = String(value || "")
+ .trim()
+ .toLowerCase();
return cpRelationTypeValues.has(relationType) ? relationType : "";
}
@@ -1519,7 +1528,7 @@ function buildResourcePayload(form) {
coinPrice: form.priceType === "coin" ? Number(form.coinPrice) : 0,
levelTrack: resourceType === "badge" && form.badgeKind === "level" ? form.levelTrack : undefined,
managerGrantEnabled: Boolean(form.managerGrantEnabled),
- metadataJson: resourceType === "profile_card" ? profileCardMetadataJSON(form.metadataJson) : undefined,
+ metadataJson: resourceMetadataJSON(resourceType, form.metadataJson),
name: form.name.trim(),
previewUrl: form.previewUrl.trim(),
priceType: form.priceType,
diff --git a/src/features/resources/mp4AlphaLayout.js b/src/features/resources/mp4AlphaLayout.js
new file mode 100644
index 0000000..bfa95c1
--- /dev/null
+++ b/src/features/resources/mp4AlphaLayout.js
@@ -0,0 +1,521 @@
+export const mp4AlphaLayoutKey = "mp4_alpha_layout";
+export const mp4AlphaDetectVersion = 1;
+
+export const mp4AlphaLayoutOptions = [
+ ["normal", "普通 MP4"],
+ ["alpha_left_rgb_right", "左 Alpha / 右 RGB"],
+ ["rgb_left_alpha_right", "左 RGB / 右 Alpha"],
+ ["alpha_top_rgb_bottom", "上 Alpha / 下 RGB"],
+ ["rgb_top_alpha_bottom", "上 RGB / 下 Alpha"],
+ ["custom", "自定义"],
+];
+
+const transparentLayoutKinds = new Set([
+ "alpha_left_rgb_right",
+ "rgb_left_alpha_right",
+ "alpha_top_rgb_bottom",
+ "rgb_top_alpha_bottom",
+ "custom",
+]);
+const videoFrameStepSeconds = 1 / 24;
+const maxSeekFrameCount = 360;
+
+export function isMp4File(file) {
+ if (!file?.name) {
+ return false;
+ }
+ const filename = String(file.name).toLowerCase();
+ const type = String(file.type || "").toLowerCase();
+ return filename.endsWith(".mp4") || type === "video/mp4";
+}
+
+export function isMp4Source(source = "") {
+ const normalized = String(source || "").trim();
+ if (!normalized) {
+ return false;
+ }
+ try {
+ const url = new URL(normalized, "https://admin.local");
+ return url.pathname.toLowerCase().endsWith(".mp4");
+ } catch {
+ return normalized.toLowerCase().split("?")[0].endsWith(".mp4");
+ }
+}
+
+export function createMp4AlphaLayout({
+ alphaLayout = "normal",
+ videoW,
+ videoH,
+ rgbFrame,
+ alphaFrame = null,
+ confirmed = false,
+ confidence = 0,
+ detectVersion = mp4AlphaDetectVersion,
+} = {}) {
+ const videoWidth = positiveInteger(videoW);
+ const videoHeight = positiveInteger(videoH);
+ if (videoWidth <= 0 || videoHeight <= 0) {
+ return null;
+ }
+ const layoutKind = normalizeAlphaLayout(alphaLayout);
+ const defaultFrames = framePairForLayout(layoutKind, videoWidth, videoHeight);
+ const normalizedRgbFrame = sanitizeFrame(rgbFrame, videoWidth, videoHeight) || defaultFrames.rgbFrame;
+ const normalizedAlphaFrame =
+ layoutKind === "normal" ? null : sanitizeFrame(alphaFrame, videoWidth, videoHeight) || defaultFrames.alphaFrame;
+
+ if (!normalizedRgbFrame || (layoutKind !== "normal" && !normalizedAlphaFrame)) {
+ return null;
+ }
+
+ return {
+ alpha_layout: layoutKind,
+ video_w: videoWidth,
+ video_h: videoHeight,
+ rgb_frame: normalizedRgbFrame,
+ alpha_frame: normalizedAlphaFrame,
+ confirmed: Boolean(confirmed),
+ confidence: roundConfidence(confidence),
+ detect_version: positiveInteger(detectVersion) || mp4AlphaDetectVersion,
+ };
+}
+
+export function mp4AlphaLayoutFromMetadata(metadataJson) {
+ const metadata = parseMetadataObject(metadataJson);
+ return sanitizeMp4AlphaLayout(metadata?.[mp4AlphaLayoutKey]);
+}
+
+export function mergeMp4AlphaLayoutMetadata(metadataJson, layout) {
+ const sanitizedLayout = sanitizeMp4AlphaLayout(layout);
+ if (!sanitizedLayout) {
+ return normalizeMetadataJSON(metadataJson);
+ }
+ return stringifyMetadata({
+ ...parseMetadataObject(metadataJson),
+ animation_format: "mp4",
+ [mp4AlphaLayoutKey]: sanitizedLayout,
+ });
+}
+
+export function removeMp4AlphaLayoutMetadata(metadataJson) {
+ const metadata = parseMetadataObject(metadataJson);
+ delete metadata[mp4AlphaLayoutKey];
+ if (metadata.animation_format === "mp4") {
+ delete metadata.animation_format;
+ }
+ return stringifyMetadata(metadata);
+}
+
+export function confirmMp4AlphaLayoutMetadata(metadataJson) {
+ const layout = mp4AlphaLayoutFromMetadata(metadataJson);
+ if (!layout) {
+ return normalizeMetadataJSON(metadataJson);
+ }
+ return mergeMp4AlphaLayoutMetadata(metadataJson, { ...layout, confirmed: true });
+}
+
+export function updateMp4AlphaLayoutKind(metadataJson, alphaLayout) {
+ const layout = mp4AlphaLayoutFromMetadata(metadataJson);
+ if (!layout) {
+ return normalizeMetadataJSON(metadataJson);
+ }
+ const nextLayout = createMp4AlphaLayout({
+ alphaLayout,
+ alphaFrame: layout.alpha_frame,
+ confirmed: false,
+ confidence: layout.confidence,
+ detectVersion: layout.detect_version,
+ rgbFrame: layout.rgb_frame,
+ videoH: layout.video_h,
+ videoW: layout.video_w,
+ });
+ return nextLayout ? mergeMp4AlphaLayoutMetadata(metadataJson, nextLayout) : normalizeMetadataJSON(metadataJson);
+}
+
+export function updateMp4AlphaLayoutFrame(metadataJson, key, value) {
+ const layout = mp4AlphaLayoutFromMetadata(metadataJson);
+ if (!layout) {
+ return normalizeMetadataJSON(metadataJson);
+ }
+ const frame = parseFrameText(value);
+ const nextLayout = createMp4AlphaLayout({
+ alphaLayout: layout.alpha_layout,
+ rgbFrame: key === "rgb_frame" ? frame : layout.rgb_frame,
+ alphaFrame: key === "alpha_frame" ? frame : layout.alpha_frame,
+ confirmed: false,
+ confidence: layout.confidence,
+ detectVersion: layout.detect_version,
+ videoH: layout.video_h,
+ videoW: layout.video_w,
+ });
+ return nextLayout ? mergeMp4AlphaLayoutMetadata(metadataJson, nextLayout) : normalizeMetadataJSON(metadataJson);
+}
+
+export function hasUnconfirmedMp4Layout(metadataJson) {
+ const layout = mp4AlphaLayoutFromMetadata(metadataJson);
+ return Boolean(layout && !layout.confirmed);
+}
+
+export function mp4LayoutLabel(layout) {
+ const kind = typeof layout === "string" ? layout : layout?.alpha_layout;
+ return mp4AlphaLayoutOptions.find(([value]) => value === kind)?.[1] || "未解析";
+}
+
+export function frameText(frame) {
+ return Array.isArray(frame) ? frame.join(",") : "";
+}
+
+export async function detectMp4AlphaLayout(file) {
+ if (!isMp4File(file)) {
+ throw new Error("请选择 MP4 动效素材");
+ }
+ const frames = await readVideoFrames(file);
+ if (!frames.length) {
+ const info = await readMp4VideoInfo(file);
+ return createMp4AlphaLayout({
+ alphaLayout: "normal",
+ videoW: info.width,
+ videoH: info.height,
+ confirmed: false,
+ });
+ }
+ return detectMp4AlphaLayoutFromFrames(frames);
+}
+
+export async function mp4FileFromSource(source) {
+ const normalizedSource = String(source || "").trim();
+ if (!isMp4Source(normalizedSource)) {
+ throw new Error("动态资源不是 MP4");
+ }
+ let response;
+ try {
+ response = await fetch(normalizedSource);
+ } catch {
+ throw new Error("MP4 素材下载失败,请确认素材 URL 可访问");
+ }
+ if (!response.ok) {
+ throw new Error("MP4 素材下载失败,请确认素材 URL 可访问");
+ }
+ const blob = await response.blob();
+ return new File([blob], filenameFromSource(normalizedSource), { type: blob.type || "video/mp4" });
+}
+
+export async function readMp4VideoInfo(file) {
+ const video = await loadVideo(file);
+ const info = {
+ duration: Number.isFinite(video.duration) ? video.duration : 0,
+ height: video.videoHeight || 0,
+ width: video.videoWidth || 0,
+ };
+ URL.revokeObjectURL(video.src);
+ video.removeAttribute("src");
+ video.load();
+ return info;
+}
+
+export function detectMp4AlphaLayoutFromFrames(frames) {
+ const validFrames = (frames || []).filter((frame) => frame?.data && frame.width > 0 && frame.height > 0);
+ if (!validFrames.length) {
+ return null;
+ }
+ const { width, height } = validFrames[0];
+ const candidates = candidateLayoutsForVideo(width, height).map((alphaLayout) => {
+ const pair = framePairForLayout(alphaLayout, width, height);
+ return {
+ alphaLayout,
+ ...pair,
+ score: scoreCandidate(validFrames, pair),
+ };
+ });
+ candidates.sort((left, right) => right.score - left.score);
+ const selected = candidates[0];
+ if (!selected || selected.score < 0.42) {
+ return createMp4AlphaLayout({
+ alphaLayout: "normal",
+ videoW: width,
+ videoH: height,
+ confirmed: false,
+ confidence: selected?.score || 0,
+ });
+ }
+ return createMp4AlphaLayout({
+ alphaLayout: selected.alphaLayout,
+ videoW: width,
+ videoH: height,
+ rgbFrame: selected.rgbFrame,
+ alphaFrame: selected.alphaFrame,
+ confirmed: false,
+ confidence: selected.score,
+ });
+}
+
+function candidateLayoutsForVideo(width, height) {
+ const aspectRatio = width / height;
+ const layouts = [];
+ if (aspectRatio >= 0.85) {
+ layouts.push("alpha_left_rgb_right", "rgb_left_alpha_right");
+ }
+ if (aspectRatio <= 0.52) {
+ layouts.push("alpha_top_rgb_bottom", "rgb_top_alpha_bottom");
+ }
+ return layouts;
+}
+
+export function sanitizeMp4AlphaLayout(layout) {
+ if (!layout || typeof layout !== "object") {
+ return null;
+ }
+ return createMp4AlphaLayout({
+ alphaLayout: layout.alpha_layout ?? layout.alphaLayout,
+ videoW: layout.video_w ?? layout.videoW,
+ videoH: layout.video_h ?? layout.videoH,
+ rgbFrame: layout.rgb_frame ?? layout.rgbFrame,
+ alphaFrame: layout.alpha_frame ?? layout.alphaFrame,
+ confirmed: layout.confirmed,
+ confidence: layout.confidence,
+ detectVersion: layout.detect_version ?? layout.detectVersion,
+ });
+}
+
+function normalizeAlphaLayout(value) {
+ const normalized = String(value || "")
+ .trim()
+ .toLowerCase();
+ if (normalized === "normal" || transparentLayoutKinds.has(normalized)) {
+ return normalized;
+ }
+ return "normal";
+}
+
+function framePairForLayout(alphaLayout, width, height) {
+ if (alphaLayout === "alpha_left_rgb_right") {
+ const half = Math.floor(width / 2);
+ return {
+ alphaFrame: [0, 0, half, height],
+ rgbFrame: [half, 0, width - half, height],
+ };
+ }
+ if (alphaLayout === "rgb_left_alpha_right") {
+ const half = Math.floor(width / 2);
+ return {
+ alphaFrame: [half, 0, width - half, height],
+ rgbFrame: [0, 0, half, height],
+ };
+ }
+ if (alphaLayout === "alpha_top_rgb_bottom") {
+ const half = Math.floor(height / 2);
+ return {
+ alphaFrame: [0, 0, width, half],
+ rgbFrame: [0, half, width, height - half],
+ };
+ }
+ if (alphaLayout === "rgb_top_alpha_bottom") {
+ const half = Math.floor(height / 2);
+ return {
+ alphaFrame: [0, half, width, height - half],
+ rgbFrame: [0, 0, width, half],
+ };
+ }
+ return {
+ alphaFrame: null,
+ rgbFrame: [0, 0, width, height],
+ };
+}
+
+function sanitizeFrame(value, videoWidth, videoHeight) {
+ const values = Array.isArray(value) ? value : parseFrameText(value);
+ if (!Array.isArray(values) || values.length < 4) {
+ return null;
+ }
+ const frame = values.slice(0, 4).map(positiveIntegerOrZero);
+ if (frame[0] < 0 || frame[1] < 0) {
+ return null;
+ }
+ if (frame[2] <= 0 || frame[3] <= 0) {
+ return null;
+ }
+ if (frame[0] + frame[2] > videoWidth || frame[1] + frame[3] > videoHeight) {
+ return null;
+ }
+ return frame;
+}
+
+function parseFrameText(value) {
+ if (Array.isArray(value)) {
+ return value;
+ }
+ const text = String(value || "").trim();
+ if (!text) {
+ return null;
+ }
+ const values = text.split(",").map((item) => Number(item.trim()));
+ return values.length >= 4 && values.every(Number.isFinite) ? values.slice(0, 4) : null;
+}
+
+function filenameFromSource(source) {
+ try {
+ const url = new URL(source, "https://admin.local");
+ const pathname = decodeURIComponent(url.pathname || "");
+ return pathname.split("/").filter(Boolean).pop() || "animation.mp4";
+ } catch {
+ const clean = source.split("?")[0].split("#")[0];
+ return decodeURIComponent(clean.split("/").filter(Boolean).pop() || "animation.mp4");
+ }
+}
+
+function parseMetadataObject(metadataJson) {
+ const normalized = String(metadataJson || "").trim();
+ if (!normalized) {
+ return {};
+ }
+ try {
+ const parsed = JSON.parse(normalized);
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
+ } catch {
+ return {};
+ }
+}
+
+function normalizeMetadataJSON(metadataJson) {
+ return stringifyMetadata(parseMetadataObject(metadataJson));
+}
+
+function stringifyMetadata(metadata) {
+ const clean = {};
+ Object.entries(metadata || {}).forEach(([key, value]) => {
+ if (value !== undefined && value !== null && value !== "") {
+ clean[key] = value;
+ }
+ });
+ return Object.keys(clean).length ? JSON.stringify(clean) : "{}";
+}
+
+function positiveInteger(value) {
+ const number = Number(value);
+ return Number.isInteger(number) && number > 0 ? number : 0;
+}
+
+function positiveIntegerOrZero(value) {
+ const number = Number(value);
+ return Number.isInteger(number) && number >= 0 ? number : -1;
+}
+
+function roundConfidence(value) {
+ const number = Number(value);
+ if (!Number.isFinite(number) || number <= 0) {
+ return 0;
+ }
+ return Math.round(Math.min(number, 1) * 1000) / 1000;
+}
+
+async function readVideoFrames(file) {
+ const video = await loadVideo(file);
+ const canvas = document.createElement("canvas");
+ canvas.width = video.videoWidth;
+ canvas.height = video.videoHeight;
+ const context = canvas.getContext("2d", { willReadFrequently: true });
+ if (!context || canvas.width <= 0 || canvas.height <= 0) {
+ throw new Error("MP4 画面无法读取");
+ }
+ const duration = Number.isFinite(video.duration) && video.duration > 0 ? video.duration : 0;
+ const frameCount = Math.min(Math.max(Math.ceil(duration / videoFrameStepSeconds), 1), maxSeekFrameCount);
+ const frames = [];
+ for (let index = 0; index < frameCount; index += 1) {
+ const time = duration > 0 ? Math.min(duration - 0.001, index * videoFrameStepSeconds) : 0;
+ await seekVideo(video, Math.max(0, time));
+ context.drawImage(video, 0, 0, canvas.width, canvas.height);
+ frames.push({
+ data: context.getImageData(0, 0, canvas.width, canvas.height).data,
+ height: canvas.height,
+ width: canvas.width,
+ });
+ }
+ URL.revokeObjectURL(video.src);
+ video.removeAttribute("src");
+ video.load();
+ return frames;
+}
+
+function loadVideo(file) {
+ return new Promise((resolve, reject) => {
+ const video = document.createElement("video");
+ video.muted = true;
+ video.playsInline = true;
+ video.preload = "metadata";
+ video.src = URL.createObjectURL(file);
+ video.onloadedmetadata = () => resolve(video);
+ video.onerror = () => {
+ URL.revokeObjectURL(video.src);
+ reject(new Error("MP4 元数据读取失败"));
+ };
+ });
+}
+
+function seekVideo(video, time) {
+ return new Promise((resolve, reject) => {
+ const done = () => {
+ video.removeEventListener("seeked", done);
+ video.removeEventListener("error", fail);
+ resolve();
+ };
+ const fail = () => {
+ video.removeEventListener("seeked", done);
+ video.removeEventListener("error", fail);
+ reject(new Error("MP4 画面解析失败"));
+ };
+ video.addEventListener("seeked", done, { once: true });
+ video.addEventListener("error", fail, { once: true });
+ video.currentTime = time;
+ });
+}
+
+function scoreCandidate(frames, candidate) {
+ const scores = frames.map((frame) => scoreFrameCandidate(frame, candidate));
+ if (!scores.length) {
+ return 0;
+ }
+ const average = scores.reduce((sum, value) => sum + value, 0) / scores.length;
+ return Math.max(0, Math.min(1, average));
+}
+
+function scoreFrameCandidate(frame, candidate) {
+ const alpha = regionStats(frame, candidate.alphaFrame);
+ const rgb = regionStats(frame, candidate.rgbFrame);
+ const maskScore = alpha.lowChromaRatio * 0.45 + alpha.brightnessSpread * 0.35 + alpha.darkOrBrightRatio * 0.2;
+ const rgbScore = Math.max(0, 1 - rgb.lowChromaRatio) * 0.6 + rgb.brightnessSpread * 0.4;
+ return maskScore * 0.7 + rgbScore * 0.3;
+}
+
+function regionStats(frame, rect) {
+ const [left, top, width, height] = rect;
+ const xStep = Math.max(1, Math.floor(width / 90));
+ const yStep = Math.max(1, Math.floor(height / 90));
+ let count = 0;
+ let lowChroma = 0;
+ let darkOrBright = 0;
+ let minBrightness = 255;
+ let maxBrightness = 0;
+ for (let y = top; y < top + height; y += yStep) {
+ for (let x = left; x < left + width; x += xStep) {
+ const index = (y * frame.width + x) * 4;
+ const r = frame.data[index];
+ const g = frame.data[index + 1];
+ const b = frame.data[index + 2];
+ const brightness = (r + g + b) / 3;
+ const chroma = Math.max(r, g, b) - Math.min(r, g, b);
+ count += 1;
+ if (chroma <= 16) {
+ lowChroma += 1;
+ }
+ if (brightness <= 32 || brightness >= 224) {
+ darkOrBright += 1;
+ }
+ minBrightness = Math.min(minBrightness, brightness);
+ maxBrightness = Math.max(maxBrightness, brightness);
+ }
+ }
+ return {
+ brightnessSpread: count ? (maxBrightness - minBrightness) / 255 : 0,
+ darkOrBrightRatio: count ? darkOrBright / count : 0,
+ lowChromaRatio: count ? lowChroma / count : 0,
+ };
+}
diff --git a/src/features/resources/mp4AlphaLayout.test.js b/src/features/resources/mp4AlphaLayout.test.js
new file mode 100644
index 0000000..a415792
--- /dev/null
+++ b/src/features/resources/mp4AlphaLayout.test.js
@@ -0,0 +1,197 @@
+import { afterEach, describe, expect, test, vi } from "vitest";
+import {
+ confirmMp4AlphaLayoutMetadata,
+ createMp4AlphaLayout,
+ detectMp4AlphaLayoutFromFrames,
+ isMp4Source,
+ mp4AlphaLayoutFromMetadata,
+ mp4FileFromSource,
+ updateMp4AlphaLayoutFrame,
+ updateMp4AlphaLayoutKind,
+} from "./mp4AlphaLayout.js";
+
+afterEach(() => {
+ vi.unstubAllGlobals();
+});
+
+describe("mp4 alpha layout metadata", () => {
+ test("builds a confirmed normal layout for a full-frame MP4", () => {
+ const layout = createMp4AlphaLayout({
+ alphaLayout: "normal",
+ confirmed: true,
+ videoH: 1334,
+ videoW: 750,
+ });
+
+ expect(layout).toMatchObject({
+ alpha_frame: null,
+ alpha_layout: "normal",
+ confirmed: true,
+ rgb_frame: [0, 0, 750, 1334],
+ video_h: 1334,
+ video_w: 750,
+ });
+ });
+
+ test("builds left-alpha and right-rgb frames for a 1500x1334 MP4", () => {
+ const layout = createMp4AlphaLayout({
+ alphaLayout: "alpha_left_rgb_right",
+ confirmed: true,
+ videoH: 1334,
+ videoW: 1500,
+ });
+
+ expect(layout).toMatchObject({
+ alpha_frame: [0, 0, 750, 1334],
+ alpha_layout: "alpha_left_rgb_right",
+ rgb_frame: [750, 0, 750, 1334],
+ video_h: 1334,
+ video_w: 1500,
+ });
+ });
+
+ test("builds split frames for an uneven 1472x1328 MP4", () => {
+ const layout = createMp4AlphaLayout({
+ alphaLayout: "rgb_left_alpha_right",
+ confirmed: true,
+ videoH: 1328,
+ videoW: 1472,
+ });
+
+ expect(layout).toMatchObject({
+ alpha_frame: [736, 0, 736, 1328],
+ alpha_layout: "rgb_left_alpha_right",
+ rgb_frame: [0, 0, 736, 1328],
+ });
+ });
+
+ test("keeps custom rgb and alpha rectangles", () => {
+ const layout = createMp4AlphaLayout({
+ alphaFrame: [10, 20, 200, 300],
+ alphaLayout: "custom",
+ confirmed: true,
+ rgbFrame: [240, 20, 200, 300],
+ videoH: 812,
+ videoW: 750,
+ });
+
+ expect(layout).toMatchObject({
+ alpha_frame: [10, 20, 200, 300],
+ alpha_layout: "custom",
+ rgb_frame: [240, 20, 200, 300],
+ });
+ });
+
+ test("marks metadata unconfirmed when layout or frames are edited", () => {
+ const metadataJson = confirmMp4AlphaLayoutMetadata(
+ JSON.stringify({
+ animation_format: "mp4",
+ mp4_alpha_layout: createMp4AlphaLayout({
+ alphaLayout: "alpha_left_rgb_right",
+ confirmed: false,
+ videoH: 1334,
+ videoW: 1500,
+ }),
+ }),
+ );
+
+ expect(mp4AlphaLayoutFromMetadata(metadataJson)?.confirmed).toBe(true);
+ expect(mp4AlphaLayoutFromMetadata(updateMp4AlphaLayoutKind(metadataJson, "normal"))).toMatchObject({
+ alpha_layout: "normal",
+ confirmed: false,
+ });
+ expect(
+ mp4AlphaLayoutFromMetadata(updateMp4AlphaLayoutFrame(metadataJson, "rgb_frame", "760,0,700,1334")),
+ ).toMatchObject({
+ confirmed: false,
+ rgb_frame: [760, 0, 700, 1334],
+ });
+ });
+
+ test("detects and downloads mp4 source urls for row recalculation", async () => {
+ const fetchMock = vi.fn(async () => ({
+ blob: async () => new Blob(["mp4"], { type: "video/mp4" }),
+ ok: true,
+ }));
+ vi.stubGlobal("fetch", fetchMock);
+
+ expect(isMp4Source("https://cdn.example.test/assets/礼物_love_animation.mp4?token=1")).toBe(true);
+ expect(isMp4Source("https://cdn.example.test/assets/rose.svga")).toBe(false);
+
+ const file = await mp4FileFromSource("https://cdn.example.test/assets/%E7%A4%BC%E7%89%A9.mp4?token=1");
+
+ expect(fetchMock).toHaveBeenCalledWith("https://cdn.example.test/assets/%E7%A4%BC%E7%89%A9.mp4?token=1");
+ expect(file.name).toBe("礼物.mp4");
+ expect(file.type).toBe("video/mp4");
+ });
+
+ test("keeps a regular 750x1334 portrait MP4 as normal layout", () => {
+ const layout = detectMp4AlphaLayoutFromFrames([
+ buildSyntheticFrame(750, 1334, {
+ alphaFrame: [0, 0, 375, 1334],
+ rgbFrame: [375, 0, 375, 1334],
+ }),
+ ]);
+
+ expect(layout).toMatchObject({
+ alpha_frame: null,
+ alpha_layout: "normal",
+ rgb_frame: [0, 0, 750, 1334],
+ video_h: 1334,
+ video_w: 750,
+ });
+ });
+
+ test("detects a wide left-alpha right-rgb MP4 layout", () => {
+ const layout = detectMp4AlphaLayoutFromFrames([
+ buildSyntheticFrame(1500, 1334, {
+ alphaFrame: [0, 0, 750, 1334],
+ rgbFrame: [750, 0, 750, 1334],
+ }),
+ ]);
+
+ expect(layout).toMatchObject({
+ alpha_frame: [0, 0, 750, 1334],
+ alpha_layout: "alpha_left_rgb_right",
+ rgb_frame: [750, 0, 750, 1334],
+ video_h: 1334,
+ video_w: 1500,
+ });
+ });
+});
+
+function buildSyntheticFrame(width, height, { alphaFrame, rgbFrame }) {
+ const data = new Uint8ClampedArray(width * height * 4);
+ fillRect(data, width, [0, 0, width, height], [32, 32, 32]);
+ fillCheckerRect(data, width, alphaFrame, [0, 0, 0], [255, 255, 255]);
+ fillCheckerRect(data, width, rgbFrame, [230, 30, 20], [20, 150, 240]);
+ return { data, height, width };
+}
+
+function fillRect(data, canvasWidth, rect, color) {
+ const [left, top, width, height] = rect;
+ for (let y = top; y < top + height; y += 1) {
+ for (let x = left; x < left + width; x += 1) {
+ const offset = (y * canvasWidth + x) * 4;
+ data[offset] = color[0];
+ data[offset + 1] = color[1];
+ data[offset + 2] = color[2];
+ data[offset + 3] = 255;
+ }
+ }
+}
+
+function fillCheckerRect(data, canvasWidth, rect, firstColor, secondColor) {
+ const [left, top, width, height] = rect;
+ for (let y = top; y < top + height; y += 1) {
+ for (let x = left; x < left + width; x += 1) {
+ const color =
+ (Math.floor(x / 12) + Math.floor(y / 12)) % 2 === 0 ? firstColor : secondColor;
+ const offset = (y * canvasWidth + x) * 4;
+ data[offset] = color[0];
+ data[offset + 1] = color[1];
+ data[offset + 2] = color[2];
+ data[offset + 3] = 255;
+ }
+ }
+}
diff --git a/src/features/resources/pages/ResourceListPage.jsx b/src/features/resources/pages/ResourceListPage.jsx
index 687d3d7..1b1db69 100644
--- a/src/features/resources/pages/ResourceListPage.jsx
+++ b/src/features/resources/pages/ResourceListPage.jsx
@@ -3,6 +3,7 @@ import EditOutlined from "@mui/icons-material/EditOutlined";
import FileUploadOutlined from "@mui/icons-material/FileUploadOutlined";
import HelpOutlineOutlined from "@mui/icons-material/HelpOutlineOutlined";
import Inventory2Outlined from "@mui/icons-material/Inventory2Outlined";
+import RefreshOutlined from "@mui/icons-material/RefreshOutlined";
import CircularProgress from "@mui/material/CircularProgress";
import LinearProgress from "@mui/material/LinearProgress";
import MenuItem from "@mui/material/MenuItem";
@@ -50,11 +51,28 @@ import {
translateResourceCodes,
} from "@/features/resources/batchUpload.js";
import { useResourceListPage } from "@/features/resources/hooks/useResourcePages.js";
+import { detectProfileCardLayout, profileCardLayoutFromMetadata } from "@/features/resources/profileCardLayout.js";
import {
- detectProfileCardLayout,
- mergeProfileCardLayoutMetadata,
- profileCardLayoutFromMetadata,
-} from "@/features/resources/profileCardLayout.js";
+ confirmMp4AlphaLayoutMetadata,
+ createMp4AlphaLayout,
+ detectMp4AlphaLayout,
+ frameText,
+ hasUnconfirmedMp4Layout,
+ isMp4File,
+ isMp4Source,
+ mergeMp4AlphaLayoutMetadata,
+ mp4FileFromSource,
+ mp4AlphaLayoutFromMetadata,
+ mp4AlphaLayoutOptions,
+ mp4LayoutLabel,
+ readMp4VideoInfo,
+ updateMp4AlphaLayoutFrame,
+ updateMp4AlphaLayoutKind,
+} from "@/features/resources/mp4AlphaLayout.js";
+import {
+ metadataWithProfileCardLayout,
+ removeProfileCardLayoutMetadata,
+} from "@/features/resources/resourceMetadata.js";
import { uploadFilesBatch } from "@/shared/api/upload";
import { useToast } from "@/shared/ui/ToastProvider.jsx";
import styles from "@/features/resources/resources.module.css";
@@ -156,12 +174,12 @@ export function ResourceListPage() {
...column,
render: (resource) =>