@lalalic/markcut 2.8.0 → 3.0.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.
Files changed (41) hide show
  1. package/B] +2 -0
  2. package/README.md +29 -0
  3. package/package.json +1 -1
  4. package/skills/markcut/SKILL.md +12 -45
  5. package/skills/markcut/docs/components.md +89 -0
  6. package/skills/markcut/docs/markdown-descriptive.md +17 -2
  7. package/skills/markcut/docs/sound-effects.md +45 -0
  8. package/src/components/Markdown.tsx +138 -24
  9. package/src/components/Mermaid.tsx +223 -22
  10. package/src/config.mjs +2 -2
  11. package/src/context/EventContext.tsx +3 -0
  12. package/src/descriptive/compiler.ts +68 -29
  13. package/src/descriptive/markdown.ts +20 -0
  14. package/src/player/browser.tsx +95 -5
  15. package/src/player/bundle/player.js +1078 -629
  16. package/src/player/components/EditControls.tsx +6 -3
  17. package/src/player/components/EditMessagePanel.tsx +96 -0
  18. package/src/player/components/HeaderBar.tsx +9 -11
  19. package/src/player/components/index.ts +1 -0
  20. package/src/player/pipeline.mjs +72 -21
  21. package/src/player/server-shared.mjs +4 -1
  22. package/src/player/server.mjs +202 -42
  23. package/src/render/cli.mjs +1 -1
  24. package/src/schema/index.ts +4 -1
  25. package/src/types/Component.tsx +27 -1
  26. package/src/types/Effect.tsx +13 -6
  27. package/src/types/Folder.tsx +1 -1
  28. package/src/types/Map.tsx +51 -1
  29. package/src/utils/index.ts +14 -2
  30. package/tests/fixtures/md/animate-diagrams.md +40 -0
  31. package/tests/fixtures/md/electricity-grow.md +130 -0
  32. package/tests/tmp/vision-1785081637127-video/videos/.normalized/segments/test-clip_0to3_seg_1100to3000.mp4 +0 -0
  33. package/tests/tmp/vision-1785081637127-video/videos/.normalized/test-clip_0to3.mp4 +0 -0
  34. package/tests/tmp/vision-1785081637127-video/videos/.normalized/test-clip_audio.mp3 +0 -0
  35. package/tests/tmp/vision-1785081637127-video/videos/metadata.json +9 -0
  36. package/tests/tmp/vision-1785081637127-video/videos/test-clip.mp4 +0 -0
  37. package/tests/tmp/vision-1785081637127-video/videos/test-clip.vtt +5 -0
  38. package/tests/tmp/vision-1784830584961/images/.normalized/test-photo_384.jpg +0 -0
  39. package/tests/tmp/vision-1784830584961/images/metadata.json +0 -8
  40. package/tests/tmp/vision-1784830584961/images/test-photo.png +0 -0
  41. package/tmp/frontmatter-test.ts +0 -21
@@ -17,11 +17,14 @@ interface EditControlsProps {
17
17
  currentTime?: number;
18
18
  /** Active scene name (sent with edit request for context) */
19
19
  activeScene?: string;
20
+ /** UI locale */
21
+ locale?: "en" | "zh";
20
22
  }
21
23
 
22
- export function EditControls({ onStatusChange, suppressReloadRef, currentTime, activeScene }: EditControlsProps) {
24
+ export function EditControls({ onStatusChange, suppressReloadRef, currentTime, activeScene, locale = "en" }: EditControlsProps) {
23
25
  const [busy, setBusy] = React.useState(false);
24
26
  const inputRef = React.useRef<HTMLInputElement>(null);
27
+ const isZh = locale === "zh";
25
28
 
26
29
  // ── Edit submit ──────────────────────────────────────────────────────
27
30
  const handleApplyEdit = React.useCallback(
@@ -75,12 +78,12 @@ export function EditControls({ onStatusChange, suppressReloadRef, currentTime, a
75
78
  <input
76
79
  ref={inputRef}
77
80
  id="edit-input"
78
- placeholder="What should change? e.g. make text bigger"
81
+ placeholder={isZh ? "想改什么?例如:把文字放大一些" : "What should change? e.g. make text bigger"}
79
82
  onKeyDown={handleKeyDown}
80
83
  />
81
84
  <button
82
85
  id="edit-btn"
83
- title="Apply edit"
86
+ title={isZh ? "应用修改" : "Apply edit"}
84
87
  disabled={busy}
85
88
  onClick={() => handleApplyEdit(inputRef.current?.value || "")}
86
89
  >
@@ -0,0 +1,96 @@
1
+ /**
2
+ * EditMessagePanel — displays edit session messages chronologically.
3
+ *
4
+ * Shows user edit requests and assistant responses in a scrollable panel
5
+ * that can be minimized to a compact bar. Replaces the old one-line
6
+ * edit-status in the header.
7
+ */
8
+ import * as React from "react";
9
+
10
+ export interface EditEntry {
11
+ id: number;
12
+ request: string;
13
+ /** Accumulated assistant response text */
14
+ progress: string;
15
+ status: "thinking" | "done" | "error";
16
+ error?: string;
17
+ }
18
+
19
+ interface EditMessagePanelProps {
20
+ entries: EditEntry[];
21
+ minimized: boolean;
22
+ locale?: "en" | "zh";
23
+ onToggleMinimize: () => void;
24
+ }
25
+
26
+ export function EditMessagePanel({ entries, minimized, locale = "en", onToggleMinimize }: EditMessagePanelProps) {
27
+ const listRef = React.useRef<HTMLDivElement>(null);
28
+ const isZh = locale === "zh";
29
+
30
+ // Auto-scroll to bottom when entries change
31
+ React.useEffect(() => {
32
+ if (listRef.current) {
33
+ listRef.current.scrollTop = listRef.current.scrollHeight;
34
+ }
35
+ }, [entries]);
36
+
37
+ // Minimized bar: show count + latest status
38
+ if (minimized) {
39
+ const latest = entries[entries.length - 1];
40
+ const label =
41
+ entries.length === 0
42
+ ? (isZh ? "✨ 编辑" : "✨ Edit")
43
+ : isZh
44
+ ? `✨ ${entries.length} 条编辑${latest?.status === "thinking" ? " ⏳" : ""}`
45
+ : `✨ ${entries.length} edit${entries.length > 1 ? "s" : ""}${latest?.status === "thinking" ? " ⏳" : ""}`;
46
+ return (
47
+ <button id="edit-message-bar" onClick={onToggleMinimize} title={isZh ? "显示编辑历史" : "Show edit history"}>
48
+ {label}
49
+ </button>
50
+ );
51
+ }
52
+
53
+ return (
54
+ <div id="edit-message-panel">
55
+ <div id="edit-message-header">
56
+ <span>{isZh ? "✨ 编辑历史" : "✨ Edit History"}</span>
57
+ <button
58
+ id="edit-message-minimize"
59
+ onClick={onToggleMinimize}
60
+ title={isZh ? "最小化" : "Minimize"}
61
+ aria-label={isZh ? "最小化编辑面板" : "Minimize edit panel"}
62
+ >
63
+ ─
64
+ </button>
65
+ </div>
66
+ <div id="edit-message-list" ref={listRef}>
67
+ {entries.length === 0 && (
68
+ <div className="edit-message-empty">{isZh ? "暂无编辑记录。请在下方输入修改请求。" : "No edits yet. Type a request below."}</div>
69
+ )}
70
+ {entries.map((entry) => (
71
+ <div key={entry.id} className={`edit-message-entry ${entry.status}`}>
72
+ <div className="edit-message-request">
73
+ <span className="edit-role">{isZh ? "你:" : "You:"}</span> {entry.request}
74
+ </div>
75
+ {entry.status === "thinking" && (
76
+ <div className="edit-message-thinking">
77
+ <span className="edit-role">{isZh ? "助手:" : "Assistant:"}</span> {isZh ? "思考中" : "Thinking"}
78
+ <span className="edit-dots"><span>.</span><span>.</span><span>.</span></span>
79
+ </div>
80
+ )}
81
+ {entry.status === "done" && entry.progress && (
82
+ <div className="edit-message-response">
83
+ <span className="edit-role">{isZh ? "助手:" : "Assistant:"}</span> {entry.progress}
84
+ </div>
85
+ )}
86
+ {entry.status === "error" && (
87
+ <div className="edit-message-error">
88
+ <span className="edit-role">{isZh ? "错误:" : "Error:"}</span> {entry.error || entry.progress || (isZh ? "编辑失败" : "Edit failed")}
89
+ </div>
90
+ )}
91
+ </div>
92
+ ))}
93
+ </div>
94
+ </div>
95
+ );
96
+ }
@@ -12,18 +12,19 @@ interface HeaderBarProps {
12
12
  mode: string;
13
13
  /** Label mode scene info text (e.g. "slide1 (1.2s)") */
14
14
  sceneInfo?: string;
15
- /** Edit mode status text (e.g. "✅ done", "⏳ editing...") */
16
- editStatus?: string;
17
- /** Edit mode: whether SSE is connected */
15
+ /** Whether SSE is connected */
18
16
  sseConnected?: boolean;
17
+ /** UI locale */
18
+ locale?: "en" | "zh";
19
19
  }
20
20
 
21
- export function HeaderBar({ mode, sceneInfo, editStatus, sseConnected }: HeaderBarProps) {
21
+ export function HeaderBar({ mode, sceneInfo, sseConnected, locale = "en" }: HeaderBarProps) {
22
+ const isZh = locale === "zh";
22
23
  const handleClose = React.useCallback(() => {
23
24
  navigator.sendBeacon("/api/shutdown", "{}");
24
25
  document.body.innerHTML =
25
- "<div style='display:flex;align-items:center;justify-content:center;height:100vh;background:#0a0a0a;color:#555;font-family:sans-serif;font-size:16px'>\u2B61 player closed \u2014 return to terminal</div>";
26
- }, []);
26
+ `<div style='display:flex;align-items:center;justify-content:center;height:100vh;background:#0a0a0a;color:#555;font-family:sans-serif;font-size:16px'>\u2B61 ${isZh ? "播放器已关闭,返回终端" : "player closed — return to terminal"}</div>`;
27
+ }, [isZh]);
27
28
 
28
29
  return (
29
30
  <div id="header">
@@ -32,13 +33,10 @@ export function HeaderBar({ mode, sceneInfo, editStatus, sseConnected }: HeaderB
32
33
  {mode === "label" && sceneInfo && (
33
34
  <span id="scene-info">{sceneInfo}</span>
34
35
  )}
35
- {mode === "edit" && editStatus && (
36
- <span id="edit-status">{editStatus}</span>
37
- )}
38
36
  {/* SSE indicator — shown in all modes */}
39
37
  <span
40
38
  id="sse-indicator"
41
- title={sseConnected ? "Connected — auto-reload ready" : "Disconnected"}
39
+ title={sseConnected ? (isZh ? "已连接,可自动刷新" : "Connected — auto-reload ready") : (isZh ? "连接断开" : "Disconnected")}
42
40
  style={{
43
41
  display: "inline-block",
44
42
  width: 8,
@@ -51,7 +49,7 @@ export function HeaderBar({ mode, sceneInfo, editStatus, sseConnected }: HeaderB
51
49
  </span>
52
50
  {/* Right: close button */}
53
51
  <div id="header-actions">
54
- <button id="close-btn" title="Close player and return to terminal" onClick={handleClose}>
52
+ <button id="close-btn" title={isZh ? "关闭播放器并返回终端" : "Close player and return to terminal"} onClick={handleClose}>
55
53
  ✕
56
54
  </button>
57
55
  </div>
@@ -1,5 +1,6 @@
1
1
  export { HeaderBar } from "./HeaderBar";
2
2
  export { EditControls } from "./EditControls";
3
+ export { EditMessagePanel } from "./EditMessagePanel";
3
4
  export { LabelControls } from "./LabelControls";
4
5
  export { SceneThumbnails } from "./SceneThumbnails";
5
6
  export { VariantBar } from "./VariantBar";
@@ -33,7 +33,13 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
33
33
 
34
34
  // src/utils/index.ts
35
35
  function uid() {
36
- return Math.random().toString(36).slice(2, 10);
36
+ const raw = Math.random().toString(36).slice(2, 10);
37
+ const first = raw[0];
38
+ if (/^[0-9]/.test(first)) {
39
+ const letter = String.fromCharCode(97 + Math.floor(Math.random() * 26));
40
+ return letter + raw;
41
+ }
42
+ return raw;
37
43
  }
38
44
  function walkDown(node2, visit, parent = null, depth = 0) {
39
45
  const keep = visit(node2, parent, depth);
@@ -207,7 +213,8 @@ function wrapWithEffects(node2, result, parentKind) {
207
213
  const absStart = innerStream.start ?? 0;
208
214
  const absEnd = innerStream.end ?? result.duration;
209
215
  const duration = absEnd - absStart;
210
- const resetStream = {
216
+ const isBgNoEnd = innerStream.isBackground && innerStream.end == null;
217
+ const resetStream = isBgNoEnd ? { ...innerStream } : {
211
218
  ...innerStream,
212
219
  start: 0,
213
220
  end: duration,
@@ -224,28 +231,33 @@ function wrapWithEffects(node2, result, parentKind) {
224
231
  id: uid(),
225
232
  type: "effect",
226
233
  animation: spec.animation,
234
+ animationDurationSeconds: spec.duration,
227
235
  durationInSeconds: spec.duration,
228
236
  animationTimingFunction: spec.animationTimingFunction,
229
237
  animationIterationCount: spec.animationIterationCount ?? 1,
230
238
  customKeyframes: spec.customKeyframes,
231
239
  children: [currentStream],
232
- start: effStart,
233
- end: effEnd,
234
- visible: true,
240
+ // For background inner nodes: propagate start/end as-is so parent
241
+ // back-propagation fills the correct scene duration. The effect's
242
+ // durationInSeconds (animation spec) controls animation timing.
243
+ start: isOutermost && isBgNoEnd ? innerStream.start : effStart,
244
+ end: isOutermost && isBgNoEnd ? void 0 : effEnd,
245
+ visible: innerStream.visible ?? true,
235
246
  ...pickOn(node2)
236
247
  };
237
248
  }
238
249
  return { stream: currentStream, duration: result.duration };
239
250
  }
240
251
  function compileLeaf(node2, ctx, parentKind) {
241
- const id = node2.id ?? uid();
252
+ const id = node2.id;
253
+ const hasExplicitId = node2.id != null;
242
254
  const hasOwnDuration = typeof node2.duration === "number" || typeof node2.endAt === "number";
243
255
  const isBgNoOwnTiming = node2.isBackground && !hasOwnDuration;
244
256
  const start = isBgNoOwnTiming ? typeof node2.start === "number" ? node2.start : void 0 : parentKind === "parallel" ? Math.max(0, node2.start ?? 0) : 0;
245
257
  const duration = isBgNoOwnTiming ? void 0 : deriveLeafDuration(node2, ctx);
246
258
  const end = duration != null ? start + duration : void 0;
247
259
  const base = {
248
- id,
260
+ ...id ? { id } : {},
249
261
  style: node2.style,
250
262
  visible: node2.visible ?? true,
251
263
  isBackground: node2.isBackground,
@@ -305,8 +317,7 @@ function compileLeaf(node2, ctx, parentKind) {
305
317
  const bindings = {};
306
318
  for (const key of Object.keys(node2)) {
307
319
  if (!KNOWN_COMPONENT_KEYS.has(key)) {
308
- const val = node2[key];
309
- if (typeof val === "string") bindings[key] = val;
320
+ bindings[key] = node2[key];
310
321
  }
311
322
  }
312
323
  const stream = {
@@ -338,6 +349,8 @@ function compileLeaf(node2, ctx, parentKind) {
338
349
  zoom: node2.zoom ?? 10,
339
350
  center: node2.center,
340
351
  mapType: node2.mapType ?? "roadmap",
352
+ language: node2.language,
353
+ region: node2.region,
341
354
  travelMode: node2.travelMode ?? "DRIVING",
342
355
  routeMarker: node2.routeMarker ?? "\u{1F697}",
343
356
  googleMapsApiKey: ctx.googleMapsApiKey
@@ -405,13 +418,23 @@ function compileScene(node2, ctx, parentKind) {
405
418
  ];
406
419
  const sceneContentDuration = sceneKind === "parallel" ? aggregateDuration(compiledChildren, "parallel") : aggregateDuration(compiledChildren, sceneKind, resolved.time);
407
420
  const localDuration = Math.max(node2.duration ?? 0, sceneContentDuration);
408
- for (const c of compiledChildren) {
409
- if (c.stream.isBackground && c.stream.end == null) {
410
- c.stream.end = localDuration;
411
- c.stream.durationInSeconds = localDuration;
412
- if (c.stream.start == null) c.stream.start = 0;
421
+ function backpropagate(stream2, dur) {
422
+ if (stream2.end == null) {
423
+ stream2.end = dur;
424
+ if (stream2.durationInSeconds == null) {
425
+ stream2.durationInSeconds = dur;
426
+ }
427
+ if (stream2.start == null) stream2.start = 0;
428
+ }
429
+ if (stream2.type === "effect" && Array.isArray(stream2.children)) {
430
+ for (const child of stream2.children) {
431
+ backpropagate(child, dur);
432
+ }
413
433
  }
414
434
  }
435
+ for (const c of compiledChildren) {
436
+ backpropagate(c.stream, localDuration);
437
+ }
415
438
  const start = parentKind === "parallel" ? Math.max(0, node2.start ?? 0) : 0;
416
439
  const end = start + localDuration;
417
440
  const stream = {
@@ -505,13 +528,23 @@ function compileContainer(node2, ctx, parentKind) {
505
528
  const resolved = node2.type === "transitionSeries" ? resolveTransition(node2.transition, node2.transitionTime) : { name: "fade", time: 0.5 };
506
529
  const children = compileChildren(node2.children, ctx, node2.type);
507
530
  const duration = aggregateDuration(children, node2.type, resolved.time);
508
- for (const c of children) {
509
- if (c.stream.isBackground && c.stream.end == null) {
510
- c.stream.end = duration;
511
- c.stream.durationInSeconds = duration;
512
- if (c.stream.start == null) c.stream.start = 0;
531
+ function backpropagate(stream2, dur) {
532
+ if (stream2.end == null) {
533
+ stream2.end = dur;
534
+ if (stream2.durationInSeconds == null) {
535
+ stream2.durationInSeconds = dur;
536
+ }
537
+ if (stream2.start == null) stream2.start = 0;
538
+ }
539
+ if (stream2.type === "effect" && Array.isArray(stream2.children)) {
540
+ for (const child of stream2.children) {
541
+ backpropagate(child, dur);
542
+ }
513
543
  }
514
544
  }
545
+ for (const c of children) {
546
+ backpropagate(c.stream, duration);
547
+ }
515
548
  const stream = {
516
549
  id,
517
550
  type: "folder",
@@ -1062,8 +1095,8 @@ var MAX_VIDEO_DURATION = Number(process.env.MARKCUT_MAX_VIDEO_DURATION) || 60;
1062
1095
  var MAX_VIDEO_DIMENSION = Number(process.env.MARKCUT_MAX_VIDEO_DIMENSION) || 360;
1063
1096
  var GOOGLE_MAPS_API_KEY = process.env.GOOGLE_MAPS_API_KEY || "";
1064
1097
  var DEFAULT_VTT_SAMPLE_INTERVAL = Number(process.env.MARKCUT_VTT_SAMPLE_INTERVAL) || 5;
1065
- var DEFAULT_STT_CLI = args.cliOverrides.stt || process.env.MARKCUT_STT_CLI || 'uvx --from openai-whisper whisper "{input}" --output_format vtt --output_dir "{output}"';
1066
- var DEFAULT_TTS_CLI = args.cliOverrides.tts || process.env.MARKCUT_TTS_CLI || 'uvx edge-tts --voice "en-US-GuyNeural" --text "{input}" --write-media "{output}"';
1098
+ var DEFAULT_STT_CLI = args.cliOverrides.stt || process.env.MARKCUT_STT_CLI || 'uvx --from openai-whisper whisper "{input}" --output_format vtt --output_dir "{output}" --word_timestamps True --max_line_count 1 --max_line_width 14';
1099
+ var DEFAULT_TTS_CLI = args.cliOverrides.tts || process.env.MARKCUT_TTS_CLI || 'uvx edge-tts --voice "zh-CN-YunxiNeural" --text "{input}" --write-media "{output}"';
1067
1100
  var DEFAULT_AGENT_CLI = args.cliOverrides.agent || process.env.MARKCUT_AGENT_CLI || "npx pi -p {prompt}";
1068
1101
  var DEFAULT_EDIT_CLI = args.cliOverrides.editCli || process.env.MARKCUT_EDIT_CLI || "npx pi --session-id {sessionid} --system-prompt {systemprompt} -p {prompt}";
1069
1102
  var DEFAULT_TTI_CLI = args.cliOverrides.tti || process.env.MARKCUT_TTI_CLI || 'uvx --from mflux mflux-generate-flux2 --model flux2-klein-4b --steps 2 --prompt "{input}" --output "{output}" --seed {seed}';
@@ -10120,6 +10153,7 @@ var TYPE_TOKENS = {
10120
10153
  video: "video",
10121
10154
  audio: "audio",
10122
10155
  component: "component",
10156
+ event: "event",
10123
10157
  rhythm: "rhythm",
10124
10158
  include: "include",
10125
10159
  map: "map",
@@ -10339,6 +10373,21 @@ function parseNodeLine(content3, lineNum) {
10339
10373
  preserveVariantAttrs(node2, attrs);
10340
10374
  return node2;
10341
10375
  }
10376
+ case "event": {
10377
+ const node2 = {
10378
+ type: "component",
10379
+ id: attrs.id,
10380
+ jsx: "",
10381
+ duration: attrs.duration,
10382
+ start: attrs.start,
10383
+ instruction: attrs.instruction,
10384
+ style: attrs.style,
10385
+ effects: attrs.effects,
10386
+ on: attrs.on
10387
+ };
10388
+ preserveVariantAttrs(node2, attrs);
10389
+ return node2;
10390
+ }
10342
10391
  case "rhythm": {
10343
10392
  const src = firstPositional ?? attrs.src;
10344
10393
  if (!src) throw new DslError("rhythm requires src", ctx);
@@ -10413,6 +10462,8 @@ function parseNodeLine(content3, lineNum) {
10413
10462
  zoom: attrs.zoom,
10414
10463
  center: attrs.center,
10415
10464
  mapType: attrs.mapType,
10465
+ language: attrs.language ?? attrs.lang,
10466
+ region: attrs.region,
10416
10467
  instruction: attrs.instruction,
10417
10468
  visible: attrs.visible,
10418
10469
  isBackground: attrs.isBackground,
@@ -128,7 +128,10 @@ export function serveFile(req, res, filePath) {
128
128
  const ext = extname(filePath).toLowerCase();
129
129
  const mime = MIME[ext] || "application/octet-stream";
130
130
  const fileSize = statSync(filePath).size;
131
- const cacheControl = (ext === ".js" || ext === ".html") ? "no-cache" : "public, max-age=3600";
131
+ // Dynamic authoring assets (vtt/json/html/js) must not be cached,
132
+ // otherwise subtitle edits can appear stale after refresh.
133
+ const noStoreExt = new Set([".js", ".html", ".json", ".vtt"]);
134
+ const cacheControl = noStoreExt.has(ext) ? "no-store" : "public, max-age=3600";
132
135
  const range = req.headers.range;
133
136
 
134
137
  if (range) {