@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.
@@ -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
- // Reading-flow lint — reject the "jump + long hold" anti-pattern that makes
98
- // recordings feel like a slideshow of screenshots instead of a person
99
- // scrolling through a page and pausing at key spots to explain. This is what
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
- // Rules:
106
- // - Each segment (except the opening hook, segment 0) MUST contain at least
107
- // one scroll_to with duration_ms >= 1500ms the "slow scroll while
108
- // narrating" beat.
109
- // - Any hold with duration_ms > 2000ms MUST be immediately preceded by a
110
- // scroll_to with duration_ms >= 1500ms long holds are only legal as
111
- // "I just slowly scrolled to a key spot, now I'm pausing on it".
112
- const READING_FLOW_SLOW_SCROLL_MIN_MS = 1500;
113
- const READING_FLOW_LONG_HOLD_THRESHOLD_MS = 2000;
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 may legitimately be a fully static hero shot
117
- // with no scroll (e.g. "校招,实习岗位更新,速投" over a poster).
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
- const hasSlowScroll = ops.some(
124
- op => op?.atom === 'scroll_to' && Number(op.duration_ms) >= READING_FLOW_SLOW_SCROLL_MIN_MS,
125
- );
126
- if (!hasSlowScroll) {
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
- `reading_flow_violation: segments[${segmentIndex}] has no slow scroll. `
129
- + `Reading-flow mode requires at least one scroll_to with duration_ms >= ${READING_FLOW_SLOW_SCROLL_MIN_MS}ms `
130
- + 'per non-opening segment this simulates a finger sliding through the page '
131
- + 'while narration plays, instead of jumping cut-style to a position. '
132
- + 'Fix: replace any "short scroll_to(duration_ms<1000) + long hold(>2000)" pair '
133
- + `with one "slow scroll_to(duration_ms=2000~3500)" + "short hold(duration_ms=800~1500)".`,
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 = 'READING_FLOW_VIOLATION';
140
+ err.code = 'TRANSITION_REQUIRED';
136
141
  throw err;
137
142
  }
143
+ }
138
144
 
139
- for (let i = 1; i < ops.length; i += 1) {
140
- const op = ops[i];
141
- if (op?.atom !== 'hold') continue;
142
- const holdMs = Number(op.duration_ms);
143
- if (!Number.isFinite(holdMs) || holdMs <= READING_FLOW_LONG_HOLD_THRESHOLD_MS) continue;
144
-
145
- const prev = ops[i - 1];
146
- if (prev?.atom !== 'scroll_to') {
147
- const err = new Error(
148
- `reading_flow_violation: segments[${segmentIndex}].operations[${i}] is a long hold `
149
- + `(${holdMs}ms) but its preceding atom is "${prev?.atom ?? 'none'}", not scroll_to. `
150
- + 'Long holds (>2000ms) must immediately follow a scroll_to — '
151
- + 'the natural reading pattern is "slow scroll to a key spot → pause to explain".',
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
- const duration = planDurationSec(audioDurationMs, kind === 'scroll' ? 1.0 : 0.5);
316
- presentation = { duration, ...(kind === 'scroll' ? { style: 'scroll' } : {}) };
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 on Xvfb + Playwright recordVideo to capture a silent mp4
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();