feat(render): Phase 2 — FlexStory render passthrough + journey template seed

Closes the render boundary so a user's scene list (order, per-scene content,
per-scene duration, theme) actually drives the FlexStory engine — the one gap the
scene-engine mapping found.

- render-svc GetFlexStoryProps (db.go): structured per-scene query that groups
  saved_scene_contents BY scene (the flat GetRenderBindings union collides when
  scenes share keys like "title"), recovers blockId from the scene key
  ("<BlockId>__<n>"), and emits the FlexStory props object
  {scenes:[{blockId,durationSec,props}], accentColor, …}.
- render-svc Claim (internal.go): when the template is Remotion + comp starts with
  "FlexStory", send that object as a single "__flexprops__" binding (no protocol
  struct change).
- node-agent remotionProps (remotion.go): if "__flexprops__" is present, pass it
  to `remotion render --props` verbatim (it's the complete props object).
- scripts/seed_flexstory.py: seeds the CharacterJourney template (7 scenes, theme
  colours, FLEXIBLE) with blockId-encoded scene keys, so the studio's existing
  CopyTemplateGraphAsync copies them into saved_scenes with zero studio-svc change.

Both Go services compile; template is live in the catalog (detail 200, per-aspect
previews). End-to-end render verification needs a live Remotion render node.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-23 13:45:04 +03:30
parent 2104dd3c84
commit f8ea9af3b6
11 changed files with 211 additions and 1 deletions
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 962 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 660 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 889 KiB

Binary file not shown.
+89
View File
@@ -0,0 +1,89 @@
#!/usr/bin/env python3
# Seeds FlexStory (scene-engine) templates into content.* so they appear on the
# site AND render through the new render-svc passthrough (GetFlexStoryProps).
#
# A FlexStory template = one container + 3 aspect projects (render_engine=Remotion,
# render_remotion_comp=FlexStory-<asp>, choose_mode=FLEXIBLE). Each scene's KEY
# encodes its block: "<BlockId>__<n>" — render-svc strips "__n" to recover blockId,
# and the per-scene content elements become that block's props. The studio's
# existing CopyTemplateGraphAsync copies key+contents+durations into saved_scenes,
# so no studio-svc change is needed.
#
# Usage: PYTHONIOENCODING=utf-8 python scripts/seed_flexstory.py | docker exec -i fr2-postgres psql -U postgres -d flatrender
import uuid
MINIO = "" # assets served from the Next app's public/ (relative URLs)
NS = uuid.UUID("11111111-2222-3333-4444-666666666666")
def uid(s): return str(uuid.uuid5(NS, s))
def q(s): return "'" + str(s).replace("'", "''") + "'"
ASPECTS = [("16x9", 1920, 1080, "16:9"), ("1x1", 1080, 1080, "1:1"), ("9x16", 1080, 1920, "9:16")]
CTITLES = {"accentColor": "رنگ اصلی", "secondaryColor": "رنگ دوم", "backgroundColor": "پس‌زمینه", "textColor": "رنگ متن"}
def icon_svg(hexv):
return f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10" fill="{hexv}"/></svg>'
# A FlexStory template: id, slug, name, desc, theme colors, and a scene list.
# Each scene: (blockId, default_sec, min_sec, max_sec, [(key, type, value)]).
TEMPLATES = [
{
"tid": "CharacterJourney", "slug": "fr-character-journey",
"name": "سفر شخصیت (موتور صحنه‌ای)",
"desc": "قالب داستان‌گویی منعطف با شخصیت؛ صحنه‌ها قابل افزودن، حذف، جابه‌جایی و تنظیم مدت. «از یک ایده تا واقعیت».",
"colors": ("#cf8a76", "#6f9d96", "#ece4d6", "#2b3a55"),
"scenes": [
("TitleCard", 4, 2, 8, [("kicker","Text","داستان شما"),("title","Text","از یک ایده تا واقعیت"),("subtitle","Text","چطور ایده‌ات را به یک ویدیوی حرفه‌ای تبدیل می‌کنی")]),
("CharacterScene", 3, 1, 6, [("title","Text","یک ایده"),("caption","Text","همه‌چیز با یک جرقهٔ کوچک شروع شد"),("character","Media","illustrations/dicebear/openpeeps-04.svg"),("prop","Text","cup")]),
("CharacterScene", 3, 1, 6, [("title","Text","اما سخت بود"),("caption","Text","ساختن یک ویدیوی حرفه‌ای پیچیده به‌نظر می‌رسید"),("character","Media","illustrations/dicebear/openpeeps-11.svg"),("prop","Text","none")]),
("CharacterScene", 3, 1, 6, [("title","Text","تا اینکه…"),("caption","Text","با فلت‌رندر آشنا شدم"),("character","Media","illustrations/dicebear/openpeeps-21.svg"),("prop","Text","laptop")]),
("Slideshow", 6, 3, 12, [("title","Text","چرا فلت‌رندر؟"),("slide1","Text","ساخت در چند دقیقه"),("slide2","Text","هزینهٔ بسیار پایین"),("slide3","Text","کیفیت حرفه‌ای"),("slide4","Text","")]),
("CharacterScene", 3, 1, 6, [("title","Text","و حالا…"),("caption","Text","داستان خودم را می‌سازم"),("character","Media","illustrations/dicebear/openpeeps-27.svg"),("prop","Text","plant")]),
("OutroCTA", 4, 2, 8, [("brandText","Text","فلت‌رندر"),("tagline","Text","همین حالا داستان خود را بساز"),("cta","Text","رایگان شروع کن")]),
],
},
]
out = ["BEGIN;"]
slugs = ",".join(q(t["slug"]) for t in TEMPLATES)
out.append(f"DELETE FROM content.project_containers WHERE slug IN ({slugs});")
for ti, tpl in enumerate(TEMPLATES):
tid, slug = tpl["tid"], tpl["slug"]
accent, sec, bg, txt = tpl["colors"]
colors = [("accentColor", accent), ("secondaryColor", sec), ("backgroundColor", bg), ("textColor", txt)]
dur_total = sum(s[1] for s in tpl["scenes"])
cid = uid("c-" + tid)
thumb16 = f"{MINIO}/template-media/{tid}-16x9.png"
preview = f"{MINIO}/template-media/{tid}.mp4"
out.append(
"INSERT INTO content.project_containers (id,tenant_id,slug,name,description,image,demo,full_demo,mini_demo,"
"is_published,is_premium,is_mockup,primary_mode,sort) VALUES ("
f"{q(cid)},NULL,{q(slug)},{q(tpl['name'])},{q(tpl['desc'])},{q(thumb16)},{q(preview)},{q(preview)},{q(preview)},"
f"TRUE,FALSE,FALSE,'FLEXIBLE',{ti});")
for (asp, w, h, aspstr) in ASPECTS:
pid = uid(f"p-{tid}-{asp}")
thumb = f"{MINIO}/template-media/{tid}-{asp}.png"
pvideo = f"{MINIO}/template-media/{tid}-{asp}.mp4"
out.append(
"INSERT INTO content.projects (id,container_id,name,image,full_demo,original_width,original_height,aspect,"
"project_duration_sec,free_fps,choose_mode,resolution,render_engine,render_remotion_comp,is_published,sort) VALUES ("
f"{q(pid)},{q(cid)},{q(aspstr)},{q(thumb)},{q(pvideo)},{w},{h},{q(aspstr)},"
f"{dur_total},30,'FLEXIBLE','FullHD','Remotion',{q('FlexStory-'+asp)},TRUE,0);")
for si, (blockId, dsec, dmin, dmax, fields) in enumerate(tpl["scenes"]):
skey = f"{blockId}__{si+1}" # blockId encoded in the unique key
sid = uid(f"s-{tid}-{asp}-{si}")
out.append(
"INSERT INTO content.scenes (id,project_id,key,title,default_duration_sec,min_duration_sec,max_duration_sec,sort) VALUES ("
f"{q(sid)},{q(pid)},{q(skey)},{q(blockId)},{dsec},{dmin},{dmax},{si});")
for pos, (k, typ, val) in enumerate(fields):
out.append(
"INSERT INTO content.scene_content_elements (id,scene_id,key,title,type,default_value,position_in_container,direction_layer_value) VALUES ("
f"{q(uid(f'ce-{tid}-{asp}-{si}-{k}'))},{q(sid)},{q(k)},{q(k)},{q(typ)},{q(val)},{pos},1);")
for ci, (k, hexv) in enumerate(colors):
out.append(
"INSERT INTO content.shared_colors (id,project_id,element_key,title,icon,attr_value,default_color,sort) VALUES ("
f"{q(uid(f'sc-{tid}-{asp}-{k}'))},{q(pid)},{q(k)},{q(CTITLES[k])},{q(icon_svg(hexv))},'fill',{q(hexv)},{ci});")
out.append("COMMIT;")
out.append("SELECT count(*) AS flexstory_containers FROM content.project_containers WHERE slug LIKE 'fr-character-journey';")
print("\n".join(out))
@@ -57,6 +57,15 @@ func npxCmd() string {
// (logoText, accentColor, …) and Value is the user's edited string. Anything the // (logoText, accentColor, …) and Value is the user's edited string. Anything the
// user didn't touch falls back to the composition's defaultProps. // user didn't touch falls back to the composition's defaultProps.
func remotionProps(job *Job) (string, error) { func remotionProps(job *Job) (string, error) {
// FlexStory (scene engine) passthrough: the orchestrator already built the full
// props object (scenes:[{blockId,durationSec,props}] + theme colours) as a single
// "__flexprops__" binding. Use it verbatim — it's a complete JSON object, not a
// flat key/value map.
for _, b := range job.Bindings {
if b.Key == "__flexprops__" {
return b.Value, nil
}
}
props := make(map[string]string, len(job.Bindings)) props := make(map[string]string, len(job.Bindings))
for _, b := range job.Bindings { for _, b := range job.Bindings {
if b.Key == "" { if b.Key == "" {
+103
View File
@@ -2,6 +2,7 @@ package db
import ( import (
"context" "context"
"encoding/json"
"fmt" "fmt"
"strings" "strings"
"time" "time"
@@ -739,6 +740,108 @@ func (s *Store) GetRenderBindings(ctx context.Context, savedProjectID uuid.UUID)
return out, rows.Err() return out, rows.Err()
} }
// GetFlexStoryProps builds the input-props object for a FlexStory (scene-engine)
// render. Unlike GetRenderBindings (a flat key/value union, which collides when
// several scenes share a key like "title"), this groups content BY SCENE and
// returns the structured shape FlexStory expects:
//
// { "scenes": [ {blockId, durationSec, props:{key:val}}, … ],
// "accentColor": "#…", "secondaryColor": "#…", … }
//
// blockId is encoded in the scene key as "<BlockId>__<n>" (n keeps the per-project
// UNIQUE(key) happy across repeated blocks). Returns a JSON string ready for
// `npx remotion render … --props=`.
func (s *Store) GetFlexStoryProps(ctx context.Context, savedProjectID uuid.UUID) (string, error) {
type sceneOut struct {
BlockID string `json:"blockId"`
DurationSec float64 `json:"durationSec"`
Props map[string]string `json:"props"`
}
rows, err := s.pool.Query(ctx,
`SELECT id, key, scene_length_sec FROM studio.saved_scenes
WHERE saved_project_id = $1 ORDER BY sort`, savedProjectID)
if err != nil {
return "", err
}
var scenes []*sceneOut
byID := map[int64]*sceneOut{}
for rows.Next() {
var id int64
var key string
var dur float64
if err := rows.Scan(&id, &key, &dur); err != nil {
rows.Close()
return "", err
}
blockID := key
if i := strings.Index(key, "__"); i >= 0 {
blockID = key[:i]
}
sc := &sceneOut{BlockID: blockID, DurationSec: dur, Props: map[string]string{}}
scenes = append(scenes, sc)
byID[id] = sc
}
rows.Close()
if err := rows.Err(); err != nil {
return "", err
}
// per-scene content values → that scene's props
crows, err := s.pool.Query(ctx, `
SELECT c.saved_scene_id, c.key, COALESCE(c.value, '')
FROM studio.saved_scene_contents c
JOIN studio.saved_scenes s ON s.id = c.saved_scene_id
WHERE s.saved_project_id = $1 AND c.value IS NOT NULL AND c.value <> ''`,
savedProjectID)
if err != nil {
return "", err
}
for crows.Next() {
var sid int64
var k, v string
if err := crows.Scan(&sid, &k, &v); err != nil {
crows.Close()
return "", err
}
if sc := byID[sid]; sc != nil {
sc.Props[k] = v
}
}
crows.Close()
if err := crows.Err(); err != nil {
return "", err
}
out := map[string]any{"scenes": scenes}
// project-wide theme colours → top-level props (accentColor, …)
clr, err := s.pool.Query(ctx,
`SELECT element_key, value FROM studio.saved_shared_colors
WHERE saved_project_id = $1 AND value IS NOT NULL AND value <> ''`, savedProjectID)
if err != nil {
return "", err
}
for clr.Next() {
var k, v string
if err := clr.Scan(&k, &v); err != nil {
clr.Close()
return "", err
}
out[k] = v
}
clr.Close()
if err := clr.Err(); err != nil {
return "", err
}
data, err := json.Marshal(out)
if err != nil {
return "", err
}
return string(data), nil
}
func (s *Store) CreateExportForJob(ctx context.Context, jobID uuid.UUID) (*models.Export, error) { func (s *Store) CreateExportForJob(ctx context.Context, jobID uuid.UUID) (*models.Export, error) {
// Look up the job to get tenant/user/project context // Look up the job to get tenant/user/project context
job, err := s.getJobByIDInternal(ctx, jobID) job, err := s.getJobByIDInternal(ctx, jobID)
+10 -1
View File
@@ -321,7 +321,16 @@ func (h *InternalHandler) Claim(c *gin.Context) {
// User's edited input values → the node writes them into the AE project before // User's edited input values → the node writes them into the AE project before
// rendering, or passes them as Remotion --props. Non-fatal: empty → template defaults. // rendering, or passes them as Remotion --props. Non-fatal: empty → template defaults.
bindings, _ := h.store.GetRenderBindings(c.Request.Context(), job.SavedProjectID) // FlexStory (scene engine) needs the structured per-scene shape (grouped by scene
// + per-scene duration + theme colours); everything else uses the flat union.
var bindings []models.RenderBinding
if rcfg.Engine == "Remotion" && strings.HasPrefix(rcfg.CompName, "FlexStory") {
if flex, ferr := h.store.GetFlexStoryProps(c.Request.Context(), job.SavedProjectID); ferr == nil && flex != "" {
bindings = []models.RenderBinding{{Key: "__flexprops__", Type: "json", Value: flex}}
}
} else {
bindings, _ = h.store.GetRenderBindings(c.Request.Context(), job.SavedProjectID)
}
c.JSON(http.StatusOK, models.ClaimedJob{ c.JSON(http.StatusOK, models.ClaimedJob{
JobID: job.ID, JobID: job.ID,