A collaborative storyboard: planners, artists, and a director open the same board at the same time, break a script into cuts, generate the art, mark up the drawings with feedback, and sign off.
Runs on AWS. The drawings are generated by an open-weight model on our own GPU, not by a managed AWS model.
cdk deploy
plus one script for the accounts.u1 Kim Hana (planner) · u2 Lee Dohyun (artist) ·
u3 Park Seojun (director) · u4 Choi Yujin (reviewer) · u5 Jung Mina (admin).
You choose the password at deploy time · it is never stored in this repository.app-walkthrough/screens/| Area | Capability |
|---|---|
| Concurrent editing | Many people edit one board. Merging is per field, so nobody overwrites your sentence. You see other people's cursors, avatars, and "editing now" markers |
| Planning | Write the story from one prompt (Amazon Bedrock, Claude Sonnet): it invents the characters, the synopsis and the cut-by-cut flow. Options: new story / next episode / spin-off, genre, tone, runtime, cut count, how many characters to invent, whether to reuse the board's cast. You read the outline first, then it lands on the board. Or paste a script you already have and split it into cuts mechanically |
| Episodes | One board holds the main story plus its next episodes and spin-offs. Cuts belong to an episode; characters are shared across all of them |
| Characters | One character, many poses (front, 3/4, side, back, full body, expression). Pick one as the reference and the other poses follow that face |
| Art | Generate from a prompt, upload a hand sketch, or transform an existing drawing. Pick one of three open-weight models per shot. Versions stack up, can be compared side by side, and can be deleted |
| Roles | Five: planner, artist, director, reviewer, admin. Making art · generate, sketch upload, model swap · is for artists and planners. Story planning is for planners and directors. Approval is the director's, and so is the 관리 tab (assignment table, team load). Changing someone's role is the admin's. Three of those are enforced on the server (generation, planning, role changes); everything else is UI only (see the disclaimer) |
| Review | Pin notes and circle marks on the drawing, tags (framing, light, background, wardrobe, expression, proportion), @mentions, replies, resolve |
| Approval | Draft → in progress → in review → approved / changes requested. Buttons are locked for roles that don't own the action |
| Notifications | A director's change request notifies the assignee and hands the cut back to them |
| Output | Timeline (duration per scene), print / PDF |
Browser ──┬─ CloudFront ─ S3 static files, no build step
├─ Cognito login; name and role come from token claims
├─ AppSync ─ DynamoDB op log + realtime subscription (WebSocket)
├─ AppSync ─ Lambda ─ Bedrock story planning · async job, result polled from DynamoDB
└─ CloudFront /gen ─ ALB ─ EC2 g6e (L40S) ─ open-weight model ─ S3
Tech: plain ES modules in the browser (no framework, no bundler) · Amazon Cognito user pool
(HTTP calls, no SDK) · AWS AppSync GraphQL with JS resolvers · DynamoDB as an append-only log ·
Amazon Bedrock (Claude Sonnet, called from a Lambda as an async job · see below) ·
CloudFront + S3 · one EC2 g6e.2xlarge running FastAPI + PyTorch + 🤗 diffusers ·
AWS CDK (JavaScript) for all of it.
Story planning is two calls, not one. The outline (title, logline, synopsis, characters, beats)
comes back first and a person reads it; only then are the beats expanded into cuts, four beats per
call, in parallel. One call for a 24-cut board would sit near the resolver's time limit and lose
everything if it broke. The prompts live in the browser (app/story/story.js) so changing the wording
isn't a resolver redeploy, and the model's JSON is clipped and whitelisted by normalizePlan()
before any of it becomes an op · the model is a second trust boundary, not a trusted author.
Planning is an async job, because AppSync stops at 30 seconds. The AppSync quota "request
execution time" is 30 seconds and is not adjustable, so a slow model cannot be awaited inside the
request. The plan mutation invokes the Lambda with invocationType: 'Event' and returns a
jobId immediately; the Lambda calls Bedrock, then writes {status, text, usage, stop} into the
same DynamoDB table under PLAN#<jobId> with a one-hour TTL; the browser polls planResult(jobId)
every 1.5 s for up to 120 s. net.plan() hides all of it, so story.js still just awaits a
{text, usage, stop}. Only the account that started the job can read its result, and the Lambda
writes a result on failure too · otherwise the browser would wait out the full 120 s for nothing.
Three models, one GPU. Pick per shot. All three are Apache-2.0, so an organization can deploy them.
| Model | Good for | Reference image | |
|---|---|---|---|
chroma | Chroma1-Flash 8.9B | the default: 12 steps, ~15 s, the best pencil-storyboard texture | img2img · keeps the layout, redraws the face |
klein | FLUX.2 klein 4B | same character in a new shot; 8 steps, ~4 s | condition · keeps the face |
hd | Chroma1-HD 8.9B | Flash before distillation: 26 steps, for a final pass | img2img · keeps the layout, redraws the face |
The reference-image column is the reason there are three. chroma/hd paint over the image with
noise, so the layout survives and the face does not. klein takes it as a condition, so the face
survives · that's what makes "one character, many shots" work.
Only one of them fits in the card's 48 GB at a time, so picking a model swaps it: the server loads
the new weights in the background and refuses generation until they're resident (~1 min from disk).
The picker shows the wait. chroma is what a fresh deploy starts with.
The only truth on the board is the op log. One edit is one immutable op; on boot the client replays the log to build the screen. There is no separate stored state, which is why two people's edits can always be merged.
scrub() in app/story/core.js before they touch state. That is the trust boundary.Three directories, and that is the whole repository: app/ is the product, app-walkthrough/ is
everything that exists only for the guided example, infra/ is the AWS side. Anyone changing a
feature reads app/ and infra/ and can ignore app-walkthrough/ entirely.
Inside app/, files are grouped by who reads them. platform/ has no product opinion, chrome/ is
what every screen shares, story/ is the state rules and the ontology graph, and screens/ is one
file per screen. The four .html files stay at the top because that is where their URLs are.
app/ browser. no framework, no build. this directory becomes the bucket root
index.html home · the four steps in story order, and the login gate
board.html storyboard screen (markup and all CSS)
story-graph.html story develop + script-to-graph screen (markup and CSS)
key-visual.html key visual: script → one image per scene
screens/ one file per screen. each one imports from the folders below
home.js home: the step list, the project cards, the example button
board.js board, detail, viewer, markup, timeline, print
story-graph.js script → graph → seeds → branches → cuts → script
key-visual.js script → scenes → prompts → one image per scene
platform/ no product opinion. swap any of these and the screens do not notice
net.js transport. AppSync when configured, BroadcastChannel between tabs when not
auth.js Cognito login and token refresh · two HTTP calls, no SDK
login.js login screen
dom.js the only place that writes HTML into the DOM, plus escaping
chrome/ what every screen shares
nav-tabs.js the top tab bar, plus NAV_TABS (the step order)
theme.css the one set of colours and fonts (--sb-*) all four screens read
history.js "what happened" list · who did what, and continue-from-here
projects.js the project picker and the cards on home
coach.js coachmarks · the veil, the bubble, the keyboard handling
empty-panel.js the "처음 오셨나요?" panel every screen shows while empty
story/ the rules. no DOM in here
core.js status transitions, role permissions, field merge, op validation,
plan normalization. pure functions, testable without a browser
story.js story planning: the option set, the prompts, and the two-phase Bedrock
call. falls back to splitting the prompt when there is no model
graph-*.js the ontology graph: schema, layout engine, canvas view, probe queries
art/
art.js canvas-drawn placeholder art for local mode
seed-art.js generated by infra/scripts/seed-art.mjs; the starter board's images
aws-config.js `null` in the repo (that's what selects local mode); CDK overwrites it at deploy
test.html self-check · open it in a browser and it prints PASS/FAIL. not deployed
app-walkthrough/ example only. nothing under app/ is imported from here, and cannot be:
deploy puts app/* at the bucket root and this folder under its own prefix, so
a path from here back into app/ resolves on disk and 404s in production. The
screens inject what the example needs instead (tour.js `wire`). Delete this
folder and the example goes away; the product still runs.
tour.js the one walkthrough across screens · ?demo=1, which screen is next
guide.js the guide engine: the veil with a hole, the bubble, wait/done, the note lane
steps/ what each screen's example actually does, step by step
develop.js synopsis → graph → seeds → one branch
key-visual.js the sample script, its prompts, and browser-drawn stand-in art
board.js replays the seed ops one stage at a time, not all at once
data/ the fixtures the example replays · graph, seeds, stories, a synopsis
data/build.mjs rebuilds extracted-graph.json from the raw model response
screens/ screenshots and the key-visual mockup. not deployed
infra/
bin/app.js CDK entry point
lib/storyboard-stack.js the whole stack, one file
schema.graphql Op, Presence and PlanJob types, the queries/mutations, two subscriptions
resolvers/ AppSync JS resolvers · putOp, listOps, presence, plan (async job) + planResult,
and the four graph resolvers
graph/index.js the one Lambda: Neptune Gremlin queries + the Bedrock plan job
gpu/server.py FastAPI generation server: the three-model registry, one-resident-at-a-time
VRAM swapping, prompt assembly per model family, upload to S3
gpu/user-data.sh GPU boot script · pinned Python deps, systemd unit, model pre-download
scripts/users.sh create the five demo accounts
scripts/seed-art.mjs generate the starter board's images with the real model
scripts/stop.sh stop the Neptune cluster (it bills by the hour while it runs)
scripts/start.sh start it again · same endpoint, same data
node infra/scripts/serve-local.mjs
# http://localhost:8000 · no login, and multiple tabs sync with each other live
Use that script rather than python3 -m http.server: the deployed layout is not the repository
layout. CDK uploads app/* to the bucket root and app-walkthrough/* to a folder of that name, so
serving the repository directly gives 404 for / and /board.html. serve-local.mjs reproduces the
deployed paths, and it returns 403 for directory URLs exactly like CloudFront does · otherwise a
broken link works locally and only breaks in production.
With an empty app/aws-config.js the app runs in local mode (BroadcastChannel + localStorage) and
draws placeholder art instead of calling a GPU. Good enough to see concurrent editing work.
app/art/seed-art.js ships empty, so the starter board draws canvas placeholders · the same locally and
on a fresh deployment. scripts/seed-art.mjs fills it with real generated art once a GPU is up, but
those /img/<hash>.png paths only resolve in the account that generated them. That is why the file is
committed empty: any account gets the same board on the first boot.
Two commands: cdk deploy builds everything, scripts/users.sh creates the accounts. Four things
have to be true before the first one · none of them are things CDK can do for you.
ap-northeast-2. Not a preference. The stack is pinned there in four places: the
CloudFront origin-facing prefix list ID (pl-22a6434b · different in every region), the g6e AZ
list (only 2a and 2b sell it here), the global.anthropic.claude-sonnet-4-6 inference profile
in infra/resolvers/plan.js, and infra/scripts/seed-art.mjs. Elsewhere means editing all four.
Two cautions learned the hard way. Model prefix: us.* profiles exist only in US regions;
outside them use global.*, or the plan resolver returns a Bedrock error. Capacity vs.
availability: run-instances --dry-run does not verify either. It reports "would have
succeeded" for a type the region does not even sell, so use
aws ec2 describe-instance-types --region <r> --instance-types g6e.2xlarge to check that a region
offers the card at all, and expect capacity itself to be provable only by launching.
This was us-east-1, then us-west-2; g6e.2xlarge ran dry in every AZ of both.
ap-southeast-1 is not a fallback · it has no g6e at all (T4/T4g/A100 only).AccessDeniedException until it is. Check with
aws bedrock get-foundation-model-availability --region ap-northeast-2 --model-id anthropic.claude-sonnet-4-6
· you want "authorizationStatus": "AUTHORIZED".g6e.2xlarge needs 8 under Running On-Demand G and VT
instances (L-DB2E81BA); a new account can start at 0. Without it the instance fails to launch
and the stack rolls back. Request the increase first.Then npx cdk bootstrap once per account and region. cdk.context.json is not committed, so the
VPC lookup runs again on your first synth.
cd infra && npm ci
npx cdk deploy --outputs-file /tmp/sb-out.json # prints Url, UserPoolId, ClientId, GpuInstance
POOL=$(node -p "require('/tmp/sb-out.json').StoryboardDemo.UserPoolId")
SB_PW='<pick a password>' bash scripts/users.sh "$POOL" # the five demo accounts
SB_PW='<the same password>' node scripts/seed-art.mjs # optional: real art for the starter board
npx cdk deploy # only if you ran seed-art: it writes a local file
The password only ever arrives through SB_PW, never as an argument and never in a file · arguments
show up in shell history and ps. Leave SB_DEMO_PW unset: it prefills the login form for a private
demo, and setting it writes the password into aws-config.js, which anyone can read over CloudFront.
The GPU is only warm after it has pulled the model (tens of GB). When the generation-server chip in
the header changes from 모델 올리는 중 to the model name, it's ready. First boot takes a few minutes.
The GPU is one EC2 instance, and two EventBridge Scheduler rules stop and start it · weekdays
09:00–20:00 KST (GPU_HOURS in infra/lib/storyboard-stack.js, written in UTC). Outside those
hours the instance is stopped and the hourly charge is not running. About $370/month instead of
$1,600.
Say what this is precisely: it is a schedule, not idle detection. The GPU is on at 09:00 whether anyone is using it or not, and it goes down at 20:00 whether they are or not. Nothing wakes it up when a request arrives · outside the window, generation fails until someone starts it.
Editing GPU_HOURS is the whole configuration. The rules call ec2:StartInstances /
ec2:StopInstances directly through Scheduler's universal target, so there is no Lambda to read.
The root volume survives a stop. deleteOnTermination: false, so the ~67 GB of model weights on
/opt/hf are still there in the morning and nothing is re-downloaded · that is the only reason a
stop/start schedule is worth having here. Two consequences:
ec2.Instance, not an Auto Scaling group. An ASG terminates on
scale-in, so a retained volume would be left attached to nothing, billed monthly, and unusable by
whatever launched next.cdk destroy does not delete the volume. Delete it yourself afterwards or it keeps costing
~$27/month:
aws ec2 describe-volumes --filters Name=status,Values=available \
--query "Volumes[?Size==\`200\`].[VolumeId,CreateTime]" --output table
aws ec2 delete-volume --volume-id vol-…
On and off by hand, when the schedule isn't what you want:
GPU=$(aws cloudformation describe-stacks --stack-name StoryboardDemo \
--query "Stacks[0].Outputs[?OutputKey=='GpuInstance'].OutputValue" --output text)
aws ec2 stop-instances --instance-ids "$GPU" # off
aws ec2 start-instances --instance-ids "$GPU" # on
A manual start does not disable the schedule: the next 20:00 rule still stops it. To keep it up
overnight, disable the GpuOff schedule as well.
Stop the graph database too. Neptune runs on a provisioned instance (db.t4g.medium by default),
so it bills by the hour whether or not anyone queries it. Stopping keeps the data and the endpoint:
start.sh brings back the same cluster, and neither the graph Lambda nor the AppSync resolvers need
redeploying.
bash scripts/stop.sh # stop · takes a few minutes
bash scripts/start.sh # start · the graph Lambda errors until the status is `available`
Both default to cluster storyboarddemo-graph in ap-northeast-2; override with SB_NEPTUNE_CLUSTER
and AWS_REGION if you deployed the stack under another name or region.
Neptune restarts a stopped cluster by itself after 7 days. That is an AWS limit, not something the scripts can opt out of. If you are done with the demo for more than a week, either re-run
stop.sheach week ornpx cdk destroy· otherwise the cluster quietly comes back and bills.
A bigger instance takes no code change:
npx cdk deploy --context neptuneInstance=db.r6g.large
Tear everything down: npx cdk destroy · then delete the volume above.
Open the site in two windows and log in as different people · a new tab starts a fresh session, so
u2 in one window and u3 in the other. Both are looking at the same board.
Example: prompt → story → art → review → approval
u1). Press + 이야기 기획 in the sidebar. Type what the film is about, pick genre,
tone, runtime, cut count and how many characters to invent, then press 개요 만들기 (~10 s). Read
the outline · title, logline, synopsis, the characters it invented, the beat list · and press
이대로 컷 만들기. Characters, their pose sheets and the cuts all land on the board at once.
Assign a cut to u2.
With a story already on the board, the same dialog offers 다음 회차 and 스핀오프: those
land as a new episode in the sidebar and leave the existing cuts alone.
(Already have a script? 시나리오 → 컷으로 분해 still splits it mechanically, no model involved.)u2). Open 인물, add a character, write a short physical description, generate the
pose sheet. Mark one pose as the reference · later cuts with that character start from it.u2). Open the cut, edit 작업 지시, press 생성. ~15 s. Or upload a hand sketch and press
생성 with 구도 유지 to ink it. Every attempt becomes a version; the version strip compares them.u3). Open the drawing, click on it to drop a pin, drag to circle, tag it 조명, and
press 수정 요청 with a memo. u2 gets a notification and the cut comes back to them.u2 regenerates, presses 리뷰 요청, u3 presses 승인. The cut locks;
nobody can edit approved content until it's reopened.While you do this, watch the other window: everything lands there within a second, without a refresh.
| Item | Roughly |
|---|---|
EC2 g6e.2xlarge (L40S 48GB) | $2.24/hour · ~$54 for a full day (us-east-1 on-demand) |
| EBS 200GB gp3 @ 500 MB/s | ~$27/month (the extra throughput is what makes a model swap ~1 min instead of ~4) |
| ALB | ~$0.025/hour + traffic |
Neptune db.t4g.medium | ~$0.09/hour while running · $0 stopped, storage aside (scripts/stop.sh) |
| CloudFront · S3 · Cognito · AppSync · DynamoDB | a few dollars at demo scale |
GPU uptime is essentially the entire bill. Nothing shuts it down for you, and nothing shuts Neptune
down either · and Neptune wakes itself back up 7 days after you stop it.
| EC2 g6e.2xlarge (L40S 48GB) | $2.24/hour · ~$370/month on the default 09–20 weekday schedule · over $1,600 if it never stops |
| EBS 200GB gp3 @ 500 MB/s | ~$27/month, including the stopped hours · it is retained on purpose, and cdk destroy leaves it behind |
| ALB | ~$0.025/hour + traffic, ~$19/month · it does not stop with the GPU |
| CloudFront · S3 · Cognito · AppSync · DynamoDB | a few dollars at demo scale |
GPU uptime is essentially the entire bill, and the schedule is what bounds it. Everything else keeps running while the GPU is stopped: the ALB, the retained volume, and the board itself.
/img/* is cached for a year.retryAttempts: 0) for the same reason.This is a demo built to show a concept. It is not a product and is not in a state to put into production. Read all of the below before you deploy it yourself. The author takes no responsibility for damages, costs, or leaks resulting from using it as-is. This is not an official AWS product or sample and has no affiliation with AWS.
Cost · the GPU is $2.24/hour while it runs. The default schedule holds that to weekday
09:00–20:00 KST, about $370/month, but it is a fixed schedule and not a safety net: nothing notices
that nobody is using the instance, and a manual start stays up until the next 20:00 rule. The ALB and
the retained 200 GB volume are billed around the clock, and the volume outlives cdk destroy. There
is no budget alarm. Deploying starts the meter.
Security · fix these before deploying to an organization
The demo prioritized showing the idea, and the price was leaving the following open. It assumes a demo environment that only trusted people can reach.
custom:role changes take an admin API call. The display name is self-writable, though: a name in the roster is not proof of who someone is.putOp.js, and story planning in plan.js.)SB_DEMO_PW set writes the password into a public file (aws-config.js). Never set it for a public demo.Image generation · art is produced by open-weight models (Chroma1-Flash, FLUX.2 klein 4B,
Chroma1-HD) running on our own EC2 instance. You are responsible for checking the license of
each model and its weights and what you may do with the output. Bedrock writes the image prompts in
English, so they reach the GPU untouched. A prompt line someone typed Korean into by hand is
translated on the way in, and that call goes to Bedrock too (en() in infra/gpu/server.py, which
skips any ASCII string): every model call in the project, planning and translation alike, is a
Bedrock call. Output may not match the prompt, and the same seed produces different images when the
model or library versions change.
Data · board contents, uploaded sketches, and generated images are stored in this account's S3 and
DynamoDB. Encryption is S3 default (AES256) only, with no separate KMS key. Do not put real
production material or personal data in it. cdk destroy deletes the buckets and the table with it,
and that is irreversible.
Characters · the starter board's script, characters, and names (Yeorum Studio, Kim Hana, Lee Dohyun, Park Seojun, Choi Yujin) are fictional and exist only for the demo.
See CONTRIBUTING for more information.
This library is licensed under the MIT-0 License. See the LICENSE file.
JavaScript
68.5%
HTML
29.5%
Python
1.2%
A collaborative storyboard: planners, artists, and a director open the same board at the same time, break a script into cuts, generate the art, mark up the drawings with feedback, and sign off.
Runs on AWS. The drawings are generated by an open-weight model on our own GPU, not by a managed AWS model.
cdk deploy
plus one script for the accounts.u1 Kim Hana (planner) · u2 Lee Dohyun (artist) ·
u3 Park Seojun (director) · u4 Choi Yujin (reviewer) · u5 Jung Mina (admin).
You choose the password at deploy time · it is never stored in this repository.app-walkthrough/screens/| Area | Capability |
|---|---|
| Concurrent editing | Many people edit one board. Merging is per field, so nobody overwrites your sentence. You see other people's cursors, avatars, and "editing now" markers |
| Planning | Write the story from one prompt (Amazon Bedrock, Claude Sonnet): it invents the characters, the synopsis and the cut-by-cut flow. Options: new story / next episode / spin-off, genre, tone, runtime, cut count, how many characters to invent, whether to reuse the board's cast. You read the outline first, then it lands on the board. Or paste a script you already have and split it into cuts mechanically |
| Episodes | One board holds the main story plus its next episodes and spin-offs. Cuts belong to an episode; characters are shared across all of them |
| Characters | One character, many poses (front, 3/4, side, back, full body, expression). Pick one as the reference and the other poses follow that face |
| Art | Generate from a prompt, upload a hand sketch, or transform an existing drawing. Pick one of three open-weight models per shot. Versions stack up, can be compared side by side, and can be deleted |
| Roles | Five: planner, artist, director, reviewer, admin. Making art · generate, sketch upload, model swap · is for artists and planners. Story planning is for planners and directors. Approval is the director's, and so is the 관리 tab (assignment table, team load). Changing someone's role is the admin's. Three of those are enforced on the server (generation, planning, role changes); everything else is UI only (see the disclaimer) |
| Review | Pin notes and circle marks on the drawing, tags (framing, light, background, wardrobe, expression, proportion), @mentions, replies, resolve |
| Approval | Draft → in progress → in review → approved / changes requested. Buttons are locked for roles that don't own the action |
| Notifications | A director's change request notifies the assignee and hands the cut back to them |
| Output | Timeline (duration per scene), print / PDF |
Browser ──┬─ CloudFront ─ S3 static files, no build step
├─ Cognito login; name and role come from token claims
├─ AppSync ─ DynamoDB op log + realtime subscription (WebSocket)
├─ AppSync ─ Lambda ─ Bedrock story planning · async job, result polled from DynamoDB
└─ CloudFront /gen ─ ALB ─ EC2 g6e (L40S) ─ open-weight model ─ S3
Tech: plain ES modules in the browser (no framework, no bundler) · Amazon Cognito user pool
(HTTP calls, no SDK) · AWS AppSync GraphQL with JS resolvers · DynamoDB as an append-only log ·
Amazon Bedrock (Claude Sonnet, called from a Lambda as an async job · see below) ·
CloudFront + S3 · one EC2 g6e.2xlarge running FastAPI + PyTorch + 🤗 diffusers ·
AWS CDK (JavaScript) for all of it.
Story planning is two calls, not one. The outline (title, logline, synopsis, characters, beats)
comes back first and a person reads it; only then are the beats expanded into cuts, four beats per
call, in parallel. One call for a 24-cut board would sit near the resolver's time limit and lose
everything if it broke. The prompts live in the browser (app/story/story.js) so changing the wording
isn't a resolver redeploy, and the model's JSON is clipped and whitelisted by normalizePlan()
before any of it becomes an op · the model is a second trust boundary, not a trusted author.
Planning is an async job, because AppSync stops at 30 seconds. The AppSync quota "request
execution time" is 30 seconds and is not adjustable, so a slow model cannot be awaited inside the
request. The plan mutation invokes the Lambda with invocationType: 'Event' and returns a
jobId immediately; the Lambda calls Bedrock, then writes {status, text, usage, stop} into the
same DynamoDB table under PLAN#<jobId> with a one-hour TTL; the browser polls planResult(jobId)
every 1.5 s for up to 120 s. net.plan() hides all of it, so story.js still just awaits a
{text, usage, stop}. Only the account that started the job can read its result, and the Lambda
writes a result on failure too · otherwise the browser would wait out the full 120 s for nothing.
Three models, one GPU. Pick per shot. All three are Apache-2.0, so an organization can deploy them.
| Model | Good for | Reference image | |
|---|---|---|---|
chroma | Chroma1-Flash 8.9B | the default: 12 steps, ~15 s, the best pencil-storyboard texture | img2img · keeps the layout, redraws the face |
klein | FLUX.2 klein 4B | same character in a new shot; 8 steps, ~4 s | condition · keeps the face |
hd | Chroma1-HD 8.9B | Flash before distillation: 26 steps, for a final pass | img2img · keeps the layout, redraws the face |
The reference-image column is the reason there are three. chroma/hd paint over the image with
noise, so the layout survives and the face does not. klein takes it as a condition, so the face
survives · that's what makes "one character, many shots" work.
Only one of them fits in the card's 48 GB at a time, so picking a model swaps it: the server loads
the new weights in the background and refuses generation until they're resident (~1 min from disk).
The picker shows the wait. chroma is what a fresh deploy starts with.
The only truth on the board is the op log. One edit is one immutable op; on boot the client replays the log to build the screen. There is no separate stored state, which is why two people's edits can always be merged.
scrub() in app/story/core.js before they touch state. That is the trust boundary.Three directories, and that is the whole repository: app/ is the product, app-walkthrough/ is
everything that exists only for the guided example, infra/ is the AWS side. Anyone changing a
feature reads app/ and infra/ and can ignore app-walkthrough/ entirely.
Inside app/, files are grouped by who reads them. platform/ has no product opinion, chrome/ is
what every screen shares, story/ is the state rules and the ontology graph, and screens/ is one
file per screen. The four .html files stay at the top because that is where their URLs are.
app/ browser. no framework, no build. this directory becomes the bucket root
index.html home · the four steps in story order, and the login gate
board.html storyboard screen (markup and all CSS)
story-graph.html story develop + script-to-graph screen (markup and CSS)
key-visual.html key visual: script → one image per scene
screens/ one file per screen. each one imports from the folders below
home.js home: the step list, the project cards, the example button
board.js board, detail, viewer, markup, timeline, print
story-graph.js script → graph → seeds → branches → cuts → script
key-visual.js script → scenes → prompts → one image per scene
platform/ no product opinion. swap any of these and the screens do not notice
net.js transport. AppSync when configured, BroadcastChannel between tabs when not
auth.js Cognito login and token refresh · two HTTP calls, no SDK
login.js login screen
dom.js the only place that writes HTML into the DOM, plus escaping
chrome/ what every screen shares
nav-tabs.js the top tab bar, plus NAV_TABS (the step order)
theme.css the one set of colours and fonts (--sb-*) all four screens read
history.js "what happened" list · who did what, and continue-from-here
projects.js the project picker and the cards on home
coach.js coachmarks · the veil, the bubble, the keyboard handling
empty-panel.js the "처음 오셨나요?" panel every screen shows while empty
story/ the rules. no DOM in here
core.js status transitions, role permissions, field merge, op validation,
plan normalization. pure functions, testable without a browser
story.js story planning: the option set, the prompts, and the two-phase Bedrock
call. falls back to splitting the prompt when there is no model
graph-*.js the ontology graph: schema, layout engine, canvas view, probe queries
art/
art.js canvas-drawn placeholder art for local mode
seed-art.js generated by infra/scripts/seed-art.mjs; the starter board's images
aws-config.js `null` in the repo (that's what selects local mode); CDK overwrites it at deploy
test.html self-check · open it in a browser and it prints PASS/FAIL. not deployed
app-walkthrough/ example only. nothing under app/ is imported from here, and cannot be:
deploy puts app/* at the bucket root and this folder under its own prefix, so
a path from here back into app/ resolves on disk and 404s in production. The
screens inject what the example needs instead (tour.js `wire`). Delete this
folder and the example goes away; the product still runs.
tour.js the one walkthrough across screens · ?demo=1, which screen is next
guide.js the guide engine: the veil with a hole, the bubble, wait/done, the note lane
steps/ what each screen's example actually does, step by step
develop.js synopsis → graph → seeds → one branch
key-visual.js the sample script, its prompts, and browser-drawn stand-in art
board.js replays the seed ops one stage at a time, not all at once
data/ the fixtures the example replays · graph, seeds, stories, a synopsis
data/build.mjs rebuilds extracted-graph.json from the raw model response
screens/ screenshots and the key-visual mockup. not deployed
infra/
bin/app.js CDK entry point
lib/storyboard-stack.js the whole stack, one file
schema.graphql Op, Presence and PlanJob types, the queries/mutations, two subscriptions
resolvers/ AppSync JS resolvers · putOp, listOps, presence, plan (async job) + planResult,
and the four graph resolvers
graph/index.js the one Lambda: Neptune Gremlin queries + the Bedrock plan job
gpu/server.py FastAPI generation server: the three-model registry, one-resident-at-a-time
VRAM swapping, prompt assembly per model family, upload to S3
gpu/user-data.sh GPU boot script · pinned Python deps, systemd unit, model pre-download
scripts/users.sh create the five demo accounts
scripts/seed-art.mjs generate the starter board's images with the real model
scripts/stop.sh stop the Neptune cluster (it bills by the hour while it runs)
scripts/start.sh start it again · same endpoint, same data
node infra/scripts/serve-local.mjs
# http://localhost:8000 · no login, and multiple tabs sync with each other live
Use that script rather than python3 -m http.server: the deployed layout is not the repository
layout. CDK uploads app/* to the bucket root and app-walkthrough/* to a folder of that name, so
serving the repository directly gives 404 for / and /board.html. serve-local.mjs reproduces the
deployed paths, and it returns 403 for directory URLs exactly like CloudFront does · otherwise a
broken link works locally and only breaks in production.
With an empty app/aws-config.js the app runs in local mode (BroadcastChannel + localStorage) and
draws placeholder art instead of calling a GPU. Good enough to see concurrent editing work.
app/art/seed-art.js ships empty, so the starter board draws canvas placeholders · the same locally and
on a fresh deployment. scripts/seed-art.mjs fills it with real generated art once a GPU is up, but
those /img/<hash>.png paths only resolve in the account that generated them. That is why the file is
committed empty: any account gets the same board on the first boot.
Two commands: cdk deploy builds everything, scripts/users.sh creates the accounts. Four things
have to be true before the first one · none of them are things CDK can do for you.
ap-northeast-2. Not a preference. The stack is pinned there in four places: the
CloudFront origin-facing prefix list ID (pl-22a6434b · different in every region), the g6e AZ
list (only 2a and 2b sell it here), the global.anthropic.claude-sonnet-4-6 inference profile
in infra/resolvers/plan.js, and infra/scripts/seed-art.mjs. Elsewhere means editing all four.
Two cautions learned the hard way. Model prefix: us.* profiles exist only in US regions;
outside them use global.*, or the plan resolver returns a Bedrock error. Capacity vs.
availability: run-instances --dry-run does not verify either. It reports "would have
succeeded" for a type the region does not even sell, so use
aws ec2 describe-instance-types --region <r> --instance-types g6e.2xlarge to check that a region
offers the card at all, and expect capacity itself to be provable only by launching.
This was us-east-1, then us-west-2; g6e.2xlarge ran dry in every AZ of both.
ap-southeast-1 is not a fallback · it has no g6e at all (T4/T4g/A100 only).AccessDeniedException until it is. Check with
aws bedrock get-foundation-model-availability --region ap-northeast-2 --model-id anthropic.claude-sonnet-4-6
· you want "authorizationStatus": "AUTHORIZED".g6e.2xlarge needs 8 under Running On-Demand G and VT
instances (L-DB2E81BA); a new account can start at 0. Without it the instance fails to launch
and the stack rolls back. Request the increase first.Then npx cdk bootstrap once per account and region. cdk.context.json is not committed, so the
VPC lookup runs again on your first synth.
cd infra && npm ci
npx cdk deploy --outputs-file /tmp/sb-out.json # prints Url, UserPoolId, ClientId, GpuInstance
POOL=$(node -p "require('/tmp/sb-out.json').StoryboardDemo.UserPoolId")
SB_PW='<pick a password>' bash scripts/users.sh "$POOL" # the five demo accounts
SB_PW='<the same password>' node scripts/seed-art.mjs # optional: real art for the starter board
npx cdk deploy # only if you ran seed-art: it writes a local file
The password only ever arrives through SB_PW, never as an argument and never in a file · arguments
show up in shell history and ps. Leave SB_DEMO_PW unset: it prefills the login form for a private
demo, and setting it writes the password into aws-config.js, which anyone can read over CloudFront.
The GPU is only warm after it has pulled the model (tens of GB). When the generation-server chip in
the header changes from 모델 올리는 중 to the model name, it's ready. First boot takes a few minutes.
The GPU is one EC2 instance, and two EventBridge Scheduler rules stop and start it · weekdays
09:00–20:00 KST (GPU_HOURS in infra/lib/storyboard-stack.js, written in UTC). Outside those
hours the instance is stopped and the hourly charge is not running. About $370/month instead of
$1,600.
Say what this is precisely: it is a schedule, not idle detection. The GPU is on at 09:00 whether anyone is using it or not, and it goes down at 20:00 whether they are or not. Nothing wakes it up when a request arrives · outside the window, generation fails until someone starts it.
Editing GPU_HOURS is the whole configuration. The rules call ec2:StartInstances /
ec2:StopInstances directly through Scheduler's universal target, so there is no Lambda to read.
The root volume survives a stop. deleteOnTermination: false, so the ~67 GB of model weights on
/opt/hf are still there in the morning and nothing is re-downloaded · that is the only reason a
stop/start schedule is worth having here. Two consequences:
ec2.Instance, not an Auto Scaling group. An ASG terminates on
scale-in, so a retained volume would be left attached to nothing, billed monthly, and unusable by
whatever launched next.cdk destroy does not delete the volume. Delete it yourself afterwards or it keeps costing
~$27/month:
aws ec2 describe-volumes --filters Name=status,Values=available \
--query "Volumes[?Size==\`200\`].[VolumeId,CreateTime]" --output table
aws ec2 delete-volume --volume-id vol-…
On and off by hand, when the schedule isn't what you want:
GPU=$(aws cloudformation describe-stacks --stack-name StoryboardDemo \
--query "Stacks[0].Outputs[?OutputKey=='GpuInstance'].OutputValue" --output text)
aws ec2 stop-instances --instance-ids "$GPU" # off
aws ec2 start-instances --instance-ids "$GPU" # on
A manual start does not disable the schedule: the next 20:00 rule still stops it. To keep it up
overnight, disable the GpuOff schedule as well.
Stop the graph database too. Neptune runs on a provisioned instance (db.t4g.medium by default),
so it bills by the hour whether or not anyone queries it. Stopping keeps the data and the endpoint:
start.sh brings back the same cluster, and neither the graph Lambda nor the AppSync resolvers need
redeploying.
bash scripts/stop.sh # stop · takes a few minutes
bash scripts/start.sh # start · the graph Lambda errors until the status is `available`
Both default to cluster storyboarddemo-graph in ap-northeast-2; override with SB_NEPTUNE_CLUSTER
and AWS_REGION if you deployed the stack under another name or region.
Neptune restarts a stopped cluster by itself after 7 days. That is an AWS limit, not something the scripts can opt out of. If you are done with the demo for more than a week, either re-run
stop.sheach week ornpx cdk destroy· otherwise the cluster quietly comes back and bills.
A bigger instance takes no code change:
npx cdk deploy --context neptuneInstance=db.r6g.large
Tear everything down: npx cdk destroy · then delete the volume above.
Open the site in two windows and log in as different people · a new tab starts a fresh session, so
u2 in one window and u3 in the other. Both are looking at the same board.
Example: prompt → story → art → review → approval
u1). Press + 이야기 기획 in the sidebar. Type what the film is about, pick genre,
tone, runtime, cut count and how many characters to invent, then press 개요 만들기 (~10 s). Read
the outline · title, logline, synopsis, the characters it invented, the beat list · and press
이대로 컷 만들기. Characters, their pose sheets and the cuts all land on the board at once.
Assign a cut to u2.
With a story already on the board, the same dialog offers 다음 회차 and 스핀오프: those
land as a new episode in the sidebar and leave the existing cuts alone.
(Already have a script? 시나리오 → 컷으로 분해 still splits it mechanically, no model involved.)u2). Open 인물, add a character, write a short physical description, generate the
pose sheet. Mark one pose as the reference · later cuts with that character start from it.u2). Open the cut, edit 작업 지시, press 생성. ~15 s. Or upload a hand sketch and press
생성 with 구도 유지 to ink it. Every attempt becomes a version; the version strip compares them.u3). Open the drawing, click on it to drop a pin, drag to circle, tag it 조명, and
press 수정 요청 with a memo. u2 gets a notification and the cut comes back to them.u2 regenerates, presses 리뷰 요청, u3 presses 승인. The cut locks;
nobody can edit approved content until it's reopened.While you do this, watch the other window: everything lands there within a second, without a refresh.
| Item | Roughly |
|---|---|
EC2 g6e.2xlarge (L40S 48GB) | $2.24/hour · ~$54 for a full day (us-east-1 on-demand) |
| EBS 200GB gp3 @ 500 MB/s | ~$27/month (the extra throughput is what makes a model swap ~1 min instead of ~4) |
| ALB | ~$0.025/hour + traffic |
Neptune db.t4g.medium | ~$0.09/hour while running · $0 stopped, storage aside (scripts/stop.sh) |
| CloudFront · S3 · Cognito · AppSync · DynamoDB | a few dollars at demo scale |
GPU uptime is essentially the entire bill. Nothing shuts it down for you, and nothing shuts Neptune
down either · and Neptune wakes itself back up 7 days after you stop it.
| EC2 g6e.2xlarge (L40S 48GB) | $2.24/hour · ~$370/month on the default 09–20 weekday schedule · over $1,600 if it never stops |
| EBS 200GB gp3 @ 500 MB/s | ~$27/month, including the stopped hours · it is retained on purpose, and cdk destroy leaves it behind |
| ALB | ~$0.025/hour + traffic, ~$19/month · it does not stop with the GPU |
| CloudFront · S3 · Cognito · AppSync · DynamoDB | a few dollars at demo scale |
GPU uptime is essentially the entire bill, and the schedule is what bounds it. Everything else keeps running while the GPU is stopped: the ALB, the retained volume, and the board itself.
/img/* is cached for a year.retryAttempts: 0) for the same reason.This is a demo built to show a concept. It is not a product and is not in a state to put into production. Read all of the below before you deploy it yourself. The author takes no responsibility for damages, costs, or leaks resulting from using it as-is. This is not an official AWS product or sample and has no affiliation with AWS.
Cost · the GPU is $2.24/hour while it runs. The default schedule holds that to weekday
09:00–20:00 KST, about $370/month, but it is a fixed schedule and not a safety net: nothing notices
that nobody is using the instance, and a manual start stays up until the next 20:00 rule. The ALB and
the retained 200 GB volume are billed around the clock, and the volume outlives cdk destroy. There
is no budget alarm. Deploying starts the meter.
Security · fix these before deploying to an organization
The demo prioritized showing the idea, and the price was leaving the following open. It assumes a demo environment that only trusted people can reach.
custom:role changes take an admin API call. The display name is self-writable, though: a name in the roster is not proof of who someone is.putOp.js, and story planning in plan.js.)SB_DEMO_PW set writes the password into a public file (aws-config.js). Never set it for a public demo.Image generation · art is produced by open-weight models (Chroma1-Flash, FLUX.2 klein 4B,
Chroma1-HD) running on our own EC2 instance. You are responsible for checking the license of
each model and its weights and what you may do with the output. Bedrock writes the image prompts in
English, so they reach the GPU untouched. A prompt line someone typed Korean into by hand is
translated on the way in, and that call goes to Bedrock too (en() in infra/gpu/server.py, which
skips any ASCII string): every model call in the project, planning and translation alike, is a
Bedrock call. Output may not match the prompt, and the same seed produces different images when the
model or library versions change.
Data · board contents, uploaded sketches, and generated images are stored in this account's S3 and
DynamoDB. Encryption is S3 default (AES256) only, with no separate KMS key. Do not put real
production material or personal data in it. cdk destroy deletes the buckets and the table with it,
and that is irreversible.
Characters · the starter board's script, characters, and names (Yeorum Studio, Kim Hana, Lee Dohyun, Park Seojun, Choi Yujin) are fictional and exist only for the demo.
See CONTRIBUTING for more information.
This library is licensed under the MIT-0 License. See the LICENSE file.
JavaScript
68.5%
HTML
29.5%
Python
1.2%