Skip to content

Commit 8924a0c

Browse files
authored
Merge pull request #5221 from nodetool-ai/claude/stoic-babbage-hpi44i
feat(marketing): rebuild the movie-trailer use case around storyboard and timeline
2 parents 73a0f8f + e496d12 commit 8924a0c

14 files changed

Lines changed: 425 additions & 112 deletions

marketing/public/llms.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@
3737

3838
- [Product Video](https://nodetool.ai/use-cases/product-video): Make product videos with AI workflows.
3939
- [Movie Poster](https://nodetool.ai/use-cases/movie-poster): Generate movie posters with AI workflows.
40-
- [Movie Trailer](https://nodetool.ai/use-cases/movie-trailer): Cut movie trailers with AI workflows.
40+
- [Movie Trailer](https://nodetool.ai/use-cases/movie-trailer): Storyboard a trailer, then cut it on the timeline.
4141

4242
## FAQ
4343

349 KB
Loading
211 KB
Loading

marketing/src/app/use-cases/movie-trailer/layout.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import type { Metadata, Viewport } from "next";
55

66
const TITLE = "AI Movie Trailer Generator | NodeTool use case";
77
const DESCRIPTION =
8-
"Type one logline and the canvas builds a cinematic teaser. A Director node storyboards it into shots, a text-to-image model renders the key art, and a video model animates and cuts it into a finished trailer, all on one open canvas with your own keys.";
8+
"Write one logline and get a storyboard back: a card per shot with a still on it. Approve the stills you like, animate those into clips, and cut the trailer on the built-in timeline — all in NodeTool, on your own provider keys.";
99

1010
export const metadata: Metadata = {
1111
title: TITLE,
@@ -20,8 +20,8 @@ export const metadata: Metadata = {
2020
"text to video workflow",
2121
"image to video",
2222
"AI film teaser",
23-
"storyboard automation",
24-
"AI video pipeline",
23+
"AI storyboard",
24+
"AI video editing timeline",
2525
"NodeTool use case",
2626
],
2727
openGraph: {
@@ -36,7 +36,7 @@ export const metadata: Metadata = {
3636
card: "summary_large_image",
3737
title: "AI Movie Trailer Generator",
3838
description:
39-
"From one logline to a cinematic teaser: a storyboard, key art, and a cut trailer, on one open canvas.",
39+
"From one logline to a cinematic teaser: a storyboard, a still per shot, and a cut trailer on the timeline.",
4040
},
4141
};
4242

marketing/src/app/use-cases/movie-trailer/page.tsx

Lines changed: 149 additions & 51 deletions
Large diffs are not rendered by default.

marketing/src/app/use-cases/useCaseEntries.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,8 @@ export const useCaseEntries: UseCaseEntry[] = [
2424
title: "Movie Trailer Generator",
2525
category: "Film",
2626
teaser:
27-
"Type one logline and the canvas builds a cinematic teaser. A Director node storyboards it into shots, each shot is rendered as key art, animated, and cut into a finished trailer.",
28-
pipeline: ["Logline", "Storyboard", "Shots", "Key art", "Trailer"],
27+
"Type one logline and get a storyboard back: a card per shot, a still on every card. Approve the ones you like, animate those, and cut the trailer on the timeline.",
28+
pipeline: ["Logline", "Storyboard", "Stills", "Clips", "Timeline"],
2929
video: "/movie_trailer_example.mp4",
3030
poster: "/trailer-shot-1-800.webp",
3131
accent: "amber",

marketing/src/data/staticEntries.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ export const staticEntries: PageEntry[] = [
3232
{ route: "/apps", title: "AI Mini Apps", description: "Ready-to-use AI mini apps built with NodeTool's App Builder.", priority: 0.8, changeFrequency: "weekly", indexable: true },
3333
{ route: "/use-cases/product-video", title: "Product Video", description: "Make product videos with AI workflows.", priority: 0.6, changeFrequency: "monthly", indexable: true },
3434
{ route: "/use-cases/movie-poster", title: "Movie Poster", description: "Generate movie posters with AI workflows.", priority: 0.6, changeFrequency: "monthly", indexable: true },
35-
{ route: "/use-cases/movie-trailer", title: "Movie Trailer", description: "Cut movie trailers with AI workflows.", priority: 0.6, changeFrequency: "monthly", indexable: true },
35+
{ route: "/use-cases/movie-trailer", title: "Movie Trailer", description: "Storyboard a trailer, then cut it on the timeline.", priority: 0.6, changeFrequency: "monthly", indexable: true },
3636
{ route: "/imprint", title: "Imprint", description: "Legal imprint.", priority: 0.3, changeFrequency: "yearly", indexable: true },
3737
{ route: "/privacy", title: "Privacy Policy", description: "Privacy policy.", priority: 0.3, changeFrequency: "yearly", indexable: true },
3838
{ route: "/terms", title: "Terms of Service", description: "Terms of service.", priority: 0.3, changeFrequency: "yearly", indexable: true },

marketing/src/data/useCaseEntries.ts

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ export const movieTrailerUseCase: UseCaseEntry = {
8787
route: "/use-cases/movie-trailer",
8888
howToName: "How to generate a movie trailer with AI in NodeTool",
8989
howToDescription:
90-
"Turn one logline into a storyboard, key art for every beat, and an animated, cut-together trailer on one canvas.",
90+
"Turn one logline into a storyboard, a still on every card, animated clips, and a finished cut on the timeline.",
9191
tools: [
9292
"NodeTool",
9393
"Gemini 3.1 Pro Preview",
@@ -96,47 +96,47 @@ export const movieTrailerUseCase: UseCaseEntry = {
9696
],
9797
steps: [
9898
{
99-
title: "Start with one line",
100-
body: "Type the logline. Two more inputs set the visual style and the shot count, that's the entire brief.",
99+
title: "Write the logline",
100+
body: "One line about the film, the look you want, and how many shots. That is the whole brief.",
101101
detail:
102102
"A getaway driver outruns a collapsing bridge · gritty daylight · 6 shots",
103103
},
104104
{
105105
title: "Direct the storyboard",
106-
body: "One Director node writes the screenplay: a shot for every beat, each with its own camera direction, under one style bible.",
107-
detail: "6 shots · framing, lens, angle, movement · one style bible",
106+
body: "Press Direct and the board fills with cards — one beat per card, with its action, camera, and motion, all under the look you asked for.",
107+
detail: "6 cards · framing, lens, angle, movement · one style bible",
108108
},
109109
{
110-
title: "Render the key art",
111-
body: "Screenplay Shots turns each shot into an image prompt — action, camera, style — and a text-to-image model renders it as a cinematic 16:9 frame.",
112-
detail: "2K · anamorphic framing · film grain · no on-screen text",
110+
title: "Approve the stills",
111+
body: "Every card renders a still first. Stills cost cents, so re-roll a card until it looks right — the rest of the board stays put.",
112+
detail: "Cinematic 16:9 key art · re-roll one card at a time",
113113
},
114114
{
115115
title: "Animate and cut",
116-
body: "An image-to-video model animates every frame, then a Concat node stitches them into one finished trailer.",
117-
detail: "Image-to-Video · 720p · auto-concatenated",
116+
body: "Animate only the cards you approved, then send the board to the timeline: the clips arrive in shot order, ready to trim, score, and export.",
117+
detail: "Image-to-video · clips in shot order · export an MP4",
118118
},
119119
],
120120
faq: [
121121
{
122122
question: "How do you make a movie trailer with AI?",
123123
answer:
124-
"Type a logline, a visual style, and a shot count. A Director node writes a screenplay with a camera direction per shot, an image model renders key art for every beat, a video model animates each frame, and a Concat node cuts them into one trailer.",
124+
"Write a logline, a visual style, and a shot count. NodeTool's storyboard turns that into a card per shot with action and camera notes, renders a still on each card, animates the ones you approve into clips, and sends the board to the timeline where you trim, score, and export the cut.",
125125
},
126126
{
127-
question: "Which models does this workflow use?",
127+
question: "Which models does this use?",
128128
answer:
129-
"Gemini 3.1 Pro Preview directs the storyboard, GPT Image-2 renders each shot's key art, and Veo 3.1 Preview animates the frames into video. All three run on your own provider keys.",
129+
"Gemini 3.1 Pro Preview directs the board, GPT Image-2 renders each card's still, and Veo 3.1 Preview animates the approved stills into clips. All three run on your own provider keys.",
130130
},
131131
{
132132
question: "Can I use a different video model?",
133133
answer:
134-
"Yes. Veo, Seedance, Kling, and Runway are one node apart. Change the video model and the storyboard, shots, and key art stay exactly as they were.",
134+
"Yes. Veo, Seedance, Kling, and Runway are a dropdown apart, and you can change it for a single shot. Your board, your stills, and the clips you already approved stay as they are.",
135135
},
136136
{
137137
question: "Do I need a video editor to cut the trailer?",
138138
answer:
139-
"No. The Concat node stitches the animated shots into a finished clip inside the workflow, and NodeTool's timeline editor is there when you want to re-cut it by hand.",
139+
"No. The timeline is built in. The approved clips land on a track in shot order, and you trim, reorder, lay music and voice underneath, and export a finished video without leaving NodeTool.",
140140
},
141141
],
142142
};

web/scripts/build-storyboard-cast-stills.mjs

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,12 @@ const OUT = path.join(WEB, "src/demo/assets/storyboardStills.ts");
1919

2020
/** One per `trailer-shot-<n>.png`, in cut order. */
2121
const STILLS = [
22-
{ name: "DEAD_ENGINE", shot: "the engine block blows out over the salt" },
23-
{ name: "THE_CHAIN", shot: "a rider swings a chain at the chase truck" },
24-
{ name: "THE_ROLLOVER", shot: "a wheel churns rock as the truck goes over" },
25-
{ name: "THE_BIKE", shot: "the bike cuts through the ruins" },
26-
{ name: "THE_CUT", shot: "an angle grinder throws sparks across the wreck" },
27-
{ name: "THE_GETAWAY", shot: "the car breaks away across open flats" },
22+
{ name: "THE_BLOWER", shot: "the supercharger spits fire down the straight" },
23+
{ name: "THE_CHAIN", shot: "a raider hauls the buggy in on a chain" },
24+
{ name: "THE_ROCK_BED", shot: "a rear wheel churns loose rock at the lens" },
25+
{ name: "THE_CHOPPER", shot: "the chopper runs flat out through the ruins" },
26+
{ name: "THE_CUT", shot: "a grinder throws sparks off a frame rail" },
27+
{ name: "THE_GETAWAY", shot: "the car breaks loose across the dry lake" },
2828
];
2929

3030
const uris = [];
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
// Captures the storyboard and timeline shots the movie-trailer use-case page uses.
2+
//
3+
// node scripts/screenshot-trailer-surfaces.mjs [--only storyboard,timeline] [--headed]
4+
//
5+
// Set PLAYWRIGHT_CHROMIUM_PATH to use a Chromium already on the machine.
6+
//
7+
// Both frames come out of the real editor components, not a mockup: the
8+
// storyboard cast drives `StoryboardBoard` and the timeline cast drives the
9+
// timeline editor, replayed at a fixed millisecond through /demo.html. The
10+
// media in both is the SCRAPHEART trailer already shipped on the marketing
11+
// site — the six `trailer-shot-*.png` stills, and segments of
12+
// `movie_trailer_example.mp4` pinned under demo/public/casts/promo.
13+
//
14+
// Backend-free by construction: the casts are authored, tRPC is cut, and the
15+
// stills are inline data URIs, so a re-run reproduces the same frames on any
16+
// machine with no server and no credits spent.
17+
18+
import { spawn, spawnSync } from "node:child_process";
19+
import fs from "node:fs";
20+
import path from "node:path";
21+
import { fileURLToPath } from "node:url";
22+
import { chromium } from "playwright";
23+
import sharp from "sharp";
24+
25+
const WEB = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
26+
const ROOT = path.resolve(WEB, "..");
27+
const OUT = path.join(ROOT, "marketing/public");
28+
const PORT = 5199;
29+
const BASE = `http://localhost:${PORT}`;
30+
31+
/** The pinned clips the timeline cast resolves, staged where the page looks. */
32+
const PROMO_ASSETS = path.join(ROOT, "demo/public/casts/promo");
33+
const STAGED_ASSETS = path.join(WEB, "public/demo-assets/promo-timeline");
34+
35+
/**
36+
* `atMs` is the frame. Storyboard: past the last keyframe, so all six cards
37+
* carry their still. Timeline: after the cut is assembled, so the four takes
38+
* sit on the video track with the score running under them.
39+
*/
40+
const SHOTS = [
41+
{
42+
id: "storyboard",
43+
url: `${BASE}/demo.html?doc=storyboard-assistant&t=24000&bare=1`,
44+
file: "trailer-storyboard.webp",
45+
// Tall enough for all six cards, with no dead space under them.
46+
width: 1600,
47+
height: 1230,
48+
},
49+
{
50+
id: "timeline",
51+
url: `${BASE}/demo.html?timeline=promo-timeline&t=7500&bare=1&assets=/demo-assets/promo-timeline`,
52+
file: "trailer-timeline.webp",
53+
width: 1600,
54+
height: 1000,
55+
},
56+
];
57+
58+
const args = process.argv.slice(2);
59+
const argValue = (flag) => {
60+
const i = args.indexOf(flag);
61+
return i >= 0 ? args[i + 1] : undefined;
62+
};
63+
const only = argValue("--only")?.split(",").map((s) => s.trim());
64+
const entries = SHOTS.filter((s) => !only || only.includes(s.id));
65+
66+
async function waitForServer(url, timeoutMs = 90000) {
67+
const start = Date.now();
68+
while (Date.now() - start < timeoutMs) {
69+
try {
70+
const res = await fetch(url);
71+
if (res.ok) return;
72+
} catch {
73+
// still booting
74+
}
75+
await new Promise((r) => setTimeout(r, 400));
76+
}
77+
throw new Error(`Vite did not come up at ${url}`);
78+
}
79+
80+
fs.rmSync(STAGED_ASSETS, { recursive: true, force: true });
81+
fs.cpSync(PROMO_ASSETS, STAGED_ASSETS, { recursive: true });
82+
83+
// A crashed earlier run can leave its dev server holding the port; without
84+
// this the next run screenshots a stale bundle instead of failing.
85+
spawnSync("pkill", ["-f", `vite --port ${PORT}`]);
86+
87+
const vite = spawn("npx", ["vite", "--port", String(PORT), "--strictPort"], {
88+
cwd: WEB,
89+
stdio: ["ignore", "pipe", "pipe"],
90+
});
91+
const viteLog = [];
92+
vite.stderr.on("data", (d) => viteLog.push(String(d)));
93+
94+
try {
95+
await waitForServer(BASE);
96+
// A sandbox whose Playwright browsers predate the installed version can
97+
// point at its own Chromium with PLAYWRIGHT_CHROMIUM_PATH.
98+
const browser = await chromium.launch({
99+
headless: !args.includes("--headed"),
100+
executablePath: process.env.PLAYWRIGHT_CHROMIUM_PATH || undefined,
101+
});
102+
103+
for (const shot of entries) {
104+
const page = await browser.newPage({
105+
viewport: { width: shot.width, height: shot.height },
106+
deviceScaleFactor: 2,
107+
colorScheme: "dark",
108+
});
109+
page.on("pageerror", (e) => console.error(`[page] ${shot.id}: ${e.message}`));
110+
// The casts need no backend. Cutting tRPC keeps a NodeTool server that
111+
// happens to run on this machine out of a frame that has to reproduce.
112+
await page.route(
113+
(url) => url.pathname.startsWith("/trpc/"),
114+
(route) => route.abort()
115+
);
116+
await page.goto(shot.url, { waitUntil: "domcontentloaded" });
117+
console.log(${shot.id}: loaded, waiting for the surface`);
118+
const ready = await page.waitForSelector("[data-ready]", { timeout: 60000 });
119+
if ((await ready.getAttribute("data-ready")) === "error") {
120+
throw new Error(`${shot.id}: ${await page.locator("pre").innerText()}`);
121+
}
122+
await page.waitForSelector("[data-demo-player]", { timeout: 30000 });
123+
// Every still decoded before the capture.
124+
await page.waitForFunction(
125+
() => Array.from(document.images).every((i) => i.complete && i.naturalWidth > 0),
126+
undefined,
127+
{ timeout: 30000 }
128+
);
129+
// Pinned clips too, when the surface has any. A clip whose codec this
130+
// browser refuses is reported rather than fatal: the tracks still render,
131+
// and the frame says plainly whether the preview came up.
132+
const unpainted = await page
133+
.waitForFunction(
134+
() =>
135+
Array.from(document.querySelectorAll("video")).every((v) => v.readyState >= 2),
136+
undefined,
137+
{ timeout: 20000 }
138+
)
139+
.then(() => 0)
140+
.catch(async () =>
141+
page.evaluate(
142+
() =>
143+
Array.from(document.querySelectorAll("video")).filter((v) => v.readyState < 2)
144+
.length
145+
)
146+
);
147+
if (unpainted) {
148+
console.warn(`! ${shot.id}: ${unpainted} clip(s) never painted`);
149+
}
150+
await page.waitForTimeout(1200);
151+
152+
const file = path.join(OUT, shot.file);
153+
const info = await sharp(await page.locator("[data-demo-player]").screenshot())
154+
.webp({ quality: 88 })
155+
.toFile(file);
156+
console.log(`✓ ${path.relative(ROOT, file)} (${info.width}×${info.height})`);
157+
await page.close();
158+
}
159+
160+
await browser.close();
161+
} catch (error) {
162+
process.stderr.write(viteLog.join(""));
163+
throw error;
164+
} finally {
165+
vite.kill("SIGTERM");
166+
fs.rmSync(STAGED_ASSETS, { recursive: true, force: true });
167+
}

0 commit comments

Comments
 (0)