@kaydash9999/banker-plugins 0.15.1 → 0.16.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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +60 -0
- package/README.md +31 -9
- package/codex/manifest.json +6 -3
- package/codex/transform-matrix.md +2 -0
- package/commands/progress.md +10 -0
- package/commands/setup.md +1 -1
- package/hooks/graceful-pause.mjs +31 -71
- package/hooks/hooks.json +11 -1
- package/hooks/progress-fallback.mjs +10 -0
- package/hooks/progress.mjs +427 -0
- package/hooks/register.mjs +82 -5
- package/package.json +1 -1
- package/skills/3d-intro-build/SKILL.md +235 -122
- package/skills/3d-intro-build/references/NOTICE.md +2 -1
- package/skills/3d-intro-build/references/assemble.mjs +164 -108
- package/skills/3d-intro-build/references/azure-adapter.mjs +115 -29
- package/skills/3d-intro-build/references/curate.mjs +573 -267
- package/skills/3d-intro-build/references/intro-fixes.css +61 -0
- package/skills/3d-intro-build/references/panel-glass.css +55 -0
- package/skills/3d-intro-build/references/preview-lib.mjs +256 -0
- package/skills/3d-intro-build/references/prompts.md +32 -9
- package/skills/3d-intro-build/references/serve.mjs +111 -103
- package/skills/3d-intro-build/references/step-nav.css +59 -0
- package/skills/3d-intro-build/references/step-nav.js +503 -0
- package/skills/3d-intro-build/references/verify-intro.mjs +474 -0
- package/skills/3d-intro-build/references/verify.md +45 -0
- package/skills/3d-intro-build/references/video-pool.mjs +780 -0
- package/skills/3d-intro-setup/SKILL.md +49 -12
- package/skills/3d-intro-setup/references/azure-adapter.mjs +115 -29
- package/skills/3d-intro-setup/references/video-pool.mjs +780 -0
- package/skills/lineage/SKILL.md +12 -8
- package/skills/lineage/lineage.py +190 -59
- package/skills/motion-graphic-make/SKILL.md +73 -28
- package/skills/motion-graphic-make/references/curate.mjs +607 -0
- package/skills/motion-graphic-make/references/preview-lib.mjs +256 -0
- package/skills/motion-graphic-make/references/serve.mjs +131 -0
- package/skills/omc-patch/SKILL.md +143 -0
- package/skills/omc-patch/scripts/omc-patch.mjs +577 -0
- package/skills/remains/SKILL.md +125 -0
- package/skills/remains/scripts/boxes.mjs +448 -0
- package/skills/trouble-shooting/SKILL.md +50 -0
|
@@ -1,301 +1,607 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/*
|
|
3
|
-
*
|
|
3
|
+
* Review page for the 3d-intro-build and motion-graphic-make skills (scripts/sync-adapter.js keeps
|
|
4
|
+
* the copies identical). Every take (a still, a clip, a snapshot, a render) is a card beside the
|
|
5
|
+
* prompt that made it, so a person judges the output and its instructions together, before any
|
|
6
|
+
* further paid step. The convention is a local page on an unused port, never a claude.ai artifact.
|
|
4
7
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* the user wants that scene re-generated). Written on POST /select.
|
|
15
|
-
*
|
|
16
|
-
* Routes: GET / -> curation page (rebuilt from curate-input.json each load, so a
|
|
17
|
-
* regenerate-loop re-read shows fresh stills without a restart)
|
|
18
|
-
* GET /<img> -> a still image (image/png/…; traversal-guarded; Cache-Control no-store)
|
|
19
|
-
* POST /select -> writes selection.json, returns {ok:true}
|
|
20
|
-
*
|
|
21
|
-
* Runtime: Node >=18 builtins ONLY (node:http / node:fs / node:path). No external deps, no shell —
|
|
22
|
-
* cross-platform (path.join / path.sep everywhere). Loopback by default; PREVIEW_HOST=0.0.0.0 for LAN.
|
|
8
|
+
* Files in the project folder:
|
|
9
|
+
* IN curate-input.json { title?, stage, stageLabel?, promptLabel?, budgetUsd?, spentUsd?,
|
|
10
|
+
* scenes: [{ id, label?, takes: [{ file, take?, prompt?, negativePrompt?,
|
|
11
|
+
* model?, size?, seconds?, firstFrame?, lastFrame?, createdAt?, costUsd? }] }] }
|
|
12
|
+
* stage: stills | clips | snapshots | render (or any name). `variants` is read as `takes`.
|
|
13
|
+
* Every file is relative to the project folder; one outside it is refused.
|
|
14
|
+
* OUT decisions.json { <stage>: { updatedAt, scenes: [{ id, verdict, chosen?, note?, rejected[],
|
|
15
|
+
* archived?, by: page|chat, at }] } }
|
|
16
|
+
* verdict: approve (chosen is the take to use) | regenerate (note says what to change) | pending.
|
|
23
17
|
*
|
|
24
18
|
* Usage:
|
|
25
|
-
* node curate.mjs [projectDir] [--port N]
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
19
|
+
* node curate.mjs [projectDir] [--port N] serve the page (state: <projectDir>/curate.server.json)
|
|
20
|
+
* node curate.mjs --stop [projectDir] end that server
|
|
21
|
+
* node curate.mjs --record [projectDir] --scene <id|n> --verdict approve|regenerate|reject|pending
|
|
22
|
+
* [--take <n|file>] [--note "..."] record a verdict given in chat
|
|
23
|
+
* node curate.mjs --archive [projectDir] move rejected takes to rejected-YYYYMMDD/
|
|
24
|
+
* PREVIEW_HOST=0.0.0.0 binds every interface and prints a warning.
|
|
25
|
+
* The server answers only the images and videos the input lists as takes and frames, inside the
|
|
26
|
+
* project by path and by link (never other files of the folder), only to a Host that is localhost
|
|
27
|
+
* or an IP address, and takes verdicts only as JSON from its own page; a page drawn before a newer
|
|
28
|
+
* verdict (one recorded in chat) gets 409 instead of overwriting it. Errors name no absolute path.
|
|
29
|
+
* A start while the recorded server runs only reports its address; with another PREVIEW_HOST or a
|
|
30
|
+
* --port other than the one it listens on or its own start asked for, it exits 1: --stop first.
|
|
31
|
+
* Each take's sha256 is read in chunks (any size) from the server start on, and again once the file
|
|
32
|
+
* changes.
|
|
33
|
+
*
|
|
34
|
+
* Runtime: Node >=18 builtins only. No shell, cross-platform.
|
|
29
35
|
*/
|
|
30
|
-
import
|
|
36
|
+
import crypto from 'node:crypto';
|
|
31
37
|
import fs from 'node:fs';
|
|
38
|
+
import http from 'node:http';
|
|
32
39
|
import path from 'node:path';
|
|
40
|
+
import { fileURLToPath } from 'node:url';
|
|
41
|
+
import {
|
|
42
|
+
LOOPBACK, READ_FLAGS, answersPid, decodePath, forgetSelf, hostAllowed, hostWarning, isRunning, listen, portMatches,
|
|
43
|
+
readState, resolveInside, send, sendFile, stopServer, urlFor, writeState,
|
|
44
|
+
} from './preview-lib.mjs';
|
|
33
45
|
|
|
34
|
-
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
else if (a.startsWith('--port=')) portArg = Number(a.slice('--port='.length));
|
|
42
|
-
else if (!a.startsWith('--')) dirArg = a;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
const DIR = path.resolve(dirArg || process.cwd());
|
|
46
|
-
if (!fs.existsSync(DIR) || !fs.statSync(DIR).isDirectory()) {
|
|
47
|
-
console.error(`curate: not a directory: ${DIR}`);
|
|
48
|
-
process.exit(1);
|
|
49
|
-
}
|
|
50
|
-
const INPUT = path.join(DIR, 'curate-input.json');
|
|
51
|
-
const OUTPUT = path.join(DIR, 'selection.json');
|
|
52
|
-
|
|
53
|
-
const MIME = {
|
|
54
|
-
'.html': 'text/html;charset=utf-8',
|
|
55
|
-
'.png': 'image/png',
|
|
56
|
-
'.jpg': 'image/jpeg',
|
|
57
|
-
'.jpeg': 'image/jpeg',
|
|
58
|
-
'.webp': 'image/webp',
|
|
59
|
-
'.gif': 'image/gif',
|
|
60
|
-
'.svg': 'image/svg+xml',
|
|
61
|
-
};
|
|
46
|
+
const INPUT = 'curate-input.json';
|
|
47
|
+
const DECISIONS = 'decisions.json';
|
|
48
|
+
const STATE = 'curate.server.json';
|
|
49
|
+
const STAGE_LABELS = { stills: '스틸 검토', clips: '클립 검토', snapshots: '스냅숏 검토', render: '렌더 검토' };
|
|
50
|
+
const INSTRUCTION_STAGES = new Set(['snapshots', 'render']);
|
|
51
|
+
const VERDICTS = new Set(['approve', 'regenerate', 'reject', 'pending']);
|
|
52
|
+
const VIDEO = /\.(mp4|webm|mov)$/i;
|
|
62
53
|
|
|
63
|
-
// ---- html helpers ----
|
|
64
54
|
const escHtml = (s) => String(s).replace(/[&<>]/g, (c) => ({ '&': '&', '<': '<', '>': '>' }[c]));
|
|
65
55
|
const escAttr = (s) => String(s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
|
66
|
-
//
|
|
56
|
+
// A project-relative file as a URL path, each segment encoded.
|
|
67
57
|
const fileUrl = (file) => '/' + String(file).replace(/^[.][/\\]/, '').split(/[\\/]/).map(encodeURIComponent).join('/');
|
|
58
|
+
const money = (n) => `$${Number(Number(n).toFixed(3))}`;
|
|
59
|
+
const inside = (dir, file) => typeof file === 'string' && file !== '' && resolveInside(dir, '/' + file) !== null;
|
|
60
|
+
const MEDIA = /\.(png|jpe?g|webp|gif|mp4|webm|mov)$/i;
|
|
68
61
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
62
|
+
// Where a path's links lead: null when they stay in the project and end at an image or a video (a
|
|
63
|
+
// file not there yet goes by its path), else why not.
|
|
64
|
+
function linkRefusal(dir, fp) {
|
|
65
|
+
let real;
|
|
66
|
+
try { real = fs.realpathSync(fp); } catch { return null; }
|
|
67
|
+
if (!real.startsWith(fs.realpathSync(dir) + path.sep)) return '링크가 프로젝트 폴더 밖을 가리켜 보이지 않습니다.';
|
|
68
|
+
return MEDIA.test(real) ? null : '링크가 이미지나 영상이 아닌 파일을 가리켜 보이지 않습니다.';
|
|
73
69
|
}
|
|
74
70
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
return
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
<style>
|
|
104
|
-
:root {
|
|
105
|
-
--bg: #f5f3ef; --panel: #ffffff; --ink: #23202a; --muted: #6c6675;
|
|
106
|
-
--line: #e2ddd4; --accent: #7c5cbf; --accent-ink: #ffffff;
|
|
107
|
-
--keep: #2f7d4f; --keep-bg: #e6f3ea; --regen: #b26a00; --regen-bg: #fbefd8;
|
|
71
|
+
// Why a listed file is neither shown nor served nor hashed, or null when it may be: it must be an
|
|
72
|
+
// image or a video inside the project, by its path and by where its links lead, so a key file or
|
|
73
|
+
// a record listed by mistake never leaves the folder.
|
|
74
|
+
function refusal(dir, file) {
|
|
75
|
+
if (!inside(dir, file)) return '파일 경로가 프로젝트 폴더 밖이라 보이지 않습니다.';
|
|
76
|
+
if (!MEDIA.test(file)) return '이미지나 영상 파일이 아니라 보이지 않습니다.';
|
|
77
|
+
return linkRefusal(dir, path.join(dir, file));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ---- input and decisions ----------------------------------------------------
|
|
81
|
+
|
|
82
|
+
export function readInput(dir) {
|
|
83
|
+
const raw = JSON.parse(fs.readFileSync(path.join(dir, INPUT), 'utf8'));
|
|
84
|
+
const scenes = (Array.isArray(raw.scenes) ? raw.scenes : []).map((s, i) => ({
|
|
85
|
+
...s, id: String(s.id ?? `scene-${i + 1}`), label: s.label || String(s.id ?? `scene-${i + 1}`),
|
|
86
|
+
takes: (Array.isArray(s.takes) ? s.takes : Array.isArray(s.variants) ? s.variants : [])
|
|
87
|
+
.map((t, j) => ({ ...t, take: t.take ?? j + 1 })),
|
|
88
|
+
}));
|
|
89
|
+
return { ...raw, stage: raw.stage || 'stills', scenes };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// No file yet is an empty record; a file that does not parse stops the work, so that a hand edit gone
|
|
93
|
+
// wrong is not replaced with the other stages' verdicts lost.
|
|
94
|
+
export function readDecisions(dir) {
|
|
95
|
+
let text;
|
|
96
|
+
try { text = fs.readFileSync(path.join(dir, DECISIONS), 'utf8'); } catch (e) {
|
|
97
|
+
if (e.code === 'ENOENT') return {};
|
|
98
|
+
throw e;
|
|
108
99
|
}
|
|
109
|
-
|
|
110
|
-
:
|
|
111
|
-
--bg: #17151b; --panel: #201d26; --ink: #ece8f1; --muted: #a49db1;
|
|
112
|
-
--line: #322d3c; --accent: #a488e6; --accent-ink: #17151b;
|
|
113
|
-
--keep: #7fd3a0; --keep-bg: #1d3327; --regen: #e6b063; --regen-bg: #3a2c14;
|
|
114
|
-
}
|
|
100
|
+
try { return JSON.parse(text); } catch (e) {
|
|
101
|
+
throw new Error(`curate: ${DECISIONS} 을 읽지 못했습니다(${e.message}). 고치거나 지운 뒤 다시 하세요`);
|
|
115
102
|
}
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
.
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
.
|
|
132
|
-
.
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
.
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Written whole or not at all: a crash mid-write leaves the previous file.
|
|
106
|
+
function writeJson(file, value) {
|
|
107
|
+
fs.writeFileSync(`${file}.tmp`, JSON.stringify(value, null, 2) + '\n');
|
|
108
|
+
fs.renameSync(`${file}.tmp`, file);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const writeDecisions = (dir, all) => writeJson(path.join(dir, DECISIONS), all);
|
|
112
|
+
|
|
113
|
+
// The verdict a scene's choice and note add up to: a note asks for a new take.
|
|
114
|
+
const verdictOf = (e) => (e.note ? 'regenerate' : e.chosen ? 'approve' : 'pending');
|
|
115
|
+
|
|
116
|
+
function entryFor(stageDecisions, id) {
|
|
117
|
+
let e = stageDecisions.scenes.find((x) => x.id === id);
|
|
118
|
+
if (!e) stageDecisions.scenes.push((e = { id, verdict: 'pending', rejected: [] }));
|
|
119
|
+
e.rejected = Array.isArray(e.rejected) ? e.rejected : [];
|
|
120
|
+
return e;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function stageOf(all, stage) {
|
|
124
|
+
all[stage] = all[stage] && Array.isArray(all[stage].scenes) ? all[stage] : { scenes: [] };
|
|
125
|
+
return all[stage];
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function findScene(input, ref) {
|
|
129
|
+
const byId = input.scenes.find((s) => s.id === String(ref));
|
|
130
|
+
const byNumber = /^\d+$/.test(String(ref)) ? input.scenes[Number(ref) - 1] : undefined;
|
|
131
|
+
const scene = byId || byNumber;
|
|
132
|
+
if (!scene) throw new Error(`curate: 장면 ${ref} 을 찾지 못했습니다`);
|
|
133
|
+
return scene;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function findTake(scene, ref) {
|
|
137
|
+
const take = scene.takes.find((t) => String(t.take) === String(ref) || t.file === ref);
|
|
138
|
+
if (!take) throw new Error(`curate: ${scene.id} 에 테이크 ${ref} 가 없습니다`);
|
|
139
|
+
return take.file;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// How a verdict given in chat changes one scene's entry. A reject drops the take from the choice.
|
|
143
|
+
const CHAT_VERDICTS = {
|
|
144
|
+
pending: (e) => Object.assign(e, { rejected: [], chosen: undefined, note: undefined }),
|
|
145
|
+
approve: (e, file, note) => Object.assign(e, { chosen: file, note: note || undefined, rejected: e.rejected.filter((f) => f !== file) }),
|
|
146
|
+
reject: (e, file) => {
|
|
147
|
+
if (!e.rejected.includes(file)) e.rejected.push(file);
|
|
148
|
+
if (e.chosen === file) e.chosen = undefined;
|
|
149
|
+
},
|
|
150
|
+
regenerate: (e, file, note) => {
|
|
151
|
+
if (note !== undefined) e.note = note || undefined;
|
|
152
|
+
if (file) e.chosen = file;
|
|
153
|
+
},
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
function applyVerdict(e, verdict, file, note) {
|
|
157
|
+
CHAT_VERDICTS[verdict](e, file, note);
|
|
158
|
+
e.verdict = verdict === 'regenerate' ? 'regenerate' : verdictOf(e);
|
|
159
|
+
return e;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Records a verdict given in chat: scene by id or 1-based number, take by number or file. */
|
|
163
|
+
export function recordDecision(dir, { scene, verdict, take, note }, now = new Date()) {
|
|
164
|
+
if (!VERDICTS.has(verdict)) throw new Error(`curate: 판정은 approve, regenerate, reject, pending 중 하나입니다 (${verdict})`);
|
|
165
|
+
const input = readInput(dir);
|
|
166
|
+
const s = findScene(input, scene);
|
|
167
|
+
const needsTake = verdict === 'approve' || verdict === 'reject';
|
|
168
|
+
if (needsTake && take === undefined) throw new Error(`curate: ${verdict} 에는 테이크 번호가 필요합니다`);
|
|
169
|
+
const file = take === undefined ? undefined : findTake(s, take);
|
|
170
|
+
const all = readDecisions(dir);
|
|
171
|
+
const stage = stageOf(all, input.stage);
|
|
172
|
+
const e = applyVerdict(entryFor(stage, s.id), verdict, file, note);
|
|
173
|
+
Object.assign(e, { by: 'chat', at: now.toISOString() });
|
|
174
|
+
stage.updatedAt = now.toISOString();
|
|
175
|
+
writeDecisions(dir, all);
|
|
176
|
+
return e;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const stale = () => Object.assign(new Error('curate: 페이지를 연 뒤 판정이 바뀌었습니다. 새로 고친 뒤 다시 저장하세요'), { status: 409 });
|
|
180
|
+
|
|
181
|
+
// One scene's verdict as the page sends it: the chosen take, a note, the rejected takes.
|
|
182
|
+
function pageEntry(stage, scene, p, now) {
|
|
183
|
+
const files = new Set(scene.takes.map((t) => t.file));
|
|
184
|
+
if (p.chosen && !files.has(p.chosen)) throw new Error(`curate: ${scene.id} 의 선택이 테이크가 아닙니다`);
|
|
185
|
+
const rejected = (Array.isArray(p.rejected) ? p.rejected : []).filter((f) => files.has(f));
|
|
186
|
+
const e = entryFor(stage, scene.id);
|
|
187
|
+
Object.assign(e, { chosen: p.chosen || undefined, note: String(p.note ?? '').trim() || undefined, rejected });
|
|
188
|
+
return Object.assign(e, { verdict: verdictOf(e), by: 'page', at: now.toISOString() });
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Verdicts the page sends. `basedOn` is the record the page was drawn from; a verdict recorded since
|
|
192
|
+
// (in chat) is not overwritten.
|
|
193
|
+
function applyPageDecisions(dir, payload, now = new Date()) {
|
|
194
|
+
const input = readInput(dir);
|
|
195
|
+
const all = readDecisions(dir);
|
|
196
|
+
const stage = stageOf(all, input.stage);
|
|
197
|
+
if ((payload.basedOn || '') !== (stage.updatedAt || '')) throw stale();
|
|
198
|
+
for (const p of Array.isArray(payload.scenes) ? payload.scenes : []) pageEntry(stage, findScene(input, p.id), p, now);
|
|
199
|
+
stage.updatedAt = now.toISOString();
|
|
200
|
+
writeDecisions(dir, all);
|
|
201
|
+
return stage;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const dated = (now) => `rejected-${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}`;
|
|
205
|
+
|
|
206
|
+
// Moves a take into `folder`, keeping its place relative to the project however its path is spelled.
|
|
207
|
+
function moveInto(dir, file, folder) {
|
|
208
|
+
const src = path.normalize(path.join(dir, file));
|
|
209
|
+
const rel = path.relative(dir, src);
|
|
210
|
+
const dest = path.join(dir, folder, rel);
|
|
211
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
212
|
+
fs.renameSync(src, dest);
|
|
213
|
+
return { file, to: [folder, ...rel.split(path.sep)].join('/') };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Moves the current stage's rejected takes into rejected-YYYYMMDD/ and drops them from the input. */
|
|
217
|
+
export function archiveRejected(dir, { now = new Date() } = {}) {
|
|
218
|
+
const raw = JSON.parse(fs.readFileSync(path.join(dir, INPUT), 'utf8'));
|
|
219
|
+
const input = readInput(dir);
|
|
220
|
+
const all = readDecisions(dir);
|
|
221
|
+
const stage = stageOf(all, input.stage);
|
|
222
|
+
const moves = [];
|
|
223
|
+
for (const e of stage.scenes) {
|
|
224
|
+
const moved = e.rejected.filter((f) => !refusal(dir, f) && fs.existsSync(path.join(dir, f))).map((f) => moveInto(dir, f, dated(now)));
|
|
225
|
+
e.archived = [...(e.archived || []), ...moved];
|
|
226
|
+
e.rejected = e.rejected.filter((f) => !moved.some((m) => m.file === f));
|
|
227
|
+
moves.push(...moved);
|
|
228
|
+
}
|
|
229
|
+
if (!moves.length) return moves;
|
|
230
|
+
const gone = new Set(moves.map((m) => m.file));
|
|
231
|
+
for (const s of raw.scenes || []) {
|
|
232
|
+
for (const key of ['takes', 'variants']) if (Array.isArray(s[key])) s[key] = s[key].filter((t) => !gone.has(t.file));
|
|
233
|
+
}
|
|
234
|
+
writeJson(path.join(dir, INPUT), raw);
|
|
235
|
+
writeDecisions(dir, all);
|
|
236
|
+
return moves;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// ---- the page -----------------------------------------------------------------
|
|
240
|
+
|
|
241
|
+
// sha256 of a file, read in chunks so a render of any size never sits whole in memory (one read
|
|
242
|
+
// stops at 2 GiB), and only up to the size it had when the read began, so a file that grows or
|
|
243
|
+
// never ends (/proc) is still read once. Once per version of the file: the key holds its inode,
|
|
244
|
+
// size, mtime and ctime, so a regenerated take is hashed anew; a view that finds the read already
|
|
245
|
+
// going closes its own file and waits for that one. Rejects for a file that cannot be read, and for
|
|
246
|
+
// anything but a regular file (a link to /dev/zero, a FIFO; opened without blocking in case the
|
|
247
|
+
// path turns into one after the first check).
|
|
248
|
+
const hashes = new Map();
|
|
249
|
+
const notRegular = () => new Error('일반 파일 아님');
|
|
250
|
+
export async function hashFile(fp) {
|
|
251
|
+
if (!(await fs.promises.stat(fp)).isFile()) throw notRegular();
|
|
252
|
+
const fh = await fs.promises.open(fp, READ_FLAGS);
|
|
253
|
+
let done;
|
|
254
|
+
try {
|
|
255
|
+
const st = await fh.stat();
|
|
256
|
+
if (!st.isFile()) throw notRegular();
|
|
257
|
+
const key = `${fp}:${st.ino}:${st.size}:${st.mtimeMs}:${st.ctimeMs}`;
|
|
258
|
+
done = hashes.get(key);
|
|
259
|
+
if (done) return done;
|
|
260
|
+
done = sha256Of(fh, st.size);
|
|
261
|
+
done.catch(() => hashes.delete(key));
|
|
262
|
+
hashes.set(key, done);
|
|
263
|
+
await done.catch(() => {});
|
|
264
|
+
} finally {
|
|
265
|
+
await fh.close();
|
|
266
|
+
}
|
|
267
|
+
return done;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// sha256 of the first `size` bytes of an open file.
|
|
271
|
+
function sha256Of(fh, size) {
|
|
272
|
+
const h = crypto.createHash('sha256');
|
|
273
|
+
if (size === 0) return Promise.resolve(h.digest('hex'));
|
|
274
|
+
return new Promise((resolve, reject) => {
|
|
275
|
+
fh.createReadStream({ start: 0, end: size - 1, autoClose: false })
|
|
276
|
+
.on('error', reject).on('data', (c) => h.update(c)).on('end', () => resolve(h.digest('hex')));
|
|
182
277
|
});
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
alert('저장 실패: ' + (err && err.message ? err.message : err));
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// What a card shows for a take's file: its sha256, or why there is none.
|
|
281
|
+
async function hashText(dir, file) {
|
|
282
|
+
if (refusal(dir, file)) return '보이지 않음';
|
|
283
|
+
const fp = path.join(dir, file);
|
|
284
|
+
if (!fs.existsSync(fp)) return '파일 없음';
|
|
285
|
+
try { return await hashFile(fp); } catch (e) { return `해시 실패(${e.code || e.message})`; }
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// The hash text of every listed take by file, four files at a time per call (a long list must not
|
|
289
|
+
// run out of file handles), before the page is drawn.
|
|
290
|
+
async function takeHashes(input, dir) {
|
|
291
|
+
const files = [...new Set(input.scenes.flatMap((s) => s.takes.map((t) => t.file)))];
|
|
292
|
+
const out = new Map();
|
|
293
|
+
let next = 0;
|
|
294
|
+
const worker = async () => {
|
|
295
|
+
while (next < files.length) {
|
|
296
|
+
const f = files[next++];
|
|
297
|
+
out.set(f, await hashText(dir, f));
|
|
204
298
|
}
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
299
|
+
};
|
|
300
|
+
await Promise.all(Array.from({ length: Math.min(4, files.length) }, worker));
|
|
301
|
+
return out;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function media(t, dir) {
|
|
305
|
+
const no = refusal(dir, t.file);
|
|
306
|
+
if (no) return `<p class="refused">${no}</p>`;
|
|
307
|
+
const src = escAttr(fileUrl(t.file));
|
|
308
|
+
return VIDEO.test(t.file)
|
|
309
|
+
? `<video src="${src}" muted loop autoplay playsinline preload="metadata"></video>`
|
|
310
|
+
: `<img src="${src}" alt="" loading="lazy">`;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function frameRow(label, file, dir) {
|
|
314
|
+
if (!file) return '';
|
|
315
|
+
const thumb = refusal(dir, file) ? '' : `<img class="thumb" src="${escAttr(fileUrl(file))}" alt="">`;
|
|
316
|
+
return `<dt>${label}</dt><dd>${thumb}<code>${escHtml(file)}</code></dd>`;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function fileFacts(t, hash) {
|
|
320
|
+
return `<dt>파일</dt><dd><code>${escHtml(t.file ?? '')}</code></dd><dt>sha256</dt><dd><code class="hash">${escHtml(hash)}</code></dd>`;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function facts(t, ctx) {
|
|
324
|
+
const row = (label, v) => (v === undefined || v === null || v === '' ? '' : `<dt>${label}</dt><dd>${escHtml(v)}</dd>`);
|
|
325
|
+
return '<dl>'
|
|
326
|
+
+ (t.prompt ? `<dt>${ctx.promptLabel}</dt><dd class="prompt">${escHtml(t.prompt)}</dd>` : '')
|
|
327
|
+
+ row('제외 프롬프트', t.negativePrompt) + row('모델', t.model) + row('크기', t.size)
|
|
328
|
+
+ row('길이', t.seconds != null ? `${t.seconds}초` : null)
|
|
329
|
+
+ frameRow('첫 프레임', t.firstFrame, ctx.dir) + frameRow('끝 프레임', t.lastFrame, ctx.dir)
|
|
330
|
+
+ row('생성 시각', t.createdAt) + row('비용', t.costUsd != null ? money(t.costUsd) : null)
|
|
331
|
+
+ fileFacts(t, ctx.hashes.get(t.file)) + '</dl>';
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function card(t, ctx, e) {
|
|
335
|
+
const cls = ['card', e.chosen === t.file ? 'selected' : '', e.rejected.includes(t.file) ? 'rejected' : ''].filter(Boolean).join(' ');
|
|
336
|
+
return `<article class="${cls}" data-file="${escAttr(t.file ?? '')}">
|
|
337
|
+
<div class="media">${media(t, ctx.dir)}</div>
|
|
338
|
+
<div class="meta"><strong>테이크 ${escHtml(t.take)}</strong>${facts(t, ctx)}
|
|
339
|
+
<div class="actions"><button type="button" data-act="choose">이 테이크로 승인</button>
|
|
340
|
+
<button type="button" data-act="reject">탈락</button></div></div>
|
|
341
|
+
</article>`;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const CHIPS = { approve: '승인', regenerate: '재생성 요청', pending: '대기' };
|
|
345
|
+
|
|
346
|
+
function sceneBlock(s, i, ctx) {
|
|
347
|
+
const found = ctx.entries.get(s.id) || { verdict: 'pending', rejected: [] };
|
|
348
|
+
const e = { ...found, verdict: CHIPS[found.verdict] ? found.verdict : 'pending' };
|
|
349
|
+
const cards = s.takes.map((t) => card(t, ctx, e)).join('\n ') || '<p class="empty">테이크가 없습니다.</p>';
|
|
350
|
+
return `<section class="scene" data-scene-id="${escAttr(s.id)}">
|
|
351
|
+
<div class="scene-head"><h2>${i + 1}. ${escHtml(s.label)}</h2><span class="chip ${e.verdict}" data-chip>${CHIPS[e.verdict] || '대기'}</span></div>
|
|
352
|
+
<div class="takes">
|
|
353
|
+
${cards}
|
|
354
|
+
</div>
|
|
355
|
+
<label class="note"><span>재생성 메모 (비우면 고른 테이크로 승인)</span>
|
|
356
|
+
<textarea data-note rows="2">${escHtml(e.note || '')}</textarea></label>
|
|
357
|
+
</section>`;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function summary(input, entries) {
|
|
361
|
+
const approved = input.scenes.filter((s) => entries.get(s.id)?.verdict === 'approve').length;
|
|
362
|
+
const spent = input.spentUsd ?? input.scenes.flatMap((s) => s.takes).reduce((a, t) => a + (Number(t.costUsd) || 0), 0);
|
|
363
|
+
const budget = input.budgetUsd != null ? ` / 승인 예산 ${money(input.budgetUsd)}` : '';
|
|
364
|
+
const stage = input.stageLabel || STAGE_LABELS[input.stage] || input.stage;
|
|
365
|
+
return `<strong>${escHtml(stage)}</strong> <span>승인 ${approved} / ${input.scenes.length}</span> <span>누적 비용 ${money(spent)}${budget}</span>`;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** The review page for `input`. Resolves once every listed take is hashed. */
|
|
369
|
+
export async function renderPage(input, decisions, { dir }) {
|
|
370
|
+
const stage = decisions[input.stage] || { scenes: [] };
|
|
371
|
+
const entries = new Map(stage.scenes.map((e) => [e.id, { ...e, rejected: e.rejected || [] }]));
|
|
372
|
+
const promptLabel = input.promptLabel || (INSTRUCTION_STAGES.has(input.stage) ? '구성 지시문' : '프롬프트');
|
|
373
|
+
const ctx = { dir, promptLabel, entries, hashes: await takeHashes(input, dir) };
|
|
374
|
+
const body = input.scenes.map((s, i) => sceneBlock(s, i, ctx)).join('\n') || '<p class="empty">curate-input.json 에 장면이 없습니다.</p>';
|
|
375
|
+
return `<!doctype html>
|
|
376
|
+
<html lang="ko"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
|
377
|
+
<title>${escHtml(STAGE_LABELS[input.stage] || input.stage)}${input.title ? ' - ' + escHtml(input.title) : ''}</title>
|
|
378
|
+
<style>${PAGE_CSS}</style></head>
|
|
379
|
+
<body><header class="bar"><div class="bar-in"><div class="sum">${summary(input, entries)}</div>
|
|
380
|
+
<button id="save" class="btn">판정 저장</button></div></header>
|
|
381
|
+
<main id="app" data-based-on="${escAttr(stage.updatedAt || '')}">${body}</main>
|
|
382
|
+
<div id="done" class="done" hidden><div class="done-card">판정을 저장했습니다. 에이전트로 돌아가세요.</div></div>
|
|
383
|
+
<script>${PAGE_JS}</script></body></html>
|
|
209
384
|
`;
|
|
210
385
|
}
|
|
211
386
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
387
|
+
const PAGE_CSS = `
|
|
388
|
+
:root{--bg:#f5f3ef;--panel:#fff;--ink:#23202a;--muted:#6c6675;--line:#e2ddd4;--accent:#7c5cbf;--ok:#2f7d4f;--warn:#b26a00;--bad:#b3261e}
|
|
389
|
+
@media (prefers-color-scheme:dark){:root{--bg:#17151b;--panel:#201d26;--ink:#ece8f1;--muted:#a49db1;--line:#322d3c;--accent:#a488e6;--ok:#7fd3a0;--warn:#e6b063;--bad:#f2b8b5}}
|
|
390
|
+
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--ink);font:14px/1.5 system-ui,-apple-system,"Segoe UI","Noto Sans KR",sans-serif}
|
|
391
|
+
.bar{position:sticky;top:0;z-index:5;background:var(--panel);border-bottom:1px solid var(--line)}
|
|
392
|
+
.bar-in{max-width:1400px;margin:0 auto;padding:10px 20px;display:flex;gap:16px;align-items:center;justify-content:space-between}
|
|
393
|
+
.sum{display:flex;gap:18px;flex-wrap:wrap;align-items:baseline}.sum span{color:var(--muted)}
|
|
394
|
+
.btn{border:0;border-radius:8px;background:var(--accent);color:#fff;font-weight:600;padding:9px 18px;cursor:pointer}.btn:disabled{opacity:.5}
|
|
395
|
+
main{max-width:1400px;margin:0 auto;padding:18px 20px}
|
|
396
|
+
.scene{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:14px;margin-bottom:16px}
|
|
397
|
+
.scene-head{display:flex;gap:12px;align-items:center}.scene-head h2{font-size:16px;margin:0}
|
|
398
|
+
.chip{font-size:12px;font-weight:600;padding:2px 10px;border-radius:999px;border:1px solid currentColor}
|
|
399
|
+
.chip.approve{color:var(--ok)}.chip.regenerate{color:var(--warn)}.chip.pending{color:var(--muted)}
|
|
400
|
+
.takes{display:flex;gap:12px;overflow-x:auto;padding:12px 0}
|
|
401
|
+
.card{flex:0 0 340px;border:2px solid var(--line);border-radius:10px;overflow:hidden;display:flex;flex-direction:column}
|
|
402
|
+
.card.selected{border-color:var(--ok)}.card.rejected{border-color:var(--bad);opacity:.6}
|
|
403
|
+
.media img,.media video{display:block;width:100%;height:auto;background:#000}
|
|
404
|
+
.meta{padding:8px 10px}.meta dl{display:grid;grid-template-columns:max-content 1fr;gap:2px 8px;margin:6px 0}
|
|
405
|
+
.meta dt{color:var(--muted)}.meta dd{margin:0;overflow-wrap:anywhere}.prompt{white-space:pre-wrap;max-height:9em;overflow:auto}
|
|
406
|
+
.thumb{width:72px;height:auto;vertical-align:middle;margin-right:6px}.hash{font-size:11px}
|
|
407
|
+
.actions{display:flex;gap:8px}.actions button{border:1px solid var(--line);background:var(--bg);color:var(--ink);border-radius:6px;padding:4px 10px;cursor:pointer}
|
|
408
|
+
.note span{display:block;color:var(--muted);font-size:13px}.note textarea{width:100%;border:1px solid var(--line);border-radius:8px;padding:6px 8px;font:inherit;background:var(--bg);color:var(--ink)}
|
|
409
|
+
.refused,.empty{color:var(--bad)}[hidden]{display:none!important}
|
|
410
|
+
.done{position:fixed;inset:0;display:flex;align-items:center;justify-content:center;background:color-mix(in srgb,var(--bg) 82%,transparent);z-index:10}
|
|
411
|
+
.done-card{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:24px 32px;font-size:17px;font-weight:600}`;
|
|
412
|
+
|
|
413
|
+
const PAGE_JS = `
|
|
414
|
+
const chip = (scene) => {
|
|
415
|
+
const c = scene.querySelector('[data-chip]');
|
|
416
|
+
const note = scene.querySelector('[data-note]').value.trim();
|
|
417
|
+
const v = note ? 'regenerate' : scene.querySelector('.card.selected') ? 'approve' : 'pending';
|
|
418
|
+
c.className = 'chip ' + v; c.textContent = { approve: '승인', regenerate: '재생성 요청', pending: '대기' }[v];
|
|
419
|
+
};
|
|
420
|
+
document.getElementById('app').addEventListener('click', (e) => {
|
|
421
|
+
const btn = e.target.closest('[data-act]'); if (!btn) return;
|
|
422
|
+
const card = btn.closest('.card'); const scene = card.closest('.scene');
|
|
423
|
+
if (btn.dataset.act === 'choose') {
|
|
424
|
+
scene.querySelectorAll('.card').forEach((c) => c.classList.remove('selected'));
|
|
425
|
+
card.classList.add('selected'); card.classList.remove('rejected');
|
|
426
|
+
} else { card.classList.toggle('rejected'); if (card.classList.contains('rejected')) card.classList.remove('selected'); }
|
|
427
|
+
chip(scene);
|
|
428
|
+
});
|
|
429
|
+
document.getElementById('app').addEventListener('input', (e) => { const s = e.target.closest('.scene'); if (s) chip(s); });
|
|
430
|
+
document.getElementById('save').addEventListener('click', async () => {
|
|
431
|
+
const basedOn = document.getElementById('app').dataset.basedOn;
|
|
432
|
+
const scenes = [...document.querySelectorAll('.scene')].map((s) => ({ id: s.dataset.sceneId,
|
|
433
|
+
chosen: s.querySelector('.card.selected')?.dataset.file || '', note: s.querySelector('[data-note]').value,
|
|
434
|
+
rejected: [...s.querySelectorAll('.card.rejected')].map((c) => c.dataset.file) }));
|
|
435
|
+
const btn = document.getElementById('save'); btn.disabled = true;
|
|
436
|
+
try {
|
|
437
|
+
const r = await fetch('/decide', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ basedOn, scenes }) });
|
|
438
|
+
if (!r.ok) throw new Error(await r.text());
|
|
439
|
+
document.getElementById('done').hidden = false;
|
|
440
|
+
} catch (err) { btn.disabled = false; alert('저장하지 못했습니다: ' + (err && err.message ? err.message : err)); }
|
|
441
|
+
});`;
|
|
442
|
+
|
|
443
|
+
// ---- the server ---------------------------------------------------------------
|
|
444
|
+
|
|
445
|
+
function readBody(req, limit = 1_000_000) {
|
|
446
|
+
return new Promise((resolve, reject) => {
|
|
447
|
+
let body = '';
|
|
448
|
+
req.on('data', (c) => { body += c; if (body.length > limit) { req.destroy(); reject(new Error('too large')); } });
|
|
449
|
+
req.on('end', () => resolve(body));
|
|
450
|
+
req.on('error', reject);
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// Verdicts come only from the page itself: JSON (a cross-site form or text/plain post is refused,
|
|
455
|
+
// and JSON across sites needs a preflight this server never grants) from its own origin.
|
|
456
|
+
// Text for the server's terminal: every control character escaped, C0 by JSON and the rest (DEL, C1,
|
|
457
|
+
// bidirectional marks) by hand, so a request body cannot drive the terminal.
|
|
458
|
+
const logSafe = (text) => JSON.stringify(String(text))
|
|
459
|
+
.replace(/[\u007f-\u009f\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/g, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, '0')}`);
|
|
460
|
+
|
|
461
|
+
// What a failure tells the page: a curate: message as it is; anything else only by its code (a system
|
|
462
|
+
// message names absolute paths), with the whole of it on the server's stderr, escaped (a request
|
|
463
|
+
// body's control characters must not reach the terminal).
|
|
464
|
+
function failureText(e, what) {
|
|
465
|
+
if (String(e.message).startsWith('curate:')) return e.message;
|
|
466
|
+
console.error(`curate: ${what}: ${logSafe(e.message || e)}`);
|
|
467
|
+
return `${what}(${e.code || e.name || 'error'})`;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
async function decide(dir, req, res) {
|
|
471
|
+
if (!/^application\/json\b/i.test(req.headers['content-type'] || '')) return send(res, 415, 'JSON only');
|
|
472
|
+
const origin = req.headers.origin;
|
|
473
|
+
if (origin && origin !== `http://${req.headers.host}`) return send(res, 403, 'verdicts from another origin are refused');
|
|
474
|
+
try {
|
|
475
|
+
const stage = applyPageDecisions(dir, JSON.parse(await readBody(req)));
|
|
476
|
+
res.writeHead(200, { 'Content-Type': 'application/json;charset=utf-8', 'Cache-Control': 'no-store' });
|
|
477
|
+
res.end(JSON.stringify({ ok: true, written: DECISIONS, scenes: stage.scenes.length }));
|
|
478
|
+
} catch (e) {
|
|
479
|
+
send(res, e.status || 400, failureText(e, '판정을 저장하지 못했습니다'));
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// The files the page may serve: the images and videos the input lists as takes and frames, inside
|
|
484
|
+
// the project by their paths. Nothing else in the folder (keys in .env files, the review records) is
|
|
485
|
+
// reachable; where a requested file's links lead is checked for that file alone (serveListed).
|
|
486
|
+
function listedFiles(dir) {
|
|
487
|
+
let input;
|
|
488
|
+
try { input = readInput(dir); } catch { return new Set(); }
|
|
489
|
+
const files = new Set();
|
|
490
|
+
for (const t of input.scenes.flatMap((s) => s.takes)) {
|
|
491
|
+
for (const f of [t.file, t.firstFrame, t.lastFrame]) if (inside(dir, f) && MEDIA.test(f)) files.add(path.normalize(path.join(dir, f)));
|
|
492
|
+
}
|
|
493
|
+
return files;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
async function page(dir, res) {
|
|
497
|
+
let html;
|
|
498
|
+
try { html = await renderPage(readInput(dir), readDecisions(dir), { dir }); } catch (e) {
|
|
499
|
+
return send(res, 500, failureText(e, `${INPUT} 을 읽지 못했습니다`));
|
|
220
500
|
}
|
|
221
|
-
const html = renderPage(manifest);
|
|
222
501
|
res.writeHead(200, { 'Content-Type': 'text/html;charset=utf-8', 'Cache-Control': 'no-store' });
|
|
223
502
|
res.end(html);
|
|
224
503
|
}
|
|
225
504
|
|
|
226
|
-
function
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
if (fp
|
|
230
|
-
|
|
231
|
-
const type = MIME[path.extname(fp).toLowerCase()] || 'application/octet-stream';
|
|
232
|
-
res.writeHead(200, {
|
|
233
|
-
'Content-Type': type,
|
|
234
|
-
'Content-Length': fs.statSync(fp).size,
|
|
235
|
-
'Cache-Control': 'no-store',
|
|
236
|
-
});
|
|
237
|
-
fs.createReadStream(fp).pipe(res);
|
|
505
|
+
function serveListed(dir, req, res, urlPath) {
|
|
506
|
+
const fp = resolveInside(dir, urlPath);
|
|
507
|
+
if (!fp) return send(res, 403, '403');
|
|
508
|
+
if (!listedFiles(dir).has(fp) || linkRefusal(dir, fp)) return send(res, 404, '404');
|
|
509
|
+
sendFile(req, res, fp);
|
|
238
510
|
}
|
|
239
511
|
|
|
240
|
-
function
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
});
|
|
247
|
-
req.on('end', () => {
|
|
248
|
-
if (tooBig) { res.writeHead(413); res.end('413'); return; }
|
|
249
|
-
let payload;
|
|
250
|
-
try { payload = JSON.parse(body); }
|
|
251
|
-
catch { res.writeHead(400, { 'Content-Type': 'application/json;charset=utf-8' }); res.end('{"ok":false,"error":"invalid json"}'); return; }
|
|
252
|
-
const scenes = (Array.isArray(payload.scenes) ? payload.scenes : []).map((s) => {
|
|
253
|
-
const out = { id: String(s.id), chosen: String(s.chosen) };
|
|
254
|
-
const note = s.regenerate == null ? '' : String(s.regenerate).trim();
|
|
255
|
-
if (note) out.regenerate = note;
|
|
256
|
-
return out;
|
|
257
|
-
});
|
|
258
|
-
try { fs.writeFileSync(OUTPUT, JSON.stringify({ scenes }, null, 2) + '\n'); }
|
|
259
|
-
catch (e) {
|
|
260
|
-
res.writeHead(500, { 'Content-Type': 'application/json;charset=utf-8' });
|
|
261
|
-
res.end(JSON.stringify({ ok: false, error: String(e.message || e) }));
|
|
262
|
-
return;
|
|
263
|
-
}
|
|
264
|
-
res.writeHead(200, { 'Content-Type': 'application/json;charset=utf-8', 'Cache-Control': 'no-store' });
|
|
265
|
-
res.end(JSON.stringify({ ok: true, written: 'selection.json', scenes: scenes.length }));
|
|
266
|
-
console.log(`selection.json written -> ${OUTPUT} (${scenes.length} scene(s))`);
|
|
267
|
-
});
|
|
512
|
+
function route(dir, req, res, urlPath) {
|
|
513
|
+
if (req.method === 'POST' && urlPath === '/decide') return void decide(dir, req, res);
|
|
514
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') return send(res, 405, '405');
|
|
515
|
+
if (answersPid(urlPath, res)) return;
|
|
516
|
+
if (urlPath === '/' || urlPath === '/index.html') return void page(dir, res);
|
|
517
|
+
serveListed(dir, req, res, urlPath);
|
|
268
518
|
}
|
|
269
519
|
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
}
|
|
520
|
+
function handler(dir, host) {
|
|
521
|
+
return (req, res) => {
|
|
522
|
+
if (!hostAllowed(req.headers.host, [host])) return send(res, 403, 'host not allowed');
|
|
523
|
+
const urlPath = decodePath(req.url);
|
|
524
|
+
if (urlPath === null) return send(res, 400, '400');
|
|
525
|
+
route(dir, req, res, urlPath);
|
|
526
|
+
};
|
|
527
|
+
}
|
|
278
528
|
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
});
|
|
293
|
-
server
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
529
|
+
/**
|
|
530
|
+
* Serves the review page for `dir` until close(). Resolves to { server, port, host, url, stateFile,
|
|
531
|
+
* close, warmed }; `warmed` resolves to the hash texts of the listed takes by file.
|
|
532
|
+
*/
|
|
533
|
+
export async function startCurate(dir, { port, host = process.env.PREVIEW_HOST || LOOPBACK } = {}) {
|
|
534
|
+
const root = path.resolve(dir);
|
|
535
|
+
if (!fs.existsSync(root) || !fs.statSync(root).isDirectory()) throw new Error(`curate: not a directory: ${root}`);
|
|
536
|
+
const server = http.createServer(handler(root, host));
|
|
537
|
+
const bound = await listen(server, { host, port });
|
|
538
|
+
const url = urlFor(host, bound);
|
|
539
|
+
const stateFile = path.join(root, STATE);
|
|
540
|
+
writeState(stateFile, { pid: process.pid, port: bound, requestedPort: port || null, host, url, dir: root, startedAt: new Date().toISOString() });
|
|
541
|
+
const warmed = warmHashes(root);
|
|
542
|
+
const close = () => new Promise((resolve) => { forgetSelf(stateFile); server.close(() => resolve()); });
|
|
543
|
+
return { server, port: bound, host, url, stateFile, close, warmed };
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// Starts hashing the listed takes, so the first view of the page does not wait for a large render.
|
|
547
|
+
// An input that cannot be read hashes nothing here; the page reports it.
|
|
548
|
+
function warmHashes(dir) {
|
|
549
|
+
try { return takeHashes(readInput(dir), dir).catch(() => new Map()); } catch { return Promise.resolve(new Map()); }
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// ---- CLI ------------------------------------------------------------------------
|
|
299
553
|
|
|
300
|
-
|
|
301
|
-
|
|
554
|
+
function parseArgs(argv) {
|
|
555
|
+
const o = { dir: null, port: null };
|
|
556
|
+
const flags = new Set(['--stop', '--record', '--archive']);
|
|
557
|
+
for (let i = 0; i < argv.length; i++) {
|
|
558
|
+
const a = argv[i];
|
|
559
|
+
if (flags.has(a)) o.mode = a.slice(2);
|
|
560
|
+
else if (a.startsWith('--')) {
|
|
561
|
+
const eq = a.indexOf('=');
|
|
562
|
+
o[eq > 0 ? a.slice(2, eq) : a.slice(2)] = eq > 0 ? a.slice(eq + 1) : argv[++i];
|
|
563
|
+
} else o.dir = a;
|
|
564
|
+
}
|
|
565
|
+
return o;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
// A review server already running for this folder answers a start with the same host and port;
|
|
569
|
+
// other settings exit 1, so a page meant for one address is not reviewed on another.
|
|
570
|
+
async function serveMain(dir, o) {
|
|
571
|
+
const recorded = path.join(dir, STATE);
|
|
572
|
+
if (await isRunning(recorded)) {
|
|
573
|
+
const st = readState(recorded);
|
|
574
|
+
if ((st.host || LOOPBACK) === (process.env.PREVIEW_HOST || LOOPBACK) && portMatches(st, Number(o.port))) {
|
|
575
|
+
return console.log(`CURATE ${st.url} (already running, pid ${st.pid})`);
|
|
576
|
+
}
|
|
577
|
+
console.error(`curate: a review server for this folder is running with different settings (host: ${st.host}, port: ${st.port}); `
|
|
578
|
+
+ 'stop it with --stop and start again');
|
|
579
|
+
return process.exit(1);
|
|
580
|
+
}
|
|
581
|
+
const s = await startCurate(dir, { port: Number(o.port) || undefined });
|
|
582
|
+
const warn = hostWarning(s.host);
|
|
583
|
+
if (warn) console.warn(warn);
|
|
584
|
+
console.log(`CURATE ${s.url}`);
|
|
585
|
+
console.log(`reviewing ${dir} (in: ${INPUT} out: ${DECISIONS} state: ${s.stateFile})`);
|
|
586
|
+
const quit = () => { forgetSelf(s.stateFile); s.server.close(); process.exit(0); };
|
|
587
|
+
process.on('SIGINT', quit);
|
|
588
|
+
process.on('SIGTERM', quit);
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
async function main() {
|
|
592
|
+
const o = parseArgs(process.argv.slice(2));
|
|
593
|
+
const dir = path.resolve(o.dir || process.cwd());
|
|
594
|
+
if (o.mode === 'stop') {
|
|
595
|
+
const r = await stopServer(path.join(dir, STATE));
|
|
596
|
+
console.log(`STOP ${r.reason}`);
|
|
597
|
+
return process.exit(['stopped', 'not-running', 'no-state'].includes(r.reason) ? 0 : 1);
|
|
598
|
+
}
|
|
599
|
+
if (o.mode === 'record') return console.log(JSON.stringify(recordDecision(dir, o)));
|
|
600
|
+
if (o.mode === 'archive') return console.log(JSON.stringify(archiveRejected(dir)));
|
|
601
|
+
return serveMain(dir, o);
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
const invokedDirectly = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
605
|
+
if (invokedDirectly) {
|
|
606
|
+
main().catch((e) => { console.error(String(e?.message || e)); process.exit(1); });
|
|
607
|
+
}
|