// ============================================================================ // douyin_pipeline.js // // Pure-JS port of the original `VideoRewriteIntegration.processDouyinShareComplete` // pipeline. All host capabilities (network, fs, ffmpeg, OSS, ASR, AI chat, // log, progress) come from the `__native` global injected by the Rust side // (see `js_runtime/native.rs`). Nothing in this file should ever depend on // Node.js, Electron or browser globals. // // 由后端 `/api/v1/quickjs-scripts?name=douyin_pipeline.js` 下发;桌面端 QuickJS // eval 后调用 `globalThis.__quickjsMain(params)`。 // // `params` shape mirrors the original Electron IPC payload: // { // shareText: string, // modelConfig: { providerId, modelId, apiUrl, apiKey, // asrMode: "online" | "local", // aliyunApiKey, ossConfig, asrServerInfo }, // rewriteConfig: object, // customPrompt: string | null // } // ============================================================================ const log = (level, msg, fields) => __native.log(level, msg, fields || null); const progress = (s) => { try { __native.emitProgress(s); } catch (_) { /* ignore */ } }; // --------------------------------------------------------------------------- // DouyinContentParser (pure-JS, no host calls) // --------------------------------------------------------------------------- const DouyinContentParser = { // Extract a sharable URL from a raw "share text". Handles: // - direct https://... links // - "复制此链接,打开Dou音搜索,直接观看视频!https://v.douyin.com/xxxx/" // - bare v.douyin.com short links parseContent(shareText) { if (!shareText || typeof shareText !== "string") { return { success: false, error: "分享文本为空" }; } const urlRegex = /https?:\/\/[^\s\u4e00-\u9fa5]+/g; const matches = shareText.match(urlRegex); if (!matches || matches.length === 0) { return { success: false, error: "未在文本中识别到任何 URL" }; } // Prefer douyin/iesdouyin domains, fallback to first match. const douyinUrl = matches.find((u) => /douyin\.com|iesdouyin\.com/i.test(u)) || matches[0]; return { success: true, content: { videoUrl: douyinUrl, raw: shareText } }; }, // Whether `url` already points at an actual media file we can fetch directly. isDirectVideoUrl(url) { if (!url) return false; return /\.(mp4|mov|m4v|webm|mkv|flv|avi)(\?|$)/i.test(url); }, // Derive a short title (<= 20 chars) from a long description. generateTitleFromDescription(desc) { if (!desc) return "改写视频"; const firstLine = String(desc).split(/[\r\n]/)[0].trim(); const cleaned = firstLine.replace(/[#@].*$/g, "").trim(); return (cleaned || "改写视频").slice(0, 20); }, }; // --------------------------------------------------------------------------- // DouyinVideoDownloader // Resolves the final media URL (following 302 redirects on the share // domain) and saves the binary to a host-managed temp file. // --------------------------------------------------------------------------- class DouyinVideoDownloader { async downloadDirectVideo(url) { log("info", "downloader.directVideo", { url }); const dest = __native.tempPath("video", "mp4"); const res = await __native.httpRequest({ method: "GET", url, headers: { "User-Agent": this._ua() }, response_type: "file", dest_path: dest, timeout_ms: 120000, }); if (!res || res.status >= 400) { return { success: false, error: `直链下载失败 status=${res && res.status}` }; } return { success: true, videoPath: dest }; } async downloadDouyinVideo(shareUrl) { log("info", "downloader.douyin", { shareUrl }); // Step 1: follow redirect on v.douyin.com -> iesdouyin.com to get itemId let landing; try { landing = await __native.httpRequest({ method: "GET", url: shareUrl, headers: { "User-Agent": this._ua() }, response_type: "text", follow_redirects: true, timeout_ms: 30000, }); } catch (e) { return { success: false, error: `解析跳转失败: ${e.message || e}` }; } const finalUrl = (landing && landing.final_url) || shareUrl; const itemId = this._extract(/\/video\/(\d+)/, finalUrl) || this._extract(/item_ids?=(\d+)/, finalUrl) || this._extract(/modal_id=(\d+)/, finalUrl); if (!itemId) { return { success: false, error: "未能从分享链接中提取视频 itemId,可能是抖音改版", }; } // Step 2: ask iesdouyin for the playable URL. const apiUrl = "https://www.iesdouyin.com/web/api/v2/aweme/iteminfo/?item_ids=" + itemId; let info; try { info = await __native.httpRequest({ method: "GET", url: apiUrl, headers: { "User-Agent": this._ua() }, response_type: "json", timeout_ms: 30000, }); } catch (e) { return { success: false, error: `获取视频信息失败: ${e.message || e}` }; } const item = info && info.body && info.body.item_list && info.body.item_list[0]; const playAddr = item && item.video && item.video.play_addr && item.video.play_addr.url_list; if (!playAddr || !playAddr.length) { return { success: false, error: "iesdouyin 接口未返回播放地址" }; } // Force HTTPS + replace `playwm` (watermarked) with `play` if present. const mediaUrl = playAddr[0] .replace(/^http:\/\//, "https://") .replace("/playwm/", "/play/"); return this.downloadDirectVideo(mediaUrl); } _ua() { return ( "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/124.0.0.0 Safari/537.36" ); } _extract(re, s) { const m = String(s || "").match(re); return m ? m[1] : null; } } // --------------------------------------------------------------------------- // AI rewrite (OpenAI-compatible chat completions) // --------------------------------------------------------------------------- const VideoRewriteOptimizer = { buildRewritePrompt(content, rewriteConfig, customPrompt) { if (customPrompt && String(customPrompt).trim().length > 0) { return String(customPrompt).replace(/\{\{\s*content\s*\}\}/g, content); } const cfg = rewriteConfig || {}; const style = cfg.style || "短视频口播风格"; const tone = cfg.tone || "自然、口语化"; const lengthHint = cfg.length ? `控制在 ${cfg.length} 字以内` : "保持与原文相近的长度"; return [ "你是一名专业的短视频文案改写助手。", `请基于下面这段视频转写文案,用【${style}】重新仿写一版。`, `要求:语气 ${tone},${lengthHint},保留原意但避免与原文雷同;`, "不要解释、不要加任何额外标题,直接输出仿写后的正文。", "", "【原文案】", content, ].join("\n"); }, }; async function rewriteWithAI(content, modelConfig, rewriteConfig, customPrompt) { if (!modelConfig || !modelConfig.apiUrl || !modelConfig.apiKey) { log("error", "rewrite.missingConfig"); return null; } const prompt = VideoRewriteOptimizer.buildRewritePrompt( content, rewriteConfig || {}, customPrompt ); const result = await __native.openaiChat({ apiUrl: modelConfig.apiUrl, apiKey: modelConfig.apiKey, model: modelConfig.modelId, messages: [ { role: "system", content: "你是专业的短视频文案改写助手。" }, { role: "user", content: prompt }, ], temperature: typeof modelConfig.temperature === "number" ? modelConfig.temperature : 0.8, max_tokens: modelConfig.maxTokens || 1024, }); if (!result || !result.success) { log("error", "rewrite.failed", { error: result && result.error }); return null; } return (result.content || "").trim(); } // --------------------------------------------------------------------------- // VideoRewriteIntegration.processDouyinShareComplete // The 1:1 port of the Electron version's pipeline. // --------------------------------------------------------------------------- async function processDouyinShareComplete(shareText, modelConfig, rewriteConfig, customPrompt) { let videoPath = null; let audioPath = null; try { log("info", "pipeline.start", { inputLength: shareText && shareText.length, modelConfig: { providerId: modelConfig && modelConfig.providerId, modelId: modelConfig && modelConfig.modelId, asrMode: modelConfig && modelConfig.asrMode, }, }); // 1) parse share text -> video URL progress("正在解析视频URL..."); const parseResult = DouyinContentParser.parseContent(shareText); if (!parseResult.success || !parseResult.content) { return { success: false, error: parseResult.error || "内容解析失败" }; } const { videoUrl } = parseResult.content; log("info", "pipeline.urlExtracted", { videoUrl }); // 2) download video progress("正在下载视频..."); const downloader = new DouyinVideoDownloader(); const downloadResult = DouyinContentParser.isDirectVideoUrl(videoUrl) ? await downloader.downloadDirectVideo(videoUrl) : await downloader.downloadDouyinVideo(videoUrl); if (!downloadResult.success || !downloadResult.videoPath) { return { success: false, error: downloadResult.error || "视频下载失败" }; } videoPath = downloadResult.videoPath; log("info", "pipeline.videoDownloaded", { videoPath }); // 3) extract audio (ffmpeg sidecar) progress("视频下载完成,正在提取音频..."); try { audioPath = await __native.ffmpegExtractAudio(videoPath); } catch (e) { return { success: false, error: `音频提取失败: ${(e && e.message) || e}`, }; } log("info", "pipeline.audioExtracted", { audioPath }); // 4) ASR const asrMode = (modelConfig && modelConfig.asrMode) || "online"; let recognizedText = ""; if (asrMode === "online") { progress("音频提取完成,正在上传到OSS..."); const ossConfig = modelConfig.ossConfig; if (!ossConfig || !ossConfig.bucket) { return { success: false, error: "未配置阿里云 OSS(bucket / region / key)" }; } const upload = await __native.aliyunOssUpload(audioPath, ossConfig); if (!upload || !upload.success || !upload.url) { return { success: false, error: `音频上传失败: ${(upload && upload.error) || "unknown"}`, }; } log("info", "pipeline.ossOk", { audioUrl: upload.url.slice(0, 100) + "..." }); progress("音频上传完成,正在识别文案(在线模式)..."); const asr = await __native.aliyunAsrFiletrans(upload.url, { apiKey: modelConfig.aliyunApiKey, model: "qwen3-asr-flash-filetrans", }); if (!asr || !asr.success) { return { success: false, error: `语音识别失败(在线模式): ${(asr && asr.error) || "unknown"}`, }; } recognizedText = asr.text || ""; log("info", "pipeline.asrOk", { method: "aliyun-online", len: recognizedText.length, }); } else { progress("音频提取完成,正在识别文案(本地模式)..."); const info = modelConfig.asrServerInfo; if (!info) { return { success: false, error: "未配置本地语音识别服务器,请在「设置」中配置", }; } const asr = await __native.localAsr(audioPath, info); if (!asr || !asr.success) { return { success: false, error: `语音识别失败(本地模式): ${(asr && asr.error) || "unknown"}`, }; } recognizedText = asr.text || ""; log("info", "pipeline.asrOk", { method: "local", server: info.name, len: recognizedText.length, }); } // 5) rewrite if (!modelConfig || !modelConfig.apiUrl || !modelConfig.apiKey) { return { success: false, error: "未配置AI模型,请先在设置中配置" }; } progress("文案识别完成,正在改写..."); const rewrittenDescription = await rewriteWithAI( recognizedText, modelConfig, rewriteConfig, customPrompt ); if (!rewrittenDescription) { return { success: false, error: "AI仿写失败,请检查模型配置和网络连接" }; } const rewrittenTitle = DouyinContentParser.generateTitleFromDescription(rewrittenDescription); log("info", "pipeline.success", { originalLength: recognizedText.length, rewrittenLength: rewrittenDescription.length, }); progress("完成!"); return { success: true, original: { videoUrl, description: recognizedText, hashtags: [], title: "视频音频识别结果", }, rewritten: { description: rewrittenDescription, title: rewrittenTitle, fullContent: rewrittenDescription, }, }; } catch (e) { log("error", "pipeline.error", { message: (e && e.message) || String(e) }); return { success: false, error: (e && e.message) || String(e) }; } finally { // 6) cleanup local temp files if (videoPath) { try { __native.deleteFile(videoPath); } catch (_) {} } if (audioPath) { try { __native.deleteFile(audioPath); } catch (_) {} } } } // --------------------------------------------------------------------------- // 单一入口:Rust eval 本脚本后调用 globalThis.__quickjsMain(params) // --------------------------------------------------------------------------- globalThis.__quickjsMain = function (params) { const p = params || {}; return processDouyinShareComplete( p.shareText, p.modelConfig, p.rewriteConfig, p.customPrompt || null ); };