|
1 | 1 | import { z } from 'zod'; |
2 | 2 | import path from 'node:path'; |
3 | 3 | import fs from 'node:fs/promises'; |
| 4 | +import { spawn } from 'node:child_process'; |
4 | 5 | import matter from 'gray-matter'; |
5 | 6 | import { ulid } from 'ulid'; |
6 | 7 | import { toAppAssetUrl } from '../localAssetManager'; |
@@ -426,8 +427,8 @@ const APP_CLI_NAMESPACE_HELP: Record<string, { summary: string; actions: string[ |
426 | 427 | }, |
427 | 428 | manuscripts: { |
428 | 429 | summary: 'List, read, write, and organize manuscripts.', |
429 | | - actions: ['list', 'read', 'write', 'create', 'organize'], |
430 | | - examples: ['manuscripts list', 'manuscripts write --path "drafts/demo.md"'], |
| 430 | + actions: ['list', 'read', 'write', 'create', 'organize', 'clips', 'clip-update', 'export'], |
| 431 | + examples: ['manuscripts list', 'manuscripts write --path "drafts/demo.md"', 'manuscripts export --path "wander/demo.redvideo"'], |
431 | 432 | }, |
432 | 433 | knowledge: { |
433 | 434 | summary: 'List and search saved knowledge items.', |
@@ -804,6 +805,269 @@ function normalizePackageTimeline(timeline: Record<string, unknown>) { |
804 | 805 | return timeline; |
805 | 806 | } |
806 | 807 |
|
| 808 | +const isProbablyFilePath = (value: string): boolean => { |
| 809 | + if (!value) return false; |
| 810 | + if (path.isAbsolute(value)) return true; |
| 811 | + return value.includes('/') || value.includes('\\'); |
| 812 | +}; |
| 813 | + |
| 814 | +async function resolveFfmpegCommand(): Promise<string> { |
| 815 | + const envPath = String(process.env.REDCONVERT_FFMPEG_PATH || process.env.FFMPEG_PATH || '').trim(); |
| 816 | + const candidates: string[] = []; |
| 817 | + if (envPath) candidates.push(envPath.includes('app.asar') ? envPath.replace('app.asar', 'app.asar.unpacked') : envPath); |
| 818 | + |
| 819 | + try { |
| 820 | + // eslint-disable-next-line @typescript-eslint/no-var-requires |
| 821 | + const ffmpegStaticPath = require('ffmpeg-static') as string | null; |
| 822 | + if (ffmpegStaticPath) { |
| 823 | + candidates.push(ffmpegStaticPath.includes('app.asar') ? ffmpegStaticPath.replace('app.asar', 'app.asar.unpacked') : ffmpegStaticPath); |
| 824 | + } |
| 825 | + } catch { |
| 826 | + // ignore |
| 827 | + } |
| 828 | + |
| 829 | + if ((process as any).resourcesPath) { |
| 830 | + const exeName = process.platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg'; |
| 831 | + candidates.push(path.join((process as any).resourcesPath, 'app.asar.unpacked', 'node_modules', 'ffmpeg-static', exeName)); |
| 832 | + } |
| 833 | + |
| 834 | + for (const candidate of candidates) { |
| 835 | + if (!candidate || !isProbablyFilePath(candidate)) continue; |
| 836 | + try { |
| 837 | + await fs.access(candidate); |
| 838 | + return candidate; |
| 839 | + } catch { |
| 840 | + // keep trying |
| 841 | + } |
| 842 | + } |
| 843 | + |
| 844 | + const allowSystemFallback = String(process.env.REDCONVERT_ALLOW_SYSTEM_FFMPEG || '').trim().toLowerCase(); |
| 845 | + if (allowSystemFallback === '1' || allowSystemFallback === 'true' || allowSystemFallback === 'yes') { |
| 846 | + return 'ffmpeg'; |
| 847 | + } |
| 848 | + throw new Error('Bundled ffmpeg not found. Please reinstall app/package to restore internal ffmpeg binary.'); |
| 849 | +} |
| 850 | + |
| 851 | +async function runFfmpeg(args: string[]): Promise<void> { |
| 852 | + const ffmpegCommand = await resolveFfmpegCommand(); |
| 853 | + await new Promise<void>((resolve, reject) => { |
| 854 | + const child = spawn(ffmpegCommand, args, { |
| 855 | + windowsHide: true, |
| 856 | + stdio: ['ignore', 'ignore', 'pipe'], |
| 857 | + }); |
| 858 | + let stderr = ''; |
| 859 | + child.stderr?.on('data', (chunk) => { |
| 860 | + if (!chunk) return; |
| 861 | + stderr += String(chunk); |
| 862 | + if (stderr.length > 6000) stderr = stderr.slice(-6000); |
| 863 | + }); |
| 864 | + child.once('error', reject); |
| 865 | + child.once('close', (code) => { |
| 866 | + if (code === 0) { |
| 867 | + resolve(); |
| 868 | + return; |
| 869 | + } |
| 870 | + reject(new Error(`ffmpeg exited with code ${code}: ${stderr || '(no stderr)'}`)); |
| 871 | + }); |
| 872 | + }); |
| 873 | +} |
| 874 | + |
| 875 | +function sanitizeExportBaseName(input: string): string { |
| 876 | + const base = String(input || '').trim().replace(/[\\/:*?"<>|]+/g, '-').replace(/\s+/g, '-'); |
| 877 | + return base.replace(/-+/g, '-').replace(/^-+|-+$/g, '') || `export-${Date.now()}`; |
| 878 | +} |
| 879 | + |
| 880 | +function parseOutputSize(input: unknown, fallback: { width: number; height: number }): { width: number; height: number } { |
| 881 | + const raw = String(input || '').trim(); |
| 882 | + const matched = raw.match(/^(\d{2,5})x(\d{2,5})$/i); |
| 883 | + if (!matched) return fallback; |
| 884 | + const width = Number(matched[1]); |
| 885 | + const height = Number(matched[2]); |
| 886 | + if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) { |
| 887 | + return fallback; |
| 888 | + } |
| 889 | + return { width, height }; |
| 890 | +} |
| 891 | + |
| 892 | +function quoteConcatPath(filePath: string): string { |
| 893 | + return `file '${String(filePath).replace(/'/g, `'\\''`)}'`; |
| 894 | +} |
| 895 | + |
| 896 | +async function exportVideoOrAudioPackage(params: { |
| 897 | + packagePath: string; |
| 898 | + relPath: string; |
| 899 | + packageKind: 'video' | 'audio'; |
| 900 | + outputName?: string; |
| 901 | + size?: string; |
| 902 | +}): Promise<Record<string, unknown>> { |
| 903 | + const timeline = await readJsonFile<Record<string, unknown>>(getPackageTimelinePath(params.packagePath), createEmptyOtioTimeline(path.basename(params.packagePath))); |
| 904 | + const clips = (buildTimelineClipSummaries(timeline) as Array<Record<string, unknown>>) |
| 905 | + .filter((item: Record<string, unknown>) => item.enabled !== false) |
| 906 | + .sort((a: Record<string, unknown>, b: Record<string, unknown>) => { |
| 907 | + const trackCompare = String(a.track || '').localeCompare(String(b.track || '')); |
| 908 | + if (trackCompare !== 0) return trackCompare; |
| 909 | + return Number(a.order ?? 0) - Number(b.order ?? 0); |
| 910 | + }); |
| 911 | + |
| 912 | + const targetTrack = params.packageKind === 'video' ? 'V1' : 'A1'; |
| 913 | + const exportableClips = clips.filter((item: Record<string, unknown>) => String(item.track || '').trim() === targetTrack); |
| 914 | + if (exportableClips.length === 0) { |
| 915 | + throw new Error(`No enabled clips found on track ${targetTrack}`); |
| 916 | + } |
| 917 | + |
| 918 | + const exportsDir = path.join(params.packagePath, 'exports'); |
| 919 | + await fs.mkdir(exportsDir, { recursive: true }); |
| 920 | + const tempDir = await fs.mkdtemp(path.join(exportsDir, '.render-')); |
| 921 | + const skipped: Array<{ assetId: string; reason: string }> = []; |
| 922 | + |
| 923 | + try { |
| 924 | + const segmentPaths: string[] = []; |
| 925 | + const size = parseOutputSize(params.size, params.packageKind === 'video' ? { width: 1280, height: 720 } : { width: 0, height: 0 }); |
| 926 | + for (let index = 0; index < exportableClips.length; index += 1) { |
| 927 | + const clip = exportableClips[index]; |
| 928 | + const assetId = String(clip.assetId || '').trim(); |
| 929 | + const mediaPath = String(clip.mediaPath || '').trim(); |
| 930 | + if (!assetId || !mediaPath) { |
| 931 | + skipped.push({ assetId: assetId || `clip-${index + 1}`, reason: 'missing mediaPath' }); |
| 932 | + continue; |
| 933 | + } |
| 934 | + const absolutePath = getAbsoluteMediaPath(mediaPath); |
| 935 | + try { |
| 936 | + await fs.access(absolutePath); |
| 937 | + } catch { |
| 938 | + skipped.push({ assetId, reason: 'source asset missing on disk' }); |
| 939 | + continue; |
| 940 | + } |
| 941 | + |
| 942 | + const clipDurationMs = parseNumber(clip.durationMs); |
| 943 | + const trimInMs = Math.max(0, parseNumber(clip.trimInMs) || 0); |
| 944 | + const segmentBase = path.join(tempDir, `segment-${String(index + 1).padStart(3, '0')}`); |
| 945 | + |
| 946 | + if (params.packageKind === 'audio') { |
| 947 | + const segmentPath = `${segmentBase}.mp3`; |
| 948 | + const args = ['-y']; |
| 949 | + if (trimInMs > 0) { |
| 950 | + args.push('-ss', (trimInMs / 1000).toFixed(3)); |
| 951 | + } |
| 952 | + args.push('-i', absolutePath); |
| 953 | + if (clipDurationMs && clipDurationMs > 0) { |
| 954 | + args.push('-t', (clipDurationMs / 1000).toFixed(3)); |
| 955 | + } |
| 956 | + args.push( |
| 957 | + '-vn', |
| 958 | + '-ac', '2', |
| 959 | + '-ar', '44100', |
| 960 | + '-c:a', 'libmp3lame', |
| 961 | + '-b:a', '192k', |
| 962 | + segmentPath, |
| 963 | + ); |
| 964 | + await runFfmpeg(args); |
| 965 | + segmentPaths.push(segmentPath); |
| 966 | + continue; |
| 967 | + } |
| 968 | + |
| 969 | + const kind = String(clip.assetKind || '').trim().toLowerCase(); |
| 970 | + const segmentPath = `${segmentBase}.mp4`; |
| 971 | + if (kind === 'image') { |
| 972 | + const durationSeconds = ((clipDurationMs && clipDurationMs > 0 ? clipDurationMs : 4000) / 1000).toFixed(3); |
| 973 | + await runFfmpeg([ |
| 974 | + '-y', |
| 975 | + '-loop', '1', |
| 976 | + '-i', absolutePath, |
| 977 | + '-t', durationSeconds, |
| 978 | + '-vf', `scale=${size.width}:${size.height}:force_original_aspect_ratio=decrease,pad=${size.width}:${size.height}:(ow-iw)/2:(oh-ih)/2,format=yuv420p`, |
| 979 | + '-r', '30', |
| 980 | + '-c:v', 'libx264', |
| 981 | + '-pix_fmt', 'yuv420p', |
| 982 | + '-an', |
| 983 | + segmentPath, |
| 984 | + ]); |
| 985 | + segmentPaths.push(segmentPath); |
| 986 | + continue; |
| 987 | + } |
| 988 | + |
| 989 | + const args = ['-y']; |
| 990 | + if (trimInMs > 0) { |
| 991 | + args.push('-ss', (trimInMs / 1000).toFixed(3)); |
| 992 | + } |
| 993 | + args.push('-i', absolutePath); |
| 994 | + if (clipDurationMs && clipDurationMs > 0) { |
| 995 | + args.push('-t', (clipDurationMs / 1000).toFixed(3)); |
| 996 | + } |
| 997 | + args.push( |
| 998 | + '-vf', `scale=${size.width}:${size.height}:force_original_aspect_ratio=decrease,pad=${size.width}:${size.height}:(ow-iw)/2:(oh-ih)/2,format=yuv420p`, |
| 999 | + '-r', '30', |
| 1000 | + '-c:v', 'libx264', |
| 1001 | + '-preset', 'veryfast', |
| 1002 | + '-pix_fmt', 'yuv420p', |
| 1003 | + '-c:a', 'aac', |
| 1004 | + '-ar', '48000', |
| 1005 | + '-ac', '2', |
| 1006 | + segmentPath, |
| 1007 | + ); |
| 1008 | + await runFfmpeg(args); |
| 1009 | + segmentPaths.push(segmentPath); |
| 1010 | + } |
| 1011 | + |
| 1012 | + if (segmentPaths.length === 0) { |
| 1013 | + throw new Error('No clip could be rendered'); |
| 1014 | + } |
| 1015 | + |
| 1016 | + const extension = params.packageKind === 'video' ? 'mp4' : 'mp3'; |
| 1017 | + const outputName = `${sanitizeExportBaseName(params.outputName || `${stripManuscriptExtension(path.basename(params.packagePath))}-rough-cut`)}.${extension}`; |
| 1018 | + const outputPath = path.join(exportsDir, outputName); |
| 1019 | + |
| 1020 | + if (segmentPaths.length === 1) { |
| 1021 | + await fs.copyFile(segmentPaths[0], outputPath); |
| 1022 | + } else { |
| 1023 | + const concatListPath = path.join(tempDir, 'concat.txt'); |
| 1024 | + await fs.writeFile(concatListPath, segmentPaths.map((item) => quoteConcatPath(item)).join('\n'), 'utf-8'); |
| 1025 | + if (params.packageKind === 'audio') { |
| 1026 | + await runFfmpeg([ |
| 1027 | + '-y', |
| 1028 | + '-f', 'concat', |
| 1029 | + '-safe', '0', |
| 1030 | + '-i', concatListPath, |
| 1031 | + '-c:a', 'libmp3lame', |
| 1032 | + '-b:a', '192k', |
| 1033 | + outputPath, |
| 1034 | + ]); |
| 1035 | + } else { |
| 1036 | + await runFfmpeg([ |
| 1037 | + '-y', |
| 1038 | + '-f', 'concat', |
| 1039 | + '-safe', '0', |
| 1040 | + '-i', concatListPath, |
| 1041 | + '-c:v', 'libx264', |
| 1042 | + '-preset', 'veryfast', |
| 1043 | + '-pix_fmt', 'yuv420p', |
| 1044 | + '-c:a', 'aac', |
| 1045 | + '-ar', '48000', |
| 1046 | + '-ac', '2', |
| 1047 | + outputPath, |
| 1048 | + ]); |
| 1049 | + } |
| 1050 | + } |
| 1051 | + |
| 1052 | + return { |
| 1053 | + success: true, |
| 1054 | + path: params.relPath, |
| 1055 | + packageKind: params.packageKind, |
| 1056 | + outputName, |
| 1057 | + outputPath, |
| 1058 | + outputUrl: toAppAssetUrl(outputPath), |
| 1059 | + renderedClipCount: segmentPaths.length, |
| 1060 | + skipped, |
| 1061 | + }; |
| 1062 | + } finally { |
| 1063 | + try { |
| 1064 | + await fs.rm(tempDir, { recursive: true, force: true }); |
| 1065 | + } catch { |
| 1066 | + // ignore cleanup |
| 1067 | + } |
| 1068 | + } |
| 1069 | +} |
| 1070 | + |
807 | 1071 | export class AppCliTool extends DeclarativeTool<typeof AppCliParamsSchema> { |
808 | 1072 | readonly name = 'app_cli'; |
809 | 1073 | readonly displayName = 'App CLI'; |
@@ -936,6 +1200,36 @@ export class AppCliTool extends DeclarativeTool<typeof AppCliParamsSchema> { |
936 | 1200 | }, |
937 | 1201 | }; |
938 | 1202 | } |
| 1203 | + if (parsed.namespace === 'manuscripts' && parsed.action === 'export') { |
| 1204 | + const info = result as { |
| 1205 | + path?: string; |
| 1206 | + packageKind?: string; |
| 1207 | + outputPath?: string; |
| 1208 | + outputUrl?: string; |
| 1209 | + renderedClipCount?: number; |
| 1210 | + skipped?: Array<{ assetId: string; reason: string }>; |
| 1211 | + }; |
| 1212 | + const lines = [ |
| 1213 | + 'Manuscript export completed.', |
| 1214 | + `path=${info.path || ''}`, |
| 1215 | + `kind=${info.packageKind || ''}`, |
| 1216 | + `renderedClips=${info.renderedClipCount ?? 0}`, |
| 1217 | + `outputPath=${info.outputPath || ''}`, |
| 1218 | + `outputUrl=${info.outputUrl || ''}`, |
| 1219 | + ]; |
| 1220 | + if (Array.isArray(info.skipped) && info.skipped.length > 0) { |
| 1221 | + lines.push(`skipped=${info.skipped.length}`); |
| 1222 | + } |
| 1223 | + return { |
| 1224 | + success: true, |
| 1225 | + llmContent: lines.join('\n'), |
| 1226 | + display: 'manuscripts export', |
| 1227 | + data: { |
| 1228 | + kind: 'manuscript-export', |
| 1229 | + ...info, |
| 1230 | + }, |
| 1231 | + }; |
| 1232 | + } |
939 | 1233 | return createSuccessResult(toPrettyJson(result), `${parsed.namespace} ${parsed.action}`); |
940 | 1234 | } catch (error) { |
941 | 1235 | const message = error instanceof Error ? error.message : String(error); |
@@ -1500,6 +1794,26 @@ export class AppCliTool extends DeclarativeTool<typeof AppCliParamsSchema> { |
1500 | 1794 | }; |
1501 | 1795 | } |
1502 | 1796 |
|
| 1797 | + if (action === 'export') { |
| 1798 | + const relPath = normalizeRelativePath(requireString(readFlag(parsed.flags, 'path') || payload.path, 'path')); |
| 1799 | + const absolute = path.join(manuscriptsRoot, relPath); |
| 1800 | + const stats = await fs.stat(absolute); |
| 1801 | + if (!stats.isDirectory() || !isManuscriptPackageName(path.basename(absolute))) { |
| 1802 | + throw new Error('export 仅支持工程稿件目录'); |
| 1803 | + } |
| 1804 | + const packageKind = getPackageKindFromFileName(path.basename(absolute)); |
| 1805 | + if (packageKind !== 'video' && packageKind !== 'audio') { |
| 1806 | + throw new Error('当前 export 仅支持视频/音频工程稿件'); |
| 1807 | + } |
| 1808 | + return exportVideoOrAudioPackage({ |
| 1809 | + packagePath: absolute, |
| 1810 | + relPath, |
| 1811 | + packageKind, |
| 1812 | + outputName: readFlag(parsed.flags, 'output', 'output-name') || (payload.outputName as string | undefined), |
| 1813 | + size: readFlag(parsed.flags, 'size') || (payload.size as string | undefined), |
| 1814 | + }); |
| 1815 | + } |
| 1816 | + |
1503 | 1817 | if (action === 'write' || action === 'create') { |
1504 | 1818 | const relPathInput = requireString(readFlag(parsed.flags, 'path') || payload.path, 'path'); |
1505 | 1819 | const fallbackExtension = getManuscriptExtension(relPathInput) || '.md'; |
|
0 commit comments