@gthanks/moviespec 0.1.0
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.
- package/README.md +45 -0
- package/bin/moviespec.js +525 -0
- package/package.json +29 -0
- package/schema/manifest.schema.yaml +55 -0
- package/schema/moviespec.schema.yaml +56 -0
- package/schema/requirements.schema.yaml +25 -0
- package/schema/storyboard.schema.yaml +37 -0
- package/schema/validator.contract.yaml +23 -0
- package/templates/agents/antigravity.md +11 -0
- package/templates/agents/cursor.md +7 -0
- package/templates/agents/windsurf.md +7 -0
- package/templates/characters.md +19 -0
- package/templates/cinematography.md +14 -0
- package/templates/prompt_guide.md +12 -0
- package/templates/shot_template.md +18 -0
- package/templates/visual_style.md +14 -0
- package/templates/world_bible.md +14 -0
package/README.md
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# MovieSpec
|
|
2
|
+
|
|
3
|
+
MovieSpec 是一套面向 AI 图片/视频生成的「规范先行」工作方式:先把世界观、角色、视觉风格、镜头语言与分镜结构写成可机读/可复用的规范文档,再从这些规范稳定编译出 Prompt 与参数,并将每次生成的输入与输出写入 manifest 以便复现与排查一致性漂移。
|
|
4
|
+
|
|
5
|
+
## 目录结构
|
|
6
|
+
|
|
7
|
+
- `bin/`:CLI 入口
|
|
8
|
+
- `schema/`:可机读 schema(类型、路径、依赖、编译规则)
|
|
9
|
+
- `templates/`:各类规范文档模板(Markdown + YAML front matter)
|
|
10
|
+
|
|
11
|
+
## 安装
|
|
12
|
+
|
|
13
|
+
如果你想在全局使用该命令,可以通过 NPM 安装:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npm install -g @gthanks/moviespec@latest
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## 用户项目最小目录结构(与工具仓库分离)
|
|
20
|
+
|
|
21
|
+
MovieSpec 工具仓库是本目录(`moviespec/`)。用户实际制作项目建议使用独立工作目录(任意路径),并满足最小结构:
|
|
22
|
+
|
|
23
|
+
- `config.yaml`
|
|
24
|
+
- `specs/`
|
|
25
|
+
- `storyboard/`
|
|
26
|
+
- `prompts/`(由 `compile-prompts` 生成)
|
|
27
|
+
- `output/`(由 `generate` 生成)
|
|
28
|
+
|
|
29
|
+
你可以在用户项目目录下执行:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
moviespec init
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
CLI 将按 `schema/moviespec.schema.yaml` 的默认路径读取与写入上述目录。
|
|
36
|
+
|
|
37
|
+
## 最小命令集(v1)
|
|
38
|
+
|
|
39
|
+
- `moviespec init`
|
|
40
|
+
- `moviespec list types`
|
|
41
|
+
- `moviespec new <type> <name>`
|
|
42
|
+
- `moviespec compile-prompts --shots <file>`
|
|
43
|
+
- `moviespec validate` / `moviespec check`
|
|
44
|
+
- `moviespec generate image --shot <id>`(stub)
|
|
45
|
+
- `moviespec generate sequence --shots <file>`(stub)
|
package/bin/moviespec.js
ADDED
|
@@ -0,0 +1,525 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import fs from 'node:fs/promises';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import process from 'node:process';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
|
|
8
|
+
import chalk from 'chalk';
|
|
9
|
+
import { Command } from 'commander';
|
|
10
|
+
import fg from 'fast-glob';
|
|
11
|
+
import YAML from 'yaml';
|
|
12
|
+
|
|
13
|
+
function toPosix(p) {
|
|
14
|
+
return p.split(path.sep).join(path.posix.sep);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function pathExists(p) {
|
|
18
|
+
try {
|
|
19
|
+
await fs.access(p);
|
|
20
|
+
return true;
|
|
21
|
+
} catch {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function ensureDir(p) {
|
|
27
|
+
await fs.mkdir(p, { recursive: true });
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function readYamlFile(filePath) {
|
|
31
|
+
const raw = await fs.readFile(filePath, 'utf8');
|
|
32
|
+
return YAML.parse(raw);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function writeYamlFile(filePath, data) {
|
|
36
|
+
const raw = YAML.stringify(data);
|
|
37
|
+
await fs.writeFile(filePath, raw, 'utf8');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function readJsonFile(filePath) {
|
|
41
|
+
const raw = await fs.readFile(filePath, 'utf8');
|
|
42
|
+
return JSON.parse(raw);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function writeJsonFile(filePath, data) {
|
|
46
|
+
const raw = JSON.stringify(data, null, 2) + '\n';
|
|
47
|
+
await fs.writeFile(filePath, raw, 'utf8');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function stableStringify(value) {
|
|
51
|
+
if (value === null || typeof value !== 'object') return JSON.stringify(value);
|
|
52
|
+
if (Array.isArray(value)) return `[${value.map((v) => stableStringify(v)).join(',')}]`;
|
|
53
|
+
const keys = Object.keys(value).sort();
|
|
54
|
+
return `{${keys.map((k) => `${JSON.stringify(k)}:${stableStringify(value[k])}`).join(',')}}`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function sha256Hex(str) {
|
|
58
|
+
const crypto = await import('node:crypto');
|
|
59
|
+
return crypto.createHash('sha256').update(str, 'utf8').digest('hex');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function loadMovieSpecSchema() {
|
|
63
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
64
|
+
const schemaPath = path.resolve(here, '..', 'schema', 'moviespec.schema.yaml');
|
|
65
|
+
return readYamlFile(schemaPath);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function resolveProjectRoot(projectRoot) {
|
|
69
|
+
return path.resolve(projectRoot ?? process.cwd());
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function readProjectConfig(projectRoot, schema) {
|
|
73
|
+
const configPath = path.join(projectRoot, schema.project?.config ?? 'config.yaml');
|
|
74
|
+
if (!(await pathExists(configPath))) return null;
|
|
75
|
+
return readYamlFile(configPath);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function listTypes(schema) {
|
|
79
|
+
return Object.keys(schema.types ?? {});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function defaultTypePath(schema, typeId) {
|
|
83
|
+
const type = schema.types?.[typeId];
|
|
84
|
+
if (!type) throw new Error(`Unknown type: ${typeId}`);
|
|
85
|
+
return type.defaultPath;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function cmdInit(projectRoot) {
|
|
89
|
+
const schema = await loadMovieSpecSchema();
|
|
90
|
+
const root = resolveProjectRoot(projectRoot);
|
|
91
|
+
|
|
92
|
+
const dirs = [
|
|
93
|
+
schema.project?.specsDir ?? 'specs',
|
|
94
|
+
schema.project?.storyboardDir ?? 'storyboard',
|
|
95
|
+
schema.project?.promptsDir ?? 'prompts',
|
|
96
|
+
schema.project?.outputDir ?? 'output',
|
|
97
|
+
path.join(schema.project?.outputDir ?? 'output', schema.project?.manifestsDir ?? 'manifests'),
|
|
98
|
+
path.join(schema.project?.outputDir ?? 'output', schema.project?.shotsDir ?? 'shots')
|
|
99
|
+
];
|
|
100
|
+
|
|
101
|
+
for (const d of dirs) {
|
|
102
|
+
await ensureDir(path.join(root, d));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const configPath = path.join(root, schema.project?.config ?? 'config.yaml');
|
|
106
|
+
if (!(await pathExists(configPath))) {
|
|
107
|
+
const defaultConfig = {
|
|
108
|
+
moviespec_version: schema.version,
|
|
109
|
+
media: {
|
|
110
|
+
resolution: '1920x1080',
|
|
111
|
+
aspect_ratio: '16:9',
|
|
112
|
+
fps: 24
|
|
113
|
+
},
|
|
114
|
+
lora: {
|
|
115
|
+
characters: {},
|
|
116
|
+
styles: {}
|
|
117
|
+
},
|
|
118
|
+
backend: {
|
|
119
|
+
id: 'stub'
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
await writeYamlFile(configPath, defaultConfig);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
console.log(chalk.green(`Initialized MovieSpec project at: ${root}`));
|
|
126
|
+
|
|
127
|
+
try {
|
|
128
|
+
const prompts = await import('@inquirer/prompts');
|
|
129
|
+
const selectedAgents = await prompts.checkbox({
|
|
130
|
+
message: 'Select AI agents to configure for this MovieSpec project:',
|
|
131
|
+
choices: [
|
|
132
|
+
{ name: 'Cursor', value: 'cursor' },
|
|
133
|
+
{ name: 'Windsurf', value: 'windsurf' },
|
|
134
|
+
{ name: 'Antigravity', value: 'antigravity' }
|
|
135
|
+
]
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
if (selectedAgents.length > 0) {
|
|
139
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
140
|
+
const templatesDir = path.resolve(here, '..', 'templates', 'agents');
|
|
141
|
+
|
|
142
|
+
const agentFiles = {
|
|
143
|
+
cursor: '.cursorrules',
|
|
144
|
+
windsurf: '.windsurfrules',
|
|
145
|
+
antigravity: 'AGENTS.md'
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
for (const agent of selectedAgents) {
|
|
149
|
+
const sourceFile = path.join(templatesDir, `${agent}.md`);
|
|
150
|
+
const targetFile = path.join(root, agentFiles[agent]);
|
|
151
|
+
|
|
152
|
+
if (await pathExists(sourceFile)) {
|
|
153
|
+
const content = await fs.readFile(sourceFile, 'utf8');
|
|
154
|
+
await fs.writeFile(targetFile, content, 'utf8');
|
|
155
|
+
console.log(chalk.blue(`Configured rules for ${agent}: ${agentFiles[agent]}`));
|
|
156
|
+
} else {
|
|
157
|
+
console.log(chalk.yellow(`Warning: Template for ${agent} not found at ${sourceFile}`));
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
} catch (err) {
|
|
162
|
+
// If the prompt is cancelled or in non-interactive environment, just ignore safely
|
|
163
|
+
if (err.name !== 'ExitPromptError') {
|
|
164
|
+
console.log(chalk.yellow(`Could not run interactive agent setup: ${err.message}`));
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function cmdListTypes() {
|
|
170
|
+
const schema = await loadMovieSpecSchema();
|
|
171
|
+
const types = await listTypes(schema);
|
|
172
|
+
for (const t of types) {
|
|
173
|
+
const desc = schema.types?.[t]?.description ?? '';
|
|
174
|
+
console.log(`${t}${desc ? `\t${desc}` : ''}`);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function cmdNew(typeId, name, { projectRoot }) {
|
|
179
|
+
const schema = await loadMovieSpecSchema();
|
|
180
|
+
const root = resolveProjectRoot(projectRoot);
|
|
181
|
+
|
|
182
|
+
const templateRel = schema.types?.[typeId]?.template;
|
|
183
|
+
if (!templateRel) throw new Error(`Unknown type or missing template: ${typeId}`);
|
|
184
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
185
|
+
const templateAbs = path.resolve(here, '..', templateRel);
|
|
186
|
+
|
|
187
|
+
const targetRel = defaultTypePath(schema, typeId);
|
|
188
|
+
const targetDir = path.join(root, targetRel);
|
|
189
|
+
await ensureDir(targetDir);
|
|
190
|
+
|
|
191
|
+
const filename = `${name}.md`;
|
|
192
|
+
const targetPath = path.join(targetDir, filename);
|
|
193
|
+
if (await pathExists(targetPath)) throw new Error(`File already exists: ${targetPath}`);
|
|
194
|
+
|
|
195
|
+
const template = await fs.readFile(templateAbs, 'utf8');
|
|
196
|
+
const rendered = template
|
|
197
|
+
.replaceAll('{{name}}', name)
|
|
198
|
+
.replaceAll('{{type}}', typeId);
|
|
199
|
+
|
|
200
|
+
await fs.writeFile(targetPath, rendered, 'utf8');
|
|
201
|
+
console.log(chalk.green(`Created ${typeId}: ${toPosix(path.relative(root, targetPath))}`));
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async function readAllSpecInputs(projectRoot, schema) {
|
|
205
|
+
const root = resolveProjectRoot(projectRoot);
|
|
206
|
+
const specsRoot = path.join(root, schema.project?.specsDir ?? 'specs');
|
|
207
|
+
|
|
208
|
+
const inputs = {};
|
|
209
|
+
for (const typeId of await listTypes(schema)) {
|
|
210
|
+
const rel = defaultTypePath(schema, typeId);
|
|
211
|
+
const abs = path.join(root, rel);
|
|
212
|
+
const files = await fg(['**/*.md', '**/*.yaml', '**/*.yml', '**/*.json'], { cwd: abs, dot: false, onlyFiles: true, suppressErrors: true });
|
|
213
|
+
inputs[typeId] = {
|
|
214
|
+
baseDir: abs,
|
|
215
|
+
files: files.map((f) => path.join(abs, f))
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const storyboardDir = path.join(root, schema.project?.storyboardDir ?? 'storyboard');
|
|
220
|
+
const storyboardFiles = await fg(['**/*.yaml', '**/*.yml', '**/*.json'], { cwd: storyboardDir, dot: false, onlyFiles: true, suppressErrors: true });
|
|
221
|
+
|
|
222
|
+
return {
|
|
223
|
+
root,
|
|
224
|
+
specsRoot,
|
|
225
|
+
storyboardDir,
|
|
226
|
+
storyboardFiles: storyboardFiles.map((f) => path.join(storyboardDir, f)),
|
|
227
|
+
inputs
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async function readStoryboardShots(filePath) {
|
|
232
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
233
|
+
if (ext === '.json') return readJsonFile(filePath);
|
|
234
|
+
return readYamlFile(filePath);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function normalizeShots(data) {
|
|
238
|
+
if (!data) return [];
|
|
239
|
+
if (Array.isArray(data)) return data;
|
|
240
|
+
if (Array.isArray(data.shots)) return data.shots;
|
|
241
|
+
return [];
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function buildPromptParts({ shot, config }) {
|
|
245
|
+
const parts = [];
|
|
246
|
+
if (shot?.prompt?.positive) parts.push(String(shot.prompt.positive));
|
|
247
|
+
if (shot?.action) parts.push(String(shot.action));
|
|
248
|
+
if (shot?.scene) parts.push(String(shot.scene));
|
|
249
|
+
if (shot?.composition) parts.push(String(shot.composition));
|
|
250
|
+
|
|
251
|
+
if (config?.media?.resolution) parts.push(`resolution:${config.media.resolution}`);
|
|
252
|
+
if (config?.media?.aspect_ratio) parts.push(`aspect_ratio:${config.media.aspect_ratio}`);
|
|
253
|
+
if (typeof config?.media?.fps !== 'undefined') parts.push(`fps:${config.media.fps}`);
|
|
254
|
+
|
|
255
|
+
return parts.filter(Boolean);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
async function cmdCompilePrompts({ shots, projectRoot }) {
|
|
259
|
+
const schema = await loadMovieSpecSchema();
|
|
260
|
+
const root = resolveProjectRoot(projectRoot);
|
|
261
|
+
const config = await readProjectConfig(root, schema);
|
|
262
|
+
if (!config) throw new Error(`Missing config file: ${path.join(root, schema.project?.config ?? 'config.yaml')}`);
|
|
263
|
+
|
|
264
|
+
const storyboardPath = path.resolve(root, shots);
|
|
265
|
+
const storyboardData = await readStoryboardShots(storyboardPath);
|
|
266
|
+
const shotList = normalizeShots(storyboardData);
|
|
267
|
+
|
|
268
|
+
const promptsDir = path.join(root, schema.project?.promptsDir ?? 'prompts');
|
|
269
|
+
await ensureDir(promptsDir);
|
|
270
|
+
|
|
271
|
+
const context = await readAllSpecInputs(root, schema);
|
|
272
|
+
const depFingerprints = {};
|
|
273
|
+
for (const [typeId, info] of Object.entries(context.inputs)) {
|
|
274
|
+
const hashes = [];
|
|
275
|
+
for (const f of info.files) {
|
|
276
|
+
const raw = await fs.readFile(f, 'utf8');
|
|
277
|
+
hashes.push(await sha256Hex(raw));
|
|
278
|
+
}
|
|
279
|
+
depFingerprints[typeId] = await sha256Hex(stableStringify(hashes.sort()));
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const compiled = [];
|
|
283
|
+
for (const s of shotList) {
|
|
284
|
+
const shotId = s.id ?? s.shot_id;
|
|
285
|
+
if (!shotId) throw new Error('Each shot must have an id (id or shot_id)');
|
|
286
|
+
|
|
287
|
+
const mergedMedia = {
|
|
288
|
+
resolution: s.media?.resolution ?? config.media?.resolution,
|
|
289
|
+
aspect_ratio: s.media?.aspect_ratio ?? config.media?.aspect_ratio,
|
|
290
|
+
fps: typeof s.media?.fps !== 'undefined' ? s.media.fps : config.media?.fps
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
const promptParts = buildPromptParts({ shot: s, config: { ...config, media: mergedMedia } });
|
|
294
|
+
const positive = promptParts.join(', ');
|
|
295
|
+
const negative = (s.prompt?.negative ?? '').toString();
|
|
296
|
+
|
|
297
|
+
const out = {
|
|
298
|
+
shot_id: shotId,
|
|
299
|
+
prompt: {
|
|
300
|
+
positive,
|
|
301
|
+
negative
|
|
302
|
+
},
|
|
303
|
+
media: mergedMedia,
|
|
304
|
+
deps: depFingerprints
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
compiled.push(out);
|
|
308
|
+
await writeJsonFile(path.join(promptsDir, `${shotId}.prompt.json`), out);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
console.log(chalk.green(`Compiled ${compiled.length} shot prompt(s) to ${toPosix(path.relative(root, promptsDir))}`));
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
async function cmdValidate({ projectRoot, shots }) {
|
|
315
|
+
const schema = await loadMovieSpecSchema();
|
|
316
|
+
const root = resolveProjectRoot(projectRoot);
|
|
317
|
+
|
|
318
|
+
const configPath = path.join(root, schema.project?.config ?? 'config.yaml');
|
|
319
|
+
if (!(await pathExists(configPath))) throw new Error(`Missing config file: ${configPath}`);
|
|
320
|
+
|
|
321
|
+
const config = await readProjectConfig(root, schema);
|
|
322
|
+
if (!config?.moviespec_version) throw new Error('config.yaml missing moviespec_version');
|
|
323
|
+
|
|
324
|
+
const context = await readAllSpecInputs(root, schema);
|
|
325
|
+
|
|
326
|
+
const currentDepFingerprints = {};
|
|
327
|
+
for (const [typeId, info] of Object.entries(context.inputs)) {
|
|
328
|
+
const hashes = [];
|
|
329
|
+
for (const f of info.files) {
|
|
330
|
+
const raw = await fs.readFile(f, 'utf8');
|
|
331
|
+
hashes.push(await sha256Hex(raw));
|
|
332
|
+
}
|
|
333
|
+
currentDepFingerprints[typeId] = await sha256Hex(stableStringify(hashes.sort()));
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const storyboardPath = shots ? path.resolve(root, shots) : null;
|
|
337
|
+
if (storyboardPath) {
|
|
338
|
+
if (!(await pathExists(storyboardPath))) throw new Error(`Shots file not found: ${storyboardPath}`);
|
|
339
|
+
|
|
340
|
+
const data = await readStoryboardShots(storyboardPath);
|
|
341
|
+
const shotList = normalizeShots(data);
|
|
342
|
+
|
|
343
|
+
const characterDirRel = defaultTypePath(schema, 'characters');
|
|
344
|
+
const characterDirAbs = path.join(root, characterDirRel);
|
|
345
|
+
const characterFiles = await fg(['**/*.md', '**/*.yaml', '**/*.yml', '**/*.json'], { cwd: characterDirAbs, onlyFiles: true, suppressErrors: true });
|
|
346
|
+
const characterNames = new Set(characterFiles.map((f) => path.parse(f).name));
|
|
347
|
+
|
|
348
|
+
for (const s of shotList) {
|
|
349
|
+
const shotId = s.id ?? s.shot_id ?? '(unknown)';
|
|
350
|
+
const roles = Array.isArray(s.characters) ? s.characters : [];
|
|
351
|
+
for (const r of roles) {
|
|
352
|
+
if (!characterNames.has(String(r))) {
|
|
353
|
+
throw new Error(`Storyboard shot ${shotId} references missing character: ${r}`);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const promptsDir = path.join(root, schema.project?.promptsDir ?? 'prompts');
|
|
359
|
+
const dirty = [];
|
|
360
|
+
for (const s of shotList) {
|
|
361
|
+
const shotId = s.id ?? s.shot_id;
|
|
362
|
+
if (!shotId) continue;
|
|
363
|
+
const promptPath = path.join(promptsDir, `${shotId}.prompt.json`);
|
|
364
|
+
if (!(await pathExists(promptPath))) continue;
|
|
365
|
+
const compiled = await readJsonFile(promptPath);
|
|
366
|
+
const previousDeps = compiled?.deps ?? {};
|
|
367
|
+
if (stableStringify(previousDeps) !== stableStringify(currentDepFingerprints)) {
|
|
368
|
+
dirty.push(String(shotId));
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
if (dirty.length > 0) {
|
|
372
|
+
console.log(chalk.yellow(`Out-of-sync/Dirty shots detected: ${dirty.join(', ')}`));
|
|
373
|
+
console.log(chalk.yellow('Re-run compile-prompts (and re-generate if needed).'));
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const requiredDirs = [
|
|
378
|
+
schema.project?.specsDir ?? 'specs',
|
|
379
|
+
schema.project?.storyboardDir ?? 'storyboard',
|
|
380
|
+
schema.project?.promptsDir ?? 'prompts',
|
|
381
|
+
schema.project?.outputDir ?? 'output'
|
|
382
|
+
];
|
|
383
|
+
|
|
384
|
+
for (const d of requiredDirs) {
|
|
385
|
+
if (!(await pathExists(path.join(root, d)))) {
|
|
386
|
+
throw new Error(`Missing required directory: ${path.join(root, d)}`);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
console.log(chalk.green('Validation passed (dry-run).'));
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
async function cmdGenerateImage({ projectRoot, shot }) {
|
|
394
|
+
const schema = await loadMovieSpecSchema();
|
|
395
|
+
const root = resolveProjectRoot(projectRoot);
|
|
396
|
+
const config = await readProjectConfig(root, schema);
|
|
397
|
+
if (!config) throw new Error('Missing config.yaml');
|
|
398
|
+
|
|
399
|
+
const promptsDir = path.join(root, schema.project?.promptsDir ?? 'prompts');
|
|
400
|
+
const promptPath = path.join(promptsDir, `${shot}.prompt.json`);
|
|
401
|
+
if (!(await pathExists(promptPath))) {
|
|
402
|
+
throw new Error(`Missing compiled prompt for shot ${shot}. Run compile-prompts first.`);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
const compiled = await readJsonFile(promptPath);
|
|
406
|
+
|
|
407
|
+
const outputRoot = path.join(root, schema.project?.outputDir ?? 'output');
|
|
408
|
+
const shotsDir = path.join(outputRoot, schema.project?.shotsDir ?? 'shots');
|
|
409
|
+
await ensureDir(shotsDir);
|
|
410
|
+
|
|
411
|
+
const runId = new Date().toISOString().replace(/[:.]/g, '-');
|
|
412
|
+
const shotRunDir = path.join(shotsDir, String(shot), `run_${runId}`);
|
|
413
|
+
await ensureDir(shotRunDir);
|
|
414
|
+
|
|
415
|
+
const manifest = {
|
|
416
|
+
shot_id: shot,
|
|
417
|
+
backend: config.backend ?? { id: 'stub' },
|
|
418
|
+
tool: { id: 'moviespec', version: '0.1.0' },
|
|
419
|
+
created_at: new Date().toISOString(),
|
|
420
|
+
prompt: compiled.prompt,
|
|
421
|
+
media: compiled.media,
|
|
422
|
+
deps: compiled.deps,
|
|
423
|
+
lora: config.lora ?? { characters: {}, styles: {} },
|
|
424
|
+
outputs: {
|
|
425
|
+
placeholder: 'stub'
|
|
426
|
+
}
|
|
427
|
+
};
|
|
428
|
+
|
|
429
|
+
await writeJsonFile(path.join(shotRunDir, 'manifest.json'), manifest);
|
|
430
|
+
await fs.writeFile(path.join(shotRunDir, 'OUTPUT_STUB.txt'), 'stub output\n', 'utf8');
|
|
431
|
+
|
|
432
|
+
console.log(chalk.green(`Generated stub output for shot ${shot}: ${toPosix(path.relative(root, shotRunDir))}`));
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
async function cmdGenerateSequence({ projectRoot, shots }) {
|
|
436
|
+
const schema = await loadMovieSpecSchema();
|
|
437
|
+
const root = resolveProjectRoot(projectRoot);
|
|
438
|
+
const storyboardPath = path.resolve(root, shots);
|
|
439
|
+
if (!(await pathExists(storyboardPath))) throw new Error(`Shots file not found: ${storyboardPath}`);
|
|
440
|
+
|
|
441
|
+
const data = await readStoryboardShots(storyboardPath);
|
|
442
|
+
const shotList = normalizeShots(data);
|
|
443
|
+
let count = 0;
|
|
444
|
+
for (const s of shotList) {
|
|
445
|
+
const shotId = s.id ?? s.shot_id;
|
|
446
|
+
if (!shotId) continue;
|
|
447
|
+
await cmdGenerateImage({ projectRoot: root, shot: String(shotId) });
|
|
448
|
+
count += 1;
|
|
449
|
+
}
|
|
450
|
+
console.log(chalk.green(`Generated stub sequence for ${count} shot(s).`));
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
const program = new Command();
|
|
454
|
+
program.name('moviespec').description('MovieSpec CLI').version('0.1.0');
|
|
455
|
+
|
|
456
|
+
program
|
|
457
|
+
.command('init')
|
|
458
|
+
.option('-C, --project-root <path>', 'Project root (default: cwd)')
|
|
459
|
+
.action(async (opts) => {
|
|
460
|
+
await cmdInit(opts.projectRoot);
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
program
|
|
464
|
+
.command('list')
|
|
465
|
+
.description('List resources')
|
|
466
|
+
.command('types')
|
|
467
|
+
.action(async () => {
|
|
468
|
+
await cmdListTypes();
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
program
|
|
472
|
+
.command('new')
|
|
473
|
+
.argument('<type>', 'Type id')
|
|
474
|
+
.argument('<name>', 'Document name')
|
|
475
|
+
.option('-C, --project-root <path>', 'Project root (default: cwd)')
|
|
476
|
+
.action(async (type, name, opts) => {
|
|
477
|
+
await cmdNew(type, name, { projectRoot: opts.projectRoot });
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
program
|
|
481
|
+
.command('compile-prompts')
|
|
482
|
+
.requiredOption('--shots <file>', 'Storyboard shots file (yaml/json)')
|
|
483
|
+
.option('-C, --project-root <path>', 'Project root (default: cwd)')
|
|
484
|
+
.action(async (opts) => {
|
|
485
|
+
await cmdCompilePrompts({ shots: opts.shots, projectRoot: opts.projectRoot });
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
program
|
|
489
|
+
.command('validate')
|
|
490
|
+
.option('--shots <file>', 'Storyboard shots file (optional)')
|
|
491
|
+
.option('-C, --project-root <path>', 'Project root (default: cwd)')
|
|
492
|
+
.action(async (opts) => {
|
|
493
|
+
await cmdValidate({ projectRoot: opts.projectRoot, shots: opts.shots });
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
program
|
|
497
|
+
.command('check')
|
|
498
|
+
.option('--shots <file>', 'Storyboard shots file (optional)')
|
|
499
|
+
.option('-C, --project-root <path>', 'Project root (default: cwd)')
|
|
500
|
+
.action(async (opts) => {
|
|
501
|
+
await cmdValidate({ projectRoot: opts.projectRoot, shots: opts.shots });
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
const generate = program.command('generate').description('Generate assets');
|
|
505
|
+
|
|
506
|
+
generate
|
|
507
|
+
.command('image')
|
|
508
|
+
.requiredOption('--shot <id>', 'Shot id')
|
|
509
|
+
.option('-C, --project-root <path>', 'Project root (default: cwd)')
|
|
510
|
+
.action(async (opts) => {
|
|
511
|
+
await cmdGenerateImage({ projectRoot: opts.projectRoot, shot: opts.shot });
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
generate
|
|
515
|
+
.command('sequence')
|
|
516
|
+
.requiredOption('--shots <file>', 'Storyboard shots file (yaml/json)')
|
|
517
|
+
.option('-C, --project-root <path>', 'Project root (default: cwd)')
|
|
518
|
+
.action(async (opts) => {
|
|
519
|
+
await cmdGenerateSequence({ projectRoot: opts.projectRoot, shots: opts.shots });
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
program.parseAsync(process.argv).catch((err) => {
|
|
523
|
+
console.error(chalk.red(err?.message ?? String(err)));
|
|
524
|
+
process.exitCode = 1;
|
|
525
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@gthanks/moviespec",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Specification-driven consistency framework for AI image and video generation",
|
|
5
|
+
"author": "gthanks",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"publishConfig": {
|
|
8
|
+
"access": "public"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"bin",
|
|
12
|
+
"schema",
|
|
13
|
+
"templates"
|
|
14
|
+
],
|
|
15
|
+
"type": "module",
|
|
16
|
+
"bin": {
|
|
17
|
+
"moviespec": "./bin/moviespec.js"
|
|
18
|
+
},
|
|
19
|
+
"engines": {
|
|
20
|
+
"node": ">=20.19.0"
|
|
21
|
+
},
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"@inquirer/prompts": "^8.3.0",
|
|
24
|
+
"chalk": "^5.5.0",
|
|
25
|
+
"commander": "^14.0.0",
|
|
26
|
+
"fast-glob": "^3.3.3",
|
|
27
|
+
"yaml": "^2.8.2"
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
version: 0.1.0
|
|
2
|
+
id: manifest
|
|
3
|
+
format: json
|
|
4
|
+
required:
|
|
5
|
+
- shot_id
|
|
6
|
+
- created_at
|
|
7
|
+
- backend
|
|
8
|
+
- tool
|
|
9
|
+
- prompt
|
|
10
|
+
- media
|
|
11
|
+
- deps
|
|
12
|
+
properties:
|
|
13
|
+
shot_id:
|
|
14
|
+
type: string
|
|
15
|
+
created_at:
|
|
16
|
+
type: string
|
|
17
|
+
backend:
|
|
18
|
+
type: object
|
|
19
|
+
required: [id]
|
|
20
|
+
properties:
|
|
21
|
+
id: { type: string }
|
|
22
|
+
params: { type: object }
|
|
23
|
+
tool:
|
|
24
|
+
type: object
|
|
25
|
+
required: [id, version]
|
|
26
|
+
properties:
|
|
27
|
+
id: { type: string }
|
|
28
|
+
version: { type: string }
|
|
29
|
+
prompt:
|
|
30
|
+
type: object
|
|
31
|
+
required: [positive, negative]
|
|
32
|
+
properties:
|
|
33
|
+
positive: { type: string }
|
|
34
|
+
negative: { type: string }
|
|
35
|
+
media:
|
|
36
|
+
type: object
|
|
37
|
+
properties:
|
|
38
|
+
resolution: { type: string }
|
|
39
|
+
aspect_ratio: { type: string }
|
|
40
|
+
fps: { type: number }
|
|
41
|
+
deps:
|
|
42
|
+
type: object
|
|
43
|
+
lora:
|
|
44
|
+
type: object
|
|
45
|
+
properties:
|
|
46
|
+
characters: { type: object }
|
|
47
|
+
styles: { type: object }
|
|
48
|
+
sources:
|
|
49
|
+
type: object
|
|
50
|
+
properties:
|
|
51
|
+
compiled_prompt: { type: string }
|
|
52
|
+
storyboard: { type: string }
|
|
53
|
+
specs: { type: object }
|
|
54
|
+
outputs:
|
|
55
|
+
type: object
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
version: 0.1.0
|
|
2
|
+
project:
|
|
3
|
+
config: config.yaml
|
|
4
|
+
specsDir: specs
|
|
5
|
+
storyboardDir: storyboard
|
|
6
|
+
promptsDir: prompts
|
|
7
|
+
outputDir: output
|
|
8
|
+
manifestsDir: manifests
|
|
9
|
+
shotsDir: shots
|
|
10
|
+
types:
|
|
11
|
+
world_bible:
|
|
12
|
+
description: 世界观
|
|
13
|
+
defaultPath: specs/world_bible
|
|
14
|
+
template: templates/world_bible.md
|
|
15
|
+
dependsOn: []
|
|
16
|
+
characters:
|
|
17
|
+
description: 人物设定
|
|
18
|
+
defaultPath: specs/characters
|
|
19
|
+
template: templates/characters.md
|
|
20
|
+
dependsOn: []
|
|
21
|
+
visual_style:
|
|
22
|
+
description: 视觉风格
|
|
23
|
+
defaultPath: specs/visual_style
|
|
24
|
+
template: templates/visual_style.md
|
|
25
|
+
dependsOn: []
|
|
26
|
+
cinematography:
|
|
27
|
+
description: 镜头语言
|
|
28
|
+
defaultPath: specs/cinematography
|
|
29
|
+
template: templates/cinematography.md
|
|
30
|
+
dependsOn: []
|
|
31
|
+
shot_template:
|
|
32
|
+
description: 分镜模板
|
|
33
|
+
defaultPath: specs/shot_template
|
|
34
|
+
template: templates/shot_template.md
|
|
35
|
+
dependsOn:
|
|
36
|
+
- characters
|
|
37
|
+
- visual_style
|
|
38
|
+
- cinematography
|
|
39
|
+
prompt_guide:
|
|
40
|
+
description: AI Prompt 规范
|
|
41
|
+
defaultPath: specs/prompt_guide
|
|
42
|
+
template: templates/prompt_guide.md
|
|
43
|
+
dependsOn:
|
|
44
|
+
- world_bible
|
|
45
|
+
- visual_style
|
|
46
|
+
compile:
|
|
47
|
+
prompt:
|
|
48
|
+
priority:
|
|
49
|
+
- shot
|
|
50
|
+
- characters
|
|
51
|
+
- cinematography
|
|
52
|
+
- visual_style
|
|
53
|
+
- world_bible
|
|
54
|
+
- prompt_guide
|
|
55
|
+
maxTokens:
|
|
56
|
+
stub: null
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
version: 0.1.0
|
|
2
|
+
id: requirements
|
|
3
|
+
format: yaml
|
|
4
|
+
required:
|
|
5
|
+
- project
|
|
6
|
+
- logline
|
|
7
|
+
properties:
|
|
8
|
+
project: { type: string }
|
|
9
|
+
logline: { type: string }
|
|
10
|
+
style_keywords:
|
|
11
|
+
type: array
|
|
12
|
+
items: { type: string }
|
|
13
|
+
characters:
|
|
14
|
+
type: array
|
|
15
|
+
items:
|
|
16
|
+
type: object
|
|
17
|
+
required: [name]
|
|
18
|
+
properties:
|
|
19
|
+
name: { type: string }
|
|
20
|
+
description: { type: string }
|
|
21
|
+
constraints:
|
|
22
|
+
type: object
|
|
23
|
+
properties:
|
|
24
|
+
language: { type: string }
|
|
25
|
+
target: { type: string }
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
version: 0.1.0
|
|
2
|
+
id: storyboard
|
|
3
|
+
format: yaml_or_json
|
|
4
|
+
shots:
|
|
5
|
+
required:
|
|
6
|
+
- id
|
|
7
|
+
properties:
|
|
8
|
+
id: { type: string }
|
|
9
|
+
characters:
|
|
10
|
+
type: array
|
|
11
|
+
items: { type: string }
|
|
12
|
+
scene: { type: string }
|
|
13
|
+
scene_context:
|
|
14
|
+
type: object
|
|
15
|
+
properties:
|
|
16
|
+
camera: { type: string, description: "摄像机运动,如 Dolly, Pan, Tilt" }
|
|
17
|
+
lighting: { type: string, description: "环境光效" }
|
|
18
|
+
assets:
|
|
19
|
+
type: object
|
|
20
|
+
properties:
|
|
21
|
+
seed: { type: integer, description: "一致性种子" }
|
|
22
|
+
face_id: { type: string, description: "角色面部特征 ID" }
|
|
23
|
+
reference_image: { type: string, description: "参考图 URL 或路径" }
|
|
24
|
+
action: { type: string }
|
|
25
|
+
composition: { type: string }
|
|
26
|
+
duration_s: { type: number }
|
|
27
|
+
media:
|
|
28
|
+
type: object
|
|
29
|
+
properties:
|
|
30
|
+
resolution: { type: string }
|
|
31
|
+
aspect_ratio: { type: string }
|
|
32
|
+
fps: { type: number }
|
|
33
|
+
prompt:
|
|
34
|
+
type: object
|
|
35
|
+
properties:
|
|
36
|
+
positive: { type: string }
|
|
37
|
+
negative: { type: string }
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
version: 0.1.0
|
|
2
|
+
id: validator_contract
|
|
3
|
+
input:
|
|
4
|
+
required:
|
|
5
|
+
- manifest
|
|
6
|
+
properties:
|
|
7
|
+
manifest:
|
|
8
|
+
type: object
|
|
9
|
+
references:
|
|
10
|
+
type: object
|
|
11
|
+
description: Optional references such as spec snapshots or reference images.
|
|
12
|
+
rules:
|
|
13
|
+
type: array
|
|
14
|
+
items: { type: string }
|
|
15
|
+
description: Specific evaluation rules or VQA prompts for the multi-modal LLM to check.
|
|
16
|
+
output:
|
|
17
|
+
required:
|
|
18
|
+
- ok
|
|
19
|
+
- report
|
|
20
|
+
properties:
|
|
21
|
+
ok: { type: boolean }
|
|
22
|
+
score: { type: number }
|
|
23
|
+
report: { type: object }
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Assistant guidelines for working with MovieSpec projects
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
# MovieSpec Guidelines
|
|
6
|
+
|
|
7
|
+
You are Antigravity, and you are operating within a MovieSpec project workspace. MovieSpec projects enforce a specific architecture to guarantee consistency across AI image and video generation tasks.
|
|
8
|
+
|
|
9
|
+
- **Schemas and Templates**: Always adhere to the project's YAML schemas. When generating markdown for characters or shots, include the required YAML frontmatter (like `seed`, `face_id`, `reference_image`).
|
|
10
|
+
- **Consistency**: Maintain continuity of `scene_context` across sequences of shots.
|
|
11
|
+
- **Workflow Tools**: You have access to `moviespec` CLI commands. If you modify a storyboard, run `moviespec compile-prompts --shots <your_file.yaml>`.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# MovieSpec Guidelines
|
|
2
|
+
|
|
3
|
+
You are assisting with a MovieSpec project. MovieSpec enforces consistency in AI image and video generation through predictable YAML schemas.
|
|
4
|
+
|
|
5
|
+
1. **Schemas and Templates**: When the user wants to create a new character, shot, or world bible, DO NOT do it arbitrarily. Read the corresponding schema in `schema/` or use the templates in `templates/` to guide your outputs. Structure everything in valid YAML or Markdown with YAML frontmatter.
|
|
6
|
+
2. **Shot Consistency**: When creating a `storyboard.schema.yaml` or a `.prompt.json` shot, always ensure `assets` (seed, face_id, reference_image) and `scene_context` (camera, lighting) are maintained and passed between related shots to ensure visual consistency.
|
|
7
|
+
3. **Compilation**: After saving or modifying a storyboard, remind the user to run `moviespec compile-prompts --shots <file>` to regenerate prompt outputs.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# MovieSpec Guidelines
|
|
2
|
+
|
|
3
|
+
You are an AI agent operating in a MovieSpec project workspace. Your primary task is to generate and maintain consistent AI video generation documents.
|
|
4
|
+
|
|
5
|
+
1. **Schemas and Templates**: When the user wants to create a new character, shot, or world bible, DO NOT do it arbitrarily. Read the corresponding schema in `schema/` or use the templates in `templates/` to guide your outputs. Structure everything in valid YAML or Markdown with YAML frontmatter.
|
|
6
|
+
2. **Shot Consistency**: When creating a `storyboard.schema.yaml` or a `.prompt.json` shot, always ensure `assets` (seed, face_id, reference_image) and `scene_context` (camera, lighting) are maintained and passed between related shots to ensure visual consistency.
|
|
7
|
+
3. **Compilation**: After saving or modifying a storyboard, automatically run `moviespec compile-prompts --shots <file>` for the user if necessary.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
---
|
|
2
|
+
type: shot_template
|
|
3
|
+
name: "{{name}}"
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# 分镜模板:{{name}}
|
|
7
|
+
|
|
8
|
+
## 每镜字段建议
|
|
9
|
+
|
|
10
|
+
- id
|
|
11
|
+
- characters
|
|
12
|
+
- scene
|
|
13
|
+
- scene_context (camera/lighting)
|
|
14
|
+
- action
|
|
15
|
+
- composition
|
|
16
|
+
- assets (seed/face_id/reference_image)
|
|
17
|
+
- media (resolution/aspect_ratio/fps 可选覆盖)
|
|
18
|
+
- prompt (positive/negative)
|