礼物布局

This commit is contained in:
zhx 2026-06-09 13:14:11 +08:00
parent 8175726223
commit 8a6e9dc18d
11 changed files with 1323 additions and 32 deletions

View File

@ -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",

View File

@ -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,
};
}

View File

@ -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,

View File

@ -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,
};
}

View File

@ -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;
}
}
}

View File

@ -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) => <ManagerGrantSwitch page={page} resource={resource} />,
}
: column.key === "actions"
? {
...column,
render: (resource) => <ResourceRowActions page={page} resource={resource} />,
}
: column,
: column.key === "actions"
? {
...column,
render: (resource) => <ResourceRowActions page={page} resource={resource} />,
}
: column,
);
const openBatchDialog = () => {
@ -176,11 +194,70 @@ export function ResourceListPage() {
}
};
const handleFolderChange = (event) => {
const handleFolderChange = async (event) => {
const plan = parseResourceFolderFiles(event.target.files);
event.target.value = "";
setBatchPlan(plan);
setBatchProgress(emptyBatchProgress);
if (!plan.resources.some((resource) => isMp4File(resource.animationFile))) {
return;
}
setBatchProgress({ label: "解析 MP4 透明布局", running: true, value: 0 });
const resources = [];
for (let index = 0; index < plan.resources.length; index += 1) {
const resource = plan.resources[index];
resources.push(await resourceWithDetectedMp4Layout(resource));
setBatchProgress({
label: `解析 MP4 透明布局 ${index + 1}/${plan.resources.length}`,
running: true,
value: Math.round(((index + 1) / plan.resources.length) * 100),
});
}
setBatchPlan({ ...plan, resources });
setBatchProgress(emptyBatchProgress);
};
const changeBatchMp4Layout = (resourceIndex, alphaLayout) => {
setBatchPlan((current) => ({
...current,
resources: current.resources.map((resource, index) =>
index === resourceIndex
? {
...resource,
metadataJson: updateMp4AlphaLayoutKind(resource.metadataJson, alphaLayout),
}
: resource,
),
}));
};
const changeBatchMp4Frame = (resourceIndex, frameKey, value) => {
setBatchPlan((current) => ({
...current,
resources: current.resources.map((resource, index) =>
index === resourceIndex
? {
...resource,
metadataJson: updateMp4AlphaLayoutFrame(resource.metadataJson, frameKey, value),
}
: resource,
),
}));
};
const confirmBatchMp4Layout = (resourceIndex) => {
setBatchPlan((current) => ({
...current,
resources: current.resources.map((resource, index) =>
index === resourceIndex
? {
...resource,
metadataJson: confirmMp4AlphaLayoutMetadata(resource.metadataJson),
}
: resource,
),
}));
};
const submitBatchUpload = async (event) => {
@ -188,6 +265,10 @@ export function ResourceListPage() {
if (!batchPlan.resources.length || batchProgress.running) {
return;
}
if (batchPlan.resources.some(needsBatchMp4Confirmation)) {
showToast("请先确认 MP4 透明布局", "error");
return;
}
const resources = batchPlan.resources.map((item) => ({ ...item }));
const uploadEntries = resources.flatMap((resource, resourceIndex) => [
@ -213,7 +294,7 @@ export function ResourceListPage() {
}
try {
const layout = await detectProfileCardLayout(resource.animationFile);
resource.metadataJson = mergeProfileCardLayoutMetadata(resource.metadataJson, layout);
resource.metadataJson = metadataWithProfileCardLayout(resource.metadataJson, layout);
} catch {
showToast(`${resource.name} 内容高度解析失败`, "error");
}
@ -327,7 +408,10 @@ export function ResourceListPage() {
open={batchOpen}
plan={batchPlan}
progress={batchProgress}
onChangeMp4Frame={changeBatchMp4Frame}
onChangeMp4Layout={changeBatchMp4Layout}
onClose={closeBatchDialog}
onConfirmMp4Layout={confirmBatchMp4Layout}
onFileChange={handleFolderChange}
onSubmit={submitBatchUpload}
/>
@ -335,8 +419,58 @@ export function ResourceListPage() {
);
}
function ResourceBatchUploadDialog({ disabled, fileInputRef, onClose, onFileChange, onSubmit, open, plan, progress }) {
const submitDisabled = disabled || progress.running || !plan.resources.length;
async function resourceWithDetectedMp4Layout(resource) {
if (!isMp4File(resource.animationFile)) {
return resource;
}
try {
const layout = await detectMp4AlphaLayout(resource.animationFile);
return {
...resource,
metadataJson: mergeMp4AlphaLayoutMetadata(resource.metadataJson, layout),
mp4LayoutError: "",
};
} catch (err) {
try {
const info = await readMp4VideoInfo(resource.animationFile);
const fallbackLayout = createMp4AlphaLayout({
alphaLayout: "normal",
videoH: info.height,
videoW: info.width,
});
return {
...resource,
metadataJson: mergeMp4AlphaLayoutMetadata(resource.metadataJson, fallbackLayout),
mp4LayoutError: err.message || "MP4 透明布局解析失败",
};
} catch {
return {
...resource,
mp4LayoutError: err.message || "MP4 透明布局解析失败",
};
}
}
}
function needsBatchMp4Confirmation(resource) {
return isMp4File(resource.animationFile) && !mp4AlphaLayoutFromMetadata(resource.metadataJson)?.confirmed;
}
function ResourceBatchUploadDialog({
disabled,
fileInputRef,
onChangeMp4Frame,
onChangeMp4Layout,
onClose,
onConfirmMp4Layout,
onFileChange,
onSubmit,
open,
plan,
progress,
}) {
const submitDisabled =
disabled || progress.running || !plan.resources.length || plan.resources.some(needsBatchMp4Confirmation);
return (
<AdminFormDialog
@ -396,7 +530,12 @@ function ResourceBatchUploadDialog({ disabled, fileInputRef, onClose, onFileChan
/>
</div>
) : null}
<ResourceBatchPreview resources={plan.resources} />
<ResourceBatchPreview
resources={plan.resources}
onChangeMp4Frame={onChangeMp4Frame}
onChangeMp4Layout={onChangeMp4Layout}
onConfirmMp4Layout={onConfirmMp4Layout}
/>
</AdminFormSection>
</AdminFormDialog>
);
@ -420,7 +559,7 @@ function ResourceBatchRules() {
);
}
function ResourceBatchPreview({ resources }) {
function ResourceBatchPreview({ onChangeMp4Frame, onChangeMp4Layout, onConfirmMp4Layout, resources }) {
if (!resources.length) {
return <div className={styles.batchEmpty}>尚未选择资源文件夹</div>;
}
@ -432,24 +571,110 @@ function ResourceBatchPreview({ resources }) {
<span>类型</span>
<span>价格</span>
<span>素材</span>
<span>MP4 布局</span>
</div>
{resources.map((resource) => (
<div className={styles.batchTableRow} key={resource.resourceCode}>
{resources.map((resource, index) => (
<div className={styles.batchTableRow} key={`${resource.resourceCode}_${index}`}>
<span>{resource.name}</span>
<span>{resourceTypeLabels[resource.resourceType] || resource.resourceType}</span>
<span>{resource.price > 0 ? `金币 ${resource.price}` : "免费"}</span>
<span>
{resourceBatchUploadRoleLabels.cover} / {resourceBatchUploadRoleLabels.animation}
</span>
<BatchMp4LayoutCell
resource={resource}
onChangeFrame={(frameKey, value) => onChangeMp4Frame(index, frameKey, value)}
onChangeLayout={(alphaLayout) => onChangeMp4Layout(index, alphaLayout)}
onConfirm={() => onConfirmMp4Layout(index)}
/>
</div>
))}
</div>
);
}
function BatchMp4LayoutCell({ onChangeFrame, onChangeLayout, onConfirm, resource }) {
if (!isMp4File(resource.animationFile)) {
return <span className={styles.meta}>-</span>;
}
const layout = mp4AlphaLayoutFromMetadata(resource.metadataJson);
if (!layout) {
return <span className={styles.meta}>{resource.mp4LayoutError || "解析失败"}</span>;
}
return (
<div className={styles.mp4BatchCell}>
<select
className={styles.mp4Select}
value={layout.alpha_layout}
onChange={(event) => onChangeLayout(event.target.value)}
>
{mp4AlphaLayoutOptions.map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
<span className={styles.meta}>
{layout.video_w}x{layout.video_h} · {layout.confirmed ? "已确认" : "待确认"}
</span>
{layout.alpha_layout === "custom" ? (
<div className={styles.mp4FrameGrid}>
<input
aria-label="批量 RGB 区域"
className={styles.mp4FrameInput}
value={frameText(layout.rgb_frame)}
onChange={(event) => onChangeFrame("rgb_frame", event.target.value)}
/>
<input
aria-label="批量 Alpha 区域"
className={styles.mp4FrameInput}
value={frameText(layout.alpha_frame)}
onChange={(event) => onChangeFrame("alpha_frame", event.target.value)}
/>
</div>
) : null}
<button className={styles.mp4ConfirmButton} disabled={layout.confirmed} type="button" onClick={onConfirm}>
确认
</button>
</div>
);
}
function ResourceRowActions({ page, resource }) {
const { showToast } = useToast();
const [recalculating, setRecalculating] = useState(false);
const mp4AnimationSource = mp4ResourceAnimationSource(resource);
const loading = recalculating || page.loadingAction === `resource-mp4-layout-${resource.resourceId}`;
const recalculateMp4Layout = async () => {
if (!mp4AnimationSource || !page.abilities.canUpdate || loading) {
return;
}
setRecalculating(true);
try {
const file = await mp4FileFromSource(mp4AnimationSource);
const layout = await detectMp4AlphaLayout(file);
const metadataJson = mergeMp4AlphaLayoutMetadata(resource.metadataJson, layout);
page.openEditResource({ ...resource, metadataJson });
showToast("MP4 布局已重新计算,请确认后保存", "success");
} catch (err) {
showToast(err.message || "MP4 布局重新计算失败", "error");
} finally {
setRecalculating(false);
}
};
return (
<AdminRowActions>
{mp4AnimationSource ? (
<AdminActionIconButton
disabled={!page.abilities.canUpdate || loading}
label="重新计算 MP4 布局"
onClick={recalculateMp4Layout}
>
{loading ? <CircularProgress size={16} /> : <RefreshOutlined fontSize="small" />}
</AdminActionIconButton>
) : null}
<AdminActionIconButton
disabled={!page.abilities.canUpdate}
label="编辑资源"
@ -461,12 +686,29 @@ function ResourceRowActions({ page, resource }) {
);
}
function mp4ResourceAnimationSource(resource) {
const source = String(resource?.animationUrl || resource?.assetUrl || "").trim();
return isMp4Source(source) ? source : "";
}
function ResourceFormDialog({ disabled, form, loading, mode, onClose, onSubmit, open, setForm, uploadDisabled }) {
const submitDisabled = disabled || loading;
const submitDisabled = disabled || loading || hasUnconfirmedMp4Layout(form.metadataJson);
const { showToast } = useToast();
const profileCardLayout =
form.resourceType === "profile_card" ? profileCardLayoutFromMetadata(form.metadataJson) : null;
const handleProfileCardAnimationSelected = async (file) => {
const mp4Layout = mp4AlphaLayoutFromMetadata(form.metadataJson);
const handleAnimationSelected = async (file) => {
if (isMp4File(file)) {
try {
const layout = await detectMp4AlphaLayout(file);
setForm((previous) => ({
...previous,
metadataJson: mergeMp4AlphaLayoutMetadata(previous.metadataJson, layout),
}));
} catch (err) {
showToast(err.message || "MP4 透明布局解析失败", "error");
}
}
if (form.resourceType !== "profile_card") {
return;
}
@ -476,7 +718,7 @@ function ResourceFormDialog({ disabled, form, loading, mode, onClose, onSubmit,
previous.resourceType === "profile_card"
? {
...previous,
metadataJson: mergeProfileCardLayoutMetadata(previous.metadataJson, layout),
metadataJson: metadataWithProfileCardLayout(previous.metadataJson, layout),
}
: previous,
);
@ -522,7 +764,10 @@ function ResourceFormDialog({ disabled, form, loading, mode, onClose, onSubmit,
badgeForm: resourceType === "badge" ? previous.badgeForm || "tile" : previous.badgeForm,
badgeKind: resourceType === "badge" ? previous.badgeKind || "normal" : "normal",
levelTrack: resourceType === "badge" ? previous.levelTrack || "" : "",
metadataJson: resourceType === "profile_card" ? previous.metadataJson : "",
metadataJson:
resourceType === "profile_card"
? previous.metadataJson
: removeProfileCardLayoutMetadata(previous.metadataJson),
resourceType,
walletAssetAmount: "",
}));
@ -647,9 +892,18 @@ function ResourceFormDialog({ disabled, form, loading, mode, onClose, onSubmit,
label="动效素材"
value={form.animationUrl}
onChange={(animationUrl) => setForm((previous) => ({ ...previous, animationUrl }))}
onFileSelected={handleProfileCardAnimationSelected}
onFileSelected={handleAnimationSelected}
/>
</AdminFormFieldGrid>
{mp4Layout ? (
<Mp4AlphaLayoutEditor
disabled={disabled}
metadataJson={form.metadataJson}
onChange={(metadataJson) => setForm((previous) => ({ ...previous, metadataJson }))}
/>
) : isMp4Source(form.animationUrl) ? (
<div className={styles.mp4LayoutEmpty}>请重新选择 MP4 文件解析布局或保存前补充布局元数据</div>
) : null}
{profileCardLayout ? (
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
<TextField disabled label="content_top" value={String(profileCardLayout.content_top)} />
@ -679,6 +933,66 @@ function ResourceFormDialog({ disabled, form, loading, mode, onClose, onSubmit,
);
}
function Mp4AlphaLayoutEditor({ disabled, metadataJson, onChange }) {
const layout = mp4AlphaLayoutFromMetadata(metadataJson);
if (!layout) {
return null;
}
const transparentLayout = layout.alpha_layout !== "normal";
return (
<div className={styles.mp4LayoutPanel}>
<AdminFormFieldGrid columns="repeat(3, minmax(0, 1fr))">
<TextField
disabled={disabled}
label="MP4 布局"
select
value={layout.alpha_layout}
onChange={(event) => onChange(updateMp4AlphaLayoutKind(metadataJson, event.target.value))}
>
{mp4AlphaLayoutOptions.map(([value, label]) => (
<MenuItem key={value} value={value}>
{label}
</MenuItem>
))}
</TextField>
<TextField disabled label="视频宽高" value={`${layout.video_w} x ${layout.video_h}`} />
<TextField disabled label="确认状态" value={layout.confirmed ? "已确认" : "待确认"} />
</AdminFormFieldGrid>
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
<TextField
disabled={disabled || layout.alpha_layout !== "custom"}
label="rgb_frame"
value={frameText(layout.rgb_frame)}
onChange={(event) =>
onChange(updateMp4AlphaLayoutFrame(metadataJson, "rgb_frame", event.target.value))
}
/>
<TextField
disabled={disabled || !transparentLayout || layout.alpha_layout !== "custom"}
label="alpha_frame"
value={frameText(layout.alpha_frame)}
onChange={(event) =>
onChange(updateMp4AlphaLayoutFrame(metadataJson, "alpha_frame", event.target.value))
}
/>
</AdminFormFieldGrid>
<div className={styles.mp4LayoutActions}>
<span className={styles.meta}>
{mp4LayoutLabel(layout)} · confidence {layout.confidence || 0}
</span>
<button
className={styles.mp4ConfirmButton}
disabled={disabled || layout.confirmed}
type="button"
onClick={() => onChange(confirmMp4AlphaLayoutMetadata(metadataJson))}
>
确认 MP4 布局
</button>
</div>
</div>
);
}
function ResourceIdentity({ resource }) {
const preview = imageURL(resource.previewUrl || resource.assetUrl);
return (
@ -712,7 +1026,9 @@ function ManagerGrantSwitch({ page, resource }) {
<AdminSwitch
checked={checked}
checkedLabel="开启"
disabled={!page.abilities.canUpdate || page.loadingAction === `resource-manager-grant-${resource.resourceId}`}
disabled={
!page.abilities.canUpdate || page.loadingAction === `resource-manager-grant-${resource.resourceId}`
}
inputProps={{ "aria-label": checked ? "关闭经理赠送" : "开启经理赠送" }}
uncheckedLabel="关闭"
onChange={(event) => page.toggleManagerGrant(resource, event.target.checked)}

View File

@ -0,0 +1,57 @@
import { profileCardLayoutFromMetadata } from "@/features/resources/profileCardLayout.js";
import { mp4AlphaLayoutFromMetadata, mp4AlphaLayoutKey } from "@/features/resources/mp4AlphaLayout.js";
export const emptyResourceMetadata = "{}";
export function resourceMetadataJSON(resourceType, metadataJson) {
const metadata = {};
const profileCardLayout = resourceType === "profile_card" ? profileCardLayoutFromMetadata(metadataJson) : null;
if (profileCardLayout) {
metadata.profile_card_layout = profileCardLayout;
}
const mp4AlphaLayout = mp4AlphaLayoutFromMetadata(metadataJson);
if (mp4AlphaLayout) {
metadata.animation_format = "mp4";
metadata[mp4AlphaLayoutKey] = mp4AlphaLayout;
}
return Object.keys(metadata).length ? JSON.stringify(metadata) : emptyResourceMetadata;
}
export function removeProfileCardLayoutMetadata(metadataJson) {
const metadata = parseMetadataObject(metadataJson);
delete metadata.profile_card_layout;
return stringifyMetadata(metadata);
}
export function metadataWithProfileCardLayout(metadataJson, layout) {
const metadata = parseMetadataObject(metadataJson);
if (layout) {
metadata.profile_card_layout = layout;
} else {
delete metadata.profile_card_layout;
}
return stringifyMetadata(metadata);
}
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 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) : emptyResourceMetadata;
}

View File

@ -325,10 +325,81 @@
min-height: 42px;
align-items: center;
gap: var(--space-3);
grid-template-columns: minmax(180px, 1.2fr) minmax(110px, 0.7fr) minmax(100px, 0.55fr) minmax(150px, 0.9fr);
grid-template-columns:
minmax(160px, 1fr) minmax(100px, 0.55fr) minmax(90px, 0.45fr) minmax(130px, 0.65fr)
minmax(220px, 1.25fr);
padding: 0 var(--space-3);
}
.mp4LayoutPanel {
display: grid;
gap: var(--space-3);
padding-top: var(--space-2);
}
.mp4LayoutEmpty {
color: var(--text-secondary);
font-size: var(--admin-font-size);
}
.mp4LayoutActions {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
}
.mp4BatchCell {
display: grid;
min-width: 0;
gap: 6px;
}
.mp4Select,
.mp4FrameInput {
width: 100%;
min-width: 0;
height: 30px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg-card);
color: var(--text-primary);
font: inherit;
}
.mp4FrameGrid {
display: grid;
gap: 6px;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.mp4FrameInput {
padding: 0 8px;
}
.mp4ConfirmButton {
display: inline-flex;
width: fit-content;
min-height: 30px;
align-items: center;
justify-content: center;
padding: 0 var(--space-2);
border: 1px solid var(--primary-border);
border-radius: var(--radius-sm);
background: var(--primary-surface);
color: var(--primary);
cursor: pointer;
font: inherit;
font-weight: 650;
}
.mp4ConfirmButton:disabled {
border-color: var(--border);
background: var(--bg-card-strong);
color: var(--text-tertiary);
cursor: not-allowed;
}
.batchTableHead {
border-bottom: 1px solid var(--border);
background: var(--bg-card-strong);

View File

@ -1,5 +1,6 @@
import { z } from "zod";
import { cpRelationTypeOptions } from "@/features/resources/constants.js";
import { hasUnconfirmedMp4Layout } from "@/features/resources/mp4AlphaLayout.js";
const resourceTypes = [
"avatar_frame",
@ -84,6 +85,13 @@ export const resourceCreateFormSchema = z
});
}
}
if (hasUnconfirmedMp4Layout(value.metadataJson)) {
context.addIssue({
code: "custom",
message: "请确认 MP4 透明布局",
path: ["metadataJson"],
});
}
});
export const resourceFormSchema = resourceCreateFormSchema;

View File

@ -79,6 +79,45 @@ describe("resource form schema", () => {
).toThrow(FormValidationError);
});
test("requires explicit confirmation before saving mp4 alpha layout", () => {
const basePayload = {
animationUrl: "https://media.haiyihy.com/resource/gift.mp4",
enabled: true,
managerGrantEnabled: true,
name: "Gift",
previewUrl: "https://media.haiyihy.com/resource/gift.png",
coinPrice: "1",
priceType: "coin",
resourceCode: "gift_love",
resourceType: "gift",
walletAssetAmount: "",
};
const layout = {
alpha_frame: [0, 0, 750, 1334],
alpha_layout: "alpha_left_rgb_right",
detect_version: 1,
rgb_frame: [750, 0, 750, 1334],
video_h: 1334,
video_w: 1500,
};
expect(() =>
parseForm(resourceFormSchema, {
...basePayload,
metadataJson: JSON.stringify({
animation_format: "mp4",
mp4_alpha_layout: { ...layout, confirmed: false },
}),
}),
).toThrow(FormValidationError);
const payload = parseForm(resourceFormSchema, {
...basePayload,
metadataJson: JSON.stringify({ animation_format: "mp4", mp4_alpha_layout: { ...layout, confirmed: true } }),
});
expect(payload.metadataJson).toContain("mp4_alpha_layout");
});
test("allows resource group resource items without wallet asset type", () => {
const payload = parseForm(resourceGroupCreateFormSchema, {
description: "",

View File

@ -6,6 +6,7 @@
"module": "ESNext",
"moduleResolution": "Bundler",
"noEmit": true,
"ignoreDeprecations": "6.0",
"resolveJsonModule": true,
"skipLibCheck": true,
"strict": true,