Files
UHD_Media_Tool/apps/web/app/components/library/slot-rules.tsx
T
2026-09-28 02:09:39 +08:00

523 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { JsonObject } from "@umt/contracts";
import { useId } from "react";
import { z } from "zod";
import { FormField } from "~/components/console/form-field.tsx";
import { Input } from "~/components/ui/input.tsx";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select.tsx";
import { Switch } from "~/components/ui/switch.tsx";
import { formatBytes } from "~/lib/utils.ts";
import { ChipToggleGroup, splitList } from "./parts.tsx";
// Slot 规则:硬条件 / 软偏好 / 回退(字段未知时) / 升级阈值 / 完成条件。
// 结构对应 domain SlotSpec(packages/domain/src/slot/types.ts),落在 acquisition_slot 的 JSON 列与
// AcquisitionPolicyRules 的 SlotTemplate 上;这里只做结构化表单,不生成脚本。
export const RESOLUTIONS = ["2160p", "1080p", "1080i", "720p", "576p", "480p", "4320p", "1440p"] as const;
export const SOURCES = [
"REMUX",
"BluRay",
"BluRay-Disc",
"WEB-DL",
"WEBRip",
"HDTV",
"HDRip",
"DVD",
"DVDRip",
] as const;
export const HDRS = ["DV", "HDR10", "HDR10+", "HLG", "HDR Vivid"] as const;
export const CODECS = ["H.265", "H.264", "AV1", "VC-1", "MPEG-2"] as const;
export const CUTS = [
"default",
"Theatrical",
"Director's Cut",
"Extended",
"IMAX",
"Unrated",
"Uncut",
"Remastered",
"Final Cut",
"Criterion",
"Ultimate",
"Special Edition",
] as const;
export const CONTENT_TYPES = ["main", "extra", "behind_the_scenes", "performance_only", "preview", "reaction"] as const;
const SOURCE_LABEL: Record<string, string> = { BluRay: "BluRay(压制)", "BluRay-Disc": "原盘" };
const CUT_LABEL: Record<string, string> = { default: "默认(无剪辑标记)" };
const CT_LABEL: Record<string, string> = {
main: "正片",
extra: "花絮",
behind_the_scenes: "幕后",
performance_only: "纯享",
preview: "预告",
reaction: "reaction",
};
const UNKNOWN_LABEL = { hold: "等待补充(不下载)", reject: "拒绝", accept: "放行" } as const;
const GB = 1024 ** 3;
const o = <T extends string>(list: readonly T[], labels: Record<string, string> = {}) =>
list.map((v) => ({ value: v, label: labels[v] ?? v }));
// ── 规则结构(zod:读 JSON 列时宽松解析,写回前严格校验) ──
const strArr = z.array(z.string()).optional();
export const HardConditions = z.object({
resolutions: strArr,
sources: strArr,
requireEncode: z.boolean().optional(),
maxSizeBytes: z.number().positive().optional(),
minSizeBytes: z.number().nonnegative().optional(),
requiredSubtitles: strArr,
cuts: strArr,
excludeGroups: strArr,
contentTypes: strArr,
});
export const SoftPreferences = z.object({
hdr: strArr,
groups: strArr,
sources: strArr,
codecs: strArr,
preferredSizeBytes: z.object({ min: z.number().optional(), max: z.number().optional() }).optional(),
});
export const UpgradePolicy = z.object({
enabled: z.boolean(),
minTierSteps: z.number().int().min(0).max(5).optional(),
allowResolutionUpgrade: z.boolean().optional(),
encodeGroupAllowlist: strArr,
ceilingTier: z.number().int().min(0).max(5).optional(),
});
export const UnknownPolicy = z.enum(["hold", "reject", "accept"]);
export const Completion = z.object({ kind: z.enum(["movie", "units"]) });
export type HardConditions = z.infer<typeof HardConditions>;
export type SoftPreferences = z.infer<typeof SoftPreferences>;
export type UpgradePolicy = z.infer<typeof UpgradePolicy>;
/** 编辑器状态(字符串形态,提交时转换) */
export type SlotRulesDraft = {
resolutions: string[];
sources: string[];
requireEncode: boolean;
minSizeGb: string;
maxSizeGb: string;
requiredSubtitles: string;
cuts: string[];
excludeGroups: string;
contentTypes: string[];
unknownPolicy: "" | "hold" | "reject" | "accept";
softHdr: string[];
softGroups: string;
softSources: string[];
softCodecs: string[];
upgradeEnabled: boolean;
minTierSteps: string;
allowResolutionUpgrade: boolean;
encodeGroupAllowlist: string;
ceilingTier: string;
completion: "" | "movie" | "units";
};
export type SlotRulesJson = {
hardConditions: JsonObject;
softPreferences: JsonObject;
fallback: JsonObject;
upgradeThreshold: JsonObject;
completion: JsonObject;
};
const numStr = (n: unknown, div = 1) => (typeof n === "number" ? String(Math.round((n / div) * 100) / 100) : "");
export function draftFromJson(j: Partial<SlotRulesJson>): SlotRulesDraft {
const h = HardConditions.safeParse(j.hardConditions ?? {}).data ?? {};
const s = SoftPreferences.safeParse(j.softPreferences ?? {}).data ?? {};
const u = UpgradePolicy.safeParse(j.upgradeThreshold ?? {}).data;
const up = UnknownPolicy.safeParse((j.fallback as { unknownPolicy?: unknown } | undefined)?.unknownPolicy).data;
const c = Completion.safeParse(j.completion ?? {}).data;
return {
resolutions: h.resolutions ?? [],
sources: h.sources ?? [],
requireEncode: h.requireEncode ?? false,
minSizeGb: numStr(h.minSizeBytes, GB),
maxSizeGb: numStr(h.maxSizeBytes, GB),
requiredSubtitles: (h.requiredSubtitles ?? []).join(", "),
cuts: h.cuts ?? [],
excludeGroups: (h.excludeGroups ?? []).join(", "),
contentTypes: h.contentTypes ?? [],
unknownPolicy: up ?? "",
softHdr: s.hdr ?? [],
softGroups: (s.groups ?? []).join(", "),
softSources: s.sources ?? [],
softCodecs: s.codecs ?? [],
upgradeEnabled: u?.enabled ?? false,
minTierSteps: numStr(u?.minTierSteps),
allowResolutionUpgrade: u?.allowResolutionUpgrade ?? false,
encodeGroupAllowlist: (u?.encodeGroupAllowlist ?? []).join(", "),
ceilingTier: numStr(u?.ceilingTier),
completion: c?.kind ?? "",
};
}
export const emptyDraft = (): SlotRulesDraft => draftFromJson({});
/** 草稿 → JSON 列;返回字段级错误(键同草稿字段名) */
export function draftToJson(
d: SlotRulesDraft,
): { ok: true; value: SlotRulesJson } | { ok: false; errors: Record<string, string> } {
const errors: Record<string, string> = {};
const num = (key: keyof SlotRulesDraft, s: string, opts: { int?: boolean; min?: number; max?: number } = {}) => {
if (s.trim() === "") return undefined;
const n = Number(s);
if (!Number.isFinite(n) || (opts.int && !Number.isInteger(n))) {
errors[key] = opts.int ? "须是整数" : "须是数字";
return undefined;
}
if (opts.min !== undefined && n < opts.min) errors[key] = `不能小于 ${opts.min}`;
if (opts.max !== undefined && n > opts.max) errors[key] = `不能大于 ${opts.max}`;
return n;
};
const minGb = num("minSizeGb", d.minSizeGb, { min: 0 });
const maxGb = num("maxSizeGb", d.maxSizeGb, { min: 0.01 });
if (minGb !== undefined && maxGb !== undefined && minGb > maxGb) errors.maxSizeGb = "上限须大于下限";
const minTier = num("minTierSteps", d.minTierSteps, { int: true, min: 0, max: 5 });
const ceiling = num("ceilingTier", d.ceilingTier, { int: true, min: 0, max: 5 });
if (Object.keys(errors).length) return { ok: false, errors };
const arr = (v: string[]) => (v.length ? v : undefined);
const hard: HardConditions = {
resolutions: arr(d.resolutions),
sources: arr(d.sources),
requireEncode: d.requireEncode || undefined,
minSizeBytes: minGb !== undefined ? Math.round(minGb * GB) : undefined,
maxSizeBytes: maxGb !== undefined ? Math.round(maxGb * GB) : undefined,
requiredSubtitles: arr(splitList(d.requiredSubtitles)),
cuts: arr(d.cuts),
excludeGroups: arr(splitList(d.excludeGroups)),
contentTypes: arr(d.contentTypes),
};
const soft: SoftPreferences = {
hdr: arr(d.softHdr),
groups: arr(splitList(d.softGroups)),
sources: arr(d.softSources),
codecs: arr(d.softCodecs),
};
const upgrade: UpgradePolicy | undefined =
d.upgradeEnabled || minTier !== undefined || d.encodeGroupAllowlist.trim()
? {
enabled: d.upgradeEnabled,
minTierSteps: minTier,
allowResolutionUpgrade: d.allowResolutionUpgrade || undefined,
encodeGroupAllowlist: arr(splitList(d.encodeGroupAllowlist)),
ceilingTier: ceiling,
}
: undefined;
const clean = (x: object): JsonObject => JSON.parse(JSON.stringify(x)) as JsonObject;
// 严格校验一遍(与 domain 类型对齐)
const hv = HardConditions.safeParse(clean(hard));
const sv = SoftPreferences.safeParse(clean(soft));
if (!hv.success || !sv.success) return { ok: false, errors: { form: "规则结构不合法" } };
return {
ok: true,
value: {
hardConditions: clean(hard),
softPreferences: clean(soft),
fallback: d.unknownPolicy ? { unknownPolicy: d.unknownPolicy } : {},
upgradeThreshold: upgrade ? clean(upgrade) : {},
completion: d.completion ? { kind: d.completion } : {},
},
};
}
// ── 摘要(卡片/列表上的一行说明) ──
export function summarizeHard(j: JsonObject | undefined, fallback?: JsonObject): string[] {
const h = HardConditions.safeParse(j ?? {}).data;
const out: string[] = [];
if (!h) return ["规则无法解析"];
if (h.resolutions?.length) out.push(`分辨率 ${h.resolutions.join(" / ")}`);
if (h.sources?.length) out.push(`来源 ${h.sources.map((s) => SOURCE_LABEL[s] ?? s).join(" / ")}`);
if (h.requireEncode) out.push("仅蓝光压制(不含原盘/Remux)");
if (h.minSizeBytes !== undefined || h.maxSizeBytes !== undefined)
out.push(
`体积 ${h.minSizeBytes !== undefined ? formatBytes(h.minSizeBytes, 1) : "0"} – ${
h.maxSizeBytes !== undefined ? formatBytes(h.maxSizeBytes, 1) : "不限"
}`,
);
if (h.requiredSubtitles?.length) out.push(`需要字幕 ${h.requiredSubtitles.join(" / ")}`);
if (h.cuts?.length) out.push(`内容版本 ${h.cuts.map((c) => CUT_LABEL[c] ?? c).join(" / ")}`);
if (h.excludeGroups?.length) out.push(`排除组 ${h.excludeGroups.join(" / ")}`);
if (h.contentTypes?.length) out.push(`内容 ${h.contentTypes.map((c) => CT_LABEL[c] ?? c).join(" / ")}`);
const up = UnknownPolicy.safeParse((fallback as { unknownPolicy?: unknown } | undefined)?.unknownPolicy).data;
if (up) out.push(`字段未知时${UNKNOWN_LABEL[up]}`);
return out;
}
export function summarizeSoft(j: JsonObject | undefined): string[] {
const s = SoftPreferences.safeParse(j ?? {}).data;
if (!s) return ["规则无法解析"];
const out: string[] = [];
if (s.hdr?.length) out.push(`HDR ${s.hdr.join(" > ")}`);
if (s.sources?.length) out.push(`来源 ${s.sources.join(" > ")}`);
if (s.codecs?.length) out.push(`编码 ${s.codecs.join(" > ")}`);
if (s.groups?.length) out.push(`发布组 ${s.groups.join(" > ")}`);
if (s.preferredSizeBytes)
out.push(
`体积 ${s.preferredSizeBytes.min ? formatBytes(s.preferredSizeBytes.min, 1) : "0"} – ${
s.preferredSizeBytes.max ? formatBytes(s.preferredSizeBytes.max, 1) : "不限"
}`,
);
return out;
}
export function summarizeUpgrade(j: JsonObject | undefined): string[] {
const u = UpgradePolicy.safeParse(j ?? {}).data;
if (!u) return Object.keys(j ?? {}).length ? ["规则无法解析"] : [];
if (!u.enabled) return ["不自动升级"];
const out = [`来源等级至少提升 ${u.minTierSteps ?? 1} 级`];
if (u.allowResolutionUpgrade) out.push("允许仅因分辨率升级");
if (u.encodeGroupAllowlist?.length) out.push(`合格压制组 ${u.encodeGroupAllowlist.join(" / ")}`);
if (u.ceilingTier !== undefined) out.push(`达到等级 ${u.ceilingTier} 后不再升级`);
return out;
}
export function summarizeCompletion(j: JsonObject | undefined): string {
const c = Completion.safeParse(j ?? {}).data;
if (!c) return "未设置";
return c.kind === "movie" ? "按内容版本核对(电影)" : "按内容单元覆盖核对(剧集/综艺)";
}
// ── 编辑器 ──
export function SlotRulesEditor({
value,
onChange,
errors = {},
}: {
value: SlotRulesDraft;
onChange: (v: SlotRulesDraft) => void;
errors?: Record<string, string>;
}) {
const id = useId();
const set = <K extends keyof SlotRulesDraft>(k: K, v: SlotRulesDraft[K]) => onChange({ ...value, [k]: v });
return (
<div className="space-y-5">
<fieldset className="space-y-3">
<legend className="mb-1 text-label font-semibold tracking-[0.18em] text-muted-foreground uppercase">
硬条件 · 已知不满足即拒绝
</legend>
<FormField label="分辨率">
<ChipToggleGroup
label="分辨率"
options={o(RESOLUTIONS)}
value={value.resolutions}
onChange={(v) => set("resolutions", v)}
/>
</FormField>
<FormField label="交付来源" hint="BluRay 指蓝光压制;原盘与 Remux 单列">
<ChipToggleGroup
label="交付来源"
options={o(SOURCES, SOURCE_LABEL)}
value={value.sources}
onChange={(v) => set("sources", v)}
/>
</FormField>
<div className="flex items-center justify-between gap-3 rounded-lg border border-border px-3 py-2">
<div className="min-w-0">
<p className="text-caption font-medium text-foreground">只接受蓝光二次压制</p>
<p className="text-meta text-muted-foreground">不含原盘和 Remux</p>
</div>
<Switch
checked={value.requireEncode}
onCheckedChange={(v) => set("requireEncode", v)}
aria-label="只接受蓝光二次压制"
/>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<FormField label="体积下限(GB)" htmlFor={`${id}-min`} error={errors.minSizeGb}>
<Input
id={`${id}-min`}
inputMode="decimal"
value={value.minSizeGb}
aria-invalid={!!errors.minSizeGb}
onChange={(e) => set("minSizeGb", e.target.value)}
placeholder="不限"
/>
</FormField>
<FormField label="体积上限(GB)" htmlFor={`${id}-max`} error={errors.maxSizeGb}>
<Input
id={`${id}-max`}
inputMode="decimal"
value={value.maxSizeGb}
aria-invalid={!!errors.maxSizeGb}
onChange={(e) => set("maxSizeGb", e.target.value)}
placeholder="不限"
/>
</FormField>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<FormField label="必需字幕语言" htmlFor={`${id}-subs`} hint="逗号分隔;zh 可被 zh-Hans/zh-Hant 满足">
<Input
id={`${id}-subs`}
value={value.requiredSubtitles}
onChange={(e) => set("requiredSubtitles", e.target.value)}
placeholder="zh"
/>
</FormField>
<FormField label="排除发布组" htmlFor={`${id}-exg`} hint="逗号分隔">
<Input
id={`${id}-exg`}
value={value.excludeGroups}
onChange={(e) => set("excludeGroups", e.target.value)}
/>
</FormField>
</div>
<FormField label="内容版本(剪辑)" hint="不选即不限">
<ChipToggleGroup
label="内容版本"
options={o(CUTS, CUT_LABEL)}
value={value.cuts}
onChange={(v) => set("cuts", v)}
/>
</FormField>
<FormField label="内容类型" hint="综艺正片 / 花絮等">
<ChipToggleGroup
label="内容类型"
options={o(CONTENT_TYPES, CT_LABEL)}
value={value.contentTypes}
onChange={(v) => set("contentTypes", v)}
/>
</FormField>
<FormField label="字段未知时" hint="资源缺少判断字段(如字幕)时">
<Select
value={value.unknownPolicy || "__none"}
onValueChange={(v) => set("unknownPolicy", v === "__none" ? "" : (v as SlotRulesDraft["unknownPolicy"]))}
>
<SelectTrigger className="w-full sm:w-64" aria-label="字段未知时">
<SelectValue />
</SelectTrigger>
<SelectContent position="popper">
<SelectItem value="__none">按系统默认</SelectItem>
{(Object.keys(UNKNOWN_LABEL) as (keyof typeof UNKNOWN_LABEL)[]).map((k) => (
<SelectItem key={k} value={k}>
{UNKNOWN_LABEL[k]}
</SelectItem>
))}
</SelectContent>
</Select>
</FormField>
</fieldset>
<fieldset className="space-y-3 border-t border-border/60 pt-4">
<legend className="mb-1 text-label font-semibold tracking-[0.18em] text-muted-foreground uppercase">
软偏好 · 通过硬条件后排序,越靠前越优先
</legend>
<FormField label="HDR">
<ChipToggleGroup
ordered
label="HDR 偏好"
options={o(HDRS)}
value={value.softHdr}
onChange={(v) => set("softHdr", v)}
/>
</FormField>
<FormField label="来源">
<ChipToggleGroup
ordered
label="来源偏好"
options={o(SOURCES, SOURCE_LABEL)}
value={value.softSources}
onChange={(v) => set("softSources", v)}
/>
</FormField>
<FormField label="编码">
<ChipToggleGroup
ordered
label="编码偏好"
options={o(CODECS)}
value={value.softCodecs}
onChange={(v) => set("softCodecs", v)}
/>
</FormField>
<FormField label="偏好发布组" htmlFor={`${id}-grp`} hint="逗号分隔,越靠前越好">
<Input id={`${id}-grp`} value={value.softGroups} onChange={(e) => set("softGroups", e.target.value)} />
</FormField>
</fieldset>
<fieldset className="space-y-3 border-t border-border/60 pt-4">
<legend className="mb-1 text-label font-semibold tracking-[0.18em] text-muted-foreground uppercase">
升级 · 生成替换计划,不直接覆盖旧文件
</legend>
<div className="flex items-center justify-between gap-3 rounded-lg border border-border px-3 py-2">
<p className="text-caption font-medium text-foreground">允许自动升级</p>
<Switch
checked={value.upgradeEnabled}
onCheckedChange={(v) => set("upgradeEnabled", v)}
aria-label="允许自动升级"
/>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<FormField
label="最小来源等级提升"
htmlFor={`${id}-tier`}
error={errors.minTierSteps}
hint="默认 1;同等级不反复更换"
>
<Input
id={`${id}-tier`}
inputMode="numeric"
value={value.minTierSteps}
aria-invalid={!!errors.minTierSteps}
onChange={(e) => set("minTierSteps", e.target.value)}
placeholder="1"
/>
</FormField>
<FormField label="封顶等级" htmlFor={`${id}-ceil`} error={errors.ceilingTier} hint="达到后不再升级(0–5)">
<Input
id={`${id}-ceil`}
inputMode="numeric"
value={value.ceilingTier}
aria-invalid={!!errors.ceilingTier}
onChange={(e) => set("ceilingTier", e.target.value)}
placeholder="不封顶"
/>
</FormField>
</div>
<FormField label="合格压制组名单" htmlFor={`${id}-allow`} hint="设置后,名单外的压制不触发自动升级">
<Input
id={`${id}-allow`}
value={value.encodeGroupAllowlist}
onChange={(e) => set("encodeGroupAllowlist", e.target.value)}
/>
</FormField>
<div className="flex items-center justify-between gap-3 rounded-lg border border-border px-3 py-2">
<p className="text-caption font-medium text-foreground">允许仅因分辨率提升而升级</p>
<Switch
checked={value.allowResolutionUpgrade}
onCheckedChange={(v) => set("allowResolutionUpgrade", v)}
aria-label="允许仅因分辨率提升而升级"
/>
</div>
</fieldset>
<fieldset className="space-y-3 border-t border-border/60 pt-4">
<legend className="mb-1 text-label font-semibold tracking-[0.18em] text-muted-foreground uppercase">
完成条件
</legend>
<Select
value={value.completion || "__none"}
onValueChange={(v) => set("completion", v === "__none" ? "" : (v as "movie" | "units"))}
>
<SelectTrigger className="w-full sm:w-72" aria-label="完成条件">
<SelectValue />
</SelectTrigger>
<SelectContent position="popper">
<SelectItem value="__none">未设置</SelectItem>
<SelectItem value="movie">按内容版本核对(电影)</SelectItem>
<SelectItem value="units">按内容单元覆盖核对(剧集/综艺)</SelectItem>
</SelectContent>
</Select>
{errors.form ? <p className="text-meta text-destructive">{errors.form}</p> : null}
</fieldset>
</div>
);
}