manager-app/lib/Helpers/ImageCompressor.dart
Thomas Fransolet c8947880ba Ecran VR, section Scene3D et medias immersifs
Canal VR
- L'entree VR affiche VrScreen au lieu de « non configuree » : sous-onglet
  Configuration (AppConfigurationLinkScreen reutilise) et sous-onglet Casques
  (pincode d'appairage, etat, batterie, version, dernier vu).
- Le dialogue de plan SuperAdmin porte aussi les canaux et les add-ons
  (assistant, contenu immersif). Activer un canal cree son
  ApplicationInstance s'il n'existe pas ; le desactiver ne supprime rien.

Medias immersifs
- Types Image360, Video360, Model3D : icones, libelles, extensions, apercu
  dans la Mediatheque et facettes.
- Au depot, la pastille de type bascule Image <-> Image 360 et
  Video <-> Video 360 : rien dans un .jpg ne dit qu'il est
  equirectangulaire. Un .glb est reconnu seul.
- ImageCompressor ne touche plus aux 360 ni aux GLB, sur les deux chemins
  d'upload : ramenee a 2560 px, une equirectangulaire devient illisible dans
  un casque.

Scene3D
- Nouveau type de section (famille des lieux) et son ecran de configuration :
  modele, mode objet/decor, points d'interet.
- Fond immersif d'une configuration, visible si l'instance a l'add-on : le
  type est deduit de la ressource choisie, avec une image de repli.

Client API edite a la main en miroir du backend : SectionScene3DApi,
Scene3DDTO, ImmersiveBackgroundDTO, Position3D, AppType sur les devices,
ResourceType etendu. Libelles FR/EN/NL.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-13 16:54:13 +02:00

97 lines
3.7 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'dart:typed_data';
import 'package:image/image.dart' as img;
import 'package:manager_api_new/api.dart';
/// Compression des images à l'upload.
///
/// Un seul point d'entrée, appelé par les **deux** chemins d'upload de
/// `resources_screen`. Côté serveur, ces deux chemins avaient divergé sur
/// `StoragePath` puis sur le contrôle de quota (C1 et C3) : la même erreur
/// coûterait ici des images compressées d'un côté et pas de l'autre.
class ImageCompressor {
/// Côté le plus long. Au-delà, une photo n'apporte rien à l'écran d'un
/// visiteur et coûte du stockage, du transfert et du temps de visite hors ligne.
static const int maxDimension = 2560;
static const int jpegQuality = 82;
static const List<String> _compressibleExtensions = ['jpg', 'jpeg', 'png'];
static bool handles(String? extension) =>
extension != null && _compressibleExtensions.contains(extension.toLowerCase());
/// Renvoie les octets à téléverser et le type MIME correspondant.
///
/// L'original est renvoyé tel quel si le format n'est pas gérable, si le
/// décodage échoue, ou si la compression n'a rien gagné — une image déjà
/// petite et bien encodée peut grossir en repassant par un ré-encodage.
///
/// [type] décide aussi : une image 360 n'est **jamais** compressée. Ramenée à
/// 2560 px de côté long, une équirectangulaire de 8192×4096 perd les trois
/// quarts de sa définition — sur un écran ça se voit à peine, dans un casque
/// elle couvre tout le champ de vision et devient une bouillie.
static CompressedImage compress(Uint8List bytes, String? extension, String fallbackMimeType,
{ResourceType? type}) {
if (type == ResourceType.Image360 || type == ResourceType.Video360 ||
type == ResourceType.Model3D) {
return CompressedImage(bytes, fallbackMimeType, false);
}
if (!handles(extension)) {
return CompressedImage(bytes, fallbackMimeType, false);
}
try {
final decoded = img.decodeImage(bytes);
if (decoded == null) {
return CompressedImage(bytes, fallbackMimeType, false);
}
final resized = (decoded.width > maxDimension || decoded.height > maxDimension)
? img.copyResize(
decoded,
width: decoded.width >= decoded.height ? maxDimension : null,
height: decoded.height > decoded.width ? maxDimension : null,
interpolation: img.Interpolation.average,
)
: decoded;
// Un PNG à canal alpha reste un PNG : le passer en JPEG remplirait la
// transparence de noir, ce qui abîmerait les logos et les filigranes.
final keepsAlpha = decoded.hasAlpha;
final encoded = keepsAlpha
? Uint8List.fromList(img.encodePng(resized))
: Uint8List.fromList(img.encodeJpg(resized, quality: jpegQuality));
if (encoded.length >= bytes.length) {
return CompressedImage(bytes, fallbackMimeType, false,
width: decoded.width, height: decoded.height);
}
return CompressedImage(encoded, keepsAlpha ? 'image/png' : 'image/jpeg', true,
width: resized.width, height: resized.height);
} catch (e) {
print("ERROR ImageCompressor.compress");
print(e);
return CompressedImage(bytes, fallbackMimeType, false);
}
}
}
class CompressedImage {
final Uint8List bytes;
final String mimeType;
final bool wasCompressed;
/// Dimensions de l'image telle qu'elle sera stockee. Nulles quand le format
/// n'est pas decodable ici : la Mediatheque n'affiche alors pas de dimensions.
final int? width;
final int? height;
CompressedImage(this.bytes, this.mimeType, this.wasCompressed,
{this.width, this.height});
int get sizeBytes => bytes.length;
}