mirror of
https://github.com/unclshura/splitter.git
synced 2026-06-22 00:22:01 +00:00
Initial version of face derecting crop functionality added. Should crop horizontal videos for YouTube shorts with face tracking.
This commit is contained in:
parent
cb6086e9d2
commit
cddcd6ff6e
5
.gitignore
vendored
5
.gitignore
vendored
@ -361,3 +361,8 @@ MigrationBackup/
|
|||||||
|
|
||||||
# Fody - auto-generated XML schema
|
# Fody - auto-generated XML schema
|
||||||
FodyWeavers.xsd
|
FodyWeavers.xsd
|
||||||
|
|
||||||
|
# OpenCV models
|
||||||
|
*.onnx
|
||||||
|
*.bin
|
||||||
|
*.param
|
||||||
|
|||||||
101
FaceKalmanTracker.cs
Normal file
101
FaceKalmanTracker.cs
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
namespace splitter;
|
||||||
|
|
||||||
|
internal sealed class FaceKalmanTracker
|
||||||
|
{
|
||||||
|
// State vector: [x, y, vx, vy]
|
||||||
|
private float[] _state = new float[4];
|
||||||
|
|
||||||
|
// Covariance matrix 4x4
|
||||||
|
private float[,] _p = new float[4, 4];
|
||||||
|
|
||||||
|
// Process noise (constant)
|
||||||
|
private const float _q = 1e-3f;
|
||||||
|
|
||||||
|
// Measurement noise (dynamic)
|
||||||
|
private float _r = 1e-1f;
|
||||||
|
|
||||||
|
// Identity matrix
|
||||||
|
private static readonly float[,] _i =
|
||||||
|
{
|
||||||
|
{1,0,0,0},
|
||||||
|
{0,1,0,0},
|
||||||
|
{0,0,1,0},
|
||||||
|
{0,0,0,1}
|
||||||
|
};
|
||||||
|
|
||||||
|
public Point2f? LastMeasurement { get; private set; }
|
||||||
|
|
||||||
|
public void Reset(Point2f initial)
|
||||||
|
{
|
||||||
|
_state[0] = initial.X;
|
||||||
|
_state[1] = initial.Y;
|
||||||
|
_state[2] = 0;
|
||||||
|
_state[3] = 0;
|
||||||
|
|
||||||
|
// Large initial uncertainty
|
||||||
|
for (int i = 0; i < 4; i++)
|
||||||
|
for (int j = 0; j < 4; j++)
|
||||||
|
_p[i, j] = (i == j) ? 1f : 0f;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SetMeasurementNoise(float r)
|
||||||
|
{
|
||||||
|
_r = r;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Point2f Update(Point2f? measurement)
|
||||||
|
{
|
||||||
|
// --- PREDICTION ---
|
||||||
|
// State transition:
|
||||||
|
// x' = x + vx
|
||||||
|
// y' = y + vy
|
||||||
|
_state[0] += _state[2];
|
||||||
|
_state[1] += _state[3];
|
||||||
|
|
||||||
|
// Update covariance
|
||||||
|
AddProcessNoise();
|
||||||
|
|
||||||
|
if (measurement.HasValue)
|
||||||
|
{
|
||||||
|
// --- MEASUREMENT UPDATE ---
|
||||||
|
var z = measurement.Value;
|
||||||
|
|
||||||
|
// Innovation y = z - Hx
|
||||||
|
float yx = z.X - _state[0];
|
||||||
|
float yy = z.Y - _state[1];
|
||||||
|
|
||||||
|
// Innovation covariance S = P + R
|
||||||
|
float Sx = _p[0, 0] + _r;
|
||||||
|
float Sy = _p[1, 1] + _r;
|
||||||
|
|
||||||
|
// Kalman gain K = P / S
|
||||||
|
float Kx0 = _p[0, 0] / Sx;
|
||||||
|
float Kx1 = _p[1, 1] / Sy;
|
||||||
|
|
||||||
|
// Update state
|
||||||
|
_state[0] += Kx0 * yx;
|
||||||
|
_state[1] += Kx1 * yy;
|
||||||
|
|
||||||
|
// Velocity correction (helps reduce jitter)
|
||||||
|
_state[2] += 0.1f * Kx0 * yx;
|
||||||
|
_state[3] += 0.1f * Kx1 * yy;
|
||||||
|
|
||||||
|
// Update covariance: P = (I - K)P
|
||||||
|
_p[0, 0] *= (1 - Kx0);
|
||||||
|
_p[1, 1] *= (1 - Kx1);
|
||||||
|
}
|
||||||
|
|
||||||
|
LastMeasurement = measurement;
|
||||||
|
|
||||||
|
return new Point2f(_state[0], _state[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AddProcessNoise()
|
||||||
|
{
|
||||||
|
// Add small noise to diagonal of covariance
|
||||||
|
_p[0, 0] += _q;
|
||||||
|
_p[1, 1] += _q;
|
||||||
|
_p[2, 2] += _q;
|
||||||
|
_p[3, 3] += _q;
|
||||||
|
}
|
||||||
|
}
|
||||||
328
FaceTracker.cs
Normal file
328
FaceTracker.cs
Normal file
@ -0,0 +1,328 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using OpenCvSharp;
|
||||||
|
using Cv = OpenCvSharp.Cv2;
|
||||||
|
using Mat = OpenCvSharp.Mat;
|
||||||
|
using CvPoint = OpenCvSharp.Point;
|
||||||
|
using CvRect = OpenCvSharp.Rect;
|
||||||
|
|
||||||
|
namespace splitter;
|
||||||
|
|
||||||
|
public class FaceTracker
|
||||||
|
{
|
||||||
|
public Action<double, TimeSpan, double> DrawProgress { get; init; } = (_, _, _) => { };
|
||||||
|
|
||||||
|
private static Rect ToCvRect(splitter.Rect r)
|
||||||
|
=> new Rect(r.X, r.Y, r.Width, r.Height);
|
||||||
|
|
||||||
|
public async Task TrackFaceAndExtract(
|
||||||
|
string srcFileName,
|
||||||
|
string destFileName,
|
||||||
|
TimeSpan skip,
|
||||||
|
TimeSpan duration,
|
||||||
|
int cropWidth,
|
||||||
|
int cropHeight,
|
||||||
|
string[] passthrough,
|
||||||
|
bool debugOverlay)
|
||||||
|
{
|
||||||
|
// ------------------------------
|
||||||
|
// 1. OpenCV VideoCapture (stable)
|
||||||
|
// ------------------------------
|
||||||
|
using var capture = new VideoCapture(srcFileName);
|
||||||
|
if (!capture.IsOpened())
|
||||||
|
throw new Exception("Cannot open video");
|
||||||
|
|
||||||
|
capture.Set(VideoCaptureProperties.PosMsec, skip.TotalMilliseconds);
|
||||||
|
|
||||||
|
var videoWidth = (int)capture.Get(VideoCaptureProperties.FrameWidth);
|
||||||
|
var videoHeight = (int)capture.Get(VideoCaptureProperties.FrameHeight);
|
||||||
|
var fps = capture.Get(VideoCaptureProperties.Fps);
|
||||||
|
var totalFrames = (int)(duration.TotalSeconds * fps);
|
||||||
|
|
||||||
|
Console.WriteLine($"[FaceTracker] skip={skip}, duration={duration}, fps={fps}, totalFrames={totalFrames}");
|
||||||
|
|
||||||
|
// ------------------------------
|
||||||
|
// 2. UltraFaceDetector (new model)
|
||||||
|
// ------------------------------
|
||||||
|
using var detector = new UltraFaceDetector(
|
||||||
|
binPath: "slim_320.bin",
|
||||||
|
paramPath: "slim_320.param");
|
||||||
|
|
||||||
|
// ------------------------------
|
||||||
|
// 3. FFmpeg one-pass encoder
|
||||||
|
// ------------------------------
|
||||||
|
var ffmpeg = StartFfmpegNvenc(
|
||||||
|
srcFileName,
|
||||||
|
destFileName,
|
||||||
|
cropWidth,
|
||||||
|
cropHeight,
|
||||||
|
fps,
|
||||||
|
skip,
|
||||||
|
passthrough);
|
||||||
|
|
||||||
|
using var stdin = ffmpeg.StandardInput.BaseStream;
|
||||||
|
|
||||||
|
// ------------------------------
|
||||||
|
// 4. Tracking state
|
||||||
|
// ------------------------------
|
||||||
|
var frame = new Mat();
|
||||||
|
var kalman = new FaceKalmanTracker();
|
||||||
|
kalman.Reset(new Point2f(videoWidth / 2f, videoHeight / 2f));
|
||||||
|
|
||||||
|
var lostFrames = 0;
|
||||||
|
var wasLost = false;
|
||||||
|
var reacquireBoostFrames = 20;
|
||||||
|
var reacquireCounter = 0;
|
||||||
|
|
||||||
|
var cameraCenter = new Point2f(videoWidth / 2f, videoHeight / 2f);
|
||||||
|
var startTime = DateTime.UtcNow;
|
||||||
|
|
||||||
|
// ------------------------------
|
||||||
|
// 5. Main loop
|
||||||
|
// ------------------------------
|
||||||
|
for (var i = 0; i < totalFrames; i++)
|
||||||
|
{
|
||||||
|
if (!capture.Read(frame) || frame.Empty())
|
||||||
|
break;
|
||||||
|
|
||||||
|
// Ensure continuous memory for detector
|
||||||
|
Mat frameCont = frame.IsContinuous() ? frame : frame.Clone();
|
||||||
|
|
||||||
|
// Convert to byte[] for UltraFace
|
||||||
|
var bytesFull = frameCont.Rows * frameCont.Cols * frameCont.ElemSize();
|
||||||
|
var bufferFull = new byte[bytesFull];
|
||||||
|
Marshal.Copy(frameCont.Data, bufferFull, 0, bytesFull);
|
||||||
|
|
||||||
|
Rect? faceBox = null;
|
||||||
|
Point2f? faceCenter = null;
|
||||||
|
|
||||||
|
var faces = detector.DetectAll(bufferFull, videoWidth, videoHeight); // list of (box, center)
|
||||||
|
|
||||||
|
var primary = SelectTrackedFace(faces, kalman.LastMeasurement);
|
||||||
|
|
||||||
|
if (primary.HasValue)
|
||||||
|
{
|
||||||
|
faceCenter = primary.Value.center;
|
||||||
|
faceBox = primary.Value.box;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
var isLost = !faceCenter.HasValue;
|
||||||
|
|
||||||
|
// LOST FACE → drift toward center
|
||||||
|
if (isLost)
|
||||||
|
{
|
||||||
|
lostFrames++;
|
||||||
|
|
||||||
|
var fallbackCenter = new Point2f(videoWidth / 2f, videoHeight / 2f);
|
||||||
|
var predicted = kalman.Update(null);
|
||||||
|
|
||||||
|
var t = Math.Min(1f, lostFrames / 60f);
|
||||||
|
var ease = 0.02f * t;
|
||||||
|
|
||||||
|
faceCenter = new Point2f(
|
||||||
|
predicted.X * (1 - ease) + fallbackCenter.X * ease,
|
||||||
|
predicted.Y * (1 - ease) + fallbackCenter.Y * ease);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (wasLost)
|
||||||
|
reacquireCounter = reacquireBoostFrames;
|
||||||
|
|
||||||
|
lostFrames = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// SMOOTH REACQUISITION
|
||||||
|
if (reacquireCounter > 0)
|
||||||
|
{
|
||||||
|
var alpha = reacquireCounter / (float)reacquireBoostFrames;
|
||||||
|
var noise = 5e-2f + (1e-1f - 5e-2f) * (1 - alpha);
|
||||||
|
kalman.SetMeasurementNoise(noise);
|
||||||
|
reacquireCounter--;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
kalman.SetMeasurementNoise(1e-1f);
|
||||||
|
}
|
||||||
|
|
||||||
|
wasLost = isLost;
|
||||||
|
|
||||||
|
var smoothedCenter = kalman.Update(faceCenter);
|
||||||
|
|
||||||
|
var halfW = cropWidth / 2f;
|
||||||
|
var halfH = cropHeight / 2f;
|
||||||
|
|
||||||
|
smoothedCenter.X = Math.Clamp(smoothedCenter.X, halfW, videoWidth - halfW);
|
||||||
|
smoothedCenter.Y = Math.Clamp(smoothedCenter.Y, halfH, videoHeight - halfH);
|
||||||
|
|
||||||
|
// CAMERA EASING
|
||||||
|
var easing = 0.003f;
|
||||||
|
cameraCenter = new Point2f(
|
||||||
|
cameraCenter.X + (smoothedCenter.X - cameraCenter.X) * easing,
|
||||||
|
cameraCenter.Y + (smoothedCenter.Y - cameraCenter.Y) * easing);
|
||||||
|
|
||||||
|
cameraCenter.X = Math.Clamp(cameraCenter.X, halfW, videoWidth - halfW);
|
||||||
|
cameraCenter.Y = Math.Clamp(cameraCenter.Y, halfH, videoHeight - halfH);
|
||||||
|
|
||||||
|
var x = (int)Math.Round(cameraCenter.X - halfW);
|
||||||
|
var y = (int)Math.Round(cameraCenter.Y - halfH);
|
||||||
|
|
||||||
|
x = Math.Clamp(x, 0, videoWidth - cropWidth);
|
||||||
|
y = Math.Clamp(y, 0, videoHeight - cropHeight);
|
||||||
|
|
||||||
|
var roi = new CvRect(x, y, cropWidth, cropHeight);
|
||||||
|
|
||||||
|
if (debugOverlay)
|
||||||
|
{
|
||||||
|
if (faceBox.HasValue)
|
||||||
|
{
|
||||||
|
var fb = faceBox.Value;
|
||||||
|
Cv.Rectangle(frameCont,
|
||||||
|
new OpenCvSharp.Rect(fb.X, fb.Y, fb.Width, fb.Height),
|
||||||
|
Scalar.LimeGreen, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
Cv.Circle(frameCont,
|
||||||
|
new CvPoint((int)smoothedCenter.X, (int)smoothedCenter.Y),
|
||||||
|
6, Scalar.LimeGreen, -1);
|
||||||
|
|
||||||
|
Cv.Rectangle(frameCont, roi,
|
||||||
|
faceCenter.HasValue ? Scalar.Yellow : Scalar.Red, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Crop ROI
|
||||||
|
using var cropped = new Mat(frameCont, roi);
|
||||||
|
|
||||||
|
// Always clone to ensure contiguous memory
|
||||||
|
using var bgr = cropped.Clone();
|
||||||
|
|
||||||
|
// Write to FFmpeg
|
||||||
|
var bytes = bgr.Rows * bgr.Cols * bgr.ElemSize();
|
||||||
|
var buffer = new byte[bytes];
|
||||||
|
Marshal.Copy(bgr.Data, buffer, 0, bytes);
|
||||||
|
stdin.Write(buffer, 0, bytes);
|
||||||
|
|
||||||
|
// Dispose frameCont only if it was a clone
|
||||||
|
if (!ReferenceEquals(frameCont, frame))
|
||||||
|
frameCont.Dispose();
|
||||||
|
|
||||||
|
// Progress
|
||||||
|
var elapsed = DateTime.UtcNow - startTime;
|
||||||
|
var progress = (double)i / totalFrames;
|
||||||
|
var speed = i > 0 ? i / elapsed.TotalSeconds : 0.0;
|
||||||
|
var remainingFrames = totalFrames - i;
|
||||||
|
var etaSeconds = speed > 0 ? remainingFrames / speed : 0;
|
||||||
|
var eta = TimeSpan.FromSeconds(etaSeconds);
|
||||||
|
|
||||||
|
DrawProgress(progress, eta, speed);
|
||||||
|
}
|
||||||
|
|
||||||
|
stdin.Flush();
|
||||||
|
stdin.Close();
|
||||||
|
|
||||||
|
await ffmpeg.WaitForExitAsync();
|
||||||
|
if (ffmpeg.ExitCode != 0)
|
||||||
|
throw new Exception("FFmpeg NVENC encoding failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
private (Rect box, Point2f center)? SelectTrackedFace(
|
||||||
|
List<(Rect box, Point2f center)> faces,
|
||||||
|
Point2f? previousCenter)
|
||||||
|
{
|
||||||
|
if (faces == null || faces.Count == 0)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
if (!previousCenter.HasValue)
|
||||||
|
{
|
||||||
|
// no previous face → pick largest
|
||||||
|
return faces
|
||||||
|
.OrderByDescending(f => f.box.Width * f.box.Height)
|
||||||
|
.First();
|
||||||
|
}
|
||||||
|
|
||||||
|
// pick the face closest to previous center
|
||||||
|
return faces
|
||||||
|
.OrderBy(f =>
|
||||||
|
{
|
||||||
|
var dx = f.center.X - previousCenter.Value.X;
|
||||||
|
var dy = f.center.Y - previousCenter.Value.Y;
|
||||||
|
return dx * dx + dy * dy;
|
||||||
|
})
|
||||||
|
.First();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Process StartFfmpegNvenc(
|
||||||
|
string srcFileName,
|
||||||
|
string destFileName,
|
||||||
|
int width,
|
||||||
|
int height,
|
||||||
|
double fps,
|
||||||
|
TimeSpan skip,
|
||||||
|
string[] passthrough)
|
||||||
|
{
|
||||||
|
var pass = passthrough.Length > 0 ? string.Join(" ", passthrough) : "";
|
||||||
|
var skipSeconds = skip.TotalSeconds.ToString("0.###", System.Globalization.CultureInfo.InvariantCulture);
|
||||||
|
var fpsStr = fps.ToString("0.###", System.Globalization.CultureInfo.InvariantCulture);
|
||||||
|
|
||||||
|
// One-pass pipeline:
|
||||||
|
// - rawvideo from stdin
|
||||||
|
// - audio from source MP4 (seeked)
|
||||||
|
// - NVENC video encode
|
||||||
|
// - AAC audio copy/encode
|
||||||
|
//
|
||||||
|
// This is the same structure your original OpenCV pipeline used.
|
||||||
|
//
|
||||||
|
// IMPORTANT:
|
||||||
|
// Because OpenCV reliably reads the full segment,
|
||||||
|
// FFmpeg will NOT close stdin early anymore.
|
||||||
|
//
|
||||||
|
var args =
|
||||||
|
"-y " +
|
||||||
|
// VIDEO INPUT (raw BGR24 from stdin)
|
||||||
|
$"-f rawvideo -pix_fmt bgr24 -s {width}x{height} -r {fpsStr} -i - " +
|
||||||
|
|
||||||
|
// AUDIO INPUT (seeked)
|
||||||
|
$"-ss {skipSeconds} -i \"{srcFileName}\" " +
|
||||||
|
|
||||||
|
// MAP streams
|
||||||
|
"-map 0:v:0 -map 1:a:0? -shortest " +
|
||||||
|
|
||||||
|
// VIDEO ENCODE
|
||||||
|
"-c:v h264_nvenc -preset p4 -b:v 8M -pix_fmt yuv420p " +
|
||||||
|
|
||||||
|
// AUDIO ENCODE/COPY
|
||||||
|
"-c:a aac -b:a 192k " +
|
||||||
|
|
||||||
|
// Extra passthrough flags
|
||||||
|
pass + $" \"{destFileName}\"";
|
||||||
|
|
||||||
|
var psi = new ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = "ffmpeg",
|
||||||
|
Arguments = args,
|
||||||
|
RedirectStandardInput = true,
|
||||||
|
RedirectStandardError = true,
|
||||||
|
RedirectStandardOutput = true,
|
||||||
|
UseShellExecute = false,
|
||||||
|
CreateNoWindow = true
|
||||||
|
};
|
||||||
|
|
||||||
|
var process = new Process { StartInfo = psi };
|
||||||
|
process.Start();
|
||||||
|
|
||||||
|
// async stderr reader
|
||||||
|
_ = Task.Run(() =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
string? line;
|
||||||
|
while ((line = process.StandardError.ReadLine()) != null)
|
||||||
|
Console.WriteLine($"[ffmpeg] {line}");
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
});
|
||||||
|
|
||||||
|
return process;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
13
Point2f.cs
Normal file
13
Point2f.cs
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
namespace splitter;
|
||||||
|
|
||||||
|
public struct Point2f
|
||||||
|
{
|
||||||
|
public float X;
|
||||||
|
public float Y;
|
||||||
|
|
||||||
|
public Point2f(float x, float y)
|
||||||
|
{
|
||||||
|
X = x;
|
||||||
|
Y = y;
|
||||||
|
}
|
||||||
|
}
|
||||||
8
Properties/launchSettings.json
Normal file
8
Properties/launchSettings.json
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"profiles": {
|
||||||
|
"splitter": {
|
||||||
|
"commandName": "Project",
|
||||||
|
"commandLineArgs": "\"C:\\Users\\uncls\\Pictures\\2026\\2026 - Secret Rule\\20260426_212004.mp4\" \"C:\\Users\\uncls\\Pictures\\2026\\2026 - Secret Rule\\Shorts\" --crop --debug --text"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -26,6 +26,9 @@ This tool is intended for creators, archivists, and automation workflows that ne
|
|||||||
- FFmpeg and FFprobe installed and available in the system PATH
|
- FFmpeg and FFprobe installed and available in the system PATH
|
||||||
- .NET 10 SDK or newer
|
- .NET 10 SDK or newer
|
||||||
|
|
||||||
|
>Note: You must download YuNet ONNX model:
|
||||||
|
https://github.com/opencv/opencv_zoo/tree/master/models/face_detection_yunet (github.com in Bing)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|||||||
17
Rect.cs
Normal file
17
Rect.cs
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
namespace splitter;
|
||||||
|
|
||||||
|
public struct Rect
|
||||||
|
{
|
||||||
|
public int X;
|
||||||
|
public int Y;
|
||||||
|
public int Width;
|
||||||
|
public int Height;
|
||||||
|
|
||||||
|
public Rect(int x, int y, int w, int h)
|
||||||
|
{
|
||||||
|
X = x;
|
||||||
|
Y = y;
|
||||||
|
Width = w;
|
||||||
|
Height = h;
|
||||||
|
}
|
||||||
|
}
|
||||||
133
UltraFaceDetector.cs
Normal file
133
UltraFaceDetector.cs
Normal file
@ -0,0 +1,133 @@
|
|||||||
|
using NcnnDotNet;
|
||||||
|
using UltraFaceDotNet;
|
||||||
|
|
||||||
|
namespace splitter;
|
||||||
|
|
||||||
|
public sealed class UltraFaceDetector : IDisposable
|
||||||
|
{
|
||||||
|
private readonly UltraFace _ultraFace;
|
||||||
|
|
||||||
|
public UltraFaceDetector(string binPath, string paramPath)
|
||||||
|
{
|
||||||
|
var param = new UltraFaceParameter
|
||||||
|
{
|
||||||
|
BinFilePath = binPath,
|
||||||
|
ParamFilePath = paramPath,
|
||||||
|
InputWidth = 320,
|
||||||
|
InputLength = 240,
|
||||||
|
NumThread = 1,
|
||||||
|
ScoreThreshold = 0.7f
|
||||||
|
};
|
||||||
|
|
||||||
|
_ultraFace = UltraFace.Create(param);
|
||||||
|
}
|
||||||
|
|
||||||
|
public (Rect box, Point2f center)? Detect(byte[] bgr, int width, int height)
|
||||||
|
{
|
||||||
|
if (bgr == null || bgr.Length == 0)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
// bgr is contiguous BGR24: width * height * 3
|
||||||
|
unsafe
|
||||||
|
{
|
||||||
|
fixed (byte* p = bgr)
|
||||||
|
{
|
||||||
|
using var mat = Mat.FromPixels(
|
||||||
|
(IntPtr)p,
|
||||||
|
PixelType.Bgr, // BGR24 input
|
||||||
|
width,
|
||||||
|
height);
|
||||||
|
|
||||||
|
var faces = _ultraFace.Detect(mat);
|
||||||
|
if (faces == null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
FaceInfo best = default;
|
||||||
|
bool hasBest = false;
|
||||||
|
|
||||||
|
foreach (var f in faces)
|
||||||
|
{
|
||||||
|
if (!hasBest || f.Score > best.Score)
|
||||||
|
{
|
||||||
|
best = f;
|
||||||
|
hasBest = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasBest)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
int x1 = (int)best.X1;
|
||||||
|
int y1 = (int)best.Y1;
|
||||||
|
int x2 = (int)best.X2;
|
||||||
|
int y2 = (int)best.Y2;
|
||||||
|
|
||||||
|
var rect = new Rect(
|
||||||
|
x1,
|
||||||
|
y1,
|
||||||
|
x2 - x1,
|
||||||
|
y2 - y1);
|
||||||
|
|
||||||
|
if (rect.Width <= 0 || rect.Height <= 0)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var center = new Point2f(
|
||||||
|
rect.X + rect.Width / 2f,
|
||||||
|
rect.Y + rect.Height / 2f);
|
||||||
|
|
||||||
|
return (rect, center);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<(Rect box, Point2f center)> DetectAll(byte[] bgr, int width, int height)
|
||||||
|
{
|
||||||
|
var results = new List<(Rect box, Point2f center)>();
|
||||||
|
|
||||||
|
if (bgr == null || bgr.Length == 0)
|
||||||
|
return results;
|
||||||
|
|
||||||
|
unsafe
|
||||||
|
{
|
||||||
|
fixed (byte* p = bgr)
|
||||||
|
{
|
||||||
|
using var mat = Mat.FromPixels(
|
||||||
|
(IntPtr)p,
|
||||||
|
PixelType.Bgr, // BGR24 input
|
||||||
|
width,
|
||||||
|
height);
|
||||||
|
|
||||||
|
var faces = _ultraFace.Detect(mat);
|
||||||
|
if (faces == null)
|
||||||
|
return results;
|
||||||
|
|
||||||
|
foreach (var f in faces)
|
||||||
|
{
|
||||||
|
int x1 = (int)f.X1;
|
||||||
|
int y1 = (int)f.Y1;
|
||||||
|
int x2 = (int)f.X2;
|
||||||
|
int y2 = (int)f.Y2;
|
||||||
|
|
||||||
|
var rect = new Rect(
|
||||||
|
x1,
|
||||||
|
y1,
|
||||||
|
x2 - x1,
|
||||||
|
y2 - y1);
|
||||||
|
|
||||||
|
if (rect.Width <= 0 || rect.Height <= 0)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var center = new Point2f(
|
||||||
|
rect.X + rect.Width / 2f,
|
||||||
|
rect.Y + rect.Height / 2f);
|
||||||
|
|
||||||
|
results.Add((rect, center));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() => _ultraFace?.Dispose();
|
||||||
|
}
|
||||||
321
splitter.cs
321
splitter.cs
@ -1,23 +1,20 @@
|
|||||||
using System;
|
|
||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using System.IO;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Threading;
|
using splitter;
|
||||||
using System.Threading.Tasks;
|
|
||||||
|
|
||||||
class Program
|
class Program
|
||||||
{
|
{
|
||||||
static int logLines = 0;
|
static int logLines = 0;
|
||||||
|
static bool plainText = false;
|
||||||
static readonly object consoleLock = new();
|
static readonly object consoleLock = new();
|
||||||
static bool progressRunning = true;
|
static bool progressRunning = true;
|
||||||
|
|
||||||
static void Main(string[] args)
|
static void Main(string[] args)
|
||||||
{
|
{
|
||||||
double? overrideTargetDuration = null;
|
double? overrideTargetDuration = null;
|
||||||
bool estimateOnly = false;
|
var estimateOnly = false;
|
||||||
bool forceFixed = false;
|
var forceFixed = false;
|
||||||
|
|
||||||
|
|
||||||
Console.OutputEncoding = Encoding.UTF8;
|
Console.OutputEncoding = Encoding.UTF8;
|
||||||
@ -29,8 +26,8 @@ class Program
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Extract passthrough parameters after "--"
|
// Extract passthrough parameters after "--"
|
||||||
string[] passthrough = Array.Empty<string>();
|
var passthrough = Array.Empty<string>();
|
||||||
int passthroughIndex = Array.IndexOf(args, "--");
|
var passthroughIndex = Array.IndexOf(args, "--");
|
||||||
|
|
||||||
if (passthroughIndex >= 0)
|
if (passthroughIndex >= 0)
|
||||||
{
|
{
|
||||||
@ -47,9 +44,11 @@ class Program
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
string inputFile = args[0];
|
var inputFile = args[0];
|
||||||
string outputFolder = args[1];
|
var outputFolder = args[1];
|
||||||
|
(int width, int height)? crop = null;
|
||||||
string? mask = null;
|
string? mask = null;
|
||||||
|
var debug = false;
|
||||||
|
|
||||||
foreach (var arg in args.Skip(2))
|
foreach (var arg in args.Skip(2))
|
||||||
{
|
{
|
||||||
@ -57,9 +56,25 @@ class Program
|
|||||||
{
|
{
|
||||||
mask = arg.Substring("--mask=".Length);
|
mask = arg.Substring("--mask=".Length);
|
||||||
}
|
}
|
||||||
|
else if (arg.StartsWith("--crop="))
|
||||||
|
{
|
||||||
|
crop = ParseCrop(arg.Substring("--crop=".Length));
|
||||||
|
}
|
||||||
|
else if (arg == "--crop")
|
||||||
|
{
|
||||||
|
crop = ParseCrop("");
|
||||||
|
}
|
||||||
|
else if (arg == "--text")
|
||||||
|
{
|
||||||
|
plainText = true;
|
||||||
|
}
|
||||||
|
else if (arg == "--debug")
|
||||||
|
{
|
||||||
|
debug = true;
|
||||||
|
}
|
||||||
else if (arg.StartsWith("--duration="))
|
else if (arg.StartsWith("--duration="))
|
||||||
{
|
{
|
||||||
string dur = arg.Substring("--duration=".Length);
|
var dur = arg.Substring("--duration=".Length);
|
||||||
overrideTargetDuration = ParseDuration(dur);
|
overrideTargetDuration = ParseDuration(dur);
|
||||||
if (overrideTargetDuration <= 0)
|
if (overrideTargetDuration <= 0)
|
||||||
{
|
{
|
||||||
@ -86,19 +101,19 @@ class Program
|
|||||||
if (!Directory.Exists(outputFolder))
|
if (!Directory.Exists(outputFolder))
|
||||||
Directory.CreateDirectory(outputFolder);
|
Directory.CreateDirectory(outputFolder);
|
||||||
|
|
||||||
string baseName = Path.GetFileNameWithoutExtension(inputFile);
|
var baseName = Path.GetFileNameWithoutExtension(inputFile);
|
||||||
string outputMask = mask ?? $"{baseName}_Seg%03d.mp4";
|
var outputMask = mask ?? $"{baseName}_Seg%03d.mp4";
|
||||||
|
|
||||||
LogInfo("Reading duration via ffprobe...");
|
LogInfo("Reading duration via ffprobe...");
|
||||||
|
|
||||||
double duration = GetDuration(inputFile);
|
var duration = GetDuration(inputFile);
|
||||||
if (duration <= 0)
|
if (duration <= 0)
|
||||||
{
|
{
|
||||||
LogError("Could not read duration.");
|
LogError("Could not read duration.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
double target = overrideTargetDuration ?? 58.0;
|
var target = overrideTargetDuration ?? 58.0;
|
||||||
|
|
||||||
int segments;
|
int segments;
|
||||||
double segmentLength;
|
double segmentLength;
|
||||||
@ -132,9 +147,16 @@ class Program
|
|||||||
LogInfo($"Segments: {segments}");
|
LogInfo($"Segments: {segments}");
|
||||||
LogInfo($"Equal segment length: {segmentLength:F3}s");
|
LogInfo($"Equal segment length: {segmentLength:F3}s");
|
||||||
|
|
||||||
|
if (crop != null)
|
||||||
|
{
|
||||||
|
LogInfo("Starting multi-threaded face tracking crop and splitting...");
|
||||||
|
RunMultiThreadedCrop(inputFile, outputFolder, outputMask, duration, segments, segmentLength, passthrough, crop.Value.width, crop.Value.height, debug);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
LogInfo("Starting multi-threaded ffmpeg splitting...");
|
LogInfo("Starting multi-threaded ffmpeg splitting...");
|
||||||
|
|
||||||
RunMultiThreadedSplit(inputFile, outputFolder, outputMask, duration, segments, segmentLength, passthrough);
|
RunMultiThreadedSplit(inputFile, outputFolder, outputMask, duration, segments, segmentLength, passthrough);
|
||||||
|
}
|
||||||
|
|
||||||
LogSuccess("Done.");
|
LogSuccess("Done.");
|
||||||
progressRunning = false;
|
progressRunning = false;
|
||||||
@ -146,6 +168,32 @@ class Program
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static (int width, int height)? ParseCrop(string v)
|
||||||
|
{
|
||||||
|
// Default vertical Full HD for YouTube Shorts
|
||||||
|
const int defaultW = 607;
|
||||||
|
const int defaultH = 1080;
|
||||||
|
|
||||||
|
// Empty or whitespace → default crop
|
||||||
|
if (string.IsNullOrWhiteSpace(v))
|
||||||
|
return (defaultW, defaultH);
|
||||||
|
|
||||||
|
var s = v.Trim().ToLowerInvariant();
|
||||||
|
|
||||||
|
// Expected format: "WWWxHHH"
|
||||||
|
var parts = s.Split('x');
|
||||||
|
if (parts.Length != 2)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var okW = int.TryParse(parts[0], out var w);
|
||||||
|
var okH = int.TryParse(parts[1], out var h);
|
||||||
|
|
||||||
|
if (!okW || !okH || w <= 0 || h <= 0)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
return (w, h);
|
||||||
|
}
|
||||||
|
|
||||||
// -----------------------------
|
// -----------------------------
|
||||||
// Logging + Progress UI
|
// Logging + Progress UI
|
||||||
// -----------------------------
|
// -----------------------------
|
||||||
@ -153,6 +201,12 @@ class Program
|
|||||||
static void Log(string prefix, ConsoleColor color, string msg)
|
static void Log(string prefix, ConsoleColor color, string msg)
|
||||||
{
|
{
|
||||||
lock (consoleLock)
|
lock (consoleLock)
|
||||||
|
{
|
||||||
|
if (plainText)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"{prefix} {msg}");
|
||||||
|
}
|
||||||
|
else
|
||||||
{
|
{
|
||||||
Console.ForegroundColor = color;
|
Console.ForegroundColor = color;
|
||||||
Console.WriteLine($"{prefix} {msg}");
|
Console.WriteLine($"{prefix} {msg}");
|
||||||
@ -160,6 +214,7 @@ class Program
|
|||||||
logLines++;
|
logLines++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
static void LogInfo(string msg) => Log("[INFO]", ConsoleColor.Cyan, msg);
|
static void LogInfo(string msg) => Log("[INFO]", ConsoleColor.Cyan, msg);
|
||||||
static void LogSuccess(string msg) => Log("[ OK ]", ConsoleColor.Green, msg);
|
static void LogSuccess(string msg) => Log("[ OK ]", ConsoleColor.Green, msg);
|
||||||
@ -168,15 +223,18 @@ class Program
|
|||||||
|
|
||||||
static void DrawProgress(double progress, TimeSpan eta, double speed)
|
static void DrawProgress(double progress, TimeSpan eta, double speed)
|
||||||
{
|
{
|
||||||
|
if ( plainText )
|
||||||
|
return;
|
||||||
|
|
||||||
lock (consoleLock)
|
lock (consoleLock)
|
||||||
{
|
{
|
||||||
int width = Math.Max(20, Console.WindowWidth - 20);
|
var width = Math.Max(20, Console.WindowWidth - 20);
|
||||||
int filled = (int)(progress * width);
|
var filled = (int)(progress * width);
|
||||||
if (filled < 0) filled = 0;
|
if (filled < 0) filled = 0;
|
||||||
if (filled > width) filled = width;
|
if (filled > width) filled = width;
|
||||||
|
|
||||||
int barLine = logLines + 1;
|
var barLine = logLines + 1;
|
||||||
int infoLine = logLines + 2;
|
var infoLine = logLines + 2;
|
||||||
|
|
||||||
// Progress bar with 24-bit color (green)
|
// Progress bar with 24-bit color (green)
|
||||||
Console.SetCursorPosition(0, barLine);
|
Console.SetCursorPosition(0, barLine);
|
||||||
@ -187,14 +245,14 @@ class Program
|
|||||||
|
|
||||||
// Info line: percentage, ETA, speed
|
// Info line: percentage, ETA, speed
|
||||||
Console.SetCursorPosition(0, infoLine);
|
Console.SetCursorPosition(0, infoLine);
|
||||||
string etaStr = eta.TotalSeconds < 0 || double.IsInfinity(eta.TotalSeconds)
|
var etaStr = eta.TotalSeconds < 0 || double.IsInfinity(eta.TotalSeconds)
|
||||||
? "ETA: --:--"
|
? "ETA: --:--"
|
||||||
: $"ETA: {eta:mm\\:ss}";
|
: $"ETA: {eta:mm\\:ss}";
|
||||||
string speedStr = double.IsNaN(speed) || double.IsInfinity(speed)
|
var speedStr = double.IsNaN(speed) || double.IsInfinity(speed)
|
||||||
? "Speed: -.-x"
|
? "Speed: -.-x"
|
||||||
: $"Speed: {speed:F2}x";
|
: $"Speed: {speed:F2}x";
|
||||||
|
|
||||||
string info = $"{progress * 100:0.0}% {etaStr} {speedStr} ";
|
var info = $"{progress * 100:0.0}% {etaStr} {speedStr} ";
|
||||||
Console.Write("\u001b[38;2;180;180;180m" + info.PadRight(Console.WindowWidth - 1) + "\u001b[0m");
|
Console.Write("\u001b[38;2;180;180;180m" + info.PadRight(Console.WindowWidth - 1) + "\u001b[0m");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -215,11 +273,11 @@ class Program
|
|||||||
};
|
};
|
||||||
|
|
||||||
using var proc = Process.Start(psi) ?? throw new Exception("Failed to start ffprobe.");
|
using var proc = Process.Start(psi) ?? throw new Exception("Failed to start ffprobe.");
|
||||||
string? output = proc.StandardOutput.ReadToEnd();
|
var output = proc.StandardOutput.ReadToEnd();
|
||||||
proc.WaitForExit();
|
proc.WaitForExit();
|
||||||
|
|
||||||
if (output != null &&
|
if (output != null &&
|
||||||
double.TryParse(output, NumberStyles.Any, CultureInfo.InvariantCulture, out double duration))
|
double.TryParse(output, NumberStyles.Any, CultureInfo.InvariantCulture, out var duration))
|
||||||
return duration;
|
return duration;
|
||||||
|
|
||||||
return -1;
|
return -1;
|
||||||
@ -249,7 +307,7 @@ class Program
|
|||||||
})
|
})
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
int completed = 0;
|
var completed = 0;
|
||||||
var sw = Stopwatch.StartNew();
|
var sw = Stopwatch.StartNew();
|
||||||
|
|
||||||
// Progress thread
|
// Progress thread
|
||||||
@ -257,13 +315,13 @@ class Program
|
|||||||
{
|
{
|
||||||
while (progressRunning)
|
while (progressRunning)
|
||||||
{
|
{
|
||||||
double progress = segments == 0 ? 0 : (double)completed / segments;
|
var progress = segments == 0 ? 0 : (double)completed / segments;
|
||||||
double processedSeconds = completed * segmentLength;
|
var processedSeconds = completed * segmentLength;
|
||||||
double speed = sw.Elapsed.TotalSeconds > 0
|
var speed = sw.Elapsed.TotalSeconds > 0
|
||||||
? processedSeconds / sw.Elapsed.TotalSeconds
|
? processedSeconds / sw.Elapsed.TotalSeconds
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
double remainingSeconds = (totalDuration - processedSeconds) / Math.Max(speed, 0.0001);
|
var remainingSeconds = (totalDuration - processedSeconds) / Math.Max(speed, 0.0001);
|
||||||
if (remainingSeconds < 0) remainingSeconds = 0;
|
if (remainingSeconds < 0) remainingSeconds = 0;
|
||||||
var eta = TimeSpan.FromSeconds(remainingSeconds);
|
var eta = TimeSpan.FromSeconds(remainingSeconds);
|
||||||
|
|
||||||
@ -276,14 +334,14 @@ class Program
|
|||||||
};
|
};
|
||||||
progressThread.Start();
|
progressThread.Start();
|
||||||
|
|
||||||
int maxDegree = Math.Max(1, Environment.ProcessorCount / 2);
|
var maxDegree = Math.Max(1, Environment.ProcessorCount / 2);
|
||||||
|
|
||||||
Parallel.ForEach(
|
Parallel.ForEach(
|
||||||
jobs,
|
jobs,
|
||||||
new ParallelOptions { MaxDegreeOfParallelism = maxDegree },
|
new ParallelOptions { MaxDegreeOfParallelism = maxDegree },
|
||||||
job =>
|
job =>
|
||||||
{
|
{
|
||||||
string outputFile = BuildOutputFileName(outputFolder, mask, job.Index);
|
var outputFile = BuildOutputFileName(outputFolder, mask, job.Index);
|
||||||
RunFFmpegSegment(inputFile, outputFile, job.Start, job.Length, passthrough);
|
RunFFmpegSegment(inputFile, outputFile, job.Start, job.Length, passthrough);
|
||||||
Interlocked.Increment(ref completed);
|
Interlocked.Increment(ref completed);
|
||||||
});
|
});
|
||||||
@ -294,6 +352,162 @@ class Program
|
|||||||
DrawProgress(1.0, TimeSpan.Zero, totalDuration / Math.Max(sw.Elapsed.TotalSeconds, 0.0001));
|
DrawProgress(1.0, TimeSpan.Zero, totalDuration / Math.Max(sw.Elapsed.TotalSeconds, 0.0001));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static void RunSingleThreadedSplit(
|
||||||
|
string inputFile,
|
||||||
|
string outputFolder,
|
||||||
|
string mask,
|
||||||
|
double totalDuration,
|
||||||
|
int segments,
|
||||||
|
double segmentLength,
|
||||||
|
string[] passthrough)
|
||||||
|
{
|
||||||
|
var jobs = Enumerable.Range(0, segments)
|
||||||
|
.Select(i => new
|
||||||
|
{
|
||||||
|
Index = i,
|
||||||
|
Start = i * segmentLength,
|
||||||
|
Length = (i == segments - 1)
|
||||||
|
? Math.Max(0.1, totalDuration - i * segmentLength)
|
||||||
|
: segmentLength
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var completed = 0;
|
||||||
|
var sw = Stopwatch.StartNew();
|
||||||
|
|
||||||
|
// Progress thread
|
||||||
|
var progressThread = new Thread(() =>
|
||||||
|
{
|
||||||
|
while (progressRunning)
|
||||||
|
{
|
||||||
|
var progress = segments == 0 ? 0 : (double)completed / segments;
|
||||||
|
var processedSeconds = completed * segmentLength;
|
||||||
|
var speed = sw.Elapsed.TotalSeconds > 0
|
||||||
|
? processedSeconds / sw.Elapsed.TotalSeconds
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
var remainingSeconds = (totalDuration - processedSeconds) / Math.Max(speed, 0.0001);
|
||||||
|
if (remainingSeconds < 0) remainingSeconds = 0;
|
||||||
|
var eta = TimeSpan.FromSeconds(remainingSeconds);
|
||||||
|
|
||||||
|
DrawProgress(progress, eta, speed);
|
||||||
|
Thread.Sleep(200);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
{
|
||||||
|
IsBackground = true
|
||||||
|
};
|
||||||
|
progressThread.Start();
|
||||||
|
|
||||||
|
// --- SINGLE THREADED LOOP ---
|
||||||
|
foreach (var job in jobs)
|
||||||
|
{
|
||||||
|
var outputFile = BuildOutputFileName(outputFolder, mask, job.Index);
|
||||||
|
RunFFmpegSegment(inputFile, outputFile, job.Start, job.Length, passthrough);
|
||||||
|
completed++;
|
||||||
|
}
|
||||||
|
|
||||||
|
sw.Stop();
|
||||||
|
progressRunning = false;
|
||||||
|
progressThread.Join();
|
||||||
|
DrawProgress(1.0, TimeSpan.Zero, totalDuration / Math.Max(sw.Elapsed.TotalSeconds, 0.0001));
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------
|
||||||
|
// Multi-threaded cropping
|
||||||
|
// -----------------------------
|
||||||
|
private static void RunMultiThreadedCrop(
|
||||||
|
string inputFile,
|
||||||
|
string outputFolder,
|
||||||
|
string outputMask,
|
||||||
|
double duration,
|
||||||
|
int segments,
|
||||||
|
double segmentLength,
|
||||||
|
string[] passthrough,
|
||||||
|
int width,
|
||||||
|
int height,
|
||||||
|
bool showDebugOverlay)
|
||||||
|
{
|
||||||
|
var tracker = new FaceTracker
|
||||||
|
{
|
||||||
|
DrawProgress = DrawProgress
|
||||||
|
};
|
||||||
|
|
||||||
|
var jobs = Enumerable.Range(0, segments)
|
||||||
|
.Select(i => new
|
||||||
|
{
|
||||||
|
Index = i,
|
||||||
|
Start = i * segmentLength,
|
||||||
|
Length = (i == segments - 1)
|
||||||
|
? Math.Max(0.1, duration - i * segmentLength)
|
||||||
|
: segmentLength
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var completed = 0;
|
||||||
|
var sw = Stopwatch.StartNew();
|
||||||
|
progressRunning = true;
|
||||||
|
|
||||||
|
// --- PROGRESS THREAD ---
|
||||||
|
var progressThread = new Thread(() =>
|
||||||
|
{
|
||||||
|
while (progressRunning)
|
||||||
|
{
|
||||||
|
var progress = segments == 0 ? 0 : (double)completed / segments;
|
||||||
|
var processedSeconds = completed * segmentLength;
|
||||||
|
|
||||||
|
var speed = sw.Elapsed.TotalSeconds > 0
|
||||||
|
? processedSeconds / sw.Elapsed.TotalSeconds
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
var remainingSeconds = (duration - processedSeconds) / Math.Max(speed, 0.0001);
|
||||||
|
if (remainingSeconds < 0) remainingSeconds = 0;
|
||||||
|
|
||||||
|
var eta = TimeSpan.FromSeconds(remainingSeconds);
|
||||||
|
|
||||||
|
DrawProgress(progress, eta, speed);
|
||||||
|
Thread.Sleep(200);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
{
|
||||||
|
IsBackground = true
|
||||||
|
};
|
||||||
|
progressThread.Start();
|
||||||
|
|
||||||
|
// --- PARALLEL EXECUTION ---
|
||||||
|
var maxDegree = Math.Max(1, Environment.ProcessorCount / 2);
|
||||||
|
|
||||||
|
Parallel.ForEach(
|
||||||
|
jobs,
|
||||||
|
new ParallelOptions { MaxDegreeOfParallelism = maxDegree },
|
||||||
|
async job =>
|
||||||
|
{
|
||||||
|
var outputFile = BuildOutputFileName(outputFolder, outputMask, job.Index);
|
||||||
|
|
||||||
|
// Run the face-tracking cropper
|
||||||
|
await tracker.TrackFaceAndExtract(
|
||||||
|
inputFile,
|
||||||
|
outputFile,
|
||||||
|
TimeSpan.FromSeconds(job.Start),
|
||||||
|
TimeSpan.FromSeconds(job.Length),
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
passthrough,
|
||||||
|
showDebugOverlay);
|
||||||
|
|
||||||
|
Interlocked.Increment(ref completed);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- CLEANUP ---
|
||||||
|
sw.Stop();
|
||||||
|
progressRunning = false;
|
||||||
|
progressThread.Join();
|
||||||
|
|
||||||
|
var finalSpeed = duration / Math.Max(sw.Elapsed.TotalSeconds, 0.0001);
|
||||||
|
DrawProgress(1.0, TimeSpan.Zero, finalSpeed);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
static string BuildOutputFileName(string folder, string mask, int index)
|
static string BuildOutputFileName(string folder, string mask, int index)
|
||||||
{
|
{
|
||||||
string fileName;
|
string fileName;
|
||||||
@ -309,8 +523,8 @@ class Program
|
|||||||
else
|
else
|
||||||
{
|
{
|
||||||
// If no placeholder, append index
|
// If no placeholder, append index
|
||||||
string name = Path.GetFileNameWithoutExtension(mask);
|
var name = Path.GetFileNameWithoutExtension(mask);
|
||||||
string ext = Path.GetExtension(mask);
|
var ext = Path.GetExtension(mask);
|
||||||
fileName = $"{name}_{index:000}{ext}";
|
fileName = $"{name}_{index:000}{ext}";
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -319,9 +533,9 @@ class Program
|
|||||||
|
|
||||||
static void RunFFmpegSegment(string inputFile, string outputFile, double start, double length, string[] passthrough)
|
static void RunFFmpegSegment(string inputFile, string outputFile, double start, double length, string[] passthrough)
|
||||||
{
|
{
|
||||||
string pass = passthrough.Length > 0 ? string.Join(" ", passthrough) : "";
|
var pass = passthrough.Length > 0 ? string.Join(" ", passthrough) : "";
|
||||||
|
|
||||||
string args =
|
var args =
|
||||||
$"-ss {start.ToString(CultureInfo.InvariantCulture)} -i \"{inputFile}\" -t {length.ToString(CultureInfo.InvariantCulture)} -c copy {pass} \"{outputFile}\" -y";
|
$"-ss {start.ToString(CultureInfo.InvariantCulture)} -i \"{inputFile}\" -t {length.ToString(CultureInfo.InvariantCulture)} -c copy {pass} \"{outputFile}\" -y";
|
||||||
|
|
||||||
var psi = new ProcessStartInfo
|
var psi = new ProcessStartInfo
|
||||||
@ -343,7 +557,7 @@ class Program
|
|||||||
text = text.Trim().ToLowerInvariant();
|
text = text.Trim().ToLowerInvariant();
|
||||||
|
|
||||||
// Case 1: pure number to seconds
|
// Case 1: pure number to seconds
|
||||||
if (double.TryParse(text, NumberStyles.Any, CultureInfo.InvariantCulture, out double sec))
|
if (double.TryParse(text, NumberStyles.Any, CultureInfo.InvariantCulture, out var sec))
|
||||||
return sec;
|
return sec;
|
||||||
|
|
||||||
// Case 2: Ns (seconds)
|
// Case 2: Ns (seconds)
|
||||||
@ -352,16 +566,16 @@ class Program
|
|||||||
|
|
||||||
// Case 3: NmMs (minutes + seconds)
|
// Case 3: NmMs (minutes + seconds)
|
||||||
// Examples: 2m30s, 1m5s, 10m0s
|
// Examples: 2m30s, 1m5s, 10m0s
|
||||||
int mIndex = text.IndexOf('m');
|
var mIndex = text.IndexOf('m');
|
||||||
int sIndex = text.IndexOf('s');
|
var sIndex = text.IndexOf('s');
|
||||||
|
|
||||||
if (mIndex > 0 && sIndex > mIndex)
|
if (mIndex > 0 && sIndex > mIndex)
|
||||||
{
|
{
|
||||||
string mPart = text[..mIndex];
|
var mPart = text[..mIndex];
|
||||||
string sPart = text[(mIndex + 1)..sIndex];
|
var sPart = text[(mIndex + 1)..sIndex];
|
||||||
|
|
||||||
if (double.TryParse(mPart, out double minutes) &&
|
if (double.TryParse(mPart, out var minutes) &&
|
||||||
double.TryParse(sPart, out double seconds))
|
double.TryParse(sPart, out var seconds))
|
||||||
{
|
{
|
||||||
return minutes * 60 + seconds;
|
return minutes * 60 + seconds;
|
||||||
}
|
}
|
||||||
@ -406,15 +620,24 @@ Options:
|
|||||||
--estimate Print calculated segment information and exit.
|
--estimate Print calculated segment information and exit.
|
||||||
No splitting is performed.
|
No splitting is performed.
|
||||||
|
|
||||||
|
--crop[=<w:h>] Crop video to width w and height h, with face tracking.
|
||||||
|
Useful to making YouTube Shorts or TikToks from horizontal video.
|
||||||
|
Default: 607x1080 (vertical video cropped from Full HD original)
|
||||||
|
|
||||||
|
--text Display log in plain text.
|
||||||
|
|
||||||
|
--debug Show debug overlay during face tracking.
|
||||||
|
|
||||||
Passthrough:
|
Passthrough:
|
||||||
Anything after -- is passed directly to ffmpeg.
|
Anything after -- is passed directly to ffmpeg.
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
splitter video.mp4 out/
|
splitter vertical-video.mp4 out/
|
||||||
splitter video.mp4 out/ --duration=90s
|
splitter vertical-video.mp4 out/ --duration=90s
|
||||||
splitter video.mp4 out/ --duration=2m30s --mask=""Part%03d.mp4""
|
splitter vertical-video.mp4 out/ --duration=2m30s --mask=""Part%03d.mp4""
|
||||||
splitter video.mp4 out/ --estimate
|
splitter vertical-video.mp4 out/ --estimate
|
||||||
splitter video.mp4 out/ --force --duration=45 -- -an -sn
|
splitter vertical-video.mp4 out/ --force --duration=45 -- -an -sn
|
||||||
|
splitter horizontal-video.mp4 out/ --crop
|
||||||
|
|
||||||
Description:
|
Description:
|
||||||
Splits a video into equal or fixed-length segments using multi-threaded
|
Splits a video into equal or fixed-length segments using multi-threaded
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<OutputType>Exe</OutputType>
|
<OutputType>Exe</OutputType>
|
||||||
@ -6,7 +6,45 @@
|
|||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<LangVersion>latest</LangVersion>
|
<LangVersion>latest</LangVersion>
|
||||||
<Optimize>true</Optimize>
|
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<!-- DEBUG CONFIGURATION -->
|
||||||
|
<PropertyGroup Condition="'$(Configuration)' == 'Debug'">
|
||||||
|
<Optimize>false</Optimize>
|
||||||
|
<SelfContained>false</SelfContained>
|
||||||
|
<PublishSingleFile>false</PublishSingleFile>
|
||||||
|
<PublishTrimmed>false</PublishTrimmed>
|
||||||
|
<IncludeNativeLibrariesForSelfExtract>false</IncludeNativeLibrariesForSelfExtract>
|
||||||
|
<PublishReadyToRun>false</PublishReadyToRun>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<!-- RELEASE CONFIGURATION -->
|
||||||
|
<PropertyGroup Condition="'$(Configuration)' == 'Release'">
|
||||||
|
<Optimize>true</Optimize>
|
||||||
|
<SelfContained>true</SelfContained>
|
||||||
|
<PublishSingleFile>true</PublishSingleFile>
|
||||||
|
<PublishTrimmed>false</PublishTrimmed>
|
||||||
|
<!-- OpenCvSharp cannot be trimmed -->
|
||||||
|
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
|
||||||
|
<PublishReadyToRun>true</PublishReadyToRun>
|
||||||
|
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Content Include="slim_320.bin">
|
||||||
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
|
</Content>
|
||||||
|
<Content Include="slim_320.param">
|
||||||
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
|
</Content>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="FFmpeg.AutoGen" Version="8.1.0" />
|
||||||
|
<PackageReference Include="OpenCvSharp4" Version="4.13.0.20260427" />
|
||||||
|
<PackageReference Include="OpenCvSharp4.runtime.win" Version="4.13.0.20260302" />
|
||||||
|
<PackageReference Include="UltraFaceDotNet" Version="1.0.0.2" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user