diff --git a/.gitignore b/.gitignore index cfce883..2fbc35d 100644 --- a/.gitignore +++ b/.gitignore @@ -9,4 +9,6 @@ CustomEncodingJsonExample/bin CustomEncodingJsonExample/obj CustomTranscodingParamsTest/bin CustomTranscodingParamsTest/obj +CustomEncodingJsonTUSUploadExample/bin +CustomEncodingJsonTUSUploadExample/obj ScaleTest diff --git a/CustomEncodingJsonTUSUploadExample/CustomEncodingJsonTUSUploadExample.csproj b/CustomEncodingJsonTUSUploadExample/CustomEncodingJsonTUSUploadExample.csproj new file mode 100644 index 0000000..1078ab3 --- /dev/null +++ b/CustomEncodingJsonTUSUploadExample/CustomEncodingJsonTUSUploadExample.csproj @@ -0,0 +1,26 @@ + + + + Exe + netcoreapp2.2 + + + + + + + + + Always + + + + + + + + + + + + diff --git a/CustomEncodingJsonTUSUploadExample/Program.cs b/CustomEncodingJsonTUSUploadExample/Program.cs new file mode 100644 index 0000000..c2313ce --- /dev/null +++ b/CustomEncodingJsonTUSUploadExample/Program.cs @@ -0,0 +1,171 @@ +using Qencode.Api.CSharp.Client; +using Qencode.Api.CSharp.Client.Classes; +using Qencode.Api.CSharp.Client.Exceptions; +using System; +using System.ComponentModel; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Security.Cryptography; +using System.Threading; +using TusDotNetClient; + +namespace CustomEncodingJsonTUSUploadExample +{ + class Program + { + /* + * This example upload a local file, + * transcode it and then, + * download the transcoded file + */ + + static void Main(string[] args) + { + // You can find your API key under Project settings in your Dashboard on Qencode portal + const string apiKey = "YOUR_API_KEY_HERE"; + const string relative_output_dir = "TranscodedOutput"; // relative output dir + + // This is the full file name of the source video + string sourceVideoFullFileName = "E:\\dev\\My\\Sample720.flv"; + // if an argument is specified, the source video file will be readed from the first argument + if (args.Length >= 1 && !string.IsNullOrEmpty(args[0])) + sourceVideoFullFileName = args[0]; + + try + { + + // get access token + Console.WriteLine("Requesting access token..."); + var q = new QencodeApiClient(apiKey); + Console.WriteLine("\taccess token: '" + q.AccessToken + "'"); + + // create a new task + Console.WriteLine("Creating a new task..."); + var task = q.CreateTask(); + Console.WriteLine("\tcreated new task with token: '" + task.TaskToken + "' and url for direct video upload (TUS) '" + task.UploadUrl + "'"); + + // direct video upload - initiate upload (get endpoint for task) + Console.WriteLine("Initiate upload..."); + var srcFI = new FileInfo(sourceVideoFullFileName); + var client = new TusClient(); + var tusUploadLocationUrl = client.CreateAsync(task.UploadUrl + "/" + task.TaskToken, srcFI.Length).Result; + Console.WriteLine("\tobtained TUS upload location: '" + tusUploadLocationUrl + "'"); + + // direct video upload - send data + var uploadOperation = client.UploadAsync(tusUploadLocationUrl, srcFI); + Console.WriteLine("\ttransfer started"); + uploadOperation.Progressed += (transferred, total) => + { + Console.CursorLeft = 0; + Console.Write($"Progress: {transferred}/{total}"); + }; + Console.WriteLine(); + Console.WriteLine(); + uploadOperation.GetAwaiter().GetResult(); + Console.WriteLine("\tupload done"); + + // define a custom task by reading query.json and filling the ##TUS_FILE_UUID## placeholder + var tusFileUUID = tusUploadLocationUrl.Substring(tusUploadLocationUrl.LastIndexOf('/') + 1); + var customTrascodingJSON = File.ReadAllText("query.json").Replace("##TUS_FILE_UUID##", tusFileUUID); + var customTranscodingParams = CustomTranscodingParams.FromJSON(customTrascodingJSON); + + // start a custom task + Console.WriteLine("Custom task starting.."); + Console.WriteLine(customTrascodingJSON); + + // start a custom task - set event handler + bool taskCompletedOrError = false; + task.TaskCompleted = new RunWorkerCompletedEventHandler( + delegate (object o, RunWorkerCompletedEventArgs e) + { + if (e.Error != null) + { + taskCompletedOrError = true; + Console.WriteLine("Error: ", e.Error.Message); + } + + var response = e.Result as TranscodingTaskStatus; + if (response.error == 1) + { + taskCompletedOrError = true; + Console.WriteLine("Error: " + response.error_description); + } + else if (response.status == "completed") + { + taskCompletedOrError = true; + Console.WriteLine("Video urls: "); + foreach (var video in response.videos) + Console.WriteLine(video.url); + } + else + { + Console.WriteLine(response.status); + } + }); + // start a custom task - start + Console.WriteLine("\tstarting..."); + var started = task.StartCustom(customTranscodingParams); // starts and poll + + // waiting + Console.WriteLine("Waiting.."); + while (!taskCompletedOrError) + Thread.Sleep(1000); + + // get download url + if (task.LastStatus == null) + throw new InvalidOperationException("Unable to obtain download url"); + var outputDownloadUrl = new Uri(task.LastStatus.videos.First().url); + Console.WriteLine("Output download url: '" + outputDownloadUrl.ToString() + "'"); + string output_file_name = GetOutputFileName(sourceVideoFullFileName, outputDownloadUrl.Segments.Last()); + Console.WriteLine("Output file name: '" + output_file_name + "'"); + + + // download + Console.WriteLine("Downloading.."); + HttpFileDownload(outputDownloadUrl, relative_output_dir, output_file_name); + Console.WriteLine("\tdownload done"); + Environment.Exit(0); + + } + catch (QencodeApiException e) + { + Console.WriteLine("Qencode API exception: " + e.Message); + Environment.Exit(-1); + } + catch (QencodeException e) + { + Console.WriteLine("API call failed: " + e.Message); + Environment.Exit(-1); + } + } + + private static string GetOutputFileName(string inputFullFileName, string outputProposedFileName) + { + var inputFI = new FileInfo(inputFullFileName); + var ouputFI = new FileInfo(outputProposedFileName); + return inputFI.Name + ouputFI.Extension; + } + + private static void HttpFileDownload(Uri url, string localFolder, string localFileName) + { + if (!Directory.Exists($"./{localFolder}")) + Directory.CreateDirectory($"./{localFolder}"); + + var relativeFileName = $"./{localFolder}/{localFileName}"; + + if (File.Exists(relativeFileName)) + { + File.Delete(relativeFileName); + Console.WriteLine("\toutput file already existing: deleted"); + } + + using (var wc = new System.Net.WebClient()) + wc.DownloadFile(url, relativeFileName); + + } + + + } +} diff --git a/CustomEncodingJsonTUSUploadExample/query.json b/CustomEncodingJsonTUSUploadExample/query.json new file mode 100644 index 0000000..a66a6e5 --- /dev/null +++ b/CustomEncodingJsonTUSUploadExample/query.json @@ -0,0 +1,14 @@ +{ + "query": { + "format": [ + { + "output": "mp4", + "size": "1280x720", + "profile": "main", + "bitrate": 3800, + "video_codec": "libx264" + } + ], + "source": "tus:##TUS_FILE_UUID##" + } +} \ No newline at end of file diff --git a/Qencode.Api.CSharp.Client/Classes/TranscodingTask.cs b/Qencode.Api.CSharp.Client/Classes/TranscodingTask.cs index 5e03d6d..ce64ace 100644 --- a/Qencode.Api.CSharp.Client/Classes/TranscodingTask.cs +++ b/Qencode.Api.CSharp.Client/Classes/TranscodingTask.cs @@ -9,231 +9,243 @@ namespace Qencode.Api.CSharp.Client.Classes { - public class TranscodingTask - { - private QencodeApiClient api; - - private string taskToken; - /// - public string TaskToken - { - get { return taskToken; } - } - - private string statusUrl; - /// - public string StatusUrl - { - get { return statusUrl; } - } - - private TranscodingTaskStatus lastStatus; - /// - public TranscodingTaskStatus LastStatus - { - get { return lastStatus; } - } - - /// - /// A starting time in seconds in original video to make clip from - /// - public double StartTime { get; set; } - - /// - /// Duration from specified start time in original video, seconds - /// - public double Duration { get; set; } - - /// - /// Output path variables map (used to set transcoding profile output path placeholder values)s - /// - public Dictionary OutputPathVariables { get; } - - - /// Creates new transcoding task - /// a reference to QencodeApiClient object - /// transcoding task token - public TranscodingTask(QencodeApiClient api, string taskToken) - { - this.api = api; - this.taskToken = taskToken; - this.statusUrl = null; - OutputPathVariables = new Dictionary(); - } - - /// Starts transcoding job using specified transcoding profile list - /// Array of transcoding profile identifiers - /// a link to input video or TUS uri - /// Transfer method identifier - /// Any string data of 1000 characters max length. E.g. you could pass id of your site user uploading the video or any json object. - public StartEncodeResponse Start(string[] transcodingProfiles, string uri, string transferMethod = null, string payload = null) - { - var profiles = String.Join(",", transcodingProfiles); - return Start(profiles, uri, transferMethod, payload); - } - - public RunWorkerCompletedEventHandler TaskCompleted; - public ProgressChangedEventHandler ProgressChanged; - - /// Starts transcoding job using specified transcoding profile or list of profiles - /// One or several transcoding profile identifiers (as comma-separated string) - /// a link to input video or TUS uri - /// Transfer method identifier - /// Any string data of 1000 characters max length. E.g. you could pass id of your site user uploading the video or any json object. - public StartEncodeResponse Start(string transcodingProfile, string uri, string transferMethod = null, string payload = null) - { - var parameters = new Dictionary() { - { "task_token", taskToken }, - { "uri", uri }, - { "profiles", transcodingProfile } - }; - - if (transferMethod != null) - { - parameters.Add("transfer_method", transferMethod); - } - - if (payload != null) - { - parameters.Add("payload", payload); - } - - var numberFormat = new NumberFormatInfo(); - numberFormat.NumberDecimalSeparator = "."; - if (StartTime > 0) - { - parameters.Add("start_time", StartTime.ToString("0.####", numberFormat)); - } - - if (Duration > 0) - { - parameters.Add("duration", Duration.ToString("0.####", numberFormat)); - } - - if (OutputPathVariables.Count > 0) - { - var outputPathVars = JsonConvert.SerializeObject(OutputPathVariables, - Formatting.None, - new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore }); - parameters.Add("output_path_variables", outputPathVars); - } - - var response = api.Request("start_encode", parameters) as StartEncodeResponse; - this.statusUrl = response.status_url; - PollStatus(); - return response; - } - - private void PollStatus() - { - if (TaskCompleted != null) - { - var bw = new BackgroundWorker(); - bw.WorkerReportsProgress = true; - bw.DoWork += new DoWorkEventHandler( - delegate (object o, DoWorkEventArgs args) - { - BackgroundWorker b = o as BackgroundWorker; - int percent = 0; - do - { - Thread.Sleep(checkStatusInterval); - GetStatus(); - int newPercent = Convert.ToInt32(lastStatus.percent); - if (newPercent > percent) - { - percent = newPercent; - b.ReportProgress(percent, lastStatus); - } - } while (lastStatus.status != "completed" && lastStatus.error != 1); - args.Result = lastStatus; - }); - - if (ProgressChanged != null) - { - bw.ProgressChanged += ProgressChanged; - } - - bw.RunWorkerCompleted += TaskCompleted; - bw.RunWorkerAsync(); - } - } - - private int checkStatusInterval = 5000; - public int CheckStatusInterval - { - get { return checkStatusInterval; } - set - { - if (value < 1000) - { - value = 1000; - } - checkStatusInterval = value; - } - } - - //TODO: implement startCustom transcoding method - /** - - * Starts transcoding job using custom params - - * @param CustomTranscodingParams $task_params - - * @param string $payload Any string data of 1000 characters max length. E.g. you could pass id of your site user uploading the video or any json object. - - * @return array start_encode API method response - - */ - - public StartEncodeResponse StartCustom(CustomTranscodingParams taskParams, string payload = null) - { - var query = new Dictionary() { { "query", taskParams } }; - var query_json = JsonConvert.SerializeObject(query, - Formatting.None, - new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore}); - - var parameters = new Dictionary - { - {"task_token", taskToken }, - {"query", query_json } - }; - if (payload != null) - { - parameters.Add("payload", payload); - } - - return _do_request("start_encode2", parameters); - } - - private StartEncodeResponse _do_request(string methodName, Dictionary parameters) - { - var response = api.Request(methodName, parameters) as StartEncodeResponse; - this.statusUrl = response.status_url; - PollStatus(); - return response; - } - - /// - /// Gets current task status from qencode service - /// - public TranscodingTaskStatus GetStatus() - { - var parameters = new Dictionary() { - { "task_tokens[]", this.taskToken } - }; - - //TODO: fallback to /v1/status - - var response = api.Request(statusUrl, parameters) as StatusResponse; - lastStatus = response.statuses[this.taskToken]; - return lastStatus; - } - } + public class TranscodingTask + { + private QencodeApiClient api; + + private string taskToken; + /// + public string TaskToken + { + get { return taskToken; } + } + + private string statusUrl; + /// + public string StatusUrl + { + get { return statusUrl; } + } + + private string uploadUrl; + /// + public string UploadUrl + { + get { return uploadUrl; } + } + + + private TranscodingTaskStatus lastStatus; + /// + public TranscodingTaskStatus LastStatus + { + get { return lastStatus; } + } + + /// + /// A starting time in seconds in original video to make clip from + /// + public double StartTime { get; set; } + + /// + /// Duration from specified start time in original video, seconds + /// + public double Duration { get; set; } + + /// + /// Output path variables map (used to set transcoding profile output path placeholder values)s + /// + public Dictionary OutputPathVariables { get; } + + + /// Creates new transcoding task + /// a reference to QencodeApiClient object + /// transcoding task token + /// url for direct video uplload + public TranscodingTask(QencodeApiClient api, string taskToken, string uploadUrl = null) + { + this.api = api; + this.taskToken = taskToken; + this.statusUrl = null; + this.uploadUrl = uploadUrl; + OutputPathVariables = new Dictionary(); + } + + /// Starts transcoding job using specified transcoding profile list + /// Array of transcoding profile identifiers + /// a link to input video or TUS uri + /// Transfer method identifier + /// Any string data of 1000 characters max length. E.g. you could pass id of your site user uploading the video or any json object. + public StartEncodeResponse Start(string[] transcodingProfiles, string uri, string transferMethod = null, string payload = null) + { + var profiles = String.Join(",", transcodingProfiles); + return Start(profiles, uri, transferMethod, payload); + } + + public RunWorkerCompletedEventHandler TaskCompleted; + public ProgressChangedEventHandler ProgressChanged; + + /// Starts transcoding job using specified transcoding profile or list of profiles + /// One or several transcoding profile identifiers (as comma-separated string) + /// a link to input video or TUS uri + /// Transfer method identifier + /// Any string data of 1000 characters max length. E.g. you could pass id of your site user uploading the video or any json object. + public StartEncodeResponse Start(string transcodingProfile, string uri, string transferMethod = null, string payload = null) + { + var parameters = new Dictionary() { + { "task_token", taskToken }, + { "uri", uri }, + { "profiles", transcodingProfile } + }; + + if (transferMethod != null) + { + parameters.Add("transfer_method", transferMethod); + } + + if (payload != null) + { + parameters.Add("payload", payload); + } + + var numberFormat = new NumberFormatInfo(); + numberFormat.NumberDecimalSeparator = "."; + if (StartTime > 0) + { + parameters.Add("start_time", StartTime.ToString("0.####", numberFormat)); + } + + if (Duration > 0) + { + parameters.Add("duration", Duration.ToString("0.####", numberFormat)); + } + + if (OutputPathVariables.Count > 0) + { + var outputPathVars = JsonConvert.SerializeObject(OutputPathVariables, + Formatting.None, + new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore }); + parameters.Add("output_path_variables", outputPathVars); + } + + var response = api.Request("start_encode", parameters) as StartEncodeResponse; + this.statusUrl = response.status_url; + PollStatus(); + return response; + } + + private void PollStatus() + { + if (TaskCompleted != null) + { + var bw = new BackgroundWorker(); + bw.WorkerReportsProgress = true; + bw.DoWork += new DoWorkEventHandler( + delegate (object o, DoWorkEventArgs args) + { + BackgroundWorker b = o as BackgroundWorker; + int percent = 0; + do + { + Thread.Sleep(checkStatusInterval); + GetStatus(); + int newPercent = Convert.ToInt32(lastStatus.percent); + if (newPercent > percent) + { + percent = newPercent; + b.ReportProgress(percent, lastStatus); + } + } while (lastStatus.status != "completed" && lastStatus.error != 1); + args.Result = lastStatus; + }); + + if (ProgressChanged != null) + { + bw.ProgressChanged += ProgressChanged; + } + + bw.RunWorkerCompleted += TaskCompleted; + bw.RunWorkerAsync(); + } + } + + private int checkStatusInterval = 5000; + public int CheckStatusInterval + { + get { return checkStatusInterval; } + set + { + if (value < 1000) + { + value = 1000; + } + checkStatusInterval = value; + } + } + + //TODO: implement startCustom transcoding method + /** + + * Starts transcoding job using custom params + + * @param CustomTranscodingParams $task_params + + * @param string $payload Any string data of 1000 characters max length. E.g. you could pass id of your site user uploading the video or any json object. + + * @return array start_encode API method response + + */ + + public StartEncodeResponse StartCustom(CustomTranscodingParams taskParams, string payload = null) + { + var query = new Dictionary() { { "query", taskParams } }; + var query_json = JsonConvert.SerializeObject(query, + Formatting.None, + new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore }); + + var parameters = new Dictionary + { + {"task_token", taskToken }, + {"query", query_json } + }; + if (payload != null) + { + parameters.Add("payload", payload); + } + + return _do_request("start_encode2", parameters); + } + + private StartEncodeResponse _do_request(string methodName, Dictionary parameters) + { + var response = api.Request(methodName, parameters) as StartEncodeResponse; + this.statusUrl = response.status_url; + PollStatus(); + return response; + } + + /// + /// Gets current task status from qencode service + /// + public TranscodingTaskStatus GetStatus() + { + var parameters = new Dictionary() { + { "task_tokens[]", this.taskToken } + }; + + //TODO: fallback to /v1/status + + var response = api.Request(statusUrl, parameters) as StatusResponse; + lastStatus = response.statuses[this.taskToken]; + return lastStatus; + } + } } diff --git a/Qencode.Api.CSharp.Client/Classes/UploadTask.cs b/Qencode.Api.CSharp.Client/Classes/UploadTask.cs new file mode 100644 index 0000000..d893604 --- /dev/null +++ b/Qencode.Api.CSharp.Client/Classes/UploadTask.cs @@ -0,0 +1,59 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace Qencode.Api.CSharp.Client.Classes +{ + /// + /// This class describe a TUS upload task + /// + public class UploadTask + { + + private QencodeApiClient api; + + private string taskToken; + /// + public string TaskToken + { + get { return taskToken; } + } + + /// + /// Output path variables map (used to set transcoding profile output path placeholder values)s + /// + public Dictionary OutputPathVariables { get; } + + public UploadTask(QencodeApiClient api, string taskToken, string upload_location, string file_fullName, long file_size, string file_sha1) + { + this.api = api; + this.taskToken = taskToken; + this.upload_location = upload_location; + this.file_fullName = file_fullName; + this.file_size = file_size; + this.file_sha1 = file_sha1; + OutputPathVariables = new Dictionary(); + } + + public int error { get; set; } + /// + /// Upload url location for specified file. + /// + /// + /// Location: https://storage.qencode.com/v1/upload_file/6c6a9b0a7d23cc9555d460269aa9ed56/fa6ca4b8f06f42daa5b0da04cc461dcb + /// (where fa6ca4b8f06f42daa5b0da04cc461dcb is file_uuid) + /// + public string upload_location { get; private set; } + public string file_fullName { get; private set; } + public long file_size { get; private set; } + public string file_sha1 { get; private set; } + /// + /// Error description + /// + public string error_description { get; set; } + + public string payload; + } +} diff --git a/Qencode.Api.CSharp.Client/QencodeApiClient.cs b/Qencode.Api.CSharp.Client/QencodeApiClient.cs index 04f5a31..4b0261c 100644 --- a/Qencode.Api.CSharp.Client/QencodeApiClient.cs +++ b/Qencode.Api.CSharp.Client/QencodeApiClient.cs @@ -217,7 +217,7 @@ public TranscodingTask CreateTask() { "token", accessToken } }) as CreateTaskResponse; - return new TranscodingTask(this, response.task_token); + return new TranscodingTask(this, response.task_token, response.upload_url); } } } diff --git a/Qencode.Api.CSharp.Client/Responses/CreateTaskResponse.cs b/Qencode.Api.CSharp.Client/Responses/CreateTaskResponse.cs index 4b4e385..93731b7 100644 --- a/Qencode.Api.CSharp.Client/Responses/CreateTaskResponse.cs +++ b/Qencode.Api.CSharp.Client/Responses/CreateTaskResponse.cs @@ -9,8 +9,7 @@ public class CreateTaskResponse : QencodeApiResponse public string task_token { get; set; } /// - /// Url for direct video upload using tus.io protocol - /// (currently not supported with this library) + /// Url for direct video upload using tus.io protocol /// public string upload_url { get; set; } } diff --git a/Qencode.Api.CSharp.Client/Responses/CreateUploadTaskResponse.cs b/Qencode.Api.CSharp.Client/Responses/CreateUploadTaskResponse.cs new file mode 100644 index 0000000..4e95394 --- /dev/null +++ b/Qencode.Api.CSharp.Client/Responses/CreateUploadTaskResponse.cs @@ -0,0 +1,17 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace Qencode.Api.CSharp.Client.Responses +{ + public class CreateUploadTaskResponse : QencodeApiResponse + { + /// + /// Upload location for this direct video upload + /// + /// + /// Location: https://storage.qencode.com/v1/upload_file/6c6a9b0a7d23cc9555d460269aa9ed56/fa6ca4b8f06f42daa5b0da04cc461dcb + /// + public string Location { get; set; } + } +} diff --git a/Qencode.Api.CSharp.sln b/Qencode.Api.CSharp.sln index 7f4fe42..93dfc7d 100644 --- a/Qencode.Api.CSharp.sln +++ b/Qencode.Api.CSharp.sln @@ -1,7 +1,7 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio 15 -VisualStudioVersion = 15.0.27130.2027 +# Visual Studio Version 16 +VisualStudioVersion = 16.0.29806.167 MinimumVisualStudioVersion = 10.0.40219.1 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Qencode.Api.CSharp.Client", "Qencode.Api.CSharp.Client\Qencode.Api.CSharp.Client.csproj", "{D961400F-9432-4078-A664-FFF8134FE133}" EndProject @@ -11,6 +11,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ProfileEncodingExample", "P EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CustomEncodingJsonExample", "CustomEncodingJsonExample\CustomEncodingJsonExample.csproj", "{15CA760D-18F6-4C77-9E10-28FC35B80996}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CustomEncodingJsonTUSUploadExample", "CustomEncodingJsonTUSUploadExample\CustomEncodingJsonTUSUploadExample.csproj", "{8B2299C9-6239-4AF5-83A9-AF1150D5BE67}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -33,6 +35,10 @@ Global {15CA760D-18F6-4C77-9E10-28FC35B80996}.Debug|Any CPU.Build.0 = Debug|Any CPU {15CA760D-18F6-4C77-9E10-28FC35B80996}.Release|Any CPU.ActiveCfg = Release|Any CPU {15CA760D-18F6-4C77-9E10-28FC35B80996}.Release|Any CPU.Build.0 = Release|Any CPU + {8B2299C9-6239-4AF5-83A9-AF1150D5BE67}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8B2299C9-6239-4AF5-83A9-AF1150D5BE67}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8B2299C9-6239-4AF5-83A9-AF1150D5BE67}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8B2299C9-6239-4AF5-83A9-AF1150D5BE67}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE