feat(studio): theme picker — 4-color brand theme + curated preset swatches

Extends the studio's 2-color palette to the full 4-color brand theme
(accent / secondary / background / text) matching the Remotion SceneColors,
so the studio's colour state maps 1:1 to the scene engine.

- studio-store: add sceneSecondaryColor + sceneTextColor + their setters + an
  applySceneTheme(accent,secondary,background,text) action (sets all four +
  recolours canvas layers: bg→background, overlays→secondary, shapes→accent,
  text→text explicitly); persist both new fields in hydrate + getSceneDataForSave.
- studio-scene-data: carry sceneSecondaryColor + sceneTextColor through
  VideoPersistedSceneData / build / parse (with defaults).
- ColorsCustomTab: 6 one-click theme presets (Warm/Berry/Midnight/Ocean/Sunset/
  Mono) + 4 manual colour inputs + Apply.
- i18n: secondaryColor/textColor/themePresets/applyTheme(+preset) in fa + en.

Verified with `npm run build`. NOTE: the theme persists in scene_data and
recolours the canvas; wiring the 4 colours all the way to a FlexStory render's
saved_shared_colors depends on the studio-svc shared-colour sync (a small
follow-up). Block-FIELD editing remains the Phase 4 follow-up.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-23 14:56:14 +03:30
parent 8ddca5647b
commit 8582e956c9
5 changed files with 161 additions and 46 deletions
+7 -2
View File
@@ -1148,8 +1148,13 @@
"description": "Generate voiceovers from your script directly in the studio." "description": "Generate voiceovers from your script directly in the studio."
}, },
"componentsStudioSidebarColorsCustomTab": { "componentsStudioSidebarColorsCustomTab": {
"mainColor": "Main Color", "mainColor": "Background",
"additionalColor": "Additional Color", "additionalColor": "Accent",
"secondaryColor": "Secondary",
"textColor": "Text",
"themePresets": "Themes",
"applyThemePreset": "Apply {name} theme",
"applyTheme": "Apply theme",
"applyToAllScenes": "Apply to all scenes" "applyToAllScenes": "Apply to all scenes"
}, },
"componentsStudioSidebarColorsPalettesTab": { "componentsStudioSidebarColorsPalettesTab": {
+7 -2
View File
@@ -1148,8 +1148,13 @@
"description": "صداگذاری را مستقیماً از روی متن خود در استودیو بسازید." "description": "صداگذاری را مستقیماً از روی متن خود در استودیو بسازید."
}, },
"componentsStudioSidebarColorsCustomTab": { "componentsStudioSidebarColorsCustomTab": {
"mainColor": "رنگ اصلی", "mainColor": "پس‌زمینه",
"additionalColor": "رنگ مکمل", "additionalColor": "رنگ اصلی",
"secondaryColor": "رنگ دوم",
"textColor": "رنگ متن",
"themePresets": "تم‌ها",
"applyThemePreset": "اعمال تم {name}",
"applyTheme": "اعمال تم",
"applyToAllScenes": "اعمال به همه صحنه‌ها" "applyToAllScenes": "اعمال به همه صحنه‌ها"
}, },
"componentsStudioSidebarColorsPalettesTab": { "componentsStudioSidebarColorsPalettesTab": {
@@ -4,63 +4,91 @@ import { Button } from "@/components/ui/button";
import { useStudioStore } from "@/lib/studio-store"; import { useStudioStore } from "@/lib/studio-store";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
/** Curated brand themes (accent / secondary / background / text) — the cohesion
* lever: one click re-skins the whole project. Mirrors the Remotion theme set. */
const THEME_PRESETS: { id: string; colors: [string, string, string, string] }[] = [
{ id: "warm", colors: ["#cf8a76", "#6f9d96", "#ece4d6", "#2b3a55"] },
{ id: "berry", colors: ["#d6477e", "#8b5cf6", "#f3ebf0", "#3b2440"] },
{ id: "midnight", colors: ["#7c93ff", "#22d3ee", "#11141f", "#eef1f8"] },
{ id: "ocean", colors: ["#2a9d8f", "#4895ef", "#eaf4f4", "#1d3557"] },
{ id: "sunset", colors: ["#f4795b", "#f9c74f", "#fff4e6", "#4a2c2a"] },
{ id: "mono", colors: ["#b08d57", "#8a8a8a", "#f5f3ee", "#1a1a1a"] },
];
export function ColorsCustomTab() { export function ColorsCustomTab() {
const t = useTranslations("auto.componentsStudioSidebarColorsCustomTab"); const t = useTranslations("auto.componentsStudioSidebarColorsCustomTab");
const sceneBackgroundColor = useStudioStore( const sceneBackgroundColor = useStudioStore((s) => s.sceneBackgroundColor);
(state) => state.sceneBackgroundColor const sceneAccentColor = useStudioStore((s) => s.sceneAccentColor);
); const sceneSecondaryColor = useStudioStore((s) => s.sceneSecondaryColor);
const sceneAccentColor = useStudioStore((state) => state.sceneAccentColor); const sceneTextColor = useStudioStore((s) => s.sceneTextColor);
const setSceneBackgroundColor = useStudioStore( const setSceneBackgroundColor = useStudioStore((s) => s.setSceneBackgroundColor);
(state) => state.setSceneBackgroundColor const setSceneAccentColor = useStudioStore((s) => s.setSceneAccentColor);
); const setSceneSecondaryColor = useStudioStore((s) => s.setSceneSecondaryColor);
const setSceneAccentColor = useStudioStore((state) => state.setSceneAccentColor); const setSceneTextColor = useStudioStore((s) => s.setSceneTextColor);
const applyPaletteToAllScenes = useStudioStore( const applySceneTheme = useStudioStore((s) => s.applySceneTheme);
(state) => state.applyPaletteToAllScenes
const swatch = (
id: string,
label: string,
value: string,
onChange: (c: string) => void
) => (
<div className="flex items-center justify-between gap-2">
<label htmlFor={id} className="shrink-0 text-xs text-gray-600">
{label}
</label>
<input
id={id}
type="color"
value={value}
onChange={(e) => onChange(e.target.value)}
className="h-8 w-12 cursor-pointer rounded border border-gray-200 bg-white p-0.5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
/>
</div>
); );
return ( return (
<div className="space-y-4 py-2"> <div className="space-y-4 py-2">
<div className="flex items-center justify-between gap-2"> {/* Preset themes — one-click cohesive re-skin */}
<label <div>
htmlFor="colors-main" <p className="mb-2 text-xs text-gray-600">{t("themePresets")}</p>
className="shrink-0 text-xs text-gray-600" <div className="grid grid-cols-3 gap-2">
> {THEME_PRESETS.map((preset) => (
{t("mainColor")} <button
</label> key={preset.id}
<input type="button"
id="colors-main" onClick={() => applySceneTheme(...preset.colors)}
type="color" aria-label={t("applyThemePreset", { name: preset.id })}
value={sceneBackgroundColor} className="flex h-9 overflow-hidden rounded-md border border-gray-200 transition hover:ring-2 hover:ring-blue-500"
onChange={(event) => setSceneBackgroundColor(event.target.value)} >
className="h-8 w-12 cursor-pointer rounded border border-gray-200 bg-white p-0.5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500" {preset.colors.map((c, i) => (
/> <span key={i} className="flex-1" style={{ background: c }} />
))}
</button>
))}
</div>
</div> </div>
<div className="flex items-center justify-between gap-2"> {/* Manual 4-color tweak */}
<label {swatch("colors-bg", t("mainColor"), sceneBackgroundColor, setSceneBackgroundColor)}
htmlFor="colors-additional" {swatch("colors-accent", t("additionalColor"), sceneAccentColor, setSceneAccentColor)}
className="shrink-0 text-xs text-gray-600" {swatch("colors-secondary", t("secondaryColor"), sceneSecondaryColor, setSceneSecondaryColor)}
> {swatch("colors-text", t("textColor"), sceneTextColor, setSceneTextColor)}
{t("additionalColor")}
</label>
<input
id="colors-additional"
type="color"
value={sceneAccentColor}
onChange={(event) => setSceneAccentColor(event.target.value)}
className="h-8 w-12 cursor-pointer rounded border border-gray-200 bg-white p-0.5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
/>
</div>
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
className="w-full border-gray-200 bg-white text-gray-200 hover:bg-gray-100 hover:text-gray-900" className="w-full border-gray-200 bg-white text-gray-700 hover:bg-gray-100 hover:text-gray-900"
onClick={() => onClick={() =>
applyPaletteToAllScenes(sceneBackgroundColor, sceneAccentColor) applySceneTheme(
sceneAccentColor,
sceneSecondaryColor,
sceneBackgroundColor,
sceneTextColor
)
} }
> >
{t("applyToAllScenes")} {t("applyTheme")}
</Button> </Button>
</div> </div>
); );
+12
View File
@@ -172,6 +172,8 @@ export interface VideoPersistedSceneData {
audioVolume?: number; audioVolume?: number;
sceneBackgroundColor?: string; sceneBackgroundColor?: string;
sceneAccentColor?: string; sceneAccentColor?: string;
sceneSecondaryColor?: string;
sceneTextColor?: string;
/** Project render mode (FIX / FLEXIBLE / MusicVisualizer / …). FIX/MusicVisualizer /** Project render mode (FIX / FLEXIBLE / MusicVisualizer / …). FIX/MusicVisualizer
* scenes come from AE layer names, so adding scenes is disabled. */ * scenes come from AE layer names, so adding scenes is disabled. */
chooseMode?: string; chooseMode?: string;
@@ -190,6 +192,8 @@ export function buildVideoSceneDataPayload(
audioVolume: input.audioVolume, audioVolume: input.audioVolume,
sceneBackgroundColor: input.sceneBackgroundColor, sceneBackgroundColor: input.sceneBackgroundColor,
sceneAccentColor: input.sceneAccentColor, sceneAccentColor: input.sceneAccentColor,
sceneSecondaryColor: input.sceneSecondaryColor,
sceneTextColor: input.sceneTextColor,
}; };
} }
@@ -225,6 +229,14 @@ export function parseVideoSceneData(
typeof sceneData.sceneAccentColor === "string" typeof sceneData.sceneAccentColor === "string"
? sceneData.sceneAccentColor ? sceneData.sceneAccentColor
: DEFAULT_SCENE_ACCENT_COLOR, : DEFAULT_SCENE_ACCENT_COLOR,
sceneSecondaryColor:
typeof sceneData.sceneSecondaryColor === "string"
? sceneData.sceneSecondaryColor
: DEFAULT_SCENE_ACCENT_COLOR,
sceneTextColor:
typeof sceneData.sceneTextColor === "string"
? sceneData.sceneTextColor
: "#111827",
chooseMode: chooseMode:
typeof sceneData.chooseMode === "string" typeof sceneData.chooseMode === "string"
? sceneData.chooseMode ? sceneData.chooseMode
+65
View File
@@ -159,6 +159,10 @@ export interface StudioState {
audioVolume: number; audioVolume: number;
sceneBackgroundColor: string; sceneBackgroundColor: string;
sceneAccentColor: string; sceneAccentColor: string;
/** The 4-color brand theme (matches Remotion SceneColors; secondary+text added
* for the scene-engine theme picker). */
sceneSecondaryColor: string;
sceneTextColor: string;
/** Project render mode (FIX / FLEXIBLE / MusicVisualizer / …). Empty until hydrated. */ /** Project render mode (FIX / FLEXIBLE / MusicVisualizer / …). Empty until hydrated. */
chooseMode: string; chooseMode: string;
past: StudioHistorySnapshot[]; past: StudioHistorySnapshot[];
@@ -197,7 +201,12 @@ export interface StudioActions {
setAudioVolume: (volume: number) => void; setAudioVolume: (volume: number) => void;
setSceneBackgroundColor: (color: string) => void; setSceneBackgroundColor: (color: string) => void;
setSceneAccentColor: (color: string) => void; setSceneAccentColor: (color: string) => void;
setSceneSecondaryColor: (color: string) => void;
setSceneTextColor: (color: string) => void;
applyPaletteToAllScenes: (mainColor: string, accentColor: string) => void; applyPaletteToAllScenes: (mainColor: string, accentColor: string) => void;
/** Apply a full 4-color brand theme (accent/secondary/background/text) to the
* project + recolor canvas layers. */
applySceneTheme: (accent: string, secondary: string, background: string, text: string) => void;
applyTransitionToAllScenes: (transitionType: SceneTransition) => void; applyTransitionToAllScenes: (transitionType: SceneTransition) => void;
applyFontFamilyToAllTextLayers: (fontFamily: string) => void; applyFontFamilyToAllTextLayers: (fontFamily: string) => void;
hydrateFromSceneData: (sceneData: Record<string, unknown>) => boolean; hydrateFromSceneData: (sceneData: Record<string, unknown>) => boolean;
@@ -251,6 +260,8 @@ export const useStudioStore = create<StudioStore>((set, get) => {
audioVolume: 100, audioVolume: 100,
sceneBackgroundColor: DEFAULT_SCENE_BACKGROUND_COLOR, sceneBackgroundColor: DEFAULT_SCENE_BACKGROUND_COLOR,
sceneAccentColor: DEFAULT_SCENE_ACCENT_COLOR, sceneAccentColor: DEFAULT_SCENE_ACCENT_COLOR,
sceneSecondaryColor: DEFAULT_SCENE_ACCENT_COLOR,
sceneTextColor: "#111827",
chooseMode: "", chooseMode: "",
past: [], past: [],
future: [], future: [],
@@ -638,6 +649,10 @@ export const useStudioStore = create<StudioStore>((set, get) => {
setSceneAccentColor: (color) => set({ sceneAccentColor: color }), setSceneAccentColor: (color) => set({ sceneAccentColor: color }),
setSceneSecondaryColor: (color) => set({ sceneSecondaryColor: color }),
setSceneTextColor: (color) => set({ sceneTextColor: color }),
applyPaletteToAllScenes: (mainColor, accentColor) => { applyPaletteToAllScenes: (mainColor, accentColor) => {
const state = get(); const state = get();
@@ -712,6 +727,51 @@ export const useStudioStore = create<StudioStore>((set, get) => {
scheduleActiveSceneThumbnailUpdate(); scheduleActiveSceneThumbnailUpdate();
}, },
applySceneTheme: (accent, secondary, background, text) => {
const state = get();
// Same canvas-shape heuristic as applyPaletteToAllScenes, but with an explicit
// text color (not auto-contrast) and a secondary accent for overlay shapes.
const isCanvasBg = (layer: Layer) =>
layer.type === "shape" &&
layer.zIndex === 0 &&
layer.x <= 10 &&
layer.y <= 10 &&
layer.width >= 1200 &&
layer.height >= 600;
const isOverlay = (layer: Layer) =>
layer.type === "shape" && layer.zIndex >= 2 && layer.opacity <= 0.65;
const nextScenes = state.scenes.map((scene) => ({
...scene,
layers: scene.layers.map((layer) => {
if (isCanvasBg(layer)) {
return { ...layer, props: { ...layer.props, fill: background, stroke: background } };
}
if (isOverlay(layer)) {
return { ...layer, props: { ...layer.props, fill: secondary, stroke: secondary } };
}
if (layer.type === "shape") {
return { ...layer, props: { ...layer.props, fill: accent, stroke: accent } };
}
if (layer.type === "text") {
return { ...layer, props: { ...layer.props, fill: text } };
}
return layer;
}),
}));
set(
pushHistory(state, {
scenes: nextScenes,
sceneAccentColor: accent,
sceneSecondaryColor: secondary,
sceneBackgroundColor: background,
sceneTextColor: text,
})
);
scheduleActiveSceneThumbnailUpdate();
},
applyTransitionToAllScenes: (transitionType) => { applyTransitionToAllScenes: (transitionType) => {
set({ set({
scenes: get().scenes.map((scene) => ({ ...scene, transitionType })), scenes: get().scenes.map((scene) => ({ ...scene, transitionType })),
@@ -760,6 +820,9 @@ export const useStudioStore = create<StudioStore>((set, get) => {
parsed.sceneBackgroundColor ?? DEFAULT_SCENE_BACKGROUND_COLOR, parsed.sceneBackgroundColor ?? DEFAULT_SCENE_BACKGROUND_COLOR,
sceneAccentColor: sceneAccentColor:
parsed.sceneAccentColor ?? DEFAULT_SCENE_ACCENT_COLOR, parsed.sceneAccentColor ?? DEFAULT_SCENE_ACCENT_COLOR,
sceneSecondaryColor:
parsed.sceneSecondaryColor ?? DEFAULT_SCENE_ACCENT_COLOR,
sceneTextColor: parsed.sceneTextColor ?? "#111827",
chooseMode: parsed.chooseMode ?? "", chooseMode: parsed.chooseMode ?? "",
past: [], past: [],
future: [], future: [],
@@ -779,6 +842,8 @@ export const useStudioStore = create<StudioStore>((set, get) => {
audioVolume: state.audioVolume, audioVolume: state.audioVolume,
sceneBackgroundColor: state.sceneBackgroundColor, sceneBackgroundColor: state.sceneBackgroundColor,
sceneAccentColor: state.sceneAccentColor, sceneAccentColor: state.sceneAccentColor,
sceneSecondaryColor: state.sceneSecondaryColor,
sceneTextColor: state.sceneTextColor,
}); });
}, },
}; };