diff --git a/public/template-media/CharacterJourney-16x9.mp4 b/public/template-media/CharacterJourney-16x9.mp4 new file mode 100644 index 0000000..b6d4f9b Binary files /dev/null and b/public/template-media/CharacterJourney-16x9.mp4 differ diff --git a/public/template-media/CharacterJourney-16x9.png b/public/template-media/CharacterJourney-16x9.png new file mode 100644 index 0000000..6c45230 Binary files /dev/null and b/public/template-media/CharacterJourney-16x9.png differ diff --git a/public/template-media/CharacterJourney-1x1.mp4 b/public/template-media/CharacterJourney-1x1.mp4 new file mode 100644 index 0000000..9e38176 Binary files /dev/null and b/public/template-media/CharacterJourney-1x1.mp4 differ diff --git a/public/template-media/CharacterJourney-1x1.png b/public/template-media/CharacterJourney-1x1.png new file mode 100644 index 0000000..fcbc76b Binary files /dev/null and b/public/template-media/CharacterJourney-1x1.png differ diff --git a/public/template-media/CharacterJourney-9x16.mp4 b/public/template-media/CharacterJourney-9x16.mp4 new file mode 100644 index 0000000..854908c Binary files /dev/null and b/public/template-media/CharacterJourney-9x16.mp4 differ diff --git a/public/template-media/CharacterJourney-9x16.png b/public/template-media/CharacterJourney-9x16.png new file mode 100644 index 0000000..42c9d01 Binary files /dev/null and b/public/template-media/CharacterJourney-9x16.png differ diff --git a/public/template-media/CharacterJourney.mp4 b/public/template-media/CharacterJourney.mp4 new file mode 100644 index 0000000..b6d4f9b Binary files /dev/null and b/public/template-media/CharacterJourney.mp4 differ diff --git a/scripts/seed_flexstory.py b/scripts/seed_flexstory.py new file mode 100644 index 0000000..571e825 --- /dev/null +++ b/scripts/seed_flexstory.py @@ -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-, choose_mode=FLEXIBLE). Each scene's KEY +# encodes its block: "__" — 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'' + +# 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)) diff --git a/services/node-agent/internal/runner/remotion.go b/services/node-agent/internal/runner/remotion.go index 774d822..9b8449b 100644 --- a/services/node-agent/internal/runner/remotion.go +++ b/services/node-agent/internal/runner/remotion.go @@ -57,6 +57,15 @@ func npxCmd() string { // (logoText, accentColor, …) and Value is the user's edited string. Anything the // user didn't touch falls back to the composition's defaultProps. 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)) for _, b := range job.Bindings { if b.Key == "" { diff --git a/services/render/internal/db/db.go b/services/render/internal/db/db.go index 20f21f9..f4bdd3d 100644 --- a/services/render/internal/db/db.go +++ b/services/render/internal/db/db.go @@ -2,6 +2,7 @@ package db import ( "context" + "encoding/json" "fmt" "strings" "time" @@ -739,6 +740,108 @@ func (s *Store) GetRenderBindings(ctx context.Context, savedProjectID uuid.UUID) 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 "__" (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) { // Look up the job to get tenant/user/project context job, err := s.getJobByIDInternal(ctx, jobID) diff --git a/services/render/internal/handlers/internal.go b/services/render/internal/handlers/internal.go index 4dff721..1748f02 100644 --- a/services/render/internal/handlers/internal.go +++ b/services/render/internal/handlers/internal.go @@ -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 // 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{ JobID: job.ID,