XFE Git
XFE Studio Git
Git 首页 全局搜索
XFE 主站 文档 NuGet

STHApplication

【MAUI】寄当家——安卓端交易管理系统

公开
关注 0 Fork 0 Star 0
UTF-8
using System.Diagnostics;
using System.Globalization;
using System.Text.Json;
using JiDangJia.Contracts;
using SkiaSharp;

namespace JiDangJia.Server;

internal sealed record MediaOrigin(string Source,DateTimeOffset CreatedAt,bool ExplicitTime)
{
    public string Timestamp=>CreatedAt.ToUniversalTime().ToString("O");
    public string Label=>Source=="CAMERA"?"APP现场拍摄":"用户本地素材";
    public string DisplayTime=>CreatedAt.ToOffset(TimeSpan.FromHours(8)).ToString("yyyy-MM-dd HH:mm:ss",CultureInfo.InvariantCulture);
    public static MediaOrigin Read(HttpRequest request,string kind)
    {
        if(kind=="AUDIO")return new("",DateTimeOffset.UtcNow,false);
        string source=request.Headers["X-Media-Source"].ToString(),time=request.Headers["X-Media-Created-At"].ToString();
        if(source.Length==0)source="LOCAL";if(source is not ("CAMERA" or "LOCAL"))throw new ArgumentException("素材来源无效,请重新选择素材");
        DateTimeOffset stamp=DateTimeOffset.UtcNow;if(time.Length>0&&(!DateTimeOffset.TryParse(time,CultureInfo.InvariantCulture,DateTimeStyles.None,out stamp)||stamp.Year<2000||stamp>DateTimeOffset.UtcNow.AddMinutes(15)))throw new ArgumentException("素材日期时间无效,请检查设备时间");
        return new(source,stamp,time.Length>0);
    }
    public void CheckRetry(Dictionary<string,object?> row)
    {
        if(ServerStore.Text(row,"source").Length==0)return; // Attachments from older clients keep their original retry contract.
        if(ServerStore.Text(row,"source")!=Source||ExplicitTime&&ServerStore.Text(row,"markedAt")!=Timestamp)throw new CommunityException(409,"重复上传的素材来源或时间不同");
    }
}

public sealed class MediaWatermark(IConfiguration configuration) : IDisposable
{
    private readonly SemaphoreSlim videoSlots=new(2);
    private readonly Lazy<SKTypeface> typeface=new(()=>LoadTypeface(configuration));
    private static SKTypeface LoadTypeface(IConfiguration configuration)
    {
        var path=configuration["JDJ_MEDIA_FONT"]??configuration["JDJ_WATERMARK_FONT"]??Path.Combine(AppContext.BaseDirectory,"media-tools","fonts","NotoSansCJKsc-Regular.otf");
        var face=File.Exists(path)?SKTypeface.FromFile(path):SKTypeface.FromFamilyName("Noto Sans CJK SC");
        if(face is null||!face.ContainsGlyphs("APP现场拍摄用户本地素材")){face?.Dispose();throw new CommunityException(503,"素材水印字体未配置,请联系管理员");}return face;
    }
    private SKData Stamp(int width,int height,MediaOrigin origin)
    {
        using var font=new SKFont(typeface.Value,Math.Clamp(width*.027f,10,40));
        float margin=Math.Clamp(width*.015f,2,24),padding=Math.Max(2,font.Size*.4f);
        float textWidth=Math.Max(font.MeasureText(origin.Label),font.MeasureText(origin.DisplayTime));
        float fit=Math.Min(1,Math.Min(Math.Max(1,width-2*margin)/(textWidth+2*padding),Math.Max(1,height-2*margin)/(font.Size*2.8f+2*padding)));
        font.Size*=fit;padding*=fit;textWidth=Math.Max(font.MeasureText(origin.Label),font.MeasureText(origin.DisplayTime));
        int w=Math.Max(1,(int)Math.Ceiling(textWidth+2*padding)),h=Math.Max(1,(int)Math.Ceiling(font.Size*2.8f+2*padding));
        using var surface=SKSurface.Create(new SKImageInfo(w,h));surface.Canvas.Clear(SKColors.Transparent);
        using var backdrop=new SKPaint{Color=new SKColor(25,25,25,165),IsAntialias=true};surface.Canvas.DrawRoundRect(new SKRect(0,0,w,h),padding,padding,backdrop);
        using var text=new SKPaint{Color=SKColors.White,IsAntialias=true};surface.Canvas.DrawText(origin.Label,w-padding-font.MeasureText(origin.Label),padding+font.Size,font,text);surface.Canvas.DrawText(origin.DisplayTime,w-padding-font.MeasureText(origin.DisplayTime),padding+font.Size*2.35f,font,text);
        using var snapshot=surface.Snapshot();return snapshot.Encode(SKEncodedImageFormat.Png,100);
    }
    internal byte[] Image(byte[] bytes,MediaOrigin origin)
    {
        using var data=SKData.CreateCopy(bytes);using var codec=SKCodec.Create(data)??throw new ArgumentException("图片无法读取,请重新选择");return Image(codec,origin);
    }
    internal byte[] Image(string path,MediaOrigin origin)
    {
        using var stream=new SKFileStream(path);using var codec=SKCodec.Create(stream)??throw new ArgumentException("图片无法读取,请重新选择");return Image(codec,origin);
    }
    private byte[] Image(SKCodec codec,MediaOrigin origin)
    {
        var info=codec.Info;if(info.Width is <1 or >8192||info.Height is <1 or >8192||(long)info.Width*info.Height>25_000_000)throw new ArgumentException("图片分辨率过大或无效");
        using var bitmap=SKBitmap.Decode(codec)??throw new ArgumentException("图片无法解码,请重新选择");
        int w=bitmap.Width,h=bitmap.Height;bool swap=(int)codec.EncodedOrigin>=5;int width=swap?h:w,height=swap?w:h;
        using var surface=SKSurface.Create(new SKImageInfo(width,height));var canvas=surface.Canvas;canvas.Clear(SKColors.White);canvas.Save();
        var matrix=codec.EncodedOrigin switch
        {
            SKEncodedOrigin.TopRight=>new SKMatrix(-1,0,w,0,1,0,0,0,1),
            SKEncodedOrigin.BottomRight=>new SKMatrix(-1,0,w,0,-1,h,0,0,1),
            SKEncodedOrigin.BottomLeft=>new SKMatrix(1,0,0,0,-1,h,0,0,1),
            SKEncodedOrigin.LeftTop=>new SKMatrix(0,1,0,1,0,0,0,0,1),
            SKEncodedOrigin.RightTop=>new SKMatrix(0,-1,h,1,0,0,0,0,1),
            SKEncodedOrigin.RightBottom=>new SKMatrix(0,-1,h,-1,0,w,0,0,1),
            SKEncodedOrigin.LeftBottom=>new SKMatrix(0,1,0,-1,0,w,0,0,1),
            _=>SKMatrix.Identity
        };
        canvas.Concat(matrix);canvas.DrawBitmap(bitmap,0,0);canvas.Restore();using var stamp=Stamp(width,height,origin);using var marker=SKImage.FromEncodedData(stamp);float margin=Math.Clamp(width*.015f,2,24);canvas.DrawImage(marker,Math.Max(0,width-marker.Width-margin),Math.Min(margin,Math.Max(0,height-marker.Height)));
        using var result=surface.Snapshot();using var encoded=result.Encode(SKEncodedImageFormat.Jpeg,90);return encoded.ToArray();
    }
    private string Tool(string name)
    {
        string? configured=configuration[name=="ffmpeg"?"JDJ_FFMPEG":"JDJ_FFPROBE"];
        if(!string.IsNullOrWhiteSpace(configured))return configured;
        string bundled=Path.Combine(AppContext.BaseDirectory,"media-tools","ffmpeg","bin",name+(OperatingSystem.IsWindows()?".exe":""));return File.Exists(bundled)?bundled:name;
    }
    private async Task<string> Run(string executable,IEnumerable<string> args,CancellationToken ct)
    {
        using var process=new Process{StartInfo=new(executable){UseShellExecute=false,CreateNoWindow=true,RedirectStandardError=true,RedirectStandardOutput=true}};foreach(var argument in args)process.StartInfo.ArgumentList.Add(argument);
        try{process.Start();}catch(System.ComponentModel.Win32Exception){throw new CommunityException(503,"视频水印处理服务未配置,请联系管理员");}
        // Drain both pipes without logging media paths or allowing child processes to outlive cancelled requests.
        var stdout=process.StandardOutput.ReadToEndAsync(ct);var stderr=process.StandardError.ReadToEndAsync(ct);
        try{await process.WaitForExitAsync(ct);await stderr;if(process.ExitCode!=0)throw new ArgumentException("视频无法处理,请选择完整的 MP4 或 WebM 视频");return await stdout;}
        catch{if(!process.HasExited)process.Kill(true);throw;}
    }
    internal async Task<(string Mime,double Duration)> Video(string input,string output,MediaOrigin origin,bool recommendation,CancellationToken ct)
    {
        await videoSlots.WaitAsync(ct);string marker=output+".png";
        try
        {
            using var probeTimeout=CancellationTokenSource.CreateLinkedTokenSource(ct);probeTimeout.CancelAfter(TimeSpan.FromSeconds(30));
            using var probe=JsonDocument.Parse(await Run(Tool("ffprobe"),["-v","error","-protocol_whitelist","file,pipe","-select_streams","v:0","-show_entries","stream=width,height:stream_side_data=rotation:format=duration","-of","json",input],probeTimeout.Token));
            var streams=probe.RootElement.GetProperty("streams");if(streams.GetArrayLength()==0)throw new ArgumentException("视频没有有效画面");var stream=streams[0];int w=stream.GetProperty("width").GetInt32(),h=stream.GetProperty("height").GetInt32();
            if(w is <1 or >8192||h is <1 or >8192||(long)w*h>25_000_000)throw new ArgumentException("视频分辨率过大或无效");
            if(stream.TryGetProperty("side_data_list",out var sides)&&sides.EnumerateArray().Any(s=>s.TryGetProperty("rotation",out var rotation)&&Math.Abs(rotation.GetDouble())%180==90))(w,h)=(h,w);
            double duration=probe.RootElement.TryGetProperty("format",out var format)&&format.TryGetProperty("duration",out var d)&&double.TryParse(d.GetString(),CultureInfo.InvariantCulture,out var seconds)?seconds:0;
            if(!recommendation&&duration>30.1)throw new ArgumentException("视频最长 30 秒,请选择较短视频");
            double scale=Math.Min(1,1280d/Math.Max(w,h));int width=Math.Max(2,(int)(w*scale)/2*2),height=Math.Max(2,(int)(h*scale)/2*2);
            using(var stamp=Stamp(width,height,origin))File.WriteAllBytes(marker,stamp.ToArray());
            var args=new List<string>{"-hide_banner","-loglevel","error","-nostdin","-y","-threads","2","-protocol_whitelist","file,pipe","-i",input,"-i",marker,"-filter_complex_threads","1","-filter_complex",$"[0:v:0]scale={width}:{height},setsar=1[v];[v][1:v:0]overlay=W-w-{Math.Clamp((int)(width*.015),2,24)}:{Math.Clamp((int)(width*.015),2,24)}:format=auto[out]","-map","[out]","-map","0:a:0?","-c:v","libvpx-vp9","-pix_fmt","yuv420p","-b:v","1000k","-crf","30","-deadline","realtime","-cpu-used","5","-row-mt","1","-threads","2","-c:a","libopus","-b:a","64k","-map_metadata","-1","-f","webm"};
            if(!recommendation)args.AddRange(["-t","31"]);args.Add(output);
            using var timeout=CancellationTokenSource.CreateLinkedTokenSource(ct);timeout.CancelAfter(TimeSpan.FromSeconds(recommendation?Math.Max(180,duration*4+120):120));await Run(Tool("ffmpeg"),args,timeout.Token);
            return recommendation?RecommendationMediaInspector.Inspect(output,"VIDEO"):MediaInspector.Inspect(await File.ReadAllBytesAsync(output,ct),"VIDEO");
        }
        catch(OperationCanceledException)when(!ct.IsCancellationRequested){throw new CommunityException(503,"视频水印处理超时,请稍后重试");}
        finally{File.Delete(marker);videoSlots.Release();}
    }
    public void Dispose(){if(typeface.IsValueCreated)typeface.Value.Dispose();videoSlots.Dispose();}
}