礼物布局
This commit is contained in:
parent
8175726223
commit
8a6e9dc18d
@ -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",
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
521
src/features/resources/mp4AlphaLayout.js
Normal file
521
src/features/resources/mp4AlphaLayout.js
Normal 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,
|
||||
};
|
||||
}
|
||||
197
src/features/resources/mp4AlphaLayout.test.js
Normal file
197
src/features/resources/mp4AlphaLayout.test.js
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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)}
|
||||
|
||||
57
src/features/resources/resourceMetadata.js
Normal file
57
src/features/resources/resourceMetadata.js
Normal 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;
|
||||
}
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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: "",
|
||||
|
||||
@ -6,6 +6,7 @@
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"noEmit": true,
|
||||
"ignoreDeprecations": "6.0",
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user