@remixmate/cli 0.9.63 → 0.9.65

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/dist/cli.js +13 -0
  2. package/dist/handlers/index.js +2 -0
  3. package/dist/handlers/template-init.d.ts +10 -0
  4. package/dist/handlers/template-init.js +21 -0
  5. package/dist/manifest.json +53 -3
  6. package/dist/template/args.d.ts +25 -0
  7. package/dist/template/args.js +69 -0
  8. package/dist/template/check.d.ts +23 -0
  9. package/dist/template/check.js +25 -0
  10. package/dist/template/commands.d.ts +25 -0
  11. package/dist/template/commands.js +146 -0
  12. package/dist/template/constants.d.ts +25 -0
  13. package/dist/template/constants.js +32 -0
  14. package/dist/template/init.d.ts +22 -0
  15. package/dist/template/init.js +115 -0
  16. package/dist/template/list.d.ts +23 -0
  17. package/dist/template/list.js +46 -0
  18. package/dist/template/preview.d.ts +60 -0
  19. package/dist/template/preview.js +318 -0
  20. package/dist/template/proc.d.ts +26 -0
  21. package/dist/template/proc.js +43 -0
  22. package/dist/template/project.d.ts +37 -0
  23. package/dist/template/project.js +110 -0
  24. package/dist/template/publish.d.ts +43 -0
  25. package/dist/template/publish.js +182 -0
  26. package/dist/template/render-client.d.ts +39 -0
  27. package/dist/template/render-client.js +79 -0
  28. package/package.json +1 -1
  29. package/skills/template-authoring/SKILL.md +69 -0
  30. package/skills/template-authoring/skill.json +32 -0
  31. package/skills/template-authoring/version.json +7 -0
  32. package/skills/template-registry/SKILL.md +2 -2
  33. package/skills/template-registry/scripts/list_templates.py +46 -13
  34. package/skills/template-registry/scripts/registry_loader.py +17 -0
  35. package/skills/template-registry/skill.json +1 -1
@@ -0,0 +1,115 @@
1
+ /**
2
+ * `remixmate template init <dir> [--force]` — scaffold a starter project.
3
+ *
4
+ * Only Node + npm are required (requirement 3.1): the starter is fetched with
5
+ * `npm pack` (works with whatever registry/proxy npm is configured for), unpacked
6
+ * with the system `tar` (bsdtar ships with Windows 10+), then `npm install`ed.
7
+ * No login is needed (requirement 3.5).
8
+ */
9
+ import { existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, statSync } from 'node:fs';
10
+ import os from 'node:os';
11
+ import path from 'node:path';
12
+ import { EXIT, SkillError } from '../errors.js';
13
+ import { emitProgress } from '../progress.js';
14
+ import { STARTER_PACKAGE, STARTER_TARBALL_ENV, STARTER_VERSION } from './constants.js';
15
+ import { Output } from './project.js';
16
+ import { runNpm, runProcess } from './proc.js';
17
+ function isNonEmptyDir(dir) {
18
+ if (!existsSync(dir))
19
+ return false;
20
+ if (!statSync(dir).isDirectory()) {
21
+ throw new SkillError(`❌ ${dir} 已存在且不是目录`, EXIT.USAGE);
22
+ }
23
+ return readdirSync(dir).length > 0;
24
+ }
25
+ /** Obtain the starter tarball: local override, else `npm pack` into `tmp`. */
26
+ async function obtainTarball(tmp, out) {
27
+ const override = (process.env[STARTER_TARBALL_ENV] ?? '').trim();
28
+ if (override) {
29
+ const file = path.resolve(override);
30
+ if (!existsSync(file)) {
31
+ throw new SkillError(`❌ ${STARTER_TARBALL_ENV} 指向的文件不存在: ${file}`, EXIT.USAGE);
32
+ }
33
+ out.info(`使用本地 starter 包: ${file}`);
34
+ return { file, label: `local:${path.basename(file)}` };
35
+ }
36
+ const spec = `${STARTER_PACKAGE}@${STARTER_VERSION}`;
37
+ out.info(`下载 ${spec} …`);
38
+ const res = await runNpm(['pack', spec, '--json', '--silent'], { cwd: tmp, stdio: 'pipe' });
39
+ if (res.code !== 0) {
40
+ const tail = res.stderr.trim().split('\n').slice(-5).join('\n');
41
+ throw new SkillError(`❌ npm pack ${spec} 失败(退出码 ${res.code})。检查网络与 npm registry 配置。\n${tail}`, EXIT.BACKEND_UNREACHABLE);
42
+ }
43
+ let file;
44
+ try {
45
+ const parsed = JSON.parse(res.stdout);
46
+ if (parsed[0]?.filename)
47
+ file = path.join(tmp, parsed[0].filename);
48
+ }
49
+ catch {
50
+ // Older npm without --json support: fall back to whatever .tgz landed in tmp.
51
+ }
52
+ file ??= readdirSync(tmp).filter((f) => f.endsWith('.tgz')).map((f) => path.join(tmp, f))[0];
53
+ if (!file || !existsSync(file))
54
+ throw new SkillError('❌ npm pack 未产出 tarball');
55
+ return { file, label: spec };
56
+ }
57
+ export async function runInit(opts) {
58
+ const out = new Output(Boolean(opts.json));
59
+ if (!opts.dir)
60
+ throw new SkillError('❌ 缺少目标目录:remixmate template init <dir>', EXIT.USAGE);
61
+ const dir = path.resolve(opts.dir);
62
+ if (isNonEmptyDir(dir) && !opts.force) {
63
+ throw new SkillError(`❌ 目标目录非空: ${dir}\n 换一个空目录,或确认可以覆盖后加 --force。`, EXIT.USAGE);
64
+ }
65
+ const tmp = mkdtempSync(path.join(os.tmpdir(), 'remixmate-starter-'));
66
+ try {
67
+ emitProgress({ phase: 'template-init:fetch', progress: 0.1 });
68
+ const { file, label } = await obtainTarball(tmp, out);
69
+ emitProgress({ phase: 'template-init:extract', progress: 0.4 });
70
+ mkdirSync(dir, { recursive: true });
71
+ // npm tarballs are rooted at `package/` — strip it so files land directly in <dir>.
72
+ const tar = await runProcess('tar', ['-xzf', file, '-C', dir, '--strip-components=1'], { stdio: 'pipe' });
73
+ if (tar.code !== 0) {
74
+ throw new SkillError(`❌ 解压 starter 失败(tar 退出码 ${tar.code}): ${tar.stderr.trim().slice(0, 300)}`);
75
+ }
76
+ let installed = false;
77
+ if (!opts.skipInstall) {
78
+ emitProgress({ phase: 'template-init:install', progress: 0.6 });
79
+ out.info('安装依赖(npm install)…');
80
+ const inst = await runNpm(['install', '--no-audit', '--no-fund'], { cwd: dir, stdio: 'stderr' });
81
+ if (inst.code !== 0) {
82
+ throw new SkillError(`❌ npm install 失败(退出码 ${inst.code})。工程文件已解压到 ${dir},修复后在该目录重跑 npm install。`);
83
+ }
84
+ installed = true;
85
+ }
86
+ emitProgress({ phase: 'template-init:done', progress: 1 });
87
+ printNextSteps(out, dir, installed);
88
+ const result = { ok: true, dir, starter: label, installed };
89
+ out.result({ ...result });
90
+ return result;
91
+ }
92
+ finally {
93
+ rmSync(tmp, { recursive: true, force: true });
94
+ }
95
+ }
96
+ function printNextSteps(out, dir, installed) {
97
+ const rel = path.relative(process.cwd(), dir) || '.';
98
+ out.info('');
99
+ out.info(`✅ 模板工程已创建: ${dir}`);
100
+ out.info('');
101
+ out.info('下一步:');
102
+ out.info(` cd ${rel}`);
103
+ if (!installed)
104
+ out.info(' npm install');
105
+ out.info(' npm run dev # 打开 Remotion Studio 本地预览');
106
+ out.info(' remixmate template check <name> # 本地校验(无需登录)');
107
+ out.info(' remixmate template preview <name> # 远程渲染预览 mp4(需登录)');
108
+ out.info(' remixmate template publish <name> # 发布为你的私有模板(需登录)');
109
+ out.info('');
110
+ out.info('在本地 agent 里使用:');
111
+ out.info(' Codex / WorkBuddy 在工程目录打开,agent 会读取 AGENTS.md');
112
+ out.info(' Claude Code 在工程目录打开,agent 会读取 CLAUDE.md(与 AGENTS.md 内容相同)');
113
+ out.info(' TraeWork 只认项目级 skill:在工程目录执行一次 remixmate install --project');
114
+ out.info(' 其它宿主若还没装 skill:remixmate install');
115
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * `remixmate template list` — the caller's own (non-builtin) templates.
3
+ *
4
+ * Uses ab-api `/remotionTemplate/myPage` (the "mine" view: user_id = caller,
5
+ * is_builtin = 0, not deleted). The builtin filter is re-applied client side
6
+ * so a backend change can never make this list show platform templates.
7
+ */
8
+ export interface ListOptions {
9
+ json?: boolean;
10
+ token?: string;
11
+ apiBaseUrl?: string;
12
+ }
13
+ export interface PrivateTemplate {
14
+ templateKey: string;
15
+ name: string;
16
+ lifecycleStatus?: string;
17
+ coreVersion?: string;
18
+ updateTime?: string;
19
+ }
20
+ export declare function runList(opts: ListOptions): Promise<{
21
+ ok: true;
22
+ templates: PrivateTemplate[];
23
+ }>;
@@ -0,0 +1,46 @@
1
+ /**
2
+ * `remixmate template list` — the caller's own (non-builtin) templates.
3
+ *
4
+ * Uses ab-api `/remotionTemplate/myPage` (the "mine" view: user_id = caller,
5
+ * is_builtin = 0, not deleted). The builtin filter is re-applied client side
6
+ * so a backend change can never make this list show platform templates.
7
+ */
8
+ import { mmPost, resolveHttpContext } from '../http.js';
9
+ import { padDisplay } from '../text.js';
10
+ import { Output } from './project.js';
11
+ const PAGE_SIZE = 100;
12
+ const MAX_PAGES = 20;
13
+ export async function runList(opts) {
14
+ const out = new Output(Boolean(opts.json));
15
+ const ctx = await resolveHttpContext('template-list', { privateToken: opts.token, apiBaseUrl: opts.apiBaseUrl });
16
+ const templates = [];
17
+ for (let current = 1; current <= MAX_PAGES; current++) {
18
+ const page = await mmPost(ctx, '/remotionTemplate/myPage', { current, pageSize: PAGE_SIZE }, { timeoutMs: 30_000 });
19
+ const list = page?.list ?? [];
20
+ for (const t of list) {
21
+ if (t.isBuiltin === true)
22
+ continue;
23
+ templates.push({
24
+ templateKey: String(t.templateKey ?? ''),
25
+ name: String(t.name ?? ''),
26
+ lifecycleStatus: typeof t.lifecycleStatus === 'string' ? t.lifecycleStatus : undefined,
27
+ coreVersion: typeof t.coreVersion === 'string' && t.coreVersion ? t.coreVersion : undefined,
28
+ updateTime: typeof t.updateTime === 'string' ? t.updateTime : undefined,
29
+ });
30
+ }
31
+ const total = page?.pager?.total ?? 0;
32
+ if (list.length < PAGE_SIZE || current * PAGE_SIZE >= total)
33
+ break;
34
+ }
35
+ if (templates.length === 0) {
36
+ out.info('还没有私有模板。用 remixmate template publish <name> 发布一个。');
37
+ }
38
+ else {
39
+ for (const t of templates) {
40
+ out.info(` ${padDisplay(t.templateKey, 32)}${padDisplay(t.name, 24)}${padDisplay(t.lifecycleStatus ?? '', 12)}${t.updateTime ?? ''}`);
41
+ }
42
+ }
43
+ const result = { ok: true, templates };
44
+ out.result(result);
45
+ return result;
46
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * `remixmate template preview <name>` — render a draft mp4 on the real ab-render.
3
+ *
4
+ * Ported from template-library `scripts/preview-remote.mjs` (same HTTP contract,
5
+ * same file-enumeration and sample-props rules). It deliberately does NOT
6
+ * reimplement the DSL → TemplateBinding pipeline: preview answers "does this
7
+ * code run in the real renderer"; binding correctness is `render-video`'s job.
8
+ *
9
+ * Sample props are contract-driven: they come from the template's own
10
+ * `dsl-example.json` through the `slotMapping[slot].propExtractors` it declares,
11
+ * plus two global conventions (scene.customPayload → props.templateData,
12
+ * dsl.assets → resolvedAssets). Nothing here knows any specific template.
13
+ */
14
+ export declare const PREVIEW_LIMIT_MESSAGE: string;
15
+ export interface PreviewOptions {
16
+ name: string;
17
+ propsJson?: string;
18
+ durationSec?: number;
19
+ compositionId?: string;
20
+ timeoutSec?: number;
21
+ json?: boolean;
22
+ token?: string;
23
+ apiBaseUrl?: string;
24
+ cwd?: string;
25
+ /** Poll interval override (tests). */
26
+ pollIntervalMs?: number;
27
+ }
28
+ type Json = Record<string, any>;
29
+ export declare function collectFiles(root: string, warn?: (m: string) => void): {
30
+ files: Record<string, string>;
31
+ bytes: number;
32
+ };
33
+ export interface SampleProps {
34
+ props: Json | Json[];
35
+ assets: unknown[];
36
+ durationSec: number | null;
37
+ scene?: Json;
38
+ }
39
+ export declare function buildSampleProps(tplDir: string, tpl: Json, compositionId: string, propsJson: string | undefined, warn?: (m: string) => void): SampleProps;
40
+ /** Narration → subtitle segments (exact from items[].atSec, else proportional by sentence). */
41
+ export declare function buildSubtitleSegments(scene: Json | undefined, totalFrames: number): Json[];
42
+ /** Largest even-pixel frame of the given aspect that fits the draft preview limits. */
43
+ export declare function previewDimensions(aspect: string): [number, number];
44
+ export interface PreviewPlan {
45
+ files: Record<string, string>;
46
+ inputProps: Json;
47
+ compositionId: string;
48
+ totalSeconds: number;
49
+ }
50
+ export declare function buildPreviewPlan(tplDir: string, opts: Pick<PreviewOptions, 'propsJson' | 'durationSec' | 'compositionId'>, warn?: (m: string) => void): PreviewPlan;
51
+ export interface PreviewResult {
52
+ ok: true;
53
+ name: string;
54
+ taskId: string;
55
+ url: string;
56
+ compositionId: string;
57
+ durationSec: number;
58
+ }
59
+ export declare function runPreview(opts: PreviewOptions): Promise<PreviewResult>;
60
+ export {};
@@ -0,0 +1,318 @@
1
+ /**
2
+ * `remixmate template preview <name>` — render a draft mp4 on the real ab-render.
3
+ *
4
+ * Ported from template-library `scripts/preview-remote.mjs` (same HTTP contract,
5
+ * same file-enumeration and sample-props rules). It deliberately does NOT
6
+ * reimplement the DSL → TemplateBinding pipeline: preview answers "does this
7
+ * code run in the real renderer"; binding correctness is `render-video`'s job.
8
+ *
9
+ * Sample props are contract-driven: they come from the template's own
10
+ * `dsl-example.json` through the `slotMapping[slot].propExtractors` it declares,
11
+ * plus two global conventions (scene.customPayload → props.templateData,
12
+ * dsl.assets → resolvedAssets). Nothing here knows any specific template.
13
+ */
14
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
15
+ import path from 'node:path';
16
+ import { EXIT, SkillError } from '../errors.js';
17
+ import { resolveHttpContext } from '../http.js';
18
+ import { emitProgress } from '../progress.js';
19
+ import { IDENTITY_FILE, PREVIEW_MAX_LONG_EDGE, PREVIEW_MAX_SECONDS, PREVIEW_MAX_SHORT_EDGE, } from './constants.js';
20
+ import { runCheck } from './check.js';
21
+ import { Output, readJsonFile, requireProjectRoot, requireTemplateDir, validateTemplateName } from './project.js';
22
+ import { RenderBusinessError, renderPost, resolveRenderBaseUrl } from './render-client.js';
23
+ const FPS = 30;
24
+ const DEFAULT_DURATION_SEC = 5;
25
+ const DEFAULT_TIMEOUT_SEC = 180;
26
+ // Same enumeration rules as preview-remote.mjs.
27
+ const SKIP_DIRS = new Set(['node_modules', 'dist', '__pycache__', '.git']);
28
+ const TEXT_EXTS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mts', '.cts', '.json', '.css', '.scss', '.md']);
29
+ const MAX_FILE_BYTES = 512 * 1024;
30
+ const WARN_TOTAL_BYTES = 20 * 1024 * 1024;
31
+ /** Keys must stay in sync with the metadata schema's supportedAspectRatios enum. */
32
+ const ASPECT_RATIOS = {
33
+ '16:9': [16, 9],
34
+ '9:16': [9, 16],
35
+ '1:1': [1, 1],
36
+ '3:4': [3, 4],
37
+ '4:3': [4, 3],
38
+ };
39
+ export const PREVIEW_LIMIT_MESSAGE = `草稿预览超出上限:长边 ≤ ${PREVIEW_MAX_LONG_EDGE}px、短边 ≤ ${PREVIEW_MAX_SHORT_EDGE}px、时长 ≤ ${PREVIEW_MAX_SECONDS} 秒。` +
40
+ '请缩短 --duration-sec,或减少 --props-json 的页数;正式出片用 remixmate render-video,不受此限。';
41
+ // ── files ────────────────────────────────────────────────────────────────────
42
+ export function collectFiles(root, warn = () => { }) {
43
+ const files = {};
44
+ let bytes = 0;
45
+ const walk = (dir) => {
46
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
47
+ const full = path.join(dir, entry.name);
48
+ if (entry.isDirectory()) {
49
+ if (!SKIP_DIRS.has(entry.name))
50
+ walk(full);
51
+ continue;
52
+ }
53
+ if (!TEXT_EXTS.has(path.extname(entry.name)))
54
+ continue;
55
+ if (/\.(test|spec)\./.test(entry.name))
56
+ continue;
57
+ // Local publish identity — not template source.
58
+ if (dir === root && entry.name === IDENTITY_FILE)
59
+ continue;
60
+ const size = statSync(full).size;
61
+ if (size > MAX_FILE_BYTES) {
62
+ warn(` ⚠ 跳过超大文件(${(size / 1024).toFixed(0)} KB):${path.relative(root, full)}`);
63
+ continue;
64
+ }
65
+ bytes += size;
66
+ files[path.relative(root, full).split(path.sep).join('/')] = readFileSync(full, 'utf8');
67
+ }
68
+ };
69
+ walk(root);
70
+ return { files, bytes };
71
+ }
72
+ // ── sample props ─────────────────────────────────────────────────────────────
73
+ function applyExtractors(scene, extractors) {
74
+ const out = {};
75
+ for (const [propName, spec] of Object.entries(extractors ?? {})) {
76
+ if (!spec || typeof spec.from !== 'string')
77
+ continue;
78
+ const from = spec.from;
79
+ if (from === 'textLayers') {
80
+ const layer = (scene.textLayers ?? []).find((l) => l.role === spec.role);
81
+ if (layer?.content !== undefined)
82
+ out[propName] = layer.content;
83
+ continue;
84
+ }
85
+ const value = from.split('.').reduce((acc, k) => (acc == null ? acc : acc[k]), scene); // eslint-disable-line @typescript-eslint/no-explicit-any
86
+ if (value !== undefined)
87
+ out[propName] = value;
88
+ }
89
+ return out;
90
+ }
91
+ export function buildSampleProps(tplDir, tpl, compositionId, propsJson, warn = () => { }) {
92
+ const examplePath = path.join(tplDir, 'dsl-example.json');
93
+ const dsl = existsSync(examplePath) ? readJsonFile(examplePath) : null;
94
+ const assets = Array.isArray(dsl?.assets) ? dsl.assets : [];
95
+ // --props-json overrides props but keeps the example's asset table: assetRefs
96
+ // in the override still need something to resolve against.
97
+ if (propsJson !== undefined) {
98
+ let props;
99
+ try {
100
+ props = JSON.parse(propsJson);
101
+ }
102
+ catch (err) {
103
+ throw new SkillError(`❌ --props-json 解析失败: ${err.message}`, EXIT.USAGE);
104
+ }
105
+ return { props, assets, durationSec: null };
106
+ }
107
+ const scene = (dsl?.scenes ?? [])[0];
108
+ if (!scene)
109
+ return { props: {}, assets, durationSec: null };
110
+ const slot = (tpl.compositions ?? []).find((c) => c.compositionId === compositionId)?.slot;
111
+ const extractors = slot ? tpl.slotMapping?.[slot]?.propExtractors : undefined;
112
+ // Covers render through a dedicated channel and never declare slotMapping.cover.
113
+ if (slot && slot !== 'cover' && !extractors) {
114
+ warn(` ⚠ template.json 的 slotMapping.${slot} 未声明 propExtractors —— 预览将只带 templateData。`);
115
+ }
116
+ const props = applyExtractors(scene, extractors);
117
+ if (scene.customPayload)
118
+ props.templateData = scene.customPayload;
119
+ return { props, assets, durationSec: typeof scene.duration === 'number' ? scene.duration : null, scene };
120
+ }
121
+ /** Narration → subtitle segments (exact from items[].atSec, else proportional by sentence). */
122
+ export function buildSubtitleSegments(scene, totalFrames) {
123
+ const narration = scene?.audio?.narration;
124
+ if (!narration)
125
+ return [];
126
+ const finish = (segs) => segs
127
+ .filter((s) => s.text)
128
+ .map((s, i, arr) => ({
129
+ text: s.text,
130
+ startFrame: Math.max(0, Math.round(s.startFrame)),
131
+ endFrame: i === arr.length - 1 ? totalFrames : Math.round(arr[i + 1].startFrame),
132
+ }))
133
+ .filter((s) => s.endFrame > s.startFrame);
134
+ const items = Array.isArray(narration.items) ? narration.items : null;
135
+ if (items && items.length > 0 && items.some((it) => typeof it?.atSec === 'number')) {
136
+ let cursor = 0;
137
+ return finish(items.map((it) => {
138
+ const at = typeof it?.atSec === 'number' ? it.atSec * FPS : cursor;
139
+ cursor = at;
140
+ return { text: it?.text ?? '', startFrame: at };
141
+ }));
142
+ }
143
+ const text = typeof narration.text === 'string' ? narration.text.trim() : '';
144
+ if (!text)
145
+ return [];
146
+ const sentences = text.split(/(?<=[。!?!?])/).map((s) => s.trim()).filter(Boolean);
147
+ if (sentences.length === 0)
148
+ return [];
149
+ const weights = sentences.map((s) => Math.max(1, s.length));
150
+ const total = weights.reduce((a, b) => a + b, 0);
151
+ let acc = 0;
152
+ return finish(sentences.map((s, i) => {
153
+ const startFrame = (acc / total) * totalFrames;
154
+ acc += weights[i];
155
+ return { text: s, startFrame };
156
+ }));
157
+ }
158
+ /** Largest even-pixel frame of the given aspect that fits the draft preview limits. */
159
+ export function previewDimensions(aspect) {
160
+ const [aw, ah] = ASPECT_RATIOS[aspect] ?? ASPECT_RATIOS['16:9'];
161
+ const landscape = aw >= ah;
162
+ const long = landscape ? aw : ah;
163
+ const short = landscape ? ah : aw;
164
+ const scale = Math.min(PREVIEW_MAX_LONG_EDGE / long, PREVIEW_MAX_SHORT_EDGE / short);
165
+ const even = (n) => Math.floor(n / 2) * 2;
166
+ const w = even(aw * scale);
167
+ const h = even(ah * scale);
168
+ return [w, h];
169
+ }
170
+ export function buildPreviewPlan(tplDir, opts, warn = () => { }) {
171
+ const tpl = readJsonFile(path.join(tplDir, 'template.json'));
172
+ const comps = Array.isArray(tpl.compositions) ? tpl.compositions : [];
173
+ const compositionId = opts.compositionId ?? comps[0]?.compositionId;
174
+ if (!compositionId)
175
+ throw new SkillError('❌ template.json 未声明 compositions', EXIT.USAGE);
176
+ if (opts.compositionId && !comps.some((c) => c.compositionId === compositionId)) {
177
+ throw new SkillError(`❌ composition "${compositionId}" 不在该模板中。可选: ${comps.map((c) => c.compositionId).join(', ')}`, EXIT.USAGE);
178
+ }
179
+ const { files, bytes } = collectFiles(tplDir, warn);
180
+ if (Object.keys(files).length === 0)
181
+ throw new SkillError('❌ 模板目录没有可提交的源文件');
182
+ if (bytes > WARN_TOTAL_BYTES)
183
+ warn(` ⚠ 模板总体积 ${(bytes / 1024 / 1024).toFixed(1)} MB,接近 ab-render 的请求上限`);
184
+ const sample = buildSampleProps(tplDir, tpl, compositionId, opts.propsJson, warn);
185
+ const aspect = (tpl.supportedAspectRatios ?? [])[0] ?? '16:9';
186
+ const durationSec = Number(opts.durationSec ?? sample.durationSec ?? tpl.supportedDurations?.min ?? DEFAULT_DURATION_SEC);
187
+ const [width, height] = previewDimensions(aspect);
188
+ // An array of props = one page each, chained into a multi-entry timeline.
189
+ const propsList = Array.isArray(sample.props) ? sample.props : null;
190
+ const perSceneFrames = Math.max(1, Math.round(durationSec * FPS));
191
+ const totalFrames = propsList ? perSceneFrames * propsList.length : perSceneFrames;
192
+ const totalSeconds = totalFrames / FPS;
193
+ if (totalSeconds > PREVIEW_MAX_SECONDS) {
194
+ throw new SkillError(`❌ ${PREVIEW_LIMIT_MESSAGE}(本次 ${totalSeconds.toFixed(1)} 秒)`, EXIT.USAGE);
195
+ }
196
+ const subtitleSegments = propsList ? [] : buildSubtitleSegments(sample.scene, perSceneFrames);
197
+ const timeline = (propsList ?? [sample.props]).map((props, i) => {
198
+ const startFrame = i * perSceneFrames;
199
+ const endFrame = startFrame + perSceneFrames;
200
+ return {
201
+ sceneId: propsList ? `preview-${String(i + 1).padStart(2, '0')}` : 'preview',
202
+ compositionId,
203
+ startFrame,
204
+ endFrame,
205
+ durationFrames: perSceneFrames,
206
+ startTime: Number((startFrame / FPS).toFixed(2)),
207
+ endTime: Number((endFrame / FPS).toFixed(2)),
208
+ props,
209
+ layers: [],
210
+ subtitleSegments,
211
+ transition: { type: 'none', durationFrames: 0 },
212
+ };
213
+ });
214
+ const inputProps = {
215
+ timeline,
216
+ renderConfig: {
217
+ width,
218
+ height,
219
+ fps: FPS,
220
+ totalFrames,
221
+ totalDuration: Number(totalSeconds.toFixed(2)),
222
+ codec: 'h264',
223
+ crf: 18,
224
+ outputFormat: 'mp4',
225
+ },
226
+ resolvedAssets: sample.assets,
227
+ globalTypography: tpl.defaultTypography,
228
+ colorScheme: tpl.defaultColorScheme,
229
+ motionPreset: tpl.defaultMotionPreset,
230
+ };
231
+ return { files, inputProps, compositionId, totalSeconds };
232
+ }
233
+ function isLimitError(err) {
234
+ return JSON.stringify(err.payload ?? {}).includes('draft_preview_limit_exceeded');
235
+ }
236
+ function printScanIssues(out, err) {
237
+ const data = err.payload?.data;
238
+ const issues = data?.issues ?? err.payload?.issues;
239
+ if (!Array.isArray(issues) || issues.length === 0)
240
+ return false;
241
+ out.warn(`\n✗ 被 ab-render 的安全扫描拒绝(${issues.length} 处):`);
242
+ for (const i of issues) {
243
+ out.warn(typeof i === 'string' ? ` · ${i}` : ` · ${i.file}\n ${i.reason}${i.detail ? `: ${i.detail}` : ''}`);
244
+ }
245
+ out.warn('\n 先在本地运行 remixmate template check,它与平台使用同一份安全策略。');
246
+ return true;
247
+ }
248
+ export async function runPreview(opts) {
249
+ const out = new Output(Boolean(opts.json));
250
+ const name = validateTemplateName(opts.name);
251
+ const root = requireProjectRoot(opts.cwd);
252
+ const tplDir = requireTemplateDir(root, name);
253
+ emitProgress({ phase: 'template-preview:check', progress: 0.05 });
254
+ const checkCode = await runCheck({ name, cwd: root, stdio: opts.json ? 'stderr' : 'inherit' });
255
+ if (checkCode !== 0) {
256
+ throw new SkillError(`❌ 本地校验未通过(check 退出码 ${checkCode}),已中止预览。`, checkCode === 2 ? EXIT.USAGE : EXIT.ERROR);
257
+ }
258
+ const plan = buildPreviewPlan(tplDir, opts, (m) => out.warn(m));
259
+ const http = await resolveHttpContext('template-preview', { privateToken: opts.token, apiBaseUrl: opts.apiBaseUrl });
260
+ const base = resolveRenderBaseUrl();
261
+ const { width, height } = plan.inputProps.renderConfig;
262
+ out.info(`模板 ${name}`);
263
+ out.info(`composition ${plan.compositionId}`);
264
+ out.info(`画幅 ${width}×${height} ${plan.totalSeconds.toFixed(1)}s @${FPS}fps`);
265
+ out.info(`文件 ${Object.keys(plan.files).length} 个`);
266
+ out.info(`ab-render ${base}`);
267
+ const post = (route, body) => renderPost(base, route, body, { token: http.privateToken, skillName: 'template-preview' });
268
+ let taskId;
269
+ try {
270
+ emitProgress({ phase: 'template-preview:submit', progress: 0.1 });
271
+ const start = await post('/renderDraft', { files: plan.files, inputProps: plan.inputProps, upload: false });
272
+ if (!start.taskId)
273
+ throw new SkillError('❌ /renderDraft 未返回 taskId');
274
+ taskId = start.taskId;
275
+ }
276
+ catch (err) {
277
+ if (err instanceof RenderBusinessError) {
278
+ if (isLimitError(err))
279
+ throw new SkillError(`❌ ${PREVIEW_LIMIT_MESSAGE}`, EXIT.ERROR);
280
+ if (printScanIssues(out, err))
281
+ throw new SkillError('❌ 预览被安全扫描拒绝', EXIT.ERROR);
282
+ }
283
+ throw err;
284
+ }
285
+ out.info(`taskId ${taskId}`);
286
+ const timeoutSec = opts.timeoutSec ?? DEFAULT_TIMEOUT_SEC;
287
+ const interval = opts.pollIntervalMs ?? 1500;
288
+ const deadline = Date.now() + timeoutSec * 1000;
289
+ let lastStatus = '';
290
+ for (;;) {
291
+ if (Date.now() > deadline) {
292
+ // A timeout doesn't mean the task is gone — it's usually still queued.
293
+ throw new SkillError(`❌ 轮询超时(${timeoutSec}s),任务 ${taskId} 仍在 ab-render 排队/渲染。\n` +
294
+ ' 加长等待:--timeout-sec 900');
295
+ }
296
+ await new Promise((r) => setTimeout(r, interval));
297
+ const st = await post('/renderDraftStatus', { taskId });
298
+ if (st.status !== lastStatus) {
299
+ lastStatus = st.status ?? '';
300
+ out.info(st.status === 'pending' ? '排队中…(队列前面还有任务)' : `状态: ${st.status}`);
301
+ }
302
+ emitProgress({ phase: `template-preview:${st.status ?? 'unknown'}`, progress: 0.1 + 0.9 * (st.progress ?? 0), taskId });
303
+ if (st.status === 'succeeded') {
304
+ const url = st.previewUrl?.startsWith('http') ? st.previewUrl : `${base}${st.previewUrl ?? ''}`;
305
+ out.info(`\n✅ 预览就绪\n ${url}`);
306
+ out.info(' 临时产物,最长保留 24 小时(服务重启后可能更早失效)。');
307
+ const result = { ok: true, name, taskId, url, compositionId: plan.compositionId, durationSec: plan.totalSeconds };
308
+ out.result({ ...result });
309
+ return result;
310
+ }
311
+ if (st.status === 'failed') {
312
+ const detail = st.error ?? '(无错误详情)';
313
+ if (detail.includes('draft_preview_limit_exceeded'))
314
+ throw new SkillError(`❌ ${PREVIEW_LIMIT_MESSAGE}`);
315
+ throw new SkillError(`❌ 渲染失败: ${detail}`);
316
+ }
317
+ }
318
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Child-process helpers for `remixmate template`.
3
+ *
4
+ * Every command is spawned with an argv array — never a shell string — so a
5
+ * directory or package spec can't be interpreted by a shell. The one exception
6
+ * is npm on Windows: `npm` is `npm.cmd` there, and since Node's CVE-2024-27980
7
+ * fix a .cmd can only be spawned with `shell: true`. For that path we only ever
8
+ * pass fixed tokens or validated package specs (paths travel via `cwd`), and
9
+ * refuse anything containing shell metacharacters.
10
+ */
11
+ export interface ProcResult {
12
+ code: number;
13
+ /** Captured stdout — only populated when stdout is piped. */
14
+ stdout: string;
15
+ stderr: string;
16
+ }
17
+ export interface ProcOptions {
18
+ cwd?: string;
19
+ /** 'inherit' streams to the terminal; 'pipe' captures. Default: inherit. */
20
+ stdio?: 'inherit' | 'pipe' | 'stderr';
21
+ env?: NodeJS.ProcessEnv;
22
+ shell?: boolean;
23
+ }
24
+ export declare function runProcess(command: string, args: string[], opts?: ProcOptions): Promise<ProcResult>;
25
+ /** Run npm portably. `args` must be plain tokens; paths go through `cwd`. */
26
+ export declare function runNpm(args: string[], opts?: Omit<ProcOptions, 'shell'>): Promise<ProcResult>;
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Child-process helpers for `remixmate template`.
3
+ *
4
+ * Every command is spawned with an argv array — never a shell string — so a
5
+ * directory or package spec can't be interpreted by a shell. The one exception
6
+ * is npm on Windows: `npm` is `npm.cmd` there, and since Node's CVE-2024-27980
7
+ * fix a .cmd can only be spawned with `shell: true`. For that path we only ever
8
+ * pass fixed tokens or validated package specs (paths travel via `cwd`), and
9
+ * refuse anything containing shell metacharacters.
10
+ */
11
+ import { spawn } from 'node:child_process';
12
+ import { EXIT, SkillError } from '../errors.js';
13
+ export function runProcess(command, args, opts = {}) {
14
+ const mode = opts.stdio ?? 'inherit';
15
+ // 'stderr' = child output is human progress (npm install chatter), route it
16
+ // to our stderr so our stdout stays the result/progress protocol.
17
+ const stdio = mode === 'inherit' ? 'inherit' : mode === 'pipe' ? ['ignore', 'pipe', 'pipe'] : ['ignore', 'pipe', 'inherit'];
18
+ return new Promise((resolve, reject) => {
19
+ let stdout = '';
20
+ let stderr = '';
21
+ const child = spawn(command, args, { cwd: opts.cwd, stdio, env: opts.env ?? process.env, shell: opts.shell ?? false });
22
+ child.stdout?.on('data', (d) => {
23
+ if (mode === 'stderr')
24
+ process.stderr.write(d);
25
+ else
26
+ stdout += d.toString();
27
+ });
28
+ child.stderr?.on('data', (d) => (stderr += d.toString()));
29
+ child.on('error', (err) => reject(new SkillError(`❌ 无法启动 ${command}: ${err.message}`, EXIT.SPAWN_FAILED)));
30
+ child.on('close', (code, signal) => resolve({ code: signal ? EXIT.ERROR : code ?? EXIT.ERROR, stdout, stderr }));
31
+ });
32
+ }
33
+ const SAFE_SHELL_ARG = /^[A-Za-z0-9@._/:=+-]+$/;
34
+ /** Run npm portably. `args` must be plain tokens; paths go through `cwd`. */
35
+ export function runNpm(args, opts = {}) {
36
+ if (process.platform !== 'win32')
37
+ return runProcess('npm', args, opts);
38
+ for (const a of args) {
39
+ if (!SAFE_SHELL_ARG.test(a))
40
+ throw new SkillError(`❌ 拒绝把不安全的参数交给 npm.cmd: ${a}`);
41
+ }
42
+ return runProcess('npm.cmd', args, { ...opts, shell: true });
43
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Starter-project discovery, template identity (K4) and the shared output helper.
3
+ */
4
+ /** Walk up from `start` to the directory holding `.remixmate/check/index.mjs`. */
5
+ export declare function findProjectRoot(start?: string): string | null;
6
+ export declare function requireProjectRoot(start?: string): string;
7
+ /** A template name is a single directory under templates/ — never a path. */
8
+ export declare function validateTemplateName(name: string | undefined): string;
9
+ export declare function requireTemplateDir(root: string, name: string): string;
10
+ export declare function readJsonFile<T = Record<string, unknown>>(file: string): T;
11
+ export interface TemplateIdentity {
12
+ templateKey: string;
13
+ version: number;
14
+ }
15
+ /** kebab-case slug of a directory name; falls back to `template` when nothing survives. */
16
+ export declare function kebabSlug(input: string): string;
17
+ /** First-publish key: `my-<slug>-<6 hex>`. */
18
+ export declare function generateTemplateKey(dirName: string, hex?: string): string;
19
+ export declare function readIdentity(templateDir: string): TemplateIdentity | null;
20
+ /** Stable key: the identity file wins, so renaming the directory never forks a template. */
21
+ export declare function resolveTemplateKey(templateDir: string): {
22
+ templateKey: string;
23
+ existing: boolean;
24
+ };
25
+ export declare function writeIdentity(templateDir: string, identity: TemplateIdentity): void;
26
+ /**
27
+ * Human text goes to stdout by default. With --json it moves to stderr so the
28
+ * last stdout line is the JSON result (progress lines still go to stdout —
29
+ * hosts parse them by their `__progress__` marker).
30
+ */
31
+ export declare class Output {
32
+ readonly json: boolean;
33
+ constructor(json: boolean);
34
+ info(line?: string): void;
35
+ warn(line: string): void;
36
+ result(obj: Record<string, unknown>): void;
37
+ }