@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
@@ -4,7 +4,7 @@
4
4
  *
5
5
  * Modes:
6
6
  * --label – playback with label input overlay; labels map to media timestamps
7
- * --edit – auto-reload when the JSON file changes (agent edits file, player refreshes)
7
+ * --edit – edit input mode with AI-assisted changes
8
8
  *
9
9
  * Usage:
10
10
  * node src/player/server.mjs <video.json> [--label] [--edit] [--port 3001]
@@ -17,7 +17,7 @@ import { fileURLToPath } from "node:url";
17
17
  import { isDescriptiveRoot, resolveAndCompile, resolveAndCompileMarkdown, parseImportsBlock, extractDependencySpecs } from "./pipeline.mjs";
18
18
  import { bundleFromEntries } from "./bundler.mjs";
19
19
  import { extractScenes, MIME, serveFile, handleShutdown } from "./server-shared.mjs";
20
- import { DEFAULT_EDIT_CLI } from "../config.mjs";
20
+ import { DEFAULT_EDIT_CLI, DEFAULT_STT_CLI, GOOGLE_MAPS_API_KEY } from "../config.mjs";
21
21
 
22
22
 
23
23
  const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -79,6 +79,14 @@ const VARIANT_CONFIGS = parseVariantConfigs();
79
79
  const sseClients = new Set();
80
80
  let shutdownTimer = null;
81
81
 
82
+ /** Push a JSON event to all connected SSE clients. */
83
+ function ssePush(data) {
84
+ const text = `data: ${JSON.stringify(data)}\n\n`;
85
+ for (const client of sseClients) {
86
+ try { client.write(text); } catch { sseClients.delete(client); }
87
+ }
88
+ }
89
+
82
90
  // ─── Label store for label mode ───────────────────────────────────────────
83
91
  let labels = [];
84
92
 
@@ -128,16 +136,24 @@ function handleAgentLine(obj) {
128
136
  const t = obj.type;
129
137
  if (t === "message_end") {
130
138
  const txt = extractAssistantText(obj.message);
131
- if (txt) agentTurnText += (agentTurnText ? "\n" : "") + txt;
139
+ if (txt) {
140
+ agentTurnText += (agentTurnText ? "\n" : "") + txt;
141
+ // Push incremental assistant text to SSE clients
142
+ ssePush({ type: "edit:progress", text: agentTurnText });
143
+ }
132
144
  return;
133
145
  }
134
146
  if (t === "agent_settled" && pendingEditResolver) {
147
+ ssePush({ type: "edit:done", summary: agentTurnText.substring(0, 120) });
135
148
  finishAgentTurn({ ok: true });
136
149
  return;
137
150
  }
138
151
  if (t === "response" && obj.command === "prompt" && pendingEditResolver) {
139
152
  // Preflight result: success means "accepted, events incoming"; failure aborts.
140
- if (obj.success === false) finishAgentTurn({ ok: false, error: obj.error || "prompt rejected" });
153
+ if (obj.success === false) {
154
+ ssePush({ type: "edit:error", error: obj.error || "prompt rejected" });
155
+ finishAgentTurn({ ok: false, error: obj.error || "prompt rejected" });
156
+ }
141
157
  return;
142
158
  }
143
159
  if (t === "extension_error" && obj.error) {
@@ -447,11 +463,14 @@ async function compileVariant(config, parsed, raw) {
447
463
  scriptOutputDir: TTS_OUTPUT_DIR,
448
464
  mediaOutputDir: MEDIA_OUTPUT_DIR,
449
465
  includeOutputDir: INCLUDE_CACHE_DIR,
466
+ sttCli: DEFAULT_STT_CLI,
450
467
  subtitleOutputDir: subtitleDir,
451
468
  storyboard: MODE_STORYBOARD,
452
469
  variants: config.chain.length > 0 ? config.chain : undefined,
453
470
  });
454
- compiled = compileDescriptiveRoot(resolved);
471
+ compiled = compileDescriptiveRoot(resolved, {
472
+ googleMapsApiKey: GOOGLE_MAPS_API_KEY || "",
473
+ });
455
474
  } else {
456
475
  const parsedJson = JSON.parse(raw);
457
476
  const root = parsedJson.root || parsedJson;
@@ -471,6 +490,7 @@ async function compileVariant(config, parsed, raw) {
471
490
  scriptOutputDir: TTS_OUTPUT_DIR,
472
491
  mediaOutputDir: MEDIA_OUTPUT_DIR,
473
492
  includeOutputDir: INCLUDE_CACHE_DIR,
493
+ sttCli: DEFAULT_STT_CLI,
474
494
  subtitleOutputDir: subtitleDir,
475
495
  storyboard: MODE_STORYBOARD,
476
496
  variants: config.chain.length > 0 ? config.chain : undefined,
@@ -667,40 +687,36 @@ function getScenes(label) {
667
687
  // Will be awaited before announcing "Player ready"
668
688
  const initScenesPromise = compileAndExtractScenes();
669
689
 
670
- // ─── Watch file for changes (--edit mode) ───────────────────────────────
671
- if (MODE_EDIT) {
672
- let lastContent = readFileSync(VIDEO_JSON, "utf-8");
673
- watchFile(VIDEO_JSON, { interval: 1000 }, async (curr, prev) => {
674
- if (curr.mtimeMs === prev.mtimeMs) return;
675
- if (pipelineRunning) return;
676
- const newContent = readFileSync(VIDEO_JSON, "utf-8");
677
- if (newContent === lastContent) return;
678
- lastContent = newContent;
679
- pipelineRunning = true;
680
- console.log(` 📁 ${VIDEO_JSON} changed, re-running all variants...`);
690
+ // ─── Watch source file for changes (all modes) ──────────────────────────
691
+ let lastContent = readFileSync(VIDEO_JSON, "utf-8");
692
+ watchFile(VIDEO_JSON, { interval: 1000 }, async (curr, prev) => {
693
+ if (curr.mtimeMs === prev.mtimeMs) return;
694
+ if (pipelineRunning) return;
695
+ const newContent = readFileSync(VIDEO_JSON, "utf-8");
696
+ if (newContent === lastContent) return;
697
+ lastContent = newContent;
698
+ pipelineRunning = true;
699
+ console.log(` 📁 ${VIDEO_JSON} changed, re-running all variants...`);
681
700
 
682
- try {
683
- compiledRootCache.clear();
684
- scenesCache.clear();
685
- await compileAllVariants();
686
-
687
- for (const config of VARIANT_CONFIGS) {
688
- try {
689
- const root = await loadCompiledRoot(config.label);
690
- scenesCache.set(config.label, extractScenes(root));
691
- } catch {}
692
- }
701
+ try {
702
+ compiledRootCache.clear();
703
+ scenesCache.clear();
704
+ await compileAllVariants();
693
705
 
694
- for (const client of sseClients) {
695
- client.write("data: " + JSON.stringify({ type: "reload" }) + "\n\n");
696
- }
697
- } catch (e) {
698
- console.error(" ⚠️ Failed to re-process after change:", e.message);
699
- } finally {
700
- pipelineRunning = false;
706
+ for (const config of VARIANT_CONFIGS) {
707
+ try {
708
+ const root = await loadCompiledRoot(config.label);
709
+ scenesCache.set(config.label, extractScenes(root));
710
+ } catch {}
701
711
  }
702
- });
703
- }
712
+
713
+ ssePush({ type: "reload" });
714
+ } catch (e) {
715
+ console.error(" ⚠️ Failed to re-process after change:", e.message);
716
+ } finally {
717
+ pipelineRunning = false;
718
+ }
719
+ });
704
720
  // ─── MIME imported from ./server-shared.mjs ──────────────────────────────
705
721
 
706
722
  // ─── Variant detection from URL path ─────────────────────────────────────
@@ -762,7 +778,7 @@ function getHtml(variantLabel) {
762
778
  html, body { width: 100%; height: 100%; overflow: hidden; background: #0a0a0a; }
763
779
  body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; display: flex; flex-direction: column; align-items: center; }
764
780
  #header { display: flex; align-items: center; justify-content: flex-end; width: 100%; max-width: 500px; padding: 8px 12px; flex-shrink: 0; gap: 8px; }
765
- #header-status, #edit-status { font-size: 11px; color: rgba(255,255,255,.4); flex: 1; text-align: left; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
781
+ #header-status { font-size: 11px; color: rgba(255,255,255,.4); flex: 1; text-align: left; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
766
782
  #scene-info { font-size: 11px; color: rgba(255,255,255,.4); flex: 1; text-align: left; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
767
783
  #header-actions { display: flex; gap: 6px; align-items: center; flex-shrink: 0; }
768
784
  #close-btn { width: 22px; height: 22px; border-radius: 50%; border: 1px solid rgba(255,255,255,.15); background: rgba(0,0,0,.3); color: rgba(255,255,255,.4); font-size: 10px; cursor: pointer; display: flex; align-items: center; justify-content: center; transition: all .15s; }
@@ -771,7 +787,7 @@ function getHtml(variantLabel) {
771
787
  .variant-link { font-size: 11px; padding: 3px 10px; border-radius: 12px; background: rgba(255,255,255,.06); color: rgba(255,255,255,.4); text-decoration: none; white-space: nowrap; transition: all .15s; }
772
788
  .variant-link:hover { background: rgba(255,255,255,.12); color: rgba(255,255,255,.7); }
773
789
  .variant-link.active { background: rgba(74,158,255,.2); color: #4a9eff; }
774
- #player-frame { flex: 1; width: 100%; max-width: 480px; min-height: 0; border-radius: 16px; overflow: hidden; border: 1px solid rgba(255,255,255,.08); background: #000; box-shadow: 0 4px 40px rgba(0,0,0,.6); margin: 0 12px; }
790
+ #player-frame { flex: 1; width: 100%; max-width: 480px; min-height: 0; border-radius: 16px; overflow: hidden; border: 1px solid rgba(255,255,255,.08); background: #000; box-shadow: 0 4px 40px rgba(0,0,0,.6); margin: 0 12px; position: relative; }
775
791
  #root { width: 100%; height: 100%; }
776
792
  #reload-toast { position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); background: rgba(74,158,255,.9); color: #fff; padding: 12px 24px; border-radius: 10px; font-size: 14px; font-weight: 600; opacity: 0; transition: opacity .3s; pointer-events: none; z-index: 200; backdrop-filter: blur(8px); }
777
793
  #reload-toast.show { opacity: 1; }
@@ -805,6 +821,136 @@ function getHtml(variantLabel) {
805
821
  .scene-pill { flex-shrink: 0; padding: 4px 12px; border-radius: 12px; cursor: pointer; border: 1px solid transparent; transition: all .15s; background: rgba(255,255,255,.06); color: rgba(255,255,255,.45); font-size: 11px; white-space: nowrap; }
806
822
  .scene-pill:hover { border-color: rgba(74,158,255,.4); color: rgba(255,255,255,.7); }
807
823
  .scene-pill.active { background: rgba(74,158,255,.2); border-color: #4a9eff; color: #4a9eff; }
824
+
825
+ .subtitle-overlay>*{width:100%}
826
+
827
+ /* ── Edit message panel ───────────────────────────────────────────── */
828
+ #edit-message-overlay {
829
+ position: absolute;
830
+ left: 8px;
831
+ right: 8px;
832
+ top: 8px;
833
+ z-index: 30;
834
+ pointer-events: none;
835
+ }
836
+ #edit-message-panel {
837
+ width: 100%;
838
+ max-width: 100%;
839
+ flex-shrink: 0;
840
+ background: rgba(12,12,12,.78);
841
+ border: 1px solid rgba(255,255,255,.08);
842
+ border-radius: 10px;
843
+ margin: 0;
844
+ overflow: hidden;
845
+ backdrop-filter: blur(8px);
846
+ pointer-events: auto;
847
+ }
848
+ #edit-message-header {
849
+ display: flex;
850
+ align-items: center;
851
+ justify-content: space-between;
852
+ padding: 6px 10px;
853
+ font-size: 11px;
854
+ color: rgba(255,255,255,.5);
855
+ border-bottom: 1px solid rgba(255,255,255,.06);
856
+ }
857
+ #edit-message-minimize {
858
+ width: 20px;
859
+ height: 20px;
860
+ padding: 0;
861
+ background: rgba(255,255,255,.06);
862
+ color: rgba(255,255,255,.4);
863
+ border: 1px solid rgba(255,255,255,.1);
864
+ border-radius: 4px;
865
+ cursor: pointer;
866
+ font-size: 10px;
867
+ display: flex;
868
+ align-items: center;
869
+ justify-content: center;
870
+ transition: all .15s;
871
+ }
872
+ #edit-message-minimize:hover {
873
+ background: rgba(74,158,255,.2);
874
+ border-color: rgba(74,158,255,.4);
875
+ color: #4a9eff;
876
+ }
877
+ #edit-message-list {
878
+ max-height: 150px;
879
+ overflow-y: auto;
880
+ padding: 6px 10px;
881
+ display: flex;
882
+ flex-direction: column;
883
+ gap: 8px;
884
+ scrollbar-width: thin;
885
+ }
886
+ #edit-message-list::-webkit-scrollbar { width: 4px; }
887
+ #edit-message-list::-webkit-scrollbar-thumb { background: rgba(255,255,255,.15); border-radius: 2px; }
888
+ .edit-message-empty {
889
+ font-size: 11px;
890
+ color: rgba(255,255,255,.25);
891
+ padding: 8px 0;
892
+ text-align: center;
893
+ }
894
+ .edit-message-entry {
895
+ font-size: 11px;
896
+ line-height: 1.5;
897
+ padding: 4px 6px;
898
+ border-radius: 6px;
899
+ background: rgba(255,255,255,.03);
900
+ }
901
+ .edit-message-entry.error {
902
+ background: rgba(255,60,60,.08);
903
+ }
904
+ .edit-role {
905
+ font-weight: 600;
906
+ color: rgba(255,255,255,.5);
907
+ margin-right: 4px;
908
+ }
909
+ .edit-message-request {
910
+ color: rgba(255,255,255,.6);
911
+ }
912
+ .edit-message-thinking {
913
+ color: rgba(74,158,255,.7);
914
+ }
915
+ .edit-dots span {
916
+ animation: editDotPulse 1.4s infinite;
917
+ opacity: 0;
918
+ }
919
+ .edit-dots span:nth-child(1) { animation-delay: 0s; }
920
+ .edit-dots span:nth-child(2) { animation-delay: 0.2s; }
921
+ .edit-dots span:nth-child(3) { animation-delay: 0.4s; }
922
+ @keyframes editDotPulse {
923
+ 0% { opacity: 0; }
924
+ 50% { opacity: 1; }
925
+ 100% { opacity: 0; }
926
+ }
927
+ .edit-message-response {
928
+ color: rgba(74,222,128,.8);
929
+ white-space: pre-wrap;
930
+ word-break: break-word;
931
+ }
932
+ .edit-message-error {
933
+ color: rgba(255,100,100,.8);
934
+ }
935
+ #edit-message-bar {
936
+ display: block;
937
+ width: auto;
938
+ padding: 4px 12px;
939
+ margin: 4px 12px;
940
+ border-radius: 14px;
941
+ border: 1px solid rgba(255,255,255,.1);
942
+ background: rgba(74,158,255,.12);
943
+ color: rgba(74,158,255,.8);
944
+ font-size: 11px;
945
+ cursor: pointer;
946
+ transition: all .15s;
947
+ flex-shrink: 0;
948
+ white-space: nowrap;
949
+ }
950
+ #edit-message-bar:hover {
951
+ background: rgba(74,158,255,.2);
952
+ border-color: rgba(74,158,255,.4);
953
+ }
808
954
  </style>
809
955
  </head>
810
956
  <body>
@@ -963,6 +1109,9 @@ Edit request: ${text}`;
963
1109
 
964
1110
  console.log(` 🤖 edit: ${text} (${currentTime !== undefined ? currentTime.toFixed(1) + "s" : ""} ${activeScene || ""})`);
965
1111
 
1112
+ // Push start event to SSE clients for real-time UI feedback
1113
+ ssePush({ type: "edit:start", request: text });
1114
+
966
1115
  // Send to the persistent rpc agent (one cold start; conversation kept in memory)
967
1116
  const result = await sendToAgent(userPrompt);
968
1117
 
@@ -988,10 +1137,12 @@ Edit request: ${text}`;
988
1137
  } else {
989
1138
  const msg = result.error || "failed";
990
1139
  console.error(` ❌ edit ${msg}: ${(result.output || "").trim().substring(0, 100)}`);
1140
+ ssePush({ type: "edit:error", error: msg });
991
1141
  res.writeHead(500, { "Content-Type": "application/json" });
992
1142
  res.end(JSON.stringify({ error: msg, output: (result.output || "").trim().substring(0, 200) }));
993
1143
  }
994
1144
  } catch (e) {
1145
+ ssePush({ type: "edit:error", error: e.message });
995
1146
  res.writeHead(500, { "Content-Type": "application/json" });
996
1147
  res.end(JSON.stringify({ error: e.message }));
997
1148
  }
@@ -1032,7 +1183,10 @@ Edit request: ${text}`;
1032
1183
  try {
1033
1184
  const root = await loadCompiledRoot(variantLabel);
1034
1185
  const rootOut = resolveAssetPaths(root);
1035
- res.writeHead(200, { "Content-Type": "application/json" });
1186
+ res.writeHead(200, {
1187
+ "Content-Type": "application/json",
1188
+ "Cache-Control": "no-store",
1189
+ });
1036
1190
  res.end(JSON.stringify(rootOut));
1037
1191
  } catch (e) {
1038
1192
  res.writeHead(500, { "Content-Type": "application/json" });
@@ -1044,7 +1198,10 @@ Edit request: ${text}`;
1044
1198
  // API: Get scenes with media info for a specific variant
1045
1199
  if (path === "/api/scenes") {
1046
1200
  const { scenes, totalDuration } = getScenes(variantLabel);
1047
- res.writeHead(200, { "Content-Type": "application/json" });
1201
+ res.writeHead(200, {
1202
+ "Content-Type": "application/json",
1203
+ "Cache-Control": "no-store",
1204
+ });
1048
1205
  res.end(JSON.stringify(scenes));
1049
1206
  return;
1050
1207
  }
@@ -1052,7 +1209,10 @@ Edit request: ${text}`;
1052
1209
  // API: Get current video info for a specific variant
1053
1210
  if (path === "/api/video-info") {
1054
1211
  const { scenes, totalDuration } = getScenes(variantLabel);
1055
- res.writeHead(200, { "Content-Type": "application/json" });
1212
+ res.writeHead(200, {
1213
+ "Content-Type": "application/json",
1214
+ "Cache-Control": "no-store",
1215
+ });
1056
1216
  res.end(JSON.stringify({
1057
1217
  scenes,
1058
1218
  totalDuration,
@@ -1115,6 +1275,6 @@ server.listen(PORT, async () => {
1115
1275
  console.log(` ${config.label}: ${url}`);
1116
1276
  }
1117
1277
  }
1118
- if (MODE_EDIT) console.log(` Watching: ${VIDEO_JSON.split("/").pop()}`);
1278
+ console.log(` Watching: ${VIDEO_JSON.split("/").pop()}`);
1119
1279
  console.log("");
1120
1280
  });
@@ -496,7 +496,7 @@ function hasScript(root) {
496
496
 
497
497
  // Compile to validate
498
498
  const compiled = compileDescriptiveRoot(descriptive);
499
- emitSuccess(`Valid. Duration: ~${compiled.durationInSeconds ?? "?"}s, Scenes: ${compiled.children?.length ?? 0}`);
499
+ emitSuccess(`Valid`);
500
500
  process.exit(0);
501
501
 
502
502
  } catch (err) {
@@ -133,7 +133,7 @@ export type Image = z.infer<typeof image>;
133
133
  export const component = base.extend({
134
134
  type: z.literal("component").default("component"),
135
135
  jsx: z.string().describe("usage JSX expression compiled at runtime; tag names resolved from imports"),
136
- data: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])).optional().describe("extra variables (e.g. from ~~~md source code fences) available in JSX scope"),
136
+ data: z.record(z.string(), z.unknown()).optional().describe("extra variables (e.g. from ~~~md source code fences) available in JSX scope"),
137
137
  });
138
138
  export type Component = z.infer<typeof component>;
139
139
 
@@ -143,6 +143,7 @@ export type Component = z.infer<typeof component>;
143
143
  export const effect = base.extend({
144
144
  type: z.literal("effect").default("effect"),
145
145
  animation: z.string().optional().describe("builtin keyframe name or 'custom'"),
146
+ animationDurationSeconds: z.number().optional().describe("animation duration (separate from wrapper durationInSeconds which getDurationInSeconds may overwrite)"),
146
147
  animationTimingFunction: z
147
148
  .enum(["linear", "ease", "ease-in", "ease-out", "ease-in-out"])
148
149
  .optional(),
@@ -228,6 +229,8 @@ export const mapStream = base.extend({
228
229
  zoom: z.number().default(10),
229
230
  center: z.object({ lat: z.number(), lng: z.number() }).optional().describe("map view center (defaults to first waypoint)"),
230
231
  mapType: z.enum(["roadmap", "satellite", "hybrid", "terrain"]).default("roadmap").describe("Google Maps style"),
232
+ language: z.string().optional().describe("Google Maps UI/label language, e.g. zh-CN"),
233
+ region: z.string().optional().describe("Google Maps region code, e.g. CN"),
231
234
  travelMode: z.enum(["DRIVING", "WALKING", "BICYCLING", "TRANSIT"]).default("DRIVING").describe("Directions API travel mode"),
232
235
  routeMarker: z.string().default("🚗").describe("emoji/character for the animated traveling marker"),
233
236
  googleMapsApiKey: z.string().optional().describe("injected by compiler from GOOGLE_MAPS_API_KEY env var"),
@@ -72,7 +72,30 @@ export function ComponentLeaf({ stream }: { stream: Component }) {
72
72
  [stream.data, components, eventState],
73
73
  );
74
74
 
75
- if (!stream.jsx) return null;
75
+ if (!stream.jsx) {
76
+ // Event-only stub: no JSX to render, but may fire events on `on`.
77
+ // Still needs EventAwareComponent for useFrameEvents to register
78
+ // and fire at the right frame.
79
+ const start = stream.start ?? 0;
80
+ const end = stream.end ?? start + (stream.duration ?? 1);
81
+ const durFrames = Math.max(1, Math.floor(fps * (end - start)));
82
+ return (
83
+ <Sequence
84
+ durationInFrames={durFrames}
85
+ from={Math.floor(fps * start)}
86
+ layout="none"
87
+ >
88
+ <EventAwareComponent
89
+ jsx=""
90
+ components={components}
91
+ data={bindings}
92
+ action={{ start, end }}
93
+ durFrames={durFrames}
94
+ on={stream.on}
95
+ />
96
+ </Sequence>
97
+ );
98
+ }
76
99
 
77
100
  const start = stream.start ?? 0;
78
101
  const end = stream.end ?? start + (stream.duration ?? 1);
@@ -118,6 +141,9 @@ function EventAwareComponent({
118
141
  // Fire events at the right frame for this node's timeline
119
142
  useFrameEvents(on, durFrames);
120
143
 
144
+ // If no JSX, this is an event-only stub — nothing to render
145
+ if (!jsx) return null;
146
+
121
147
  return (
122
148
  <TweenedJsxParser
123
149
  jsx={jsx}
@@ -34,17 +34,24 @@ export function EffectWrapper({
34
34
  const durationInFrames = end - start;
35
35
  if (durationInFrames <= 0) return [] as Record<string, string>[];
36
36
 
37
+ // Use animationDurationSeconds for animation timing when available
38
+ // (set by wrapWithEffects for background nodes where end is set to parent
39
+ // duration but the animation spec duration is preserved separately).
40
+ const animDurationSec = stream.animationDurationSeconds ?? stream.durationInSeconds ?? (durationInFrames / fps);
41
+ const animDurationFrames = Math.ceil(animDurationSec * fps);
42
+
37
43
  const animation = stream.animation;
38
44
  const timingFn = stream.animationTimingFunction;
39
45
  const iterCount = stream.animationIterationCount ?? 1;
40
46
  const style = (cssJS(stream.style) ?? {}) as Record<string, string>;
41
47
 
42
- // Handle iteration count: loop the animation within the span
48
+ // Handle iteration count: loop the animation within the span.
49
+ // The animation period is animDurationFrames (not the full span duration).
43
50
  let currentFrame = frame;
44
- if (iterCount > 0 && durationInFrames > 0) {
45
- const iteration = Math.floor((frame - start) / durationInFrames);
51
+ if (iterCount > 0 && animDurationFrames > 0) {
52
+ const iteration = Math.floor((frame - start) / animDurationFrames);
46
53
  if (iteration < iterCount) {
47
- currentFrame = start + ((frame - start) % durationInFrames);
54
+ currentFrame = start + ((frame - start) % animDurationFrames);
48
55
  }
49
56
  }
50
57
 
@@ -56,7 +63,7 @@ export function EffectWrapper({
56
63
  if (config) {
57
64
  const animStyle = interpolateKeyframes(config, actionFrame, {
58
65
  fps,
59
- durationInSeconds: durationInFrames / fps,
66
+ durationInSeconds: animDurationSec,
60
67
  timingFunction: timingFn,
61
68
  });
62
69
  if (animStyle) Object.assign(style, animStyle);
@@ -65,7 +72,7 @@ export function EffectWrapper({
65
72
  }
66
73
 
67
74
  return Object.keys(style).length > 0 ? [style] : [];
68
- }, [frame, fps, startSec, endSec, stream.animation, stream.animationTimingFunction, stream.animationIterationCount, stream.customKeyframes, stream.style]);
75
+ }, [frame, fps, startSec, endSec, stream.animation, stream.animationTimingFunction, stream.animationIterationCount, stream.customKeyframes, stream.style, stream.animationDurationSeconds, stream.durationInSeconds]);
69
76
 
70
77
  if (styles.length === 0) return <>{children}</>;
71
78
 
@@ -62,7 +62,7 @@ export function FolderLeaf({ stream }: { stream: FolderStream }) {
62
62
  // Background children are rendered outside the series (parallel overlays),
63
63
  // so TransitionSeries doesn't reject the <Loop> wrapper.
64
64
  const bgChildren = visibleChildren.filter((c) => c.isBackground);
65
- const seriesChildren = isSeries ? visibleChildren.filter((c) => !c.isBackground) : visibleChildren;
65
+ const seriesChildren = visibleChildren.filter((c) => !c.isBackground);
66
66
 
67
67
  // When all non-background series children are audio, skip transitions to
68
68
  // avoid audio overlap (both audio tracks play simultaneously during a fade).
package/src/types/Map.tsx CHANGED
@@ -39,6 +39,15 @@ function resolveApiKey(stream: MapStream): string {
39
39
  return "";
40
40
  }
41
41
 
42
+ function resolveMapLocale(language?: string, region?: string): { language?: string; region?: string } {
43
+ if (!language) return { language: undefined, region };
44
+ const lang = language.trim().toLowerCase();
45
+ if (lang === "zh") return { language: "zh-CN", region: region ?? "CN" };
46
+ if (lang === "en") return { language: "en", region: region ?? "US" };
47
+ if (lang.startsWith("zh-")) return { language, region: region ?? "CN" };
48
+ return { language, region };
49
+ }
50
+
42
51
  // ============================================================
43
52
  // MapLeaf — entry point, renders each action as a Sequence
44
53
  // ============================================================
@@ -58,13 +67,53 @@ export function MapLeaf({ stream }: { stream: MapStream }) {
58
67
  const mapType = stream.mapType ?? "roadmap";
59
68
  const travelMode = stream.travelMode ?? "DRIVING";
60
69
  const markerEmoji = stream.routeMarker ?? "🚗";
70
+ const mapLocale = React.useMemo(
71
+ () => resolveMapLocale(stream.language, stream.region),
72
+ [stream.language, stream.region],
73
+ );
74
+ const mapLoadHandleRef = React.useRef<number | null>(null);
75
+ const mapLoadContinuedRef = React.useRef(false);
76
+
77
+ React.useEffect(() => {
78
+ mapLoadHandleRef.current = delayRender("Waiting for map tiles to load...");
79
+ mapLoadContinuedRef.current = false;
80
+
81
+ // Avoid hanging indefinitely when map tiles fail to load.
82
+ const fallbackTimer = window.setTimeout(() => {
83
+ if (!mapLoadContinuedRef.current && mapLoadHandleRef.current !== null) {
84
+ continueRender(mapLoadHandleRef.current);
85
+ mapLoadContinuedRef.current = true;
86
+ }
87
+ }, 8000);
88
+
89
+ return () => {
90
+ window.clearTimeout(fallbackTimer);
91
+ if (!mapLoadContinuedRef.current && mapLoadHandleRef.current !== null) {
92
+ continueRender(mapLoadHandleRef.current);
93
+ mapLoadContinuedRef.current = true;
94
+ }
95
+ mapLoadHandleRef.current = null;
96
+ };
97
+ }, [stream.id, start, end]);
98
+
99
+ const handleTilesLoaded = React.useCallback(() => {
100
+ if (!mapLoadContinuedRef.current && mapLoadHandleRef.current !== null) {
101
+ continueRender(mapLoadHandleRef.current);
102
+ mapLoadContinuedRef.current = true;
103
+ }
104
+ }, []);
105
+
61
106
  return (
62
107
  <Sequence
63
108
  durationInFrames={durFrames}
64
109
  from={Math.floor(fps * start)}
65
110
  layout="none"
66
111
  >
67
- <APIProvider apiKey={apiKey}>
112
+ <APIProvider
113
+ apiKey={apiKey}
114
+ language={mapLocale.language}
115
+ region={mapLocale.region}
116
+ >
68
117
  <GoogleMap
69
118
  mapId={String(stream.id ?? "map")}
70
119
  defaultCenter={center}
@@ -74,6 +123,7 @@ export function MapLeaf({ stream }: { stream: MapStream }) {
74
123
  disableDefaultUI: true,
75
124
  zoomControl: false,
76
125
  }}
126
+ onTilesLoaded={handleTilesLoaded}
77
127
  style={{ width: "100%", height: "100%", position: "absolute" }}
78
128
  >
79
129
  <RouteWithMarker
@@ -4,7 +4,15 @@
4
4
  */
5
5
 
6
6
  export function uid(): string {
7
- return Math.random().toString(36).slice(2, 10);
7
+ // Ensure the first character is a letter (valid JS identifier start)
8
+ const raw = Math.random().toString(36).slice(2, 10);
9
+ const first = raw[0]!;
10
+ // If first char is a digit, prepend a random letter
11
+ if (/^[0-9]/.test(first)) {
12
+ const letter = String.fromCharCode(97 + Math.floor(Math.random() * 26));
13
+ return letter + raw;
14
+ }
15
+ return raw;
8
16
  }
9
17
 
10
18
  const KEBAB = /[^a-zA-Z0-9_-]+/g;
@@ -135,7 +143,11 @@ export function getDurationInSeconds(stream: DurationStream, update = true): num
135
143
  getDurationInSeconds(child, update);
136
144
  }
137
145
 
138
- const visible = stream.children.filter((c) => !c.isBackground);
146
+ // Effect wrappers encompass all children visually; background children
147
+ // still contribute to the effect's visible duration.
148
+ const visible = stream.type === "effect"
149
+ ? stream.children
150
+ : stream.children.filter((c) => !c.isBackground);
139
151
  if (stream.isSeries) {
140
152
  const overlap = stream.transition ? (stream.transitionTime ?? 0.5) : 0;
141
153
  for (let i = 0; i < visible.length; i++) {
@@ -0,0 +1,40 @@
1
+ # video
2
+ seed:2660286133
3
+ width:640 height:480 fps:30 layout:series
4
+
5
+ ## Title
6
+ layout:parallel
7
+ - component duration:3
8
+ ~~~jsx jsx
9
+ <div style={{position:'absolute',top:0,left:0,width:640,height:480,display:'flex',flexDirection:'column',alignItems:'center',justifyContent:'center',background:'linear-gradient(135deg,#1a1a2e,#16213e)'}}>
10
+ <h1 style={{color:'#00d4ff',fontSize:28,margin:0}}>Animated Diagrams</h1>
11
+ <p style={{color:'#aaa',fontSize:16,marginTop:10}}>Mermaid Diagrams with Dynamic Highlight</p>
12
+ </div>
13
+ ~~~
14
+
15
+ ## FlowChart
16
+ layout:parallel
17
+ - component id:flowChart duration:12
18
+ ~~~jsx
19
+ <div style={{position:'absolute',top:0,left:0,width:640,height:480,background:'#1a1a2e',padding:10,fontFamily:'monospace',boxSizing:'border-box',display:'flex',flexDirection:'column'}}>
20
+ <p style={{color:'#00d4ff',fontSize:12,textAlign:'center',margin:'0 0 4px 0',flexShrink:0}}>Flow — {highlight}</p>
21
+ <div style={{flex:1,display:'flex',justifyContent:'center',alignItems:'center',overflow:'hidden'}}>
22
+ <Mermaid highlight={highlight} animateEdges={animateEdges} theme='dark' source={mermaid}/>
23
+ </div>
24
+ </div>
25
+ ~~~
26
+ ~~~mermaid
27
+ graph TD
28
+ A["Receive Request"] --> B["Validate Input"]
29
+ B --> C{"Valid?"}
30
+ C -->|Yes| D["Process Data"]
31
+ C -->|No| E["Return Error"]
32
+ D --> F["Format Response"]
33
+ F --> G["Send Response"]
34
+ classDef highlight fill:#ffd700,stroke:#ff6600,stroke-width:3px,color:#000
35
+ ~~~
36
+ highlight:"A"
37
+ animateEdges:true
38
+ - event duration:3 start:3 on:(start, flowChart.highlight="B";flowChart.animateEdges=["B->C"])
39
+ - event duration:3 start:6 on:(start, flowChart.highlight="C")
40
+ - event duration:3 start:9 on:(start, flowChart.highlight=["D","G"])