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>
97 lines
3.7 KiB
Dart
97 lines
3.7 KiB
Dart
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;
|
||
}
|