hlassiege/darylpublic Fork
⑂ 4e701a2
Commits
⬇ Clone ▾
git clone https://git.rickub.com/hlassiege/daryl.git
git clone ssh://git@rickub.com/hlassiege/daryl.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Download ZIP

feat(montage): choose the motion style per project

A project took the library design system once, when its edit was prepared,
and could never change it. A Style button in the montage toolbar now picks it
among the library design system and the presets. New motions use it; existing
ones keep theirs: the previous stylesheet is frozen under its hash and they load
it, and the render signature ignores the stylesheet name, so nothing is
re-rendered.
hlassiege committed 2026-10-13T14:02:18+02:00 Browse files
4e701a2 parent: 0606563
modified backend/studio/modules/animations/kit.py +27 -14
@@ -68,25 +68,34 @@ def _root_var(css: str, name: str) -> str | None:
6868 return m.group(1).strip() if m else None
6969
7070
71-def defaults() -> dict:
72- """Le design system de la charte de base : {couleur: #hex, radius: px, font, mono, texture}."""
73- css = _base_css()
71+def _texture_css(texture: str) -> str:
72+ path = BUILTIN_DIR / "textures" / f"{texture}.css"
73+ return path.read_text(encoding="utf-8") if path.is_file() else ""
74+
75+
76+def design_of(css: str) -> dict:
77+ """Les réglages d'une charte générée : {couleur: #hex, radius: px, font, mono, texture}."""
7478 out = {k: _root_var(css, k) for k in COLORS}
7579 out["radius"] = int(re.sub(r"\D", "", _root_var(css, "radius") or "22") or 22)
7680 for k in FONTS:
7781 out[k] = (_root_var(css, k) or "").split(",")[0].strip().strip('"')
78- out["texture"] = TEXTURES[0]
82+ out["texture"] = next((t for t in TEXTURES[1:] if _texture_css(t) and _texture_css(t) in css), TEXTURES[0])
7983 return out
8084
8185
86+def defaults() -> dict:
87+ """Le design system de la charte de base."""
88+ return design_of(_base_css())
89+
90+
8291 def design() -> dict:
8392 saved = json.loads(DESIGN_FILE.read_text(encoding="utf-8")) if DESIGN_FILE.is_file() else {}
8493 return {**defaults(), **saved}
8594
8695
87-def save_design(values: dict) -> dict:
88- """Enregistre ce qui est valide, ignore le reste ; rend le design system complet."""
89- out = design()
96+def clean(values: dict, base: dict) -> dict:
97+ """`base` modifié par ce qui est valide dans `values` (le reste est ignoré)."""
98+ out = dict(base)
9099 for k in COLORS:
91100 if isinstance(values.get(k), str) and _HEX.match(values[k]):
92101 out[k] = values[k].lower()
@@ -97,14 +106,21 @@ def save_design(values: dict) -> dict:
97106 out[k] = values[k].strip()
98107 if values.get("texture") in TEXTURES:
99108 out["texture"] = values["texture"]
109+ return out
110+
111+
112+def save_design(values: dict) -> dict:
113+ """Enregistre ce qui est valide, ignore le reste ; rend le design system complet."""
114+ out = clean(values, design())
100115 USER_DIR.mkdir(parents=True, exist_ok=True)
101116 DESIGN_FILE.write_text(json.dumps(out, ensure_ascii=False, indent=1), encoding="utf-8")
102117 return out
103118
104119
105-def style_css() -> str:
106- """La charte de base, avec les variables et les polices du design system, puis sa matière."""
107- d = design()
120+def style_css(d: dict | None = None) -> str:
121+ """La charte de base, avec les variables et les polices d'un design system (celui du kit par défaut),
122+ puis sa matière."""
123+ d = d or design()
108124 css = _base_css()
109125 families = "&".join(f"family={quote_plus(d[k])}:wght@{WEIGHTS[k]}" for k in FONTS)
110126 css = re.sub(r'@import url\("https://fonts\.googleapis\.com/[^"]*"\);',
@@ -113,10 +129,7 @@ def style_css() -> str:
113129 values |= {k: f'"{d[k]}", {FALLBACK[k]}' for k in FONTS}
114130 for k, v in values.items():
115131 css = re.sub(rf"(--{re.escape(k)}:\s*)[^;]+;", lambda m, v=v: f"{m.group(1)}{v};", css, count=1)
116- texture = BUILTIN_DIR / "textures" / f"{d['texture']}.css"
117- if texture.is_file():
118- css += texture.read_text(encoding="utf-8")
119- return css
132+ return css + _texture_css(d["texture"])
120133
121134
122135 def version() -> str:
@@ -68,25 +68,34 @@ def _root_var(css: str, name: str) -> str | None:
68 return m.group(1).strip() if m else None68 return m.group(1).strip() if m else None
69 69
70 70
71-def defaults() -> dict:71+def _texture_css(texture: str) -> str:
72- """Le design system de la charte de base : {couleur: #hex, radius: px, font, mono, texture}."""72+ path = BUILTIN_DIR / "textures" / f"{texture}.css"
73- css = _base_css()73+ return path.read_text(encoding="utf-8") if path.is_file() else ""
74+
75+
76+def design_of(css: str) -> dict:
77+ """Les réglages d'une charte générée : {couleur: #hex, radius: px, font, mono, texture}."""
74 out = {k: _root_var(css, k) for k in COLORS}78 out = {k: _root_var(css, k) for k in COLORS}
75 out["radius"] = int(re.sub(r"\D", "", _root_var(css, "radius") or "22") or 22)79 out["radius"] = int(re.sub(r"\D", "", _root_var(css, "radius") or "22") or 22)
76 for k in FONTS:80 for k in FONTS:
77 out[k] = (_root_var(css, k) or "").split(",")[0].strip().strip('"')81 out[k] = (_root_var(css, k) or "").split(",")[0].strip().strip('"')
78- out["texture"] = TEXTURES[0]82+ out["texture"] = next((t for t in TEXTURES[1:] if _texture_css(t) and _texture_css(t) in css), TEXTURES[0])
79 return out83 return out
80 84
81 85
86+def defaults() -> dict:
87+ """Le design system de la charte de base."""
88+ return design_of(_base_css())
89+
90+
82 def design() -> dict:91 def design() -> dict:
83 saved = json.loads(DESIGN_FILE.read_text(encoding="utf-8")) if DESIGN_FILE.is_file() else {}92 saved = json.loads(DESIGN_FILE.read_text(encoding="utf-8")) if DESIGN_FILE.is_file() else {}
84 return {**defaults(), **saved}93 return {**defaults(), **saved}
85 94
86 95
87-def save_design(values: dict) -> dict:96+def clean(values: dict, base: dict) -> dict:
88- """Enregistre ce qui est valide, ignore le reste ; rend le design system complet."""97+ """`base` modifié par ce qui est valide dans `values` (le reste est ignoré)."""
89- out = design()98+ out = dict(base)
90 for k in COLORS:99 for k in COLORS:
91 if isinstance(values.get(k), str) and _HEX.match(values[k]):100 if isinstance(values.get(k), str) and _HEX.match(values[k]):
92 out[k] = values[k].lower()101 out[k] = values[k].lower()
@@ -97,14 +106,21 @@ def save_design(values: dict) -> dict:
97 out[k] = values[k].strip()106 out[k] = values[k].strip()
98 if values.get("texture") in TEXTURES:107 if values.get("texture") in TEXTURES:
99 out["texture"] = values["texture"]108 out["texture"] = values["texture"]
109+ return out
110+
111+
112+def save_design(values: dict) -> dict:
113+ """Enregistre ce qui est valide, ignore le reste ; rend le design system complet."""
114+ out = clean(values, design())
100 USER_DIR.mkdir(parents=True, exist_ok=True)115 USER_DIR.mkdir(parents=True, exist_ok=True)
101 DESIGN_FILE.write_text(json.dumps(out, ensure_ascii=False, indent=1), encoding="utf-8")116 DESIGN_FILE.write_text(json.dumps(out, ensure_ascii=False, indent=1), encoding="utf-8")
102 return out117 return out
103 118
104 119
105-def style_css() -> str:120+def style_css(d: dict | None = None) -> str:
106- """La charte de base, avec les variables et les polices du design system, puis sa matière."""121+ """La charte de base, avec les variables et les polices d'un design system (celui du kit par défaut),
107- d = design()122+ puis sa matière."""
123+ d = d or design()
108 css = _base_css()124 css = _base_css()
109 families = "&".join(f"family={quote_plus(d[k])}:wght@{WEIGHTS[k]}" for k in FONTS)125 families = "&".join(f"family={quote_plus(d[k])}:wght@{WEIGHTS[k]}" for k in FONTS)
110 css = re.sub(r'@import url\("https://fonts\.googleapis\.com/[^"]*"\);',126 css = re.sub(r'@import url\("https://fonts\.googleapis\.com/[^"]*"\);',
@@ -113,10 +129,7 @@ def style_css() -> str:
113 values |= {k: f'"{d[k]}", {FALLBACK[k]}' for k in FONTS}129 values |= {k: f'"{d[k]}", {FALLBACK[k]}' for k in FONTS}
114 for k, v in values.items():130 for k, v in values.items():
115 css = re.sub(rf"(--{re.escape(k)}:\s*)[^;]+;", lambda m, v=v: f"{m.group(1)}{v};", css, count=1)131 css = re.sub(rf"(--{re.escape(k)}:\s*)[^;]+;", lambda m, v=v: f"{m.group(1)}{v};", css, count=1)
116- texture = BUILTIN_DIR / "textures" / f"{d['texture']}.css"132+ return css + _texture_css(d["texture"])
117- if texture.is_file():
118- css += texture.read_text(encoding="utf-8")
119- return css
120 133
121 134
122 def version() -> str:135 def version() -> str:
modified backend/studio/modules/projects/api.py +17 -1
@@ -1117,7 +1117,8 @@ def project_file(pid: str, rel: str):
11171117 path = (root / rel).resolve()
11181118 if root not in path.parents or not path.is_file():
11191119 raise HTTPException(404, "fichier introuvable")
1120- return FileResponse(path)
1120+ # la charte d'un projet change avec son style, sous le même nom : jamais reprise du cache sans vérifier
1121+ return FileResponse(path, headers={"Cache-Control": "no-cache"} if path.suffix == ".css" else None)
11211122
11221123
11231124 # --- Assets : YouTube --------------------------------------------------------------------
@@ -1407,6 +1408,21 @@ class MontageRequest(BaseModel):
14071408 rev: str | None = None
14081409
14091410
1411+# le style du projet : choisi parmi le design system du kit et ses ambiances
1412+@router.get("/{pid}/montage/style")
1413+def get_montage_style(pid: str):
1414+ _check(pid)
1415+ montage.prepare(pid)
1416+ return {"style": montage.style(pid), "kit": kit.design(), "presets": [{**kit.defaults(), "id": "base"}, *kit.PRESETS]}
1417+
1418+
1419+@router.put("/{pid}/montage/style")
1420+def put_montage_style(pid: str, values: dict):
1421+ _check(pid)
1422+ montage.set_style(pid, values)
1423+ return get_montage_style(pid)
1424+
1425+
14101426 @router.put("/{pid}/montage")
14111427 def put_montage(pid: str, req: MontageRequest):
14121428 _check(pid)
@@ -1117,7 +1117,8 @@ def project_file(pid: str, rel: str):
1117 path = (root / rel).resolve()1117 path = (root / rel).resolve()
1118 if root not in path.parents or not path.is_file():1118 if root not in path.parents or not path.is_file():
1119 raise HTTPException(404, "fichier introuvable")1119 raise HTTPException(404, "fichier introuvable")
1120- return FileResponse(path)1120+ # la charte d'un projet change avec son style, sous le même nom : jamais reprise du cache sans vérifier
1121+ return FileResponse(path, headers={"Cache-Control": "no-cache"} if path.suffix == ".css" else None)
1121 1122
1122 1123
1123 # --- Assets : YouTube --------------------------------------------------------------------1124 # --- Assets : YouTube --------------------------------------------------------------------
@@ -1407,6 +1408,21 @@ class MontageRequest(BaseModel):
1407 rev: str | None = None1408 rev: str | None = None
1408 1409
1409 1410
1411+# le style du projet : choisi parmi le design system du kit et ses ambiances
1412+@router.get("/{pid}/montage/style")
1413+def get_montage_style(pid: str):
1414+ _check(pid)
1415+ montage.prepare(pid)
1416+ return {"style": montage.style(pid), "kit": kit.design(), "presets": [{**kit.defaults(), "id": "base"}, *kit.PRESETS]}
1417+
1418+
1419+@router.put("/{pid}/montage/style")
1420+def put_montage_style(pid: str, values: dict):
1421+ _check(pid)
1422+ montage.set_style(pid, values)
1423+ return get_montage_style(pid)
1424+
1425+
1410 @router.put("/{pid}/montage")1426 @router.put("/{pid}/montage")
1411 def put_montage(pid: str, req: MontageRequest):1427 def put_montage(pid: str, req: MontageRequest):
1412 _check(pid)1428 _check(pid)
modified backend/studio/modules/projects/montage.py +39 -3
@@ -138,10 +138,43 @@ def empty(project: dict) -> dict:
138138
139139 def prepare(pid: str) -> None:
140140 """Le dossier du montage, avec la charte et les exemples de motion design."""
141- # la charte reste celle avec laquelle le projet a commencé ; les exemples suivent le kit
141+ # la charte est celle du projet (au départ, le design system du kit) ; les exemples suivent le kit
142142 kit.install(motion_dir(pid), keep_style=True)
143143
144144
145+# la charte du projet (style.css), ou une ancienne figée sous son empreinte (style-<empreinte>.css)
146+STYLE_FILE = "style.css"
147+_STYLE_LINK = re.compile(rb'href="(style(?:-[0-9a-f]{10})?\.css)"')
148+
149+
150+def style(pid: str) -> dict:
151+ """Le style du projet : les réglages de sa charte."""
152+ path = motion_dir(pid) / STYLE_FILE
153+ return kit.design_of(path.read_text(encoding="utf-8")) if path.is_file() else kit.design()
154+
155+
156+def set_style(pid: str, values: dict) -> None:
157+ """
158+ Change le style du projet : les motions écrits ensuite le prennent. Ceux qui existent gardent le leur : la
159+ charte d'avant est figée sous son empreinte et ils la chargent désormais (leur rendu reste valable).
160+ """
161+ with _locked(pid):
162+ prepare(pid)
163+ folder, current = motion_dir(pid), motion_dir(pid) / STYLE_FILE
164+ old = current.read_text(encoding="utf-8")
165+ new = kit.style_css(kit.clean(values, style(pid)))
166+ if new == old:
167+ return
168+ frozen = f"style-{hashlib.sha1(old.encode()).hexdigest()[:10]}.css"
169+ if not (folder / frozen).is_file():
170+ (folder / frozen).write_text(old, encoding="utf-8")
171+ for comp in folder.glob("*.html"):
172+ raw = comp.read_bytes()
173+ if f'href="{STYLE_FILE}"'.encode() in raw:
174+ comp.write_bytes(raw.replace(f'href="{STYLE_FILE}"'.encode(), f'href="{frozen}"'.encode()))
175+ current.write_text(new, encoding="utf-8")
176+
177+
145178 def load(pid: str) -> dict:
146179 path = root(pid) / TIMELINE_FILE
147180 if path.is_file():
@@ -845,8 +878,11 @@ def motion_signature(pid: str, item: dict) -> str | None:
845878 comp = motion_dir(pid) / f"{item['id']}.html"
846879 if not comp.is_file():
847880 return None
848- body = comp.read_bytes() + json.dumps([item["dur"], MOTION_PAGE_VERSION]).encode()
849- style = motion_dir(pid) / "style.css"
881+ raw = comp.read_bytes()
882+ # la charte qu'elle charge compte, pas son nom : figer une charte (changement de style) ne relance aucun rendu
883+ link = _STYLE_LINK.search(raw)
884+ style = motion_dir(pid) / (link.group(1).decode() if link else STYLE_FILE)
885+ body = _STYLE_LINK.sub(f'href="{STYLE_FILE}"'.encode(), raw) + json.dumps([item["dur"], MOTION_PAGE_VERSION]).encode()
850886 if style.is_file():
851887 body += style.read_bytes()
852888 return hashlib.sha1(body).hexdigest()[:12]
@@ -138,10 +138,43 @@ def empty(project: dict) -> dict:
138 138
139 def prepare(pid: str) -> None:139 def prepare(pid: str) -> None:
140 """Le dossier du montage, avec la charte et les exemples de motion design."""140 """Le dossier du montage, avec la charte et les exemples de motion design."""
141- # la charte reste celle avec laquelle le projet a commencé ; les exemples suivent le kit141+ # la charte est celle du projet (au départ, le design system du kit) ; les exemples suivent le kit
142 kit.install(motion_dir(pid), keep_style=True)142 kit.install(motion_dir(pid), keep_style=True)
143 143
144 144
145+# la charte du projet (style.css), ou une ancienne figée sous son empreinte (style-<empreinte>.css)
146+STYLE_FILE = "style.css"
147+_STYLE_LINK = re.compile(rb'href="(style(?:-[0-9a-f]{10})?\.css)"')
148+
149+
150+def style(pid: str) -> dict:
151+ """Le style du projet : les réglages de sa charte."""
152+ path = motion_dir(pid) / STYLE_FILE
153+ return kit.design_of(path.read_text(encoding="utf-8")) if path.is_file() else kit.design()
154+
155+
156+def set_style(pid: str, values: dict) -> None:
157+ """
158+ Change le style du projet : les motions écrits ensuite le prennent. Ceux qui existent gardent le leur : la
159+ charte d'avant est figée sous son empreinte et ils la chargent désormais (leur rendu reste valable).
160+ """
161+ with _locked(pid):
162+ prepare(pid)
163+ folder, current = motion_dir(pid), motion_dir(pid) / STYLE_FILE
164+ old = current.read_text(encoding="utf-8")
165+ new = kit.style_css(kit.clean(values, style(pid)))
166+ if new == old:
167+ return
168+ frozen = f"style-{hashlib.sha1(old.encode()).hexdigest()[:10]}.css"
169+ if not (folder / frozen).is_file():
170+ (folder / frozen).write_text(old, encoding="utf-8")
171+ for comp in folder.glob("*.html"):
172+ raw = comp.read_bytes()
173+ if f'href="{STYLE_FILE}"'.encode() in raw:
174+ comp.write_bytes(raw.replace(f'href="{STYLE_FILE}"'.encode(), f'href="{frozen}"'.encode()))
175+ current.write_text(new, encoding="utf-8")
176+
177+
145 def load(pid: str) -> dict:178 def load(pid: str) -> dict:
146 path = root(pid) / TIMELINE_FILE179 path = root(pid) / TIMELINE_FILE
147 if path.is_file():180 if path.is_file():
@@ -845,8 +878,11 @@ def motion_signature(pid: str, item: dict) -> str | None:
845 comp = motion_dir(pid) / f"{item['id']}.html"878 comp = motion_dir(pid) / f"{item['id']}.html"
846 if not comp.is_file():879 if not comp.is_file():
847 return None880 return None
848- body = comp.read_bytes() + json.dumps([item["dur"], MOTION_PAGE_VERSION]).encode()881+ raw = comp.read_bytes()
849- style = motion_dir(pid) / "style.css"882+ # la charte qu'elle charge compte, pas son nom : figer une charte (changement de style) ne relance aucun rendu
883+ link = _STYLE_LINK.search(raw)
884+ style = motion_dir(pid) / (link.group(1).decode() if link else STYLE_FILE)
885+ body = _STYLE_LINK.sub(f'href="{STYLE_FILE}"'.encode(), raw) + json.dumps([item["dur"], MOTION_PAGE_VERSION]).encode()
850 if style.is_file():886 if style.is_file():
851 body += style.read_bytes()887 body += style.read_bytes()
852 return hashlib.sha1(body).hexdigest()[:12]888 return hashlib.sha1(body).hexdigest()[:12]
modified backend/studio/modules/projects/montage_tasks.py +2 -0
@@ -160,6 +160,8 @@ par des tâches à part. Un motion en cours de création (listé dans contexte.m
160160 fais-la directement : modifie {work}/timeline.json (le client voit tes changements à la fin \
161161 de ton tour), puis écris les compositions. Ne lance pas de création d'ensemble de toi-même : propose plutôt de passer par \
162162 le storyboard.
163+- Une composition qui charge une autre charte (style-….css) a été faite avec un style précédent du \
164+projet : quand tu la retouches, garde sa charte.
163165 - Ne crée ni ne modifie aucun autre fichier que {work}/timeline.json, {work}/insertions.json \
164166 et montage/motion/*.html.
165167 - Si un média manque (pas de B-roll adapté, pas de son), dis-le dans ta réponse plutôt que \
@@ -160,6 +160,8 @@ par des tâches à part. Un motion en cours de création (listé dans contexte.m
160 fais-la directement : modifie {work}/timeline.json (le client voit tes changements à la fin \160 fais-la directement : modifie {work}/timeline.json (le client voit tes changements à la fin \
161 de ton tour), puis écris les compositions. Ne lance pas de création d'ensemble de toi-même : propose plutôt de passer par \161 de ton tour), puis écris les compositions. Ne lance pas de création d'ensemble de toi-même : propose plutôt de passer par \
162 le storyboard.162 le storyboard.
163+- Une composition qui charge une autre charte (style-….css) a été faite avec un style précédent du \
164+projet : quand tu la retouches, garde sa charte.
163 - Ne crée ni ne modifie aucun autre fichier que {work}/timeline.json, {work}/insertions.json \165 - Ne crée ni ne modifie aucun autre fichier que {work}/timeline.json, {work}/insertions.json \
164 et montage/motion/*.html.166 et montage/motion/*.html.
165 - Si un média manque (pas de B-roll adapté, pas de son), dis-le dans ta réponse plutôt que \167 - Si un média manque (pas de B-roll adapté, pas de son), dis-le dans ta réponse plutôt que \
modified web/src/i18n/locales/en/editing.ts +7 -0
@@ -22,6 +22,13 @@ export default {
2222 exportTitle: "Writes the .fcpxml to the project folder",
2323 revealTitle: "Open the edit folder",
2424 },
25+ style: {
26+ label: "Style",
27+ title: "Motion style",
28+ hint: "Motions created from now on use this style. Existing ones keep theirs, even when touched up, and aren't re-rendered; recreate a motion to give it the new style.",
29+ kit: "My design system (library)",
30+ custom: "Custom",
31+ },
2532 keys: "Ctrl+scroll move playhead · Alt+scroll zoom · Shift+Z fit · Space play · J K L · ←→ frame · ↑↓ cuts · A/B select/blade · Ctrl+B cut · Del · M beat · I/O range · Ctrl+Z undo · drag on empty space = range",
2633 tracks: {
2734 aroll: "Video",
@@ -22,6 +22,13 @@ export default {
22 exportTitle: "Writes the .fcpxml to the project folder",22 exportTitle: "Writes the .fcpxml to the project folder",
23 revealTitle: "Open the edit folder",23 revealTitle: "Open the edit folder",
24 },24 },
25+ style: {
26+ label: "Style",
27+ title: "Motion style",
28+ hint: "Motions created from now on use this style. Existing ones keep theirs, even when touched up, and aren't re-rendered; recreate a motion to give it the new style.",
29+ kit: "My design system (library)",
30+ custom: "Custom",
31+ },
25 keys: "Ctrl+scroll move playhead · Alt+scroll zoom · Shift+Z fit · Space play · J K L · ←→ frame · ↑↓ cuts · A/B select/blade · Ctrl+B cut · Del · M beat · I/O range · Ctrl+Z undo · drag on empty space = range",32 keys: "Ctrl+scroll move playhead · Alt+scroll zoom · Shift+Z fit · Space play · J K L · ←→ frame · ↑↓ cuts · A/B select/blade · Ctrl+B cut · Del · M beat · I/O range · Ctrl+Z undo · drag on empty space = range",
26 tracks: {33 tracks: {
27 aroll: "Video",34 aroll: "Video",
modified web/src/i18n/locales/fr/editing.ts +7 -0
@@ -22,6 +22,13 @@ export default {
2222 exportTitle: "Écrit le .fcpxml dans le dossier du projet",
2323 revealTitle: "Ouvrir le dossier du montage",
2424 },
25+ style: {
26+ label: "Style",
27+ title: "Style des motions",
28+ hint: "Les motions créés à partir de maintenant prennent ce style. Ceux déjà faits gardent le leur, même retouchés, et leur rendu n’est pas refait ; recrée un motion pour lui donner le nouveau style.",
29+ kit: "Mon design system (bibliothèque)",
30+ custom: "Personnalisé",
31+ },
2532 keys: "Ctrl+molette déplacer la tête · Alt+molette zoom · Maj+Z ajuster · Espace lecture · J K L · ←→ image · ↑↓ coupes · A/B sélection/lame · Ctrl+B couper · Suppr · M beat · I/O plage · Ctrl+Z annuler · glisser sur une zone vide = plage",
2633 tracks: {
2734 aroll: "Vidéo",
@@ -22,6 +22,13 @@ export default {
22 exportTitle: "Écrit le .fcpxml dans le dossier du projet",22 exportTitle: "Écrit le .fcpxml dans le dossier du projet",
23 revealTitle: "Ouvrir le dossier du montage",23 revealTitle: "Ouvrir le dossier du montage",
24 },24 },
25+ style: {
26+ label: "Style",
27+ title: "Style des motions",
28+ hint: "Les motions créés à partir de maintenant prennent ce style. Ceux déjà faits gardent le leur, même retouchés, et leur rendu n’est pas refait ; recrée un motion pour lui donner le nouveau style.",
29+ kit: "Mon design system (bibliothèque)",
30+ custom: "Personnalisé",
31+ },
25 keys: "Ctrl+molette déplacer la tête · Alt+molette zoom · Maj+Z ajuster · Espace lecture · J K L · ←→ image · ↑↓ coupes · A/B sélection/lame · Ctrl+B couper · Suppr · M beat · I/O plage · Ctrl+Z annuler · glisser sur une zone vide = plage",32 keys: "Ctrl+molette déplacer la tête · Alt+molette zoom · Maj+Z ajuster · Espace lecture · J K L · ←→ image · ↑↓ coupes · A/B sélection/lame · Ctrl+B couper · Suppr · M beat · I/O plage · Ctrl+Z annuler · glisser sur une zone vide = plage",
26 tracks: {33 tracks: {
27 aroll: "Vidéo",34 aroll: "Vidéo",
modified web/src/project/steps/MontageStep.vue +2 -0
@@ -21,6 +21,7 @@ import MonteurChat from "./montage/MonteurChat.vue";
2121 import TaskQueue from "./montage/TaskQueue.vue";
2222 import { addMedia, bladeAroll, bladeItem, frameStep, insertAroll, isVideo, remove } from "./montage/ops";
2323 import Preview from "./montage/Preview.vue";
24+import StylePicker from "./montage/StylePicker.vue";
2425 import Timeline from "./montage/Timeline.vue";
2526 import { clipStarts, fxSpan, isImage, needsAnim, spanOf, tcd, trackLabel, TYPE_COLOR, type AnimState, type Beat, type AvailableMedia, type Broll, type MontageTask, type Motion, type Selection, type Shot, type Timeline as TL, type Track } from "./montage/types";
2627 import { useMontage } from "./montage/useMontage";
@@ -367,6 +368,7 @@ onUnmounted(() => window.removeEventListener("keydown", onKey));
367368 <span v-if="M.dirty.value" class="saving mono">{{ tr("common.saving") }}</span>
368369 <span v-else-if="M.busy.value" class="busy mono">{{ tr("editing.toolbar.busy") }}</span>
369370 <span class="spacer" />
371+ <StylePicker :pid="project.id" />
370372 <button class="link-btn" :disabled="!view.can_undo" title="Ctrl+Z" @click="M.undo()">{{ tr("editing.toolbar.undo") }}</button>
371373 <button class="btn" :disabled="exporting" :title="tr('editing.toolbar.exportTitle')" @click="exportResolve">{{ tr("editing.toolbar.export") }}</button>
372374 <button class="btn" :title="tr('editing.toolbar.revealTitle')" @click="reveal">{{ tr("common.openFolder") }}</button>
@@ -21,6 +21,7 @@ import MonteurChat from "./montage/MonteurChat.vue";
21 import TaskQueue from "./montage/TaskQueue.vue";21 import TaskQueue from "./montage/TaskQueue.vue";
22 import { addMedia, bladeAroll, bladeItem, frameStep, insertAroll, isVideo, remove } from "./montage/ops";22 import { addMedia, bladeAroll, bladeItem, frameStep, insertAroll, isVideo, remove } from "./montage/ops";
23 import Preview from "./montage/Preview.vue";23 import Preview from "./montage/Preview.vue";
24+import StylePicker from "./montage/StylePicker.vue";
24 import Timeline from "./montage/Timeline.vue";25 import Timeline from "./montage/Timeline.vue";
25 import { clipStarts, fxSpan, isImage, needsAnim, spanOf, tcd, trackLabel, TYPE_COLOR, type AnimState, type Beat, type AvailableMedia, type Broll, type MontageTask, type Motion, type Selection, type Shot, type Timeline as TL, type Track } from "./montage/types";26 import { clipStarts, fxSpan, isImage, needsAnim, spanOf, tcd, trackLabel, TYPE_COLOR, type AnimState, type Beat, type AvailableMedia, type Broll, type MontageTask, type Motion, type Selection, type Shot, type Timeline as TL, type Track } from "./montage/types";
26 import { useMontage } from "./montage/useMontage";27 import { useMontage } from "./montage/useMontage";
@@ -367,6 +368,7 @@ onUnmounted(() => window.removeEventListener("keydown", onKey));
367 <span v-if="M.dirty.value" class="saving mono">{{ tr("common.saving") }}</span>368 <span v-if="M.dirty.value" class="saving mono">{{ tr("common.saving") }}</span>
368 <span v-else-if="M.busy.value" class="busy mono">{{ tr("editing.toolbar.busy") }}</span>369 <span v-else-if="M.busy.value" class="busy mono">{{ tr("editing.toolbar.busy") }}</span>
369 <span class="spacer" />370 <span class="spacer" />
371+ <StylePicker :pid="project.id" />
370 <button class="link-btn" :disabled="!view.can_undo" title="Ctrl+Z" @click="M.undo()">{{ tr("editing.toolbar.undo") }}</button>372 <button class="link-btn" :disabled="!view.can_undo" title="Ctrl+Z" @click="M.undo()">{{ tr("editing.toolbar.undo") }}</button>
371 <button class="btn" :disabled="exporting" :title="tr('editing.toolbar.exportTitle')" @click="exportResolve">{{ tr("editing.toolbar.export") }}</button>373 <button class="btn" :disabled="exporting" :title="tr('editing.toolbar.exportTitle')" @click="exportResolve">{{ tr("editing.toolbar.export") }}</button>
372 <button class="btn" :title="tr('editing.toolbar.revealTitle')" @click="reveal">{{ tr("common.openFolder") }}</button>374 <button class="btn" :title="tr('editing.toolbar.revealTitle')" @click="reveal">{{ tr("common.openFolder") }}</button>
added web/src/project/steps/montage/StylePicker.vue +69 -0
new file mode 100644
@@ -0,0 +1,69 @@
1+<script setup lang="ts">
2+/**
3+ * Le style du projet : le design system avec lequel Daryl écrit les motions. Au départ, celui du kit ; on
4+ * peut en changer ici. Les motions écrits ensuite le prennent, ceux déjà faits gardent le leur (et leur rendu).
5+ */
6+import { computed, onMounted, ref } from "vue";
7+import { useI18n } from "vue-i18n";
8+import Modal from "../../../components/Modal.vue";
9+import ThemeSwatch from "../../../components/ThemeSwatch.vue";
10+import { api, putJson } from "../../../lib/api";
11+import { toast } from "../../../lib/toast";
12+
13+type Design = Record<string, string | number>;
14+interface StyleView { style: Design; kit: Design; presets: Design[] }
15+
16+const props = defineProps<{ pid: string }>();
17+const { t } = useI18n();
18+const view = ref<StyleView | null>(null);
19+const open = ref(false);
20+
21+onMounted(async () => {
22+ view.value = await api<StyleView>(`/api/projects/${props.pid}/montage/style`);
23+});
24+
25+// le design system du kit en tête, puis les ambiances ; un choix est actif si tous ses réglages le sont
26+const options = computed(() => view.value
27+ ? [{ id: "kit", label: t("editing.style.kit"), theme: view.value.kit },
28+ ...view.value.presets.map((p) => ({ id: String(p.id), label: t(`library.design.presets.${p.id}`), theme: p }))]
29+ : []);
30+const active = (theme: Design) => !!view.value && Object.entries(theme)
31+ .every(([k, v]) => k === "id" || view.value!.style[k] === v);
32+const current = computed(() => options.value.find((o) => active(o.theme))?.label ?? t("editing.style.custom"));
33+
34+async function pick(theme: Design) {
35+ try {
36+ view.value = await putJson<StyleView>(`/api/projects/${props.pid}/montage/style`, theme);
37+ open.value = false;
38+ } catch (e) {
39+ toast((e as Error).message, true);
40+ }
41+}
42+</script>
43+
44+<template>
45+ <button v-if="view" class="btn sm current" :title="t('editing.style.title')" @click="open = true">
46+ <ThemeSwatch :theme="view.style" class="mini" />
47+ {{ t("editing.style.label") }} · {{ current }}
48+ </button>
49+ <Modal v-if="open && view" :title="t('editing.style.title')" width="560px" height="400px" @close="open = false">
50+ <p class="hint">{{ t("editing.style.hint") }}</p>
51+ <div class="list">
52+ <button v-for="o in options" :key="o.id" class="option" :class="{ active: active(o.theme) }" @click="pick(o.theme)">
53+ <ThemeSwatch :theme="o.theme" />
54+ <span>{{ o.label }}</span>
55+ </button>
56+ </div>
57+ </Modal>
58+</template>
59+
60+<style scoped>
61+.current { display: inline-flex; align-items: center; gap: 8px; }
62+.mini { width: 26px; height: 18px; font-size: 9px; }
63+.mini :deep(i) { width: 5px; height: 5px; right: 2px; bottom: 2px; }
64+.hint { margin: 0 0 12px; font-size: 13px; color: var(--muted); }
65+.list { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
66+.option { display: flex; align-items: center; gap: 10px; padding: 6px 10px 6px 6px; border: 1px solid var(--line); border-radius: 6px; background: none; font-size: 13px; color: var(--muted); cursor: pointer; text-align: left; }
67+.option:hover { border-color: var(--line-strong); }
68+.option.active { border-color: var(--ink); color: var(--ink); font-weight: 500; }
69+</style>
new file mode 100644
@@ -0,0 +1,69 @@
1+<script setup lang="ts">
2+/**
3+ * Le style du projet : le design system avec lequel Daryl écrit les motions. Au départ, celui du kit ; on
4+ * peut en changer ici. Les motions écrits ensuite le prennent, ceux déjà faits gardent le leur (et leur rendu).
5+ */
6+import { computed, onMounted, ref } from "vue";
7+import { useI18n } from "vue-i18n";
8+import Modal from "../../../components/Modal.vue";
9+import ThemeSwatch from "../../../components/ThemeSwatch.vue";
10+import { api, putJson } from "../../../lib/api";
11+import { toast } from "../../../lib/toast";
12+
13+type Design = Record<string, string | number>;
14+interface StyleView { style: Design; kit: Design; presets: Design[] }
15+
16+const props = defineProps<{ pid: string }>();
17+const { t } = useI18n();
18+const view = ref<StyleView | null>(null);
19+const open = ref(false);
20+
21+onMounted(async () => {
22+ view.value = await api<StyleView>(`/api/projects/${props.pid}/montage/style`);
23+});
24+
25+// le design system du kit en tête, puis les ambiances ; un choix est actif si tous ses réglages le sont
26+const options = computed(() => view.value
27+ ? [{ id: "kit", label: t("editing.style.kit"), theme: view.value.kit },
28+ ...view.value.presets.map((p) => ({ id: String(p.id), label: t(`library.design.presets.${p.id}`), theme: p }))]
29+ : []);
30+const active = (theme: Design) => !!view.value && Object.entries(theme)
31+ .every(([k, v]) => k === "id" || view.value!.style[k] === v);
32+const current = computed(() => options.value.find((o) => active(o.theme))?.label ?? t("editing.style.custom"));
33+
34+async function pick(theme: Design) {
35+ try {
36+ view.value = await putJson<StyleView>(`/api/projects/${props.pid}/montage/style`, theme);
37+ open.value = false;
38+ } catch (e) {
39+ toast((e as Error).message, true);
40+ }
41+}
42+</script>
43+
44+<template>
45+ <button v-if="view" class="btn sm current" :title="t('editing.style.title')" @click="open = true">
46+ <ThemeSwatch :theme="view.style" class="mini" />
47+ {{ t("editing.style.label") }} · {{ current }}
48+ </button>
49+ <Modal v-if="open && view" :title="t('editing.style.title')" width="560px" height="400px" @close="open = false">
50+ <p class="hint">{{ t("editing.style.hint") }}</p>
51+ <div class="list">
52+ <button v-for="o in options" :key="o.id" class="option" :class="{ active: active(o.theme) }" @click="pick(o.theme)">
53+ <ThemeSwatch :theme="o.theme" />
54+ <span>{{ o.label }}</span>
55+ </button>
56+ </div>
57+ </Modal>
58+</template>
59+
60+<style scoped>
61+.current { display: inline-flex; align-items: center; gap: 8px; }
62+.mini { width: 26px; height: 18px; font-size: 9px; }
63+.mini :deep(i) { width: 5px; height: 5px; right: 2px; bottom: 2px; }
64+.hint { margin: 0 0 12px; font-size: 13px; color: var(--muted); }
65+.list { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
66+.option { display: flex; align-items: center; gap: 10px; padding: 6px 10px 6px 6px; border: 1px solid var(--line); border-radius: 6px; background: none; font-size: 13px; color: var(--muted); cursor: pointer; text-align: left; }
67+.option:hover { border-color: var(--line-strong); }
68+.option.active { border-color: var(--ink); color: var(--ink); font-weight: 500; }
69+</style>