This commit is contained in:
fengchuanhn@gmail.com
2026-05-21 00:19:11 +08:00
parent 1325ff92e5
commit 7f57dfa9ed
31 changed files with 10437 additions and 12 deletions

View File

@@ -0,0 +1,186 @@
"use strict";
/**
* 视频封面生成:优先高级 Python → PIL Python → ffmpeg drawtext 回退
*/
const fs = require("fs");
const path = require("path");
const os = require("os");
const { spawnSync } = require("child_process");
const pipelineNative = require("./pipeline_native.js");
const coverPython = require("./cover_python_runner.js");
const OUTPUT_DIR = path.join(os.tmpdir(), "aiclient-cover");
function ensureDir() {
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
return OUTPUT_DIR;
}
function execFfmpeg(args) {
const ffmpeg = pipelineNative.locateFfmpeg();
if (!ffmpeg) {
throw new Error("未找到 ffmpeg请配置 AICLIENT_FFMPEG_PATH 或 src-tauri/binaries/ffmpeg.exe");
}
const r = spawnSync(ffmpeg, args, { encoding: "utf8", windowsHide: true });
if (r.status !== 0) {
throw new Error((r.stderr || r.stdout || "").trim() || `ffmpeg 退出码 ${r.status}`);
}
}
function escapeDrawtext(text) {
return String(text)
.replace(/\\/g, "\\\\")
.replace(/'/g, "'\\''")
.replace(/:/g, "\\:")
.replace(/%/g, "\\%");
}
function buildTextPosition(titlePosition) {
if (titlePosition === "top") return "y=50";
if (titlePosition === "center") return "y=(h-text_h)/2";
return "y=h-text_h-50";
}
function runFfmpegCover({ sourceVideo, titleText, effectStyle, coverPath, tempFramePath, emit }) {
emit?.("正在提取视频帧…");
execFfmpeg([
"-ss",
"00:00:03",
"-i",
sourceVideo,
"-vframes",
"1",
"-pix_fmt",
"yuvj420p",
"-c:v",
"mjpeg",
"-strict:v",
"unofficial",
"-f",
"image2",
"-y",
tempFramePath,
]);
const titleFontSize = effectStyle.titleFontSize ?? 90;
const titleFontColor = effectStyle.titleFontColor || "#FFFFFF";
const titlePosition = effectStyle.titlePosition || "bottom";
const textPos = buildTextPosition(titlePosition);
const escaped = escapeDrawtext(titleText);
const vf = `drawtext=text='${escaped}':fontsize=${titleFontSize}:fontcolor=${titleFontColor}:x=(w-text_w)/2:${textPos}:shadowcolor=black:shadowx=2:shadowy=2`;
emit?.("正在叠加标题文字ffmpeg…");
execFfmpeg(["-i", tempFramePath, "-vf", vf, "-y", coverPath]);
}
function shouldUsePilCover(effectStyle) {
if (coverPython.isAdvancedCoverConfig(effectStyle)) return false;
return (
(effectStyle.titleStrokeWidth && effectStyle.titleStrokeWidth > 0) ||
Boolean(effectStyle.titleFontFamily) ||
Boolean(effectStyle.titleColor) ||
effectStyle.titleShadowBlur > 0
);
}
globalThis.__nodejsMain = async function main(params) {
const p = params || {};
const videoPath = String(p.videoPath || "").trim();
const titleText = String(p.titleText || "").trim();
const effectStyle = p.effectStyle || {};
if (!videoPath || !fs.existsSync(videoPath)) {
return { success: false, error: "视频文件不存在,请先在步骤 02/03/05 生成视频" };
}
if (!titleText) {
return { success: false, error: "标题文本不能为空,请先在步骤 04 生成标题" };
}
ensureDir();
const ts = Date.now();
const tempFramePath = path.join(OUTPUT_DIR, `temp_frame_${ts}.jpg`);
const coverPath = path.join(OUTPUT_DIR, `cover_${ts}.jpg`);
let sourceVideo = videoPath;
const custom = String(effectStyle.customVideoPath || "").trim();
if (custom && fs.existsSync(custom)) {
sourceVideo = custom;
}
const emit = (status) => globalThis.__native?.emitProgress?.(status);
try {
if (coverPython.isAdvancedCoverConfig(effectStyle)) {
const adv = coverPython.runAdvancedCoverGenerator({
videoPath: sourceVideo,
titleText,
outputPath: coverPath,
config: effectStyle,
onProgress: emit,
});
if (adv.success) {
return {
success: true,
coverPath: adv.coverPath,
previewImages: adv.previewImages,
message: adv.message || "高级封面生成完成",
mode: "advanced_python",
};
}
emit(`高级生成失败,尝试回退:${(adv.error || "").slice(0, 80)}`);
} else if (shouldUsePilCover(effectStyle) && coverPython.locatePython()) {
const pil = coverPython.runBasicCoverGenerator({
videoPath: sourceVideo,
titleText,
outputPath: coverPath,
config: effectStyle,
timestamp: 3,
onProgress: emit,
});
if (pil.success) {
return {
success: true,
coverPath: pil.coverPath,
message: pil.message || "封面生成完成",
mode: "pil_python",
};
}
emit(`PIL 生成失败,尝试 ffmpeg 回退…`);
}
runFfmpegCover({
sourceVideo,
titleText,
effectStyle,
coverPath,
tempFramePath,
emit,
});
if (fs.existsSync(tempFramePath)) {
try {
fs.unlinkSync(tempFramePath);
} catch {
/* ignore */
}
}
if (!fs.existsSync(coverPath)) {
return { success: false, error: "封面文件未生成" };
}
return {
success: true,
coverPath,
message: "封面生成完成",
mode: "ffmpeg",
};
} catch (e) {
return {
success: false,
error: e.message || String(e),
};
}
};

View File

@@ -0,0 +1,203 @@
"use strict";
/**
* 调用 bundled Python 封面脚本(对齐 Electron ipAgent:generateVideoCover
*/
const fs = require("fs");
const path = require("path");
const { spawnSync } = require("child_process");
const pipelineNative = require("./pipeline_native.js");
function locatePython() {
const env = process.env.AICLIENT_PYTHON_PATH;
if (env && fs.existsSync(env)) return env;
return null;
}
function locateCoverScriptsDir() {
const env = process.env.AICLIENT_COVER_SCRIPTS_DIR;
if (env && fs.existsSync(path.join(env, "advanced_cover_generator.py"))) {
return env;
}
return null;
}
function normalizePathForPython(filePath) {
if (process.platform === "win32") {
return String(filePath).replace(/\\/g, "/");
}
return String(filePath);
}
function buildPythonEnv() {
const env = { ...process.env, PYTHONIOENCODING: "utf-8" };
const ffmpeg = pipelineNative.locateFfmpeg();
if (ffmpeg && fs.existsSync(ffmpeg)) {
const ffmpegDir = path.dirname(ffmpeg);
const sep = process.platform === "win32" ? ";" : ":";
env.PATH = `${ffmpegDir}${sep}${env.PATH || ""}`;
env.AICLIENT_FFMPEG_PATH = ffmpeg;
}
return env;
}
/** 与 Electron main 中 isNewTemplate 判定一致 */
function isAdvancedCoverConfig(config) {
if (!config || typeof config !== "object") return false;
return (
config.blurBackground !== undefined ||
config.extractPerson !== undefined ||
config.personOutlineColor !== undefined ||
config.personOutlineWidth !== undefined ||
config.maskImagePath !== undefined ||
Boolean(config.titleFontFamily) ||
config.titleBackgroundEnabled !== undefined ||
config.personSize !== undefined ||
config.backgroundBlurEnabled !== undefined
);
}
/**
* @param {{ videoPath: string, titleText: string, outputPath: string, config: object, onProgress?: (s: string) => void }} opts
*/
function runAdvancedCoverGenerator(opts) {
const python = locatePython();
const scriptsDir = locateCoverScriptsDir();
if (!python) {
return { success: false, error: "未找到 Python 运行时,请检查 resources-bundles/python-runtimebackup" };
}
if (!scriptsDir) {
return { success: false, error: "未找到封面脚本目录cover-python" };
}
const script = path.join(scriptsDir, "advanced_cover_generator.py");
if (!fs.existsSync(script)) {
return { success: false, error: `缺少脚本: ${script}` };
}
const configJson = JSON.stringify({
...opts.config,
output: opts.outputPath,
customVideoPath: opts.config.customVideoPath || undefined,
});
opts.onProgress?.("正在使用高级封面生成器…");
const args = [
script,
"--video",
normalizePathForPython(opts.videoPath),
"--title",
opts.titleText,
"--output",
normalizePathForPython(opts.outputPath),
"--config",
configJson,
];
const r = spawnSync(python, args, {
encoding: "utf8",
windowsHide: true,
cwd: scriptsDir,
env: buildPythonEnv(),
maxBuffer: 20 * 1024 * 1024,
});
if (r.error) {
return { success: false, error: r.error.message || String(r.error) };
}
let previewImages = [];
const stdout = (r.stdout || "").trim();
if (stdout) {
try {
const lastLine = stdout.split("\n").filter(Boolean).pop();
const parsed = JSON.parse(lastLine || stdout);
if (parsed.previewImages) previewImages = parsed.previewImages;
if (parsed.success === false) {
return {
success: false,
error: parsed.error || parsed.message || "高级封面生成失败",
stderr: r.stderr,
};
}
} catch {
/* 非 JSON 时仅检查文件 */
}
}
if (r.status !== 0 || !fs.existsSync(opts.outputPath)) {
const errMsg =
(r.stderr || "").trim() ||
stdout ||
`Python 退出码 ${r.status ?? "unknown"}`;
return { success: false, error: errMsg };
}
return {
success: true,
coverPath: opts.outputPath,
previewImages,
message: "高级封面生成完成",
};
}
/**
* PIL 中等复杂度封面cover_generator.py
* @param {{ videoPath: string, titleText: string, outputPath: string, config: object, timestamp?: number, onProgress?: (s: string) => void }} opts
*/
function runBasicCoverGenerator(opts) {
const python = locatePython();
const scriptsDir = locateCoverScriptsDir();
if (!python || !scriptsDir) {
return { success: false, error: "未找到 Python 或封面脚本" };
}
const script = path.join(scriptsDir, "cover_generator.py");
if (!fs.existsSync(script)) {
return { success: false, error: `缺少脚本: ${script}` };
}
opts.onProgress?.("正在使用 PIL 封面生成器…");
const configJson = JSON.stringify(opts.config || {});
const args = [
script,
"--video",
normalizePathForPython(opts.videoPath),
"--title",
opts.titleText,
"--output",
normalizePathForPython(opts.outputPath),
"--config",
configJson,
"--timestamp",
String(opts.timestamp ?? 3),
];
const r = spawnSync(python, args, {
encoding: "utf8",
windowsHide: true,
cwd: scriptsDir,
env: buildPythonEnv(),
maxBuffer: 10 * 1024 * 1024,
});
if (r.status === 0 && fs.existsSync(opts.outputPath)) {
return { success: true, coverPath: opts.outputPath, message: "封面生成完成" };
}
return {
success: false,
error: (r.stderr || r.stdout || "").trim() || `Python 退出码 ${r.status}`,
};
}
module.exports = {
isAdvancedCoverConfig,
runAdvancedCoverGenerator,
runBasicCoverGenerator,
locatePython,
locateCoverScriptsDir,
};

View File

@@ -259,6 +259,25 @@ function parseTranscriptionText(trans) {
return fullText;
}
/** @returns {Array<{ text: string, start: number, end: number }>} start/end 毫秒 */
function parseTranscriptionSentences(trans) {
const records = [];
if (trans?.transcripts?.length) {
for (const t of trans.transcripts) {
if (t.sentences?.length) {
for (const s of t.sentences) {
const text = String(s.text || "").trim();
if (!text) continue;
const start = Number(s.begin_time ?? s.start_time ?? 0);
const end = Number(s.end_time ?? s.end_time ?? start + 2000);
records.push({ text, start, end });
}
}
}
}
return records;
}
async function fetchJson(url, options = {}) {
const res = await fetch(url, options);
const text = await res.text();
@@ -537,7 +556,8 @@ async function aliyunAsrFiletrans(audioUrl, opts) {
}
const trans = await fetchJson(transUrl);
const fullText = parseTranscriptionText(trans);
return { success: true, text: fullText };
const sentences = parseTranscriptionSentences(trans);
return { success: true, text: fullText, sentences };
}
if (status === "FAILED" || status === "UNKNOWN") {
const detail =
@@ -597,10 +617,21 @@ async function localAsr(audioPath, info) {
data.data?.records ||
data.records ||
[];
const text = Array.isArray(records)
? records.map((r) => r.text || "").join("")
const sentences = Array.isArray(records)
? records
.map((r) => {
const text = String(r.text || "").trim();
if (!text) return null;
const start = Number(r.start ?? r.begin_time ?? 0);
const end = Number(r.end ?? r.end_time ?? start + 2000);
return { text, start, end };
})
.filter(Boolean)
: [];
const text = sentences.length
? sentences.map((r) => r.text).join("")
: data.text || data.data?.text || "";
resolve({ success: true, text: String(text) });
resolve({ success: true, text: String(text), sentences });
} else {
resolve({ success: false, error: data.msg || data.error || raw.slice(0, 200) });
}
@@ -656,8 +687,10 @@ module.exports = {
aliyunOssUpload,
aliyunAsrFiletrans,
localAsr,
parseTranscriptionSentences,
openaiChat,
resolveTtsModel,
splitTextForTts,
qwenTtsSynthesize,
locateFfmpeg,
};

View File

@@ -0,0 +1,121 @@
"use strict";
/**
* 发布用 Puppeteer 浏览器(按平台/账号隔离 userDataDir
*/
const fs = require("fs");
const path = require("path");
const os = require("os");
const {
COMMON_BROWSER_ARGS,
BROWSER_IGNORE_DEFAULT_ARGS,
STEALTH_INIT_SCRIPT,
DEFAULT_USER_AGENT,
} = require("./douyin_browser_constants.js");
const browsers = new Map();
function getRuntimeDataPath(...parts) {
const base = process.env.AICLIENT_DATA_DIR || path.join(os.homedir(), ".aiclient");
return path.join(base, ...parts);
}
function getChromiumExecutablePath() {
const fromEnv =
process.env.AICLIENT_CHROMIUM_PATH || process.env.PUPPETEER_EXECUTABLE_PATH;
if (fromEnv && fs.existsSync(fromEnv)) return fromEnv;
const candidates = [];
if (process.platform === "win32") {
const pf = process.env.ProgramFiles || "C:\\Program Files";
const local = process.env.LOCALAPPDATA || "";
candidates.push(
path.join(pf, "Google", "Chrome", "Application", "chrome.exe"),
path.join(local, "Google", "Chrome", "Application", "chrome.exe"),
path.join(pf, "Microsoft", "Edge", "Application", "msedge.exe"),
);
}
return candidates.find((p) => p && fs.existsSync(p));
}
function loadPuppeteer() {
try {
return require("puppeteer-core");
} catch {
return require("puppeteer");
}
}
function contextKey(platform, accountId) {
return accountId ? `publish_${platform}_${accountId}` : `publish_${platform}`;
}
function userDataDir(platform, accountId) {
const sub = accountId ? `${platform}_${accountId}` : platform;
return getRuntimeDataPath("browser-data", "publish", sub);
}
function delay(ms) {
return new Promise((r) => setTimeout(r, ms));
}
async function getPublishBrowser(platform, accountId) {
const key = contextKey(platform, accountId);
const existing = browsers.get(key);
if (existing && existing.isConnected()) {
return existing;
}
const puppeteer = loadPuppeteer();
const dir = userDataDir(platform, accountId);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
const executablePath = getChromiumExecutablePath();
if (!executablePath) {
throw new Error(
"未找到 Chrome/Edge请安装浏览器或设置 AICLIENT_CHROMIUM_PATH",
);
}
const browser = await puppeteer.launch({
executablePath,
headless: false,
userDataDir: dir,
args: COMMON_BROWSER_ARGS,
ignoreDefaultArgs: BROWSER_IGNORE_DEFAULT_ARGS,
defaultViewport: { width: 1280, height: 900 },
});
browser.on("disconnected", () => browsers.delete(key));
browsers.set(key, browser);
return browser;
}
async function newPublishPage(platform, accountId, cookies) {
const browser = await getPublishBrowser(platform, accountId);
const page = await browser.newPage();
await page.setUserAgent(DEFAULT_USER_AGENT);
await page.evaluateOnNewDocument(STEALTH_INIT_SCRIPT);
page.setDefaultNavigationTimeout(60000);
page.setDefaultTimeout(60000);
if (Array.isArray(cookies) && cookies.length) {
try {
await page.setCookie(...cookies);
} catch (e) {
console.warn("setCookie 失败:", e.message);
}
}
return page;
}
const LOGIN_URLS = {
douyin: "https://creator.douyin.com/",
kuaishou: "https://cp.kuaishou.com/",
shipin: "https://channels.weixin.qq.com/",
xiaohongshu: "https://creator.xiaohongshu.com/",
};
module.exports = {
delay,
getPublishBrowser,
newPublishPage,
userDataDir,
LOGIN_URLS,
getChromiumExecutablePath,
};

View File

@@ -0,0 +1,71 @@
"use strict";
const { delay, newPublishPage, LOGIN_URLS } = require("./publish_browser.js");
async function checkDouyin(page) {
await page.goto("https://creator.douyin.com/creator-micro/content/upload", {
waitUntil: "domcontentloaded",
});
await delay(3000);
const url = page.url();
if (url.includes("login") || url.includes("passport")) {
return { isLoggedIn: false };
}
const avatar = await page.$("#header-avatar");
if (!avatar) return { isLoggedIn: false };
let nickname = "";
try {
nickname = await page.$eval("#header-avatar", (el) => el.getAttribute("alt") || "");
} catch {
/* ignore */
}
return {
isLoggedIn: true,
userInfo: { nickname: nickname || "抖音用户", userId: "" },
};
}
globalThis.__nodejsMain = async function main(params) {
const p = params || {};
const platform = String(p.platform || "").trim();
const accountId = p.accountId || null;
const cookies = Array.isArray(p.cookies) ? p.cookies : [];
if (!LOGIN_URLS[platform]) {
return { success: false, isLoggedIn: false, error: `不支持的平台: ${platform}` };
}
globalThis.__native?.emitProgress?.("正在检测登录状态…");
const page = await newPublishPage(platform, accountId, cookies);
try {
let result;
if (platform === "douyin") {
result = await checkDouyin(page);
} else {
await page.goto(LOGIN_URLS[platform], { waitUntil: "domcontentloaded" });
await delay(3000);
const url = page.url();
const loggedIn = !["login", "passport", "auth"].some((k) => url.includes(k));
result = {
isLoggedIn: loggedIn,
userInfo: loggedIn ? { nickname: "已登录", userId: "" } : undefined,
};
}
return {
success: true,
isLoggedIn: Boolean(result.isLoggedIn),
userInfo: result.userInfo,
};
} catch (e) {
return {
success: false,
isLoggedIn: false,
message: e.message || String(e),
};
} finally {
try {
await page.close();
} catch {
/* ignore */
}
}
};

View File

@@ -0,0 +1,82 @@
"use strict";
const { delay, newPublishPage, LOGIN_URLS } = require("./publish_browser.js");
const LOGIN_SELECTORS = {
douyin: ["#header-avatar", ".creator-avatar", 'input[type="file"]'],
kuaishou: ['[class*="avatar"]', ".user-avatar", '[class*="upload"]'],
shipin: ['[class*="avatar"]', ".avatar"],
xiaohongshu: ['[class*="avatar"]', ".user-avatar", '[class*="upload"]'],
};
const URL_EXCLUDE = {
douyin: ["login", "passport"],
kuaishou: ["login", "passport"],
shipin: ["login", "auth", "qr"],
xiaohongshu: ["login", "passport", "account"],
};
async function checkLoggedIn(page, platform) {
const url = page.url();
const excludes = URL_EXCLUDE[platform] || URL_EXCLUDE.douyin;
if (excludes.some((k) => url.includes(k))) return false;
const sels = LOGIN_SELECTORS[platform] || LOGIN_SELECTORS.douyin;
for (const sel of sels) {
const el = await page.$(sel);
if (el) return true;
}
return false;
}
globalThis.__nodejsMain = async function main(params) {
const p = params || {};
const platform = String(p.platform || "").trim();
const accountId = p.accountId || null;
const loginUrl = LOGIN_URLS[platform];
if (!loginUrl) {
return { success: false, error: `不支持的平台: ${platform}` };
}
globalThis.__native?.emitProgress?.("正在打开登录页,请在浏览器中完成登录…");
const page = await newPublishPage(platform, accountId, []);
try {
await page.goto(loginUrl, { waitUntil: "domcontentloaded", timeout: 60000 });
await delay(3000);
const maxMs = 300000;
const start = Date.now();
while (Date.now() - start < maxMs) {
if (await checkLoggedIn(page, platform)) {
const cookies = await page.cookies();
let nickname = "";
try {
nickname = await page.$eval(
".username, .account-name, .user-name, .nickname",
(el) => (el.textContent || "").trim(),
);
} catch {
nickname = "已登录用户";
}
return {
success: true,
message: "登录成功",
nickname: nickname || "已登录用户",
uid: `user_${Date.now()}`,
cookies,
};
}
await delay(2000);
globalThis.__native?.emitProgress?.("等待登录完成…");
}
return {
success: false,
error: "登录超时5 分钟),请重试",
};
} finally {
try {
await page.close();
} catch {
/* keep browser for user */
}
}
};

View File

@@ -0,0 +1,36 @@
"use strict";
const { newPublishPage } = require("./publish_browser.js");
const { publishToDouyin } = require("./publish_to_douyin.js");
const PLATFORM_LABELS = {
douyin: "抖音",
kuaishou: "快手",
shipin: "视频号",
xiaohongshu: "小红书",
};
async function publishToPlatform(platform, accountId, cookies, params) {
const label = PLATFORM_LABELS[platform] || platform;
if (platform === "douyin") {
const page = await newPublishPage(platform, accountId, cookies);
try {
return await publishToDouyin(page, params);
} finally {
if (!params.keepPageOpen) {
try {
await page.close();
} catch {
/* ignore */
}
}
}
}
return {
success: false,
status: "failed",
message: `${label}自动发布即将支持,请先使用抖音`,
};
}
module.exports = { publishToPlatform, PLATFORM_LABELS };

View File

@@ -0,0 +1,197 @@
"use strict";
/**
* 抖音创作者中心发布(对齐 Electron publishToDouyinPuppeteer 版)
*/
const fs = require("fs");
const { delay } = require("./publish_browser.js");
async function clickIfVisible(page, selector) {
const el = await page.$(selector);
if (!el) return false;
try {
await el.click();
return true;
} catch {
return false;
}
}
async function typeIntoFirst(page, selectors, text) {
for (const sel of selectors) {
const el = await page.$(sel);
if (!el) continue;
try {
await el.click({ clickCount: 3 });
await page.keyboard.down("Control");
await page.keyboard.press("a");
await page.keyboard.up("Control");
await page.keyboard.press("Backspace");
await el.type(text, { delay: 30 });
return true;
} catch {
/* try next */
}
}
return false;
}
async function publishToDouyin(page, params) {
const { videoPath, coverPath, title, tags = [], autoPublish } = params;
if (!fs.existsSync(videoPath)) throw new Error("视频文件不存在");
if (!fs.existsSync(coverPath)) throw new Error("封面文件不存在");
globalThis.__native?.emitProgress?.("正在打开抖音上传页…");
const uploadUrl = "https://creator.douyin.com/creator-micro/content/upload";
await page.goto(uploadUrl, { waitUntil: "domcontentloaded" });
await delay(3000);
let currentUrl = page.url();
if (currentUrl.includes("login") || currentUrl.includes("passport")) {
throw new Error("未登录抖音,请先在步骤 07 点击「登录平台」");
}
let avatar = await page.$("#header-avatar");
if (!avatar) {
await delay(5000);
avatar = await page.$("#header-avatar");
}
if (!avatar) {
throw new Error("抖音登录已过期,请重新登录");
}
if (!page.url().includes("content/upload")) {
await page.goto(uploadUrl, { waitUntil: "domcontentloaded" });
await delay(3000);
}
globalThis.__native?.emitProgress?.("正在上传视频…");
await page.waitForSelector('input[type="file"]', { timeout: 60000 });
const fileInputs = await page.$$('input[type="file"]');
if (!fileInputs.length) throw new Error("未找到视频上传控件");
await fileInputs[0].uploadFile(videoPath);
await delay(5000);
globalThis.__native?.emitProgress?.("等待视频处理…");
const start = Date.now();
let ready = false;
while (Date.now() - start < 300000 && !ready) {
const titleBox = await page.$('textarea[placeholder*="标题"], input[placeholder*="标题"]');
if (titleBox) {
try {
const disabled = await page.evaluate((el) => el.disabled, titleBox);
if (!disabled) ready = true;
} catch {
ready = true;
}
}
if (!ready) await delay(2000);
}
globalThis.__native?.emitProgress?.("正在上传封面…");
try {
await clickIfVisible(page, 'button');
const buttons = await page.$$("button");
for (const btn of buttons) {
const text = await page.evaluate((el) => el.textContent || "", btn);
if (String(text).includes("选择封面")) {
await btn.click();
await delay(2000);
break;
}
}
const imgInput = await page.$('input[type="file"][accept*="image"]');
if (imgInput) {
await imgInput.uploadFile(coverPath);
await delay(3000);
}
const doneBtns = await page.$$("button");
for (const btn of doneBtns) {
const text = await page.evaluate((el) => el.textContent || "", btn);
if (String(text).trim() === "完成") {
await btn.click();
await delay(2000);
break;
}
}
} catch (e) {
console.warn("封面上传跳过:", e.message);
}
globalThis.__native?.emitProgress?.("正在填写标题与话题…");
const titleText = String(title || "").slice(0, 30);
await typeIntoFirst(
page,
[
'input[placeholder*="填写作品标题"]',
'input[placeholder*="标题"]',
'textarea[placeholder*="标题"]',
],
titleText,
);
const tagList = Array.isArray(tags) ? tags : [];
if (tagList.length) {
const descOk = await typeIntoFirst(
page,
[
'textarea[placeholder*="描述"]',
'textarea[placeholder*="简介"]',
'motion[contenteditable="true"]',
'div[contenteditable="true"]',
],
"",
);
if (descOk) {
for (const tag of tagList) {
const t = String(tag).trim();
if (!t) continue;
const clean = t.startsWith("#") ? t : `#${t}`;
await page.keyboard.type(clean, { delay: 40 });
await page.keyboard.press("Space");
await delay(400);
}
}
}
if (!autoPublish) {
return {
success: false,
status: "manual_pending",
message: "内容已填写,请在浏览器中检查并手动点击发布",
videoUrl: page.url(),
keepPageOpen: true,
};
}
globalThis.__native?.emitProgress?.("正在点击发布…");
const publishBtns = await page.$$("button");
let clicked = false;
for (const btn of publishBtns) {
const text = (await page.evaluate((el) => el.textContent || "", btn)).trim();
if (text === "发布" || text === "确定发布") {
await btn.click();
clicked = true;
break;
}
}
if (!clicked) {
return {
success: false,
status: "manual_pending",
message: "未找到发布按钮,请手动发布",
videoUrl: page.url(),
};
}
await delay(5000);
return {
success: true,
status: "success",
message: "已提交发布",
videoUrl: page.url(),
};
}
module.exports = { publishToDouyin };

View File

@@ -169,8 +169,36 @@ async function uploadFile(baseUrl, apiKey, filePath) {
return fileName;
}
function locateFfmpegBinary() {
const fromEnv = process.env.AICLIENT_FFMPEG_PATH;
if (fromEnv && fs.existsSync(fromEnv)) return fromEnv;
return process.platform === "win32" ? "ffmpeg.exe" : "ffmpeg";
}
function locateFfprobeBinary() {
const fromEnv = process.env.AICLIENT_FFPROBE_PATH;
if (fromEnv && fs.existsSync(fromEnv)) return fromEnv;
const ffmpeg = locateFfmpegBinary();
if (ffmpeg && ffmpeg !== "ffmpeg" && ffmpeg !== "ffmpeg.exe") {
const dir = path.dirname(ffmpeg);
const sibling = path.join(dir, process.platform === "win32" ? "ffprobe.exe" : "ffprobe");
if (fs.existsSync(sibling)) return sibling;
}
return process.platform === "win32" ? "ffprobe.exe" : "ffprobe";
}
function parseDurationFromFfmpegStderr(text) {
const m = String(text || "").match(/Duration:\s*(\d+):(\d+):(\d+(?:\.\d+)?)/);
if (!m) return 0;
const h = parseFloat(m[1]);
const min = parseFloat(m[2]);
const s = parseFloat(m[3]);
if (!Number.isFinite(h + min + s)) return 0;
return h * 3600 + min * 60 + s;
}
function probeAudioDuration(audioPath) {
const ffprobe = process.env.AICLIENT_FFPROBE_PATH || "ffprobe";
const ffprobe = locateFfprobeBinary();
const r = spawnSync(
ffprobe,
[
@@ -184,9 +212,19 @@ function probeAudioDuration(audioPath) {
],
{ encoding: "utf8", windowsHide: true },
);
if (r.status !== 0) return 0;
const n = parseFloat(String(r.stdout || "").trim());
return Number.isFinite(n) && n > 0 ? n : 0;
if (r.status === 0) {
const n = parseFloat(String(r.stdout || "").trim());
if (Number.isFinite(n) && n > 0) return n;
}
const ffmpeg = locateFfmpegBinary();
const r2 = spawnSync(ffmpeg, ["-i", audioPath], {
encoding: "utf8",
windowsHide: true,
});
const fromStderr = parseDurationFromFfmpegStderr(r2.stderr || "");
if (fromStderr > 0) return fromStderr;
return 0;
}
async function createTask(config, videoFileName, audioFileName, audioDuration) {
@@ -379,6 +417,14 @@ globalThis.__nodejsMain = async function (params) {
if (!Number.isFinite(audioDuration) || audioDuration <= 0) {
audioDuration = probeAudioDuration(audioPath);
}
if (audioDuration <= 0) {
return {
success: false,
error:
"无法获取音频时长。云端口播需将时长传给 RunningHub请安装 ffmpeg/ffprobe或升级客户端后重试",
};
}
const durationSec = Math.ceil(audioDuration);
try {
const videoFileName = await uploadFile(config.baseUrl, config.apiKey, videoPath);
@@ -387,7 +433,7 @@ globalThis.__nodejsMain = async function (params) {
config,
videoFileName,
audioFileName,
audioDuration,
durationSec,
);
const done = await waitForTask(config, taskId);
const remoteUrl = pickResultUrl(done.results);
@@ -398,7 +444,12 @@ globalThis.__nodejsMain = async function (params) {
};
}
const localPath = await ensureLocalVideo(remoteUrl);
return { success: true, videoPath: localPath, taskId };
return {
success: true,
videoPath: localPath,
taskId,
audioDuration: durationSec,
};
} catch (e) {
return { success: false, error: e.message || String(e) };
}

View File

@@ -0,0 +1,288 @@
"use strict";
/**
* 自动生成字幕并混 BGM对齐 Electron autoGenerateSubtitleAndBGM
*/
const fs = require("fs");
const path = require("path");
const os = require("os");
const { spawnSync } = require("child_process");
const pipelineNative = require("./pipeline_native.js");
const desktopConfig = require("./desktop_config.js");
const OUTPUT_DIR = path.join(os.tmpdir(), "aiclient-subtitle-bgm");
function ensureDir() {
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
return OUTPUT_DIR;
}
function makeOutputPath(prefix, ext = "mp4") {
return path.join(ensureDir(), `${prefix}_${Date.now()}.${ext}`);
}
function execFfmpeg(args) {
const ffmpeg = pipelineNative.locateFfmpeg();
if (!ffmpeg) {
throw new Error("未找到 ffmpeg请配置 AICLIENT_FFMPEG_PATH 或 src-tauri/binaries/ffmpeg.exe");
}
const r = spawnSync(ffmpeg, args, { encoding: "utf8", windowsHide: true });
if (r.status !== 0) {
throw new Error((r.stderr || r.stdout || "").trim() || `ffmpeg 退出码 ${r.status}`);
}
}
function probeVideoDuration(videoPath) {
const ffprobe = pipelineNative.locateFfmpeg()?.replace(/ffmpeg(\.exe)?$/i, "ffprobe$1");
if (ffprobe && fs.existsSync(ffprobe)) {
const r = spawnSync(
ffprobe,
[
"-v",
"error",
"-show_entries",
"format=duration",
"-of",
"default=noprint_wrappers=1:nokey=1",
videoPath,
],
{ encoding: "utf8", windowsHide: true },
);
if (r.status === 0) {
const n = parseFloat(String(r.stdout || "").trim());
if (Number.isFinite(n) && n > 0) return n;
}
}
const ffmpeg = pipelineNative.locateFfmpeg();
const r2 = spawnSync(ffmpeg, ["-i", videoPath], { encoding: "utf8", windowsHide: true });
const stderr = r2.stderr || "";
const m = stderr.match(/Duration:\s*(\d+):(\d+):(\d+(?:\.\d+)?)/);
if (m) {
return Number(m[1]) * 3600 + Number(m[2]) * 60 + Number(m[3]);
}
return 0;
}
function splitScriptLines(text) {
return String(text || "")
.split(/(?<=[。!?.!?])\s*|\n+/)
.map((s) => s.trim())
.filter(Boolean);
}
function alignScriptToRecords(script, records) {
const lines = splitScriptLines(script);
if (!lines.length || !records.length) return records;
if (lines.length === records.length) {
return records.map((r, i) => ({ ...r, text: lines[i] }));
}
const startMs = records[0].start;
const endMs = records[records.length - 1].end;
const total = Math.max(endMs - startMs, lines.length * 2000);
const step = total / lines.length;
return lines.map((text, i) => ({
text,
start: Math.round(startMs + i * step),
end: Math.round(startMs + (i + 1) * step),
}));
}
function recordsFromTextEvenly(text, durationSec) {
const lines = splitScriptLines(text);
if (!lines.length) return [];
const totalMs = Math.max(Math.round(durationSec * 1000), lines.length * 1500);
const step = totalMs / lines.length;
return lines.map((line, i) => ({
text: line,
start: Math.round(i * step),
end: Math.round((i + 1) * step),
}));
}
function formatSrtTime(ms) {
const h = Math.floor(ms / 3600000);
const m = Math.floor((ms % 3600000) / 60000);
const s = Math.floor((ms % 60000) / 1000);
const msPart = Math.floor(ms % 1000);
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")},${String(msPart).padStart(3, "0")}`;
}
function buildSrtContent(records) {
return records
.map((r, i) => {
const start = formatSrtTime(r.start);
const end = formatSrtTime(Math.max(r.end, r.start + 200));
return `${i + 1}\n${start} --> ${end}\n${r.text}\n`;
})
.join("\n");
}
function escapeSubPath(filePath) {
return filePath.replace(/\\/g, "/").replace(/:/g, "\\:").replace(/'/g, "\\'");
}
function addSubtitleToVideo(inputVideo, srtPath, forceStyle) {
const outputVideo = makeOutputPath("with_subtitle");
const style = forceStyle || "FontName=Microsoft YaHei,FontSize=24,PrimaryColour=&H00FFFFFF,OutlineColour=&H00000000,Outline=2,Alignment=2,MarginV=40";
const sub = escapeSubPath(srtPath);
const vf = `subtitles='${sub}':force_style='${style}'`;
execFfmpeg(["-y", "-i", inputVideo, "-vf", vf, "-c:a", "copy", outputVideo]);
return outputVideo;
}
function addBgmToVideo(inputVideo, bgmPath, bgmVolume = 30) {
const outputVideo = makeOutputPath("with_bgm");
const ratio = Number(bgmVolume) / 100;
const filter = `[0:a]volume=1.0[a0];[1:a]volume=${ratio}[a1];[a0][a1]amix=inputs=2:duration=first`;
execFfmpeg([
"-y",
"-i",
inputVideo,
"-stream_loop",
"-1",
"-i",
bgmPath,
"-filter_complex",
filter,
"-c:v",
"copy",
"-shortest",
outputVideo,
]);
return outputVideo;
}
async function runAsr(audioPath, modelConfig, scriptContent) {
const asrMode = modelConfig.asrMode || "online";
if (asrMode === "online") {
if (!modelConfig.aliyunApiKey) {
throw new Error("在线 ASR 需配置 BAILIAN_API_KEY 或 DASHSCOPE_API_KEY");
}
if (!modelConfig.ossConfig?.bucket) {
throw new Error("在线 ASR 需配置 OSS上传音频供识别");
}
globalThis.__native?.emitProgress?.("正在上传音频到 OSS…");
const upload = await pipelineNative.aliyunOssUpload(audioPath, modelConfig.ossConfig);
if (!upload?.success || !upload.url) {
throw new Error(upload?.error || "OSS 上传失败");
}
globalThis.__native?.emitProgress?.("正在进行语音识别…");
const asr = await pipelineNative.aliyunAsrFiletrans(upload.url, {
apiKey: modelConfig.aliyunApiKey,
model: "qwen3-asr-flash-filetrans",
});
if (!asr?.success) {
throw new Error(asr?.error || "语音识别失败");
}
let records = Array.isArray(asr.sentences) ? asr.sentences : [];
if (!records.length && asr.text) {
records = recordsFromTextEvenly(asr.text, 60);
}
return { text: asr.text || "", records };
}
const info = modelConfig.asrServerInfo;
if (!info) {
throw new Error("本地 ASR 需配置 ASR_SERVER_URL 或 ASR_SERVER_INFO");
}
globalThis.__native?.emitProgress?.("正在调用本地语音识别…");
const asr = await pipelineNative.localAsr(audioPath, info);
if (!asr?.success) {
throw new Error(asr?.error || "本地语音识别失败");
}
let records = Array.isArray(asr.sentences) ? asr.sentences : [];
if (!records.length && asr.text) {
records = recordsFromTextEvenly(asr.text, 60);
}
return { text: asr.text || "", records };
}
globalThis.__nodejsMain = async function main(params) {
const p = params || {};
const inputVideo = String(p.inputVideo || "").trim();
if (!inputVideo || !fs.existsSync(inputVideo)) {
return { success: false, error: "缺少或找不到输入视频,请先在步骤 02/03 生成并处理视频" };
}
const autoSubtitle = Boolean(p.autoSubtitle);
const bgmEnabled = Boolean(p.bgmEnabled);
if (!autoSubtitle && !bgmEnabled) {
return { success: false, error: "请至少勾选「自动生成字幕」或「添加背景音乐」" };
}
if (bgmEnabled) {
const bgm = String(p.bgmPath || "").trim();
if (!bgm || !fs.existsSync(bgm)) {
return { success: false, error: "已启用背景音乐,请先选择音乐文件" };
}
}
const modelConfig = desktopConfig.buildModelConfig();
const check = desktopConfig.validateModelConfig(modelConfig);
if (autoSubtitle && !check.ok) {
return { success: false, error: check.error };
}
if (autoSubtitle && (modelConfig.asrMode === "online" || !modelConfig.asrMode)) {
const appCheck = desktopConfig.validateModelConfig(modelConfig);
if (!appCheck.ok && modelConfig.asrMode === "online") {
return { success: false, error: appCheck.error };
}
}
let current = inputVideo;
let srtPath = "";
try {
if (autoSubtitle) {
globalThis.__native?.emitProgress?.("正在提取音频…");
const audioPath = await pipelineNative.ffmpegExtractAudio(current);
const scriptContent = String(p.scriptContent || "").trim();
const { records: rawRecords } = await runAsr(audioPath, modelConfig, scriptContent);
let records = rawRecords;
if (p.smartSubtitle !== false && scriptContent) {
records = alignScriptToRecords(scriptContent, records);
}
if (!records.length) {
const duration = probeVideoDuration(current);
const fallbackText = scriptContent || " ";
records = recordsFromTextEvenly(fallbackText, duration || 30);
}
if (!records.length) {
return { success: false, error: "未识别到语音内容,无法生成字幕" };
}
srtPath = makeOutputPath("subtitle", "srt");
fs.writeFileSync(srtPath, buildSrtContent(records), "utf8");
globalThis.__native?.emitProgress?.("正在烧录字幕到视频…");
const forceStyle = String(p.subtitleForceStyle || "").trim();
current = addSubtitleToVideo(current, srtPath, forceStyle || undefined);
pipelineNative.deleteFile(audioPath);
}
if (bgmEnabled) {
globalThis.__native?.emitProgress?.("正在混合背景音乐…");
current = addBgmToVideo(current, p.bgmPath, p.bgmVolume ?? 30);
}
return {
success: true,
videoPath: current,
srtPath: srtPath || null,
message: autoSubtitle && bgmEnabled
? "字幕与背景音乐处理完成"
: autoSubtitle
? "字幕生成完成"
: "背景音乐添加完成",
};
} catch (e) {
return {
success: false,
error: e.message || String(e),
partialVideo: current !== inputVideo ? current : null,
};
}
};

View File

@@ -0,0 +1,174 @@
"use strict";
/**
* 生成标题、标签、分组关键词(对齐 Electron ipAgent:generateTitleTags
*/
const pipelineNative = require("./pipeline_native.js");
const desktopConfig = require("./desktop_config.js");
const KEYWORD_GROUPS = ["重点词/成语词", "描述词", "行动词", "情感词"];
const DEFAULT_TITLE_TAG_PROMPT = `你是短视频标题与标签助手。分析以下文案内容,生成:
1. 一个最吸引人的标题不超过30字若需多个建议可写在 title 字段内用换行分隔)
2. 8-10 个相关话题标签(带 # 号)
3. 四个分组关键词,每组 0-2 个词:重点词/成语词、描述词、行动词、情感词
文案内容:
{{content}}
请只返回 JSON不要 markdown格式如下
{
"title": "标题",
"tags": ["#标签1", "#标签2"],
"keywords": {
"重点词/成语词": ["词1"],
"描述词": [],
"行动词": [],
"情感词": []
}
}`;
function buildPrompt(template, content) {
let prompt = String(template || "").trim();
if (!prompt) prompt = DEFAULT_TITLE_TAG_PROMPT;
return prompt
.replace(/\{\{content\}\}/g, content)
.replace(/\{content\}/g, content);
}
function cleanJsonText(raw) {
let text = String(raw || "").trim();
text = text.replace(/```json\s*/gi, "");
text = text.replace(/```\s*/g, "");
text = text.trim();
text = text.replace(/"/g, '"').replace(/"/g, '"');
text = text.replace(/'/g, "'").replace(/'/g, "'");
text = text.replace(//g, ",");
const m = text.match(/\{[\s\S]*\}/);
if (m) text = m[0];
return text;
}
function normalizeTags(tags) {
if (Array.isArray(tags)) {
return tags
.map((t) => String(t || "").trim())
.filter(Boolean)
.join(", ");
}
return String(tags || "").trim();
}
function normalizeKeywordGroup(val) {
if (Array.isArray(val)) {
return val
.map((w) => String(w || "").trim())
.filter(Boolean)
.join("\n");
}
if (typeof val === "string") {
return val
.split(/[,\n]/)
.map((w) => w.trim())
.filter(Boolean)
.join("\n");
}
return "";
}
function parseTitleTagsContent(content) {
let title = "";
let tags = "";
const keywords = {};
try {
const parsed = JSON.parse(cleanJsonText(content));
title = String(parsed.title || "").trim();
tags = normalizeTags(parsed.tags);
const kw = parsed.keywords;
if (kw && typeof kw === "object" && !Array.isArray(kw)) {
for (const group of KEYWORD_GROUPS) {
keywords[group] = normalizeKeywordGroup(kw[group]);
}
} else if (Array.isArray(kw)) {
keywords["重点词/成语词"] = normalizeKeywordGroup(kw);
} else if (typeof kw === "string") {
keywords["重点词/成语词"] = normalizeKeywordGroup(kw);
}
} catch {
const lines = String(content).split("\n");
for (const line of lines) {
if (/标题|Title/i.test(line)) {
title = (line.split(/[:]/)[1] || "").trim() || line.trim();
} else if (/标签|Tags|#/i.test(line)) {
tags = (line.split(/[:]/)[1] || "").trim() || line.trim();
} else if (/关键词|Keywords/i.test(line)) {
keywords["重点词/成语词"] = normalizeKeywordGroup(
(line.split(/[:]/)[1] || "").trim(),
);
}
}
}
for (const group of KEYWORD_GROUPS) {
if (!keywords[group]) keywords[group] = "";
}
const hasKeywords = KEYWORD_GROUPS.some((g) => keywords[g]);
if (!title && !tags && !hasKeywords) {
return null;
}
return { title, tags, keywords };
}
globalThis.__nodejsMain = async function main(params) {
const p = params || {};
const content = String(p.content || p.script || "").trim();
if (!content) {
return { success: false, error: "请先填写视频文案" };
}
const modelConfig = desktopConfig.buildModelConfig();
const check = desktopConfig.validateModelConfig(modelConfig);
if (!check.ok) {
return { success: false, error: check.error };
}
const prompt = buildPrompt(p.titleTagPrompt, content);
globalThis.__native?.emitProgress?.("正在生成标题、标签和关键词…");
const chat = await pipelineNative.openaiChat({
apiUrl: modelConfig.apiUrl,
apiKey: modelConfig.apiKey,
model: modelConfig.apiModelId,
messages: [{ role: "user", content: prompt }],
temperature: typeof p.temperature === "number" ? p.temperature : 0.7,
max_tokens: p.max_tokens || 2000,
});
if (!chat.success || !chat.content) {
return { success: false, error: chat.error || "模型返回为空" };
}
const parsed = parseTitleTagsContent(chat.content);
if (!parsed) {
return {
success: false,
error: "解析结果为空,请检查模型返回格式",
rawContent: chat.content,
};
}
return {
success: true,
title: parsed.title,
tags: parsed.tags,
keywords: parsed.keywords,
titleTags: JSON.stringify({
title: parsed.title,
tags: parsed.tags,
keywords: parsed.keywords,
}),
};
};

View File

@@ -0,0 +1,539 @@
/**
* 视频编辑后处理(对齐 Electron ipAgent:autoProcessVideo
* - processSilenceDetection自动剪气口
* - processVideoMixCut画中画 / 混剪
* - processGreenScreen绿幕替换
*/
const fs = require("fs");
const path = require("path");
const os = require("os");
const { spawnSync } = require("child_process");
const IMAGE_EXT = new Set([".jpg", ".jpeg", ".png", ".webp", ".bmp", ".gif"]);
function locateFfmpegBinary() {
const fromEnv = process.env.AICLIENT_FFMPEG_PATH;
if (fromEnv && fs.existsSync(fromEnv)) return fromEnv;
return process.platform === "win32" ? "ffmpeg.exe" : "ffmpeg";
}
function locateFfprobeBinary() {
const fromEnv = process.env.AICLIENT_FFPROBE_PATH;
if (fromEnv && fs.existsSync(fromEnv)) return fromEnv;
const ffmpeg = locateFfmpegBinary();
const dir = path.dirname(ffmpeg);
const sibling = path.join(dir, process.platform === "win32" ? "ffprobe.exe" : "ffprobe");
if (fs.existsSync(sibling)) return sibling;
return process.platform === "win32" ? "ffprobe.exe" : "ffprobe";
}
function execFfmpeg(args) {
const ffmpeg = locateFfmpegBinary();
const r = spawnSync(ffmpeg, args, { encoding: "utf8", windowsHide: true });
if (r.status !== 0) {
const err = (r.stderr || r.stdout || "").trim();
throw new Error(err || `ffmpeg 退出码 ${r.status}`);
}
}
function ensureOutputDir() {
const dir = path.join(os.tmpdir(), "aiclient-video-edit");
fs.mkdirSync(dir, { recursive: true });
return dir;
}
function makeOutputPath(prefix, ext = "mp4") {
return path.join(ensureOutputDir(), `${prefix}_${Date.now()}.${ext}`);
}
function parseDurationHms(text) {
const marker = "Duration:";
const idx = text.indexOf(marker);
if (idx < 0) return 0;
const rest = text.slice(idx + marker.length).trim();
const timePart = rest.split(",")[0].trim();
const parts = timePart.split(":").map(Number);
if (parts.length < 3) return 0;
const [h, m, s] = parts;
if (![h, m, s].every((n) => Number.isFinite(n))) return 0;
return h * 3600 + m * 60 + s;
}
function probeVideoDuration(videoPath) {
const ffprobe = locateFfprobeBinary();
const r = spawnSync(
ffprobe,
[
"-v",
"error",
"-show_entries",
"format=duration",
"-of",
"default=noprint_wrappers=1:nokey=1",
videoPath,
],
{ encoding: "utf8", windowsHide: true },
);
if (r.status === 0) {
const n = parseFloat(String(r.stdout || "").trim());
if (Number.isFinite(n) && n > 0) return n;
}
const ffmpeg = locateFfmpegBinary();
const r2 = spawnSync(ffmpeg, ["-i", videoPath], {
encoding: "utf8",
windowsHide: true,
});
return parseDurationHms(r2.stderr || "");
}
function probeVideoResolution(videoPath) {
const ffprobe = locateFfprobeBinary();
const r = spawnSync(
ffprobe,
[
"-v",
"error",
"-select_streams",
"v:0",
"-show_entries",
"stream=width,height",
"-of",
"csv=p=0:s=x",
videoPath,
],
{ encoding: "utf8", windowsHide: true },
);
if (r.status === 0) {
const line = String(r.stdout || "").trim();
const m = line.match(/^(\d+)x(\d+)$/);
if (m) {
return { width: Number(m[1]), height: Number(m[2]) };
}
}
return { width: 1920, height: 1080 };
}
function isImageFile(filePath) {
return IMAGE_EXT.has(path.extname(filePath).toLowerCase());
}
function processSilenceDetection(inputVideo, opts = {}) {
const silenceThreshold = Number(opts.silenceThreshold ?? -40);
const silenceDuration = Number(opts.silenceDuration ?? 1);
const minPauseDuration = Number(opts.minPauseDuration ?? 0.15);
const minPauseDurationMs = Math.round(minPauseDuration * 1000);
const outputVideo = makeOutputPath("silence");
const audioFilter = `silenceremove=stop_periods=-1:stop_duration=${silenceDuration}:stop_threshold=${silenceThreshold}dB,adelay=${minPauseDurationMs}ms|${minPauseDurationMs}ms`;
execFfmpeg([
"-i",
inputVideo,
"-af",
audioFilter,
"-c:v",
"copy",
"-y",
outputVideo,
]);
return {
outputVideo,
message: `已删除超过 ${silenceDuration} 秒且低于 ${silenceThreshold}dB 的停顿`,
};
}
function processGreenScreen(inputVideo, backgroundImage) {
const outputVideo = makeOutputPath("greenscreen");
const filterComplex = [
"[1:v][0:v]scale2ref=flags=lanczos[bg][vid]",
"[vid]format=yuva444p,chromakey=0x00FF00:0.28:0.05,chromakey=0x40FF40:0.12:0.15[fg]",
"[bg][fg]overlay=0:0:shortest=1,unsharp=3:3:0.5:3:3:0.5,format=yuv420p[out]",
].join(";");
execFfmpeg([
"-i",
inputVideo,
"-loop",
"1",
"-i",
backgroundImage,
"-filter_complex",
filterComplex,
"-map",
"[out]",
"-map",
"0:a?",
"-c:v",
"libx264",
"-preset",
"medium",
"-crf",
"18",
"-c:a",
"aac",
"-b:a",
"128k",
"-shortest",
"-y",
outputVideo,
]);
return { outputVideo, message: "绿幕替换完成" };
}
/** 对齐 Electron ipAgent:processVideoMixCut */
function processVideoMixCut({
inputVideo,
replacements,
outputPath,
videoResolution,
videoDuration,
displayMode = "pip",
}) {
if (!replacements?.length) {
throw new Error("混剪替换点为空");
}
for (const r of replacements) {
if (!r.materialPath || !fs.existsSync(r.materialPath)) {
throw new Error(`素材文件不存在: ${r.materialPath || "(空)"}`);
}
}
let filterComplex = "";
let inputFiles = [inputVideo];
let currentStream = "[0:v]";
const adjustedReplacements = replacements.map((r, i) => {
let actualEndTime = r.startTime + Math.min(r.duration, r.materialDuration);
for (let j = i + 1; j < replacements.length; j++) {
const nextR = replacements[j];
if (nextR.startTime < actualEndTime) {
actualEndTime = nextR.startTime;
}
}
return { ...r, actualEndTime };
});
for (let i = 0; i < adjustedReplacements.length; i++) {
const r = adjustedReplacements[i];
const inputIndex = i + 1;
const materialStream = `[${inputIndex}:v]`;
const trimmedMaterialStream = `[m_trim${i}]`;
const processedMaterialStream = `[m${i}]`;
const mode = r.displayMode || displayMode || "pip";
let scaleFilter = "";
let overlayParam = "";
if (mode === "fullscreen") {
scaleFilter = `scale=${videoResolution.width}:${videoResolution.height}:force_original_aspect_ratio=increase,crop=${videoResolution.width}:${videoResolution.height},boxblur=25:2`;
overlayParam = "0:0";
} else {
const pipSizePercent = r.pipSizePercent || 30;
const pipWidth = Math.round((videoResolution.width * pipSizePercent) / 100);
const pipHeight = Math.round((videoResolution.height * pipSizePercent) / 100);
const pipScaleMode = r.pipScaleMode || "fit";
let pipScaleFilter = "";
if (pipScaleMode === "fit") {
pipScaleFilter = `scale=${pipWidth}:${pipHeight}:force_original_aspect_ratio=decrease`;
} else if (pipScaleMode === "fill") {
pipScaleFilter = `scale=${pipWidth}:${pipHeight}:force_original_aspect_ratio=increase,crop=${pipWidth}:${pipHeight}`;
} else {
pipScaleFilter = `scale=${pipWidth}:${pipHeight}`;
}
const pipPos = r.pipPosition || "bottom-right";
let pipX = 0;
let pipY = 0;
switch (pipPos) {
case "top-left":
pipX = 10;
pipY = 10;
break;
case "top-center":
pipX = (videoResolution.width - pipWidth) / 2;
pipY = 10;
break;
case "top-right":
pipX = videoResolution.width - pipWidth - 10;
pipY = 10;
break;
case "center-left":
pipX = 10;
pipY = (videoResolution.height - pipHeight) / 2;
break;
case "center":
pipX = (videoResolution.width - pipWidth) / 2;
pipY = (videoResolution.height - pipHeight) / 2;
break;
case "center-right":
pipX = videoResolution.width - pipWidth - 10;
pipY = (videoResolution.height - pipHeight) / 2;
break;
case "bottom-left":
pipX = 10;
pipY = videoResolution.height - pipHeight - 10;
break;
case "bottom-center":
pipX = (videoResolution.width - pipWidth) / 2;
pipY = videoResolution.height - pipHeight - 10;
break;
case "bottom-right":
default:
pipX = videoResolution.width - pipWidth - 10;
pipY = videoResolution.height - pipHeight - 10;
break;
}
scaleFilter = pipScaleFilter;
overlayParam = `${Math.round(pipX)}:${Math.round(pipY)}`;
}
const actualDuration = Math.min(r.duration, r.materialDuration);
const isImage = r.isImage ?? isImageFile(r.materialPath);
if (isImage) {
const fpsFilter = "fps=25";
const setptsFilter = `setpts=PTS-STARTPTS+${r.startTime.toFixed(3)}/TB`;
filterComplex += `${materialStream}${fpsFilter},${setptsFilter}${trimmedMaterialStream};`;
if (mode === "fullscreen") {
const originalMinimized = r.originalVideoMinimized;
if (originalMinimized?.enabled) {
const fullscreenScaleFilter = `scale=${videoResolution.width}:${videoResolution.height}:force_original_aspect_ratio=increase,crop=${videoResolution.width}:${videoResolution.height}`;
filterComplex += `${trimmedMaterialStream}${fullscreenScaleFilter}${processedMaterialStream};`;
} else {
const simpleScaleFilter = `scale=${videoResolution.width}:${videoResolution.height}:force_original_aspect_ratio=decrease`;
filterComplex += `${trimmedMaterialStream}${simpleScaleFilter}${processedMaterialStream};`;
}
} else {
filterComplex += `${trimmedMaterialStream}${scaleFilter}${processedMaterialStream};`;
}
} else {
const trimFilter = `trim=start=${(r.materialStartOffset || 0).toFixed(3)}:duration=${actualDuration.toFixed(3)}`;
const setptsFilter = `setpts=PTS-STARTPTS+${r.startTime.toFixed(3)}/TB`;
filterComplex += `${materialStream}${trimFilter},${setptsFilter}${trimmedMaterialStream};`;
if (mode === "fullscreen") {
const originalMinimized = r.originalVideoMinimized;
if (originalMinimized?.enabled) {
const fullscreenScaleFilter = `scale=${videoResolution.width}:${videoResolution.height}:force_original_aspect_ratio=increase,crop=${videoResolution.width}:${videoResolution.height}`;
filterComplex += `${trimmedMaterialStream}${fullscreenScaleFilter}${processedMaterialStream};`;
} else {
const simpleScaleFilter = `scale=${videoResolution.width}:${videoResolution.height}:force_original_aspect_ratio=decrease`;
filterComplex += `${trimmedMaterialStream}${simpleScaleFilter}${processedMaterialStream};`;
}
} else {
filterComplex += `${trimmedMaterialStream}${scaleFilter}${processedMaterialStream};`;
}
}
const outputStream = `[v${i}]`;
const enableExpr = `gte(t,${r.startTime.toFixed(3)})*lt(t,${r.actualEndTime.toFixed(3)})`;
if (mode === "fullscreen") {
const originalMinimized = r.originalVideoMinimized;
if (originalMinimized?.enabled) {
const { shape, position, sizePercent } = originalMinimized;
const originalSize = Math.floor(videoResolution.width * (sizePercent / 100));
const splitStream1 = `[split${i}_1]`;
const splitStream2 = `[split${i}_2]`;
filterComplex += `${currentStream}split=2${splitStream1}${splitStream2};`;
const origStream = `[orig${i}]`;
let originalScaled = `${splitStream1}scale=${originalSize}:${originalSize}:force_original_aspect_ratio=increase,crop=${originalSize}:${originalSize}`;
if (shape === "circle") {
const radius = originalSize / 2;
originalScaled += `,format=rgba,geq=r='r(X,Y)':g='g(X,Y)':b='b(X,Y)':a='if(lte(hypot(X-${radius},Y-${radius}),${radius}),255,0)'`;
}
originalScaled += origStream;
filterComplex += `${originalScaled};`;
const tmpStream = `[tmp${i}]`;
filterComplex += `${splitStream2}${processedMaterialStream}overlay=0:0:enable='${enableExpr}'${tmpStream};`;
const padding = 50;
let x = 0;
let y = 0;
switch (position) {
case "top-left":
x = padding;
y = padding;
break;
case "top-right":
x = videoResolution.width - originalSize - padding;
y = padding;
break;
case "bottom-left":
x = padding;
y = videoResolution.height - originalSize - padding;
break;
case "bottom-right":
x = videoResolution.width - originalSize - padding;
y = videoResolution.height - originalSize - padding;
break;
case "center":
x = (videoResolution.width - originalSize) / 2;
y = (videoResolution.height - originalSize) / 2;
break;
default:
break;
}
filterComplex += `${tmpStream}${origStream}overlay=${Math.round(x)}:${Math.round(y)}:enable='${enableExpr}'${outputStream};`;
} else {
const blurredBgStream = `[blurred_bg${i}]`;
filterComplex += `${currentStream}boxblur=25:2:enable='${enableExpr}'${blurredBgStream};`;
filterComplex += `${blurredBgStream}${processedMaterialStream}overlay=(W-w)/2:(H-h)/2:enable='${enableExpr}'${outputStream};`;
}
} else {
filterComplex += `${currentStream}${processedMaterialStream}overlay=${overlayParam}:enable='${enableExpr}'${outputStream};`;
}
inputFiles.push(r.materialPath);
currentStream = outputStream;
}
filterComplex = filterComplex.slice(0, -1);
filterComplex += `; ${currentStream}trim=start=0:duration=${videoDuration}[final_video]`;
const ffmpegArgs = [
...inputFiles.flatMap((f) => ["-i", f]),
"-filter_complex",
filterComplex,
"-map",
"[final_video]",
"-map",
"0:a?",
"-c:a",
"copy",
"-c:v",
"libx264",
"-preset",
"medium",
"-shortest",
"-y",
outputPath,
];
execFfmpeg(ffmpegArgs);
return { outputVideo: outputPath, message: "混剪处理完成" };
}
function normalizeReplacements(raw) {
if (!Array.isArray(raw)) return [];
return raw
.map((r) => ({
startTime: Number(r.startTime),
duration: Number(r.duration),
materialPath: String(r.materialPath || "").trim(),
materialDuration: Number(r.materialDuration),
materialStartOffset: Number(r.materialStartOffset || 0),
displayMode: r.displayMode,
pipPosition: r.pipPosition,
pipSizePercent: r.pipSizePercent,
pipScaleMode: r.pipScaleMode,
isImage: r.isImage,
originalVideoMinimized: r.originalVideoMinimized,
}))
.filter(
(r) =>
Number.isFinite(r.startTime) &&
Number.isFinite(r.duration) &&
r.duration > 0 &&
r.materialPath &&
Number.isFinite(r.materialDuration) &&
r.materialDuration > 0,
);
}
function pickReplacementsFromSettings(settings, videoDuration) {
if (!settings || typeof settings !== "object") return [];
if (Array.isArray(settings.replacements) && settings.replacements.length) {
return normalizeReplacements(settings.replacements);
}
const simple = settings.simplePip;
if (simple?.materialPath && fs.existsSync(simple.materialPath)) {
const matDur =
Number(simple.materialDuration) > 0
? Number(simple.materialDuration)
: isImageFile(simple.materialPath)
? videoDuration
: probeVideoDuration(simple.materialPath);
const startTime = Number(simple.startTime || 0);
const duration = Number(simple.duration || videoDuration);
return normalizeReplacements([
{
startTime,
duration,
materialPath: simple.materialPath,
materialDuration: matDur,
materialStartOffset: Number(simple.materialStartOffset || 0),
displayMode: simple.displayMode || settings.displayMode || "pip",
pipPosition: simple.pipPosition || "bottom-right",
pipSizePercent: simple.pipSizePercent ?? 30,
pipScaleMode: simple.pipScaleMode || "fit",
isImage: simple.isImage ?? isImageFile(simple.materialPath),
},
]);
}
return [];
}
globalThis.__nodejsMain = async function main(params) {
const p = params || {};
const inputVideo = String(p.inputVideo || "").trim();
if (!inputVideo || !fs.existsSync(inputVideo)) {
return { success: false, error: "缺少或找不到输入视频" };
}
const steps = [];
let current = inputVideo;
try {
if (p.autoCutBreath) {
globalThis.__native?.emitProgress?.("正在剪气口…");
const r = processSilenceDetection(current, {
silenceThreshold: p.silenceThreshold,
silenceDuration: p.silenceDuration,
minPauseDuration: p.minPauseDuration,
});
current = r.outputVideo;
steps.push({ step: "silence", ...r });
}
if (p.pipInPicture) {
const settings = p.mixCutSettings || null;
const replacements = pickReplacementsFromSettings(
settings,
probeVideoDuration(current),
);
if (!replacements.length) {
return {
success: false,
error:
"已启用画中画,但未配置混剪素材。请在本地配置 videoMixCutSettings含 replacements 或 simplePip或使用「选择画中画素材」",
};
}
globalThis.__native?.emitProgress?.("正在混剪/画中画…");
const videoDuration = probeVideoDuration(current);
const videoResolution = probeVideoResolution(current);
const outputPath = makeOutputPath("mixcut");
const r = processVideoMixCut({
inputVideo: current,
replacements,
outputPath,
videoResolution,
videoDuration,
displayMode: settings?.displayMode || "pip",
});
current = r.outputVideo;
steps.push({ step: "mixcut", ...r });
}
if (p.greenScreen) {
const bg = String(p.backgroundImage || "").trim();
if (!bg || !fs.existsSync(bg)) {
return { success: false, error: "已启用绿幕切换,请先上传替换背景图" };
}
globalThis.__native?.emitProgress?.("正在绿幕替换…");
const r = processGreenScreen(current, bg);
current = r.outputVideo;
steps.push({ step: "greenscreen", ...r });
}
return {
success: true,
videoPath: current,
steps,
message: steps.length ? steps[steps.length - 1].message : "未执行任何处理",
};
} catch (e) {
return { success: false, error: e.message || String(e), partialVideo: current };
}
};

View File

@@ -0,0 +1,84 @@
"use strict";
/**
* 多平台视频发布(工作流步骤 07 / 一键自动)
*/
const { publishToPlatform, PLATFORM_LABELS } = require("./publish_platform.js");
globalThis.__nodejsMain = async function main(params) {
const p = params || {};
const platforms = Array.isArray(p.platforms) ? p.platforms : [];
const videoPath = String(p.videoPath || "").trim();
const coverPath = String(p.coverPath || "").trim();
const title = String(p.title || "").trim();
const description = String(p.description || title || "").trim();
const tags = Array.isArray(p.tags) ? p.tags : [];
const autoPublish = Boolean(p.autoPublish);
if (!platforms.length) {
return { success: false, error: "请至少选择一个发布平台" };
}
if (!videoPath) return { success: false, error: "缺少视频路径" };
if (!coverPath) return { success: false, error: "缺少封面路径,请先在步骤 06 生成封面" };
if (!title) return { success: false, error: "缺少标题,请先在步骤 04 生成标题" };
const results = [];
for (let i = 0; i < platforms.length; i++) {
const item = platforms[i];
const platform = item.platform;
const accountId = item.accountId;
const cookies = item.cookies || [];
const label = PLATFORM_LABELS[platform] || platform;
globalThis.__native?.emitProgress?.(
`正在发布到${label}… (${i + 1}/${platforms.length})`,
);
try {
const r = await publishToPlatform(platform, accountId, cookies, {
videoPath,
coverPath,
title,
description,
tags,
autoPublish,
});
results.push({
platform: label,
platformKey: platform,
success: Boolean(r.success),
pending:
r.status === "manual_pending" || r.status === "verification_required",
status: r.status || (r.success ? "success" : "failed"),
message: r.message || "",
videoUrl: r.videoUrl || "",
});
} catch (e) {
results.push({
platform: label,
platformKey: platform,
success: false,
pending: false,
status: "failed",
message: e.message || String(e),
});
}
if (i < platforms.length - 1) {
await new Promise((r) => setTimeout(r, 2000));
}
}
const ok = results.filter((r) => r.success).length;
const pending = results.filter((r) => r.pending).length;
const failed = results.length - ok - pending;
return {
success: ok > 0 || pending > 0,
results,
summary: { ok, pending, failed, total: results.length },
message:
ok === results.length
? "全部发布成功"
: pending > 0
? `成功 ${ok},待确认 ${pending},失败 ${failed}`
: `成功 ${ok},失败 ${failed}`,
};
};