reelforge 0.10.0 → 0.11.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/README.md +2 -0
- package/dist/commands/create.js +8 -0
- package/dist/commands/pipelines.js +7 -0
- package/dist/commands/templates.js +154 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -179,6 +179,8 @@ rf create "我的视频" --frame-template ./my-brand.html
|
|
|
179
179
|
|
|
180
180
|
Inline HTML is hard-capped at 2 MB. The audio + motion + character-ref + scene-plan stages all keep working identically — only the overlay layer is yours.
|
|
181
181
|
|
|
182
|
+
**Publishing to short-video platforms (Douyin / TikTok / WeChat Channels)** — the default template renders to the full 1080×1920 canvas, but those apps overlay UI on top/bottom/right and cover-crop ~96-180px on each side on taller phones. ReelForge does NOT bake platform-specific padding into the default template. For reference safe-zone numbers + a copy-pasteable CSS diff: `rf templates safezone [douyin|tiktok|wechat]`.
|
|
183
|
+
|
|
182
184
|
## Examples
|
|
183
185
|
|
|
184
186
|
```bash
|
package/dist/commands/create.js
CHANGED
|
@@ -155,6 +155,8 @@ function optsToBody(opts) {
|
|
|
155
155
|
out.topic = opts.topic;
|
|
156
156
|
if (opts.script !== undefined)
|
|
157
157
|
out.script = opts.script;
|
|
158
|
+
if (opts.title !== undefined)
|
|
159
|
+
out.title = opts.title;
|
|
158
160
|
if (opts.duration !== undefined)
|
|
159
161
|
out.duration = opts.duration;
|
|
160
162
|
if (opts.pace !== undefined)
|
|
@@ -344,6 +346,7 @@ export function registerCreate(program) {
|
|
|
344
346
|
// --- Content (exactly one of --topic / --script) ---
|
|
345
347
|
.option("-t, --topic <text>", "video topic; AI writes the script (mode=generate). Prefix with @file to read from disk.")
|
|
346
348
|
.option("--script <text>", "your own master script text; AI just plans scenes + visuals (mode=fixed). Prefix with @file to read from disk.")
|
|
349
|
+
.option("--title <text>", "hard-override video title; LLM will NOT auto-summarize. Useful for compliance-sensitive content (e.g. financial). Prefix with @file to read from disk. Pass --title '' (empty string) to explicitly suppress title rendering. Omit to keep LLM auto-title.")
|
|
347
350
|
.option("-d, --duration <sec>", "target video duration in seconds (generate mode only; default 45). LLM aims for ~duration × 5 chars of narration.", (v) => parseInt(v, 10))
|
|
348
351
|
.option("-p, --pace <pace>", "visual rhythm hint passed to the LLM: slow | normal | fast (default normal). LLM still decides the actual scene count from semantic structure.")
|
|
349
352
|
// --- Visual ---
|
|
@@ -569,6 +572,11 @@ export function registerCreate(program) {
|
|
|
569
572
|
if (typeof body.script === "string") {
|
|
570
573
|
body.script = await resolveTextOrFile(body.script);
|
|
571
574
|
}
|
|
575
|
+
// Title: resolve @file when present. Empty string is a legit value (=
|
|
576
|
+
// suppress rendering) and must pass through verbatim, never as @file.
|
|
577
|
+
if (typeof body.title === "string" && body.title.startsWith("@")) {
|
|
578
|
+
body.title = await resolveTextOrFile(body.title);
|
|
579
|
+
}
|
|
572
580
|
// Resolve character ref: local file path → data: URI (RelayX accepts
|
|
573
581
|
// both https:// and data: in image_urls). Done after layering so a
|
|
574
582
|
// recipe can carry the ref by path too.
|
|
@@ -45,6 +45,7 @@ export function registerPipelines(program) {
|
|
|
45
45
|
.helpOption("-h, --help", "show help")
|
|
46
46
|
.option("-t, --topic <text>", "video topic (mode=generate). Use @file to read from disk.")
|
|
47
47
|
.option("--script <text>", "your own master script text (mode=fixed). Use @file to read from disk.")
|
|
48
|
+
.option("--title <text>", "hard-override video title; LLM will NOT auto-summarize. Useful for compliance-sensitive content. Use @file to read from disk. Pass --title '' (empty) to explicitly suppress title rendering. Omit to keep LLM auto-title.")
|
|
48
49
|
.option("-d, --duration <sec>", "target video duration in seconds (generate mode; default 45)", (v) => parseInt(v, 10))
|
|
49
50
|
.option("-p, --pace <pace>", "visual rhythm hint: slow | normal | fast (default normal)")
|
|
50
51
|
.option("--motion <preset>", "per-scene image animation: off | lite (default) | max")
|
|
@@ -108,10 +109,15 @@ export function registerPipelines(program) {
|
|
|
108
109
|
}
|
|
109
110
|
let topic = opts.topic;
|
|
110
111
|
let script = opts.script;
|
|
112
|
+
let title = opts.title;
|
|
111
113
|
if (topic?.startsWith("@"))
|
|
112
114
|
topic = await fs.readFile(topic.slice(1), "utf-8");
|
|
113
115
|
if (script?.startsWith("@"))
|
|
114
116
|
script = await fs.readFile(script.slice(1), "utf-8");
|
|
117
|
+
// Title: resolve @file only when prefix present. Empty "" is a legit value
|
|
118
|
+
// (= suppress title) and must pass through verbatim.
|
|
119
|
+
if (title?.startsWith("@"))
|
|
120
|
+
title = await fs.readFile(title.slice(1), "utf-8");
|
|
115
121
|
// --frame-template can be a preset key OR a local .html — same heuristic
|
|
116
122
|
// as 0.7.x. Local path is read and sent as frame_template_html inline.
|
|
117
123
|
let frame_template;
|
|
@@ -134,6 +140,7 @@ export function registerPipelines(program) {
|
|
|
134
140
|
await submitAndMaybeWait("/api/v1/pipelines/standard", {
|
|
135
141
|
topic,
|
|
136
142
|
script,
|
|
143
|
+
title,
|
|
137
144
|
duration: opts.duration,
|
|
138
145
|
pace: opts.pace,
|
|
139
146
|
motion: opts.motion,
|
|
@@ -1,16 +1,26 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* reelforge templates <list|preview|show>
|
|
2
|
+
* reelforge templates <list|preview|show|safezone>
|
|
3
3
|
*/
|
|
4
4
|
import fs from "node:fs/promises";
|
|
5
|
+
import kleur from "kleur";
|
|
5
6
|
import { get, post } from "../client.js";
|
|
6
7
|
import { downloadTo } from "../utils/download.js";
|
|
7
|
-
import { print, success, table } from "../utils/output.js";
|
|
8
|
+
import { isJson, print, success, table } from "../utils/output.js";
|
|
8
9
|
import { buildTemplatePayload } from "./frames.js";
|
|
9
10
|
export function registerTemplates(program) {
|
|
10
11
|
const tpl = program
|
|
11
12
|
.command("templates")
|
|
12
|
-
.description("Browse
|
|
13
|
-
.helpOption("-h, --help", "show help")
|
|
13
|
+
.description("Browse templates, preview, view source, and look up platform safe zones")
|
|
14
|
+
.helpOption("-h, --help", "show help")
|
|
15
|
+
.addHelpText("after", [
|
|
16
|
+
"",
|
|
17
|
+
"Examples:",
|
|
18
|
+
" rf templates list --size 1080x1920",
|
|
19
|
+
" rf templates show 1080x1920/default.html -o my.html",
|
|
20
|
+
" rf templates safezone # all platforms overview",
|
|
21
|
+
" rf templates safezone douyin # CSS diff for 抖音",
|
|
22
|
+
" rf templates preview ./my.html -o p.png",
|
|
23
|
+
].join("\n"));
|
|
14
24
|
tpl
|
|
15
25
|
.command("list")
|
|
16
26
|
.description("List all HTML templates (static / image / video / asset, grouped by size)")
|
|
@@ -86,9 +96,149 @@ export function registerTemplates(program) {
|
|
|
86
96
|
if (opts.output) {
|
|
87
97
|
await fs.writeFile(opts.output, r.html, "utf-8");
|
|
88
98
|
success(`Saved → ${opts.output} (${r.size}, ${r.type}, ${r.params.length} custom params)`);
|
|
99
|
+
if (/default\.html$/.test(key) && !isJson()) {
|
|
100
|
+
process.stderr.write(kleur.dim(" ↳ for short-video platform safe zones (抖音/TikTok/视频号), see ") +
|
|
101
|
+
kleur.cyan("rf templates safezone") +
|
|
102
|
+
"\n");
|
|
103
|
+
}
|
|
89
104
|
}
|
|
90
105
|
else {
|
|
91
106
|
process.stdout.write(r.html);
|
|
92
107
|
}
|
|
93
108
|
});
|
|
109
|
+
tpl
|
|
110
|
+
.command("safezone [platform]")
|
|
111
|
+
.description("Reference safe-zone padding for short-video platforms (抖音 / TikTok / 视频号)")
|
|
112
|
+
.helpOption("-h, --help", "show help")
|
|
113
|
+
.addHelpText("after", [
|
|
114
|
+
"",
|
|
115
|
+
"Why this exists:",
|
|
116
|
+
" Default 1080×1920 templates render to the full canvas. When a short-video",
|
|
117
|
+
" app plays a 9:16 video on a taller phone (19.5:9 iPhone, 20:9/21:9 Android)",
|
|
118
|
+
" it cover-crops ~96-180px each side, AND overlays its own UI on top, bottom,",
|
|
119
|
+
" and the right action-button column. Important content near those edges gets",
|
|
120
|
+
" cut or covered.",
|
|
121
|
+
"",
|
|
122
|
+
" ReelForge does NOT bake platform-specific padding into the default template",
|
|
123
|
+
" (UI changes; device crop varies). Use this command to look up reference",
|
|
124
|
+
" numbers, then dial them into your own copy of default.html.",
|
|
125
|
+
"",
|
|
126
|
+
"Workflow:",
|
|
127
|
+
" rf templates show 1080x1920/default.html -o my-douyin.html",
|
|
128
|
+
" rf templates safezone douyin # copy the CSS diff",
|
|
129
|
+
" # paste the diff into my-douyin.html",
|
|
130
|
+
" rf create '...' --frame-template ./my-douyin.html",
|
|
131
|
+
"",
|
|
132
|
+
"Examples:",
|
|
133
|
+
" rf templates safezone # overview table for all platforms",
|
|
134
|
+
" rf templates safezone douyin # detailed CSS diff for 抖音",
|
|
135
|
+
" rf templates safezone tiktok",
|
|
136
|
+
" rf templates safezone wechat # 视频号",
|
|
137
|
+
" rf templates safezone --json # machine-readable",
|
|
138
|
+
].join("\n"))
|
|
139
|
+
.action((platform) => {
|
|
140
|
+
if (isJson()) {
|
|
141
|
+
if (platform) {
|
|
142
|
+
const zone = PLATFORM_SAFEZONES[platform.toLowerCase()];
|
|
143
|
+
if (!zone) {
|
|
144
|
+
print({ ok: false, error: `unknown platform: ${platform}`, known: Object.keys(PLATFORM_SAFEZONES) });
|
|
145
|
+
process.exit(1);
|
|
146
|
+
}
|
|
147
|
+
print({ ok: true, platform: platform.toLowerCase(), ...zone });
|
|
148
|
+
}
|
|
149
|
+
else {
|
|
150
|
+
print({ ok: true, platforms: PLATFORM_SAFEZONES });
|
|
151
|
+
}
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
if (platform) {
|
|
155
|
+
const key = platform.toLowerCase();
|
|
156
|
+
const zone = PLATFORM_SAFEZONES[key];
|
|
157
|
+
if (!zone) {
|
|
158
|
+
process.stderr.write(kleur.red(`✗ unknown platform: ${platform}\n`) +
|
|
159
|
+
` known: ${Object.keys(PLATFORM_SAFEZONES).join(", ")}\n`);
|
|
160
|
+
process.exit(1);
|
|
161
|
+
}
|
|
162
|
+
printPlatformDetail(key, zone);
|
|
163
|
+
}
|
|
164
|
+
else {
|
|
165
|
+
printPlatformOverview();
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
const PLATFORM_SAFEZONES = {
|
|
170
|
+
douyin: {
|
|
171
|
+
label: "Douyin (抖音)",
|
|
172
|
+
top: 250,
|
|
173
|
+
bottom: 400,
|
|
174
|
+
left: 150,
|
|
175
|
+
right: 250,
|
|
176
|
+
centerMax: 820,
|
|
177
|
+
reason: "Cover-crop on tall phones cuts ~96-180px per side; status bar ~250px; bottom caption+progress+buttons ~400px; right action-button column ~150px from the visible edge.",
|
|
178
|
+
},
|
|
179
|
+
tiktok: {
|
|
180
|
+
label: "TikTok",
|
|
181
|
+
top: 220,
|
|
182
|
+
bottom: 380,
|
|
183
|
+
left: 150,
|
|
184
|
+
right: 240,
|
|
185
|
+
centerMax: 820,
|
|
186
|
+
reason: "Same cover-crop behavior as Douyin; slightly smaller top tab area and bottom caption.",
|
|
187
|
+
},
|
|
188
|
+
wechat: {
|
|
189
|
+
label: "WeChat Channels (视频号)",
|
|
190
|
+
top: 200,
|
|
191
|
+
bottom: 350,
|
|
192
|
+
left: 120,
|
|
193
|
+
right: 120,
|
|
194
|
+
centerMax: 880,
|
|
195
|
+
reason: "Cover-crop similar; right-side action buttons are less obtrusive than 抖音/TikTok, so right padding can be smaller.",
|
|
196
|
+
},
|
|
197
|
+
};
|
|
198
|
+
function printPlatformOverview() {
|
|
199
|
+
process.stdout.write(kleur.bold("Reference safe-zone padding for 1080×1920 vertical video.\n") +
|
|
200
|
+
kleur.dim("Numbers cover ~90% of mainstream phones — verify on your own target devices.\n\n"));
|
|
201
|
+
table(Object.entries(PLATFORM_SAFEZONES).map(([key, z]) => ({
|
|
202
|
+
platform: key,
|
|
203
|
+
name: z.label,
|
|
204
|
+
top: z.top,
|
|
205
|
+
bottom: z.bottom,
|
|
206
|
+
left: z.left,
|
|
207
|
+
right: z.right,
|
|
208
|
+
"center-max": z.centerMax,
|
|
209
|
+
})));
|
|
210
|
+
process.stdout.write("\n" +
|
|
211
|
+
kleur.dim("CSS diff for a specific platform: ") +
|
|
212
|
+
kleur.cyan("rf templates safezone <platform>") +
|
|
213
|
+
"\n" +
|
|
214
|
+
kleur.dim("Start from default.html: ") +
|
|
215
|
+
kleur.cyan("rf templates show 1080x1920/default.html -o my.html") +
|
|
216
|
+
"\n");
|
|
217
|
+
}
|
|
218
|
+
function printPlatformDetail(key, z) {
|
|
219
|
+
process.stdout.write(kleur.bold(`${z.label} — safe-zone padding for 1080×1920 vertical video.\n\n`) +
|
|
220
|
+
kleur.dim("Why: ") +
|
|
221
|
+
z.reason +
|
|
222
|
+
"\n\n" +
|
|
223
|
+
kleur.bold("Apply this CSS to your custom template (copy of default.html):\n\n") +
|
|
224
|
+
kleur.cyan(buildCssDiff(z)) +
|
|
225
|
+
"\n" +
|
|
226
|
+
kleur.dim("Note: layout=blur-bg / letterbox push title/subtitle into matte zones\n" +
|
|
227
|
+
`(y=0..420 / 1500..1920) that overlap ${z.label}'s UI heavily. For ${key}\n` +
|
|
228
|
+
"investment, layout=full + the padding above is the safest combo.\n"));
|
|
229
|
+
}
|
|
230
|
+
function buildCssDiff(z) {
|
|
231
|
+
return [
|
|
232
|
+
` .title {`,
|
|
233
|
+
` top: ${z.top}px;`,
|
|
234
|
+
` max-width: ${z.centerMax}px;`,
|
|
235
|
+
` }`,
|
|
236
|
+
` .subtitle {`,
|
|
237
|
+
` bottom: ${z.bottom}px;`,
|
|
238
|
+
` max-width: ${z.centerMax}px;`,
|
|
239
|
+
` }`,
|
|
240
|
+
` body[data-brand-position$="left"] .brand-corner { left: ${z.left}px; }`,
|
|
241
|
+
` body[data-brand-position$="right"] .brand-corner { right: ${z.right}px; }`,
|
|
242
|
+
"",
|
|
243
|
+
].join("\n");
|
|
94
244
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "reelforge",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"description": "CLI for ReelForge Studio — AI video engine. Installs as both `reelforge` and the short alias `rf`. Every REST API exposed as a command, with --help on every level.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|