Files

251 lines
8.2 KiB
JavaScript
Raw Permalink 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.
/**
* 草稿内资源路径的相对化工具(`src/pyJianYingDraft/draft_paths.py` 的 JS 镜像)。
*
* 剪映保存草稿时不写绝对路径,而是使用「草稿目录占位符」:
*
* ##_draftpath_placeholder_<UUID>_##/<草稿内相对路径>
*
* 该 UUID 是编译进剪映二进制的常量,因此两端硬编码同一个默认值;
* 可用环境变量 `DRAFT_PATH_PLACEHOLDER_ID` 覆盖。
*
* 仅依赖 `path`,保持纯函数以便单测。
*/
const path = require("path");
const DEFAULT_PLACEHOLDER_ID = "0E685133-18CE-45ED-8CB8-2904A212EC80";
const PLACEHOLDER_RE = /^##_draftpath_placeholder_[^#]+_##/;
const DRIVE_ABS_RE = /^[A-Za-z]:[\\/]/;
const DRIVE_REF_RE = /^[A-Za-z]:/;
/** 需要改写的素材字段:[materials 下的列表名, 素材内路径键]。 */
const MATERIAL_PATH_KEYS = [
["videos", "path"],
["audios", "path"],
["images", "path"],
];
/**
* 草稿内素材的根目录名,与 Python 侧 `draft_paths.DRAFT_MEDIA_ROOT` 保持一致。
* 剪映原生用 `Resources/local`;本项目沿用 `assets/{videos,audios,images}`,
* 集中成常量是为了将来切换布局时只需改这一处。
*/
const DRAFT_MEDIA_ROOT = "assets";
function materialLocalDir(draftDir, subDir) {
return path.join(draftDir, DRAFT_MEDIA_ROOT, subDir);
}
function placeholderId() {
return process.env.DRAFT_PATH_PLACEHOLDER_ID || DEFAULT_PLACEHOLDER_ID;
}
function placeholderPrefix() {
return `##_draftpath_placeholder_${placeholderId()}_##`;
}
function isHttpUrl(value) {
if (!value || typeof value !== "string") return false;
try {
const parsed = new URL(value);
return parsed.protocol === "http:" || parsed.protocol === "https:";
} catch {
return false;
}
}
function isDraftRelative(value) {
return typeof value === "string" && PLACEHOLDER_RE.test(value);
}
function isAbsLike(value) {
if (!value || typeof value !== "string") return false;
if (value.startsWith("\\\\") || value.startsWith("//")) return true;
if (DRIVE_ABS_RE.test(value)) return true;
// 两套实现都试:客户端可能在任何平台运行,而草稿路径常为 Windows 形式
return path.posix.isAbsolute(value) || path.win32.isAbsolute(value);
}
/**
* 归一化到可比较形式(分隔符统一为 `/`,Windows 下忽略大小写)。
* 仅做等长替换,因此可以按同一偏移量切回原字符串。
*/
function normalizeForCompare(value) {
const posix = value.replace(/\\/g, "/").replace(/\/+$/, "");
return process.platform === "win32" ? posix.toLowerCase() : posix;
}
/**
* 若 `value` 位于 `directory` 内,返回 posix 相对路径,否则返回 null。
*/
function relativeUnder(value, directory) {
if (!value || typeof value !== "string") return null;
if (!directory || typeof directory !== "string") return null;
const target = normalizeForCompare(value);
const base = normalizeForCompare(directory);
if (!target || !base || target === base) return null;
const prefix = `${base}/`;
if (!target.startsWith(prefix)) return null;
const posixValue = value.replace(/\\/g, "/");
// normalizeForCompare 只在去除末尾斜杠时会改变长度,此时无法按同一偏移量切分
if (target.length !== posixValue.length) return null;
return posixValue.slice(prefix.length);
}
/**
* 把草稿目录内的绝对路径改写为占位符相对路径,其余原样返回。
*
* 原样返回:非字符串 / 空串、http(s) URL、已是占位符形式、非绝对路径
* (含 `"D:"` 这类剪映字体哨兵值)、以及位于 `draftDir` 之外的绝对路径。
*/
function toDraftRelative(value, draftDir) {
if (!value || typeof value !== "string") return value;
if (isHttpUrl(value) || isDraftRelative(value) || !isAbsLike(value)) return value;
const rel = draftDir ? relativeUnder(value, draftDir) : null;
if (rel === null) return value;
return `${placeholderPrefix()}/${rel}`;
}
/**
* 转换为 `draft_meta_info.json` 中 `file_Path` 的形式(`./<相对路径>`)。
*/
function toDraftFilePath(value) {
if (!value || typeof value !== "string") return value;
// `"D:"` / `"C:"` 是字体哨兵值而非路径
if (isHttpUrl(value) || isAbsLike(value) || DRIVE_REF_RE.test(value)) return value;
let rel = value;
const match = value.match(PLACEHOLDER_RE);
if (match) rel = value.slice(match[0].length);
rel = rel.replace(/^\/+/, "");
if (!rel) return value;
return `./${rel}`;
}
/**
* 就地把 `materials` 中位于草稿目录内的绝对素材路径改写为占位符形式。
* @returns {boolean} 是否发生了改写
*/
function normalizeMaterialPaths(materials, draftDir) {
if (!materials || typeof materials !== "object" || !draftDir) return false;
let changed = false;
for (const [listKey, pathKey] of MATERIAL_PATH_KEYS) {
const items = materials[listKey];
if (!Array.isArray(items)) continue;
for (const item of items) {
if (!item || typeof item !== "object") continue;
const oldValue = item[pathKey];
const newValue = toDraftRelative(oldValue, draftDir);
if (newValue !== oldValue) {
item[pathKey] = newValue;
changed = true;
}
}
}
return changed;
}
/**
* 按素材路径的实际形态推断 `config.material_save_mode`。
* 1 = 素材已收纳进草稿目录;0 = 仍引用草稿目录之外的资源。
*/
function computeMaterialSaveMode(materials, draftDir) {
if (!materials || typeof materials !== "object") return 1;
for (const [listKey, pathKey] of MATERIAL_PATH_KEYS) {
const items = materials[listKey];
if (!Array.isArray(items)) continue;
for (const item of items) {
if (!item || typeof item !== "object") continue;
const value = item[pathKey];
if (isHttpUrl(value)) return 0;
if (isAbsLike(value) && relativeUnder(value, draftDir) === null) return 0;
}
}
return 1;
}
/**
* 推断并写入 `jsonData.config.material_save_mode`。
*
* 远程素材本地化之后必须重新推断:URL 直写模式下服务端声明的是 0,
* 客户端把素材下载进草稿目录后若仍写 0,剪映会认为素材在草稿之外。
*/
function ensureMaterialSaveMode(jsonData, draftDir) {
if (!jsonData || typeof jsonData !== "object") return 1;
const mode = computeMaterialSaveMode(jsonData.materials, draftDir);
if (!jsonData.config || typeof jsonData.config !== "object") {
jsonData.config = {};
}
jsonData.config.material_save_mode = mode;
return mode;
}
/**
* 归一化整个 `draft_content.json` / `draft_info.json`。
* @returns {number} 最终写入的 material_save_mode
*/
function normalizeDraftContent(jsonData, draftDir) {
if (!jsonData || typeof jsonData !== "object") return 1;
normalizeMaterialPaths(jsonData.materials, draftDir);
return ensureMaterialSaveMode(jsonData, draftDir);
}
/**
* 把 `draft_meta_info.json` 的名称与路径改写为本地草稿目录。
*
* 服务端写的是**服务端自己的**草稿目录绝对路径,直接下发会把服务端路径
* 带进用户机器;`draft_name` 也必须与本地文件夹名一致,否则剪映首页标题
* 对不上、导出阶段会 DraftNotFound。
*/
function localizeDraftMeta(meta, targetDir, targetId) {
if (!meta || typeof meta !== "object") return meta;
const toPosix = (value) =>
typeof value === "string" ? value.replace(/\\/g, "/") : value;
if (targetId) meta.draft_name = targetId;
if (targetDir) {
meta.draft_fold_path = toPosix(targetDir);
meta.draft_root_path = toPosix(path.dirname(targetDir));
}
// 素材登记表里的 file_Path 同样只保留相对形式
if (Array.isArray(meta.draft_materials)) {
for (const group of meta.draft_materials) {
if (!group || !Array.isArray(group.value)) continue;
for (const entry of group.value) {
if (entry && typeof entry === "object" && entry.file_Path) {
entry.file_Path = toDraftFilePath(entry.file_Path);
}
}
}
}
return meta;
}
module.exports = {
DEFAULT_PLACEHOLDER_ID,
MATERIAL_PATH_KEYS,
DRAFT_MEDIA_ROOT,
materialLocalDir,
placeholderId,
placeholderPrefix,
isHttpUrl,
isDraftRelative,
isAbsLike,
relativeUnder,
toDraftRelative,
toDraftFilePath,
normalizeMaterialPaths,
computeMaterialSaveMode,
ensureMaterialSaveMode,
normalizeDraftContent,
localizeDraftMeta,
};