storyforge 0.3.0 → 0.4.1
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/dist/index.js +263 -3
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -42,6 +42,142 @@ function loadCredentials() {
|
|
|
42
42
|
}
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
// src/utils/script-prompt.ts
|
|
46
|
+
var STYLE_GUIDES = {
|
|
47
|
+
"3blue1brown": 'Mathematical rigor, progressive intuition building, "but why?" framing. Build from first principles.',
|
|
48
|
+
coldfusion: `Story-driven narrative, industry context, "here's what happened" framing. Data points, company names, real-world impact.`,
|
|
49
|
+
educative: "System design interview structure. Functional vs non-functional requirements, architecture, deep dives.",
|
|
50
|
+
wendover: 'Real-world logistics framing. "The surprising reason" hooks. Ground abstract concepts in physical analogies.',
|
|
51
|
+
veritasium: "Counterintuitive hook or common misconception. Hypothesis, evidence, reveal. Challenge assumptions.",
|
|
52
|
+
custom: "Documentary-quality educational content. Clear structure, dense evidence, engaging narration."
|
|
53
|
+
};
|
|
54
|
+
function styleGuideFor(style) {
|
|
55
|
+
return STYLE_GUIDES[style] ?? STYLE_GUIDES.custom;
|
|
56
|
+
}
|
|
57
|
+
function buildScriptPrompt(args) {
|
|
58
|
+
const minMin = args.targetMinutesMin ?? 18;
|
|
59
|
+
const maxMin = args.targetMinutesMax ?? 25;
|
|
60
|
+
const tagline = (args.channelTagline ?? "Documentary-quality explainers").trim();
|
|
61
|
+
const handle = (args.channelHandle ?? "@channel").trim();
|
|
62
|
+
const channel = (args.channelName ?? "this channel").trim();
|
|
63
|
+
const banList = (args.forbiddenVisuals && args.forbiddenVisuals.length ? args.forbiddenVisuals : [
|
|
64
|
+
"imagine a world where",
|
|
65
|
+
"in today's fast-paced world",
|
|
66
|
+
"in the realm of",
|
|
67
|
+
"paradigm shift",
|
|
68
|
+
"unlock the power of",
|
|
69
|
+
"revolutionizing the way we",
|
|
70
|
+
"studies have shown",
|
|
71
|
+
"up to X percent",
|
|
72
|
+
"as we look to the future",
|
|
73
|
+
"the future is bright",
|
|
74
|
+
"sky's the limit",
|
|
75
|
+
"game-changing",
|
|
76
|
+
"cutting-edge",
|
|
77
|
+
"state-of-the-art",
|
|
78
|
+
"in conclusion",
|
|
79
|
+
"let's dive in",
|
|
80
|
+
"buckle up"
|
|
81
|
+
]).map((s) => ` - "${s}"`).join("\n");
|
|
82
|
+
const referenceBlock = args.referenceScript ? `
|
|
83
|
+
|
|
84
|
+
=== REFERENCE SCRIPT (match this density and craft) ===
|
|
85
|
+
|
|
86
|
+
${args.referenceScript.slice(0, 12e3)}
|
|
87
|
+
|
|
88
|
+
=== END REFERENCE ===
|
|
89
|
+
|
|
90
|
+
The script you write must match the reference's:
|
|
91
|
+
- Hook structure (pattern of historical examples \u2192 reversal \u2192 stakes \u2192 bet).
|
|
92
|
+
- Evidence density (named companies, specific numbers, dollar amounts, dates).
|
|
93
|
+
- Section pacing (varying section lengths from 2s teasers to 200s deep dives).
|
|
94
|
+
- Sentence rhythm (mix of long flowing sentences and 4-word punches).` : "";
|
|
95
|
+
return `You are a senior documentary scriptwriter for ${channel} (${handle}). The channel's promise: "${tagline}".
|
|
96
|
+
|
|
97
|
+
You write the kind of script that earns 25 minutes of someone's attention. The kind where every paragraph adds load-bearing information. The kind where one historical example replaces three paragraphs of generic exposition.
|
|
98
|
+
|
|
99
|
+
YOUR TASK
|
|
100
|
+
\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
101
|
+
Write a narrated documentary-style script on this topic:
|
|
102
|
+
|
|
103
|
+
TOPIC: "${args.topic}"
|
|
104
|
+
STYLE: ${args.style} \u2014 ${args.styleGuide}
|
|
105
|
+
|
|
106
|
+
The video target length is ${minMin}-${maxMin} minutes. Don't pad. If the topic legitimately needs less, write less; if it needs more, write more. Anemic length comes from anemic thinking \u2014 go deeper, not shorter.
|
|
107
|
+
|
|
108
|
+
HOW THE HOOK WORKS (this is non-negotiable)
|
|
109
|
+
\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
110
|
+
The first chunk is the hook. It is NOT "Imagine a world where..." or "In today's fast-paced..." or any LLM-template opener. The hook follows this structure:
|
|
111
|
+
|
|
112
|
+
1. PATTERN (4-6 sentences): Open with 3-5 SPECIFIC historical examples that share a common pattern. Real companies. Real years. Real outcomes.
|
|
113
|
+
2. REVERSAL: Name the surprising counter-pattern that connects to the topic.
|
|
114
|
+
3. STAKES: One paragraph naming the specific cost / consequence / scale.
|
|
115
|
+
4. BET: Frame the topic as a bet between two competing approaches with concrete numbers.
|
|
116
|
+
5. PROMISE: One sentence promising what the viewer will see in the next ${minMin}+ minutes.
|
|
117
|
+
|
|
118
|
+
The hook is typically 90-150 seconds. Do NOT aim for a uniform short hook.
|
|
119
|
+
|
|
120
|
+
EVIDENCE RULES (every paragraph must satisfy at least one)
|
|
121
|
+
\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
122
|
+
- Name a specific company / lab / paper / person.
|
|
123
|
+
- Include a specific number with units (dollars, percent, milliseconds, GB, parameters, miles, etc.).
|
|
124
|
+
- Cite a specific year or product version.
|
|
125
|
+
- Reference a specific event or product launch.
|
|
126
|
+
|
|
127
|
+
If you cannot satisfy any of these for a paragraph, REMOVE the paragraph. Do not write filler.
|
|
128
|
+
|
|
129
|
+
FORBIDDEN PHRASES (zero of them)
|
|
130
|
+
\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
131
|
+
${banList}
|
|
132
|
+
|
|
133
|
+
Plus: no rhetorical questions opening sections, no "let's explore", no "in this video we'll look at".
|
|
134
|
+
|
|
135
|
+
LENGTH + PACING
|
|
136
|
+
\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
137
|
+
- 14 to 22 chunks. Vary durations:
|
|
138
|
+
* Hook: 90-150s
|
|
139
|
+
* Channel intro / cold-open: 2-5s
|
|
140
|
+
* Body sections: 100-220s each
|
|
141
|
+
* Conclusion: 90-180s
|
|
142
|
+
* CTA: 8-15s
|
|
143
|
+
* Next-video teaser: 20-40s
|
|
144
|
+
* End-screen: 8-15s
|
|
145
|
+
- Total duration: ${minMin}-${maxMin} minutes.
|
|
146
|
+
|
|
147
|
+
NARRATION VOICE
|
|
148
|
+
\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
149
|
+
- Conversational but authoritative. Speak in declarative sentences.
|
|
150
|
+
- Mix sentence lengths: occasionally drop a 4-word sentence after a long one for rhythm.
|
|
151
|
+
- No exclamation marks.
|
|
152
|
+
- "And" / "But" / "Because" can start sentences.
|
|
153
|
+
- End each section with a sentence that creates forward pull.
|
|
154
|
+
|
|
155
|
+
STYLE: ${args.style.toUpperCase()}
|
|
156
|
+
${args.styleGuide}
|
|
157
|
+
${referenceBlock}
|
|
158
|
+
|
|
159
|
+
OUTPUT FORMAT
|
|
160
|
+
\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
161
|
+
Return ONLY a valid JSON array, no markdown fences, no commentary. Each chunk:
|
|
162
|
+
|
|
163
|
+
{
|
|
164
|
+
"id": "c01-hook" or "c07-section-three-mechanism" (semantic, kebab-case),
|
|
165
|
+
"title": "Hook \u2014 <Title>" or "Section Three. <Phrase>." etc.
|
|
166
|
+
"narrationText": "Full narration body. Multiple paragraphs separated by blank lines.",
|
|
167
|
+
"durationEstimate": <integer seconds>
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
CRITICAL CHECKLIST:
|
|
171
|
+
\u25A1 Hook follows pattern \u2192 reversal \u2192 stakes \u2192 bet \u2192 promise.
|
|
172
|
+
\u25A1 Total duration is ${minMin * 60}-${maxMin * 60} seconds.
|
|
173
|
+
\u25A1 Every paragraph has a specific number, name, year, or product reference.
|
|
174
|
+
\u25A1 ZERO forbidden phrases.
|
|
175
|
+
\u25A1 Section durations vary (not uniform 45s blocks).
|
|
176
|
+
\u25A1 Conclusion connects back to the hook's pattern.
|
|
177
|
+
|
|
178
|
+
Now write the script.`;
|
|
179
|
+
}
|
|
180
|
+
|
|
45
181
|
// src/commands/dev.ts
|
|
46
182
|
var exec = promisify(execCb);
|
|
47
183
|
var PORT = 4444;
|
|
@@ -451,6 +587,112 @@ async function devCommand(options) {
|
|
|
451
587
|
});
|
|
452
588
|
return;
|
|
453
589
|
}
|
|
590
|
+
if (pathname === "/api/script-gen" && req.method === "POST") {
|
|
591
|
+
const bodyChunks = [];
|
|
592
|
+
req.on("data", (c2) => bodyChunks.push(c2));
|
|
593
|
+
req.on("end", async () => {
|
|
594
|
+
let body;
|
|
595
|
+
try {
|
|
596
|
+
body = JSON.parse(Buffer.concat(bodyChunks).toString("utf-8"));
|
|
597
|
+
} catch {
|
|
598
|
+
res.writeHead(400, { ...CORS_HEADERS, "Content-Type": "application/json" });
|
|
599
|
+
res.end(JSON.stringify({ error: "Invalid JSON body" }));
|
|
600
|
+
return;
|
|
601
|
+
}
|
|
602
|
+
if (!body.topic || typeof body.topic !== "string") {
|
|
603
|
+
res.writeHead(400, { ...CORS_HEADERS, "Content-Type": "application/json" });
|
|
604
|
+
res.end(JSON.stringify({ error: "topic is required" }));
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
const style = body.style ?? "custom";
|
|
608
|
+
const prompt2 = buildScriptPrompt({
|
|
609
|
+
topic: body.topic,
|
|
610
|
+
style,
|
|
611
|
+
styleGuide: styleGuideFor(style),
|
|
612
|
+
channelName: body.channelName,
|
|
613
|
+
channelTagline: body.channelTagline,
|
|
614
|
+
channelHandle: body.channelHandle,
|
|
615
|
+
forbiddenVisuals: body.forbiddenVisuals,
|
|
616
|
+
referenceScript: body.referenceScript,
|
|
617
|
+
targetMinutesMin: body.targetMinutesMin,
|
|
618
|
+
targetMinutesMax: body.targetMinutesMax
|
|
619
|
+
});
|
|
620
|
+
const tmpFile = path2.join(os2.tmpdir(), `forge-script-${Date.now()}.txt`);
|
|
621
|
+
fs2.writeFileSync(tmpFile, prompt2, "utf-8");
|
|
622
|
+
const cleanup = () => {
|
|
623
|
+
if (fs2.existsSync(tmpFile)) fs2.unlinkSync(tmpFile);
|
|
624
|
+
};
|
|
625
|
+
const candidates = ["claude-opus-4-7", "claude-sonnet-4-6"];
|
|
626
|
+
let raw = "";
|
|
627
|
+
let modelUsed = "";
|
|
628
|
+
let lastErr;
|
|
629
|
+
for (const model of candidates) {
|
|
630
|
+
try {
|
|
631
|
+
log.info(`[script-gen] Generating via Claude CLI \xB7 model=${model}`);
|
|
632
|
+
const { stdout } = await exec(
|
|
633
|
+
`cat "${tmpFile}" | claude -p --model ${model} --no-session-persistence`,
|
|
634
|
+
{ maxBuffer: 16 * 1024 * 1024, timeout: 3e5 }
|
|
635
|
+
);
|
|
636
|
+
raw = (stdout ?? "").trim();
|
|
637
|
+
if (raw) {
|
|
638
|
+
modelUsed = model;
|
|
639
|
+
break;
|
|
640
|
+
}
|
|
641
|
+
} catch (err) {
|
|
642
|
+
lastErr = err instanceof Error ? err.message : String(err);
|
|
643
|
+
log.warn(`[script-gen] ${model} failed: ${lastErr.slice(0, 120)}`);
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
cleanup();
|
|
647
|
+
if (!raw) {
|
|
648
|
+
res.writeHead(502, { ...CORS_HEADERS, "Content-Type": "application/json" });
|
|
649
|
+
res.end(JSON.stringify({
|
|
650
|
+
error: "No Claude CLI model returned output",
|
|
651
|
+
detail: lastErr,
|
|
652
|
+
hint: "Run `claude` once to verify your subscription, then retry."
|
|
653
|
+
}));
|
|
654
|
+
return;
|
|
655
|
+
}
|
|
656
|
+
const stripped = raw.replace(/^```(?:json)?\s*/m, "").replace(/```\s*$/m, "").trim();
|
|
657
|
+
const arr = stripped.match(/\[[\s\S]*\]/);
|
|
658
|
+
if (!arr) {
|
|
659
|
+
res.writeHead(502, { ...CORS_HEADERS, "Content-Type": "application/json" });
|
|
660
|
+
res.end(JSON.stringify({
|
|
661
|
+
error: "Claude returned non-JSON",
|
|
662
|
+
model: modelUsed,
|
|
663
|
+
preview: raw.slice(0, 400)
|
|
664
|
+
}));
|
|
665
|
+
return;
|
|
666
|
+
}
|
|
667
|
+
let chunks;
|
|
668
|
+
try {
|
|
669
|
+
chunks = JSON.parse(arr[0]);
|
|
670
|
+
} catch (e) {
|
|
671
|
+
res.writeHead(502, { ...CORS_HEADERS, "Content-Type": "application/json" });
|
|
672
|
+
res.end(JSON.stringify({
|
|
673
|
+
error: "Failed to parse script JSON",
|
|
674
|
+
model: modelUsed,
|
|
675
|
+
detail: e.message,
|
|
676
|
+
preview: arr[0].slice(0, 400)
|
|
677
|
+
}));
|
|
678
|
+
return;
|
|
679
|
+
}
|
|
680
|
+
const totalSec = chunks.reduce((s, c2) => s + (c2.durationEstimate ?? 0), 0);
|
|
681
|
+
log.success(`[script-gen] ${chunks.length} chunks \xB7 ~${(totalSec / 60).toFixed(1)} min via ${modelUsed}`);
|
|
682
|
+
res.writeHead(200, { ...CORS_HEADERS, "Content-Type": "application/json" });
|
|
683
|
+
res.end(JSON.stringify({
|
|
684
|
+
chunks,
|
|
685
|
+
model: modelUsed,
|
|
686
|
+
provider: "claude-cli",
|
|
687
|
+
total_sec: totalSec
|
|
688
|
+
}));
|
|
689
|
+
});
|
|
690
|
+
req.on("error", () => {
|
|
691
|
+
res.writeHead(500, CORS_HEADERS);
|
|
692
|
+
res.end(JSON.stringify({ error: "Request error" }));
|
|
693
|
+
});
|
|
694
|
+
return;
|
|
695
|
+
}
|
|
454
696
|
if (pathname === "/api/ai-edit" && req.method === "POST") {
|
|
455
697
|
const bodyChunks = [];
|
|
456
698
|
req.on("data", (c2) => bodyChunks.push(c2));
|
|
@@ -837,9 +1079,22 @@ Return ONLY the complete updated TSX. No markdown fences, no explanation.`;
|
|
|
837
1079
|
openBrowser(webUrl);
|
|
838
1080
|
}
|
|
839
1081
|
const aiMethods = [];
|
|
1082
|
+
let scriptGenModel = null;
|
|
840
1083
|
try {
|
|
841
1084
|
execSync("which claude", { stdio: "ignore" });
|
|
842
|
-
|
|
1085
|
+
const candidates = ["claude-opus-4-7", "claude-sonnet-4-6"];
|
|
1086
|
+
for (const model of candidates) {
|
|
1087
|
+
try {
|
|
1088
|
+
execSync(
|
|
1089
|
+
`echo "ping" | claude -p --model ${model} --no-session-persistence`,
|
|
1090
|
+
{ stdio: "ignore", timeout: 3e4 }
|
|
1091
|
+
);
|
|
1092
|
+
scriptGenModel = model;
|
|
1093
|
+
break;
|
|
1094
|
+
} catch {
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
aiMethods.push(`Claude CLI \u2192 ${scriptGenModel ?? "claude-sonnet-4-6 (default)"}`);
|
|
843
1098
|
} catch {
|
|
844
1099
|
}
|
|
845
1100
|
try {
|
|
@@ -847,8 +1102,8 @@ Return ONLY the complete updated TSX. No markdown fences, no explanation.`;
|
|
|
847
1102
|
aiMethods.push("Codex CLI \u2192 gpt-5.4");
|
|
848
1103
|
} catch {
|
|
849
1104
|
}
|
|
850
|
-
if (process.env.ANTHROPIC_API_KEY) aiMethods.push("Anthropic API \u2192 claude-sonnet-4-
|
|
851
|
-
if (process.env.OPENAI_API_KEY) aiMethods.push("OpenAI API \u2192 gpt-
|
|
1105
|
+
if (process.env.ANTHROPIC_API_KEY) aiMethods.push("Anthropic API \u2192 claude-sonnet-4-5");
|
|
1106
|
+
if (process.env.OPENAI_API_KEY) aiMethods.push("OpenAI API \u2192 gpt-4o");
|
|
852
1107
|
console.log("");
|
|
853
1108
|
if (aiMethods.length > 0) {
|
|
854
1109
|
console.log(" AI edit chain (tried in order):");
|
|
@@ -859,6 +1114,11 @@ Return ONLY the complete updated TSX. No markdown fences, no explanation.`;
|
|
|
859
1114
|
console.log(" Install Codex CLI: npm install -g @openai/codex");
|
|
860
1115
|
console.log(" Or set ANTHROPIC_API_KEY / OPENAI_API_KEY in your shell");
|
|
861
1116
|
}
|
|
1117
|
+
if (scriptGenModel) {
|
|
1118
|
+
console.log("");
|
|
1119
|
+
console.log(` Script generation \u2192 ${scriptGenModel}`);
|
|
1120
|
+
console.log(` POST http://localhost:${port}/api/script-gen`);
|
|
1121
|
+
}
|
|
862
1122
|
console.log("");
|
|
863
1123
|
console.log(" Ctrl+C to stop");
|
|
864
1124
|
console.log("");
|