LifePlay-Revival/Tools/HairPipeline.ps1
edujiro5972 a680f0bf19 H3b : outillage des mods d'assets (BuildMod, ModSources, pipeline en mode mod)
- Tools/BuildMod.ps1 : controles, cuisson DLC contre la version de reference, controle du
  conteneur, archive installable testee, installation optionnelle dans un jeu de test.
- Tools/ModSource.ps1 : sources dans ModSources/<Nom>, montees dans Mods/ le temps d'un
  travail ; le build du jeu de base ne contient rien des mods.
- HairPipeline en mode mod ; HairMaterials : analyse du dossier (plantage dans un plugin),
  reutilisation des textures ; sous-systeme : repli DoesPackageExist.

Valide en jeu le 2026-10-03 (CHG-104).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 09:06:48 +02:00

284 lines
19 KiB
PowerShell

<#
.SYNOPSIS
Chaine industrialisee de conversion des coiffures (Docs/Migration/13_Protocole_Coiffure.md, section 7).
.DESCRIPTION
Pilotee par un fichier JSON (un lot de coiffures). Deux etapes :
-Step Inspect : pour chaque coiffure
1. extrait l'archive (zips imbriques compris) dans <backup>\<nom>\src ; copie le FBX et les textures sous des
noms sans espaces ;
2. importe le FBX en maillages statiques (Tools/HairImportFbx.py) dans <folder>/_Work_<nom> ;
3. propose : maillage de la coiffure, tete de reference (nom contenant "head"), emplacements de calotte (nom
contenant base, cap ou scalp), texture de couleur et masque des meches (analyse des images : niveaux de gris,
proportion de pixels noirs/blancs, orientation) ;
4. ecrit la proposition dans le fichier JSON (champs "auto" remplaces) et un rapport.
-> relire le rapport, corriger le JSON si besoin.
-Step Apply : pour chaque coiffure inspectee
1. LPHairFromStaticMesh (essai a blanc, controle des echelles, puis conversion) ;
2. Tools/HairMaterials.py (textures, materiaux DazHair, controle des dependances) ;
3. dossier _Work_<nom> deplace vers <backup>\<nom>\work (jamais supprime) ;
4. Modules/<module>/Hairstyles/<nom>.lphair (attribution comprise) ; credits dans
Licenses/Free_3D_Models_Credits.txt (une ligne par coiffure, sans doublon) ;
puis Tools/BuildCollections.py et un bilan. Le build et le controle en jeu restent manuels (protocole, etapes 6 et 7).
Une coiffure dont le SkeletalMesh existe deja est sautee (rien n'est ecrase), sauf avec -Rebuild : la coiffure et son
squelette precedents sont alors deplaces dans <backup>\<nom>\precedente_<date> avant reconstruction.
Options de LPHairFromStaticMesh par coiffure dans le JSON : margin, scale, offset_z, offset_y, skin (transfer|rigid),
gap (cm).
Mod d'assets (H3b) : avec "mod": "<Nom>" et "folder": "/<Nom>/Hairs", les sources ModSources\<Nom> sont montees dans
Mods\<Nom> pendant le travail (Tools/ModSource.ps1) puis remises en place ; les .lphair et les credits vont dans
ModSources\<Nom>\Module ; -Rebuild deplace aussi les textures du mod vers la sauvegarde (pas de remplacement de
texture dans un plugin). Le mod se cuit ensuite avec Tools/BuildMod.ps1.
.EXAMPLE
.\Tools\HairPipeline.ps1 -Config Tools\hairs\HAIRS1.json -Step Inspect
.\Tools\HairPipeline.ps1 -Config Tools\hairs\HAIRS1.json -Step Apply
#>
param(
[Parameter(Mandatory)] [string] $Config,
[Parameter(Mandatory)] [ValidateSet('Inspect', 'Apply')] [string] $Step,
[string] $Only = '',
[switch] $Rebuild
)
$ErrorActionPreference = 'Stop'
$Root = Split-Path $PSScriptRoot -Parent
$Proj = Join-Path $Root 'LifePlay.uproject'
$UE = 'D:\Game\Launcher\Engine\UE_5.5\Engine\Binaries\Win64\UnrealEditor-Cmd.exe'
$Tools = ($PSScriptRoot -replace '\\', '/')
Add-Type -AssemblyName System.Drawing
. (Join-Path $PSScriptRoot 'ModSource.ps1')
$cfg = Get-Content $Config -Raw | ConvertFrom-Json
$backup = $cfg.backup
$folder = $cfg.folder
$mod = $cfg.mod
New-Item -ItemType Directory -Force $backup | Out-Null
function Invoke-UE([string[]] $Arguments, [string] $Filter) {
$out = & $UE $Proj @Arguments -unattended -nopause -nosplash 2>&1 | ForEach-Object { "$_" }
$out | Where-Object { $_ -match $Filter -and $_ -notmatch 'LogInit: Display' }
}
# Chemin disque d'un chemin de paquet : /Game/X -> Content/X ; /<Plugin>/X -> Mods/<Plugin>/Content/X (mod, H3)
# ou Plugins/<Plugin>/Content/X.
function Get-DiskPath([string] $PackagePath) {
$parts = $PackagePath.TrimStart('/').Split('/', 2)
if ($parts[0] -eq 'Game') { return Join-Path $Root ('Content\' + ($parts[1] -replace '/', '\')) }
foreach ($base in 'Mods', 'Plugins') {
$pluginDir = Join-Path $Root "$base\$($parts[0])"
if (Test-Path $pluginDir) { return Join-Path $pluginDir ('Content\' + ($parts[1] -replace '/', '\')) }
}
throw "Dossier de contenu introuvable pour $PackagePath"
}
$SevenZip = 'C:\Program Files\7-Zip\7z.exe'
function Expand-One([string] $Archive, [string] $Dest) {
# 7-Zip lit zip, rar et 7z (Pocolov 03 : source/Hair3.rar) ; Expand-Archive en secours pour les zip.
if (Test-Path $SevenZip) { & $SevenZip x -y "-o$Dest" $Archive | Out-Null }
else { Expand-Archive -LiteralPath $Archive -DestinationPath $Dest -Force }
}
function Expand-All([string] $Archive, [string] $Dest) {
Expand-One $Archive $Dest
# Archives imbriquees (Pocolov : source/<nom>.zip ou .rar)
for ($pass = 0; $pass -lt 3; $pass++) {
$inner = Get-ChildItem $Dest -Recurse -Include *.zip, *.rar, *.7z | Where-Object { -not (Test-Path ($_.FullName + '.done')) }
if (-not $inner) { break }
foreach ($z in $inner) {
Expand-One $z.FullName (Join-Path $z.DirectoryName ($z.BaseName -replace '\s', '_'))
New-Item -ItemType File ($z.FullName + '.done') | Out-Null
}
}
}
# Statistiques d'une image : saturation moyenne, part de pixels tres sombres / tres clairs, part sombre du bas.
function Get-ImageStats([string] $Path) {
try { $bmp = [System.Drawing.Bitmap]::FromFile($Path) } catch { return $null }
try {
$step = [Math]::Max(1, [int]([Math]::Sqrt($bmp.Width * $bmp.Height / 20000)))
$n = 0; $sat = 0.0; $dark = 0; $light = 0; $bottomDark = 0; $bottomN = 0; $sideDark = 0; $sideN = 0
for ($y = 0; $y -lt $bmp.Height; $y += $step) {
for ($x = 0; $x -lt $bmp.Width; $x += $step) {
$c = $bmp.GetPixel($x, $y)
$max = [Math]::Max($c.R, [Math]::Max($c.G, $c.B)); $min = [Math]::Min($c.R, [Math]::Min($c.G, $c.B))
$sat += ($max - $min) / 255.0
$l = ($c.R + $c.G + $c.B) / 765.0
if ($l -lt 0.15) { $dark++ } elseif ($l -gt 0.85) { $light++ }
if ($y -gt $bmp.Height * 0.8) { $bottomN++; if ($l -lt 0.3) { $bottomDark++ } }
if ($x -lt $bmp.Width * 0.05 -or $x -ge $bmp.Width * 0.95) { $sideN++; if ($l -lt 0.3) { $sideDark++ } }
$n++
}
}
[pscustomobject]@{
File = $Path; Width = $bmp.Width; Height = $bmp.Height
Saturation = [Math]::Round($sat / $n, 3)
Binary = [Math]::Round(($dark + $light) / $n, 3)
Dark = [Math]::Round($dark / $n, 3)
BottomDark = if ($bottomN) { [Math]::Round($bottomDark / $bottomN, 3) } else { 0 }
SideDark = if ($sideN) { [Math]::Round($sideDark / $sideN, 3) } else { 0 }
}
} finally { $bmp.Dispose() }
}
function Select-Textures($all, [string[]] $StrandSlots) {
# Archive avec une tete complete (Pocolov 03 : peau, yeux, sourcils) : ne garder que les textures nommees "hair"
# s'il y en a.
$stats = @($all | Where-Object { (Split-Path $_.File -Leaf) -match 'hair' })
if (-not $stats) { $stats = $all }
# Masque : niveaux de gris, majoritairement noir/blanc. Masque des meches : vertical (hauteur > largeur ; un masque
# carre est en general la lisiere de la calotte, Pocolov 16) ; a
# egalite, celui dont le nom contient un emplacement des meches (Hair_01 -> Hair_01A1), puis l'ordre alphabetique
# (A1 avant A2). Couleur : le reste ; de preference grise (teintee par HairColor), sinon la plus grande.
$masks = $stats | Where-Object { $_.Saturation -lt 0.05 -and $_.Binary -gt 0.55 }
$matchesSlot = { param($f) foreach ($s in $StrandSlots) { if ($s -and (Split-Path $f -Leaf) -match [regex]::Escape($s)) { return $true } }; $false }
# Puis bords lateraux sombres (carte de meches a bords transparents : Hair_01A1 plutot que Hair_01A, Pocolov 03).
$strands = $masks | Where-Object { $_.Height -gt $_.Width } | Sort-Object @{ e = { & $matchesSlot $_.File }; Descending = $true }, @{ e = { $_.SideDark -gt 0.5 }; Descending = $true }, @{ e = { Split-Path $_.File -Leaf } } | Select-Object -First 1
$colors = $stats | Where-Object { $_ -notin $masks }
$diffuse = $colors | Sort-Object @{ e = { $_.Saturation -lt 0.05 }; Descending = $true }, @{ e = { $_.Height * $_.Width }; Descending = $true } | Select-Object -First 1
[pscustomobject]@{ Diffuse = $diffuse; Strands = $strands }
}
$report = @()
if ($mod) {
if ($folder -notlike "/$mod/*") { throw "folder ($folder) doit etre dans /$mod/" }
Mount-ModSource $Root $mod | Out-Null
}
try {
foreach ($h in $cfg.hairs) {
if ($Only -and $h.name -ne $Only) { continue }
$name = $h.name
$dir = Join-Path $backup $name
$meshPath = "$folder/$name"
$work = "$folder/_Work_$name"
if ($Step -eq 'Inspect') {
Write-Host "== $name : inspection"
if (-not (Test-Path $h.archive)) { $report += "$name : archive absente ($($h.archive))"; continue }
Remove-Item (Join-Path $dir 'src') -Recurse -Force -ErrorAction SilentlyContinue
Expand-All $h.archive (Join-Path $dir 'src')
$norm = Join-Path $dir 'norm'
New-Item -ItemType Directory -Force $norm | Out-Null
$fbx = Get-ChildItem (Join-Path $dir 'src') -Recurse -Filter *.fbx | Sort-Object Length -Descending | Select-Object -First 1
if (-not $fbx) { $report += "$name : aucun FBX dans l'archive"; continue }
Copy-Item $fbx.FullName (Join-Path $norm 'hair.fbx') -Force
$i = 0; $stats = @()
foreach ($t in Get-ChildItem (Join-Path $dir 'src') -Recurse -Include *.png, *.jpg, *.jpeg, *.tga, *.bmp) {
$i++
$copy = Join-Path $norm ("tex{0:D2}_{1}{2}" -f $i, ($t.BaseName -replace '[^A-Za-z0-9_-]', '_'), $t.Extension.ToLower())
Copy-Item $t.FullName $copy -Force
$s = Get-ImageStats $copy
if ($s) { $stats += $s }
}
$json = Join-Path $dir 'import.json'
$fbxArg = (Join-Path $norm 'hair.fbx') -replace '\\', '/'
Invoke-UE @('-run=pythonscript', "-script=$Tools/HairImportFbx.py fbx=$fbxArg dest=$work report=$($json -replace '\\','/')") 'HAIRIMPORT' | Write-Host
if (-not (Test-Path $json)) { $report += "$name : import du FBX echoue"; continue }
$imp = Get-Content $json -Raw | ConvertFrom-Json
$head = $imp.meshes | Where-Object { $_.name -match 'head' } | Sort-Object triangles -Descending | Select-Object -First 1
$hairMeshes = @($imp.meshes | Where-Object { $_ -ne $head })
$hair = $hairMeshes | Sort-Object triangles -Descending | Select-Object -First 1
# Champs detectes : ceux valant "auto" a la premiere inspection (memorises dans auto_fields, recalcules a chaque
# inspection) ; une valeur saisie a la main n'est jamais remplacee.
if (-not $h.auto_fields) { $h | Add-Member auto_fields @('mesh', 'head', 'cap_slots', 'diffuse', 'strands_opacity' | Where-Object { $h.$_ -eq 'auto' }) -Force }
$auto = { param($f) $f -in $h.auto_fields }
if (& $auto 'mesh') { $h.mesh = $hair.path }
if (& $auto 'head') { $h.head = if ($head) { $head.path } else { $hair.path } }
# Associations materiau -> textures declarees dans le FBX (Tools/FbxMaterialMap.py), pour le rapport et le cas
# d'un emplacement unique.
$fbxMap = $null
try { $fbxMap = (& python (Join-Path $PSScriptRoot 'FbxMaterialMap.py') (Join-Path $norm 'hair.fbx')) -join "`n" | ConvertFrom-Json } catch { }
$findCopy = { param($fileName) if ($fileName) { $b = [IO.Path]::GetFileNameWithoutExtension($fileName) -replace '[^A-Za-z0-9_-]', '_'; ($stats | Where-Object { (Split-Path $_.File -Leaf) -match ('^tex\d+_' + [regex]::Escape($b) + '\.') } | Select-Object -First 1).File } }
# Calotte : emplacement nomme base/cap/scalp, seulement s'il reste au moins un autre emplacement pour les meches.
if (& $auto 'cap_slots') { $h.cap_slots = if ($hair.slots.Count -gt 1) { (@($hair.slots | Where-Object { $_ -match 'base|cap|scalp' }) -join ',') } else { '' } }
$strandSlots = @($hair.slots | Where-Object { $_ -notin ($h.cap_slots -split ',') })
$sel = Select-Textures $stats $strandSlots
if (& $auto 'diffuse') { $h.diffuse = if ($sel.Diffuse) { $sel.Diffuse.File -replace '\\', '/' } else { '' } }
if (& $auto 'strands_opacity') {
# Emplacement unique (Pocolov 11) : masque declare par le FBX pour cet emplacement ; sinon masque vertical.
$declared = if ($strandSlots.Count -eq 1 -and $hair.slots.Count -eq 1 -and $fbxMap) { & $findCopy $fbxMap.($strandSlots[0]).TransparentColor }
$h.strands_opacity = if ($declared) { $declared -replace '\\', '/' } elseif ($sel.Strands) { $sel.Strands.File -replace '\\', '/' } else { '' }
}
$notes = @()
if ($hairMeshes.Count -gt 1) { $notes += "$($hairMeshes.Count) maillages hors tete : seul '$($hair.name)' est converti" }
if (-not $head) { $notes += 'pas de tete de reference : ajustement sur la coiffure elle-meme (margin 1.0)'; if (-not $h.margin) { $h | Add-Member margin '1.0' -Force } }
if (-not $h.diffuse -or -not $h.strands_opacity) { $notes += 'textures non identifiees : renseigner diffuse / strands_opacity' }
$h | Add-Member inspected $true -Force
$report += "$name : coiffure $($hair.name) ($($hair.triangles) tri., emplacements $($hair.slots -join ',')), tete $(if ($head) { $head.name } else { '-' }), calotte [$($h.cap_slots)], couleur $(Split-Path $h.diffuse -Leaf), masque $(Split-Path $h.strands_opacity -Leaf)$(if ($notes) { ' ; ' + ($notes -join ' ; ') })"
if ($fbxMap) { $report += ($fbxMap.PSObject.Properties | ForEach-Object { " FBX $($_.Name) : " + (($_.Value.PSObject.Properties | ForEach-Object { "$($_.Name)=$($_.Value)" }) -join ', ') }) }
$report += ($stats | ForEach-Object { " texture $(Split-Path $_.File -Leaf) : $($_.Width)x$($_.Height), saturation $($_.Saturation), noir/blanc $($_.Binary), bas sombre $($_.BottomDark), bords sombres $($_.SideDark)" })
}
else {
Write-Host "== $name : application"
if (-not $h.inspected) { $report += "$name : non inspectee, sautee"; continue }
$existing = (Get-DiskPath $meshPath) + '.uasset'
if (Test-Path $existing) {
if (-not $Rebuild) { $report += "$name : $meshPath existe deja, sautee (-Rebuild pour la refaire)"; continue }
# -Rebuild : coiffure et squelette precedents deplaces vers la sauvegarde (materiaux et textures mis a jour sur place).
$previous = Join-Path $dir ('precedente_' + (Get-Date -Format 'yyyyMMdd_HHmmss'))
New-Item -ItemType Directory -Force $previous | Out-Null
Move-Item $existing $previous
Move-Item ($existing -replace '\.uasset$', '_Skeleton.uasset') $previous -ErrorAction SilentlyContinue
if ($mod) {
foreach ($suffix in '_Diffuse', '_StrandsOpacity', '_CapOpacity') {
Move-Item ($existing -replace '\.uasset$', "$suffix.uasset") $previous -ErrorAction SilentlyContinue
}
}
}
$common = @("-Static=$($h.mesh)", "-FitHead=$($h.head)", "-Out=$meshPath")
if ($h.margin) { $common += "-Margin=$($h.margin)" }
foreach ($k in 'scale', 'offset_z', 'offset_y', 'skin', 'gap') { if ($h.$k) { $common += ('-' + @{ scale = 'Scale'; offset_z = 'OffsetZ'; offset_y = 'OffsetY'; skin = 'Skin'; gap = 'Gap' }[$k] + '=' + $h.$k) } }
$dry = Invoke-UE (@('-run=LPHairFromStaticMesh') + $common + '-DryRun') 'LogLPHairFromStatic: (Warning|Error)'
$dry | Write-Host
$scaleLine = $dry | Where-Object { $_ -match 'echelle X=([\d.]+) Y=([\d.]+)' } | Select-Object -First 1
if (-not $scaleLine) { $report += "$name : essai a blanc en echec"; continue }
$sx = [double]$Matches[1]; $sy = [double]$Matches[2]
if ($sx -lt 0.6 -or $sx -gt 1.8 -or $sy -lt 0.6 -or $sy -gt 1.8) { $report += "$name : echelle hors limites (X=$sx Y=$sy), sautee : verifier la tete de reference"; continue }
$conv = Invoke-UE (@('-run=LPHairFromStaticMesh') + $common) 'LogLPHairFromStatic: (Warning|Error)'
if (-not ($conv -match 'sauvegarde :.*' + [regex]::Escape($name) + '\.uasset')) { $conv | Write-Host; $report += "$name : conversion en echec"; continue }
$placed = ($conv | Where-Object { $_ -match 'bornes' }) -replace '.*bornes ', ''
$mat = Invoke-UE @('-run=pythonscript', "-script=$Tools/HairMaterials.py mesh=$meshPath diffuse=$($h.diffuse) strands_opacity=$($h.strands_opacity) cap_opacity=$($h.cap_opacity) cap=$($h.cap_slots) thicken=$($h.thicken)") 'HAIRMAT'
$mat | Write-Host
$depOk = [bool]($mat -match 'dependances.*-> OK')
# Nettoyage : dossier de travail deplace vers la sauvegarde.
$workDir = Get-DiskPath $work
if (Test-Path $workDir) { Move-Item $workDir (Join-Path $dir 'work') -Force }
# Declaration et credits.
# Mod (H3b) : module texte du mod, dans ses sources (montees dans Mods\<mod> pendant le travail).
$moduleDir = if ($mod) { Join-Path $Root "Mods\$mod\Module" } else { Join-Path $Root "Content\Modules\$($cfg.module)" }
$lphairDir = Join-Path $moduleDir 'Hairstyles'
New-Item -ItemType Directory -Force $lphairDir | Out-Null
$credit = "$($h.title) by $($h.author), $($h.license), $($h.url)"
$lines = @(
"// $($h.title) - $($h.author) - licence $($h.license)",
"// Source : $($h.url)",
'// Modifications (Tools/HairPipeline.ps1) : liee au squelette Genesis 8 par transfert de poids depuis le corps (tete, cou, poitrine), ajustee a la tete de Bridget, materiaux DazHair.',
"HAIRSTYLE: $meshPath.$name", "SEX: $($h.sex)", 'DAZ: true')
[IO.File]::WriteAllText((Join-Path $lphairDir "$($name.ToLower()).lphair"), ($lines -join "`r`n") + "`r`n", (New-Object Text.UTF8Encoding $false))
# Credits : fichier du jeu, ou Module\CREDITS.txt d'un mod.
$credits = if ($mod) { Join-Path $moduleDir 'CREDITS.txt' } else { Join-Path $Root 'Licenses\Free_3D_Models_Credits.txt' }
if (-not (Test-Path $credits)) { New-Item -ItemType File -Force $credits | Out-Null }
if (-not (Select-String -LiteralPath $credits -SimpleMatch $h.url -Quiet)) {
Add-Content -LiteralPath $credits -Value ("- $credit . Changes: skinned to the Genesis 8 skeleton by weight transfer from the body (head, neck, chest), fitted to the Bridget head, DazHair materials ($meshPath*).") -Encoding utf8
}
$report += "$name : OK, echelle X=$sx Y=$sy, bornes $placed, dependances $(if ($depOk) { 'OK' } else { 'A VERIFIER' })"
}
}
if ($Step -eq 'Inspect') {
$cfg | ConvertTo-Json -Depth 6 | Set-Content $Config -Encoding utf8
}
else {
Invoke-UE @('-run=pythonscript', "-script=$Tools/BuildCollections.py") 'collection\(s\) ecrite' | Write-Host
}
}
finally {
if ($mod) { Dismount-ModSource $Root $mod }
}
$reportFile = Join-Path $backup "rapport_$Step.txt"
$report | Set-Content $reportFile -Encoding utf8
Write-Host "`n=== Bilan ($Step) : $reportFile"
$report | Write-Host