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.
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: | ||
| 68 | 68 | return m.group(1).strip() if m else None |
| 69 | 69 | |
| 70 | 70 | |
| 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}.""" | |
| 74 | 78 | out = {k: _root_var(css, k) for k in COLORS} |
| 75 | 79 | out["radius"] = int(re.sub(r"\D", "", _root_var(css, "radius") or "22") or 22) |
| 76 | 80 | for k in FONTS: |
| 77 | 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 | 83 | 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 | 91 | def design() -> dict: |
| 83 | 92 | saved = json.loads(DESIGN_FILE.read_text(encoding="utf-8")) if DESIGN_FILE.is_file() else {} |
| 84 | 93 | return {**defaults(), **saved} |
| 85 | 94 | |
| 86 | 95 | |
| 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) | |
| 90 | 99 | for k in COLORS: |
| 91 | 100 | if isinstance(values.get(k), str) and _HEX.match(values[k]): |
| 92 | 101 | out[k] = values[k].lower() |
| @@ -97,14 +106,21 @@ def save_design(values: dict) -> dict: | ||
| 97 | 106 | out[k] = values[k].strip() |
| 98 | 107 | if values.get("texture") in TEXTURES: |
| 99 | 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 | 115 | USER_DIR.mkdir(parents=True, exist_ok=True) |
| 101 | 116 | DESIGN_FILE.write_text(json.dumps(out, ensure_ascii=False, indent=1), encoding="utf-8") |
| 102 | 117 | return out |
| 103 | 118 | |
| 104 | 119 | |
| 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() | |
| 108 | 124 | css = _base_css() |
| 109 | 125 | families = "&".join(f"family={quote_plus(d[k])}:wght@{WEIGHTS[k]}" for k in FONTS) |
| 110 | 126 | css = re.sub(r'@import url\("https://fonts\.googleapis\.com/[^"]*"\);', |
| @@ -113,10 +129,7 @@ def style_css() -> str: | ||
| 113 | 129 | values |= {k: f'"{d[k]}", {FALLBACK[k]}' for k in FONTS} |
| 114 | 130 | for k, v in values.items(): |
| 115 | 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" | |
| 117 | - if texture.is_file(): | |
| 118 | - css += texture.read_text(encoding="utf-8") | |
| 119 | - return css | |
| 132 | + return css + _texture_css(d["texture"]) | |
| 120 | 133 | |
| 121 | 134 | |
| 122 | 135 | 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 None | 68 | 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 out | 83 | 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 out | 117 | 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): | ||
| 1117 | 1117 | path = (root / rel).resolve() |
| 1118 | 1118 | if root not in path.parents or not path.is_file(): |
| 1119 | 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 | 1124 | # --- Assets : YouTube -------------------------------------------------------------------- |
| @@ -1407,6 +1408,21 @@ class MontageRequest(BaseModel): | ||
| 1407 | 1408 | 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 | 1426 | @router.put("/{pid}/montage") |
| 1411 | 1427 | def put_montage(pid: str, req: MontageRequest): |
| 1412 | 1428 | _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 = None | 1408 | 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: | ||
| 138 | 138 | |
| 139 | 139 | def prepare(pid: str) -> None: |
| 140 | 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 kit | |
| 141 | + # la charte est celle du projet (au départ, le design system du kit) ; les exemples suivent le kit | |
| 142 | 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 | 178 | def load(pid: str) -> dict: |
| 146 | 179 | path = root(pid) / TIMELINE_FILE |
| 147 | 180 | if path.is_file(): |
| @@ -845,8 +878,11 @@ def motion_signature(pid: str, item: dict) -> str | None: | ||
| 845 | 878 | comp = motion_dir(pid) / f"{item['id']}.html" |
| 846 | 879 | if not comp.is_file(): |
| 847 | 880 | 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() | |
| 850 | 886 | if style.is_file(): |
| 851 | 887 | body += style.read_bytes() |
| 852 | 888 | 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 kit | 141 | + # 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_FILE | 179 | 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 None | 880 | 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 | ||
| 160 | 160 | fais-la directement : modifie {work}/timeline.json (le client voit tes changements à la fin \ |
| 161 | 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 | 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 | 165 | - Ne crée ni ne modifie aucun autre fichier que {work}/timeline.json, {work}/insertions.json \ |
| 164 | 166 | et montage/motion/*.html. |
| 165 | 167 | - 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 { | ||
| 22 | 22 | exportTitle: "Writes the .fcpxml to the project folder", |
| 23 | 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 | 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 | 33 | tracks: { |
| 27 | 34 | 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 { | ||
| 22 | 22 | exportTitle: "Écrit le .fcpxml dans le dossier du projet", |
| 23 | 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 | 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 | 33 | tracks: { |
| 27 | 34 | 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"; | ||
| 21 | 21 | import TaskQueue from "./montage/TaskQueue.vue"; |
| 22 | 22 | import { addMedia, bladeAroll, bladeItem, frameStep, insertAroll, isVideo, remove } from "./montage/ops"; |
| 23 | 23 | import Preview from "./montage/Preview.vue"; |
| 24 | +import StylePicker from "./montage/StylePicker.vue"; | |
| 24 | 25 | import Timeline from "./montage/Timeline.vue"; |
| 25 | 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 | 27 | import { useMontage } from "./montage/useMontage"; |
| @@ -367,6 +368,7 @@ onUnmounted(() => window.removeEventListener("keydown", onKey)); | ||
| 367 | 368 | <span v-if="M.dirty.value" class="saving mono">{{ tr("common.saving") }}</span> |
| 368 | 369 | <span v-else-if="M.busy.value" class="busy mono">{{ tr("editing.toolbar.busy") }}</span> |
| 369 | 370 | <span class="spacer" /> |
| 371 | + <StylePicker :pid="project.id" /> | |
| 370 | 372 | <button class="link-btn" :disabled="!view.can_undo" title="Ctrl+Z" @click="M.undo()">{{ tr("editing.toolbar.undo") }}</button> |
| 371 | 373 | <button class="btn" :disabled="exporting" :title="tr('editing.toolbar.exportTitle')" @click="exportResolve">{{ tr("editing.toolbar.export") }}</button> |
| 372 | 374 | <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> | ||