@lightcone-ai/daemon 0.23.5 → 0.23.7
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/mcp-servers/official/media-tools/index.js +45 -30
- package/mcp-servers/official/media-tools/lib/render.js +5 -4
- package/package.json +3 -2
- package/src/_vendor/video/recorder/atoms.js +81 -75
- package/src/_vendor/video/recorder/chromium-driver.js +1 -5
- package/src/_vendor/video/recorder/index.js +15 -130
- package/src/_vendor/video/recorder/plan-executor.js +121 -10
- package/src/cli.js +255 -0
- package/src/doctor.js +52 -0
- package/src/index.js +36 -2
- package/src/local-api.js +106 -0
- package/src/local-config.js +116 -0
- package/src/tools/plan-video-segments.js +66 -60
- package/src/tools/record-url-narration.js +1 -1
- package/src/_vendor/video/recorder/display-pool.js +0 -126
- package/src/_vendor/video/recorder/ffmpeg-runner.js +0 -291
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, chmodSync, statSync } from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
|
|
6
|
+
const DEFAULT_CONFIG = Object.freeze({
|
|
7
|
+
serverUrl: '',
|
|
8
|
+
machineApiKey: '',
|
|
9
|
+
localApiPort: 19876,
|
|
10
|
+
localApiToken: '',
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
function normalizeText(value) {
|
|
14
|
+
return typeof value === 'string' ? value.trim() : '';
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function resolveLightconeHome(env = process.env) {
|
|
18
|
+
return path.resolve(normalizeText(env.LIGHTCONE_HOME) || path.join(os.homedir(), '.lightcone'));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function resolveConfigPath(env = process.env) {
|
|
22
|
+
return path.join(resolveLightconeHome(env), 'config.json');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function resolveLogsDir(env = process.env) {
|
|
26
|
+
return path.join(resolveLightconeHome(env), 'logs');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function resolveDaemonLogPath(env = process.env) {
|
|
30
|
+
return path.join(resolveLogsDir(env), 'daemon.log');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function resolveDaemonPidPath(env = process.env) {
|
|
34
|
+
return path.join(resolveLightconeHome(env), 'daemon.pid');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function ensureLightconeDirs(env = process.env) {
|
|
38
|
+
const home = resolveLightconeHome(env);
|
|
39
|
+
mkdirSync(home, { recursive: true });
|
|
40
|
+
mkdirSync(resolveLogsDir(env), { recursive: true });
|
|
41
|
+
mkdirSync(path.join(home, 'bin'), { recursive: true });
|
|
42
|
+
mkdirSync(path.join(home, 'chrome-profiles'), { recursive: true });
|
|
43
|
+
return home;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function readLocalConfig(env = process.env) {
|
|
47
|
+
const configPath = resolveConfigPath(env);
|
|
48
|
+
if (!existsSync(configPath)) return { ...DEFAULT_CONFIG };
|
|
49
|
+
try {
|
|
50
|
+
const parsed = JSON.parse(readFileSync(configPath, 'utf8'));
|
|
51
|
+
return {
|
|
52
|
+
...DEFAULT_CONFIG,
|
|
53
|
+
...(parsed && typeof parsed === 'object' ? parsed : {}),
|
|
54
|
+
serverUrl: normalizeText(parsed?.serverUrl ?? parsed?.server_url),
|
|
55
|
+
machineApiKey: normalizeText(parsed?.machineApiKey ?? parsed?.machine_api_key),
|
|
56
|
+
localApiPort: Number.parseInt(String(parsed?.localApiPort ?? parsed?.local_api_port ?? DEFAULT_CONFIG.localApiPort), 10) || DEFAULT_CONFIG.localApiPort,
|
|
57
|
+
localApiToken: normalizeText(parsed?.localApiToken ?? parsed?.local_api_token),
|
|
58
|
+
};
|
|
59
|
+
} catch (error) {
|
|
60
|
+
const wrapped = new Error(`local_config_invalid:${error.message}`);
|
|
61
|
+
wrapped.code = 'LOCAL_CONFIG_INVALID';
|
|
62
|
+
wrapped.configPath = configPath;
|
|
63
|
+
throw wrapped;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function writeLocalConfig(config, env = process.env) {
|
|
68
|
+
ensureLightconeDirs(env);
|
|
69
|
+
const configPath = resolveConfigPath(env);
|
|
70
|
+
const current = readLocalConfig(env);
|
|
71
|
+
const next = {
|
|
72
|
+
...current,
|
|
73
|
+
...config,
|
|
74
|
+
localApiToken: normalizeText(config?.localApiToken ?? current.localApiToken) || `lc_local_${randomBytes(24).toString('hex')}`,
|
|
75
|
+
updatedAt: new Date().toISOString(),
|
|
76
|
+
};
|
|
77
|
+
writeFileSync(configPath, `${JSON.stringify(next, null, 2)}\n`, 'utf8');
|
|
78
|
+
try { chmodSync(configPath, 0o600); } catch {}
|
|
79
|
+
return next;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function readDaemonPid(env = process.env) {
|
|
83
|
+
const pidPath = resolveDaemonPidPath(env);
|
|
84
|
+
if (!existsSync(pidPath)) return null;
|
|
85
|
+
const raw = normalizeText(readFileSync(pidPath, 'utf8'));
|
|
86
|
+
const pid = Number.parseInt(raw, 10);
|
|
87
|
+
return Number.isFinite(pid) && pid > 0 ? pid : null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function isProcessRunning(pid) {
|
|
91
|
+
if (!Number.isFinite(Number(pid)) || Number(pid) <= 0) return false;
|
|
92
|
+
try {
|
|
93
|
+
process.kill(Number(pid), 0);
|
|
94
|
+
return true;
|
|
95
|
+
} catch {
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function getDaemonStatus(env = process.env) {
|
|
101
|
+
const pid = readDaemonPid(env);
|
|
102
|
+
const logPath = resolveDaemonLogPath(env);
|
|
103
|
+
let logSizeBytes = 0;
|
|
104
|
+
try {
|
|
105
|
+
if (existsSync(logPath)) logSizeBytes = statSync(logPath).size;
|
|
106
|
+
} catch {}
|
|
107
|
+
return {
|
|
108
|
+
running: pid ? isProcessRunning(pid) : false,
|
|
109
|
+
pid,
|
|
110
|
+
pidPath: resolveDaemonPidPath(env),
|
|
111
|
+
logPath,
|
|
112
|
+
logSizeBytes,
|
|
113
|
+
configPath: resolveConfigPath(env),
|
|
114
|
+
home: resolveLightconeHome(env),
|
|
115
|
+
};
|
|
116
|
+
}
|
|
@@ -94,79 +94,79 @@ function assertNoV5Fields(seg, index) {
|
|
|
94
94
|
}
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
//
|
|
100
|
-
// the user repeatedly asked for ("从上往下滑动着介绍,到重点处停一下"). The
|
|
101
|
-
// V6 atom toolkit is fully capable of producing reading-flow output; the
|
|
102
|
-
// problem is that agents default to short-scroll + long-hold without an
|
|
103
|
-
// explicit constraint, so we enforce it here.
|
|
97
|
+
// Transition-mode lint — enforce the "explain block → smooth transition → explain block"
|
|
98
|
+
// pattern the user described:
|
|
99
|
+
// "先说一句话, 然后再往下滑, 介绍内容 1, 再往下滑, 停住介绍内容 2"
|
|
104
100
|
//
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
//
|
|
108
|
-
//
|
|
109
|
-
//
|
|
110
|
-
//
|
|
111
|
-
//
|
|
112
|
-
|
|
113
|
-
|
|
101
|
+
// Key insight: scroll_to is a TRANSITION between content blocks, not a
|
|
102
|
+
// narration vehicle. It can be short (~500-800ms) — speed doesn't matter,
|
|
103
|
+
// only smoothness. Long narration happens during hold, not during scroll.
|
|
104
|
+
//
|
|
105
|
+
// Rule (single rule): every non-opening segment MUST start with a scroll_to.
|
|
106
|
+
// This guarantees a visible transition from the previous segment's anchor
|
|
107
|
+
// to the new content block. Without this, an agent can string back-to-back
|
|
108
|
+
// hold-only segments and the viewer just sees jump cuts in audio with no
|
|
109
|
+
// page movement.
|
|
110
|
+
//
|
|
111
|
+
// What's NOT enforced anymore:
|
|
112
|
+
// - scroll_to duration_ms is not bounded — short transitions (500ms) and
|
|
113
|
+
// longer ones (2s+) are both fine. Smoothness comes from atomScrollTo's
|
|
114
|
+
// RAF-based programmatic implementation, not from duration.
|
|
115
|
+
// - hold duration_ms is not bounded — long holds (3-5s) are the normal
|
|
116
|
+
// case (this is where the agent narrates the current block).
|
|
114
117
|
|
|
115
118
|
function validateReadingFlow(operations, segmentIndex) {
|
|
116
|
-
// Opening hook segment
|
|
117
|
-
//
|
|
119
|
+
// Opening hook segment is exempt — first segment may legitimately be
|
|
120
|
+
// a fully static hero shot (e.g. "校招,实习岗位更新,速投" over a poster).
|
|
118
121
|
if (segmentIndex === 0) return;
|
|
119
122
|
|
|
120
123
|
const ops = Array.isArray(operations) ? operations : [];
|
|
121
124
|
if (ops.length === 0) return;
|
|
122
125
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
126
|
+
// The first op of a non-opening segment must be a scroll_to (the
|
|
127
|
+
// transition into this block's content). All-hold segments produce
|
|
128
|
+
// back-to-back jump cuts with no visible page movement, which the user
|
|
129
|
+
// has explicitly rejected.
|
|
130
|
+
const first = ops[0];
|
|
131
|
+
if (first?.atom !== 'scroll_to') {
|
|
127
132
|
const err = new Error(
|
|
128
|
-
`
|
|
129
|
-
+
|
|
130
|
-
+
|
|
131
|
-
+ '
|
|
132
|
-
+ '
|
|
133
|
-
+
|
|
133
|
+
`transition_required: segments[${segmentIndex}] must start with a scroll_to atom — `
|
|
134
|
+
+ 'this is the smooth transition from the previous block to this one. '
|
|
135
|
+
+ `Got first atom "${first?.atom ?? 'none'}". All-hold segments produce jump cuts. `
|
|
136
|
+
+ 'Fix: prepend a scroll_to(target_y=<new block top>, duration_ms=500~1000) before '
|
|
137
|
+
+ 'the hold. The scroll can be short (~600ms is fine); what matters is that the '
|
|
138
|
+
+ "page visibly slides — atomScrollTo's programmatic mode handles smoothness.",
|
|
134
139
|
);
|
|
135
|
-
err.code = '
|
|
140
|
+
err.code = 'TRANSITION_REQUIRED';
|
|
136
141
|
throw err;
|
|
137
142
|
}
|
|
143
|
+
}
|
|
138
144
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
);
|
|
153
|
-
err.code = 'READING_FLOW_VIOLATION';
|
|
154
|
-
throw err;
|
|
155
|
-
}
|
|
156
|
-
const prevScrollMs = Number(prev.duration_ms);
|
|
157
|
-
if (!Number.isFinite(prevScrollMs) || prevScrollMs < READING_FLOW_SLOW_SCROLL_MIN_MS) {
|
|
158
|
-
const err = new Error(
|
|
159
|
-
`reading_flow_violation: segments[${segmentIndex}].operations[${i}] is a long hold `
|
|
160
|
-
+ `(${holdMs}ms) following a fast scroll_to (${prevScrollMs}ms). This is the "跳页+长停" `
|
|
161
|
-
+ 'anti-pattern — viewers see a hard cut to a new position then a frozen frame. '
|
|
162
|
-
+ `Fix: extend the preceding scroll_to to duration_ms >= ${READING_FLOW_SLOW_SCROLL_MIN_MS}ms `
|
|
163
|
-
+ '(narrate WHILE you scroll), and shorten this hold to duration_ms <= 1500ms '
|
|
164
|
-
+ '(brief pause to stress the key point, then move on).',
|
|
165
|
-
);
|
|
166
|
-
err.code = 'READING_FLOW_VIOLATION';
|
|
167
|
-
throw err;
|
|
145
|
+
// 1 section ↔ 1 block — the V6 audio/visual sync contract. A section's
|
|
146
|
+
// scroll_to operations may reference at most one distinct block id: the
|
|
147
|
+
// section narrates that block while it sits framed. A section that scrolls
|
|
148
|
+
// across two blocks has no single "what is narrated" → "what is framed"
|
|
149
|
+
// mapping, which is the whole basis of staying in sync. Sections with no
|
|
150
|
+
// block reference (e.g. the opening lead-in drift on raw `y`) are unaffected.
|
|
151
|
+
// A tall block panned over a top-aligned + bottom-aligned scroll_to still
|
|
152
|
+
// references one block id, so it passes.
|
|
153
|
+
function validateSingleBlock(operations, segmentIndex) {
|
|
154
|
+
const ids = new Set();
|
|
155
|
+
for (const op of operations) {
|
|
156
|
+
if (op?.atom === 'scroll_to' && typeof op.block === 'string' && op.block.trim()) {
|
|
157
|
+
ids.add(op.block.trim());
|
|
168
158
|
}
|
|
169
159
|
}
|
|
160
|
+
if (ids.size > 1) {
|
|
161
|
+
const err = new Error(
|
|
162
|
+
`section_spans_multiple_blocks: segments[${segmentIndex}] references ${ids.size} different `
|
|
163
|
+
+ `blocks (${[...ids].join(', ')}) in one section. V6 contract: one narration segment ↔ one `
|
|
164
|
+
+ 'block — the segment narrates that block while it stays framed. Split this into one segment '
|
|
165
|
+
+ 'per block.',
|
|
166
|
+
);
|
|
167
|
+
err.code = 'SECTION_SPANS_MULTIPLE_BLOCKS';
|
|
168
|
+
throw err;
|
|
169
|
+
}
|
|
170
170
|
}
|
|
171
171
|
|
|
172
172
|
// Process operations[]: expand "fill" on the last hold, validate atom shape.
|
|
@@ -241,6 +241,7 @@ function processOperations(operations, audioDurationMs, segmentIndex) {
|
|
|
241
241
|
sum += n;
|
|
242
242
|
}
|
|
243
243
|
validateReadingFlow(expanded, segmentIndex);
|
|
244
|
+
validateSingleBlock(expanded, segmentIndex);
|
|
244
245
|
return { operations: expanded, durationSumMs: Math.round(sum) };
|
|
245
246
|
}
|
|
246
247
|
|
|
@@ -312,8 +313,13 @@ export async function runPlanVideoSegmentsTool({ segments } = {}) {
|
|
|
312
313
|
const perCard = Math.max(2, Math.ceil((totalDuration / numCards) * 2) / 2);
|
|
313
314
|
presentation = { per_card_duration: perCard };
|
|
314
315
|
} else {
|
|
315
|
-
|
|
316
|
-
|
|
316
|
+
// Scroll-style images (a tall image ffmpeg pans over) need a longer
|
|
317
|
+
// buffer — the eye follows motion slower than it reads a static frame.
|
|
318
|
+
// The scroll style lives on presentation.style, NOT visual_kind
|
|
319
|
+
// (visual_kind is only image / video / gif / carousel).
|
|
320
|
+
const isScroll = String(seg.presentation?.style ?? '') === 'scroll';
|
|
321
|
+
const duration = planDurationSec(audioDurationMs, isScroll ? 1.0 : 0.5);
|
|
322
|
+
presentation = { duration, ...(isScroll ? { style: 'scroll' } : {}) };
|
|
317
323
|
}
|
|
318
324
|
|
|
319
325
|
planned.push({
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// V6 record_url_narration daemon tool wrapper.
|
|
2
2
|
//
|
|
3
|
-
// Drives Chromium
|
|
3
|
+
// Drives headless Chromium + Playwright recordVideo to capture a silent mp4
|
|
4
4
|
// per section, then ffmpeg-transcodes + slices. The resulting silent mp4s
|
|
5
5
|
// feed compose_video_v2 as video-kind segments alongside narration audio.
|
|
6
6
|
//
|
|
@@ -1,126 +0,0 @@
|
|
|
1
|
-
const DEFAULT_BASE_DISPLAY = 99;
|
|
2
|
-
const DEFAULT_POOL_SIZE = 3;
|
|
3
|
-
|
|
4
|
-
function normalizePositiveInteger(value, fallback) {
|
|
5
|
-
const parsed = Number.parseInt(String(value ?? ''), 10);
|
|
6
|
-
if (!Number.isFinite(parsed) || parsed <= 0) return fallback;
|
|
7
|
-
return parsed;
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
function createAcquireTimeoutError(timeoutMs) {
|
|
11
|
-
const error = new Error(`display_pool_acquire_timeout:${timeoutMs}ms`);
|
|
12
|
-
error.code = 'DISPLAY_POOL_ACQUIRE_TIMEOUT';
|
|
13
|
-
return error;
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
function createInvalidPoolError() {
|
|
17
|
-
const error = new Error('display_pool_invalid_configuration');
|
|
18
|
-
error.code = 'DISPLAY_POOL_INVALID_CONFIGURATION';
|
|
19
|
-
return error;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
function createSlots({ baseDisplay, poolSize }) {
|
|
23
|
-
return Array.from({ length: poolSize }, (_unused, index) => {
|
|
24
|
-
const displayNumber = baseDisplay + index;
|
|
25
|
-
return {
|
|
26
|
-
displayNumber,
|
|
27
|
-
display: `:${displayNumber}`,
|
|
28
|
-
inUse: false,
|
|
29
|
-
};
|
|
30
|
-
});
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
function createLease(slot, releaseSlot) {
|
|
34
|
-
let released = false;
|
|
35
|
-
return Object.freeze({
|
|
36
|
-
display: slot.display,
|
|
37
|
-
displayNumber: slot.displayNumber,
|
|
38
|
-
release() {
|
|
39
|
-
if (released) return false;
|
|
40
|
-
released = true;
|
|
41
|
-
releaseSlot(slot);
|
|
42
|
-
return true;
|
|
43
|
-
},
|
|
44
|
-
});
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
export function createDisplayPool({
|
|
48
|
-
baseDisplay = DEFAULT_BASE_DISPLAY,
|
|
49
|
-
maxConcurrent = DEFAULT_POOL_SIZE,
|
|
50
|
-
} = {}) {
|
|
51
|
-
const normalizedBaseDisplay = normalizePositiveInteger(baseDisplay, DEFAULT_BASE_DISPLAY);
|
|
52
|
-
const normalizedPoolSize = normalizePositiveInteger(maxConcurrent, DEFAULT_POOL_SIZE);
|
|
53
|
-
if (!Number.isFinite(normalizedBaseDisplay) || !Number.isFinite(normalizedPoolSize)) {
|
|
54
|
-
throw createInvalidPoolError();
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
const slots = createSlots({
|
|
58
|
-
baseDisplay: normalizedBaseDisplay,
|
|
59
|
-
poolSize: normalizedPoolSize,
|
|
60
|
-
});
|
|
61
|
-
const waiters = [];
|
|
62
|
-
|
|
63
|
-
function reserveAvailableSlot() {
|
|
64
|
-
const slot = slots.find((candidate) => !candidate.inUse);
|
|
65
|
-
if (!slot) return null;
|
|
66
|
-
slot.inUse = true;
|
|
67
|
-
return slot;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
function dispatchWaiters() {
|
|
71
|
-
while (waiters.length > 0) {
|
|
72
|
-
const slot = reserveAvailableSlot();
|
|
73
|
-
if (!slot) return;
|
|
74
|
-
const waiter = waiters.shift();
|
|
75
|
-
if (!waiter) return;
|
|
76
|
-
if (waiter.timer) clearTimeout(waiter.timer);
|
|
77
|
-
waiter.resolve(createLease(slot, releaseSlot));
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
function releaseSlot(slot) {
|
|
82
|
-
slot.inUse = false;
|
|
83
|
-
dispatchWaiters();
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
async function acquireDisplay({ timeoutMs = null } = {}) {
|
|
87
|
-
const slot = reserveAvailableSlot();
|
|
88
|
-
if (slot) return createLease(slot, releaseSlot);
|
|
89
|
-
|
|
90
|
-
return await new Promise((resolve, reject) => {
|
|
91
|
-
const waiter = {
|
|
92
|
-
resolve,
|
|
93
|
-
reject,
|
|
94
|
-
timer: null,
|
|
95
|
-
};
|
|
96
|
-
if (Number.isFinite(Number(timeoutMs)) && Number(timeoutMs) > 0) {
|
|
97
|
-
const normalizedTimeoutMs = Math.floor(Number(timeoutMs));
|
|
98
|
-
waiter.timer = setTimeout(() => {
|
|
99
|
-
const waiterIndex = waiters.indexOf(waiter);
|
|
100
|
-
if (waiterIndex >= 0) waiters.splice(waiterIndex, 1);
|
|
101
|
-
reject(createAcquireTimeoutError(normalizedTimeoutMs));
|
|
102
|
-
}, normalizedTimeoutMs);
|
|
103
|
-
}
|
|
104
|
-
waiters.push(waiter);
|
|
105
|
-
});
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
function snapshot() {
|
|
109
|
-
return {
|
|
110
|
-
baseDisplay: normalizedBaseDisplay,
|
|
111
|
-
maxConcurrent: normalizedPoolSize,
|
|
112
|
-
waiting: waiters.length,
|
|
113
|
-
inUse: slots.filter(slot => slot.inUse).map(slot => slot.display),
|
|
114
|
-
available: slots.filter(slot => !slot.inUse).map(slot => slot.display),
|
|
115
|
-
};
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
return Object.freeze({
|
|
119
|
-
baseDisplay: normalizedBaseDisplay,
|
|
120
|
-
maxConcurrent: normalizedPoolSize,
|
|
121
|
-
acquireDisplay,
|
|
122
|
-
snapshot,
|
|
123
|
-
});
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
export const defaultDisplayPool = createDisplayPool();
|