@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,734 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import Database from "better-sqlite3";
|
|
4
|
+
import { randomUUID } from "node:crypto";
|
|
5
|
+
|
|
6
|
+
// ============================================================
|
|
7
|
+
// Types
|
|
8
|
+
// ============================================================
|
|
9
|
+
|
|
10
|
+
interface MigrateOptions {
|
|
11
|
+
source?: string;
|
|
12
|
+
target?: string;
|
|
13
|
+
dryRun?: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// v1 row types
|
|
17
|
+
interface V1Project {
|
|
18
|
+
id: number;
|
|
19
|
+
name: string;
|
|
20
|
+
source_type: string;
|
|
21
|
+
source_uri: string;
|
|
22
|
+
proxy_spec_json: string;
|
|
23
|
+
language: string;
|
|
24
|
+
target_duration_seconds: number | null;
|
|
25
|
+
created_at: string;
|
|
26
|
+
updated_at: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface V1Asset {
|
|
30
|
+
id: number;
|
|
31
|
+
project_id: number;
|
|
32
|
+
source_path: string;
|
|
33
|
+
proxy_path: string | null;
|
|
34
|
+
source_hash: string;
|
|
35
|
+
duration_ms: number | null;
|
|
36
|
+
transcode_status: string;
|
|
37
|
+
analyze_status: string;
|
|
38
|
+
transcribe_status: string;
|
|
39
|
+
created_at: string;
|
|
40
|
+
updated_at: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface V1Analysis {
|
|
44
|
+
asset_id: number;
|
|
45
|
+
subject: string;
|
|
46
|
+
overall_description: string;
|
|
47
|
+
editorial_role: string;
|
|
48
|
+
energy: number;
|
|
49
|
+
shot_type: string;
|
|
50
|
+
story_value: number;
|
|
51
|
+
segments_json: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
interface V1Transcript {
|
|
55
|
+
asset_id: number;
|
|
56
|
+
utterance_index: number;
|
|
57
|
+
speaker_label: string;
|
|
58
|
+
text: string;
|
|
59
|
+
start_ms: number;
|
|
60
|
+
end_ms: number;
|
|
61
|
+
confidence: number | null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
interface V1SearchChunk {
|
|
65
|
+
asset_id: number;
|
|
66
|
+
segment_index: number;
|
|
67
|
+
chunk_text: string;
|
|
68
|
+
transcript_text: string;
|
|
69
|
+
editorial_role: string;
|
|
70
|
+
speaker_labels: string;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
interface V1SegmentReview {
|
|
74
|
+
asset_id: number;
|
|
75
|
+
segment_index: number;
|
|
76
|
+
status: string;
|
|
77
|
+
updated_at: string;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
interface V1EditPackage {
|
|
81
|
+
id: number;
|
|
82
|
+
project_id: number;
|
|
83
|
+
version: number;
|
|
84
|
+
decision_list_path: string;
|
|
85
|
+
status: string;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
interface V1EdlClip {
|
|
89
|
+
asset_id: number;
|
|
90
|
+
clip_id: string;
|
|
91
|
+
selection_reason: string;
|
|
92
|
+
source_in_ms: number;
|
|
93
|
+
source_out_ms: number;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// v2 types (inline to avoid import issues in CLI)
|
|
97
|
+
interface FwProject {
|
|
98
|
+
fw_version: string;
|
|
99
|
+
schema: "project";
|
|
100
|
+
name: string;
|
|
101
|
+
source_path: string;
|
|
102
|
+
language: string;
|
|
103
|
+
proxy_spec?: string;
|
|
104
|
+
target_duration_seconds?: number;
|
|
105
|
+
created_at: string;
|
|
106
|
+
updated_at: string;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
interface FwAsset {
|
|
110
|
+
fw_version: string;
|
|
111
|
+
schema: "asset";
|
|
112
|
+
asset_hash: string;
|
|
113
|
+
filename: string;
|
|
114
|
+
source_path: string;
|
|
115
|
+
duration_seconds: number;
|
|
116
|
+
created_at: string;
|
|
117
|
+
updated_at: string;
|
|
118
|
+
transcode_status: string;
|
|
119
|
+
transcribe_status: string;
|
|
120
|
+
analyze_status: string;
|
|
121
|
+
proxy_path?: string;
|
|
122
|
+
analysis?: {
|
|
123
|
+
semantic?: {
|
|
124
|
+
subject: string;
|
|
125
|
+
editorial_role: string;
|
|
126
|
+
energy: number;
|
|
127
|
+
shot_type: string;
|
|
128
|
+
story_value: number;
|
|
129
|
+
summary: string;
|
|
130
|
+
};
|
|
131
|
+
segments?: Array<{
|
|
132
|
+
segment_index: number;
|
|
133
|
+
start_ms: number;
|
|
134
|
+
end_ms: number;
|
|
135
|
+
editorial_role: string;
|
|
136
|
+
energy: number;
|
|
137
|
+
story_value: number;
|
|
138
|
+
summary: string;
|
|
139
|
+
}>;
|
|
140
|
+
};
|
|
141
|
+
transcript?: {
|
|
142
|
+
speakers: string[];
|
|
143
|
+
utterances: Array<{
|
|
144
|
+
speaker_label: string;
|
|
145
|
+
text: string;
|
|
146
|
+
start_ms: number;
|
|
147
|
+
end_ms: number;
|
|
148
|
+
confidence: number;
|
|
149
|
+
}>;
|
|
150
|
+
};
|
|
151
|
+
segments?: Array<{
|
|
152
|
+
segment_index: number;
|
|
153
|
+
start_ms: number;
|
|
154
|
+
end_ms: number;
|
|
155
|
+
editorial_role: string;
|
|
156
|
+
energy: number;
|
|
157
|
+
story_value: number;
|
|
158
|
+
summary: string;
|
|
159
|
+
}>;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
interface CountRow {
|
|
163
|
+
count: number;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
interface TableRow {
|
|
167
|
+
name: string;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const FW_VERSION = "2.0.0";
|
|
171
|
+
|
|
172
|
+
// ============================================================
|
|
173
|
+
// Migration logic
|
|
174
|
+
// ============================================================
|
|
175
|
+
|
|
176
|
+
export interface MigrationResult {
|
|
177
|
+
projectName: string;
|
|
178
|
+
assetsCount: number;
|
|
179
|
+
analysisCount: number;
|
|
180
|
+
transcriptAssets: number;
|
|
181
|
+
searchChunks: number;
|
|
182
|
+
segmentReviews: number;
|
|
183
|
+
timelineClips: number;
|
|
184
|
+
targetPath: string;
|
|
185
|
+
backupPath: string;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function mapProcessingStatus(status: string): string {
|
|
189
|
+
const valid = ["pending", "running", "complete", "failed"];
|
|
190
|
+
return valid.includes(status) ? status : "pending";
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function mapReviewStatus(status: string): string {
|
|
194
|
+
const valid = ["keep", "exclude", "needs_review"];
|
|
195
|
+
if (valid.includes(status)) return status;
|
|
196
|
+
// v1 may have different status values — map common ones
|
|
197
|
+
if (status === "approved" || status === "accepted") return "keep";
|
|
198
|
+
if (status === "rejected" || status === "removed") return "exclude";
|
|
199
|
+
return "needs_review";
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Build asset_id → source_hash lookup from v1 DB.
|
|
204
|
+
*/
|
|
205
|
+
function buildAssetIdToHash(v1: Database.Database): Map<number, string> {
|
|
206
|
+
const rows = v1.prepare("SELECT id, source_hash FROM assets").all() as Array<{
|
|
207
|
+
id: number;
|
|
208
|
+
source_hash: string;
|
|
209
|
+
}>;
|
|
210
|
+
const map = new Map<number, string>();
|
|
211
|
+
for (const row of rows) {
|
|
212
|
+
map.set(row.id, row.source_hash);
|
|
213
|
+
}
|
|
214
|
+
return map;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function analyzeV1(v1: Database.Database): {
|
|
218
|
+
tables: string[];
|
|
219
|
+
counts: Record<string, number>;
|
|
220
|
+
} {
|
|
221
|
+
const tables = (
|
|
222
|
+
v1.prepare("SELECT name FROM sqlite_master WHERE type='table'").all() as TableRow[]
|
|
223
|
+
).map((r) => r.name);
|
|
224
|
+
|
|
225
|
+
const counts: Record<string, number> = {};
|
|
226
|
+
for (const name of tables) {
|
|
227
|
+
const row = v1.prepare(`SELECT COUNT(*) as count FROM "${name}"`).get() as CountRow;
|
|
228
|
+
counts[name] = row.count;
|
|
229
|
+
}
|
|
230
|
+
return { tables, counts };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function migrateProject(v1: Database.Database, projectId: number): FwProject {
|
|
234
|
+
const row = v1
|
|
235
|
+
.prepare("SELECT * FROM projects WHERE id = ?")
|
|
236
|
+
.get(projectId) as V1Project | undefined;
|
|
237
|
+
if (!row) throw new Error(`v1 project id=${projectId} not found`);
|
|
238
|
+
|
|
239
|
+
const project: FwProject = {
|
|
240
|
+
fw_version: FW_VERSION,
|
|
241
|
+
schema: "project",
|
|
242
|
+
name: row.name,
|
|
243
|
+
source_path: row.source_uri,
|
|
244
|
+
language: row.language,
|
|
245
|
+
created_at: row.created_at,
|
|
246
|
+
updated_at: row.updated_at,
|
|
247
|
+
};
|
|
248
|
+
if (row.proxy_spec_json && row.proxy_spec_json !== "{}") {
|
|
249
|
+
project.proxy_spec = row.proxy_spec_json;
|
|
250
|
+
}
|
|
251
|
+
if (row.target_duration_seconds != null) {
|
|
252
|
+
project.target_duration_seconds = row.target_duration_seconds;
|
|
253
|
+
}
|
|
254
|
+
return project;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function migrateAssets(
|
|
258
|
+
v1: Database.Database,
|
|
259
|
+
projectId: number,
|
|
260
|
+
): FwAsset[] {
|
|
261
|
+
const assets = v1
|
|
262
|
+
.prepare("SELECT * FROM assets WHERE project_id = ?")
|
|
263
|
+
.all(projectId) as V1Asset[];
|
|
264
|
+
|
|
265
|
+
// Pre-fetch analyses and transcripts for all assets
|
|
266
|
+
const analyses = new Map<number, V1Analysis>();
|
|
267
|
+
const rows = v1
|
|
268
|
+
.prepare(
|
|
269
|
+
"SELECT * FROM asset_analysis WHERE asset_id IN (SELECT id FROM assets WHERE project_id = ?)",
|
|
270
|
+
)
|
|
271
|
+
.all(projectId) as V1Analysis[];
|
|
272
|
+
for (const a of rows) {
|
|
273
|
+
analyses.set(a.asset_id, a);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const transcriptRows = v1
|
|
277
|
+
.prepare(
|
|
278
|
+
"SELECT * FROM transcripts WHERE asset_id IN (SELECT id FROM assets WHERE project_id = ?) ORDER BY asset_id, utterance_index",
|
|
279
|
+
)
|
|
280
|
+
.all(projectId) as V1Transcript[];
|
|
281
|
+
const transcriptsByAsset = new Map<number, V1Transcript[]>();
|
|
282
|
+
for (const t of transcriptRows) {
|
|
283
|
+
const list = transcriptsByAsset.get(t.asset_id) ?? [];
|
|
284
|
+
list.push(t);
|
|
285
|
+
transcriptsByAsset.set(t.asset_id, list);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
return assets.map((asset) => {
|
|
289
|
+
const fwAsset: FwAsset = {
|
|
290
|
+
fw_version: FW_VERSION,
|
|
291
|
+
schema: "asset",
|
|
292
|
+
asset_hash: asset.source_hash,
|
|
293
|
+
filename: path.basename(asset.source_path),
|
|
294
|
+
source_path: asset.source_path,
|
|
295
|
+
duration_seconds: (asset.duration_ms ?? 0) / 1000,
|
|
296
|
+
created_at: asset.created_at,
|
|
297
|
+
updated_at: asset.updated_at,
|
|
298
|
+
transcode_status: mapProcessingStatus(asset.transcode_status),
|
|
299
|
+
transcribe_status: mapProcessingStatus(asset.transcribe_status),
|
|
300
|
+
analyze_status: mapProcessingStatus(asset.analyze_status),
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
if (asset.proxy_path) {
|
|
304
|
+
fwAsset.proxy_path = asset.proxy_path;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Merge analysis
|
|
308
|
+
const analysis = analyses.get(asset.id);
|
|
309
|
+
if (analysis) {
|
|
310
|
+
let segments: FwAsset["segments"];
|
|
311
|
+
try {
|
|
312
|
+
const rawSegments = JSON.parse(analysis.segments_json) as Array<{
|
|
313
|
+
start_ms: number;
|
|
314
|
+
end_ms: number;
|
|
315
|
+
summary: string;
|
|
316
|
+
}>;
|
|
317
|
+
segments = rawSegments.map((s, i) => ({
|
|
318
|
+
segment_index: i,
|
|
319
|
+
start_ms: s.start_ms,
|
|
320
|
+
end_ms: s.end_ms,
|
|
321
|
+
editorial_role: analysis.editorial_role,
|
|
322
|
+
energy: analysis.energy,
|
|
323
|
+
story_value: analysis.story_value,
|
|
324
|
+
summary: s.summary,
|
|
325
|
+
}));
|
|
326
|
+
} catch {
|
|
327
|
+
segments = undefined;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
fwAsset.analysis = {
|
|
331
|
+
semantic: {
|
|
332
|
+
subject: analysis.subject,
|
|
333
|
+
editorial_role: analysis.editorial_role,
|
|
334
|
+
energy: analysis.energy,
|
|
335
|
+
shot_type: analysis.shot_type,
|
|
336
|
+
story_value: analysis.story_value,
|
|
337
|
+
summary: analysis.overall_description,
|
|
338
|
+
},
|
|
339
|
+
segments,
|
|
340
|
+
};
|
|
341
|
+
fwAsset.segments = segments;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Merge transcripts
|
|
345
|
+
const transcripts = transcriptsByAsset.get(asset.id);
|
|
346
|
+
if (transcripts && transcripts.length > 0) {
|
|
347
|
+
const speakers = [...new Set(transcripts.map((t) => t.speaker_label))];
|
|
348
|
+
fwAsset.transcript = {
|
|
349
|
+
speakers,
|
|
350
|
+
utterances: transcripts.map((t) => ({
|
|
351
|
+
speaker_label: t.speaker_label,
|
|
352
|
+
text: t.text,
|
|
353
|
+
start_ms: t.start_ms,
|
|
354
|
+
end_ms: t.end_ms,
|
|
355
|
+
confidence: t.confidence ?? 0,
|
|
356
|
+
})),
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
return fwAsset;
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function migrateSearchChunks(
|
|
365
|
+
v1: Database.Database,
|
|
366
|
+
v2State: Database.Database,
|
|
367
|
+
projectId: number,
|
|
368
|
+
assetIdToHash: Map<number, string>,
|
|
369
|
+
): number {
|
|
370
|
+
const chunks = v1
|
|
371
|
+
.prepare("SELECT * FROM search_chunks WHERE project_id = ?")
|
|
372
|
+
.all(projectId) as V1SearchChunk[];
|
|
373
|
+
|
|
374
|
+
if (chunks.length === 0) return 0;
|
|
375
|
+
|
|
376
|
+
const insert = v2State.prepare(
|
|
377
|
+
`INSERT OR IGNORE INTO segments_source (asset_hash, segment_index, transcript_text, analysis_summary, editorial_role, speaker_labels)
|
|
378
|
+
VALUES (?, ?, ?, ?, ?, ?)`,
|
|
379
|
+
);
|
|
380
|
+
|
|
381
|
+
let count = 0;
|
|
382
|
+
for (const chunk of chunks) {
|
|
383
|
+
const hash = assetIdToHash.get(chunk.asset_id);
|
|
384
|
+
if (!hash) continue;
|
|
385
|
+
insert.run(
|
|
386
|
+
hash,
|
|
387
|
+
chunk.segment_index,
|
|
388
|
+
chunk.transcript_text || null,
|
|
389
|
+
chunk.chunk_text || null,
|
|
390
|
+
chunk.editorial_role || null,
|
|
391
|
+
chunk.speaker_labels || null,
|
|
392
|
+
);
|
|
393
|
+
count++;
|
|
394
|
+
}
|
|
395
|
+
return count;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function migrateSegmentReviews(
|
|
399
|
+
v1: Database.Database,
|
|
400
|
+
v2State: Database.Database,
|
|
401
|
+
projectId: number,
|
|
402
|
+
assetIdToHash: Map<number, string>,
|
|
403
|
+
): number {
|
|
404
|
+
const reviews = v1
|
|
405
|
+
.prepare("SELECT * FROM segment_reviews WHERE project_id = ?")
|
|
406
|
+
.all(projectId) as V1SegmentReview[];
|
|
407
|
+
|
|
408
|
+
if (reviews.length === 0) return 0;
|
|
409
|
+
|
|
410
|
+
const insert = v2State.prepare(
|
|
411
|
+
`INSERT OR IGNORE INTO segment_reviews (asset_hash, segment_index, status, updated_at)
|
|
412
|
+
VALUES (?, ?, ?, ?)`,
|
|
413
|
+
);
|
|
414
|
+
|
|
415
|
+
let count = 0;
|
|
416
|
+
for (const review of reviews) {
|
|
417
|
+
const hash = assetIdToHash.get(review.asset_id);
|
|
418
|
+
if (!hash) continue;
|
|
419
|
+
insert.run(
|
|
420
|
+
hash,
|
|
421
|
+
review.segment_index,
|
|
422
|
+
mapReviewStatus(review.status),
|
|
423
|
+
review.updated_at,
|
|
424
|
+
);
|
|
425
|
+
count++;
|
|
426
|
+
}
|
|
427
|
+
return count;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function migrateEditPackages(
|
|
431
|
+
v1: Database.Database,
|
|
432
|
+
v2State: Database.Database,
|
|
433
|
+
projectId: number,
|
|
434
|
+
assetIdToHash: Map<number, string>,
|
|
435
|
+
): number {
|
|
436
|
+
// Find the latest complete edit package
|
|
437
|
+
const pkg = v1
|
|
438
|
+
.prepare(
|
|
439
|
+
"SELECT * FROM edit_packages WHERE project_id = ? AND status = 'complete' ORDER BY version DESC LIMIT 1",
|
|
440
|
+
)
|
|
441
|
+
.get(projectId) as V1EditPackage | undefined;
|
|
442
|
+
|
|
443
|
+
if (!pkg) return 0;
|
|
444
|
+
|
|
445
|
+
// Try to read the EDL JSON file
|
|
446
|
+
let clips: V1EdlClip[];
|
|
447
|
+
try {
|
|
448
|
+
const edlContent = fs.readFileSync(pkg.decision_list_path, "utf-8");
|
|
449
|
+
const edl = JSON.parse(edlContent) as { clips: V1EdlClip[] };
|
|
450
|
+
clips = edl.clips ?? [];
|
|
451
|
+
} catch {
|
|
452
|
+
// EDL file not readable — skip timeline migration
|
|
453
|
+
return 0;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
if (clips.length === 0) return 0;
|
|
457
|
+
|
|
458
|
+
const insert = v2State.prepare(
|
|
459
|
+
`INSERT INTO timeline_clips (id, asset_hash, clip_index, in_point_ms, out_point_ms, editorial_rationale, created_at, updated_at)
|
|
460
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
461
|
+
);
|
|
462
|
+
|
|
463
|
+
const now = new Date().toISOString();
|
|
464
|
+
let count = 0;
|
|
465
|
+
for (let i = 0; i < clips.length; i++) {
|
|
466
|
+
const clip = clips[i];
|
|
467
|
+
const hash = assetIdToHash.get(clip.asset_id);
|
|
468
|
+
if (!hash) continue;
|
|
469
|
+
insert.run(
|
|
470
|
+
randomUUID(),
|
|
471
|
+
hash,
|
|
472
|
+
i,
|
|
473
|
+
clip.source_in_ms,
|
|
474
|
+
clip.source_out_ms,
|
|
475
|
+
clip.selection_reason || null,
|
|
476
|
+
now,
|
|
477
|
+
now,
|
|
478
|
+
);
|
|
479
|
+
count++;
|
|
480
|
+
}
|
|
481
|
+
return count;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function initV2StateDb(dbPath: string): Database.Database {
|
|
485
|
+
const dir = path.dirname(dbPath);
|
|
486
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
487
|
+
|
|
488
|
+
const db = new Database(dbPath);
|
|
489
|
+
db.pragma("journal_mode = WAL");
|
|
490
|
+
db.pragma("foreign_keys = ON");
|
|
491
|
+
|
|
492
|
+
db.exec(`
|
|
493
|
+
CREATE TABLE IF NOT EXISTS revisions (
|
|
494
|
+
scope TEXT PRIMARY KEY,
|
|
495
|
+
revision INTEGER NOT NULL DEFAULT 0
|
|
496
|
+
);
|
|
497
|
+
INSERT OR IGNORE INTO revisions (scope, revision) VALUES ('timeline', 0);
|
|
498
|
+
INSERT OR IGNORE INTO revisions (scope, revision) VALUES ('reviews', 0);
|
|
499
|
+
INSERT OR IGNORE INTO revisions (scope, revision) VALUES ('project', 0);
|
|
500
|
+
INSERT OR IGNORE INTO revisions (scope, revision) VALUES ('jobs', 0);
|
|
501
|
+
INSERT OR IGNORE INTO revisions (scope, revision) VALUES ('fts', 0);
|
|
502
|
+
|
|
503
|
+
CREATE TABLE IF NOT EXISTS timeline_clips (
|
|
504
|
+
id TEXT PRIMARY KEY,
|
|
505
|
+
asset_hash TEXT NOT NULL,
|
|
506
|
+
clip_index INTEGER NOT NULL,
|
|
507
|
+
in_point_ms INTEGER NOT NULL,
|
|
508
|
+
out_point_ms INTEGER NOT NULL,
|
|
509
|
+
editorial_rationale TEXT,
|
|
510
|
+
created_at TEXT NOT NULL,
|
|
511
|
+
updated_at TEXT NOT NULL
|
|
512
|
+
);
|
|
513
|
+
|
|
514
|
+
CREATE TABLE IF NOT EXISTS segment_reviews (
|
|
515
|
+
asset_hash TEXT NOT NULL,
|
|
516
|
+
segment_index INTEGER NOT NULL,
|
|
517
|
+
status TEXT NOT NULL CHECK(status IN ('keep', 'exclude', 'needs_review')),
|
|
518
|
+
reviewer TEXT,
|
|
519
|
+
updated_at TEXT NOT NULL,
|
|
520
|
+
PRIMARY KEY (asset_hash, segment_index)
|
|
521
|
+
);
|
|
522
|
+
|
|
523
|
+
CREATE TABLE IF NOT EXISTS undo_history (
|
|
524
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
525
|
+
scope TEXT NOT NULL,
|
|
526
|
+
operation TEXT NOT NULL,
|
|
527
|
+
created_at TEXT NOT NULL
|
|
528
|
+
);
|
|
529
|
+
|
|
530
|
+
CREATE TABLE IF NOT EXISTS jobs (
|
|
531
|
+
job_id TEXT PRIMARY KEY,
|
|
532
|
+
project_id TEXT NOT NULL,
|
|
533
|
+
type TEXT NOT NULL,
|
|
534
|
+
status TEXT NOT NULL DEFAULT 'pending',
|
|
535
|
+
progress REAL DEFAULT 0,
|
|
536
|
+
step TEXT,
|
|
537
|
+
params TEXT,
|
|
538
|
+
result TEXT,
|
|
539
|
+
error TEXT,
|
|
540
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
541
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
542
|
+
);
|
|
543
|
+
|
|
544
|
+
CREATE TABLE IF NOT EXISTS segments_source (
|
|
545
|
+
asset_hash TEXT NOT NULL,
|
|
546
|
+
segment_index INTEGER NOT NULL,
|
|
547
|
+
transcript_text TEXT,
|
|
548
|
+
analysis_summary TEXT,
|
|
549
|
+
editorial_role TEXT,
|
|
550
|
+
speaker_labels TEXT,
|
|
551
|
+
PRIMARY KEY (asset_hash, segment_index)
|
|
552
|
+
);
|
|
553
|
+
|
|
554
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS segments_fts USING fts5(
|
|
555
|
+
transcript_text,
|
|
556
|
+
analysis_summary,
|
|
557
|
+
editorial_role,
|
|
558
|
+
speaker_labels,
|
|
559
|
+
content='segments_source',
|
|
560
|
+
content_rowid='rowid',
|
|
561
|
+
tokenize='unicode61'
|
|
562
|
+
);
|
|
563
|
+
|
|
564
|
+
CREATE TRIGGER IF NOT EXISTS segments_source_ai AFTER INSERT ON segments_source BEGIN
|
|
565
|
+
INSERT INTO segments_fts(rowid, transcript_text, analysis_summary, editorial_role, speaker_labels)
|
|
566
|
+
VALUES (new.rowid, new.transcript_text, new.analysis_summary, new.editorial_role, new.speaker_labels);
|
|
567
|
+
END;
|
|
568
|
+
|
|
569
|
+
CREATE TRIGGER IF NOT EXISTS segments_source_ad AFTER DELETE ON segments_source BEGIN
|
|
570
|
+
INSERT INTO segments_fts(segments_fts, rowid, transcript_text, analysis_summary, editorial_role, speaker_labels)
|
|
571
|
+
VALUES ('delete', old.rowid, old.transcript_text, old.analysis_summary, old.editorial_role, old.speaker_labels);
|
|
572
|
+
END;
|
|
573
|
+
|
|
574
|
+
CREATE TRIGGER IF NOT EXISTS segments_source_au AFTER UPDATE ON segments_source BEGIN
|
|
575
|
+
INSERT INTO segments_fts(segments_fts, rowid, transcript_text, analysis_summary, editorial_role, speaker_labels)
|
|
576
|
+
VALUES ('delete', old.rowid, old.transcript_text, old.analysis_summary, old.editorial_role, old.speaker_labels);
|
|
577
|
+
INSERT INTO segments_fts(rowid, transcript_text, analysis_summary, editorial_role, speaker_labels)
|
|
578
|
+
VALUES (new.rowid, new.transcript_text, new.analysis_summary, new.editorial_role, new.speaker_labels);
|
|
579
|
+
END;
|
|
580
|
+
`);
|
|
581
|
+
|
|
582
|
+
return db;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
// ============================================================
|
|
586
|
+
// Public command
|
|
587
|
+
// ============================================================
|
|
588
|
+
|
|
589
|
+
export async function migrateCommand(options: MigrateOptions): Promise<MigrationResult | undefined> {
|
|
590
|
+
const { source, dryRun } = options;
|
|
591
|
+
|
|
592
|
+
if (!source) {
|
|
593
|
+
console.error("Error: --source <path> is required");
|
|
594
|
+
process.exit(1);
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
if (!fs.existsSync(source)) {
|
|
598
|
+
console.error(`Error: Source database not found: ${source}`);
|
|
599
|
+
process.exit(1);
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
const v1 = new Database(source, { readonly: true });
|
|
603
|
+
|
|
604
|
+
try {
|
|
605
|
+
// Step 1: Analyze v1
|
|
606
|
+
const { tables, counts } = analyzeV1(v1);
|
|
607
|
+
|
|
608
|
+
console.log("v1 데이터베이스 분석:");
|
|
609
|
+
console.log("=====================");
|
|
610
|
+
for (const name of tables) {
|
|
611
|
+
console.log(` ${name}: ${counts[name]} 레코드`);
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
// Determine which project to migrate (first one)
|
|
615
|
+
const projects = v1.prepare("SELECT * FROM projects ORDER BY id").all() as V1Project[];
|
|
616
|
+
if (projects.length === 0) {
|
|
617
|
+
console.error("Error: No projects found in v1 database");
|
|
618
|
+
process.exit(1);
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// Migrate each project
|
|
622
|
+
for (const v1Project of projects) {
|
|
623
|
+
console.log(`\n프로젝트 마이그레이션: "${v1Project.name}" (id=${v1Project.id})`);
|
|
624
|
+
|
|
625
|
+
// Determine target path
|
|
626
|
+
const targetPath = options.target
|
|
627
|
+
? projects.length > 1
|
|
628
|
+
? path.join(options.target, v1Project.name)
|
|
629
|
+
: options.target
|
|
630
|
+
: v1Project.source_uri;
|
|
631
|
+
|
|
632
|
+
if (dryRun) {
|
|
633
|
+
console.log("\n[DRY RUN] 실제 변경 없음");
|
|
634
|
+
console.log(` 대상 경로: ${targetPath}`);
|
|
635
|
+
|
|
636
|
+
const assetCount = (
|
|
637
|
+
v1.prepare("SELECT COUNT(*) as count FROM assets WHERE project_id = ?").get(v1Project.id) as CountRow
|
|
638
|
+
).count;
|
|
639
|
+
const analysisCount = (
|
|
640
|
+
v1
|
|
641
|
+
.prepare(
|
|
642
|
+
"SELECT COUNT(*) as count FROM asset_analysis WHERE asset_id IN (SELECT id FROM assets WHERE project_id = ?)",
|
|
643
|
+
)
|
|
644
|
+
.get(v1Project.id) as CountRow
|
|
645
|
+
).count;
|
|
646
|
+
|
|
647
|
+
console.log(` 에셋: ${assetCount}개`);
|
|
648
|
+
console.log(` 분석: ${analysisCount}개`);
|
|
649
|
+
console.log(` → .fw/project.fw.json 생성 예정`);
|
|
650
|
+
console.log(` → .fw/assets/*.fw.json ${assetCount}개 생성 예정`);
|
|
651
|
+
console.log(` → .fw/state.sqlite 생성 예정`);
|
|
652
|
+
continue;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
// Step 2: Backup source
|
|
656
|
+
const backupPath = `${source}.bak`;
|
|
657
|
+
if (!fs.existsSync(backupPath)) {
|
|
658
|
+
fs.copyFileSync(source, backupPath);
|
|
659
|
+
console.log(` 백업 생성: ${backupPath}`);
|
|
660
|
+
} else {
|
|
661
|
+
console.log(` 백업 이미 존재: ${backupPath}`);
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
// Step 3: Create target directory
|
|
665
|
+
const fwDir = path.join(targetPath, ".fw");
|
|
666
|
+
const assetsDir = path.join(fwDir, "assets");
|
|
667
|
+
fs.mkdirSync(assetsDir, { recursive: true });
|
|
668
|
+
console.log(` 디렉토리 생성: ${fwDir}`);
|
|
669
|
+
|
|
670
|
+
// Step 4: Build asset ID → hash lookup
|
|
671
|
+
const assetIdToHash = buildAssetIdToHash(v1);
|
|
672
|
+
|
|
673
|
+
// Step 5: Migrate project JSON
|
|
674
|
+
const project = migrateProject(v1, v1Project.id);
|
|
675
|
+
const projectJsonPath = path.join(fwDir, "project.fw.json");
|
|
676
|
+
fs.writeFileSync(projectJsonPath, JSON.stringify(project, null, 2));
|
|
677
|
+
console.log(` project.fw.json 생성 완료`);
|
|
678
|
+
|
|
679
|
+
// Step 6: Migrate assets
|
|
680
|
+
const fwAssets = migrateAssets(v1, v1Project.id);
|
|
681
|
+
let analysisCount = 0;
|
|
682
|
+
let transcriptAssets = 0;
|
|
683
|
+
for (const asset of fwAssets) {
|
|
684
|
+
const assetPath = path.join(assetsDir, `${asset.asset_hash}.fw.json`);
|
|
685
|
+
fs.writeFileSync(assetPath, JSON.stringify(asset, null, 2));
|
|
686
|
+
if (asset.analysis) analysisCount++;
|
|
687
|
+
if (asset.transcript) transcriptAssets++;
|
|
688
|
+
}
|
|
689
|
+
console.log(` 에셋 ${fwAssets.length}개 마이그레이션 (분석: ${analysisCount}, 트랜스크립트: ${transcriptAssets})`);
|
|
690
|
+
|
|
691
|
+
// Step 7: Migrate state.sqlite data
|
|
692
|
+
const statePath = path.join(fwDir, "state.sqlite");
|
|
693
|
+
const v2State = initV2StateDb(statePath);
|
|
694
|
+
|
|
695
|
+
try {
|
|
696
|
+
const migrateSqlite = v2State.transaction(() => {
|
|
697
|
+
const chunks = migrateSearchChunks(v1, v2State, v1Project.id, assetIdToHash);
|
|
698
|
+
console.log(` 검색 청크 ${chunks}개 마이그레이션`);
|
|
699
|
+
|
|
700
|
+
const reviews = migrateSegmentReviews(v1, v2State, v1Project.id, assetIdToHash);
|
|
701
|
+
console.log(` 세그먼트 리뷰 ${reviews}개 마이그레이션`);
|
|
702
|
+
|
|
703
|
+
const clips = migrateEditPackages(v1, v2State, v1Project.id, assetIdToHash);
|
|
704
|
+
console.log(` 타임라인 클립 ${clips}개 마이그레이션`);
|
|
705
|
+
|
|
706
|
+
return { chunks, reviews, clips };
|
|
707
|
+
});
|
|
708
|
+
|
|
709
|
+
const { chunks, reviews, clips } = migrateSqlite();
|
|
710
|
+
|
|
711
|
+
console.log(`\n마이그레이션 완료: "${v1Project.name}"`);
|
|
712
|
+
console.log(` 경로: ${targetPath}/.fw/`);
|
|
713
|
+
|
|
714
|
+
return {
|
|
715
|
+
projectName: v1Project.name,
|
|
716
|
+
assetsCount: fwAssets.length,
|
|
717
|
+
analysisCount,
|
|
718
|
+
transcriptAssets,
|
|
719
|
+
searchChunks: chunks,
|
|
720
|
+
segmentReviews: reviews,
|
|
721
|
+
timelineClips: clips,
|
|
722
|
+
targetPath,
|
|
723
|
+
backupPath,
|
|
724
|
+
};
|
|
725
|
+
} finally {
|
|
726
|
+
v2State.close();
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
} finally {
|
|
730
|
+
v1.close();
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
return undefined;
|
|
734
|
+
}
|