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
@@ -4,63 +4,91 @@ import { Button } from "@/components/ui/button";
import { useStudioStore } from "@/lib/studio-store";
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() {
const t = useTranslations("auto.componentsStudioSidebarColorsCustomTab");
const sceneBackgroundColor = useStudioStore(
(state) => state.sceneBackgroundColor
);
const sceneAccentColor = useStudioStore((state) => state.sceneAccentColor);
const setSceneBackgroundColor = useStudioStore(
(state) => state.setSceneBackgroundColor
);
const setSceneAccentColor = useStudioStore((state) => state.setSceneAccentColor);
const applyPaletteToAllScenes = useStudioStore(
(state) => state.applyPaletteToAllScenes
const sceneBackgroundColor = useStudioStore((s) => s.sceneBackgroundColor);
const sceneAccentColor = useStudioStore((s) => s.sceneAccentColor);
const sceneSecondaryColor = useStudioStore((s) => s.sceneSecondaryColor);
const sceneTextColor = useStudioStore((s) => s.sceneTextColor);
const setSceneBackgroundColor = useStudioStore((s) => s.setSceneBackgroundColor);
const setSceneAccentColor = useStudioStore((s) => s.setSceneAccentColor);
const setSceneSecondaryColor = useStudioStore((s) => s.setSceneSecondaryColor);
const setSceneTextColor = useStudioStore((s) => s.setSceneTextColor);
const applySceneTheme = useStudioStore((s) => s.applySceneTheme);
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 (
<div className="space-y-4 py-2">
<div className="flex items-center justify-between gap-2">
<label
htmlFor="colors-main"
className="shrink-0 text-xs text-gray-600"
>
{t("mainColor")}
</label>
<input
id="colors-main"
type="color"
value={sceneBackgroundColor}
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 themes — one-click cohesive re-skin */}
<div>
<p className="mb-2 text-xs text-gray-600">{t("themePresets")}</p>
<div className="grid grid-cols-3 gap-2">
{THEME_PRESETS.map((preset) => (
<button
key={preset.id}
type="button"
onClick={() => applySceneTheme(...preset.colors)}
aria-label={t("applyThemePreset", { name: preset.id })}
className="flex h-9 overflow-hidden rounded-md border border-gray-200 transition hover:ring-2 hover:ring-blue-500"
>
{preset.colors.map((c, i) => (
<span key={i} className="flex-1" style={{ background: c }} />
))}
</button>
))}
</div>
</div>
<div className="flex items-center justify-between gap-2">
<label
htmlFor="colors-additional"
className="shrink-0 text-xs text-gray-600"
>
{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>
{/* Manual 4-color tweak */}
{swatch("colors-bg", t("mainColor"), sceneBackgroundColor, setSceneBackgroundColor)}
{swatch("colors-accent", t("additionalColor"), sceneAccentColor, setSceneAccentColor)}
{swatch("colors-secondary", t("secondaryColor"), sceneSecondaryColor, setSceneSecondaryColor)}
{swatch("colors-text", t("textColor"), sceneTextColor, setSceneTextColor)}
<Button
type="button"
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={() =>
applyPaletteToAllScenes(sceneBackgroundColor, sceneAccentColor)
applySceneTheme(
sceneAccentColor,
sceneSecondaryColor,
sceneBackgroundColor,
sceneTextColor
)
}
>
{t("applyToAllScenes")}
{t("applyTheme")}
</Button>
</div>
);
+12
View File
@@ -172,6 +172,8 @@ export interface VideoPersistedSceneData {
audioVolume?: number;
sceneBackgroundColor?: string;
sceneAccentColor?: string;
sceneSecondaryColor?: string;
sceneTextColor?: string;
/** Project render mode (FIX / FLEXIBLE / MusicVisualizer / …). FIX/MusicVisualizer
* scenes come from AE layer names, so adding scenes is disabled. */
chooseMode?: string;
@@ -190,6 +192,8 @@ export function buildVideoSceneDataPayload(
audioVolume: input.audioVolume,
sceneBackgroundColor: input.sceneBackgroundColor,
sceneAccentColor: input.sceneAccentColor,
sceneSecondaryColor: input.sceneSecondaryColor,
sceneTextColor: input.sceneTextColor,
};
}
@@ -225,6 +229,14 @@ export function parseVideoSceneData(
typeof sceneData.sceneAccentColor === "string"
? sceneData.sceneAccentColor
: DEFAULT_SCENE_ACCENT_COLOR,
sceneSecondaryColor:
typeof sceneData.sceneSecondaryColor === "string"
? sceneData.sceneSecondaryColor
: DEFAULT_SCENE_ACCENT_COLOR,
sceneTextColor:
typeof sceneData.sceneTextColor === "string"
? sceneData.sceneTextColor
: "#111827",
chooseMode:
typeof sceneData.chooseMode === "string"
? sceneData.chooseMode
+65
View File
@@ -159,6 +159,10 @@ export interface StudioState {
audioVolume: number;
sceneBackgroundColor: 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. */
chooseMode: string;
past: StudioHistorySnapshot[];
@@ -197,7 +201,12 @@ export interface StudioActions {
setAudioVolume: (volume: number) => void;
setSceneBackgroundColor: (color: string) => void;
setSceneAccentColor: (color: string) => void;
setSceneSecondaryColor: (color: string) => void;
setSceneTextColor: (color: 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;
applyFontFamilyToAllTextLayers: (fontFamily: string) => void;
hydrateFromSceneData: (sceneData: Record<string, unknown>) => boolean;
@@ -251,6 +260,8 @@ export const useStudioStore = create<StudioStore>((set, get) => {
audioVolume: 100,
sceneBackgroundColor: DEFAULT_SCENE_BACKGROUND_COLOR,
sceneAccentColor: DEFAULT_SCENE_ACCENT_COLOR,
sceneSecondaryColor: DEFAULT_SCENE_ACCENT_COLOR,
sceneTextColor: "#111827",
chooseMode: "",
past: [],
future: [],
@@ -638,6 +649,10 @@ export const useStudioStore = create<StudioStore>((set, get) => {
setSceneAccentColor: (color) => set({ sceneAccentColor: color }),
setSceneSecondaryColor: (color) => set({ sceneSecondaryColor: color }),
setSceneTextColor: (color) => set({ sceneTextColor: color }),
applyPaletteToAllScenes: (mainColor, accentColor) => {
const state = get();
@@ -712,6 +727,51 @@ export const useStudioStore = create<StudioStore>((set, get) => {
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) => {
set({
scenes: get().scenes.map((scene) => ({ ...scene, transitionType })),
@@ -760,6 +820,9 @@ export const useStudioStore = create<StudioStore>((set, get) => {
parsed.sceneBackgroundColor ?? DEFAULT_SCENE_BACKGROUND_COLOR,
sceneAccentColor:
parsed.sceneAccentColor ?? DEFAULT_SCENE_ACCENT_COLOR,
sceneSecondaryColor:
parsed.sceneSecondaryColor ?? DEFAULT_SCENE_ACCENT_COLOR,
sceneTextColor: parsed.sceneTextColor ?? "#111827",
chooseMode: parsed.chooseMode ?? "",
past: [],
future: [],
@@ -779,6 +842,8 @@ export const useStudioStore = create<StudioStore>((set, get) => {
audioVolume: state.audioVolume,
sceneBackgroundColor: state.sceneBackgroundColor,
sceneAccentColor: state.sceneAccentColor,
sceneSecondaryColor: state.sceneSecondaryColor,
sceneTextColor: state.sceneTextColor,
});
},
};