Skip to content

Commit e3e2346

Browse files
committed
Add manuscript export support for AV project packages
1 parent f3630a1 commit e3e2346

2 files changed

Lines changed: 326 additions & 2 deletions

File tree

desktop/electron/core/tools/appCliTool.ts

Lines changed: 316 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { z } from 'zod';
22
import path from 'node:path';
33
import fs from 'node:fs/promises';
4+
import { spawn } from 'node:child_process';
45
import matter from 'gray-matter';
56
import { ulid } from 'ulid';
67
import { toAppAssetUrl } from '../localAssetManager';
@@ -426,8 +427,8 @@ const APP_CLI_NAMESPACE_HELP: Record<string, { summary: string; actions: string[
426427
},
427428
manuscripts: {
428429
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"'],
431432
},
432433
knowledge: {
433434
summary: 'List and search saved knowledge items.',
@@ -804,6 +805,269 @@ function normalizePackageTimeline(timeline: Record<string, unknown>) {
804805
return timeline;
805806
}
806807

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+
8071071
export class AppCliTool extends DeclarativeTool<typeof AppCliParamsSchema> {
8081072
readonly name = 'app_cli';
8091073
readonly displayName = 'App CLI';
@@ -936,6 +1200,36 @@ export class AppCliTool extends DeclarativeTool<typeof AppCliParamsSchema> {
9361200
},
9371201
};
9381202
}
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+
}
9391233
return createSuccessResult(toPrettyJson(result), `${parsed.namespace} ${parsed.action}`);
9401234
} catch (error) {
9411235
const message = error instanceof Error ? error.message : String(error);
@@ -1500,6 +1794,26 @@ export class AppCliTool extends DeclarativeTool<typeof AppCliParamsSchema> {
15001794
};
15011795
}
15021796

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+
15031817
if (action === 'write' || action === 'create') {
15041818
const relPathInput = requireString(readFlag(parsed.flags, 'path') || payload.path, 'path');
15051819
const fallbackExtension = getManuscriptExtension(relPathInput) || '.md';

desktop/electron/pi/PiChatService.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3040,6 +3040,16 @@ export class PiChatService {
30403040
`- 文件路径: ${metadata.associatedFilePath}`,
30413041
'- 当用户要求分析/修改当前稿件时,优先围绕该文件操作。',
30423042
);
3043+
const associatedFilePath = String(metadata.associatedFilePath || '').trim().toLowerCase();
3044+
if (associatedFilePath.endsWith('.redvideo') || associatedFilePath.endsWith('.redaudio')) {
3045+
promptParts.push(
3046+
'- 这是一个可编辑的音视频工程稿,不是普通 Markdown。',
3047+
'- 处理当前工程时,优先使用 `app_cli` 读取和修改工程结构,而不是只改脚本文本。',
3048+
'- 推荐顺序:先 `app_cli(command="manuscripts clips --path \\"...工程路径...\\"")` 查看片段,再用 `app_cli(command="manuscripts clip-update --path \\"...工程路径...\\" --asset-id ... --track ... --order ... --duration-ms ...")` 修改时间线。',
3049+
'- 如果用户要求输出真实剪辑结果,调用 `app_cli(command="manuscripts export --path \\"...工程路径...\\"")` 导出粗剪媒体文件。',
3050+
'- 修改时间线前,先检查 clips 和关联素材,不要盲目假设当前工程里已经有可用片段。',
3051+
);
3052+
}
30433053
}
30443054

30453055
if (metadata.isContextBound && metadata.contextContent) {

0 commit comments

Comments
 (0)