diff --git a/scripts/nodejs/mixcut_replacements.js b/scripts/nodejs/mixcut_replacements.js new file mode 100644 index 0000000..9a29a10 --- /dev/null +++ b/scripts/nodejs/mixcut_replacements.js @@ -0,0 +1,176 @@ +const fs = require("fs"); +const path = require("path"); + +const IMAGE_EXT = new Set([".jpg", ".jpeg", ".png", ".webp", ".bmp", ".gif"]); + +function isImageFile(filePath) { + return IMAGE_EXT.has(path.extname(filePath).toLowerCase()); +} + +function listMediaFiles(folderPath) { + if (!folderPath || !fs.existsSync(folderPath)) return []; + const entries = fs.readdirSync(folderPath, { withFileTypes: true }); + const files = []; + for (const entry of entries) { + if (!entry.isFile()) continue; + const ext = path.extname(entry.name).toLowerCase(); + if ( + [".mp4", ".mov", ".webm", ".mkv", ".m4v", ".avi", ...IMAGE_EXT].includes(ext) + ) { + files.push(path.join(folderPath, entry.name)); + } + } + return files.sort(); +} + +function pickFromFolder(selectionMode, folderPath, specifiedPath) { + if (selectionMode === "specified" && specifiedPath) { + return specifiedPath; + } + const files = listMediaFiles(folderPath); + if (!files.length) return null; + if (selectionMode === "sequential") { + return files[0]; + } + return files[Math.floor(Math.random() * files.length)]; +} + +function buildSubtitleModeReplacements(records, subtitleModeConfig, probeVideoDuration) { + const mappings = Array.isArray(subtitleModeConfig?.mappings) + ? subtitleModeConfig.mappings + : []; + const replacements = []; + + for (const mapping of mappings) { + if (!mapping?.materialPath || !fs.existsSync(mapping.materialPath)) continue; + const indices = Array.isArray(mapping.subtitleIndices) + ? mapping.subtitleIndices + : []; + if (!indices.length || !records.length) continue; + + let minStart = Infinity; + let maxEnd = -Infinity; + for (const idx of indices) { + const record = records[idx]; + if (!record) continue; + const startSec = Number(record.start ?? record.startTime ?? 0) / 1000; + const endSec = Number(record.end ?? record.endTime ?? 0) / 1000; + minStart = Math.min(minStart, startSec); + maxEnd = Math.max(maxEnd, endSec); + } + if (!Number.isFinite(minStart) || !Number.isFinite(maxEnd)) continue; + const duration = maxEnd - minStart; + if (duration <= 0) continue; + + const isImage = mapping.isImage ?? isImageFile(mapping.materialPath); + const materialDuration = isImage + ? duration + : probeVideoDuration(mapping.materialPath); + + replacements.push({ + startTime: minStart, + duration, + materialPath: mapping.materialPath, + materialDuration, + materialStartOffset: 0, + displayMode: mapping.displayMode || "fullscreen", + pipPosition: mapping.pipPosition || "bottom-right", + pipSizePercent: mapping.pipSizePercent ?? 30, + pipScaleMode: mapping.pipScaleMode || "fit", + isImage, + originalVideoMinimized: mapping.originalVideoMinimized, + }); + } + + return replacements.sort((a, b) => a.startTime - b.startTime); +} + +function buildFixedModeReplacements(fixedModeConfig, videoDuration, probeVideoDuration) { + const replacements = []; + if (!fixedModeConfig?.enabled) return replacements; + + const segments = [ + { key: "opening", reason: "片头" }, + { key: "middle", reason: "片中" }, + { key: "ending", reason: "片尾" }, + ]; + + for (const { key, reason } of segments) { + const seg = fixedModeConfig[key]; + if (!seg?.enabled) continue; + + const min = Number(seg.timeRange?.durationMin ?? 2); + const max = Number(seg.timeRange?.durationMax ?? 5); + let startTime = 0; + let duration = max - min; + if (key === "middle") { + const mid = videoDuration / 2; + startTime = Math.max(0, mid + min); + duration = Math.min(videoDuration, mid + max) - startTime; + } else if (key === "ending") { + duration = max - min; + startTime = Math.max(0, videoDuration - duration); + } else { + startTime = min; + duration = max - min; + } + if (duration <= 0 || startTime >= videoDuration) continue; + + const materialPath = pickFromFolder( + seg.selectionMode || "random", + seg.materialFolderPath, + seg.specifiedMaterialPath, + ); + if (!materialPath || !fs.existsSync(materialPath)) continue; + + const isImage = isImageFile(materialPath); + replacements.push({ + startTime, + duration, + materialPath, + materialDuration: isImage ? duration : probeVideoDuration(materialPath), + materialStartOffset: 0, + displayMode: seg.displayMode || "fullscreen", + pipPosition: seg.pipPosition || "bottom-right", + pipSizePercent: seg.pipSizePercent ?? 30, + pipScaleMode: seg.pipScaleMode || "fit", + isImage, + originalVideoMinimized: seg.originalVideoMinimized, + reason, + }); + } + + return replacements.sort((a, b) => a.startTime - b.startTime); +} + +function buildReplacementsFromMixCutSettings(settings, videoDuration, probeVideoDuration) { + if (!settings || typeof settings !== "object" || !settings.enabled) { + return []; + } + + const mode = settings.mode || "subtitle"; + let replacements = []; + + if (mode === "subtitle") { + const records = Array.isArray(settings.records) ? settings.records : []; + replacements = buildSubtitleModeReplacements( + records, + settings.subtitleModeConfig, + probeVideoDuration, + ); + } else if (mode === "fixed" && settings.fixedModeConfig?.enabled) { + replacements = buildFixedModeReplacements( + settings.fixedModeConfig, + videoDuration, + probeVideoDuration, + ); + } + + return replacements; +} + +module.exports = { + buildReplacementsFromMixCutSettings, + buildSubtitleModeReplacements, + buildFixedModeReplacements, +}; diff --git a/scripts/nodejs/subtitle_bgm_generate.js b/scripts/nodejs/subtitle_bgm_generate.js index 108dda2..7f118f5 100644 --- a/scripts/nodejs/subtitle_bgm_generate.js +++ b/scripts/nodejs/subtitle_bgm_generate.js @@ -422,6 +422,16 @@ globalThis.__nodejsMain = async function main(params) { srtPath = makeOutputPath("subtitle", "srt"); fs.writeFileSync(srtPath, buildSrtContent(records, maxChars), "utf8"); + if (p.recognizeOnly) { + pipelineNative.deleteFile(audioPath); + return { + success: true, + records, + srtPath, + message: `字幕识别完成,共 ${records.length} 条`, + }; + } + const { width, height } = probeVideoSize(current); const assPath = makeOutputPath("subtitle", "ass"); fs.writeFileSync( diff --git a/scripts/nodejs/video_edit_process.js b/scripts/nodejs/video_edit_process.js index 17e4815..a71c4a9 100644 --- a/scripts/nodejs/video_edit_process.js +++ b/scripts/nodejs/video_edit_process.js @@ -8,6 +8,9 @@ const fs = require("fs"); const path = require("path"); const os = require("os"); const { spawnSync } = require("child_process"); +const { + buildReplacementsFromMixCutSettings, +} = require("./mixcut_replacements.js"); const IMAGE_EXT = new Set([".jpg", ".jpeg", ".png", ".webp", ".bmp", ".gif"]); @@ -434,6 +437,14 @@ function normalizeReplacements(raw) { function pickReplacementsFromSettings(settings, videoDuration) { if (!settings || typeof settings !== "object") return []; + const fromMixCut = buildReplacementsFromMixCutSettings( + settings, + videoDuration, + probeVideoDuration, + ); + if (fromMixCut.length) { + return normalizeReplacements(fromMixCut); + } if (Array.isArray(settings.replacements) && settings.replacements.length) { return normalizeReplacements(settings.replacements); }