A Blazor/Sandbox UI panel for creating a face from up to five reference photo URLs. It validates and stores image URLs, spawns a FacePhotoBridge to analyze each image in the background, shows status and thumbnails, and generates a PhotoFace by fitting measurements to a selected base when requested.
@using Sandbox
@using Sandbox.UI
@using System
@namespace CharacterEditor
@inherits Panel
<root>
<div class="optionheading">Make a face from photos</div>
<div class="optiondescription">Add up to five direct image links of the same person. Combine a front photo with an angled or side photo and a relaxed expression. Front views guide widths; side views guide the nose, lips and chin. The result is an approximate starting shape; refine it in Shape afterwards.</div>
<div class="optiondescription">Images are read by the background analyzer on this device. Some image hosts block this access. Photos and links are not included in saved characters.</div>
<TextEntry class="photo-url" Value=@Url OnTextEdited=@((string value)=>{Url=value;}) Placeholder="https://…/portrait.jpg" MaxLength=@(2048) />
<button class="creatorbutton add-photo" onclick=@AddReference>Load reference</button>
@if(Rows.Count>0){<button class="creatorbutton quiet refresh-photos" onclick=@RefreshReferences>Refresh analyses</button>}
@if(!string.IsNullOrEmpty(Message)){<div class="optiondescription">@Message</div>}
@foreach(var row in Rows)
{
<div class="photo-reference">
@if(row.Measurement is not null){<img class="photo-thumb" [email protected] />}
<div class="photo-reference-text"><div class="fieldlabel">Reference @(Rows.IndexOf(row)+1)</div><div class="optiondescription">@row.Status</div><button class=@($"creatorbutton quiet remove-photo-{Rows.IndexOf(row)}") onclick=@(()=>Remove(row))>Remove</button></div>
</div>
}
<div class="optiondescription">@(Rows.Any(x=>x.Measurement?.FrontUsable==true)?"Front widths: covered.":"Add a front view for face widths.") @(Rows.Any(x=>x.Measurement?.Yaw>=45)?"Profile depth: covered.":Rows.Any(x=>x.Measurement?.ProfileUsable==true)?"Some depth from angled views; a clearer side view helps.":"Add an angled or side view for nose and chin depth.")</div>
<div class="fieldlabel">Starting heads</div>
<div class="optiondescription">Choose the foundation to keep while fitting. Selected skin uses that skin's original head family; it does not guess from the photos. Current blend preserves your chosen balance of male and female heads.</div>
@foreach(var choice in new[]{("skin","Selected skin"),("female","Female heads"),("male","Male heads"),("current","Current blend")})
{
<button class=@($"creatorbutton {(Basis==choice.Item1?"primary":"quiet")}") onclick=@(()=>Basis=choice.Item1)>@choice.Item2</button>
}
<div class="fieldlabel">Name your base face</div>
<TextEntry class="photo-name" Value=@FaceName OnTextEdited=@((string value)=>{FaceName=value;}) MaxLength=@(40) Placeholder="My photo face" />
<button class="creatorbutton primary generate-photo-face" onclick=@Generate>Generate base face</button>
<div class="optiondescription">Generation preserves the starting balance of male and female heads while fitting proportions. It adds a custom face at full influence; existing face influences are set to zero. Your Shape edits remain applied on top.</div>
</root>
@code {
public CharacterAppearanceData Appearance { get; set; }
public Action<PhotoFace> Generated { get; set; }
public string AnalyzerPage { get; set; } = FacePhotoBridge.Page;
public sealed class Reference
{
public string Url, Status;
public FaceMeasurement Measurement;
}
readonly List<Reference> Rows = new();
string Url = "", FaceName = "My photo face", Message;
string Basis = "skin";
FacePhotoBridge Bridge;
Reference Active;
public void AddUrl(string url)
{
Url = url;
AddReference();
}
void AddReference()
{
if (Rows.Count >= 5)
{
Message = "You can use up to five references. Remove one to add another.";
return;
}
var value = Url.Trim();
if (!FacePhotoBridge.ValidUrl(value))
{
Message = "Enter a direct HTTPS link to a JPEG, PNG or WebP image.";
return;
}
if (Rows.Any(x => x.Url == value))
{
Message = "That reference is already in the list.";
return;
}
Rows.Add(new() { Url = value, Status = "Waiting…" });
Url = "";
Message = null;
}
void Remove(Reference row)
{
if (Active == row)
{
Bridge?.Dispose();
Bridge = null;
Active = null;
}
Rows.Remove(row);
Message = null;
}
public void RefreshReferences()
{
Bridge?.Dispose();
Bridge = null;
Active = null;
Message = null;
foreach (var row in Rows)
{
row.Measurement = null;
row.Status = "Waiting…";
}
}
public override void Tick()
{
base.Tick();
if (Bridge is null)
{
Active = Rows.FirstOrDefault(x => x.Status == "Waiting…");
if (Active is not null)
try
{
Bridge = new(this, Active.Url, AnalyzerPage);
}
catch (Exception e)
{
Active.Status = e.Message;
Active = null;
}
}
if (Bridge is null)
return;
Bridge.Tick();
Active.Status = Bridge.Status;
if (Bridge.Complete)
{
Active.Measurement = Bridge.Result;
if (Active.Measurement is not null)
Active.Status = "Ready · " + Active.Measurement.Description;
Bridge.Dispose();
Bridge = null;
Active = null;
}
}
void Generate()
{
if (Bridge is not null || Rows.Any(x => x.Status == "Waiting…"))
{
Message = "Wait for the references to finish loading first.";
return;
}
var good = Rows.Where(x => x.Measurement is not null).ToArray();
if (good.Length == 0)
{
Message = "Load at least one usable reference first.";
return;
}
if (good.Length != Rows.Count)
{
Message = "Remove the failed references or replace them before generating.";
return;
}
if (Appearance.BaseShape?.PhotoFaces.Count >= 4)
{
Message = "You can keep four custom faces in a character. Remove one in Face & body first.";
return;
}
try
{
UnifiedHuman.Enable(Appearance);
Generated?.Invoke(PhotoFaceFit.Generate(FaceName, good.Select(x => x.Measurement).ToArray(), PhotoFaceFit.StartingHeads(Appearance, Basis)));
}
catch (Exception e)
{
Message = e.Message;
}
}
public override void Delete(bool immediate = false)
{
Bridge?.Dispose();
base.Delete(immediate);
}
protected override int BuildHash() => HashCode.Combine(Url, FaceName, Message, Basis, string.Join("|", Rows.Select(x => x.Url + x.Status)));
}