@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.
- package/B] +2 -0
- package/README.md +29 -0
- package/package.json +1 -1
- package/skills/markcut/SKILL.md +12 -45
- package/skills/markcut/docs/components.md +89 -0
- package/skills/markcut/docs/markdown-descriptive.md +17 -2
- package/skills/markcut/docs/sound-effects.md +45 -0
- package/src/components/Markdown.tsx +138 -24
- package/src/components/Mermaid.tsx +223 -22
- package/src/config.mjs +2 -2
- package/src/context/EventContext.tsx +3 -0
- package/src/descriptive/compiler.ts +68 -29
- package/src/descriptive/markdown.ts +20 -0
- package/src/player/browser.tsx +95 -5
- package/src/player/bundle/player.js +1078 -629
- package/src/player/components/EditControls.tsx +6 -3
- package/src/player/components/EditMessagePanel.tsx +96 -0
- package/src/player/components/HeaderBar.tsx +9 -11
- package/src/player/components/index.ts +1 -0
- package/src/player/pipeline.mjs +72 -21
- package/src/player/server-shared.mjs +4 -1
- package/src/player/server.mjs +202 -42
- package/src/render/cli.mjs +1 -1
- package/src/schema/index.ts +4 -1
- package/src/types/Component.tsx +27 -1
- package/src/types/Effect.tsx +13 -6
- package/src/types/Folder.tsx +1 -1
- package/src/types/Map.tsx +51 -1
- package/src/utils/index.ts +14 -2
- package/tests/fixtures/md/animate-diagrams.md +40 -0
- package/tests/fixtures/md/electricity-grow.md +130 -0
- package/tests/tmp/vision-1785081637127-video/videos/.normalized/segments/test-clip_0to3_seg_1100to3000.mp4 +0 -0
- package/tests/tmp/vision-1785081637127-video/videos/.normalized/test-clip_0to3.mp4 +0 -0
- package/tests/tmp/vision-1785081637127-video/videos/.normalized/test-clip_audio.mp3 +0 -0
- package/tests/tmp/vision-1785081637127-video/videos/metadata.json +9 -0
- package/tests/tmp/vision-1785081637127-video/videos/test-clip.mp4 +0 -0
- package/tests/tmp/vision-1785081637127-video/videos/test-clip.vtt +5 -0
- package/tests/tmp/vision-1784830584961/images/.normalized/test-photo_384.jpg +0 -0
- package/tests/tmp/vision-1784830584961/images/metadata.json +0 -8
- package/tests/tmp/vision-1784830584961/images/test-photo.png +0 -0
- package/tmp/frontmatter-test.ts +0 -21
package/src/player/server.mjs
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*
|
|
5
5
|
* Modes:
|
|
6
6
|
* --label – playback with label input overlay; labels map to media timestamps
|
|
7
|
-
* --edit –
|
|
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)
|
|
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)
|
|
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 (
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
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
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
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
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
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
|
|
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, {
|
|
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, {
|
|
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, {
|
|
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
|
-
|
|
1278
|
+
console.log(` Watching: ${VIDEO_JSON.split("/").pop()}`);
|
|
1119
1279
|
console.log("");
|
|
1120
1280
|
});
|
package/src/render/cli.mjs
CHANGED
|
@@ -496,7 +496,7 @@ function hasScript(root) {
|
|
|
496
496
|
|
|
497
497
|
// Compile to validate
|
|
498
498
|
const compiled = compileDescriptiveRoot(descriptive);
|
|
499
|
-
emitSuccess(`Valid
|
|
499
|
+
emitSuccess(`Valid`);
|
|
500
500
|
process.exit(0);
|
|
501
501
|
|
|
502
502
|
} catch (err) {
|
package/src/schema/index.ts
CHANGED
|
@@ -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.
|
|
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"),
|
package/src/types/Component.tsx
CHANGED
|
@@ -72,7 +72,30 @@ export function ComponentLeaf({ stream }: { stream: Component }) {
|
|
|
72
72
|
[stream.data, components, eventState],
|
|
73
73
|
);
|
|
74
74
|
|
|
75
|
-
if (!stream.jsx)
|
|
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}
|
package/src/types/Effect.tsx
CHANGED
|
@@ -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 &&
|
|
45
|
-
const iteration = Math.floor((frame - start) /
|
|
51
|
+
if (iterCount > 0 && animDurationFrames > 0) {
|
|
52
|
+
const iteration = Math.floor((frame - start) / animDurationFrames);
|
|
46
53
|
if (iteration < iterCount) {
|
|
47
|
-
currentFrame = start + ((frame - start) %
|
|
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:
|
|
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
|
|
package/src/types/Folder.tsx
CHANGED
|
@@ -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 =
|
|
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
|
|
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
|
package/src/utils/index.ts
CHANGED
|
@@ -4,7 +4,15 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
export function uid(): string {
|
|
7
|
-
|
|
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
|
-
|
|
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"])
|