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:
+7
-2
@@ -1148,8 +1148,13 @@
|
||||
"description": "Generate voiceovers from your script directly in the studio."
|
||||
},
|
||||
"componentsStudioSidebarColorsCustomTab": {
|
||||
"mainColor": "Main Color",
|
||||
"additionalColor": "Additional Color",
|
||||
"mainColor": "Background",
|
||||
"additionalColor": "Accent",
|
||||
"secondaryColor": "Secondary",
|
||||
"textColor": "Text",
|
||||
"themePresets": "Themes",
|
||||
"applyThemePreset": "Apply {name} theme",
|
||||
"applyTheme": "Apply theme",
|
||||
"applyToAllScenes": "Apply to all scenes"
|
||||
},
|
||||
"componentsStudioSidebarColorsPalettesTab": {
|
||||
|
||||
+7
-2
@@ -1148,8 +1148,13 @@
|
||||
"description": "صداگذاری را مستقیماً از روی متن خود در استودیو بسازید."
|
||||
},
|
||||
"componentsStudioSidebarColorsCustomTab": {
|
||||
"mainColor": "رنگ اصلی",
|
||||
"additionalColor": "رنگ مکمل",
|
||||
"mainColor": "پسزمینه",
|
||||
"additionalColor": "رنگ اصلی",
|
||||
"secondaryColor": "رنگ دوم",
|
||||
"textColor": "رنگ متن",
|
||||
"themePresets": "تمها",
|
||||
"applyThemePreset": "اعمال تم {name}",
|
||||
"applyTheme": "اعمال تم",
|
||||
"applyToAllScenes": "اعمال به همه صحنهها"
|
||||
},
|
||||
"componentsStudioSidebarColorsPalettesTab": {
|
||||
|
||||
@@ -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"
|
||||
{/* 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"
|
||||
>
|
||||
{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.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>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user