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