优化 独立工作流为单独配置

This commit is contained in:
小肥羊 2026-02-28 14:15:05 +08:00
parent 43599fea1d
commit a2200c0296
9 changed files with 774 additions and 356 deletions

View File

@ -1,4 +1,4 @@
using FFmpeg.NET.Events;
using FFmpeg.NET.Events;
using FFmpeg.NET;
using VideoAnalysisCore.AICore.SherpaOnnx;
using VideoAnalysisCore.Common;
@ -208,5 +208,55 @@ namespace VideoAnalysisCore.AICore.FFMPGE
}
/// <summary>
/// 合并音频和视频并切片
/// </summary>
/// <param name="task"></param>
/// <returns></returns>
public async Task MergeAndSliceAsync(string task)
{
var taskID = long.Parse(task);
var localPath = task.LocalPath();
var pptPath = Path.Combine(localPath, "ppt.mp4");
var taskPath = Path.Combine(localPath, "task.mp4");
var mergedPath = Path.Combine(localPath, "merged.mp4");
var m3u8Path = Path.Combine(localPath, "out.m3u8");
if (!File.Exists(pptPath)) throw new FileNotFoundException("PPT视频文件未找到", pptPath);
if (!File.Exists(taskPath)) throw new FileNotFoundException("任务视频文件未找到", taskPath);
var ffmpeg = new Engine(FFmpegPath);
var cToken = new CancellationToken();
// 1. 合并 PPT视频(画面) + 任务视频(音频) -> merged.mp4
// -map 0:v 取第一个输入(ppt)的视频流
// -map 1:a 取第二个输入(task)的音频流
// -c:v copy 复制视频流不转码
// -c:a aac 音频转码为aac (兼容性好)
// -strict experimental 允许使用aac
// -shortest 以最短的流为准
var mergeArgs = $"-i \"{pptPath}\" -i \"{taskPath}\" -map 0:v -map 1:a -c:v copy -c:a aac -strict experimental -shortest \"{mergedPath}\" -y";
await redisManager.AddTaskLog(task, "开始合并视频与音频...");
await ffmpeg.ExecuteAsync(mergeArgs, cToken);
if (!File.Exists(mergedPath)) throw new Exception("视频合并失败");
// 2. 切片 merged.mp4 -> out.m3u8
// -c copy 直接复制流 (因为上一步已经是 mp4/aac)
// -f hls HLS格式
// -hls_time 10 切片时长10秒
// -hls_list_size 0 包含所有切片
// -hls_segment_filename out%03d.ts 切片文件名
var sliceArgs = $"-i \"{mergedPath}\" -c copy -f hls -hls_time 10 -hls_list_size 0 -hls_segment_filename \"{Path.Combine(localPath, "out%03d.ts")}\" \"{m3u8Path}\" -y";
await redisManager.AddTaskLog(task, "开始视频切片...");
await ffmpeg.ExecuteAsync(sliceArgs, cToken);
if (!File.Exists(m3u8Path)) throw new Exception("视频切片失败");
// 更新任务状态或路径? 目前只需要生成文件
await redisManager.AddTaskLog(task, "视频处理完成");
}
}
}

View File

@ -1,4 +1,4 @@
using SqlSugar.IOC;
using SqlSugar.IOC;
using System;
using System.Collections.Generic;
using System.Linq;
@ -70,6 +70,21 @@ namespace VideoAnalysisCore.Common
/// 授权配置
/// </summary>
public AuthKeyConfig AuthKey { get; set; } = new AuthKeyConfig();
/// <summary>
/// 工作流配置
/// </summary>
public WorkflowConfig Workflow { get; set; } = new WorkflowConfig();
}
public class WorkflowConfig
{
public WorkflowItemConfig Default { get; set; } = new WorkflowItemConfig();
public WorkflowItemConfig Upload { get; set; } = new WorkflowItemConfig { Enabled = true };
}
public class WorkflowItemConfig
{
public bool Enabled { get; set; } = true;
public int Concurrency { get; set; } = 1;
}
public class AuthKeyConfig

View File

@ -0,0 +1,159 @@
using AlibabaCloud.SDK.Vod20170321;
using AlibabaCloud.SDK.Vod20170321.Models;
using Aliyun.OSS;
using Microsoft.Extensions.DependencyInjection;
using SqlSugar.IOC;
using System;
using System.IO;
using System.Text;
using System.Threading.Tasks;
using VideoAnalysisCore.Model;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using System.Collections.Generic;
namespace VideoAnalysisCore.Common.Expand
{
public static class UploadExpand
{
public static void AddUploadExpand(this IServiceCollection services)
{
services.AddSingleton<UploadHandle>();
}
}
public class UploadHandle
{
private readonly Client _vodClient;
private readonly Repository<VideoTask> _videoTaskDB;
private readonly RedisManager _redisManager;
private readonly OssClient _ossClient; // 使用系统统一注入的 OSS Client
public UploadHandle(Client vodClient, Repository<VideoTask> videoTaskDB, RedisManager redisManager, OssClient ossClient)
{
_vodClient = vodClient;
_videoTaskDB = videoTaskDB;
_redisManager = redisManager;
_ossClient = ossClient;
}
public async Task RunAsync(string task)
{
var taskId = long.Parse(task);
var localPath = task.LocalPath();
var m3u8Path = Path.Combine(localPath, "out.m3u8");
if (!File.Exists(m3u8Path))
{
await _redisManager.AddTaskLog(task, "未找到 m3u8 文件,无法进行切片上传");
throw new FileNotFoundException("M3U8文件未找到", m3u8Path);
}
// 获取所有切片文件 (out*.ts)
var tsFiles = Directory.GetFiles(localPath, "out*.ts");
if (tsFiles.Length == 0)
{
await _redisManager.AddTaskLog(task, "未找到 ts 切片文件");
throw new FileNotFoundException("TS切片文件未找到");
}
var title = $"Task_{taskId}_{DateTime.Now:yyyyMMddHHmmss}";
await _redisManager.AddTaskLog(task, "正在获取VOD上传凭证...");
// 1. 获取上传凭证和地址
// 注意VOD上传m3u8时FileName必须以 .m3u8 结尾
var request = new CreateUploadVideoRequest
{
Title = title,
FileName = "out.m3u8", // 必须是 m3u8 文件名
Description = "Video Analysis HLS Upload",
// CoverURL = "...", // 可选:设置封面
// Tags = "...", // 可选:设置标签
};
var response = await _vodClient.CreateUploadVideoAsync(request);
if (response.Body == null || string.IsNullOrEmpty(response.Body.UploadAddress) || string.IsNullOrEmpty(response.Body.UploadAuth))
{
throw new Exception($"获取上传凭证失败: RequestId={response.Body?.RequestId}");
}
var videoId = response.Body.VideoId;
var uploadAddressStr = response.Body.UploadAddress;
var uploadAuthStr = response.Body.UploadAuth;
await _redisManager.AddTaskLog(task, $"获取凭证成功VideoId: {videoId}");
// 2. 解析凭证 (Base64 -> JSON)
var addressJson = JObject.Parse(Encoding.UTF8.GetString(Convert.FromBase64String(uploadAddressStr)));
var authJson = JObject.Parse(Encoding.UTF8.GetString(Convert.FromBase64String(uploadAuthStr)));
var endpoint = addressJson["Endpoint"]?.ToString();
var bucket = addressJson["Bucket"]?.ToString();
var objectName = addressJson["FileName"]?.ToString(); // 这是 VOD 分配的 m3u8 存储路径,例如 "sv/243d.../out.m3u8"
var accessKeyId = authJson["AccessKeyId"]?.ToString();
var accessKeySecret = authJson["AccessKeySecret"]?.ToString();
var securityToken = authJson["SecurityToken"]?.ToString();
if (string.IsNullOrEmpty(endpoint) || string.IsNullOrEmpty(bucket) || string.IsNullOrEmpty(objectName))
{
throw new Exception("解析上传地址失败");
}
// 修正 Endpoint 格式 (如果缺少协议头)
if (!endpoint.StartsWith("http"))
{
endpoint = "https://" + endpoint;
}
// 3. 构造 OSS 客户端 (使用临时凭证)
var ossClient = new OssClient(endpoint, accessKeyId, accessKeySecret, securityToken);
// 4. 确定 OSS 目录前缀
// VOD 返回的 objectName 是完整的文件路径,我们需要提取目录部分来存放 .ts 文件
// 例如: objectName = "sv/5903240e-19544975a64/out.m3u8"
// 则 prefix = "sv/5903240e-19544975a64/"
var ossPrefix = objectName.Substring(0, objectName.LastIndexOf('/') + 1);
await _redisManager.AddTaskLog(task, $"开始上传文件到 VOD OSS (Bucket: {bucket}, Prefix: {ossPrefix})...");
try
{
// A. 上传所有 TS 切片
await _redisManager.AddTaskLog(task, $"开始上传 TS 切片 (共 {tsFiles.Length} 个)...");
foreach (var tsFile in tsFiles)
{
var fileName = Path.GetFileName(tsFile);
var tsObjectKey = ossPrefix + fileName;
using var fs = File.OpenRead(tsFile);
ossClient.PutObject(bucket, tsObjectKey, fs);
}
// B. 上传 m3u8 索引文件
// 必须使用 VOD 指定的 objectName
await _redisManager.AddTaskLog(task, "开始上传 m3u8 索引文件...");
using (var fs = File.OpenRead(m3u8Path))
{
ossClient.PutObject(bucket, objectName, fs);
}
await _redisManager.AddTaskLog(task, "上传成功");
// 5. 更新数据库
// 对于 VOD 托管视频,我们主要存储 VideoId (TagId),播放地址通常由前端调用 VOD 接口获取
// 或者我们可以尝试获取播放地址存入 MediaUrl
await _videoTaskDB.CopyNew().AsUpdateable()
.SetColumns(it => it.TagId == videoId)
.Where(it => it.Id == taskId)
.ExecuteCommandAsync();
}
catch (Exception ex)
{
await _redisManager.AddTaskLog(task, $"上传 VOD OSS 异常: {ex.Message}");
throw;
}
}
}
}

View File

@ -1,4 +1,4 @@
using FreeRedis;
using FreeRedis;
using FreeRedis.Internal;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.IdentityModel.Tokens;
@ -42,6 +42,10 @@ namespace VideoAnalysisCore.Common
/// </summary>
public const string ChannelKey = BaseKey + "TaskChannel";
/// <summary>
/// 上传工作流 Channel key
/// </summary>
public const string UploadChannelKey = BaseKey + "UploadTaskChannel";
/// <summary>
/// 下载文件
/// </summary>
public const string DownloadFile = ChannelKey + "DownloadFile";
@ -102,72 +106,19 @@ namespace VideoAnalysisCore.Common
JsonSerializer.Deserialize(json, type);
service.AddSingleton(redis);
service.AddSingleton<RedisManager>();
service.AddVideoSliceWorkflow();
service.AddUploadWorkflow();
}
}
public class RedisInit
{
public FFMPGEHandle FFMPGE { get; set; }
public SenseVoice senseVoice { get; set; }
public FunASRNano funASRNano { get; set; }
public RedisManager redisManager { get; set; }
public RedisInit(FFMPGEHandle fFMPGE, SenseVoice senseVoice, RedisManager redisManager, FunASRNano funASRNano)
public RedisInit(IServiceProvider serviceProvider)
{
FFMPGE = fFMPGE;
this.senseVoice = senseVoice;
this.funASRNano = funASRNano;
this.redisManager = redisManager;
Init();
redisManager.InitChannel();
}
public void Init()
{
var SubscribeList = RedisManager.SubscribeList;
SubscribeList.Add(RedisChannelEnum., async (task) =>
{
await Task.CompletedTask;
});
SubscribeList.Add(RedisChannelEnum., async (task) =>
{
using var scope = AppCommon.Services?.CreateScope();
if (scope is null || scope.ServiceProvider.GetService<DownloadFile>() is null)
throw new Exception("DownloadFile 未注入");
else
await scope.ServiceProvider.GetService<DownloadFile>()?.RunTask(task);
});
SubscribeList.Add(RedisChannelEnum., FFMPGE.RunAsync);
SubscribeList.Add(RedisChannelEnum., senseVoice.RunTask);
//SubscribeList.Add(RedisChannelEnum.解析字幕, funASRNano.RunTask);
//SubscribeList.Add(RedisChannelEnum.解析说话人,Speaker.Run);
SubscribeList.Add(RedisChannelEnum.AI课程类型, async (task) =>
{
using var scope = AppCommon.Services?.CreateScope();
if (scope is null || scope.ServiceProvider.GetService<IBserGPTWorkflow>() is null)
throw new Exception("IBserGPT 未注入");
else
await scope.ServiceProvider.GetService<IBserGPTWorkflow>()?.GetVideoType(task);
});
SubscribeList.Add(RedisChannelEnum.AI模型分析, async (task) =>
{
using var scope = AppCommon.Services?.CreateScope();
if (scope is null || scope.ServiceProvider.GetService<IBserGPTWorkflow>() is null)
throw new Exception("IBserGPT 未注入");
else
await scope.ServiceProvider?.GetService<IBserGPTWorkflow>()?.GetKnow(task);
});
SubscribeList.Add(RedisChannelEnum.AI分析试题, async (task) =>
{
using var scope = AppCommon.Services?.CreateScope();
if (scope is null || scope.ServiceProvider.GetService<IBserGPTWorkflow>() is null)
throw new Exception("IBserGPT 未注入");
else
await scope.ServiceProvider?.GetService<IBserGPTWorkflow>()?.GetVideoQuestion(task);
});
SubscribeList.Add(RedisChannelEnum., redisManager.TaskEnd);
serviceProvider.GetService<VideoSliceWorkflowInit>();
serviceProvider.GetService<UploadWorkflowInit>();
// serviceProvider.GetService<RedisManager>().InitChannel(); // 已废弃,由各工作流自行初始化
}
}
/// <summary>
@ -277,105 +228,6 @@ namespace VideoAnalysisCore.Common
Redis.HMSet(RedisExpandKey.Task(taskId), "Progress", p.ToString());
}
/// <summary>
/// 将任务 插入 队列
/// </summary>
/// <param name="enum">枚举</param>
/// <param name="taskId">任务id</param>
public async Task InsertChannel(RedisChannelEnum @enum, object taskId)
{
if (taskId is null) throw new Exception("taskId为空");
if (Redis is null) throw new Exception("redis未初始化");
var tId = taskId.ToString();
await AddTaskLog(tId, "==> 开始执行任务 ");
await ProcessTaskFlow(@enum, taskId, tId);
}
private async Task ProcessTaskFlow(RedisChannelEnum currentStep, object taskId, string tId)
{
try
{
// 确保有初始校验
if (!SubscribeList.ContainsKey(currentStep))
throw new Exception($"{currentStep} 未实现");
while (true)
{
if (StopTask)
{
await AddTaskLog(tId, "==> 手动停止任务 ");
return;
}
// 1. 记录步骤开始时间
await UpdateStepTimeAsync(taskId, currentStep);
// 2. 执行当前步骤业务逻辑
await TouchChannel(currentStep, tId, SubscribeList[currentStep]);
// 3. 准备下一步
var nextStepNullable = currentStep.NextEnum();
if (nextStepNullable == null) break; // 流程结束
var nextStep = nextStepNullable.Value;
// 4. 特殊分流:解析字幕完成后,后续步骤转后台并行处理
if (currentStep == RedisChannelEnum.)
{
DispatchBackgroundFlow(nextStep, taskId, tId);
return; // 释放当前主控线程
}
// 5. 继续循环
currentStep = nextStep;
}
}
catch (Exception ex)
{
await SetTaskErrorMessage(long.Parse(tId), ex);
}
finally
{
// 每次流程结束(无论是正常结束、异常还是分流退出),都尝试延长过期时间
// 注意:如果是分流退出,这里也会执行,保证 key 活跃
await Redis.ExpireAsync(RedisExpandKey.Task(taskId), 60 * 60 * 24 * 15);
}
}
/// <summary>
/// 更新任务步骤时间
/// </summary>
private async Task UpdateStepTimeAsync(object taskId, RedisChannelEnum step)
{
// 获取现有时间字典(如果不存在则新建)
// 注意HMGet 返回的是数组,取第一个元素
var result = await Redis.HMGetAsync<Dictionary<RedisChannelEnum, DateTime>>(RedisExpandKey.Task(taskId), "StartTime");
var startTime = result?.FirstOrDefault() ?? new Dictionary<RedisChannelEnum, DateTime>();
// 更新时间
startTime[step] = DateTime.Now;
// 写回 Redis
await Redis.HMSetAsync(RedisExpandKey.Task(taskId), "StartTime", startTime);
}
/// <summary>
/// 分发后续任务到动态线程池
/// </summary>
private void DispatchBackgroundFlow(RedisChannelEnum startStep, object taskId, string tId)
{
var bgTask = Task.Run(async () =>
{
try
{
await ProcessTaskFlow(startStep, taskId, tId);
}
finally
{
RunningTasks.TryRemove(tId, out _);
}
});
RunningTasks.TryAdd(tId, bgTask);
}
public async Task TaskEnd(string task)
{
@ -427,151 +279,6 @@ namespace VideoAnalysisCore.Common
//NewTask();
}
/// <summary>
/// 初始化 队列 任务
/// </summary>
public async Task InitChannel()
{
Thread.Sleep(1000);
if (Redis is null) throw new Exception("redis未初始化");
//处理之前程序结束前未能执行完的情况
var oldTaskCount = Redis.LLen(RedisExpandKey.IDTask);
//重试任务并发过多可能会导致程序崩溃
// 未能重新分析的中断任务 则单独开一个网页来处理
if (oldTaskCount > 0)
{
//获取所有未完成的任务
var oldTaskArr = Redis.LRange(RedisExpandKey.IDTask, 0, -1);
Console.WriteLine($"{DateTime.Now:HH:mm:ss}-------------> 发现 {oldTaskArr.Length} 个未完成任务,准备恢复...");
//使用信号量限制并发数(5),防止崩溃
using var semaphore = new System.Threading.SemaphoreSlim(5);
var retryTaskArr = new List<Task>();
foreach (var oldTask in oldTaskArr)
{
await semaphore.WaitAsync();
var res = Task.Run(async () =>
{
try
{
await AddTaskLog(oldTask, DateTime.Now.ToString("HH:mm:ss") + "-------------> 接收上次未完成任务 " + oldTask);
await ClearTaskError(long.Parse(oldTask));
var lastEnum = (await Redis.HMGetAsync<RedisChannelEnum>(RedisExpandKey.Task(oldTask), "LastEnum")).FirstOrDefault();
await InsertChannel(lastEnum, oldTask);
}
catch (Exception ex)
{
await SetTaskErrorMessage(long.Parse(oldTask), ex);
Console.WriteLine($"恢复任务 {oldTask} 失败: {ex.Message}");
}
finally
{
semaphore.Release();
}
});
retryTaskArr.Add(res);
}
//等待所有 重试任务完成后接收新任务
await Task.WhenAll(retryTaskArr);
Console.WriteLine(DateTime.Now.ToString("HH:mm:ss") + "-------------> 所有未完成任务处理完毕!");
ReceivingTaskAsync();
}
else
{
Console.WriteLine(DateTime.Now.ToString("HH:mm:ss") + "-------------> 接收新任务!");
ReceivingTaskAsync();
}
}
/// <summary>
/// 停止接收新任务
/// </summary>
public void StopTaskAsync()
{
StopTask = true;
try
{
_cts?.Cancel();
}
catch (Exception)
{
throw;
}
}
/// <summary>
/// 开始接收新任务
/// </summary>
public void RestartTask()
{
StopTask = false;
NewTask();
}
/// <summary>
/// 重新执行新任务
/// </summary>
/// <returns></returns>
public void NewTask()
{
// 取消 消费机的任务订阅
if (StopTask)
{
Console.WriteLine(DateTime.Now.ToString("HH:mm:ss") + "-------------> 接收任务已经暂停 ");
return;
}
ReceivingTaskAsync();
}
/// <summary>
/// 重新接收新任务
/// </summary>
public void ReceivingTaskAsync()
{
if (AppCommon.Config.TaskSetting.IS_Server)
{
Console.WriteLine($"{DateTime.Now} =>服务端不接收任务");
return;
}
lock (Redis)
{
// 如果任务正在运行且未完成,直接返回
if (_workerTask != null && !_workerTask.IsCompleted)
return;
_cts = new CancellationTokenSource();
var token = _cts.Token;
_workerTask = Task.Run(async () =>
{
Console.WriteLine($"{DateTime.Now} => 开始监听任务队列...");
while (!token.IsCancellationRequested && !StopTask)
{
try
{
// 使用 BLPop 阻塞式获取任务超时5秒以便检查取消状态
var taskId = Redis.BLPop(RedisExpandKey.ChannelKey, 5);
if (!string.IsNullOrEmpty(taskId))
{
Redis.LPush(RedisExpandKey.IDTask, taskId);
await AddTaskLog(taskId, "-------------> 接收到任务 ");
// await等待任务处理完成确保串行执行
await InsertChannel(RedisChannelEnum., taskId);
}
}
catch (Exception ex)
{
Console.WriteLine($"任务监听异常: {ex.Message}");
await Task.Delay(2000);
}
}
Console.WriteLine($"{DateTime.Now} => 停止监听任务队列.");
}, token);
}
}
/// <summary>
/// 写入任务异常
/// </summary>
@ -589,7 +296,7 @@ namespace VideoAnalysisCore.Common
error = ex.Message + ex.StackTrace;
await AddTaskLog(taskID, $""" 出现异常 {ex.Message} {ex.StackTrace} """);
//清除失败任务 重新接收任务
NewTask();
// NewTask(); // 已废弃,工作流会自动处理
}
return await SetTaskError(taskID, error);
}
@ -615,50 +322,5 @@ namespace VideoAnalysisCore.Common
.ExecuteCommandAsync() == 1;
}
/// <summary>
/// 触发
/// </summary>
/// <param name="key"></param>
/// <param name="taskId"></param>
/// <param name="action"></param>
public async Task TouchChannel(RedisChannelEnum key, string taskId, Func<string, Task> action = null)
{
if (taskId is null) return;
var tID = long.Parse(taskId);
if (action is not null)
{
var tryCount = 1;
for (int i = 0; i < tryCount; i++)
{
await AddTaskLog(taskId, " 开始执行 " + key + " " + taskId);
try
{
Redis.HMSet(RedisExpandKey.Task(taskId), "LastEnum", key);
Redis.HMSet(RedisExpandKey.Task(taskId), "Progress", 0);
var vDB = AppCommon.Services.GetService<Repository<VideoTask>>();
await vDB.CopyNew().AsUpdateable()
.SetColumns(it => it.LastEnum == key)
.Where(it => it.Id == tID)
.ExecuteCommandAsync();
await action(taskId);
return;
}
catch (Exception ex)
{
await AddTaskLog(taskId, $""" 出现异常 {ex.Message} {ex.StackTrace} """);
Thread.Sleep(1000);
await AddTaskLog(taskId, "稍后后重试." + key + " " + taskId);
if (i + 1 == tryCount)
throw;
}
}
}
else
{
await AddTaskLog(taskId, "任务函数 未实现." + key);
}
}
}
}

View File

@ -0,0 +1,80 @@
using FreeRedis;
using Microsoft.Extensions.DependencyInjection;
using SqlSugar.IOC;
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using VideoAnalysisCore.AICore.FFMPGE;
using VideoAnalysisCore.Common.Expand;
using VideoAnalysisCore.Model;
using VideoAnalysisCore.Model.Enum;
namespace VideoAnalysisCore.Common
{
public static class UploadWorkflowExpand
{
public static void AddUploadWorkflow(this IServiceCollection services)
{
// 只有在配置启用时才注册
if (AppCommon.Config.Workflow.Upload.Enabled)
{
Console.WriteLine($"{DateTime.Now}=>初始化 视频合并工作流");
services.AddUploadExpand(); // Register UploadHandle
services.AddSingleton<UploadWorkflowManager>();
services.AddSingleton<UploadWorkflowInit>();
}
}
}
public class UploadWorkflowInit
{
private readonly UploadWorkflowManager _manager;
private readonly IServiceProvider _serviceProvider;
public UploadWorkflowInit(UploadWorkflowManager manager, IServiceProvider serviceProvider)
{
_manager = manager;
_serviceProvider = serviceProvider;
Init();
_manager.InitChannel();
}
public void Init()
{
var SubscribeList = _manager.SubscribeList;
SubscribeList.Add(RedisUploadChannelEnum., async (task) => await Task.CompletedTask);
SubscribeList.Add(RedisUploadChannelEnum., async (task) =>
{
using var scope = _serviceProvider.CreateScope();
var downloadService = scope.ServiceProvider.GetRequiredService<DownloadFile>();
await downloadService.RunTask(task);
});
SubscribeList.Add(RedisUploadChannelEnum., async (task) =>
{
using var scope = _serviceProvider.CreateScope();
var ffmpegService = scope.ServiceProvider.GetRequiredService<FFMPGEHandle>();
await ffmpegService.MergeAndSliceAsync(task);
});
SubscribeList.Add(RedisUploadChannelEnum., async (task) =>
{
using var scope = _serviceProvider.CreateScope();
var uploadService = scope.ServiceProvider.GetRequiredService<UploadHandle>();
await uploadService.RunAsync(task);
});
SubscribeList.Add(RedisUploadChannelEnum., _manager.RedisManager.TaskEnd);
}
}
public class UploadWorkflowManager : WorkflowBase<RedisUploadChannelEnum>
{
public UploadWorkflowManager(RedisClient redis, RedisManager redisManager) : base(redis, redisManager)
{
}
protected override string ChannelKey => RedisExpandKey.UploadChannelKey;
protected override int Concurrency => AppCommon.Config.Workflow.Upload.Concurrency;
}
}

View File

@ -0,0 +1,114 @@
using FreeRedis;
using Microsoft.Extensions.DependencyInjection;
using SqlSugar.IOC;
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using VideoAnalysisCore.AICore.FFMPGE;
using VideoAnalysisCore.AICore.GPT;
using VideoAnalysisCore.AICore.SherpaOnnx;
using VideoAnalysisCore.AICore.Whisper;
using VideoAnalysisCore.Common.Expand;
using VideoAnalysisCore.Model;
using VideoAnalysisCore.Model.Enum;
namespace VideoAnalysisCore.Common
{
/// <summary>
/// AI视频切片工作流
/// </summary>
public static class VideoSliceWorkflowExpand
{
public static void AddVideoSliceWorkflow(this IServiceCollection services)
{
if (AppCommon.Config.Workflow.Default.Enabled)
{
Console.WriteLine($"{DateTime.Now}=>初始化 AI切片工作流");
services.AddSingleton<VideoSliceWorkflowManager>();
services.AddSingleton<VideoSliceWorkflowInit>();
}
}
}
public class VideoSliceWorkflowInit
{
private readonly VideoSliceWorkflowManager _manager;
private readonly IServiceProvider _serviceProvider;
private readonly FFMPGEHandle _ffmpeg;
private readonly SenseVoice _senseVoice;
private readonly RedisManager _redisManager;
public VideoSliceWorkflowInit(VideoSliceWorkflowManager manager, IServiceProvider serviceProvider, FFMPGEHandle ffmpeg, SenseVoice senseVoice, RedisManager redisManager)
{
_manager = manager;
_serviceProvider = serviceProvider;
_ffmpeg = ffmpeg;
_senseVoice = senseVoice;
_redisManager = redisManager;
Init();
_manager.InitChannel();
}
public void Init()
{
var SubscribeList = _manager.SubscribeList;
SubscribeList.Add(RedisChannelEnum., async (task) => await Task.CompletedTask);
SubscribeList.Add(RedisChannelEnum., async (task) =>
{
using var scope = _serviceProvider.CreateScope();
var downloadService = scope.ServiceProvider.GetService<DownloadFile>();
if (downloadService is null) throw new Exception("DownloadFile 未注入");
await downloadService.RunTask(task);
});
SubscribeList.Add(RedisChannelEnum., _ffmpeg.RunAsync);
SubscribeList.Add(RedisChannelEnum., _senseVoice.RunTask);
SubscribeList.Add(RedisChannelEnum.AI课程类型, async (task) =>
{
using var scope = _serviceProvider.CreateScope();
var service = scope.ServiceProvider.GetService<IBserGPTWorkflow>();
if (service is null) throw new Exception("IBserGPT 未注入");
await service.GetVideoType(task);
});
SubscribeList.Add(RedisChannelEnum.AI模型分析, async (task) =>
{
using var scope = _serviceProvider.CreateScope();
var service = scope.ServiceProvider.GetService<IBserGPTWorkflow>();
if (service is null) throw new Exception("IBserGPT 未注入");
await service.GetKnow(task);
});
SubscribeList.Add(RedisChannelEnum.AI分析试题, async (task) =>
{
using var scope = _serviceProvider.CreateScope();
var service = scope.ServiceProvider.GetService<IBserGPTWorkflow>();
if (service is null) throw new Exception("IBserGPT 未注入");
await service.GetVideoQuestion(task);
});
SubscribeList.Add(RedisChannelEnum., _redisManager.TaskEnd);
}
}
public class VideoSliceWorkflowManager : WorkflowBase<RedisChannelEnum>
{
public VideoSliceWorkflowManager(RedisClient redis, RedisManager redisManager) : base(redis, redisManager)
{
}
protected override string ChannelKey => RedisExpandKey.ChannelKey;
protected override int Concurrency => AppCommon.Config.Workflow.Default.Concurrency;
protected override async Task HandleSpecialFlowAsync(RedisChannelEnum currentStep, RedisChannelEnum nextStep, string taskId)
{
// 4. 特殊分流:解析字幕完成后,后续步骤转后台并行处理
if (currentStep == RedisChannelEnum.)
{
await DispatchBackgroundFlow(nextStep, taskId, taskId);
throw new WorkflowFlowSwitchException(); // 抛出异常以中断当前流程(基类捕获)
}
await Task.CompletedTask;
}
}
}

View File

@ -0,0 +1,298 @@
using FreeRedis;
using Microsoft.Extensions.DependencyInjection;
using SqlSugar.IOC;
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using VideoAnalysisCore.Model;
using VideoAnalysisCore.Model.Enum;
namespace VideoAnalysisCore.Common
{
public class WorkflowFlowSwitchException : Exception { }
public abstract class WorkflowBase<TEnum> where TEnum : struct, Enum
{
public bool StopTask { get; set; } = false;
public Dictionary<TEnum, Func<string, Task>> SubscribeList = new Dictionary<TEnum, Func<string, Task>>();
public readonly RedisClient Redis;
public readonly RedisManager RedisManager;
private CancellationTokenSource? _cts;
private List<Task> _workerTasks = new List<Task>();
public static ConcurrentDictionary<string, Task> RunningTasks = new ConcurrentDictionary<string, Task>();
/// <summary>
/// 工作流的Redis队列key
/// </summary>
protected abstract string ChannelKey { get; }
/// <summary>
/// 单工作流并发数量
/// </summary>
protected abstract int Concurrency { get; }
public WorkflowBase(RedisClient redis, RedisManager redisManager)
{
Redis = redis;
RedisManager = redisManager;
}
public async void InitChannel()
{
if (AppCommon.Config.TaskSetting.IS_Server) return;
//处理之前程序结束前未能执行完的情况
var oldTaskCount = Redis.LLen(RedisExpandKey.IDTask);
//重试任务并发过多可能会导致程序崩溃
// 未能重新分析的中断任务 则单独开一个网页来处理
if (oldTaskCount > 0)
{
//获取所有未完成的任务
var oldTaskArr = Redis.LRange(RedisExpandKey.IDTask, 0, -1);
Console.WriteLine($"{DateTime.Now:HH:mm:ss}-------------> 发现 {oldTaskArr.Length} 个未完成任务,准备恢复...");
//使用信号量限制并发数(5),防止崩溃
using var semaphore = new System.Threading.SemaphoreSlim(5);
var retryTaskArr = new List<Task>();
foreach (var oldTask in oldTaskArr)
{
try
{
// 检查该任务是否属于当前工作流(根据 LastEnum 的类型)
var lastEnumStr = (await Redis.HMGetAsync<string>(RedisExpandKey.Task(oldTask), "LastEnum")).FirstOrDefault();
// 尝试解析为当前工作流的枚举
if (!string.IsNullOrEmpty(lastEnumStr) && Enum.TryParse(typeof(TEnum), lastEnumStr, true, out var result))
{
await semaphore.WaitAsync();
var res = Task.Run(async () =>
{
try
{
await RedisManager.AddTaskLog(oldTask, DateTime.Now.ToString("HH:mm:ss") + $"-------------> 接收上次未完成任务 [{typeof(TEnum).Name}] " + oldTask);
await RedisManager.ClearTaskError(long.Parse(oldTask));
var lastEnum = (TEnum)result;
await InsertChannel(lastEnum, oldTask);
}
catch (Exception ex)
{
await RedisManager.SetTaskErrorMessage(long.Parse(oldTask), ex);
Console.WriteLine($"恢复任务 {oldTask} 失败: {ex.Message}");
}
finally
{
semaphore.Release();
}
});
retryTaskArr.Add(res);
}
else
{
// 如果无法解析为当前工作流的枚举,说明该任务属于其他工作流,跳过
// Console.WriteLine($"任务 {oldTask} 不属于工作流 {typeof(TEnum).Name},跳过");
}
}
catch (Exception ex)
{
Console.WriteLine($"检查任务 {oldTask} 状态失败: {ex.Message}");
}
}
//等待所有 重试任务完成后接收新任务
await Task.WhenAll(retryTaskArr);
Console.WriteLine(DateTime.Now.ToString("HH:mm:ss") + "-------------> 所有未完成任务处理完毕!");
ReceivingTaskAsync();
}
else
{
Console.WriteLine(DateTime.Now.ToString("HH:mm:ss") + "-------------> 接收新任务!");
ReceivingTaskAsync();
}
}
/// <summary>
/// 重新执行任务
/// </summary>
public void ReceivingTaskAsync()
{
int concurrency = Concurrency;
if (concurrency <= 0) concurrency = 1;
_cts = new CancellationTokenSource();
var token = _cts.Token;
for (int i = 0; i < concurrency; i++)
{
var index = i;
var task = Task.Run(async () =>
{
Console.WriteLine($"{DateTime.Now} ==> 开始监听 [{typeof(TEnum).Name}] 队列 [{index}]...");
while (!token.IsCancellationRequested && !StopTask)
{
try
{
var taskId = Redis.BLPop(ChannelKey, 5);
if (!string.IsNullOrEmpty(taskId))
{
Redis.LPush(RedisExpandKey.IDTask, taskId);
await RedisManager.AddTaskLog(taskId, $"==> 接收到任务 [{typeof(TEnum).Name}] ");
// 获取第一个枚举值作为起始步骤
var firstStep = Enum.GetValues(typeof(TEnum)).Cast<TEnum>().FirstOrDefault();
if (Convert.ToInt32(firstStep) == 0) // 跳过排队中
{
var next = firstStep.NextEnum();
if (next.HasValue) firstStep = next.Value;
}
await InsertChannel(firstStep, taskId);
}
}
catch (Exception ex)
{
Console.WriteLine($"任务监听异常: {ex.Message}");
await Task.Delay(2000);
}
}
}, token);
_workerTasks.Add(task);
}
}
public async Task InsertChannel(TEnum @enum, string taskId)
{
await RedisManager.AddTaskLog(taskId, "==> 开始执行任务 ");
await ProcessTaskFlow(@enum, taskId, taskId);
}
/// <summary>
/// 异步流程判定条件
/// </summary>
/// <param name="currentStep"></param>
/// <param name="nextStep"></param>
/// <param name="taskId"></param>
/// <returns></returns>
protected virtual async Task HandleSpecialFlowAsync(TEnum currentStep, TEnum nextStep, string taskId)
{
await Task.CompletedTask;
}
/// <summary>
/// 主处理任务流程
/// </summary>
/// <param name="currentStep"></param>
/// <param name="taskId"></param>
/// <param name="tId"></param>
/// <returns></returns>
protected async Task ProcessTaskFlow(TEnum currentStep, string taskId, string tId)
{
try
{
if (!SubscribeList.ContainsKey(currentStep))
throw new Exception($"{currentStep} 未实现");
while (true)
{
if (StopTask)
{
await RedisManager.AddTaskLog(tId, "==> 手动停止任务 ");
return;
}
// 1. 记录步骤开始时间 (需要转换 RedisChannelEnum 才能调用 UpdateStepTimeAsync如果类型不匹配则需要适配)
// 这里简化,暂不记录非主流程的时间,或者需要在 RedisManager 增加泛型支持
// await RedisManager.UpdateStepTimeAsync(taskId, currentStep);
// 2. 执行当前步骤
await TouchChannel(currentStep, tId, SubscribeList[currentStep]);
// 3. 准备下一步
var nextStepNullable = currentStep.NextEnum();
if (nextStepNullable == null) break;
var nextStep = nextStepNullable.Value;
// 4. 特殊分流处理
try
{
await HandleSpecialFlowAsync(currentStep, nextStep, taskId);
}
catch (WorkflowFlowSwitchException)
{
return; // 流程切换,退出当前循环
}
currentStep = nextStep;
}
}
catch (Exception ex)
{
await RedisManager.SetTaskErrorMessage(long.Parse(tId), ex);
}
finally
{
await Redis.ExpireAsync(RedisExpandKey.Task(taskId), 60 * 60 * 24 * 15);
}
}
public async Task TouchChannel(TEnum key, string taskId, Func<string, Task> action)
{
var tID = long.Parse(taskId);
await RedisManager.AddTaskLog(taskId, " 开始执行 " + key + " " + taskId);
try
{
// 尝试将当前枚举转为 RedisChannelEnum 存储状态,如果无法转换则强转 int
RedisChannelEnum dbEnum;
if (key is RedisChannelEnum rc) dbEnum = rc;
else
{
// 使用 Convert.ToInt32 处理各种枚举基础类型 (int, byte, long 等)
var intValue = Convert.ToInt32(key);
dbEnum = (RedisChannelEnum)intValue;
}
Redis.HMSet(RedisExpandKey.Task(taskId), "LastEnum", key.ToString());
Redis.HMSet(RedisExpandKey.Task(taskId), "Progress", 0);
var vDB = AppCommon.Services.GetService<Repository<VideoTask>>();
await vDB.CopyNew().AsUpdateable()
.SetColumns(it => it.LastEnum == dbEnum)
.Where(it => it.Id == tID)
.ExecuteCommandAsync();
await action(taskId);
}
catch (Exception ex)
{
await RedisManager.AddTaskLog(taskId, $""" 出现异常 {ex.Message} {ex.StackTrace} """);
throw;
}
}
/// <summary>
/// 异步流程
/// </summary>
/// <param name="startStep"></param>
/// <param name="taskId"></param>
/// <param name="tId"></param>
/// <returns></returns>
protected async Task DispatchBackgroundFlow(TEnum startStep, string taskId, string tId)
{
var bgTask = Task.Run(async () =>
{
try
{
await ProcessTaskFlow(startStep, taskId, tId);
}
finally
{
RunningTasks.TryRemove(tId, out _);
}
});
RunningTasks.TryAdd(tId, bgTask);
await Task.CompletedTask;
}
}
}

View File

@ -39,13 +39,16 @@ namespace VideoAnalysisCore.Controllers
readonly RedisManager redisManager;
readonly UploadWorkflowManager uploadWorkflowManager;
readonly VideoSliceWorkflowManager videoSliceWorkflowManager;
public readonly SenseVoice senseVoice;
public readonly FunASRNano funASRNano;
private readonly IMapper mp;
public VideoTaskController(Repository<VideoTask> baseService, RedisManager redisManager,
Repository<VideoQuestion> videoQuestionDB,
Repository<VideoQuestionKonw> videoQuestionKonwDB, Repository<VideoKonwPoint> videoKonwPointDB, SenseVoice senseVoice, IMapper mp, Repository<TaskLog> taskLogDB, FunASRNano funASRNano, Repository<VideoTaskStage> videoTaskStageDB) : base(baseService)
Repository<VideoQuestionKonw> videoQuestionKonwDB, Repository<VideoKonwPoint> videoKonwPointDB, SenseVoice senseVoice, IMapper mp, Repository<TaskLog> taskLogDB, FunASRNano funASRNano, Repository<VideoTaskStage> videoTaskStageDB, UploadWorkflowManager uploadWorkflowManager, VideoSliceWorkflowManager videoSliceWorkflowManager) : base(baseService)
{
this.baseService = baseService;
this.redisManager = redisManager;
@ -57,6 +60,8 @@ namespace VideoAnalysisCore.Controllers
this.taskLogDB = taskLogDB;
this.funASRNano = funASRNano;
this.videoTaskStageDB = videoTaskStageDB;
this.uploadWorkflowManager = uploadWorkflowManager;
this.videoSliceWorkflowManager = videoSliceWorkflowManager;
}
@ -118,9 +123,9 @@ namespace VideoAnalysisCore.Controllers
public IActionResult StartTask(bool task)
{
if (task)
redisManager.RestartTask();
videoSliceWorkflowManager.StopTask=false;
else
redisManager.StopTaskAsync();
videoSliceWorkflowManager.StopTask=true;
return Ok();
}
/// <summary>
@ -316,7 +321,7 @@ namespace VideoAnalysisCore.Controllers
await redisManager.AddTaskLog(id,"手动重试任务");
await redisManager.ClearTaskError(id);
_ = Task.Run(async () =>
await redisManager.InsertChannel(selectEnum, id)
await videoSliceWorkflowManager.InsertChannel(selectEnum, id.ToString())
);
}

View File

@ -0,0 +1,35 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace VideoAnalysisCore.Model.Enum
{
/// <summary>
/// 上传工作流 Redis 频道枚举
/// </summary>
public enum RedisUploadChannelEnum
{
/// <summary>
/// 排队中
/// </summary>
= 0,
/// <summary>
/// 下载文件
/// </summary>
= 10,
/// <summary>
/// 合并切片
/// </summary>
= 20,
/// <summary>
/// 上传视频
/// </summary>
= 30,
/// <summary>
/// 结束任务
/// </summary>
= 100,
}
}