@filmwhisper/cli 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/LICENSE +21 -0
- package/dist/__tests__/cli.test.d.ts +2 -0
- package/dist/__tests__/cli.test.d.ts.map +1 -0
- package/dist/__tests__/cli.test.js +38 -0
- package/dist/__tests__/cli.test.js.map +1 -0
- package/dist/__tests__/migrate.test.d.ts +2 -0
- package/dist/__tests__/migrate.test.d.ts.map +1 -0
- package/dist/__tests__/migrate.test.js +449 -0
- package/dist/__tests__/migrate.test.js.map +1 -0
- package/dist/commands/init.d.ts +4 -0
- package/dist/commands/init.d.ts.map +1 -0
- package/dist/commands/init.js +21 -0
- package/dist/commands/init.js.map +1 -0
- package/dist/commands/migrate.d.ts +19 -0
- package/dist/commands/migrate.d.ts.map +1 -0
- package/dist/commands/migrate.js +443 -0
- package/dist/commands/migrate.js.map +1 -0
- package/dist/commands/process.d.ts +4 -0
- package/dist/commands/process.d.ts.map +1 -0
- package/dist/commands/process.js +21 -0
- package/dist/commands/process.js.map +1 -0
- package/dist/commands/search.d.ts +4 -0
- package/dist/commands/search.d.ts.map +1 -0
- package/dist/commands/search.js +24 -0
- package/dist/commands/search.js.map +1 -0
- package/dist/commands/status.d.ts +2 -0
- package/dist/commands/status.d.ts.map +1 -0
- package/dist/commands/status.js +21 -0
- package/dist/commands/status.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +36 -0
- package/dist/index.js.map +1 -0
- package/dist/mcp-client.d.ts +11 -0
- package/dist/mcp-client.d.ts.map +1 -0
- package/dist/mcp-client.js +29 -0
- package/dist/mcp-client.js.map +1 -0
- package/package.json +29 -0
- package/src/__tests__/cli.test.ts +47 -0
- package/src/__tests__/migrate.test.ts +600 -0
- package/src/commands/init.ts +19 -0
- package/src/commands/migrate.ts +734 -0
- package/src/commands/process.ts +19 -0
- package/src/commands/search.ts +22 -0
- package/src/commands/status.ts +20 -0
- package/src/index.ts +44 -0
- package/src/mcp-client.ts +38 -0
- package/tsconfig.json +13 -0
- package/tsconfig.tsbuildinfo +1 -0
- package/vitest.config.ts +7 -0
|
@@ -0,0 +1,600 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import * as os from "node:os";
|
|
5
|
+
import Database from "better-sqlite3";
|
|
6
|
+
import { migrateCommand } from "../commands/migrate.js";
|
|
7
|
+
|
|
8
|
+
// ============================================================
|
|
9
|
+
// Helpers — create a realistic v1 mock database
|
|
10
|
+
// ============================================================
|
|
11
|
+
|
|
12
|
+
function createMockV1Db(dbPath: string): void {
|
|
13
|
+
const db = new Database(dbPath);
|
|
14
|
+
db.pragma("journal_mode = WAL");
|
|
15
|
+
db.pragma("foreign_keys = ON");
|
|
16
|
+
|
|
17
|
+
db.exec(`
|
|
18
|
+
CREATE TABLE projects (
|
|
19
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
20
|
+
name TEXT NOT NULL UNIQUE,
|
|
21
|
+
source_type TEXT NOT NULL,
|
|
22
|
+
source_uri TEXT NOT NULL,
|
|
23
|
+
proxy_spec_json TEXT NOT NULL,
|
|
24
|
+
analysis_provider TEXT NOT NULL DEFAULT 'gemini',
|
|
25
|
+
language TEXT NOT NULL,
|
|
26
|
+
created_at TEXT NOT NULL,
|
|
27
|
+
updated_at TEXT NOT NULL,
|
|
28
|
+
target_duration_seconds INTEGER
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
CREATE TABLE assets (
|
|
32
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
33
|
+
project_id INTEGER NOT NULL,
|
|
34
|
+
source_path TEXT NOT NULL,
|
|
35
|
+
proxy_path TEXT,
|
|
36
|
+
source_hash TEXT NOT NULL,
|
|
37
|
+
source_spec_json TEXT NOT NULL,
|
|
38
|
+
proxy_spec_json TEXT,
|
|
39
|
+
duration_ms INTEGER,
|
|
40
|
+
start_timecode TEXT,
|
|
41
|
+
transcode_status TEXT NOT NULL DEFAULT 'pending',
|
|
42
|
+
analyze_status TEXT NOT NULL DEFAULT 'pending',
|
|
43
|
+
transcribe_status TEXT NOT NULL DEFAULT 'pending',
|
|
44
|
+
created_at TEXT NOT NULL,
|
|
45
|
+
updated_at TEXT NOT NULL,
|
|
46
|
+
UNIQUE(project_id, source_path),
|
|
47
|
+
FOREIGN KEY(project_id) REFERENCES projects(id)
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
CREATE TABLE asset_analysis (
|
|
51
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
52
|
+
asset_id INTEGER NOT NULL UNIQUE,
|
|
53
|
+
model_name TEXT NOT NULL,
|
|
54
|
+
model_version TEXT NOT NULL,
|
|
55
|
+
prompt_version TEXT NOT NULL,
|
|
56
|
+
subject TEXT NOT NULL,
|
|
57
|
+
subject_action TEXT NOT NULL,
|
|
58
|
+
camera_movement TEXT NOT NULL,
|
|
59
|
+
overall_description TEXT NOT NULL,
|
|
60
|
+
segments_json TEXT NOT NULL,
|
|
61
|
+
candidate_score_json TEXT NOT NULL,
|
|
62
|
+
created_at TEXT NOT NULL,
|
|
63
|
+
editorial_role TEXT NOT NULL DEFAULT 'core',
|
|
64
|
+
energy INTEGER NOT NULL DEFAULT 3,
|
|
65
|
+
shot_type TEXT NOT NULL DEFAULT 'medium',
|
|
66
|
+
is_intro_candidate INTEGER NOT NULL DEFAULT 0,
|
|
67
|
+
is_outro_candidate INTEGER NOT NULL DEFAULT 0,
|
|
68
|
+
duplicate_group TEXT NOT NULL DEFAULT '',
|
|
69
|
+
story_value REAL NOT NULL DEFAULT 0.5,
|
|
70
|
+
keep_priority INTEGER NOT NULL DEFAULT 50,
|
|
71
|
+
FOREIGN KEY(asset_id) REFERENCES assets(id)
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
CREATE TABLE transcripts (
|
|
75
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
76
|
+
asset_id INTEGER NOT NULL,
|
|
77
|
+
utterance_index INTEGER NOT NULL,
|
|
78
|
+
speaker_label TEXT NOT NULL,
|
|
79
|
+
text TEXT NOT NULL,
|
|
80
|
+
start_ms INTEGER NOT NULL,
|
|
81
|
+
end_ms INTEGER NOT NULL,
|
|
82
|
+
confidence REAL,
|
|
83
|
+
created_at TEXT NOT NULL,
|
|
84
|
+
UNIQUE(asset_id, utterance_index),
|
|
85
|
+
FOREIGN KEY(asset_id) REFERENCES assets(id)
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
CREATE TABLE search_chunks (
|
|
89
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
90
|
+
project_id INTEGER NOT NULL,
|
|
91
|
+
asset_id INTEGER NOT NULL,
|
|
92
|
+
segment_index INTEGER NOT NULL,
|
|
93
|
+
segment_start_ms INTEGER NOT NULL,
|
|
94
|
+
segment_end_ms INTEGER NOT NULL,
|
|
95
|
+
chunk_text TEXT NOT NULL,
|
|
96
|
+
editorial_role TEXT NOT NULL,
|
|
97
|
+
shot_type TEXT NOT NULL,
|
|
98
|
+
story_value REAL NOT NULL,
|
|
99
|
+
duplicate_group TEXT NOT NULL,
|
|
100
|
+
embedding_model TEXT NOT NULL,
|
|
101
|
+
embedding_version TEXT NOT NULL,
|
|
102
|
+
qdrant_point_id TEXT NOT NULL,
|
|
103
|
+
created_at TEXT NOT NULL,
|
|
104
|
+
transcript_text TEXT NOT NULL DEFAULT '',
|
|
105
|
+
speaker_labels TEXT NOT NULL DEFAULT '[]',
|
|
106
|
+
UNIQUE(project_id, asset_id, segment_index, embedding_version),
|
|
107
|
+
FOREIGN KEY(project_id) REFERENCES projects(id),
|
|
108
|
+
FOREIGN KEY(asset_id) REFERENCES assets(id)
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
CREATE TABLE segment_reviews (
|
|
112
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
113
|
+
project_id INTEGER NOT NULL,
|
|
114
|
+
asset_id INTEGER NOT NULL,
|
|
115
|
+
segment_index INTEGER NOT NULL,
|
|
116
|
+
status TEXT NOT NULL,
|
|
117
|
+
review_order INTEGER NOT NULL DEFAULT 0,
|
|
118
|
+
updated_at TEXT NOT NULL,
|
|
119
|
+
UNIQUE(project_id, asset_id, segment_index),
|
|
120
|
+
FOREIGN KEY(project_id) REFERENCES projects(id),
|
|
121
|
+
FOREIGN KEY(asset_id) REFERENCES assets(id)
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
CREATE TABLE edit_packages (
|
|
125
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
126
|
+
project_id INTEGER NOT NULL,
|
|
127
|
+
version INTEGER NOT NULL,
|
|
128
|
+
decision_list_path TEXT NOT NULL,
|
|
129
|
+
relink_map_path TEXT NOT NULL,
|
|
130
|
+
preview_path TEXT,
|
|
131
|
+
xml_path TEXT,
|
|
132
|
+
manifest_path TEXT,
|
|
133
|
+
status TEXT NOT NULL,
|
|
134
|
+
created_at TEXT NOT NULL,
|
|
135
|
+
FOREIGN KEY(project_id) REFERENCES projects(id)
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
CREATE TABLE support_docs (
|
|
139
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
140
|
+
project_id INTEGER NOT NULL,
|
|
141
|
+
doc_type TEXT NOT NULL,
|
|
142
|
+
path TEXT NOT NULL,
|
|
143
|
+
created_at TEXT NOT NULL,
|
|
144
|
+
UNIQUE(project_id, doc_type),
|
|
145
|
+
FOREIGN KEY(project_id) REFERENCES projects(id)
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
CREATE TABLE jobs (
|
|
149
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
150
|
+
project_id INTEGER NOT NULL,
|
|
151
|
+
job_type TEXT NOT NULL,
|
|
152
|
+
status TEXT NOT NULL,
|
|
153
|
+
payload_json TEXT NOT NULL,
|
|
154
|
+
error_message TEXT,
|
|
155
|
+
created_at TEXT NOT NULL,
|
|
156
|
+
started_at TEXT,
|
|
157
|
+
finished_at TEXT,
|
|
158
|
+
FOREIGN KEY(project_id) REFERENCES projects(id)
|
|
159
|
+
);
|
|
160
|
+
`);
|
|
161
|
+
|
|
162
|
+
// Insert test project
|
|
163
|
+
db.prepare(`
|
|
164
|
+
INSERT INTO projects (name, source_type, source_uri, proxy_spec_json, language, created_at, updated_at, target_duration_seconds)
|
|
165
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
166
|
+
`).run(
|
|
167
|
+
"test-project",
|
|
168
|
+
"local",
|
|
169
|
+
"/tmp/test-raws",
|
|
170
|
+
'{"width":1280}',
|
|
171
|
+
"ko",
|
|
172
|
+
"2026-03-01T00:00:00Z",
|
|
173
|
+
"2026-03-01T00:00:00Z",
|
|
174
|
+
60,
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
// Insert test assets
|
|
178
|
+
const insertAsset = db.prepare(`
|
|
179
|
+
INSERT INTO assets (project_id, source_path, proxy_path, source_hash, source_spec_json, duration_ms, transcode_status, analyze_status, transcribe_status, created_at, updated_at)
|
|
180
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
181
|
+
`);
|
|
182
|
+
|
|
183
|
+
insertAsset.run(
|
|
184
|
+
1, "/tmp/test-raws/clip1.mp4", null,
|
|
185
|
+
"aabbccdd1111", '{}', 5000,
|
|
186
|
+
"complete", "complete", "complete",
|
|
187
|
+
"2026-03-01T00:00:00Z", "2026-03-01T00:00:00Z",
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
insertAsset.run(
|
|
191
|
+
1, "/tmp/test-raws/clip2.mp4", "/tmp/proxies/clip2.mp4",
|
|
192
|
+
"eeff00112222", '{}', 10000,
|
|
193
|
+
"complete", "complete", "pending",
|
|
194
|
+
"2026-03-01T00:00:00Z", "2026-03-01T00:00:00Z",
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
// Insert analysis for asset 1
|
|
198
|
+
db.prepare(`
|
|
199
|
+
INSERT INTO asset_analysis (asset_id, model_name, model_version, prompt_version, subject, subject_action, camera_movement, overall_description, segments_json, candidate_score_json, created_at, editorial_role, energy, shot_type, story_value, keep_priority)
|
|
200
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
201
|
+
`).run(
|
|
202
|
+
1, "gpt-5", "1.0", "v1",
|
|
203
|
+
"A person walking",
|
|
204
|
+
"walking forward",
|
|
205
|
+
"tracking",
|
|
206
|
+
"A person walks through a garden.",
|
|
207
|
+
JSON.stringify([
|
|
208
|
+
{ start_ms: 0, end_ms: 2500, summary: "Person enters frame" },
|
|
209
|
+
{ start_ms: 2500, end_ms: 5000, summary: "Person walks through garden" },
|
|
210
|
+
]),
|
|
211
|
+
JSON.stringify({ score: 75, reason: "Good walking shot" }),
|
|
212
|
+
"2026-03-01T00:00:00Z",
|
|
213
|
+
"core", 4, "medium", 0.7, 75,
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
// Insert transcripts for asset 1
|
|
217
|
+
const insertTranscript = db.prepare(`
|
|
218
|
+
INSERT INTO transcripts (asset_id, utterance_index, speaker_label, text, start_ms, end_ms, confidence, created_at)
|
|
219
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
220
|
+
`);
|
|
221
|
+
|
|
222
|
+
insertTranscript.run(1, 0, "Speaker A", "Hello world", 0, 1000, 0.95, "2026-03-01T00:00:00Z");
|
|
223
|
+
insertTranscript.run(1, 1, "Speaker B", "Hi there", 1200, 2000, 0.88, "2026-03-01T00:00:00Z");
|
|
224
|
+
|
|
225
|
+
// Insert search chunks
|
|
226
|
+
db.prepare(`
|
|
227
|
+
INSERT INTO search_chunks (project_id, asset_id, segment_index, segment_start_ms, segment_end_ms, chunk_text, editorial_role, shot_type, story_value, duplicate_group, embedding_model, embedding_version, qdrant_point_id, created_at, transcript_text, speaker_labels)
|
|
228
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
229
|
+
`).run(
|
|
230
|
+
1, 1, 0, 0, 2500,
|
|
231
|
+
"Person enters frame",
|
|
232
|
+
"core", "medium", 0.7, "",
|
|
233
|
+
"text-embedding-3", "v1", "uuid-1",
|
|
234
|
+
"2026-03-01T00:00:00Z",
|
|
235
|
+
"Hello world",
|
|
236
|
+
"Speaker A",
|
|
237
|
+
);
|
|
238
|
+
|
|
239
|
+
// Insert segment review
|
|
240
|
+
db.prepare(`
|
|
241
|
+
INSERT INTO segment_reviews (project_id, asset_id, segment_index, status, updated_at)
|
|
242
|
+
VALUES (?, ?, ?, ?, ?)
|
|
243
|
+
`).run(1, 1, 0, "keep", "2026-03-01T00:00:00Z");
|
|
244
|
+
|
|
245
|
+
db.close();
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function createMockEdl(edlPath: string, assets: Array<{ id: number; hash: string }>): void {
|
|
249
|
+
const dir = path.dirname(edlPath);
|
|
250
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
251
|
+
|
|
252
|
+
const edl = {
|
|
253
|
+
clips: assets.map((a, i) => ({
|
|
254
|
+
asset_id: a.id,
|
|
255
|
+
clip_id: `clip-${a.id}`,
|
|
256
|
+
original_path: `/tmp/test-raws/clip${a.id}.mp4`,
|
|
257
|
+
proxy_path: null,
|
|
258
|
+
selection_reason: `Test clip ${i}`,
|
|
259
|
+
source_in_ms: 0,
|
|
260
|
+
source_out_ms: 2500,
|
|
261
|
+
start_timecode: "00:00:00:00",
|
|
262
|
+
timeline_in_ms: i * 2500,
|
|
263
|
+
timeline_out_ms: (i + 1) * 2500,
|
|
264
|
+
})),
|
|
265
|
+
};
|
|
266
|
+
fs.writeFileSync(edlPath, JSON.stringify(edl));
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function addEditPackageToDb(dbPath: string, edlPath: string): void {
|
|
270
|
+
const db = new Database(dbPath);
|
|
271
|
+
db.prepare(`
|
|
272
|
+
INSERT INTO edit_packages (project_id, version, decision_list_path, relink_map_path, status, created_at)
|
|
273
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
274
|
+
`).run(1, 1, edlPath, "/tmp/relink.json", "complete", "2026-03-01T00:00:00Z");
|
|
275
|
+
db.close();
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// ============================================================
|
|
279
|
+
// Tests
|
|
280
|
+
// ============================================================
|
|
281
|
+
|
|
282
|
+
describe("migrate command", () => {
|
|
283
|
+
let tmpDir: string;
|
|
284
|
+
let v1DbPath: string;
|
|
285
|
+
let targetDir: string;
|
|
286
|
+
|
|
287
|
+
beforeEach(() => {
|
|
288
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "fw-migrate-test-"));
|
|
289
|
+
v1DbPath = path.join(tmpDir, "filmwhisper.db");
|
|
290
|
+
targetDir = path.join(tmpDir, "v2-project");
|
|
291
|
+
fs.mkdirSync(targetDir, { recursive: true });
|
|
292
|
+
|
|
293
|
+
vi.spyOn(process, "exit").mockImplementation(((code?: number) => {
|
|
294
|
+
throw new Error(`process.exit(${code})`);
|
|
295
|
+
}) as never);
|
|
296
|
+
vi.spyOn(console, "log").mockImplementation(() => {});
|
|
297
|
+
vi.spyOn(console, "error").mockImplementation(() => {});
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
afterEach(() => {
|
|
301
|
+
vi.restoreAllMocks();
|
|
302
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it("requires --source option", async () => {
|
|
306
|
+
await expect(migrateCommand({})).rejects.toThrow("process.exit(1)");
|
|
307
|
+
expect(console.error).toHaveBeenCalledWith("Error: --source <path> is required");
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it("errors when source database does not exist", async () => {
|
|
311
|
+
await expect(
|
|
312
|
+
migrateCommand({ source: "/nonexistent/path/v1.sqlite" }),
|
|
313
|
+
).rejects.toThrow("process.exit(1)");
|
|
314
|
+
expect(console.error).toHaveBeenCalledWith(
|
|
315
|
+
expect.stringContaining("Source database not found"),
|
|
316
|
+
);
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it("errors when v1 database has no projects", async () => {
|
|
320
|
+
// Create empty DB with schema but no data
|
|
321
|
+
const emptyDb = new Database(v1DbPath);
|
|
322
|
+
emptyDb.exec("CREATE TABLE projects (id INTEGER PRIMARY KEY, name TEXT UNIQUE, source_type TEXT, source_uri TEXT, proxy_spec_json TEXT, language TEXT, created_at TEXT, updated_at TEXT, target_duration_seconds INTEGER, analysis_provider TEXT DEFAULT 'gemini')");
|
|
323
|
+
emptyDb.close();
|
|
324
|
+
|
|
325
|
+
await expect(
|
|
326
|
+
migrateCommand({ source: v1DbPath, target: targetDir }),
|
|
327
|
+
).rejects.toThrow("process.exit(1)");
|
|
328
|
+
expect(console.error).toHaveBeenCalledWith("Error: No projects found in v1 database");
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
describe("successful migration", () => {
|
|
332
|
+
beforeEach(() => {
|
|
333
|
+
createMockV1Db(v1DbPath);
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
it("migrates project, assets, analysis, and transcripts to v2 structure", async () => {
|
|
337
|
+
const result = await migrateCommand({ source: v1DbPath, target: targetDir });
|
|
338
|
+
|
|
339
|
+
expect(result).toBeDefined();
|
|
340
|
+
expect(result!.projectName).toBe("test-project");
|
|
341
|
+
expect(result!.assetsCount).toBe(2);
|
|
342
|
+
expect(result!.analysisCount).toBe(1);
|
|
343
|
+
expect(result!.transcriptAssets).toBe(1);
|
|
344
|
+
|
|
345
|
+
// Verify project.fw.json
|
|
346
|
+
const projectJson = JSON.parse(
|
|
347
|
+
fs.readFileSync(path.join(targetDir, ".fw", "project.fw.json"), "utf-8"),
|
|
348
|
+
);
|
|
349
|
+
expect(projectJson.fw_version).toBe("2.0.0");
|
|
350
|
+
expect(projectJson.schema).toBe("project");
|
|
351
|
+
expect(projectJson.name).toBe("test-project");
|
|
352
|
+
expect(projectJson.language).toBe("ko");
|
|
353
|
+
expect(projectJson.target_duration_seconds).toBe(60);
|
|
354
|
+
|
|
355
|
+
// Verify asset JSON files exist
|
|
356
|
+
const assetsDir = path.join(targetDir, ".fw", "assets");
|
|
357
|
+
const assetFiles = fs.readdirSync(assetsDir);
|
|
358
|
+
expect(assetFiles).toHaveLength(2);
|
|
359
|
+
expect(assetFiles).toContain("aabbccdd1111.fw.json");
|
|
360
|
+
expect(assetFiles).toContain("eeff00112222.fw.json");
|
|
361
|
+
|
|
362
|
+
// Verify asset with analysis and transcript
|
|
363
|
+
const asset1 = JSON.parse(
|
|
364
|
+
fs.readFileSync(path.join(assetsDir, "aabbccdd1111.fw.json"), "utf-8"),
|
|
365
|
+
);
|
|
366
|
+
expect(asset1.fw_version).toBe("2.0.0");
|
|
367
|
+
expect(asset1.schema).toBe("asset");
|
|
368
|
+
expect(asset1.asset_hash).toBe("aabbccdd1111");
|
|
369
|
+
expect(asset1.filename).toBe("clip1.mp4");
|
|
370
|
+
expect(asset1.duration_seconds).toBe(5);
|
|
371
|
+
expect(asset1.analysis).toBeDefined();
|
|
372
|
+
expect(asset1.analysis.semantic.subject).toBe("A person walking");
|
|
373
|
+
expect(asset1.analysis.semantic.summary).toBe("A person walks through a garden.");
|
|
374
|
+
expect(asset1.analysis.segments).toHaveLength(2);
|
|
375
|
+
expect(asset1.segments).toHaveLength(2);
|
|
376
|
+
expect(asset1.segments[0].segment_index).toBe(0);
|
|
377
|
+
expect(asset1.segments[0].editorial_role).toBe("core");
|
|
378
|
+
expect(asset1.transcript).toBeDefined();
|
|
379
|
+
expect(asset1.transcript.speakers).toEqual(["Speaker A", "Speaker B"]);
|
|
380
|
+
expect(asset1.transcript.utterances).toHaveLength(2);
|
|
381
|
+
|
|
382
|
+
// Verify asset without analysis
|
|
383
|
+
const asset2 = JSON.parse(
|
|
384
|
+
fs.readFileSync(path.join(assetsDir, "eeff00112222.fw.json"), "utf-8"),
|
|
385
|
+
);
|
|
386
|
+
expect(asset2.analysis).toBeUndefined();
|
|
387
|
+
expect(asset2.transcript).toBeUndefined();
|
|
388
|
+
expect(asset2.proxy_path).toBe("/tmp/proxies/clip2.mp4");
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
it("migrates search chunks to state.sqlite segments_source", async () => {
|
|
392
|
+
const result = await migrateCommand({ source: v1DbPath, target: targetDir });
|
|
393
|
+
|
|
394
|
+
expect(result!.searchChunks).toBe(1);
|
|
395
|
+
|
|
396
|
+
const stateDb = new Database(path.join(targetDir, ".fw", "state.sqlite"), { readonly: true });
|
|
397
|
+
const rows = stateDb.prepare("SELECT * FROM segments_source").all() as Array<{
|
|
398
|
+
asset_hash: string;
|
|
399
|
+
segment_index: number;
|
|
400
|
+
transcript_text: string;
|
|
401
|
+
analysis_summary: string;
|
|
402
|
+
}>;
|
|
403
|
+
expect(rows).toHaveLength(1);
|
|
404
|
+
expect(rows[0].asset_hash).toBe("aabbccdd1111");
|
|
405
|
+
expect(rows[0].transcript_text).toBe("Hello world");
|
|
406
|
+
expect(rows[0].analysis_summary).toBe("Person enters frame");
|
|
407
|
+
stateDb.close();
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
it("migrates segment reviews to state.sqlite", async () => {
|
|
411
|
+
const result = await migrateCommand({ source: v1DbPath, target: targetDir });
|
|
412
|
+
|
|
413
|
+
expect(result!.segmentReviews).toBe(1);
|
|
414
|
+
|
|
415
|
+
const stateDb = new Database(path.join(targetDir, ".fw", "state.sqlite"), { readonly: true });
|
|
416
|
+
const rows = stateDb.prepare("SELECT * FROM segment_reviews").all() as Array<{
|
|
417
|
+
asset_hash: string;
|
|
418
|
+
segment_index: number;
|
|
419
|
+
status: string;
|
|
420
|
+
}>;
|
|
421
|
+
expect(rows).toHaveLength(1);
|
|
422
|
+
expect(rows[0].asset_hash).toBe("aabbccdd1111");
|
|
423
|
+
expect(rows[0].status).toBe("keep");
|
|
424
|
+
stateDb.close();
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
it("migrates edit packages to timeline_clips", async () => {
|
|
428
|
+
const edlPath = path.join(tmpDir, "edl", "edit_decision_list.json");
|
|
429
|
+
createMockEdl(edlPath, [
|
|
430
|
+
{ id: 1, hash: "aabbccdd1111" },
|
|
431
|
+
{ id: 2, hash: "eeff00112222" },
|
|
432
|
+
]);
|
|
433
|
+
addEditPackageToDb(v1DbPath, edlPath);
|
|
434
|
+
|
|
435
|
+
const result = await migrateCommand({ source: v1DbPath, target: targetDir });
|
|
436
|
+
|
|
437
|
+
expect(result!.timelineClips).toBe(2);
|
|
438
|
+
|
|
439
|
+
const stateDb = new Database(path.join(targetDir, ".fw", "state.sqlite"), { readonly: true });
|
|
440
|
+
const clips = stateDb
|
|
441
|
+
.prepare("SELECT * FROM timeline_clips ORDER BY clip_index")
|
|
442
|
+
.all() as Array<{
|
|
443
|
+
asset_hash: string;
|
|
444
|
+
clip_index: number;
|
|
445
|
+
in_point_ms: number;
|
|
446
|
+
out_point_ms: number;
|
|
447
|
+
}>;
|
|
448
|
+
expect(clips).toHaveLength(2);
|
|
449
|
+
expect(clips[0].asset_hash).toBe("aabbccdd1111");
|
|
450
|
+
expect(clips[0].in_point_ms).toBe(0);
|
|
451
|
+
expect(clips[0].out_point_ms).toBe(2500);
|
|
452
|
+
expect(clips[1].asset_hash).toBe("eeff00112222");
|
|
453
|
+
stateDb.close();
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
it("creates backup of source database", async () => {
|
|
457
|
+
await migrateCommand({ source: v1DbPath, target: targetDir });
|
|
458
|
+
|
|
459
|
+
expect(fs.existsSync(`${v1DbPath}.bak`)).toBe(true);
|
|
460
|
+
|
|
461
|
+
// Verify backup is identical
|
|
462
|
+
const original = fs.readFileSync(v1DbPath);
|
|
463
|
+
const backup = fs.readFileSync(`${v1DbPath}.bak`);
|
|
464
|
+
expect(original.length).toBe(backup.length);
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
it("does not modify source database", async () => {
|
|
468
|
+
const originalSize = fs.statSync(v1DbPath).size;
|
|
469
|
+
const originalContent = fs.readFileSync(v1DbPath);
|
|
470
|
+
|
|
471
|
+
await migrateCommand({ source: v1DbPath, target: targetDir });
|
|
472
|
+
|
|
473
|
+
// Source should be unchanged
|
|
474
|
+
const afterContent = fs.readFileSync(v1DbPath);
|
|
475
|
+
expect(afterContent.length).toBe(originalContent.length);
|
|
476
|
+
});
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
describe("--dry-run flag", () => {
|
|
480
|
+
beforeEach(() => {
|
|
481
|
+
createMockV1Db(v1DbPath);
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
it("reports plan without making changes", async () => {
|
|
485
|
+
const result = await migrateCommand({
|
|
486
|
+
source: v1DbPath,
|
|
487
|
+
target: targetDir,
|
|
488
|
+
dryRun: true,
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
// No result returned for dry run
|
|
492
|
+
expect(result).toBeUndefined();
|
|
493
|
+
|
|
494
|
+
// No .fw directory should be created
|
|
495
|
+
expect(fs.existsSync(path.join(targetDir, ".fw"))).toBe(false);
|
|
496
|
+
|
|
497
|
+
// No backup should be created
|
|
498
|
+
expect(fs.existsSync(`${v1DbPath}.bak`)).toBe(false);
|
|
499
|
+
|
|
500
|
+
// Should log dry run info
|
|
501
|
+
expect(console.log).toHaveBeenCalledWith(
|
|
502
|
+
expect.stringContaining("[DRY RUN]"),
|
|
503
|
+
);
|
|
504
|
+
});
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
describe("rollback / failure handling", () => {
|
|
508
|
+
it("preserves original when target directory is invalid", async () => {
|
|
509
|
+
createMockV1Db(v1DbPath);
|
|
510
|
+
|
|
511
|
+
// Create a file at target path to block directory creation
|
|
512
|
+
const blockingFile = path.join(tmpDir, "blocking-file");
|
|
513
|
+
fs.writeFileSync(blockingFile, "block");
|
|
514
|
+
const invalidTarget = path.join(blockingFile, "subdir");
|
|
515
|
+
|
|
516
|
+
await expect(
|
|
517
|
+
migrateCommand({ source: v1DbPath, target: invalidTarget }),
|
|
518
|
+
).rejects.toThrow();
|
|
519
|
+
|
|
520
|
+
// Source DB should still be readable
|
|
521
|
+
const db = new Database(v1DbPath, { readonly: true });
|
|
522
|
+
const count = (
|
|
523
|
+
db.prepare("SELECT COUNT(*) as count FROM projects").get() as { count: number }
|
|
524
|
+
).count;
|
|
525
|
+
expect(count).toBe(1);
|
|
526
|
+
db.close();
|
|
527
|
+
});
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
describe("edge cases", () => {
|
|
531
|
+
it("handles assets with no analysis or transcripts", async () => {
|
|
532
|
+
// Create a minimal v1 DB with just a project and asset, no analysis
|
|
533
|
+
const db = new Database(v1DbPath);
|
|
534
|
+
db.exec(`
|
|
535
|
+
CREATE TABLE projects (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT UNIQUE, source_type TEXT, source_uri TEXT, proxy_spec_json TEXT, language TEXT, created_at TEXT, updated_at TEXT, target_duration_seconds INTEGER, analysis_provider TEXT DEFAULT 'gemini');
|
|
536
|
+
CREATE TABLE assets (id INTEGER PRIMARY KEY AUTOINCREMENT, project_id INTEGER, source_path TEXT, proxy_path TEXT, source_hash TEXT, source_spec_json TEXT, duration_ms INTEGER, transcode_status TEXT DEFAULT 'pending', analyze_status TEXT DEFAULT 'pending', transcribe_status TEXT DEFAULT 'pending', created_at TEXT, updated_at TEXT, FOREIGN KEY(project_id) REFERENCES projects(id));
|
|
537
|
+
CREATE TABLE asset_analysis (id INTEGER PRIMARY KEY, asset_id INTEGER UNIQUE, model_name TEXT, model_version TEXT, prompt_version TEXT, subject TEXT, subject_action TEXT, camera_movement TEXT, overall_description TEXT, segments_json TEXT, candidate_score_json TEXT, created_at TEXT, editorial_role TEXT DEFAULT 'core', energy INTEGER DEFAULT 3, shot_type TEXT DEFAULT 'medium', story_value REAL DEFAULT 0.5, keep_priority INTEGER DEFAULT 50, FOREIGN KEY(asset_id) REFERENCES assets(id));
|
|
538
|
+
CREATE TABLE transcripts (id INTEGER PRIMARY KEY, asset_id INTEGER, utterance_index INTEGER, speaker_label TEXT, text TEXT, start_ms INTEGER, end_ms INTEGER, confidence REAL, created_at TEXT, UNIQUE(asset_id, utterance_index), FOREIGN KEY(asset_id) REFERENCES assets(id));
|
|
539
|
+
CREATE TABLE search_chunks (id INTEGER PRIMARY KEY, project_id INTEGER, asset_id INTEGER, segment_index INTEGER, segment_start_ms INTEGER, segment_end_ms INTEGER, chunk_text TEXT, editorial_role TEXT, shot_type TEXT, story_value REAL, duplicate_group TEXT, embedding_model TEXT, embedding_version TEXT, qdrant_point_id TEXT, created_at TEXT, transcript_text TEXT DEFAULT '', speaker_labels TEXT DEFAULT '[]', UNIQUE(project_id, asset_id, segment_index, embedding_version));
|
|
540
|
+
CREATE TABLE segment_reviews (id INTEGER PRIMARY KEY, project_id INTEGER, asset_id INTEGER, segment_index INTEGER, status TEXT, review_order INTEGER DEFAULT 0, updated_at TEXT, UNIQUE(project_id, asset_id, segment_index));
|
|
541
|
+
CREATE TABLE edit_packages (id INTEGER PRIMARY KEY, project_id INTEGER, version INTEGER, decision_list_path TEXT, relink_map_path TEXT, preview_path TEXT, xml_path TEXT, manifest_path TEXT, status TEXT, created_at TEXT);
|
|
542
|
+
`);
|
|
543
|
+
|
|
544
|
+
db.prepare(
|
|
545
|
+
"INSERT INTO projects (name, source_type, source_uri, proxy_spec_json, language, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
|
546
|
+
).run("empty-project", "local", "/tmp/empty", "{}", "en", "2026-01-01T00:00:00Z", "2026-01-01T00:00:00Z");
|
|
547
|
+
|
|
548
|
+
db.prepare(
|
|
549
|
+
"INSERT INTO assets (project_id, source_path, source_hash, source_spec_json, duration_ms, transcode_status, analyze_status, transcribe_status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
550
|
+
).run(1, "/tmp/empty/video.mp4", "hash123", "{}", 3000, "pending", "pending", "pending", "2026-01-01T00:00:00Z", "2026-01-01T00:00:00Z");
|
|
551
|
+
|
|
552
|
+
db.close();
|
|
553
|
+
|
|
554
|
+
const result = await migrateCommand({ source: v1DbPath, target: targetDir });
|
|
555
|
+
|
|
556
|
+
expect(result).toBeDefined();
|
|
557
|
+
expect(result!.assetsCount).toBe(1);
|
|
558
|
+
expect(result!.analysisCount).toBe(0);
|
|
559
|
+
expect(result!.transcriptAssets).toBe(0);
|
|
560
|
+
|
|
561
|
+
const asset = JSON.parse(
|
|
562
|
+
fs.readFileSync(path.join(targetDir, ".fw", "assets", "hash123.fw.json"), "utf-8"),
|
|
563
|
+
);
|
|
564
|
+
expect(asset.analysis).toBeUndefined();
|
|
565
|
+
expect(asset.transcript).toBeUndefined();
|
|
566
|
+
expect(asset.duration_seconds).toBe(3);
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
it("handles null duration_ms gracefully", async () => {
|
|
570
|
+
const db = new Database(v1DbPath);
|
|
571
|
+
db.exec(`
|
|
572
|
+
CREATE TABLE projects (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT UNIQUE, source_type TEXT, source_uri TEXT, proxy_spec_json TEXT, language TEXT, created_at TEXT, updated_at TEXT, target_duration_seconds INTEGER, analysis_provider TEXT DEFAULT 'gemini');
|
|
573
|
+
CREATE TABLE assets (id INTEGER PRIMARY KEY AUTOINCREMENT, project_id INTEGER, source_path TEXT, proxy_path TEXT, source_hash TEXT, source_spec_json TEXT, duration_ms INTEGER, transcode_status TEXT DEFAULT 'pending', analyze_status TEXT DEFAULT 'pending', transcribe_status TEXT DEFAULT 'pending', created_at TEXT, updated_at TEXT);
|
|
574
|
+
CREATE TABLE asset_analysis (id INTEGER PRIMARY KEY, asset_id INTEGER UNIQUE, model_name TEXT, model_version TEXT, prompt_version TEXT, subject TEXT, subject_action TEXT, camera_movement TEXT, overall_description TEXT, segments_json TEXT, candidate_score_json TEXT, created_at TEXT, editorial_role TEXT DEFAULT 'core', energy INTEGER DEFAULT 3, shot_type TEXT DEFAULT 'medium', story_value REAL DEFAULT 0.5, keep_priority INTEGER DEFAULT 50);
|
|
575
|
+
CREATE TABLE transcripts (id INTEGER PRIMARY KEY, asset_id INTEGER, utterance_index INTEGER, speaker_label TEXT, text TEXT, start_ms INTEGER, end_ms INTEGER, confidence REAL, created_at TEXT, UNIQUE(asset_id, utterance_index));
|
|
576
|
+
CREATE TABLE search_chunks (id INTEGER PRIMARY KEY, project_id INTEGER, asset_id INTEGER, segment_index INTEGER, segment_start_ms INTEGER, segment_end_ms INTEGER, chunk_text TEXT, editorial_role TEXT, shot_type TEXT, story_value REAL, duplicate_group TEXT, embedding_model TEXT, embedding_version TEXT, qdrant_point_id TEXT, created_at TEXT, transcript_text TEXT DEFAULT '', speaker_labels TEXT DEFAULT '[]', UNIQUE(project_id, asset_id, segment_index, embedding_version));
|
|
577
|
+
CREATE TABLE segment_reviews (id INTEGER PRIMARY KEY, project_id INTEGER, asset_id INTEGER, segment_index INTEGER, status TEXT, review_order INTEGER DEFAULT 0, updated_at TEXT, UNIQUE(project_id, asset_id, segment_index));
|
|
578
|
+
CREATE TABLE edit_packages (id INTEGER PRIMARY KEY, project_id INTEGER, version INTEGER, decision_list_path TEXT, relink_map_path TEXT, preview_path TEXT, xml_path TEXT, manifest_path TEXT, status TEXT, created_at TEXT);
|
|
579
|
+
`);
|
|
580
|
+
|
|
581
|
+
db.prepare(
|
|
582
|
+
"INSERT INTO projects (name, source_type, source_uri, proxy_spec_json, language, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
|
583
|
+
).run("null-dur", "local", "/tmp/x", "{}", "en", "2026-01-01T00:00:00Z", "2026-01-01T00:00:00Z");
|
|
584
|
+
|
|
585
|
+
db.prepare(
|
|
586
|
+
"INSERT INTO assets (project_id, source_path, source_hash, source_spec_json, duration_ms, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
|
587
|
+
).run(1, "/tmp/x/a.mp4", "nullhash", "{}", null, "2026-01-01T00:00:00Z", "2026-01-01T00:00:00Z");
|
|
588
|
+
|
|
589
|
+
db.close();
|
|
590
|
+
|
|
591
|
+
const result = await migrateCommand({ source: v1DbPath, target: targetDir });
|
|
592
|
+
expect(result!.assetsCount).toBe(1);
|
|
593
|
+
|
|
594
|
+
const asset = JSON.parse(
|
|
595
|
+
fs.readFileSync(path.join(targetDir, ".fw", "assets", "nullhash.fw.json"), "utf-8"),
|
|
596
|
+
);
|
|
597
|
+
expect(asset.duration_seconds).toBe(0);
|
|
598
|
+
});
|
|
599
|
+
});
|
|
600
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { createMcpClient } from "../mcp-client.js";
|
|
2
|
+
|
|
3
|
+
export async function initCommand(sourcePath: string, options: { name?: string }) {
|
|
4
|
+
const client = await createMcpClient();
|
|
5
|
+
try {
|
|
6
|
+
const name = options.name ?? sourcePath.split("/").filter(Boolean).pop() ?? "untitled";
|
|
7
|
+
const result = await client.callTool("fw_project_init", {
|
|
8
|
+
source_path: sourcePath,
|
|
9
|
+
name,
|
|
10
|
+
});
|
|
11
|
+
console.log("Project initialized:");
|
|
12
|
+
console.log(JSON.stringify(JSON.parse(result.content[0].text), null, 2));
|
|
13
|
+
} catch (err) {
|
|
14
|
+
console.error("Error:", err instanceof Error ? err.message : err);
|
|
15
|
+
process.exit(1);
|
|
16
|
+
} finally {
|
|
17
|
+
await client.close();
|
|
18
|
+
}
|
|
19
|
+
}
|