VideoRecordingSession.cpp
1 //============================================================================== 2 // 3 // Zoomit 4 // Sysinternals - www.sysinternals.com 5 // 6 // Video capture code derived from https://github.com/robmikh/capturevideosample 7 // 8 //============================================================================== 9 #include "pch.h" 10 #include "VideoRecordingSession.h" 11 #include "CaptureFrameWait.h" 12 #include "Utility.h" 13 #include <winrt/Windows.Graphics.Imaging.h> 14 #include <winrt/Windows.Media.h> 15 #include <cstdlib> 16 #include <filesystem> 17 #include <shlwapi.h> // For SHCreateStreamOnFileEx 18 #include <mmsystem.h> // For timeBeginPeriod/timeEndPeriod 19 20 #pragma comment(lib, "shlwapi.lib") 21 #pragma comment(lib, "winmm.lib") 22 23 extern DWORD g_RecordScaling; 24 extern DWORD g_TrimDialogWidth; 25 extern DWORD g_TrimDialogHeight; 26 extern DWORD g_TrimDialogVolume; 27 extern class ClassRegistry reg; 28 extern REG_SETTING RegSettings[]; 29 extern HINSTANCE g_hInstance; 30 31 HWND hDlgTrimDialog = nullptr; 32 33 namespace winrt 34 { 35 using namespace Windows::Foundation; 36 using namespace Windows::Graphics; 37 using namespace Windows::Graphics::Capture; 38 using namespace Windows::Graphics::DirectX; 39 using namespace Windows::Graphics::DirectX::Direct3D11; 40 using namespace Windows::Graphics::Imaging; 41 using namespace Windows::Storage; 42 using namespace Windows::UI::Composition; 43 using namespace Windows::Media::Core; 44 using namespace Windows::Media::Transcoding; 45 using namespace Windows::Media::MediaProperties; 46 using namespace Windows::Media::Editing; 47 using namespace Windows::Media::Playback; 48 using namespace Windows::Storage::FileProperties; 49 } 50 51 namespace util 52 { 53 using namespace robmikh::common::uwp; 54 } 55 56 const float CLEAR_COLOR[] = { 0.0f, 0.0f, 0.0f, 1.0f }; 57 constexpr UINT kGifDefaultDelayCs = 10; // 100ms (~10 FPS) when metadata delay is missing 58 constexpr UINT kGifMinDelayCs = 2; // 20ms minimum; browsers treat <2cs as 10cs (100ms) 59 constexpr UINT kGifBrowserFixupThreshold = 2; // Delays < this are treated as 10cs by browsers 60 constexpr UINT kGifMaxPreviewDimension = 1280; // cap decoded GIF preview size to keep playback smooth 61 62 int32_t EnsureEven(int32_t value) 63 { 64 if (value % 2 == 0) 65 { 66 return value; 67 } 68 else 69 { 70 return value + 1; 71 } 72 } 73 74 static bool IsGifPath(const std::wstring& path) 75 { 76 try 77 { 78 const auto ext = std::filesystem::path(path).extension().wstring(); 79 return _wcsicmp(ext.c_str(), L".gif") == 0; 80 } 81 catch (...) 82 { 83 return false; 84 } 85 } 86 87 static void CleanupGifFrames(VideoRecordingSession::TrimDialogData* pData) 88 { 89 if (!pData) 90 { 91 return; 92 } 93 94 for (auto& frame : pData->gifFrames) 95 { 96 if (frame.hBitmap) 97 { 98 DeleteObject(frame.hBitmap); 99 frame.hBitmap = nullptr; 100 } 101 } 102 pData->gifFrames.clear(); 103 } 104 105 static size_t FindGifFrameIndex(const std::vector<VideoRecordingSession::TrimDialogData::GifFrame>& frames, int64_t ticks) 106 { 107 if (frames.empty()) 108 { 109 return 0; 110 } 111 112 // Linear scan is fine for typical GIF counts; keeps logic simple and predictable 113 for (size_t i = 0; i < frames.size(); ++i) 114 { 115 const auto start = frames[i].start.count(); 116 const auto end = start + frames[i].duration.count(); 117 if (ticks >= start && ticks < end) 118 { 119 return i; 120 } 121 } 122 123 // If we fall through, clamp to last frame 124 return frames.size() - 1; 125 } 126 127 static bool LoadGifFrames(const std::wstring& gifPath, VideoRecordingSession::TrimDialogData* pData) 128 { 129 OutputDebugStringW((L"[GIF Trim] LoadGifFrames called for: " + gifPath + L"\n").c_str()); 130 131 if (!pData) 132 { 133 OutputDebugStringW(L"[GIF Trim] pData is null\n"); 134 return false; 135 } 136 137 try 138 { 139 CleanupGifFrames(pData); 140 141 winrt::com_ptr<IWICImagingFactory> factory; 142 HRESULT hrFactory = CoCreateInstance(CLSID_WICImagingFactory, nullptr, CLSCTX_INPROC_SERVER, IID_PPV_ARGS(factory.put())); 143 if (FAILED(hrFactory)) 144 { 145 OutputDebugStringW((L"[GIF Trim] CoCreateInstance WICImagingFactory failed hr=0x" + std::to_wstring(hrFactory) + L"\n").c_str()); 146 return false; 147 } 148 149 winrt::com_ptr<IWICBitmapDecoder> decoder; 150 151 auto logHr = [&](const wchar_t* step, HRESULT hr) 152 { 153 wchar_t buf[512]{}; 154 swprintf_s(buf, L"[GIF Trim] %s failed hr=0x%08X path=%s\n", step, static_cast<unsigned>(hr), gifPath.c_str()); 155 OutputDebugStringW(buf); 156 }; 157 158 auto tryCreateDecoder = [&]() -> bool 159 { 160 OutputDebugStringW(L"[GIF Trim] Trying CreateDecoderFromFilename...\n"); 161 HRESULT hr = factory->CreateDecoderFromFilename(gifPath.c_str(), nullptr, GENERIC_READ, WICDecodeMetadataCacheOnLoad, decoder.put()); 162 if (SUCCEEDED(hr)) 163 { 164 OutputDebugStringW(L"[GIF Trim] CreateDecoderFromFilename succeeded\n"); 165 return true; 166 } 167 168 logHr(L"CreateDecoderFromFilename", hr); 169 170 // Fallback: try opening with FILE_SHARE_READ | FILE_SHARE_WRITE to handle locked files 171 OutputDebugStringW(L"[GIF Trim] Trying CreateStreamOnFile fallback...\n"); 172 HANDLE hFile = CreateFileW(gifPath.c_str(), GENERIC_READ, FILE_SHARE_READ | FILE_SHARE_WRITE, nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr); 173 if (hFile != INVALID_HANDLE_VALUE) 174 { 175 winrt::com_ptr<IStream> fileStream; 176 // Create an IStream over the file handle using SHCreateStreamOnFileEx 177 CloseHandle(hFile); 178 hr = SHCreateStreamOnFileEx(gifPath.c_str(), STGM_READ | STGM_SHARE_DENY_NONE, 0, FALSE, nullptr, fileStream.put()); 179 if (SUCCEEDED(hr) && fileStream) 180 { 181 hr = factory->CreateDecoderFromStream(fileStream.get(), nullptr, WICDecodeMetadataCacheOnLoad, decoder.put()); 182 if (SUCCEEDED(hr)) 183 { 184 OutputDebugStringW(L"[GIF Trim] CreateDecoderFromStream (SHCreateStreamOnFileEx) succeeded\n"); 185 return true; 186 } 187 logHr(L"CreateDecoderFromStream(SHCreateStreamOnFileEx)", hr); 188 } 189 else 190 { 191 logHr(L"SHCreateStreamOnFileEx", hr); 192 } 193 } 194 195 return false; 196 }; 197 198 auto tryCopyAndDecode = [&]() -> bool 199 { 200 OutputDebugStringW(L"[GIF Trim] Trying temp file copy fallback...\n"); 201 // Copy file to temp using Win32 APIs (no WinRT async) 202 wchar_t tempDir[MAX_PATH]; 203 if (GetTempPathW(MAX_PATH, tempDir) == 0) 204 { 205 return false; 206 } 207 208 std::wstring tempPath = std::wstring(tempDir) + L"ZoomIt\\"; 209 CreateDirectoryW(tempPath.c_str(), nullptr); 210 211 std::wstring tempName = L"gif_trim_cache_" + std::to_wstring(GetTickCount64()) + L".gif"; 212 tempPath += tempName; 213 214 if (!CopyFileW(gifPath.c_str(), tempPath.c_str(), FALSE)) 215 { 216 logHr(L"CopyFileW", HRESULT_FROM_WIN32(GetLastError())); 217 return false; 218 } 219 220 HRESULT hr = factory->CreateDecoderFromFilename(tempPath.c_str(), nullptr, GENERIC_READ, WICDecodeMetadataCacheOnLoad, decoder.put()); 221 if (SUCCEEDED(hr)) 222 { 223 OutputDebugStringW(L"[GIF Trim] CreateDecoderFromFilename(temp copy) succeeded\n"); 224 return true; 225 } 226 logHr(L"CreateDecoderFromFilename(temp copy)", hr); 227 228 // Clean up temp file on failure 229 DeleteFileW(tempPath.c_str()); 230 return false; 231 }; 232 233 if (!tryCreateDecoder()) 234 { 235 if (!tryCopyAndDecode()) 236 { 237 return false; 238 } 239 } 240 241 UINT frameCount = 0; 242 if (FAILED(decoder->GetFrameCount(&frameCount)) || frameCount == 0) 243 { 244 return false; 245 } 246 247 int64_t cumulativeTicks = 0; 248 UINT frameWidth = 0; 249 UINT frameHeight = 0; 250 251 for (UINT i = 0; i < frameCount; ++i) 252 { 253 winrt::com_ptr<IWICBitmapFrameDecode> frame; 254 if (FAILED(decoder->GetFrame(i, frame.put()))) 255 { 256 continue; 257 } 258 259 if (i == 0) 260 { 261 frame->GetSize(&frameWidth, &frameHeight); 262 } 263 264 UINT delayCs = kGifDefaultDelayCs; 265 try 266 { 267 winrt::com_ptr<IWICMetadataQueryReader> metadata; 268 if (SUCCEEDED(frame->GetMetadataQueryReader(metadata.put())) && metadata) 269 { 270 PROPVARIANT prop{}; 271 PropVariantInit(&prop); 272 if (SUCCEEDED(metadata->GetMetadataByName(L"/grctlext/Delay", &prop))) 273 { 274 if (prop.vt == VT_UI2) 275 { 276 delayCs = prop.uiVal; 277 } 278 else if (prop.vt == VT_UI1) 279 { 280 delayCs = prop.bVal; 281 } 282 } 283 PropVariantClear(&prop); 284 } 285 } 286 catch (...) 287 { 288 // Keep fallback delay 289 } 290 291 if (delayCs == 0) 292 { 293 // GIF spec: delay of 0 means "as fast as possible"; browsers use ~10ms 294 delayCs = kGifDefaultDelayCs; 295 } 296 else if (delayCs < kGifBrowserFixupThreshold) 297 { 298 // Browsers treat delays < 2cs (20ms) as 10cs (100ms) to prevent CPU-hogging GIFs 299 delayCs = kGifDefaultDelayCs; 300 } 301 302 // Log the first few frame delays for debugging 303 if (i < 3) 304 { 305 OutputDebugStringW((L"[GIF Trim] Frame " + std::to_wstring(i) + L" delay: " + std::to_wstring(delayCs) + L" cs (" + std::to_wstring(delayCs * 10) + L" ms)\n").c_str()); 306 } 307 308 // Respect a max preview size to avoid huge allocations on large GIFs 309 UINT targetWidth = frameWidth; 310 UINT targetHeight = frameHeight; 311 if (targetWidth > kGifMaxPreviewDimension || targetHeight > kGifMaxPreviewDimension) 312 { 313 const double scaleX = static_cast<double>(kGifMaxPreviewDimension) / static_cast<double>(targetWidth); 314 const double scaleY = static_cast<double>(kGifMaxPreviewDimension) / static_cast<double>(targetHeight); 315 const double scale = (std::min)(scaleX, scaleY); 316 targetWidth = static_cast<UINT>(std::lround(static_cast<double>(targetWidth) * scale)); 317 targetHeight = static_cast<UINT>(std::lround(static_cast<double>(targetHeight) * scale)); 318 targetWidth = (std::max)(1u, targetWidth); 319 targetHeight = (std::max)(1u, targetHeight); 320 } 321 322 winrt::com_ptr<IWICBitmapSource> source = frame; 323 if (targetWidth != frameWidth || targetHeight != frameHeight) 324 { 325 winrt::com_ptr<IWICBitmapScaler> scaler; 326 if (SUCCEEDED(factory->CreateBitmapScaler(scaler.put()))) 327 { 328 if (SUCCEEDED(scaler->Initialize(frame.get(), targetWidth, targetHeight, WICBitmapInterpolationModeFant))) 329 { 330 source = scaler; 331 } 332 } 333 } 334 335 winrt::com_ptr<IWICFormatConverter> converter; 336 if (FAILED(factory->CreateFormatConverter(converter.put()))) 337 { 338 continue; 339 } 340 341 if (FAILED(converter->Initialize(source.get(), GUID_WICPixelFormat32bppPBGRA, WICBitmapDitherTypeNone, nullptr, 0.0, WICBitmapPaletteTypeCustom))) 342 { 343 continue; 344 } 345 346 UINT convertedWidth = 0; 347 UINT convertedHeight = 0; 348 converter->GetSize(&convertedWidth, &convertedHeight); 349 if (convertedWidth == 0 || convertedHeight == 0) 350 { 351 continue; 352 } 353 354 const UINT stride = convertedWidth * 4; 355 std::vector<BYTE> buffer(static_cast<size_t>(stride) * convertedHeight); 356 if (FAILED(converter->CopyPixels(nullptr, stride, static_cast<UINT>(buffer.size()), buffer.data()))) 357 { 358 continue; 359 } 360 361 BITMAPINFO bmi{}; 362 bmi.bmiHeader.biSize = sizeof(BITMAPINFOHEADER); 363 bmi.bmiHeader.biWidth = static_cast<LONG>(convertedWidth); 364 bmi.bmiHeader.biHeight = -static_cast<LONG>(convertedHeight); 365 bmi.bmiHeader.biPlanes = 1; 366 bmi.bmiHeader.biBitCount = 32; 367 bmi.bmiHeader.biCompression = BI_RGB; 368 369 void* bits = nullptr; 370 HDC hdcScreen = GetDC(nullptr); 371 HBITMAP hBitmap = CreateDIBSection(hdcScreen, &bmi, DIB_RGB_COLORS, &bits, nullptr, 0); 372 ReleaseDC(nullptr, hdcScreen); 373 374 if (!hBitmap || !bits) 375 { 376 if (hBitmap) 377 { 378 DeleteObject(hBitmap); 379 } 380 continue; 381 } 382 383 for (UINT row = 0; row < convertedHeight; ++row) 384 { 385 memcpy(static_cast<BYTE*>(bits) + static_cast<size_t>(row) * stride, 386 buffer.data() + static_cast<size_t>(row) * stride, 387 stride); 388 } 389 390 VideoRecordingSession::TrimDialogData::GifFrame gifFrame; 391 gifFrame.hBitmap = hBitmap; 392 gifFrame.start = winrt::TimeSpan{ cumulativeTicks }; 393 gifFrame.duration = winrt::TimeSpan{ static_cast<int64_t>(delayCs) * 100'000 }; // centiseconds to 100ns 394 gifFrame.width = convertedWidth; 395 gifFrame.height = convertedHeight; 396 397 cumulativeTicks += gifFrame.duration.count(); 398 pData->gifFrames.push_back(gifFrame); 399 } 400 401 if (pData->gifFrames.empty()) 402 { 403 OutputDebugStringW(L"[GIF Trim] No frames loaded\n"); 404 return false; 405 } 406 407 const auto& lastFrame = pData->gifFrames.back(); 408 pData->videoDuration = winrt::TimeSpan{ lastFrame.start.count() + lastFrame.duration.count() }; 409 pData->trimEnd = pData->videoDuration; 410 pData->gifFramesLoaded = true; 411 pData->gifLastFrameIndex = 0; 412 413 OutputDebugStringW((L"[GIF Trim] Successfully loaded " + std::to_wstring(pData->gifFrames.size()) + L" frames\n").c_str()); 414 return true; 415 } 416 catch (const winrt::hresult_error& e) 417 { 418 OutputDebugStringW((L"[GIF Trim] Exception in LoadGifFrames: " + e.message() + L"\n").c_str()); 419 return false; 420 } 421 catch (const std::exception& e) 422 { 423 OutputDebugStringA("[GIF Trim] std::exception in LoadGifFrames: "); 424 OutputDebugStringA(e.what()); 425 OutputDebugStringA("\n"); 426 return false; 427 } 428 catch (...) 429 { 430 OutputDebugStringW(L"[GIF Trim] Unknown exception in LoadGifFrames\n"); 431 return false; 432 } 433 } 434 435 namespace 436 { 437 struct __declspec(uuid("5b0d3235-4dba-4d44-8657-1f1d0f83e9a3")) IMemoryBufferByteAccess : IUnknown 438 { 439 virtual HRESULT STDMETHODCALLTYPE GetBuffer(BYTE** value, UINT32* capacity) = 0; 440 }; 441 442 constexpr int kTimelinePadding = 12; 443 constexpr int kTimelineTrackHeight = 24; 444 constexpr int kTimelineTrackTopOffset = 18; 445 constexpr int kTimelineHandleHalfWidth = 5; 446 constexpr int kTimelineHandleHeight = 40; 447 constexpr int kTimelineHandleHitRadius = 18; 448 constexpr int64_t kJogStepTicks = 20'000'000; // 2 seconds (or 1s for short videos) 449 constexpr int64_t kPreviewMinDeltaTicks = 2'000'000; // 20ms between thumbnails while playing 450 constexpr UINT32 kPreviewRequestWidthPlaying = 320; 451 constexpr UINT32 kPreviewRequestHeightPlaying = 180; 452 constexpr int64_t kTicksPerMicrosecond = 10; // 100ns units per microsecond 453 constexpr int64_t kPlaybackSyncIntervalMs = 40; // refresh baseline frequently for smoother prediction 454 constexpr int64_t kPlaybackDriftCheckMs = 40; // sample MediaPlayer at least every 40ms (overridden to every tick currently) 455 constexpr int64_t kPlaybackDriftSnapTicks = 2'000'000; // snap if drift exceeds 200ms 456 constexpr int kPlaybackDriftBlendNumerator = 1; // blend 20% toward real position 457 constexpr int kPlaybackDriftBlendDenominator = 5; 458 constexpr UINT WMU_PREVIEW_READY = WM_USER + 1; 459 constexpr UINT WMU_PREVIEW_SCHEDULED = WM_USER + 2; 460 constexpr UINT WMU_DURATION_CHANGED = WM_USER + 3; 461 constexpr UINT WMU_PLAYBACK_POSITION = WM_USER + 4; 462 constexpr UINT WMU_PLAYBACK_STOP = WM_USER + 5; 463 constexpr UINT_PTR kPreviewDebounceTimerId = 100; 464 constexpr UINT kPreviewDebounceDelayMs = 50; // Debounce delay for preview updates during dragging 465 466 std::atomic<int> g_highResTimerRefs{ 0 }; 467 468 void AcquireHighResTimer() 469 { 470 if (g_highResTimerRefs.fetch_add(1, std::memory_order_relaxed) == 0) 471 { 472 timeBeginPeriod(1); 473 } 474 } 475 476 void ReleaseHighResTimer() 477 { 478 const int prev = g_highResTimerRefs.fetch_sub(1, std::memory_order_relaxed); 479 if (prev == 1) 480 { 481 timeEndPeriod(1); 482 } 483 } 484 485 bool EnsurePlaybackDevice(VideoRecordingSession::TrimDialogData* pData) 486 { 487 if (!pData) 488 { 489 return false; 490 } 491 492 if (pData->previewD3DDevice && pData->previewD3DContext) 493 { 494 return true; 495 } 496 497 UINT creationFlags = D3D11_CREATE_DEVICE_BGRA_SUPPORT; 498 #if defined(_DEBUG) 499 creationFlags |= D3D11_CREATE_DEVICE_DEBUG; 500 #endif 501 502 D3D_FEATURE_LEVEL levels[] = { D3D_FEATURE_LEVEL_11_1, D3D_FEATURE_LEVEL_11_0, D3D_FEATURE_LEVEL_10_1, D3D_FEATURE_LEVEL_10_0 }; 503 D3D_FEATURE_LEVEL levelCreated = D3D_FEATURE_LEVEL_11_0; 504 505 winrt::com_ptr<ID3D11Device> device; 506 winrt::com_ptr<ID3D11DeviceContext> context; 507 if (SUCCEEDED(D3D11CreateDevice( 508 nullptr, 509 D3D_DRIVER_TYPE_HARDWARE, 510 nullptr, 511 creationFlags, 512 levels, 513 ARRAYSIZE(levels), 514 D3D11_SDK_VERSION, 515 device.put(), 516 &levelCreated, 517 context.put()))) 518 { 519 pData->previewD3DDevice = device; 520 pData->previewD3DContext = context; 521 return true; 522 } 523 524 return false; 525 } 526 527 bool EnsureFrameTextures(VideoRecordingSession::TrimDialogData* pData, UINT width, UINT height) 528 { 529 if (!pData || !pData->previewD3DDevice) 530 { 531 return false; 532 } 533 534 auto recreate = [&]() 535 { 536 pData->previewFrameTexture = nullptr; 537 pData->previewFrameStaging = nullptr; 538 539 D3D11_TEXTURE2D_DESC desc{}; 540 desc.Width = width; 541 desc.Height = height; 542 desc.MipLevels = 1; 543 desc.ArraySize = 1; 544 desc.Format = DXGI_FORMAT_B8G8R8A8_UNORM; 545 desc.SampleDesc.Count = 1; 546 desc.Usage = D3D11_USAGE_DEFAULT; 547 desc.BindFlags = D3D11_BIND_RENDER_TARGET | D3D11_BIND_SHADER_RESOURCE; 548 549 winrt::com_ptr<ID3D11Texture2D> frameTex; 550 if (FAILED(pData->previewD3DDevice->CreateTexture2D(&desc, nullptr, frameTex.put()))) 551 { 552 return false; 553 } 554 555 desc.BindFlags = 0; 556 desc.CPUAccessFlags = D3D11_CPU_ACCESS_READ; 557 desc.Usage = D3D11_USAGE_STAGING; 558 559 winrt::com_ptr<ID3D11Texture2D> staging; 560 if (FAILED(pData->previewD3DDevice->CreateTexture2D(&desc, nullptr, staging.put()))) 561 { 562 return false; 563 } 564 565 pData->previewFrameTexture = frameTex; 566 pData->previewFrameStaging = staging; 567 return true; 568 }; 569 570 if (!pData->previewFrameTexture || !pData->previewFrameStaging) 571 { 572 return recreate(); 573 } 574 575 D3D11_TEXTURE2D_DESC existing{}; 576 pData->previewFrameTexture->GetDesc(&existing); 577 if (existing.Width != width || existing.Height != height) 578 { 579 return recreate(); 580 } 581 582 return true; 583 } 584 585 void CenterTrimDialog(HWND hDlg) 586 { 587 if (!hDlg) 588 { 589 return; 590 } 591 592 RECT rcDlg{}; 593 if (!GetWindowRect(hDlg, &rcDlg)) 594 { 595 return; 596 } 597 598 const int dlgWidth = rcDlg.right - rcDlg.left; 599 const int dlgHeight = rcDlg.bottom - rcDlg.top; 600 601 // Always center on the monitor containing the dialog, not the parent window 602 RECT rcTarget{}; 603 HMONITOR monitor = MonitorFromWindow(hDlg, MONITOR_DEFAULTTONEAREST); 604 MONITORINFO mi{ sizeof(mi) }; 605 if (GetMonitorInfo(monitor, &mi)) 606 { 607 rcTarget = mi.rcWork; 608 } 609 else 610 { 611 rcTarget.left = 0; 612 rcTarget.top = 0; 613 rcTarget.right = GetSystemMetrics(SM_CXSCREEN); 614 rcTarget.bottom = GetSystemMetrics(SM_CYSCREEN); 615 } 616 617 const int targetWidth = rcTarget.right - rcTarget.left; 618 const int targetHeight = rcTarget.bottom - rcTarget.top; 619 620 int newX = rcTarget.left + (targetWidth - dlgWidth) / 2; 621 int newY = rcTarget.top + (targetHeight - dlgHeight) / 2; 622 623 if (dlgWidth >= targetWidth) 624 { 625 newX = rcTarget.left; 626 } 627 else 628 { 629 newX = static_cast<int>((std::clamp)(static_cast<LONG>(newX), rcTarget.left, rcTarget.right - dlgWidth)); 630 } 631 632 if (dlgHeight >= targetHeight) 633 { 634 newY = rcTarget.top; 635 } 636 else 637 { 638 newY = static_cast<int>((std::clamp)(static_cast<LONG>(newY), rcTarget.top, rcTarget.bottom - dlgHeight)); 639 } 640 641 SetWindowPos(hDlg, nullptr, newX, newY, 0, 0, SWP_NOACTIVATE | SWP_NOSIZE | SWP_NOZORDER); 642 } 643 644 std::wstring FormatTrimTime(const winrt::TimeSpan& value, bool includeMilliseconds) 645 { 646 const int64_t ticks = (std::max)(value.count(), int64_t{ 0 }); 647 const int64_t totalMilliseconds = ticks / 10000LL; 648 const int milliseconds = static_cast<int>(totalMilliseconds % 1000); 649 const int64_t totalSeconds = totalMilliseconds / 1000LL; 650 const int seconds = static_cast<int>(totalSeconds % 60LL); 651 const int64_t totalMinutes = totalSeconds / 60LL; 652 const int minutes = static_cast<int>(totalMinutes % 60LL); 653 const int hours = static_cast<int>(totalMinutes / 60LL); 654 655 wchar_t buffer[32]{}; 656 if (hours > 0) 657 { 658 swprintf_s(buffer, L"%d:%02d:%02d", hours, minutes, seconds); 659 } 660 else 661 { 662 swprintf_s(buffer, L"%02d:%02d", minutes, seconds); 663 } 664 665 if (!includeMilliseconds) 666 { 667 return std::wstring(buffer); 668 } 669 670 wchar_t msBuffer[8]{}; 671 swprintf_s(msBuffer, L".%03d", milliseconds); 672 return std::wstring(buffer) + msBuffer; 673 } 674 675 std::wstring FormatDurationString(const winrt::TimeSpan& duration) 676 { 677 return L"Selection: " + FormatTrimTime(duration, true); 678 } 679 680 void SetTimeText(HWND hDlg, int controlId, const winrt::TimeSpan& value, bool includeMilliseconds) 681 { 682 const std::wstring formatted = FormatTrimTime(value, includeMilliseconds); 683 // Only update if the text has changed to prevent flashing 684 wchar_t currentText[64] = {}; 685 GetDlgItemText(hDlg, controlId, currentText, _countof(currentText)); 686 if (formatted != currentText) 687 { 688 SetDlgItemText(hDlg, controlId, formatted.c_str()); 689 } 690 } 691 692 int TimelineTimeToClientX(const VideoRecordingSession::TrimDialogData* pData, winrt::TimeSpan value, int clientWidth, UINT dpi = DPI_BASELINE) 693 { 694 const int padding = ScaleForDpi(kTimelinePadding, dpi); 695 const int trackWidth = (std::max)(clientWidth - padding * 2, 1); 696 return padding + pData->TimeToPixel(value, trackWidth); 697 } 698 699 winrt::TimeSpan TimelinePixelToTime(const VideoRecordingSession::TrimDialogData* pData, int x, int clientWidth, UINT dpi = DPI_BASELINE) 700 { 701 const int padding = ScaleForDpi(kTimelinePadding, dpi); 702 const int trackWidth = (std::max)(clientWidth - padding * 2, 1); 703 const int localX = std::clamp(x - padding, 0, trackWidth); 704 return pData->PixelToTime(localX, trackWidth); 705 } 706 707 void UpdateDurationDisplay(HWND hDlg, VideoRecordingSession::TrimDialogData* pData) 708 { 709 if (!pData || !hDlg) 710 { 711 return; 712 } 713 714 const int64_t selectionTicks = (std::max)(pData->trimEnd.count() - pData->trimStart.count(), int64_t{ 0 }); 715 const std::wstring durationText = FormatDurationString(winrt::TimeSpan{ selectionTicks }); 716 // Only update if the text has changed to prevent flashing 717 wchar_t currentText[64] = {}; 718 GetDlgItemText(hDlg, IDC_TRIM_DURATION_LABEL, currentText, _countof(currentText)); 719 if (durationText != currentText) 720 { 721 SetDlgItemText(hDlg, IDC_TRIM_DURATION_LABEL, durationText.c_str()); 722 } 723 724 // Enable OK when trimming is active (even if unchanged since dialog opened), 725 // or when the user changed the selection (including reverting to full length). 726 const bool trimChanged = (pData->trimStart.count() != pData->originalTrimStart.count()) || 727 (pData->trimEnd.count() != pData->originalTrimEnd.count()); 728 const bool trimIsActive = (pData->trimStart.count() > 0) || 729 (pData->videoDuration.count() > 0 && pData->trimEnd.count() < pData->videoDuration.count()); 730 EnableWindow(GetDlgItem(hDlg, IDOK), trimChanged || trimIsActive); 731 } 732 733 RECT GetTimelineTrackRect(const RECT& clientRect, UINT dpi) 734 { 735 const int padding = ScaleForDpi(kTimelinePadding, dpi); 736 const int trackOffset = ScaleForDpi(kTimelineTrackTopOffset, dpi); 737 const int trackHeight = ScaleForDpi(kTimelineTrackHeight, dpi); 738 const int trackLeft = clientRect.left + padding; 739 const int trackRight = clientRect.right - padding; 740 const int trackTop = clientRect.top + trackOffset; 741 const int trackBottom = trackTop + trackHeight; 742 RECT track{ trackLeft, trackTop, trackRight, trackBottom }; 743 return track; 744 } 745 746 RECT GetPlayheadBoundsRect(const RECT& clientRect, int x, UINT dpi) 747 { 748 RECT track = GetTimelineTrackRect(clientRect, dpi); 749 const int lineThick = ScaleForDpi(3, dpi); 750 const int topExt = ScaleForDpi(12, dpi); 751 const int botExt = ScaleForDpi(22, dpi); 752 const int circleR = ScaleForDpi(6, dpi); 753 const int circleBotOff = ScaleForDpi(12, dpi); 754 const int circleBotEnd = ScaleForDpi(24, dpi); 755 RECT lineRect{ x - lineThick + 1, track.top - topExt, x + lineThick, track.bottom + botExt }; 756 RECT circleRect{ x - circleR, track.bottom + circleBotOff, x + circleR, track.bottom + circleBotEnd }; 757 RECT combined{}; 758 UnionRect(&combined, &lineRect, &circleRect); 759 return combined; 760 } 761 762 void InvalidatePlayheadRegion(HWND hTimeline, const RECT& clientRect, int previousX, int newX, UINT dpi) 763 { 764 if (!hTimeline) 765 { 766 return; 767 } 768 769 RECT invalidRect{}; 770 bool hasRect = false; 771 772 if (previousX >= 0) 773 { 774 RECT oldRect = GetPlayheadBoundsRect(clientRect, previousX, dpi); 775 invalidRect = oldRect; 776 hasRect = true; 777 } 778 779 if (newX >= 0) 780 { 781 RECT newRect = GetPlayheadBoundsRect(clientRect, newX, dpi); 782 if (hasRect) 783 { 784 RECT unionRect{}; 785 UnionRect(&unionRect, &invalidRect, &newRect); 786 invalidRect = unionRect; 787 } 788 else 789 { 790 invalidRect = newRect; 791 hasRect = true; 792 } 793 } 794 795 if (hasRect) 796 { 797 InflateRect(&invalidRect, 2, 2); 798 InvalidateRect(hTimeline, &invalidRect, FALSE); 799 } 800 } 801 } 802 803 static int64_t SteadyClockMicros() 804 { 805 return std::chrono::duration_cast<std::chrono::microseconds>( 806 std::chrono::steady_clock::now().time_since_epoch()).count(); 807 } 808 809 static void ResetSmoothPlayback(VideoRecordingSession::TrimDialogData* pData) 810 { 811 if (!pData) 812 { 813 return; 814 } 815 816 pData->smoothActive.store(false, std::memory_order_relaxed); 817 pData->smoothBaseTicks.store(0, std::memory_order_relaxed); 818 pData->smoothLastSyncMicroseconds.store(0, std::memory_order_relaxed); 819 pData->smoothHasNonZeroSample.store(false, std::memory_order_relaxed); 820 } 821 822 static void LogSmoothingEvent(const wchar_t* label, int64_t predictedTicks, int64_t mediaTicks, int64_t driftTicks); 823 824 static void SyncSmoothPlayback(VideoRecordingSession::TrimDialogData* pData, int64_t mediaTicks, int64_t /*minTicks*/, int64_t /*maxTicks*/) 825 { 826 if (!pData) 827 { 828 return; 829 } 830 831 const int64_t nowUs = SteadyClockMicros(); 832 pData->smoothBaseTicks.store(mediaTicks, std::memory_order_relaxed); 833 pData->smoothLastSyncMicroseconds.store(nowUs, std::memory_order_relaxed); 834 pData->smoothActive.store(true, std::memory_order_relaxed); 835 pData->smoothHasNonZeroSample.store(mediaTicks > 0, std::memory_order_relaxed); 836 837 LogSmoothingEvent(L"setBase", mediaTicks, mediaTicks, 0); 838 } 839 840 static void LogSmoothingEvent(const wchar_t* label, int64_t predictedTicks, int64_t mediaTicks, int64_t driftTicks) 841 { 842 wchar_t buf[256]{}; 843 swprintf_s(buf, L"[TrimSmooth] %s pred=%lld media=%lld drift=%lld\n", 844 label ? label : L"", static_cast<long long>(predictedTicks), static_cast<long long>(mediaTicks), static_cast<long long>(driftTicks)); 845 OutputDebugStringW(buf); 846 } 847 848 static void StopPlayback(HWND hDlg, VideoRecordingSession::TrimDialogData* pData, bool capturePosition = true); 849 static winrt::fire_and_forget StartPlaybackAsync(HWND hDlg, VideoRecordingSession::TrimDialogData* pData); 850 851 852 //---------------------------------------------------------------------------- 853 // 854 // VideoRecordingSession::VideoRecordingSession 855 // 856 //---------------------------------------------------------------------------- 857 VideoRecordingSession::VideoRecordingSession( 858 winrt::IDirect3DDevice const& device, 859 winrt::GraphicsCaptureItem const& item, 860 RECT const cropRect, 861 uint32_t frameRate, 862 bool captureAudio, 863 bool captureSystemAudio, 864 winrt::Streams::IRandomAccessStream const& stream) 865 { 866 m_device = device; 867 m_d3dDevice = GetDXGIInterfaceFromObject<ID3D11Device>(m_device); 868 m_d3dDevice->GetImmediateContext(m_d3dContext.put()); 869 m_item = item; 870 auto itemSize = item.Size(); 871 auto inputWidth = EnsureEven(itemSize.Width); 872 auto inputHeight = EnsureEven(itemSize.Height); 873 m_frameWait = std::make_shared<CaptureFrameWait>(m_device, m_item, winrt::SizeInt32{ inputWidth, inputHeight }); 874 auto weakPointer{ std::weak_ptr{ m_frameWait } }; 875 m_itemClosed = item.Closed(winrt::auto_revoke, [weakPointer](auto&, auto&) 876 { 877 auto sharedPointer{ weakPointer.lock() }; 878 879 if (sharedPointer) 880 { 881 sharedPointer->StopCapture(); 882 } 883 }); 884 885 // Get crop dimension 886 if( (cropRect.right - cropRect.left) != 0 ) 887 { 888 m_rcCrop = cropRect; 889 m_frameWait->ShowCaptureBorder( false ); 890 } 891 else 892 { 893 m_rcCrop.left = 0; 894 m_rcCrop.top = 0; 895 m_rcCrop.right = inputWidth; 896 m_rcCrop.bottom = inputHeight; 897 } 898 899 // Ensure the video is not too small and try to maintain the aspect ratio 900 constexpr int c_minimumSize = 34; 901 auto scaledWidth = MulDiv(m_rcCrop.right - m_rcCrop.left, g_RecordScaling, 100); 902 auto scaledHeight = MulDiv(m_rcCrop.bottom - m_rcCrop.top, g_RecordScaling, 100); 903 auto outputWidth = scaledWidth; 904 auto outputHeight = scaledHeight; 905 if (outputWidth < c_minimumSize) 906 { 907 outputWidth = c_minimumSize; 908 outputHeight = MulDiv(outputHeight, outputWidth, scaledWidth); 909 } 910 if (outputHeight < c_minimumSize) 911 { 912 outputHeight = c_minimumSize; 913 outputWidth = MulDiv(outputWidth, outputHeight, scaledHeight); 914 } 915 if (outputWidth > inputWidth) 916 { 917 outputWidth = inputWidth; 918 outputHeight = c_minimumSize, MulDiv(outputHeight, scaledWidth, outputWidth); 919 } 920 if (outputHeight > inputHeight) 921 { 922 outputHeight = inputHeight; 923 outputWidth = c_minimumSize, MulDiv(outputWidth, scaledHeight, outputHeight); 924 } 925 outputWidth = EnsureEven(outputWidth); 926 outputHeight = EnsureEven(outputHeight); 927 928 // Describe out output: H264 video with an MP4 container 929 m_encodingProfile = winrt::MediaEncodingProfile(); 930 m_encodingProfile.Container().Subtype(L"MPEG4"); 931 auto video = m_encodingProfile.Video(); 932 video.Subtype(L"H264"); 933 video.Width(outputWidth); 934 video.Height(outputHeight); 935 video.Bitrate(static_cast<uint32_t>(outputWidth * outputHeight * frameRate * 2 * 0.07)); 936 video.FrameRate().Numerator(frameRate); 937 video.FrameRate().Denominator(1); 938 video.PixelAspectRatio().Numerator(1); 939 video.PixelAspectRatio().Denominator(1); 940 m_encodingProfile.Video(video); 941 942 // Always set up audio profile for loopback capture (stereo AAC) 943 auto audio = m_encodingProfile.Audio(); 944 audio = winrt::AudioEncodingProperties::CreateAac(48000, 2, 192000); 945 m_encodingProfile.Audio(audio); 946 947 // Describe our input: uncompressed BGRA8 buffers 948 auto properties = winrt::VideoEncodingProperties::CreateUncompressed( 949 winrt::MediaEncodingSubtypes::Bgra8(), 950 static_cast<uint32_t>(m_rcCrop.right - m_rcCrop.left), 951 static_cast<uint32_t>(m_rcCrop.bottom - m_rcCrop.top)); 952 m_videoDescriptor = winrt::VideoStreamDescriptor(properties); 953 954 m_stream = stream; 955 956 m_previewSwapChain = util::CreateDXGISwapChain( 957 m_d3dDevice, 958 static_cast<uint32_t>(m_rcCrop.right - m_rcCrop.left), 959 static_cast<uint32_t>(m_rcCrop.bottom - m_rcCrop.top), 960 DXGI_FORMAT_B8G8R8A8_UNORM, 961 2); 962 winrt::com_ptr<ID3D11Texture2D> backBuffer; 963 winrt::check_hresult(m_previewSwapChain->GetBuffer(0, winrt::guid_of<ID3D11Texture2D>(), backBuffer.put_void())); 964 winrt::check_hresult(m_d3dDevice->CreateRenderTargetView(backBuffer.get(), nullptr, m_renderTargetView.put())); 965 966 // Always create audio generator for loopback capture; captureAudio controls microphone 967 m_audioGenerator = std::make_unique<AudioSampleGenerator>(captureAudio, captureSystemAudio); 968 } 969 970 971 //---------------------------------------------------------------------------- 972 // 973 // VideoRecordingSession::~VideoRecordingSession 974 // 975 //---------------------------------------------------------------------------- 976 VideoRecordingSession::~VideoRecordingSession() 977 { 978 Close(); 979 } 980 981 982 //---------------------------------------------------------------------------- 983 // 984 // VideoRecordingSession::StartAsync 985 // 986 //---------------------------------------------------------------------------- 987 winrt::IAsyncAction VideoRecordingSession::StartAsync() 988 { 989 auto expected = false; 990 if (m_isRecording.compare_exchange_strong(expected, true)) 991 { 992 993 // Create our MediaStreamSource 994 if(m_audioGenerator) { 995 996 co_await m_audioGenerator->InitializeAsync(); 997 m_streamSource = winrt::MediaStreamSource(m_videoDescriptor, winrt::AudioStreamDescriptor(m_audioGenerator->GetEncodingProperties())); 998 } 999 else { 1000 1001 m_streamSource = winrt::MediaStreamSource(m_videoDescriptor); 1002 } 1003 m_streamSource.BufferTime(std::chrono::seconds(0)); 1004 m_streamSource.Starting({ this, &VideoRecordingSession::OnMediaStreamSourceStarting }); 1005 m_streamSource.SampleRequested({ this, &VideoRecordingSession::OnMediaStreamSourceSampleRequested }); 1006 1007 // Create our transcoder 1008 m_transcoder = winrt::MediaTranscoder(); 1009 m_transcoder.HardwareAccelerationEnabled(true); 1010 1011 auto self = shared_from_this(); 1012 1013 // Start encoding 1014 // If the user stops recording immediately after starting, MediaTranscoder may fail 1015 // with MF_E_NO_SAMPLE_PROCESSED (0xC00D4A44). Avoid surfacing this as an error. 1016 if (m_closed.load()) 1017 { 1018 co_return; 1019 } 1020 1021 winrt::PrepareTranscodeResult transcode{ nullptr }; 1022 try 1023 { 1024 transcode = co_await m_transcoder.PrepareMediaStreamSourceTranscodeAsync(m_streamSource, m_stream, m_encodingProfile); 1025 1026 if (m_closed.load()) 1027 { 1028 co_return; 1029 } 1030 1031 co_await transcode.TranscodeAsync(); 1032 } 1033 catch (winrt::hresult_error const& error) 1034 { 1035 constexpr HRESULT MF_E_NO_SAMPLE_PROCESSED = static_cast<HRESULT>(0xC00D4A44); 1036 if (m_closed.load() || error.code() == MF_E_NO_SAMPLE_PROCESSED) 1037 { 1038 co_return; 1039 } 1040 throw; 1041 } 1042 } 1043 co_return; 1044 } 1045 1046 1047 //---------------------------------------------------------------------------- 1048 // 1049 // VideoRecordingSession::Close 1050 // 1051 //---------------------------------------------------------------------------- 1052 void VideoRecordingSession::Close() 1053 { 1054 auto expected = false; 1055 if (m_closed.compare_exchange_strong(expected, true)) 1056 { 1057 expected = true; 1058 if (!m_isRecording.compare_exchange_strong(expected, false)) 1059 { 1060 CloseInternal(); 1061 } 1062 else 1063 { 1064 m_frameWait->StopCapture(); 1065 } 1066 } 1067 } 1068 1069 //---------------------------------------------------------------------------- 1070 // 1071 // VideoRecordingSession::CloseInternal 1072 // 1073 //---------------------------------------------------------------------------- 1074 void VideoRecordingSession::CloseInternal() 1075 { 1076 if(m_audioGenerator) { 1077 m_audioGenerator->Stop(); 1078 } 1079 m_frameWait->StopCapture(); 1080 m_itemClosed.revoke(); 1081 } 1082 1083 1084 //---------------------------------------------------------------------------- 1085 // 1086 // VideoRecordingSession::OnMediaStreamSourceStarting 1087 // 1088 //---------------------------------------------------------------------------- 1089 void VideoRecordingSession::OnMediaStreamSourceStarting( 1090 winrt::MediaStreamSource const&, 1091 winrt::MediaStreamSourceStartingEventArgs const& args) 1092 { 1093 auto frame = m_frameWait->TryGetNextFrame(); 1094 if (frame) { 1095 args.Request().SetActualStartPosition(frame->SystemRelativeTime); 1096 if (m_audioGenerator) { 1097 1098 m_audioGenerator->Start(); 1099 } 1100 } 1101 } 1102 1103 //---------------------------------------------------------------------------- 1104 // 1105 // VideoRecordingSession::Create 1106 // 1107 //---------------------------------------------------------------------------- 1108 std::shared_ptr<VideoRecordingSession> VideoRecordingSession::Create( 1109 winrt::IDirect3DDevice const& device, 1110 winrt::GraphicsCaptureItem const& item, 1111 RECT const& crop, 1112 uint32_t frameRate, 1113 bool captureAudio, 1114 bool captureSystemAudio, 1115 winrt::Streams::IRandomAccessStream const& stream) 1116 { 1117 return std::shared_ptr<VideoRecordingSession>(new VideoRecordingSession(device, item, crop, frameRate, captureAudio, captureSystemAudio, stream)); 1118 } 1119 1120 //---------------------------------------------------------------------------- 1121 // 1122 // VideoRecordingSession::OnMediaStreamSourceSampleRequested 1123 // 1124 //---------------------------------------------------------------------------- 1125 void VideoRecordingSession::OnMediaStreamSourceSampleRequested( 1126 winrt::MediaStreamSource const&, 1127 winrt::MediaStreamSourceSampleRequestedEventArgs const& args) 1128 { 1129 auto request = args.Request(); 1130 auto streamDescriptor = request.StreamDescriptor(); 1131 if (auto videoStreamDescriptor = streamDescriptor.try_as<winrt::VideoStreamDescriptor>()) 1132 { 1133 if (auto frame = m_frameWait->TryGetNextFrame()) 1134 { 1135 try 1136 { 1137 auto timeStamp = frame->SystemRelativeTime; 1138 auto contentSize = frame->ContentSize; 1139 auto frameTexture = GetDXGIInterfaceFromObject<ID3D11Texture2D>(frame->FrameTexture); 1140 D3D11_TEXTURE2D_DESC desc = {}; 1141 frameTexture->GetDesc(&desc); 1142 1143 winrt::com_ptr<ID3D11Texture2D> backBuffer; 1144 winrt::check_hresult(m_previewSwapChain->GetBuffer(0, winrt::guid_of<ID3D11Texture2D>(), backBuffer.put_void())); 1145 1146 // Use the smaller of the crop size or content size. The content 1147 // size can change while recording, for example by resizing the 1148 // window. This ensures that only valid content is copied. 1149 auto width = min(m_rcCrop.right - m_rcCrop.left, contentSize.Width); 1150 auto height = min(m_rcCrop.bottom - m_rcCrop.top, contentSize.Height); 1151 1152 // Set the content region to copy and clamp the coordinates to the 1153 // texture surface. 1154 D3D11_BOX region = {}; 1155 region.left = std::clamp(m_rcCrop.left, static_cast<LONG>(0), static_cast<LONG>(desc.Width)); 1156 region.right = std::clamp(m_rcCrop.left + width, static_cast<LONG>(0), static_cast<LONG>(desc.Width)); 1157 region.top = std::clamp(m_rcCrop.top, static_cast<LONG>(0), static_cast<LONG>(desc.Height)); 1158 region.bottom = std::clamp(m_rcCrop.top + height, static_cast<LONG>(0), static_cast<LONG>(desc.Height)); 1159 region.back = 1; 1160 1161 m_d3dContext->ClearRenderTargetView(m_renderTargetView.get(), CLEAR_COLOR); 1162 m_d3dContext->CopySubresourceRegion( 1163 backBuffer.get(), 1164 0, 1165 0, 0, 0, 1166 frameTexture.get(), 1167 0, 1168 ®ion); 1169 1170 desc = {}; 1171 backBuffer->GetDesc(&desc); 1172 1173 desc.Usage = D3D11_USAGE_DEFAULT; 1174 desc.BindFlags = D3D11_BIND_SHADER_RESOURCE | D3D11_BIND_RENDER_TARGET; 1175 desc.CPUAccessFlags = 0; 1176 desc.MiscFlags = 0; 1177 winrt::com_ptr<ID3D11Texture2D> sampleTexture; 1178 winrt::check_hresult(m_d3dDevice->CreateTexture2D(&desc, nullptr, sampleTexture.put())); 1179 m_d3dContext->CopyResource(sampleTexture.get(), backBuffer.get()); 1180 auto dxgiSurface = sampleTexture.as<IDXGISurface>(); 1181 auto sampleSurface = CreateDirect3DSurface(dxgiSurface.get()); 1182 1183 DXGI_PRESENT_PARAMETERS presentParameters{}; 1184 winrt::check_hresult(m_previewSwapChain->Present1(0, 0, &presentParameters)); 1185 1186 auto sample = winrt::MediaStreamSample::CreateFromDirect3D11Surface(sampleSurface, timeStamp); 1187 m_hasVideoSample.store(true); 1188 request.Sample(sample); 1189 } 1190 catch (winrt::hresult_error const& error) 1191 { 1192 OutputDebugStringW(error.message().c_str()); 1193 request.Sample(nullptr); 1194 CloseInternal(); 1195 return; 1196 } 1197 } 1198 else 1199 { 1200 request.Sample(nullptr); 1201 CloseInternal(); 1202 } 1203 } 1204 else if (auto audioStreamDescriptor = streamDescriptor.try_as<winrt::AudioStreamDescriptor>()) 1205 { 1206 try 1207 { 1208 if (auto sample = m_audioGenerator->TryGetNextSample()) 1209 { 1210 request.Sample(sample.value()); 1211 } 1212 else 1213 { 1214 request.Sample(nullptr); 1215 } 1216 } 1217 catch (winrt::hresult_error const& error) 1218 { 1219 OutputDebugStringW(error.message().c_str()); 1220 request.Sample(nullptr); 1221 CloseInternal(); 1222 return; 1223 } 1224 } 1225 } 1226 1227 //---------------------------------------------------------------------------- 1228 // 1229 // Custom file dialog events handler for Trim button 1230 // 1231 //---------------------------------------------------------------------------- 1232 class CTrimFileDialogEvents : public IFileDialogEvents, public IFileDialogControlEvents 1233 { 1234 private: 1235 long m_cRef; 1236 HWND m_hParent; 1237 std::wstring m_videoPath; 1238 std::wstring* m_pTrimmedPath; 1239 winrt::TimeSpan* m_pTrimStart; 1240 winrt::TimeSpan* m_pTrimEnd; 1241 bool* m_pShouldTrim; 1242 bool m_bIconSet; 1243 1244 public: 1245 CTrimFileDialogEvents(HWND hParent, const std::wstring& videoPath, 1246 std::wstring* pTrimmedPath, winrt::TimeSpan* pTrimStart, 1247 winrt::TimeSpan* pTrimEnd, bool* pShouldTrim) 1248 : m_cRef(1), m_hParent(hParent), m_videoPath(videoPath), 1249 m_pTrimmedPath(pTrimmedPath), m_pTrimStart(pTrimStart), 1250 m_pTrimEnd(pTrimEnd), m_pShouldTrim(pShouldTrim), m_bIconSet(false) 1251 { 1252 } 1253 1254 // IUnknown 1255 IFACEMETHODIMP QueryInterface(REFIID riid, void** ppv) 1256 { 1257 static const QITAB qit[] = { 1258 QITABENT(CTrimFileDialogEvents, IFileDialogEvents), 1259 QITABENT(CTrimFileDialogEvents, IFileDialogControlEvents), 1260 { 0 }, 1261 }; 1262 return QISearch(this, qit, riid, ppv); 1263 } 1264 1265 IFACEMETHODIMP_(ULONG) AddRef() 1266 { 1267 return InterlockedIncrement(&m_cRef); 1268 } 1269 1270 IFACEMETHODIMP_(ULONG) Release() 1271 { 1272 long cRef = InterlockedDecrement(&m_cRef); 1273 if (!cRef) 1274 delete this; 1275 return cRef; 1276 } 1277 1278 // IFileDialogEvents 1279 IFACEMETHODIMP OnFileOk(IFileDialog*) { return S_OK; } 1280 1281 IFACEMETHODIMP OnFolderChange(IFileDialog* pfd) 1282 { 1283 // Set the ZoomIt icon on the save dialog (only once) 1284 if (!m_bIconSet) 1285 { 1286 m_bIconSet = true; 1287 wil::com_ptr<IOleWindow> pOleWnd; 1288 if (SUCCEEDED(pfd->QueryInterface(IID_PPV_ARGS(&pOleWnd)))) 1289 { 1290 HWND hDlg = nullptr; 1291 if (SUCCEEDED(pOleWnd->GetWindow(&hDlg)) && hDlg) 1292 { 1293 HICON hIcon = LoadIcon(g_hInstance, L"APPICON"); 1294 if (hIcon) 1295 { 1296 SendMessage(hDlg, WM_SETICON, ICON_BIG, reinterpret_cast<LPARAM>(hIcon)); 1297 SendMessage(hDlg, WM_SETICON, ICON_SMALL, reinterpret_cast<LPARAM>(hIcon)); 1298 } 1299 1300 // Make dialog appear in taskbar 1301 LONG_PTR exStyle = GetWindowLongPtr(hDlg, GWL_EXSTYLE); 1302 SetWindowLongPtr(hDlg, GWL_EXSTYLE, exStyle | WS_EX_APPWINDOW); 1303 } 1304 } 1305 } 1306 return S_OK; 1307 } 1308 1309 IFACEMETHODIMP OnFolderChanging(IFileDialog*, IShellItem*) { return S_OK; } 1310 IFACEMETHODIMP OnSelectionChange(IFileDialog*) { return S_OK; } 1311 IFACEMETHODIMP OnShareViolation(IFileDialog*, IShellItem*, FDE_SHAREVIOLATION_RESPONSE*) { return S_OK; } 1312 IFACEMETHODIMP OnTypeChange(IFileDialog*) { return S_OK; } 1313 IFACEMETHODIMP OnOverwrite(IFileDialog*, IShellItem*, FDE_OVERWRITE_RESPONSE*) { return S_OK; } 1314 1315 // IFileDialogControlEvents 1316 IFACEMETHODIMP OnItemSelected(IFileDialogCustomize*, DWORD, DWORD) { return S_OK; } 1317 IFACEMETHODIMP OnCheckButtonToggled(IFileDialogCustomize*, DWORD, BOOL) { return S_OK; } 1318 IFACEMETHODIMP OnControlActivating(IFileDialogCustomize*, DWORD) { return S_OK; } 1319 1320 IFACEMETHODIMP OnButtonClicked(IFileDialogCustomize* pfdc, DWORD dwIDCtl) 1321 { 1322 if (dwIDCtl == 2000) // Trim button ID 1323 { 1324 try 1325 { 1326 // Get the file dialog's window handle to make trim dialog modal to it 1327 wil::com_ptr<IFileDialog> pfd; 1328 HWND hFileDlg = nullptr; 1329 if (SUCCEEDED(pfdc->QueryInterface(IID_PPV_ARGS(&pfd)))) 1330 { 1331 wil::com_ptr<IOleWindow> pOleWnd; 1332 if (SUCCEEDED(pfd->QueryInterface(IID_PPV_ARGS(&pOleWnd)))) 1333 { 1334 pOleWnd->GetWindow(&hFileDlg); 1335 } 1336 } 1337 1338 // Use file dialog window as parent if found 1339 HWND hParent = hFileDlg ? hFileDlg : m_hParent; 1340 1341 auto trimResult = VideoRecordingSession::ShowTrimDialog(hParent, m_videoPath, *m_pTrimStart, *m_pTrimEnd); 1342 if (trimResult == IDOK) 1343 { 1344 *m_pShouldTrim = true; 1345 } 1346 else if( trimResult == IDCANCEL ) 1347 { 1348 // Cancel should reset to the default selection (fresh state) and 1349 // disable trimming for the eventual save. 1350 *m_pTrimStart = winrt::TimeSpan{ 0 }; 1351 *m_pTrimEnd = winrt::TimeSpan{ 0 }; 1352 *m_pShouldTrim = false; 1353 } 1354 } 1355 catch (const std::exception& e) 1356 { 1357 (void)e; 1358 } 1359 catch (...) 1360 { 1361 } 1362 } 1363 return S_OK; 1364 } 1365 }; 1366 1367 //---------------------------------------------------------------------------- 1368 // 1369 // VideoRecordingSession::ShowSaveDialogWithTrim 1370 // 1371 // Main entry point for trim+save workflow 1372 // 1373 //---------------------------------------------------------------------------- 1374 std::wstring VideoRecordingSession::ShowSaveDialogWithTrim( 1375 HWND hParent, 1376 const std::wstring& suggestedFileName, 1377 const std::wstring& originalVideoPath, 1378 std::wstring& trimmedVideoPath) 1379 { 1380 trimmedVideoPath.clear(); 1381 1382 const bool isGif = IsGifPath(originalVideoPath); 1383 1384 std::wstring videoPathToSave = originalVideoPath; 1385 winrt::TimeSpan trimStart{ 0 }; 1386 winrt::TimeSpan trimEnd{ 0 }; 1387 bool shouldTrim = false; 1388 1389 // Create save dialog with custom Trim button 1390 auto saveDialog = wil::CoCreateInstance<::IFileSaveDialog>(CLSID_FileSaveDialog); 1391 1392 FILEOPENDIALOGOPTIONS options; 1393 if (SUCCEEDED(saveDialog->GetOptions(&options))) 1394 saveDialog->SetOptions(options | FOS_FORCEFILESYSTEM); 1395 1396 wil::com_ptr<::IShellItem> videosItem; 1397 if (SUCCEEDED(SHGetKnownFolderItem(FOLDERID_Videos, KF_FLAG_DEFAULT, nullptr, 1398 IID_IShellItem, (void**)videosItem.put()))) 1399 saveDialog->SetDefaultFolder(videosItem.get()); 1400 1401 if (isGif) 1402 { 1403 saveDialog->SetDefaultExtension(L".gif"); 1404 COMDLG_FILTERSPEC fileTypes[] = { 1405 { L"GIF Animation", L"*.gif" } 1406 }; 1407 saveDialog->SetFileTypes(_countof(fileTypes), fileTypes); 1408 } 1409 else 1410 { 1411 saveDialog->SetDefaultExtension(L".mp4"); 1412 COMDLG_FILTERSPEC fileTypes[] = { 1413 { L"MP4 Video", L"*.mp4" } 1414 }; 1415 saveDialog->SetFileTypes(_countof(fileTypes), fileTypes); 1416 } 1417 saveDialog->SetFileName(suggestedFileName.c_str()); 1418 saveDialog->SetTitle(L"ZoomIt: Save Video As..."); 1419 1420 // Add custom Trim button 1421 wil::com_ptr<IFileDialogCustomize> pfdCustomize; 1422 if (SUCCEEDED(saveDialog->QueryInterface(IID_PPV_ARGS(&pfdCustomize)))) 1423 { 1424 pfdCustomize->AddPushButton(2000, L"Trim..."); 1425 } 1426 1427 // Set up event handler 1428 CTrimFileDialogEvents* pEvents = new CTrimFileDialogEvents(hParent, originalVideoPath, 1429 &trimmedVideoPath, &trimStart, &trimEnd, &shouldTrim); 1430 DWORD dwCookie; 1431 saveDialog->Advise(pEvents, &dwCookie); 1432 1433 HRESULT hr = saveDialog->Show(hParent); 1434 1435 saveDialog->Unadvise(dwCookie); 1436 pEvents->Release(); 1437 1438 if (FAILED(hr)) 1439 { 1440 return std::wstring(); // User cancelled save dialog 1441 } 1442 1443 // If user clicked Trim button and confirmed, perform the trim 1444 if (shouldTrim) 1445 { 1446 try 1447 { 1448 auto trimOp = isGif ? TrimGifAsync(originalVideoPath, trimStart, trimEnd) 1449 : TrimVideoAsync(originalVideoPath, trimStart, trimEnd); 1450 1451 // Pump messages while waiting for async operation 1452 while (trimOp.Status() == winrt::AsyncStatus::Started) 1453 { 1454 MSG msg; 1455 while (PeekMessage(&msg, nullptr, 0, 0, PM_REMOVE)) 1456 { 1457 TranslateMessage(&msg); 1458 DispatchMessage(&msg); 1459 } 1460 Sleep(10); 1461 } 1462 1463 auto trimmedPath = std::wstring(trimOp.GetResults()); 1464 1465 if (trimmedPath.empty()) 1466 { 1467 MessageBox(hParent, L"Failed to trim video", L"Error", MB_OK | MB_ICONERROR); 1468 return std::wstring(); 1469 } 1470 1471 trimmedVideoPath = trimmedPath; 1472 videoPathToSave = trimmedPath; 1473 } 1474 catch (...) 1475 { 1476 MessageBox(hParent, L"Failed to trim video", L"Error", MB_OK | MB_ICONERROR); 1477 return std::wstring(); 1478 } 1479 } 1480 1481 wil::com_ptr<::IShellItem> item; 1482 THROW_IF_FAILED(saveDialog->GetResult(item.put())); 1483 1484 wil::unique_cotaskmem_string filePath; 1485 THROW_IF_FAILED(item->GetDisplayName(SIGDN_FILESYSPATH, filePath.put())); 1486 1487 return std::wstring(filePath.get()); 1488 } 1489 1490 //---------------------------------------------------------------------------- 1491 // 1492 // VideoRecordingSession::ShowTrimDialog 1493 // 1494 // Shows the trim UI dialog 1495 // 1496 //---------------------------------------------------------------------------- 1497 INT_PTR VideoRecordingSession::ShowTrimDialog( 1498 HWND hParent, 1499 const std::wstring& videoPath, 1500 winrt::TimeSpan& trimStart, 1501 winrt::TimeSpan& trimEnd) 1502 { 1503 std::promise<INT_PTR> resultPromise; 1504 auto resultFuture = resultPromise.get_future(); 1505 1506 std::thread staThread([hParent, videoPath, &trimStart, &trimEnd, promise = std::move(resultPromise)]() mutable 1507 { 1508 bool coInitialized = false; 1509 try 1510 { 1511 winrt::init_apartment(winrt::apartment_type::single_threaded); 1512 } 1513 catch (const winrt::hresult_error&) 1514 { 1515 HRESULT hr = CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); 1516 if (SUCCEEDED(hr)) 1517 { 1518 coInitialized = true; 1519 } 1520 } 1521 1522 try 1523 { 1524 INT_PTR dlgResult = ShowTrimDialogInternal(hParent, videoPath, trimStart, trimEnd); 1525 promise.set_value(dlgResult); 1526 } 1527 catch (const winrt::hresult_error& e) 1528 { 1529 (void)e; 1530 promise.set_exception(std::current_exception()); 1531 } 1532 catch (const std::exception& e) 1533 { 1534 (void)e; 1535 promise.set_exception(std::current_exception()); 1536 } 1537 catch (...) 1538 { 1539 promise.set_exception(std::current_exception()); 1540 } 1541 1542 if (coInitialized) 1543 { 1544 CoUninitialize(); 1545 } 1546 }); 1547 1548 bool quitReceived = false; 1549 while (!quitReceived && resultFuture.wait_for(std::chrono::milliseconds(20)) != std::future_status::ready) 1550 { 1551 MSG msg; 1552 while (PeekMessage(&msg, nullptr, 0, 0, PM_REMOVE)) 1553 { 1554 if (msg.message == WM_QUIT) 1555 { 1556 // WM_QUIT must be reposted so the main application loop can exit cleanly. 1557 quitReceived = true; 1558 } 1559 TranslateMessage(&msg); 1560 DispatchMessage(&msg); 1561 } 1562 } 1563 1564 // Repost WM_QUIT after waiting for the dialog thread to finish, so the main loop can handle it. 1565 if (quitReceived && hDlgTrimDialog != nullptr) 1566 { 1567 EndDialog(hDlgTrimDialog, IDCANCEL); 1568 PostQuitMessage(0); 1569 } 1570 1571 INT_PTR dialogResult = quitReceived ? IDCANCEL : resultFuture.get(); 1572 if (staThread.joinable()) 1573 { 1574 staThread.join(); 1575 } 1576 return dialogResult; 1577 } 1578 1579 INT_PTR VideoRecordingSession::ShowTrimDialogInternal( 1580 HWND hParent, 1581 const std::wstring& videoPath, 1582 winrt::TimeSpan& trimStart, 1583 winrt::TimeSpan& trimEnd) 1584 { 1585 TrimDialogData data; 1586 data.videoPath = videoPath; 1587 // Initialize from the caller so reopening the trim dialog can preserve prior work. 1588 data.trimStart = trimStart; 1589 data.trimEnd = trimEnd; 1590 data.isGif = IsGifPath(videoPath); 1591 1592 if (data.isGif) 1593 { 1594 if (!LoadGifFrames(videoPath, &data)) 1595 { 1596 MessageBox(hParent, L"Unable to load the GIF for trimming. The file may be locked or unreadable.", L"Error", MB_OK | MB_ICONERROR); 1597 return IDCANCEL; 1598 } 1599 } 1600 else 1601 { 1602 // Get video duration - use simple file size estimation to avoid blocking 1603 // The actual trim operation will handle the real duration 1604 WIN32_FILE_ATTRIBUTE_DATA fileInfo; 1605 if (GetFileAttributesEx(videoPath.c_str(), GetFileExInfoStandard, &fileInfo)) 1606 { 1607 ULARGE_INTEGER fileSize; 1608 fileSize.LowPart = fileInfo.nFileSizeLow; 1609 fileSize.HighPart = fileInfo.nFileSizeHigh; 1610 1611 // Estimate: ~10MB per minute for typical 1080p recording 1612 // Duration in 100-nanosecond units (10,000,000 = 1 second) 1613 int64_t estimatedSeconds = fileSize.QuadPart / (10 * 1024 * 1024 / 60); 1614 if (estimatedSeconds < 1) estimatedSeconds = 10; // minimum 10 seconds 1615 if (estimatedSeconds > 3600) estimatedSeconds = 3600; // max 1 hour 1616 1617 data.videoDuration = winrt::TimeSpan{ estimatedSeconds * 10000000LL }; 1618 if( data.trimEnd.count() <= 0 ) 1619 { 1620 data.trimEnd = data.videoDuration; 1621 } 1622 } 1623 else 1624 { 1625 // Default to 60 seconds if we can't get file size 1626 data.videoDuration = winrt::TimeSpan{ 600000000LL }; 1627 if( data.trimEnd.count() <= 0 ) 1628 { 1629 data.trimEnd = data.videoDuration; 1630 } 1631 } 1632 } 1633 1634 // Clamp incoming selection to valid bounds now that duration is known. 1635 if( data.videoDuration.count() > 0 ) 1636 { 1637 const int64_t durationTicks = data.videoDuration.count(); 1638 const int64_t endTicks = (data.trimEnd.count() > 0) ? data.trimEnd.count() : durationTicks; 1639 const int64_t clampedEnd = std::clamp<int64_t>( endTicks, 0, durationTicks ); 1640 const int64_t clampedStart = std::clamp<int64_t>( data.trimStart.count(), 0, clampedEnd ); 1641 data.trimStart = winrt::TimeSpan{ clampedStart }; 1642 data.trimEnd = winrt::TimeSpan{ clampedEnd }; 1643 } 1644 1645 // Track initial selection so we can enable OK only when trimming changes. 1646 data.originalTrimStart = data.trimStart; 1647 data.originalTrimEnd = data.trimEnd; 1648 data.currentPosition = data.trimStart; 1649 data.playbackStartPosition = data.currentPosition; 1650 data.playbackStartPositionValid = true; 1651 1652 // Center dialog on the screen containing the parent window 1653 HMONITOR hMonitor = MonitorFromWindow(hParent, MONITOR_DEFAULTTONEAREST); 1654 MONITORINFO mi = { sizeof(mi) }; 1655 GetMonitorInfo(hMonitor, &mi); 1656 1657 // Calculate center position 1658 const int dialogWidth = 521; 1659 const int dialogHeight = 381; 1660 int x = mi.rcWork.left + (mi.rcWork.right - mi.rcWork.left - dialogWidth) / 2; 1661 int y = mi.rcWork.top + (mi.rcWork.bottom - mi.rcWork.top - dialogHeight) / 2; 1662 1663 // Store position for use in dialog proc 1664 data.dialogX = x; 1665 data.dialogY = y; 1666 1667 // Pre-load the first frame preview before showing the dialog to avoid "Preview not available" flash 1668 // Must run on a background thread because WinRT async .get() cannot be called on STA (UI) thread 1669 if (!data.isGif) 1670 { 1671 std::thread preloadThread([&data, &videoPath]() 1672 { 1673 winrt::init_apartment(winrt::apartment_type::multi_threaded); 1674 try 1675 { 1676 auto file = winrt::StorageFile::GetFileFromPathAsync(videoPath).get(); 1677 auto clip = winrt::MediaClip::CreateFromFileAsync(file).get(); 1678 1679 data.composition = winrt::MediaComposition(); 1680 data.composition.Clips().Append(clip); 1681 1682 // Update to actual duration from clip 1683 auto actualDuration = clip.OriginalDuration(); 1684 if (actualDuration.count() > 0) 1685 { 1686 // If trimEnd was at full length (whether estimated or passed in), snap it to the actual end. 1687 // This handles cases where the file-size estimate was longer or shorter than actual. 1688 const int64_t oldDurationTicks = data.videoDuration.count(); 1689 const int64_t oldTrimEndTicks = data.trimEnd.count(); 1690 const bool endWasFullLength = (oldTrimEndTicks <= 0) || 1691 (oldDurationTicks > 0 && oldTrimEndTicks >= oldDurationTicks); 1692 1693 data.videoDuration = actualDuration; 1694 1695 const int64_t newTrimEndTicks = endWasFullLength ? actualDuration.count() 1696 : (std::min)(oldTrimEndTicks, actualDuration.count()); 1697 data.trimEnd = winrt::TimeSpan{ newTrimEndTicks }; 1698 1699 const int64_t oldOrigEndTicks = data.originalTrimEnd.count(); 1700 const bool origEndWasFullLength = (oldOrigEndTicks <= 0) || 1701 (oldDurationTicks > 0 && oldOrigEndTicks >= oldDurationTicks); 1702 const int64_t newOrigEndTicks = origEndWasFullLength ? actualDuration.count() 1703 : (std::min)(oldOrigEndTicks, actualDuration.count()); 1704 data.originalTrimEnd = winrt::TimeSpan{ newOrigEndTicks }; 1705 } 1706 1707 // Get first frame thumbnail 1708 const int64_t requestTicks = std::clamp<int64_t>(data.currentPosition.count(), 0, data.videoDuration.count()); 1709 auto stream = data.composition.GetThumbnailAsync( 1710 winrt::TimeSpan{ requestTicks }, 1711 0, 0, 1712 winrt::VideoFramePrecision::NearestFrame).get(); 1713 1714 if (stream) 1715 { 1716 winrt::com_ptr<IWICImagingFactory> wicFactory; 1717 if (SUCCEEDED(CoCreateInstance(CLSID_WICImagingFactory, nullptr, CLSCTX_INPROC_SERVER, IID_PPV_ARGS(wicFactory.put())))) 1718 { 1719 winrt::com_ptr<IStream> istream; 1720 auto streamAsUnknown = static_cast<::IUnknown*>(winrt::get_abi(stream)); 1721 if (SUCCEEDED(CreateStreamOverRandomAccessStream(streamAsUnknown, IID_PPV_ARGS(istream.put()))) && istream) 1722 { 1723 winrt::com_ptr<IWICBitmapDecoder> decoder; 1724 if (SUCCEEDED(wicFactory->CreateDecoderFromStream(istream.get(), nullptr, WICDecodeMetadataCacheOnDemand, decoder.put()))) 1725 { 1726 winrt::com_ptr<IWICBitmapFrameDecode> frame; 1727 if (SUCCEEDED(decoder->GetFrame(0, frame.put()))) 1728 { 1729 winrt::com_ptr<IWICFormatConverter> converter; 1730 if (SUCCEEDED(wicFactory->CreateFormatConverter(converter.put()))) 1731 { 1732 if (SUCCEEDED(converter->Initialize(frame.get(), GUID_WICPixelFormat32bppBGRA, 1733 WICBitmapDitherTypeNone, nullptr, 0.0, 1734 WICBitmapPaletteTypeCustom))) 1735 { 1736 UINT width, height; 1737 converter->GetSize(&width, &height); 1738 1739 BITMAPINFO bmi = {}; 1740 bmi.bmiHeader.biSize = sizeof(BITMAPINFOHEADER); 1741 bmi.bmiHeader.biWidth = width; 1742 bmi.bmiHeader.biHeight = -static_cast<LONG>(height); 1743 bmi.bmiHeader.biPlanes = 1; 1744 bmi.bmiHeader.biBitCount = 32; 1745 bmi.bmiHeader.biCompression = BI_RGB; 1746 1747 void* bits = nullptr; 1748 HDC hdcScreen = GetDC(nullptr); 1749 HBITMAP hBitmap = CreateDIBSection(hdcScreen, &bmi, DIB_RGB_COLORS, &bits, nullptr, 0); 1750 ReleaseDC(nullptr, hdcScreen); 1751 1752 if (hBitmap && bits) 1753 { 1754 converter->CopyPixels(nullptr, width * 4, width * height * 4, static_cast<BYTE*>(bits)); 1755 data.hPreviewBitmap = hBitmap; 1756 data.previewBitmapOwned = true; 1757 data.lastRenderedPreview.store(requestTicks, std::memory_order_relaxed); 1758 } 1759 } 1760 } 1761 } 1762 } 1763 } 1764 } 1765 } 1766 } 1767 catch (...) 1768 { 1769 // If preloading fails, the dialog will show "Preview not available" briefly 1770 // but will recover via the async UpdateVideoPreview path 1771 } 1772 }); 1773 preloadThread.join(); 1774 } 1775 1776 auto result = DialogBoxParam( 1777 GetModuleHandle(nullptr), 1778 MAKEINTRESOURCE(IDD_VIDEO_TRIM), 1779 hParent, 1780 TrimDialogProc, 1781 reinterpret_cast<LPARAM>(&data)); 1782 1783 if (result == IDOK) 1784 { 1785 trimStart = data.trimStart; 1786 trimEnd = data.trimEnd; 1787 } 1788 1789 return result; 1790 } 1791 1792 static void UpdatePositionUI(HWND hDlg, VideoRecordingSession::TrimDialogData* pData, bool invalidateTimeline = true) 1793 { 1794 if (!pData || !hDlg) 1795 { 1796 return; 1797 } 1798 1799 const auto previewTime = pData->previewOverrideActive ? pData->previewOverride : pData->currentPosition; 1800 // Show time relative to left grip (trimStart) 1801 const auto relativeTime = winrt::TimeSpan{ (std::max)(previewTime.count() - pData->trimStart.count(), int64_t{ 0 }) }; 1802 SetTimeText(hDlg, IDC_TRIM_POSITION_LABEL, relativeTime, true); 1803 if (invalidateTimeline) 1804 { 1805 InvalidateRect(GetDlgItem(hDlg, IDC_TRIM_TIMELINE), nullptr, FALSE); 1806 } 1807 } 1808 1809 static void SyncMediaPlayerPosition(VideoRecordingSession::TrimDialogData* pData) 1810 { 1811 if (!pData || !pData->mediaPlayer) 1812 { 1813 return; 1814 } 1815 1816 try 1817 { 1818 auto session = pData->mediaPlayer.PlaybackSession(); 1819 if (session) 1820 { 1821 // The selection (trimStart..trimEnd) determines what will be trimmed, 1822 // but playback may start before trimStart. Clamp only to valid media bounds. 1823 const int64_t upper = (pData->trimEnd.count() > 0) ? pData->trimEnd.count() : pData->videoDuration.count(); 1824 const int64_t clampedTicks = std::clamp<int64_t>(pData->currentPosition.count(), 0, upper); 1825 session.Position(winrt::TimeSpan{ clampedTicks }); 1826 } 1827 } 1828 catch (...) 1829 { 1830 } 1831 } 1832 1833 static void CleanupMediaPlayer(VideoRecordingSession::TrimDialogData* pData) 1834 { 1835 if (!pData || !pData->mediaPlayer) 1836 { 1837 return; 1838 } 1839 1840 try 1841 { 1842 auto session = pData->mediaPlayer.PlaybackSession(); 1843 if (session) 1844 { 1845 if (pData->positionChangedToken.value) 1846 { 1847 session.PositionChanged(pData->positionChangedToken); 1848 pData->positionChangedToken = {}; 1849 } 1850 if (pData->stateChangedToken.value) 1851 { 1852 session.PlaybackStateChanged(pData->stateChangedToken); 1853 pData->stateChangedToken = {}; 1854 } 1855 } 1856 1857 if (pData->frameAvailableToken.value) 1858 { 1859 pData->mediaPlayer.VideoFrameAvailable(pData->frameAvailableToken); 1860 pData->frameAvailableToken = {}; 1861 } 1862 1863 pData->mediaPlayer.Close(); 1864 } 1865 catch (...) 1866 { 1867 } 1868 1869 pData->mediaPlayer = nullptr; 1870 pData->frameCopyInProgress.store(false, std::memory_order_relaxed); 1871 } 1872 1873 //---------------------------------------------------------------------------- 1874 // 1875 // Helper: Update video frame preview 1876 // 1877 //---------------------------------------------------------------------------- 1878 static void UpdateVideoPreview(HWND hDlg, VideoRecordingSession::TrimDialogData* pData, bool invalidateTimeline = true) 1879 { 1880 if (!pData) 1881 { 1882 return; 1883 } 1884 1885 const auto previewTime = pData->previewOverrideActive ? pData->previewOverride : pData->currentPosition; 1886 1887 // Update position label and timeline 1888 UpdatePositionUI(hDlg, pData, invalidateTimeline); 1889 1890 // When playing with the frame server, frames arrive via VideoFrameAvailable; avoid extra thumbnails. 1891 if (pData->isPlaying.load(std::memory_order_relaxed) && pData->mediaPlayer) 1892 { 1893 return; 1894 } 1895 1896 const int64_t requestTicks = previewTime.count(); 1897 pData->latestPreviewRequest.store(requestTicks, std::memory_order_relaxed); 1898 1899 if (pData->loadingPreview.exchange(true)) 1900 { 1901 // A preview request is already running; we'll schedule the latest once it completes. 1902 return; 1903 } 1904 1905 if (pData->isGif) 1906 { 1907 // Use request time directly (don't clamp to trim bounds) so thumbnail updates outside trim region 1908 const int64_t clampedTicks = std::clamp<int64_t>(requestTicks, 0, pData->videoDuration.count()); 1909 if (!pData->gifFrames.empty()) 1910 { 1911 const size_t frameIndex = FindGifFrameIndex(pData->gifFrames, clampedTicks); 1912 pData->gifLastFrameIndex = frameIndex; 1913 { 1914 std::lock_guard<std::mutex> lock(pData->previewBitmapMutex); 1915 if (pData->hPreviewBitmap && pData->previewBitmapOwned) 1916 { 1917 DeleteObject(pData->hPreviewBitmap); 1918 } 1919 pData->hPreviewBitmap = pData->gifFrames[frameIndex].hBitmap; 1920 pData->previewBitmapOwned = false; 1921 } 1922 1923 pData->lastRenderedPreview.store(clampedTicks, std::memory_order_relaxed); 1924 pData->loadingPreview.store(false, std::memory_order_relaxed); 1925 1926 if (hDlg) 1927 { 1928 InvalidateRect(GetDlgItem(hDlg, IDC_TRIM_PREVIEW), nullptr, FALSE); 1929 } 1930 return; 1931 } 1932 1933 pData->loadingPreview.store(false, std::memory_order_relaxed); 1934 return; 1935 } 1936 1937 std::thread([](HWND hDlg, VideoRecordingSession::TrimDialogData* pData, int64_t requestTicks) 1938 { 1939 winrt::init_apartment(winrt::apartment_type::multi_threaded); 1940 1941 const int64_t requestTicksRaw = requestTicks; 1942 bool updatedBitmap = false; 1943 1944 bool durationChanged = false; 1945 1946 try 1947 { 1948 if (!pData->composition) 1949 { 1950 auto file = winrt::StorageFile::GetFileFromPathAsync(pData->videoPath).get(); 1951 auto clip = winrt::MediaClip::CreateFromFileAsync(file).get(); 1952 1953 pData->composition = winrt::MediaComposition(); 1954 pData->composition.Clips().Append(clip); 1955 1956 auto actualDuration = clip.OriginalDuration(); 1957 if (actualDuration.count() > 0) 1958 { 1959 const int64_t oldDurationTicks = pData->videoDuration.count(); 1960 if (oldDurationTicks != actualDuration.count()) 1961 { 1962 durationChanged = true; 1963 } 1964 1965 // Update duration, but preserve a user-chosen trim end. 1966 // If the trim end was "full length" (old duration or 0), keep it full length. 1967 pData->videoDuration = actualDuration; 1968 1969 const int64_t oldTrimEndTicks = pData->trimEnd.count(); 1970 const bool endWasFullLength = (oldTrimEndTicks <= 0) || (oldDurationTicks > 0 && oldTrimEndTicks >= oldDurationTicks); 1971 const int64_t newTrimEndTicks = endWasFullLength ? actualDuration.count() 1972 : (std::min)(oldTrimEndTicks, actualDuration.count()); 1973 pData->trimEnd = winrt::TimeSpan{ newTrimEndTicks }; 1974 1975 const int64_t oldOrigEndTicks = pData->originalTrimEnd.count(); 1976 const bool origEndWasFullLength = (oldOrigEndTicks <= 0) || (oldDurationTicks > 0 && oldOrigEndTicks >= oldDurationTicks); 1977 const int64_t newOrigEndTicks = origEndWasFullLength ? actualDuration.count() 1978 : (std::min)(oldOrigEndTicks, actualDuration.count()); 1979 pData->originalTrimEnd = winrt::TimeSpan{ newOrigEndTicks }; 1980 1981 // Clamp starts to the new end. 1982 if (pData->originalTrimStart.count() > pData->originalTrimEnd.count()) 1983 { 1984 pData->originalTrimStart = pData->originalTrimEnd; 1985 } 1986 if (pData->trimStart.count() > pData->trimEnd.count()) 1987 { 1988 pData->trimStart = pData->trimEnd; 1989 } 1990 } 1991 } 1992 1993 auto composition = pData->composition; 1994 if (composition) 1995 { 1996 auto durationTicks = composition.Duration().count(); 1997 if (durationTicks > 0) 1998 { 1999 requestTicks = std::clamp<int64_t>(requestTicks, 0, durationTicks); 2000 } 2001 2002 const bool isPlaying = pData->isPlaying.load(std::memory_order_relaxed); 2003 const UINT32 reqW = isPlaying ? kPreviewRequestWidthPlaying : 0; 2004 const UINT32 reqH = isPlaying ? kPreviewRequestHeightPlaying : 0; 2005 2006 auto stream = composition.GetThumbnailAsync( 2007 winrt::TimeSpan{ requestTicks }, 2008 reqW, 2009 reqH, 2010 winrt::VideoFramePrecision::NearestFrame).get(); 2011 2012 if (stream) 2013 { 2014 winrt::com_ptr<IWICImagingFactory> wicFactory; 2015 if (SUCCEEDED(CoCreateInstance(CLSID_WICImagingFactory, nullptr, CLSCTX_INPROC_SERVER, IID_PPV_ARGS(wicFactory.put())))) 2016 { 2017 winrt::com_ptr<IStream> istream; 2018 auto streamAsUnknown = static_cast<::IUnknown*>(winrt::get_abi(stream)); 2019 if (SUCCEEDED(CreateStreamOverRandomAccessStream(streamAsUnknown, IID_PPV_ARGS(istream.put()))) && istream) 2020 { 2021 winrt::com_ptr<IWICBitmapDecoder> decoder; 2022 if (SUCCEEDED(wicFactory->CreateDecoderFromStream(istream.get(), nullptr, WICDecodeMetadataCacheOnDemand, decoder.put()))) 2023 { 2024 winrt::com_ptr<IWICBitmapFrameDecode> frame; 2025 if (SUCCEEDED(decoder->GetFrame(0, frame.put()))) 2026 { 2027 winrt::com_ptr<IWICFormatConverter> converter; 2028 if (SUCCEEDED(wicFactory->CreateFormatConverter(converter.put()))) 2029 { 2030 if (SUCCEEDED(converter->Initialize(frame.get(), GUID_WICPixelFormat32bppBGRA, 2031 WICBitmapDitherTypeNone, nullptr, 0.0, 2032 WICBitmapPaletteTypeCustom))) 2033 { 2034 UINT width, height; 2035 converter->GetSize(&width, &height); 2036 2037 BITMAPINFO bmi = {}; 2038 bmi.bmiHeader.biSize = sizeof(BITMAPINFOHEADER); 2039 bmi.bmiHeader.biWidth = width; 2040 bmi.bmiHeader.biHeight = -static_cast<LONG>(height); 2041 bmi.bmiHeader.biPlanes = 1; 2042 bmi.bmiHeader.biBitCount = 32; 2043 bmi.bmiHeader.biCompression = BI_RGB; 2044 2045 void* bits = nullptr; 2046 HDC hdcScreen = GetDC(nullptr); 2047 HBITMAP hBitmap = CreateDIBSection(hdcScreen, &bmi, DIB_RGB_COLORS, &bits, nullptr, 0); 2048 ReleaseDC(nullptr, hdcScreen); 2049 2050 if (hBitmap && bits) 2051 { 2052 converter->CopyPixels(nullptr, width * 4, width * height * 4, static_cast<BYTE*>(bits)); 2053 2054 { 2055 std::lock_guard<std::mutex> lock(pData->previewBitmapMutex); 2056 if (pData->hPreviewBitmap && pData->previewBitmapOwned) 2057 { 2058 DeleteObject(pData->hPreviewBitmap); 2059 } 2060 pData->hPreviewBitmap = hBitmap; 2061 pData->previewBitmapOwned = true; 2062 } 2063 updatedBitmap = true; 2064 } 2065 } 2066 } 2067 } 2068 } 2069 } 2070 } 2071 } 2072 } 2073 } 2074 catch (...) 2075 { 2076 } 2077 2078 pData->loadingPreview.store(false, std::memory_order_relaxed); 2079 2080 if (updatedBitmap) 2081 { 2082 pData->lastRenderedPreview.store(requestTicks, std::memory_order_relaxed); 2083 PostMessage(hDlg, WMU_PREVIEW_READY, 0, 0); 2084 } 2085 2086 if (pData->latestPreviewRequest.load(std::memory_order_relaxed) != requestTicksRaw) 2087 { 2088 PostMessage(hDlg, WMU_PREVIEW_SCHEDULED, 0, 0); 2089 } 2090 2091 if (durationChanged) 2092 { 2093 PostMessage(hDlg, WMU_DURATION_CHANGED, 0, 0); 2094 } 2095 }, hDlg, pData, requestTicks).detach(); 2096 } 2097 2098 //---------------------------------------------------------------------------- 2099 // 2100 // Helper: Draw custom timeline with handles 2101 // 2102 //---------------------------------------------------------------------------- 2103 static void DrawTimeline(HDC hdc, RECT rc, VideoRecordingSession::TrimDialogData* pData, UINT dpi) 2104 { 2105 const int width = rc.right - rc.left; 2106 const int height = rc.bottom - rc.top; 2107 2108 // Scale constants for DPI 2109 const int timelinePadding = ScaleForDpi(kTimelinePadding, dpi); 2110 const int timelineTrackHeight = ScaleForDpi(kTimelineTrackHeight, dpi); 2111 const int timelineTrackTopOffset = ScaleForDpi(kTimelineTrackTopOffset, dpi); 2112 const int timelineHandleHalfWidth = ScaleForDpi(kTimelineHandleHalfWidth, dpi); 2113 const int timelineHandleHeight = ScaleForDpi(kTimelineHandleHeight, dpi); 2114 2115 // Create memory DC for double buffering 2116 HDC hdcMem = CreateCompatibleDC(hdc); 2117 HBITMAP hbmMem = CreateCompatibleBitmap(hdc, width, height); 2118 HBITMAP hbmOld = static_cast<HBITMAP>(SelectObject(hdcMem, hbmMem)); 2119 2120 // Draw to memory DC - use dark mode colors if enabled 2121 const bool darkMode = IsDarkModeEnabled(); 2122 HBRUSH hBackground = CreateSolidBrush(darkMode ? DarkMode::BackgroundColor : GetSysColor(COLOR_BTNFACE)); 2123 RECT rcMem = { 0, 0, width, height }; 2124 FillRect(hdcMem, &rcMem, hBackground); 2125 DeleteObject(hBackground); 2126 2127 const int trackLeft = timelinePadding; 2128 const int trackRight = width - timelinePadding; 2129 const int trackTop = timelineTrackTopOffset; 2130 const int trackBottom = trackTop + timelineTrackHeight; 2131 2132 RECT rcTrack = { trackLeft, trackTop, trackRight, trackBottom }; 2133 HBRUSH hTrackBase = CreateSolidBrush(darkMode ? RGB(60, 60, 65) : RGB(214, 219, 224)); 2134 FillRect(hdcMem, &rcTrack, hTrackBase); 2135 DeleteObject(hTrackBase); 2136 2137 int startX = std::clamp(TimelineTimeToClientX(pData, pData->trimStart, width, dpi), trackLeft, trackRight); 2138 int endX = std::clamp(TimelineTimeToClientX(pData, pData->trimEnd, width, dpi), trackLeft, trackRight); 2139 if (endX < startX) 2140 { 2141 std::swap(startX, endX); 2142 } 2143 2144 RECT rcBefore{ trackLeft, trackTop, startX, trackBottom }; 2145 RECT rcAfter{ endX, trackTop, trackRight, trackBottom }; 2146 HBRUSH hMuted = CreateSolidBrush(darkMode ? RGB(50, 50, 55) : RGB(198, 202, 206)); 2147 FillRect(hdcMem, &rcBefore, hMuted); 2148 FillRect(hdcMem, &rcAfter, hMuted); 2149 DeleteObject(hMuted); 2150 2151 RECT rcActive{ startX, trackTop, endX, trackBottom }; 2152 HBRUSH hActive = CreateSolidBrush(RGB(90, 147, 250)); 2153 FillRect(hdcMem, &rcActive, hActive); 2154 DeleteObject(hActive); 2155 2156 HPEN hOutline = CreatePen(PS_SOLID, 1, darkMode ? RGB(80, 80, 85) : RGB(150, 150, 150)); 2157 HPEN hOldPen = static_cast<HPEN>(SelectObject(hdcMem, hOutline)); 2158 MoveToEx(hdcMem, trackLeft, trackTop, nullptr); 2159 LineTo(hdcMem, trackRight, trackTop); 2160 LineTo(hdcMem, trackRight, trackBottom); 2161 LineTo(hdcMem, trackLeft, trackBottom); 2162 LineTo(hdcMem, trackLeft, trackTop); 2163 SelectObject(hdcMem, hOldPen); 2164 DeleteObject(hOutline); 2165 2166 const int trackWidth = trackRight - trackLeft; 2167 if (trackWidth > 0 && pData && pData->videoDuration.count() > 0) 2168 { 2169 const int tickTop = trackBottom + ScaleForDpi(2, dpi); 2170 const int tickMajorBottom = tickTop + ScaleForDpi(10, dpi); 2171 const int tickMinorBottom = tickTop + ScaleForDpi(6, dpi); 2172 2173 const std::array<double, 5> fractions{ 0.0, 0.25, 0.5, 0.75, 1.0 }; 2174 HPEN hTickPen = CreatePen(PS_SOLID, 1, darkMode ? RGB(100, 100, 105) : RGB(150, 150, 150)); 2175 HPEN hOldTickPen = static_cast<HPEN>(SelectObject(hdcMem, hTickPen)); 2176 SetBkMode(hdcMem, TRANSPARENT); 2177 SetTextColor(hdcMem, darkMode ? RGB(140, 140, 140) : RGB(80, 80, 80)); 2178 2179 // Use consistent font for all timeline text - scale for DPI (12pt) 2180 const int fontSize = -MulDiv(12, static_cast<int>(dpi), USER_DEFAULT_SCREEN_DPI); 2181 HFONT hTimelineFont = CreateFont(fontSize, 0, 0, 0, FW_NORMAL, FALSE, FALSE, FALSE, DEFAULT_CHARSET, 2182 OUT_DEFAULT_PRECIS, CLIP_DEFAULT_PRECIS, CLEARTYPE_QUALITY, 2183 DEFAULT_PITCH | FF_SWISS, L"Segoe UI"); 2184 HFONT hOldTimelineFont = static_cast<HFONT>(SelectObject(hdcMem, hTimelineFont)); 2185 2186 for (size_t i = 0; i < fractions.size(); ++i) 2187 { 2188 const double fraction = fractions[i]; 2189 const int x = trackLeft + static_cast<int>(std::round(fraction * trackWidth)); 2190 const bool isMajor = (fraction == 0.0) || (fraction == 0.5) || (fraction == 1.0); 2191 MoveToEx(hdcMem, x, tickTop, nullptr); 2192 LineTo(hdcMem, x, isMajor ? tickMajorBottom : tickMinorBottom); 2193 2194 if (fraction > 0.0 && fraction < 1.0) 2195 { 2196 // Calculate marker time within the full video duration (untrimmed) 2197 const auto markerTime = winrt::TimeSpan{ static_cast<int64_t>(fraction * pData->videoDuration.count()) }; 2198 // For short videos (under 60 seconds), show fractional seconds to distinguish markers 2199 const bool showMilliseconds = (pData->videoDuration.count() < 600000000LL); // 60 seconds in 100ns ticks 2200 const std::wstring markerText = FormatTrimTime(markerTime, showMilliseconds); 2201 const int markerHalfWidth = ScaleForDpi(showMilliseconds ? 45 : 35, dpi); 2202 const int markerHeight = ScaleForDpi(26, dpi); 2203 RECT rcMarker{ x - markerHalfWidth, tickMajorBottom + ScaleForDpi(10, dpi), x + markerHalfWidth, tickMajorBottom + ScaleForDpi(2, dpi) + markerHeight }; 2204 DrawText(hdcMem, markerText.c_str(), -1, &rcMarker, DT_CENTER | DT_TOP | DT_SINGLELINE | DT_NOPREFIX); 2205 } 2206 } 2207 2208 SelectObject(hdcMem, hOldTimelineFont); 2209 DeleteObject(hTimelineFont); 2210 SelectObject(hdcMem, hOldTickPen); 2211 DeleteObject(hTickPen); 2212 } 2213 2214 auto drawGripper = [&](int x) 2215 { 2216 RECT handleRect{ 2217 x - timelineHandleHalfWidth, 2218 trackTop - (timelineHandleHeight - timelineTrackHeight) / 2, 2219 x + timelineHandleHalfWidth, 2220 trackTop - (timelineHandleHeight - timelineTrackHeight) / 2 + timelineHandleHeight 2221 }; 2222 2223 const COLORREF fillColor = darkMode ? RGB(165, 165, 165) : RGB(200, 200, 200); 2224 const COLORREF lineColor = darkMode ? RGB(90, 90, 90) : RGB(120, 120, 120); 2225 const int cornerRadius = (std::max)(ScaleForDpi(6, dpi), timelineHandleHalfWidth); 2226 const int lineInset = ScaleForDpi(6, dpi); 2227 const int lineWidth = (std::max)(1, ScaleForDpi(2, dpi)); 2228 2229 HBRUSH hFill = CreateSolidBrush(fillColor); 2230 HPEN hNullPen = static_cast<HPEN>(SelectObject(hdcMem, GetStockObject(NULL_PEN))); 2231 HBRUSH hOldBrush2 = static_cast<HBRUSH>(SelectObject(hdcMem, hFill)); 2232 RoundRect(hdcMem, handleRect.left, handleRect.top, handleRect.right, handleRect.bottom, cornerRadius, cornerRadius); 2233 SelectObject(hdcMem, hOldBrush2); 2234 SelectObject(hdcMem, hNullPen); 2235 DeleteObject(hFill); 2236 2237 // Dark vertical line in the middle. 2238 HPEN hLinePen = CreatePen(PS_SOLID, lineWidth, lineColor); 2239 HPEN hOldLinePen = static_cast<HPEN>(SelectObject(hdcMem, hLinePen)); 2240 const int y1 = handleRect.top + lineInset; 2241 const int y2 = handleRect.bottom - lineInset; 2242 MoveToEx(hdcMem, x, y1, nullptr); 2243 LineTo(hdcMem, x, y2); 2244 SelectObject(hdcMem, hOldLinePen); 2245 DeleteObject(hLinePen); 2246 }; 2247 2248 drawGripper(startX); 2249 drawGripper(endX); 2250 2251 const int posX = std::clamp(TimelineTimeToClientX(pData, pData->currentPosition, width, dpi), trackLeft, trackRight); 2252 const int posLineWidth = ScaleForDpi(2, dpi); 2253 const int posLineExtend = ScaleForDpi(12, dpi); 2254 const int posLineBelow = ScaleForDpi(22, dpi); 2255 HPEN hPositionPen = CreatePen(PS_SOLID, posLineWidth, RGB(33, 150, 243)); 2256 hOldPen = static_cast<HPEN>(SelectObject(hdcMem, hPositionPen)); 2257 MoveToEx(hdcMem, posX, trackTop - posLineExtend, nullptr); 2258 LineTo(hdcMem, posX, trackBottom + posLineBelow); 2259 SelectObject(hdcMem, hOldPen); 2260 DeleteObject(hPositionPen); 2261 2262 const int ellipseRadius = ScaleForDpi(6, dpi); 2263 const int ellipseTop = ScaleForDpi(12, dpi); 2264 const int ellipseBottom = ScaleForDpi(24, dpi); 2265 HBRUSH hPositionBrush = CreateSolidBrush(RGB(33, 150, 243)); 2266 HBRUSH hOldBrush = static_cast<HBRUSH>(SelectObject(hdcMem, hPositionBrush)); 2267 HPEN hOldPenForEllipse = static_cast<HPEN>(SelectObject(hdcMem, GetStockObject(NULL_PEN))); 2268 Ellipse(hdcMem, posX - ellipseRadius, trackBottom + ellipseTop, posX + ellipseRadius, trackBottom + ellipseBottom); 2269 SelectObject(hdcMem, hOldPenForEllipse); 2270 SelectObject(hdcMem, hOldBrush); 2271 DeleteObject(hPositionBrush); 2272 2273 // Set font for start/end labels (same font used for tick labels - 12pt) 2274 SetBkMode(hdcMem, TRANSPARENT); 2275 SetTextColor(hdcMem, darkMode ? RGB(140, 140, 140) : RGB(80, 80, 80)); 2276 int labelFontSize = -MulDiv(12, static_cast<int>(dpi), USER_DEFAULT_SCREEN_DPI); 2277 HFONT hFont = CreateFont(labelFontSize, 0, 0, 0, FW_NORMAL, FALSE, FALSE, FALSE, DEFAULT_CHARSET, 2278 OUT_DEFAULT_PRECIS, CLIP_DEFAULT_PRECIS, CLEARTYPE_QUALITY, 2279 DEFAULT_PITCH | FF_SWISS, L"Segoe UI"); 2280 HFONT hOldFont = static_cast<HFONT>(SelectObject(hdcMem, hFont)); 2281 2282 // Align with intermediate marker labels: use same calculation as rcMarker 2283 // tickTop = trackBottom + 10, tickMajorBottom = tickTop + 10, marker starts at tickMajorBottom + 2 2284 const int tickTopForLabels = trackBottom + ScaleForDpi(10, dpi); 2285 const int tickMajorBottomForLabels = tickTopForLabels + ScaleForDpi(10, dpi); 2286 int labelTop = tickMajorBottomForLabels + ScaleForDpi(2, dpi); 2287 int labelBottom = labelTop + ScaleForDpi(26, dpi); 2288 // For short videos (under 60 seconds), show fractional seconds 2289 const bool showMilliseconds = (pData->videoDuration.count() < 600000000LL); // 60 seconds in 100ns ticks 2290 int labelWidth = ScaleForDpi(showMilliseconds ? 80 : 70, dpi); 2291 // Start label: draw to the right of trackLeft (left-aligned) 2292 RECT rcStartLabel{ trackLeft, labelTop, trackLeft + labelWidth, labelBottom }; 2293 const std::wstring startLabel = FormatTrimTime(pData->trimStart, showMilliseconds); 2294 DrawText(hdcMem, startLabel.c_str(), -1, &rcStartLabel, DT_LEFT | DT_TOP | DT_SINGLELINE); 2295 2296 // End label: draw to the left of trackRight (right-aligned) 2297 RECT rcEndLabel{ trackRight - labelWidth, labelTop, trackRight, labelBottom }; 2298 const std::wstring endLabel = FormatTrimTime(pData->trimEnd, showMilliseconds); 2299 DrawText(hdcMem, endLabel.c_str(), -1, &rcEndLabel, DT_RIGHT | DT_TOP | DT_SINGLELINE); 2300 2301 SelectObject(hdcMem, hOldFont); 2302 DeleteObject(hFont); 2303 2304 // Copy the buffered image to the screen 2305 BitBlt(hdc, rc.left, rc.top, width, height, hdcMem, 0, 0, SRCCOPY); 2306 2307 // Clean up 2308 SelectObject(hdcMem, hbmOld); 2309 DeleteObject(hbmMem); 2310 DeleteDC(hdcMem); 2311 } 2312 2313 //---------------------------------------------------------------------------- 2314 // 2315 // Helper: Mouse interaction for the trim timeline 2316 // 2317 //---------------------------------------------------------------------------- 2318 namespace 2319 { 2320 constexpr UINT_PTR kPlaybackTimerId = 1; 2321 constexpr UINT kPlaybackTimerIntervalMs = 16; // Fallback for GIF; MP4 uses multimedia timer 2322 constexpr int64_t kPlaybackStepTicks = static_cast<int64_t>(kPlaybackTimerIntervalMs) * 10'000; 2323 constexpr UINT WMU_MM_TIMER_TICK = WM_USER + 10; // Posted by multimedia timer callback 2324 constexpr UINT kMMTimerIntervalMs = 8; // 8ms for ~120Hz update rate 2325 } 2326 2327 // Multimedia timer callback - runs in a separate thread, just posts a message 2328 static void CALLBACK MMTimerCallback(UINT /*uTimerID*/, UINT /*uMsg*/, DWORD_PTR dwUser, DWORD_PTR /*dw1*/, DWORD_PTR /*dw2*/) 2329 { 2330 HWND hDlg = reinterpret_cast<HWND>(dwUser); 2331 if (hDlg && IsWindow(hDlg)) 2332 { 2333 PostMessage(hDlg, WMU_MM_TIMER_TICK, 0, 0); 2334 } 2335 } 2336 2337 static void StopMMTimer(VideoRecordingSession::TrimDialogData* pData) 2338 { 2339 if (pData && pData->mmTimerId != 0) 2340 { 2341 timeKillEvent(pData->mmTimerId); 2342 pData->mmTimerId = 0; 2343 } 2344 } 2345 2346 static bool StartMMTimer(HWND hDlg, VideoRecordingSession::TrimDialogData* pData) 2347 { 2348 if (!pData || !hDlg) 2349 { 2350 return false; 2351 } 2352 2353 StopMMTimer(pData); 2354 2355 pData->mmTimerId = timeSetEvent( 2356 kMMTimerIntervalMs, 2357 1, // 1ms resolution 2358 MMTimerCallback, 2359 reinterpret_cast<DWORD_PTR>(hDlg), 2360 TIME_PERIODIC | TIME_KILL_SYNCHRONOUS); 2361 2362 return pData->mmTimerId != 0; 2363 } 2364 2365 static void RefreshPlaybackButtons(HWND hDlg) 2366 { 2367 if (!hDlg) 2368 { 2369 return; 2370 } 2371 2372 InvalidateRect(GetDlgItem(hDlg, IDC_TRIM_SKIP_START), nullptr, FALSE); 2373 InvalidateRect(GetDlgItem(hDlg, IDC_TRIM_REWIND), nullptr, FALSE); 2374 InvalidateRect(GetDlgItem(hDlg, IDC_TRIM_PLAY_PAUSE), nullptr, FALSE); 2375 InvalidateRect(GetDlgItem(hDlg, IDC_TRIM_FORWARD), nullptr, FALSE); 2376 InvalidateRect(GetDlgItem(hDlg, IDC_TRIM_SKIP_END), nullptr, FALSE); 2377 } 2378 2379 static void HandlePlaybackCommand(int controlId, VideoRecordingSession::TrimDialogData* pData) 2380 { 2381 if (!pData || !pData->hDialog) 2382 { 2383 return; 2384 } 2385 2386 HWND hDlg = pData->hDialog; 2387 2388 // Helper lambda to invalidate cached start frame when position changes 2389 auto invalidateCachedFrame = [pData]() 2390 { 2391 std::lock_guard<std::mutex> lock(pData->previewBitmapMutex); 2392 if (pData->hCachedStartFrame) 2393 { 2394 DeleteObject(pData->hCachedStartFrame); 2395 pData->hCachedStartFrame = nullptr; 2396 } 2397 }; 2398 2399 switch (controlId) 2400 { 2401 case IDC_TRIM_PLAY_PAUSE: 2402 if (pData->isPlaying.load(std::memory_order_relaxed)) 2403 { 2404 StopPlayback(hDlg, pData, true); 2405 } 2406 else 2407 { 2408 // Always start playback from current time selector position 2409 pData->playbackStartPosition = pData->currentPosition; 2410 pData->playbackStartPositionValid = true; 2411 invalidateCachedFrame(); 2412 StartPlaybackAsync(hDlg, pData); 2413 } 2414 break; 2415 2416 case IDC_TRIM_REWIND: 2417 { 2418 StopPlayback(hDlg, pData, false); 2419 // Use 1 second step for timelines < 20 seconds, 2 seconds 2420 const int64_t duration = pData->trimEnd.count() - pData->trimStart.count(); 2421 const int64_t stepTicks = (duration < 200'000'000) ? 10'000'000 : kJogStepTicks; 2422 const int64_t newTicks = (std::max)(pData->trimStart.count(), pData->currentPosition.count() - stepTicks); 2423 pData->currentPosition = winrt::TimeSpan{ newTicks }; 2424 pData->playbackStartPosition = pData->currentPosition; 2425 pData->playbackStartPositionValid = true; 2426 invalidateCachedFrame(); 2427 SyncMediaPlayerPosition(pData); 2428 UpdateVideoPreview(hDlg, pData); 2429 break; 2430 } 2431 2432 case IDC_TRIM_FORWARD: 2433 { 2434 StopPlayback(hDlg, pData, false); 2435 // Use 1 second step for timelines < 20 seconds, 2 seconds 2436 const int64_t duration = pData->trimEnd.count() - pData->trimStart.count(); 2437 const int64_t stepTicks = (duration < 200'000'000) ? 10'000'000 : kJogStepTicks; 2438 const int64_t newTicks = (std::min)(pData->trimEnd.count(), pData->currentPosition.count() + stepTicks); 2439 pData->currentPosition = winrt::TimeSpan{ newTicks }; 2440 pData->playbackStartPosition = pData->currentPosition; 2441 pData->playbackStartPositionValid = true; 2442 invalidateCachedFrame(); 2443 SyncMediaPlayerPosition(pData); 2444 UpdateVideoPreview(hDlg, pData); 2445 break; 2446 } 2447 2448 case IDC_TRIM_SKIP_END: 2449 { 2450 StopPlayback(hDlg, pData, false); 2451 pData->currentPosition = pData->trimEnd; 2452 pData->playbackStartPosition = pData->currentPosition; 2453 pData->playbackStartPositionValid = true; 2454 invalidateCachedFrame(); 2455 SyncMediaPlayerPosition(pData); 2456 UpdateVideoPreview(hDlg, pData); 2457 break; 2458 } 2459 2460 default: 2461 StopPlayback(hDlg, pData, false); 2462 pData->currentPosition = pData->trimStart; 2463 pData->playbackStartPosition = pData->currentPosition; 2464 pData->playbackStartPositionValid = true; 2465 invalidateCachedFrame(); 2466 SyncMediaPlayerPosition(pData); 2467 UpdateVideoPreview(hDlg, pData); 2468 break; 2469 } 2470 2471 RefreshPlaybackButtons(hDlg); 2472 } 2473 2474 static void StopPlayback(HWND hDlg, VideoRecordingSession::TrimDialogData* pData, bool capturePosition) 2475 { 2476 if (!pData) 2477 { 2478 return; 2479 } 2480 2481 // Invalidate any in-flight StartPlaybackAsync continuation (e.g., after awaiting file load). 2482 pData->playbackCommandSerial.fetch_add(1, std::memory_order_acq_rel); 2483 2484 const bool wasPlaying = pData->isPlaying.exchange(false, std::memory_order_acq_rel); 2485 ResetSmoothPlayback(pData); 2486 2487 // Cancel any pending initial seek suppression. 2488 pData->pendingInitialSeek.store(false, std::memory_order_relaxed); 2489 pData->pendingInitialSeekTicks.store(0, std::memory_order_relaxed); 2490 2491 // Stop audio playback and align media position with UI state, but keep player alive for resume 2492 if (pData->mediaPlayer) 2493 { 2494 try 2495 { 2496 auto session = pData->mediaPlayer.PlaybackSession(); 2497 if (session) 2498 { 2499 if (capturePosition) 2500 { 2501 pData->currentPosition = session.Position(); 2502 } 2503 session.Position(pData->currentPosition); 2504 } 2505 pData->mediaPlayer.Pause(); 2506 } 2507 catch (...) 2508 { 2509 } 2510 } 2511 2512 if (hDlg) 2513 { 2514 if (wasPlaying) 2515 { 2516 StopMMTimer(pData); // Stop multimedia timer for MP4 2517 KillTimer(hDlg, kPlaybackTimerId); // Stop regular timer for GIF 2518 } 2519 RefreshPlaybackButtons(hDlg); 2520 } 2521 } 2522 2523 static winrt::fire_and_forget StartPlaybackAsync(HWND hDlg, VideoRecordingSession::TrimDialogData* pData) 2524 { 2525 if (!pData || !hDlg) 2526 { 2527 co_return; 2528 } 2529 2530 if (pData->trimEnd.count() <= pData->trimStart.count()) 2531 { 2532 co_return; 2533 } 2534 2535 ResetSmoothPlayback(pData); 2536 2537 // If playhead is at/past selection end, restart from trimStart. 2538 if (pData->currentPosition.count() >= pData->trimEnd.count()) 2539 { 2540 pData->currentPosition = pData->trimStart; 2541 UpdateVideoPreview(hDlg, pData); 2542 } 2543 2544 // Capture resume position (where playback should start/resume from). 2545 const auto resumePosition = pData->currentPosition; 2546 2547 // Suppress the brief Position==0 report before the initial seek takes effect. 2548 pData->pendingInitialSeek.store(resumePosition.count() > 0, std::memory_order_relaxed); 2549 pData->pendingInitialSeekTicks.store(resumePosition.count(), std::memory_order_relaxed); 2550 2551 // Capture loop anchor only if not already set by an explicit user positioning. 2552 // This keeps the loop point stable across pause/resume. 2553 if (!pData->playbackStartPositionValid) 2554 { 2555 pData->playbackStartPosition = resumePosition; 2556 pData->playbackStartPositionValid = true; 2557 } 2558 2559 // Cache the current preview frame for instant restore when playback stops. 2560 // Only cache if we have a valid preview and it matches the playback start position. 2561 { 2562 std::lock_guard<std::mutex> lock(pData->previewBitmapMutex); 2563 // Clear any previous cached frame 2564 if (pData->hCachedStartFrame) 2565 { 2566 DeleteObject(pData->hCachedStartFrame); 2567 pData->hCachedStartFrame = nullptr; 2568 } 2569 // Cache if we have a valid preview at the current position 2570 if (pData->hPreviewBitmap && pData->lastRenderedPreview.load(std::memory_order_relaxed) >= 0) 2571 { 2572 // Duplicate the bitmap so we have our own copy 2573 BITMAP bm{}; 2574 if (GetObject(pData->hPreviewBitmap, sizeof(bm), &bm)) 2575 { 2576 HDC hdcScreen = GetDC(nullptr); 2577 HDC hdcSrc = CreateCompatibleDC(hdcScreen); 2578 HDC hdcDst = CreateCompatibleDC(hdcScreen); 2579 HBITMAP hCopy = CreateCompatibleBitmap(hdcScreen, bm.bmWidth, bm.bmHeight); 2580 if (hCopy) 2581 { 2582 HBITMAP hOldSrc = static_cast<HBITMAP>(SelectObject(hdcSrc, pData->hPreviewBitmap)); 2583 HBITMAP hOldDst = static_cast<HBITMAP>(SelectObject(hdcDst, hCopy)); 2584 BitBlt(hdcDst, 0, 0, bm.bmWidth, bm.bmHeight, hdcSrc, 0, 0, SRCCOPY); 2585 SelectObject(hdcSrc, hOldSrc); 2586 SelectObject(hdcDst, hOldDst); 2587 pData->hCachedStartFrame = hCopy; 2588 pData->cachedStartFramePosition = pData->playbackStartPosition; 2589 } 2590 DeleteDC(hdcSrc); 2591 DeleteDC(hdcDst); 2592 ReleaseDC(nullptr, hdcScreen); 2593 } 2594 } 2595 } 2596 2597 #if _DEBUG 2598 OutputDebugStringW((L"[Trim] StartPlayback: currentPos=" + std::to_wstring(pData->currentPosition.count()) + 2599 L" playbackStartPos=" + std::to_wstring(pData->playbackStartPosition.count()) + 2600 L" trimStart=" + std::to_wstring(pData->trimStart.count()) + 2601 L" trimEnd=" + std::to_wstring(pData->trimEnd.count()) + L"\n").c_str()); 2602 #endif 2603 2604 bool expected = false; 2605 if (!pData->isPlaying.compare_exchange_strong(expected, true, std::memory_order_relaxed)) 2606 { 2607 co_return; 2608 } 2609 2610 const uint64_t startSerial = pData->playbackCommandSerial.fetch_add(1, std::memory_order_acq_rel) + 1; 2611 2612 if (pData->isGif) 2613 { 2614 // Initialize GIF timing so playback begins at the current playhead position 2615 // (not at the start of the containing frame). 2616 auto now = std::chrono::steady_clock::now(); 2617 if (!pData->gifFrames.empty() && pData->videoDuration.count() > 0) 2618 { 2619 const int64_t clampedTicks = std::clamp<int64_t>(resumePosition.count(), 0, pData->videoDuration.count()); 2620 const size_t frameIndex = FindGifFrameIndex(pData->gifFrames, clampedTicks); 2621 const auto& frame = pData->gifFrames[frameIndex]; 2622 const int64_t offsetTicks = std::clamp<int64_t>(clampedTicks - frame.start.count(), 0, frame.duration.count()); 2623 const auto offsetMs = std::chrono::milliseconds(offsetTicks / 10'000); 2624 pData->gifFrameStartTime = now - offsetMs; 2625 } 2626 else 2627 { 2628 pData->gifFrameStartTime = now; 2629 } 2630 2631 // Update lastPlayheadX to current position so timer ticks can track movement properly 2632 { 2633 HWND hTimeline = GetDlgItem(hDlg, IDC_TRIM_TIMELINE); 2634 if (hTimeline) 2635 { 2636 RECT rc; 2637 GetClientRect(hTimeline, &rc); 2638 const UINT dpi = GetDpiForWindowHelper(hTimeline); 2639 pData->lastPlayheadX = TimelineTimeToClientX(pData, pData->currentPosition, rc.right - rc.left, dpi); 2640 } 2641 } 2642 2643 // Use multimedia timer for smooth GIF playback 2644 if (!StartMMTimer(hDlg, pData)) 2645 { 2646 pData->isPlaying.store(false, std::memory_order_relaxed); 2647 RefreshPlaybackButtons(hDlg); 2648 co_return; 2649 } 2650 2651 PostMessage(hDlg, WMU_PLAYBACK_POSITION, 0, 0); 2652 RefreshPlaybackButtons(hDlg); 2653 co_return; 2654 } 2655 2656 // If a player already exists (paused), resume from the current playhead position. 2657 if (pData->mediaPlayer) 2658 { 2659 // If the user already canceled playback, do nothing. 2660 if (!pData->isPlaying.load(std::memory_order_acquire) || 2661 pData->playbackCommandSerial.load(std::memory_order_acquire) != startSerial) 2662 { 2663 pData->isPlaying.store(false, std::memory_order_relaxed); 2664 RefreshPlaybackButtons(hDlg); 2665 co_return; 2666 } 2667 2668 try 2669 { 2670 auto session = pData->mediaPlayer.PlaybackSession(); 2671 if (session) 2672 { 2673 // Resume from the current playhead position (do not change the loop anchor) 2674 const int64_t clampedTicks = std::clamp<int64_t>(resumePosition.count(), 0, pData->trimEnd.count()); 2675 session.Position(winrt::TimeSpan{ clampedTicks }); 2676 pData->currentPosition = winrt::TimeSpan{ clampedTicks }; 2677 // Defer smoothing until the first real media sample to avoid extrapolating from zero 2678 pData->smoothActive.store(false, std::memory_order_relaxed); 2679 pData->smoothHasNonZeroSample.store(false, std::memory_order_relaxed); 2680 } 2681 pData->mediaPlayer.Play(); 2682 } 2683 catch (...) 2684 { 2685 } 2686 2687 // Use multimedia timer for smooth updates 2688 if (!StartMMTimer(hDlg, pData)) 2689 { 2690 pData->isPlaying.store(false, std::memory_order_relaxed); 2691 ResetSmoothPlayback(pData); 2692 RefreshPlaybackButtons(hDlg); 2693 co_return; 2694 } 2695 2696 // Update lastPlayheadX to current position so timer ticks can track movement properly 2697 { 2698 HWND hTimeline = GetDlgItem(hDlg, IDC_TRIM_TIMELINE); 2699 if (hTimeline) 2700 { 2701 RECT rc; 2702 GetClientRect(hTimeline, &rc); 2703 const UINT dpi = GetDpiForWindowHelper(hTimeline); 2704 pData->lastPlayheadX = TimelineTimeToClientX(pData, pData->currentPosition, rc.right - rc.left, dpi); 2705 } 2706 } 2707 2708 PostMessage(hDlg, WMU_PLAYBACK_POSITION, 0, 0); 2709 RefreshPlaybackButtons(hDlg); 2710 co_return; 2711 } 2712 2713 CleanupMediaPlayer(pData); 2714 2715 winrt::MediaPlayer newPlayer{ nullptr }; 2716 2717 try 2718 { 2719 if (!pData->playbackFile) 2720 { 2721 auto file = co_await winrt::StorageFile::GetFileFromPathAsync(pData->videoPath); 2722 pData->playbackFile = file; 2723 } 2724 2725 // The user may have clicked Pause while the async file lookup was in-flight. 2726 if (!pData->isPlaying.load(std::memory_order_acquire) || 2727 pData->playbackCommandSerial.load(std::memory_order_acquire) != startSerial) 2728 { 2729 pData->isPlaying.store(false, std::memory_order_relaxed); 2730 RefreshPlaybackButtons(hDlg); 2731 co_return; 2732 } 2733 2734 if (!pData->playbackFile) 2735 { 2736 throw winrt::hresult_error(E_FAIL); 2737 } 2738 2739 newPlayer = winrt::MediaPlayer(); 2740 newPlayer.AudioCategory(winrt::MediaPlayerAudioCategory::Media); 2741 newPlayer.IsVideoFrameServerEnabled(true); 2742 newPlayer.AutoPlay(false); 2743 newPlayer.Volume(pData->volume); 2744 newPlayer.IsMuted(pData->volume == 0.0); 2745 2746 pData->frameCopyInProgress.store(false, std::memory_order_relaxed); 2747 pData->mediaPlayer = newPlayer; 2748 2749 auto mediaSource = winrt::MediaSource::CreateFromStorageFile(pData->playbackFile); 2750 VideoRecordingSession::TrimDialogData* dataPtr = pData; 2751 2752 pData->frameAvailableToken = pData->mediaPlayer.VideoFrameAvailable([hDlg, dataPtr](auto const& sender, auto const&) 2753 { 2754 if (!dataPtr) 2755 { 2756 return; 2757 } 2758 2759 if (dataPtr->frameCopyInProgress.exchange(true, std::memory_order_relaxed)) 2760 { 2761 return; 2762 } 2763 2764 try 2765 { 2766 if (!EnsurePlaybackDevice(dataPtr)) 2767 { 2768 dataPtr->frameCopyInProgress.store(false, std::memory_order_relaxed); 2769 return; 2770 } 2771 2772 auto session = sender.PlaybackSession(); 2773 UINT width = session.NaturalVideoWidth(); 2774 UINT height = session.NaturalVideoHeight(); 2775 if (width == 0 || height == 0) 2776 { 2777 width = 640; 2778 height = 360; 2779 } 2780 2781 if (!EnsureFrameTextures(dataPtr, width, height)) 2782 { 2783 dataPtr->frameCopyInProgress.store(false, std::memory_order_relaxed); 2784 return; 2785 } 2786 2787 winrt::com_ptr<IDXGISurface> dxgiSurface; 2788 if (dataPtr->previewFrameTexture) 2789 { 2790 dxgiSurface = dataPtr->previewFrameTexture.as<IDXGISurface>(); 2791 } 2792 2793 if (dxgiSurface) 2794 { 2795 winrt::com_ptr<IInspectable> inspectableSurface; 2796 if (SUCCEEDED(CreateDirect3D11SurfaceFromDXGISurface(dxgiSurface.get(), inspectableSurface.put()))) 2797 { 2798 auto surface = inspectableSurface.as<winrt::Windows::Graphics::DirectX::Direct3D11::IDirect3DSurface>(); 2799 sender.CopyFrameToVideoSurface(surface); 2800 2801 if (dataPtr->previewD3DContext && dataPtr->previewFrameStaging) 2802 { 2803 dataPtr->previewD3DContext->CopyResource(dataPtr->previewFrameStaging.get(), dataPtr->previewFrameTexture.get()); 2804 2805 D3D11_MAPPED_SUBRESOURCE mapped{}; 2806 if (SUCCEEDED(dataPtr->previewD3DContext->Map(dataPtr->previewFrameStaging.get(), 0, D3D11_MAP_READ, 0, &mapped))) 2807 { 2808 const UINT rowPitch = mapped.RowPitch; 2809 const UINT bytesPerPixel = 4; 2810 const UINT destStride = width * bytesPerPixel; 2811 2812 BITMAPINFO bmi{}; 2813 bmi.bmiHeader.biSize = sizeof(BITMAPINFOHEADER); 2814 bmi.bmiHeader.biWidth = static_cast<LONG>(width); 2815 bmi.bmiHeader.biHeight = -static_cast<LONG>(height); 2816 bmi.bmiHeader.biPlanes = 1; 2817 bmi.bmiHeader.biBitCount = 32; 2818 bmi.bmiHeader.biCompression = BI_RGB; 2819 2820 void* bits = nullptr; 2821 HDC hdcScreen = GetDC(nullptr); 2822 HBITMAP hBitmap = CreateDIBSection(hdcScreen, &bmi, DIB_RGB_COLORS, &bits, nullptr, 0); 2823 ReleaseDC(nullptr, hdcScreen); 2824 2825 if (hBitmap && bits) 2826 { 2827 BYTE* dest = static_cast<BYTE*>(bits); 2828 const BYTE* src = static_cast<const BYTE*>(mapped.pData); 2829 for (UINT y = 0; y < height; ++y) 2830 { 2831 memcpy(dest + static_cast<size_t>(y) * destStride, src + static_cast<size_t>(y) * rowPitch, destStride); 2832 } 2833 2834 { 2835 std::lock_guard<std::mutex> lock(dataPtr->previewBitmapMutex); 2836 if (dataPtr->hPreviewBitmap && dataPtr->previewBitmapOwned) 2837 { 2838 DeleteObject(dataPtr->hPreviewBitmap); 2839 } 2840 dataPtr->hPreviewBitmap = hBitmap; 2841 dataPtr->previewBitmapOwned = true; 2842 } 2843 2844 PostMessage(hDlg, WMU_PREVIEW_READY, 0, 0); 2845 } 2846 else if (hBitmap) 2847 { 2848 DeleteObject(hBitmap); 2849 } 2850 2851 dataPtr->previewD3DContext->Unmap(dataPtr->previewFrameStaging.get(), 0); 2852 } 2853 } 2854 } 2855 } 2856 } 2857 catch (...) 2858 { 2859 } 2860 2861 dataPtr->frameCopyInProgress.store(false, std::memory_order_relaxed); 2862 }); 2863 2864 auto session = pData->mediaPlayer.PlaybackSession(); 2865 pData->positionChangedToken = session.PositionChanged([hDlg, dataPtr](auto const& sender, auto const&) 2866 { 2867 if (!dataPtr) 2868 { 2869 return; 2870 } 2871 2872 try 2873 { 2874 // When not playing, ignore media callbacks so UI-driven seeks remain authoritative. 2875 if (!dataPtr->isPlaying.load(std::memory_order_relaxed)) 2876 { 2877 return; 2878 } 2879 2880 auto pos = sender.Position(); 2881 2882 // Suppress the transient 0-position report before the initial seek takes effect. 2883 if (dataPtr->pendingInitialSeek.load(std::memory_order_relaxed) && 2884 dataPtr->pendingInitialSeekTicks.load(std::memory_order_relaxed) > 0 && 2885 pos.count() == 0) 2886 { 2887 return; 2888 } 2889 2890 // First non-zero sample observed; allow normal updates. 2891 if (pos.count() != 0) 2892 { 2893 dataPtr->pendingInitialSeek.store(false, std::memory_order_relaxed); 2894 dataPtr->pendingInitialSeekTicks.store(0, std::memory_order_relaxed); 2895 } 2896 2897 // Check for end-of-clip BEFORE updating currentPosition to avoid 2898 // storing a value >= trimEnd that could flash in the UI 2899 if (pos >= dataPtr->trimEnd) 2900 { 2901 // Immediately mark as not playing to prevent further position updates 2902 // before WMU_PLAYBACK_STOP is processed. 2903 dataPtr->isPlaying.store(false, std::memory_order_release); 2904 #if _DEBUG 2905 OutputDebugStringW((L"[Trim] PositionChanged: pos >= trimEnd, posting stop. pos=" + 2906 std::to_wstring(pos.count()) + L"\n").c_str()); 2907 #endif 2908 PostMessage(hDlg, WMU_PLAYBACK_STOP, 0, 0); 2909 return; 2910 } 2911 2912 dataPtr->currentPosition = pos; 2913 2914 if (dataPtr->isPlaying.load(std::memory_order_relaxed) && 2915 !dataPtr->smoothHasNonZeroSample.load(std::memory_order_relaxed) && 2916 pos.count() > 0) 2917 { 2918 // Seed smoothing on first real position, but keep baseline exact to avoid a jump 2919 dataPtr->smoothHasNonZeroSample.store(true, std::memory_order_relaxed); 2920 SyncSmoothPlayback(dataPtr, pos.count(), dataPtr->trimStart.count(), dataPtr->trimEnd.count()); 2921 LogSmoothingEvent(L"eventFirst", pos.count(), pos.count(), 0); 2922 } 2923 2924 PostMessage(hDlg, WMU_PLAYBACK_POSITION, 0, 0); 2925 } 2926 catch (...) 2927 { 2928 } 2929 }); 2930 2931 pData->stateChangedToken = session.PlaybackStateChanged([hDlg](auto const&, auto const&) 2932 { 2933 PostMessage(hDlg, WMU_PLAYBACK_POSITION, 0, 0); 2934 }); 2935 2936 // Capture the resume position now since currentPosition may change before MediaOpened fires 2937 const int64_t resumePositionTicks = std::clamp<int64_t>(resumePosition.count(), 0, pData->trimEnd.count()); 2938 #if _DEBUG 2939 OutputDebugStringW((L"[Trim] Setting up MediaOpened callback with resumePos=" + 2940 std::to_wstring(resumePositionTicks) + L"\n").c_str()); 2941 #endif 2942 pData->mediaPlayer.MediaOpened([dataPtr, hDlg, resumePositionTicks, startSerial](auto const& sender, auto const&) 2943 { 2944 if (!dataPtr) 2945 { 2946 return; 2947 } 2948 try 2949 { 2950 if (!dataPtr->isPlaying.load(std::memory_order_acquire) || 2951 dataPtr->playbackCommandSerial.load(std::memory_order_acquire) != startSerial) 2952 { 2953 sender.Pause(); 2954 return; 2955 } 2956 // Seek to the captured resume position (loop anchor is stored separately) 2957 #if _DEBUG 2958 OutputDebugStringW((L"[Trim] MediaOpened: seeking to resumePos=" + 2959 std::to_wstring(resumePositionTicks) + L"\n").c_str()); 2960 #endif 2961 sender.PlaybackSession().Position(winrt::TimeSpan{ resumePositionTicks }); 2962 2963 // Re-check immediately before playing to reduce Play->Pause races. 2964 if (!dataPtr->isPlaying.load(std::memory_order_acquire) || 2965 dataPtr->playbackCommandSerial.load(std::memory_order_acquire) != startSerial) 2966 { 2967 sender.Pause(); 2968 return; 2969 } 2970 sender.Play(); 2971 2972 // Once MediaOpened has applied the initial seek, allow position updates again. 2973 dataPtr->pendingInitialSeek.store(false, std::memory_order_relaxed); 2974 dataPtr->pendingInitialSeekTicks.store(0, std::memory_order_relaxed); 2975 } 2976 catch (...) 2977 { 2978 } 2979 }); 2980 2981 pData->mediaPlayer.Source(mediaSource); 2982 } 2983 catch (...) 2984 { 2985 pData->isPlaying.store(false, std::memory_order_relaxed); 2986 CleanupMediaPlayer(pData); 2987 if (newPlayer) 2988 { 2989 try 2990 { 2991 newPlayer.Close(); 2992 } 2993 catch (...) 2994 { 2995 } 2996 } 2997 RefreshPlaybackButtons(hDlg); 2998 co_return; 2999 } 3000 3001 // Use multimedia timer for smooth updates 3002 if (!StartMMTimer(hDlg, pData)) 3003 { 3004 pData->isPlaying.store(false, std::memory_order_relaxed); 3005 CleanupMediaPlayer(pData); 3006 ResetSmoothPlayback(pData); 3007 RefreshPlaybackButtons(hDlg); 3008 co_return; 3009 } 3010 3011 // If a quick Pause happened right after Play, don't start timers/UI updates. 3012 if (!pData->isPlaying.load(std::memory_order_acquire) || 3013 pData->playbackCommandSerial.load(std::memory_order_acquire) != startSerial) 3014 { 3015 StopMMTimer(pData); 3016 pData->isPlaying.store(false, std::memory_order_relaxed); 3017 RefreshPlaybackButtons(hDlg); 3018 co_return; 3019 } 3020 3021 // Defer smoothing until first real playback position is reported to prevent early extrapolation 3022 pData->smoothActive.store(false, std::memory_order_relaxed); 3023 pData->smoothHasNonZeroSample.store(false, std::memory_order_relaxed); 3024 3025 // Update lastPlayheadX to current position so timer ticks can track movement properly 3026 { 3027 HWND hTimeline = GetDlgItem(hDlg, IDC_TRIM_TIMELINE); 3028 if (hTimeline) 3029 { 3030 RECT rc; 3031 GetClientRect(hTimeline, &rc); 3032 const UINT dpi = GetDpiForWindowHelper(hTimeline); 3033 pData->lastPlayheadX = TimelineTimeToClientX(pData, pData->currentPosition, rc.right - rc.left, dpi); 3034 } 3035 } 3036 3037 PostMessage(hDlg, WMU_PLAYBACK_POSITION, 0, 0); 3038 RefreshPlaybackButtons(hDlg); 3039 } 3040 3041 static LRESULT CALLBACK TimelineSubclassProc( 3042 HWND hWnd, 3043 UINT message, 3044 WPARAM wParam, 3045 LPARAM lParam, 3046 UINT_PTR uIdSubclass, 3047 DWORD_PTR dwRefData) 3048 { 3049 auto* pData = reinterpret_cast<VideoRecordingSession::TrimDialogData*>(dwRefData); 3050 if (!pData) 3051 { 3052 return DefSubclassProc(hWnd, message, wParam, lParam); 3053 } 3054 3055 auto restorePreviewIfNeeded = [&]() 3056 { 3057 if (!pData->restorePreviewOnRelease) 3058 { 3059 pData->previewOverrideActive = false; 3060 pData->playheadPushed = false; 3061 return; 3062 } 3063 3064 if (pData->playheadPushed) 3065 { 3066 // Keep pushed playhead; just clear override flags 3067 pData->previewOverrideActive = false; 3068 pData->restorePreviewOnRelease = false; 3069 pData->playheadPushed = false; 3070 return; 3071 } 3072 3073 if (pData->hDialog) 3074 { 3075 // Restore playhead to where it was before the gripper drag. 3076 // Only clamp to video bounds, not selection bounds, so the playhead 3077 // can remain outside the selection if it was there before. 3078 const int64_t restoredTicks = std::clamp<int64_t>( 3079 pData->positionBeforeOverride.count(), 3080 0LL, 3081 pData->videoDuration.count()); 3082 pData->currentPosition = winrt::TimeSpan{ restoredTicks }; 3083 pData->previewOverrideActive = false; 3084 pData->restorePreviewOnRelease = false; 3085 pData->playheadPushed = false; 3086 UpdateVideoPreview(pData->hDialog, pData); 3087 } 3088 }; 3089 3090 switch (message) 3091 { 3092 case WM_NCDESTROY: 3093 RemoveWindowSubclass(hWnd, TimelineSubclassProc, uIdSubclass); 3094 break; 3095 3096 case WM_LBUTTONDOWN: 3097 { 3098 // Pause without recapturing position; we might be parked on a handle 3099 StopPlayback(pData->hDialog, pData, false); 3100 3101 RECT rcClient{}; 3102 GetClientRect(hWnd, &rcClient); 3103 const int width = rcClient.right - rcClient.left; 3104 if (width <= 0) 3105 { 3106 break; 3107 } 3108 3109 const int x = GET_X_LPARAM(lParam); 3110 const int y = GET_Y_LPARAM(lParam); 3111 const int clampedX = std::clamp(x, 0, width); 3112 3113 // Get DPI for scaling hit test regions 3114 const UINT dpi = GetDpiForWindowHelper(hWnd); 3115 const int timelineTrackTopOffset = ScaleForDpi(kTimelineTrackTopOffset, dpi); 3116 const int timelineTrackHeight = ScaleForDpi(kTimelineTrackHeight, dpi); 3117 const int timelineHandleHeight = ScaleForDpi(kTimelineHandleHeight, dpi); 3118 const int timelineHandleHitRadius = ScaleForDpi(kTimelineHandleHitRadius, dpi); 3119 3120 const int trackTop = timelineTrackTopOffset; 3121 const int trackBottom = trackTop + timelineTrackHeight; 3122 3123 // Gripper vertical band: centered on track 3124 const int gripperTop = trackTop - (timelineHandleHeight - timelineTrackHeight) / 2; 3125 const int gripperBottom = gripperTop + timelineHandleHeight; 3126 const bool inGripperBand = (y >= gripperTop && y <= gripperBottom); 3127 3128 // Playhead knob vertical band: below the track (ellipse drawn at trackBottom + 12 to trackBottom + 24) 3129 const int knobTop = trackBottom + ScaleForDpi(8, dpi); // slightly above ellipse for easier hit 3130 const int knobBottom = trackBottom + ScaleForDpi(28, dpi); 3131 const bool inKnobBand = (y >= knobTop && y <= knobBottom); 3132 3133 // Playhead stem is also hittable (trackTop - 12 to trackBottom + posLineBelow) 3134 const int stemTop = trackTop - ScaleForDpi(12, dpi); 3135 const int stemBottom = trackBottom + ScaleForDpi(22, dpi); 3136 const bool inStemBand = (y >= stemTop && y <= stemBottom); 3137 3138 const int startX = TimelineTimeToClientX(pData, pData->trimStart, width, dpi); 3139 const int posX = TimelineTimeToClientX(pData, pData->currentPosition, width, dpi); 3140 const int endX = TimelineTimeToClientX(pData, pData->trimEnd, width, dpi); 3141 3142 pData->dragMode = VideoRecordingSession::TrimDialogData::None; 3143 pData->previewOverrideActive = false; 3144 pData->restorePreviewOnRelease = false; 3145 3146 // Calculate horizontal distances to each handle 3147 const int distToPos = abs(clampedX - posX); 3148 const int distToStart = abs(clampedX - startX); 3149 const int distToEnd = abs(clampedX - endX); 3150 3151 // Hit-test with vertical position awareness: 3152 // - Grippers are only hittable in the gripper band (around the track) 3153 // - Playhead is hittable in the knob band (below track) or stem band 3154 // - When clicking in the knob area (below track), playhead always wins 3155 // - When in the gripper band, grippers take priority for horizontal overlaps 3156 3157 const bool startHit = inGripperBand && distToStart <= timelineHandleHitRadius; 3158 const bool endHit = inGripperBand && distToEnd <= timelineHandleHitRadius; 3159 const bool posHitKnob = inKnobBand && distToPos <= timelineHandleHitRadius; 3160 const bool posHitStem = inStemBand && distToPos <= ScaleForDpi(4, dpi); // tighter radius for stem 3161 3162 // Prioritize playhead when clicking in the knob area (lollipop head below the track) 3163 if (posHitKnob) 3164 { 3165 pData->dragMode = VideoRecordingSession::TrimDialogData::Position; 3166 } 3167 else if (startHit && (!endHit || distToStart <= distToEnd)) 3168 { 3169 pData->dragMode = VideoRecordingSession::TrimDialogData::TrimStart; 3170 } 3171 else if (endHit) 3172 { 3173 pData->dragMode = VideoRecordingSession::TrimDialogData::TrimEnd; 3174 } 3175 else if (posHitStem) 3176 { 3177 pData->dragMode = VideoRecordingSession::TrimDialogData::Position; 3178 } 3179 3180 if (pData->dragMode != VideoRecordingSession::TrimDialogData::None) 3181 { 3182 pData->isDragging = true; 3183 pData->playheadPushed = false; 3184 if (pData->dragMode == VideoRecordingSession::TrimDialogData::TrimStart || 3185 pData->dragMode == VideoRecordingSession::TrimDialogData::TrimEnd) 3186 { 3187 pData->positionBeforeOverride = pData->currentPosition; 3188 pData->previewOverrideActive = true; 3189 pData->restorePreviewOnRelease = true; 3190 pData->previewOverride = (pData->dragMode == VideoRecordingSession::TrimDialogData::TrimStart) ? 3191 pData->trimStart : pData->trimEnd; 3192 UpdateVideoPreview(pData->hDialog, pData); 3193 // Show resize cursor during grip drag 3194 SetCursor(LoadCursor(nullptr, IDC_SIZEWE)); 3195 } 3196 SetCapture(hWnd); 3197 return 0; 3198 } 3199 break; 3200 } 3201 3202 case WM_LBUTTONUP: 3203 { 3204 if (pData->isDragging) 3205 { 3206 // Kill debounce timer and do immediate final update 3207 KillTimer(hWnd, kPreviewDebounceTimerId); 3208 const bool wasPositionDrag = (pData->dragMode == VideoRecordingSession::TrimDialogData::Position); 3209 pData->isDragging = false; 3210 ReleaseCapture(); 3211 SetCursor(LoadCursor(nullptr, IDC_ARROW)); 3212 restorePreviewIfNeeded(); 3213 pData->dragMode = VideoRecordingSession::TrimDialogData::None; 3214 InvalidateRect(hWnd, nullptr, FALSE); 3215 // Ensure final preview update for playhead drag (restorePreviewIfNeeded doesn't update for this case) 3216 if (wasPositionDrag && pData->hDialog) 3217 { 3218 UpdateVideoPreview(pData->hDialog, pData, false); 3219 } 3220 return 0; 3221 } 3222 break; 3223 } 3224 3225 case WM_MOUSEMOVE: 3226 { 3227 TRACKMOUSEEVENT tme{}; 3228 tme.cbSize = sizeof(tme); 3229 tme.dwFlags = TME_LEAVE; 3230 tme.hwndTrack = hWnd; 3231 TrackMouseEvent(&tme); 3232 3233 RECT rcClient{}; 3234 GetClientRect(hWnd, &rcClient); 3235 const int width = rcClient.right - rcClient.left; 3236 if (width <= 0) 3237 { 3238 break; 3239 } 3240 3241 const int rawX = GET_X_LPARAM(lParam); 3242 const int clampedX = std::clamp(rawX, 0, width); 3243 3244 if (!pData->isDragging) 3245 { 3246 // Get DPI for scaling hit test regions 3247 const UINT dpi = GetDpiForWindowHelper(hWnd); 3248 const int timelineHandleHitRadius = ScaleForDpi(kTimelineHandleHitRadius, dpi); 3249 3250 const int startX = TimelineTimeToClientX(pData, pData->trimStart, width, dpi); 3251 const int posX = TimelineTimeToClientX(pData, pData->currentPosition, width, dpi); 3252 const int endX = TimelineTimeToClientX(pData, pData->trimEnd, width, dpi); 3253 3254 if (abs(clampedX - posX) <= timelineHandleHitRadius) 3255 { 3256 SetCursor(LoadCursor(nullptr, IDC_HAND)); 3257 } 3258 else if (abs(clampedX - startX) < timelineHandleHitRadius || abs(clampedX - endX) < timelineHandleHitRadius) 3259 { 3260 SetCursor(LoadCursor(nullptr, IDC_HAND)); 3261 } 3262 else 3263 { 3264 SetCursor(LoadCursor(nullptr, IDC_ARROW)); 3265 } 3266 return 0; 3267 } 3268 3269 // Set appropriate cursor during drag 3270 if (pData->dragMode == VideoRecordingSession::TrimDialogData::TrimStart || 3271 pData->dragMode == VideoRecordingSession::TrimDialogData::TrimEnd) 3272 { 3273 SetCursor(LoadCursor(nullptr, IDC_SIZEWE)); 3274 } 3275 else if (pData->dragMode == VideoRecordingSession::TrimDialogData::Position) 3276 { 3277 SetCursor(LoadCursor(nullptr, IDC_HAND)); 3278 } 3279 3280 // Get DPI for pixel-to-time conversion during drag 3281 const UINT dpi = GetDpiForWindowHelper(hWnd); 3282 const auto newTime = TimelinePixelToTime(pData, clampedX, width, dpi); 3283 3284 bool requestPreviewUpdate = false; 3285 bool applyOverride = false; 3286 winrt::TimeSpan overrideTime{ 0 }; 3287 3288 switch (pData->dragMode) 3289 { 3290 case VideoRecordingSession::TrimDialogData::TrimStart: 3291 if (newTime.count() < pData->trimEnd.count()) 3292 { 3293 const auto oldTrimStart = pData->trimStart; 3294 if (newTime.count() != pData->trimStart.count()) 3295 { 3296 pData->trimStart = newTime; 3297 UpdateDurationDisplay(pData->hDialog, pData); 3298 } 3299 // Push playhead if gripper crossed over it in either direction: 3300 // - Moving right: playhead was >= oldTrimStart and is now < newTrimStart 3301 // - Moving left: playhead was <= oldTrimStart and is now >= newTrimStart 3302 // (use <= so that once pushed, the playhead continues moving with the gripper) 3303 const bool movingRight = pData->trimStart.count() > oldTrimStart.count(); 3304 const bool movingLeft = pData->trimStart.count() < oldTrimStart.count(); 3305 const bool pushRight = movingRight && 3306 pData->currentPosition.count() >= oldTrimStart.count() && 3307 pData->currentPosition.count() < pData->trimStart.count(); 3308 const bool pushLeft = movingLeft && 3309 pData->currentPosition.count() <= oldTrimStart.count() && 3310 pData->currentPosition.count() >= pData->trimStart.count(); 3311 if (pushRight || pushLeft) 3312 { 3313 pData->playheadPushed = true; 3314 pData->currentPosition = pData->trimStart; 3315 // Also update playback start position so loop resets to pushed position 3316 pData->playbackStartPosition = pData->currentPosition; 3317 pData->playbackStartPositionValid = true; 3318 // Invalidate cached start frame 3319 std::lock_guard<std::mutex> lock(pData->previewBitmapMutex); 3320 if (pData->hCachedStartFrame) 3321 { 3322 DeleteObject(pData->hCachedStartFrame); 3323 pData->hCachedStartFrame = nullptr; 3324 } 3325 } 3326 overrideTime = pData->trimStart; 3327 applyOverride = true; 3328 requestPreviewUpdate = true; 3329 } 3330 break; 3331 3332 case VideoRecordingSession::TrimDialogData::Position: 3333 { 3334 const int previousPosX = TimelineTimeToClientX(pData, pData->currentPosition, width, dpi); 3335 3336 // Allow playhead to move anywhere within video bounds (0 to videoDuration) 3337 const int64_t clampedTicks = std::clamp(newTime.count(), 0LL, pData->videoDuration.count()); 3338 pData->currentPosition = winrt::TimeSpan{ clampedTicks }; 3339 3340 // User explicitly positioned the playhead; update the loop anchor. 3341 pData->playbackStartPosition = pData->currentPosition; 3342 pData->playbackStartPositionValid = true; 3343 3344 // Invalidate cached start frame since position changed - will be re-cached when playback starts. 3345 { 3346 std::lock_guard<std::mutex> lock(pData->previewBitmapMutex); 3347 if (pData->hCachedStartFrame) 3348 { 3349 DeleteObject(pData->hCachedStartFrame); 3350 pData->hCachedStartFrame = nullptr; 3351 } 3352 } 3353 3354 const int newPosX = TimelineTimeToClientX(pData, pData->currentPosition, width, dpi); 3355 RECT clientRect{}; 3356 GetClientRect(hWnd, &clientRect); 3357 InvalidatePlayheadRegion(hWnd, clientRect, previousPosX, newPosX, dpi); 3358 UpdateWindow(hWnd); // Force immediate visual update for smooth dragging 3359 pData->previewOverrideActive = false; 3360 // Debounce preview update for playhead drag as well 3361 SetTimer(hWnd, kPreviewDebounceTimerId, kPreviewDebounceDelayMs, nullptr); 3362 break; 3363 } 3364 3365 case VideoRecordingSession::TrimDialogData::TrimEnd: 3366 if (newTime.count() > pData->trimStart.count()) 3367 { 3368 const auto oldTrimEnd = pData->trimEnd; 3369 if (newTime.count() != pData->trimEnd.count()) 3370 { 3371 pData->trimEnd = newTime; 3372 UpdateDurationDisplay(pData->hDialog, pData); 3373 } 3374 // Only push playhead if it was inside selection (<= old trimEnd) and handle crossed over it 3375 if (pData->currentPosition.count() <= oldTrimEnd.count() && 3376 pData->currentPosition.count() > pData->trimEnd.count()) 3377 { 3378 pData->playheadPushed = true; 3379 pData->currentPosition = pData->trimEnd; 3380 // Also update playback start position so loop resets to pushed position 3381 pData->playbackStartPosition = pData->currentPosition; 3382 pData->playbackStartPositionValid = true; 3383 // Invalidate cached start frame 3384 std::lock_guard<std::mutex> lock(pData->previewBitmapMutex); 3385 if (pData->hCachedStartFrame) 3386 { 3387 DeleteObject(pData->hCachedStartFrame); 3388 pData->hCachedStartFrame = nullptr; 3389 } 3390 } 3391 overrideTime = pData->trimEnd; 3392 applyOverride = true; 3393 requestPreviewUpdate = true; 3394 } 3395 break; 3396 3397 default: 3398 break; 3399 } 3400 3401 if (applyOverride) 3402 { 3403 pData->previewOverrideActive = true; 3404 pData->previewOverride = overrideTime; 3405 } 3406 3407 // Force immediate visual update of gripper for smooth dragging 3408 InvalidateRect(hWnd, nullptr, FALSE); 3409 UpdateWindow(hWnd); 3410 3411 // Debounce preview update - use a timer to avoid overwhelming the system with requests 3412 // Each mouse move resets the timer; preview only updates after dragging pauses 3413 if (requestPreviewUpdate) 3414 { 3415 SetTimer(hWnd, kPreviewDebounceTimerId, kPreviewDebounceDelayMs, nullptr); 3416 } 3417 3418 return 0; 3419 } 3420 3421 case WM_TIMER: 3422 { 3423 if (wParam == kPreviewDebounceTimerId) 3424 { 3425 KillTimer(hWnd, kPreviewDebounceTimerId); 3426 if (pData && pData->hDialog) 3427 { 3428 UpdateVideoPreview(pData->hDialog, pData, false); 3429 } 3430 return 0; 3431 } 3432 break; 3433 } 3434 3435 case WM_ERASEBKGND: 3436 return 1; 3437 3438 case WM_MOUSELEAVE: 3439 if (!pData->isDragging) 3440 { 3441 SetCursor(LoadCursor(nullptr, IDC_ARROW)); 3442 } 3443 break; 3444 3445 case WM_CAPTURECHANGED: 3446 if (pData->isDragging) 3447 { 3448 KillTimer(hWnd, kPreviewDebounceTimerId); 3449 pData->isDragging = false; 3450 pData->dragMode = VideoRecordingSession::TrimDialogData::None; 3451 restorePreviewIfNeeded(); 3452 } 3453 break; 3454 } 3455 3456 return DefSubclassProc(hWnd, message, wParam, lParam); 3457 } 3458 3459 //---------------------------------------------------------------------------- 3460 // 3461 // Helper: Draw custom playback buttons (play/pause and restart) 3462 // 3463 //---------------------------------------------------------------------------- 3464 static void DrawPlaybackButton( 3465 const DRAWITEMSTRUCT* pDIS, 3466 VideoRecordingSession::TrimDialogData* pData) 3467 { 3468 if (!pDIS || !pData) 3469 { 3470 return; 3471 } 3472 3473 const bool isPlayControl = (pDIS->CtlID == IDC_TRIM_PLAY_PAUSE); 3474 const bool isRewindControl = (pDIS->CtlID == IDC_TRIM_REWIND); 3475 const bool isForwardControl = (pDIS->CtlID == IDC_TRIM_FORWARD); 3476 const bool isSkipStartControl = (pDIS->CtlID == IDC_TRIM_SKIP_START); 3477 const bool isSkipEndControl = (pDIS->CtlID == IDC_TRIM_SKIP_END); 3478 3479 // Check if skip buttons should be disabled based on position 3480 const bool atStart = (pData->currentPosition.count() <= pData->trimStart.count()); 3481 const bool atEnd = (pData->currentPosition.count() >= pData->trimEnd.count()); 3482 3483 const bool isHover = isPlayControl ? pData->hoverPlay : 3484 (isRewindControl ? pData->hoverRewind : 3485 (isForwardControl ? pData->hoverForward : 3486 (isSkipStartControl ? pData->hoverSkipStart : pData->hoverSkipEnd))); 3487 bool isDisabled = (pDIS->itemState & ODS_DISABLED) != 0; 3488 3489 // Disable skip start when at start, skip end when at end 3490 if (isSkipStartControl && atStart) isDisabled = true; 3491 if (isSkipEndControl && atEnd) isDisabled = true; 3492 3493 const bool isPressed = (pDIS->itemState & ODS_SELECTED) != 0; 3494 const bool isPlaying = pData->isPlaying.load(std::memory_order_relaxed); 3495 3496 // Media Player color scheme - dark background with gradient 3497 COLORREF bgColorTop = RGB(45, 45, 50); 3498 COLORREF bgColorBottom = RGB(35, 35, 40); 3499 COLORREF iconColor = RGB(220, 220, 220); 3500 COLORREF borderColor = RGB(120, 120, 125); 3501 3502 if (isHover && !isDisabled) 3503 { 3504 bgColorTop = RGB(60, 60, 65); 3505 bgColorBottom = RGB(50, 50, 55); 3506 iconColor = RGB(255, 255, 255); 3507 borderColor = RGB(150, 150, 155); 3508 } 3509 if (isPressed && !isDisabled) 3510 { 3511 bgColorTop = RGB(30, 30, 35); 3512 bgColorBottom = RGB(25, 25, 30); 3513 iconColor = RGB(200, 200, 200); 3514 } 3515 if (isDisabled) 3516 { 3517 bgColorTop = RGB(40, 40, 45); 3518 bgColorBottom = RGB(35, 35, 40); 3519 iconColor = RGB(100, 100, 100); 3520 } 3521 3522 int width = pDIS->rcItem.right - pDIS->rcItem.left; 3523 int height = pDIS->rcItem.bottom - pDIS->rcItem.top; 3524 float centerX = pDIS->rcItem.left + width / 2.0f; 3525 float centerY = pDIS->rcItem.top + height / 2.0f; 3526 float radius = min(width, height) / 2.0f - 1.0f; 3527 3528 // Use GDI+ for antialiased rendering 3529 Gdiplus::Graphics graphics(pDIS->hDC); 3530 graphics.SetSmoothingMode(Gdiplus::SmoothingModeAntiAlias); 3531 3532 // Draw flat background circle (no gradient) 3533 Gdiplus::SolidBrush bgBrush(Gdiplus::Color(255, GetRValue(bgColorBottom), GetGValue(bgColorBottom), GetBValue(bgColorBottom))); 3534 graphics.FillEllipse(&bgBrush, centerX - radius, centerY - radius, radius * 2, radius * 2); 3535 3536 // Draw subtle border 3537 Gdiplus::Pen borderPen(Gdiplus::Color(100, GetRValue(borderColor), GetGValue(borderColor), GetBValue(borderColor)), 0.5f); 3538 graphics.DrawEllipse(&borderPen, centerX - radius, centerY - radius, radius * 2, radius * 2); 3539 3540 // Draw icons 3541 Gdiplus::SolidBrush iconBrush(Gdiplus::Color(255, GetRValue(iconColor), GetGValue(iconColor), GetBValue(iconColor))); 3542 float iconSize = radius * 0.8f; // slightly larger icons 3543 3544 if (isPlayControl) 3545 { 3546 if (isPlaying) 3547 { 3548 // Draw pause icon (two vertical bars) 3549 float barWidth = iconSize / 4.0f; 3550 float barHeight = iconSize; 3551 float gap = iconSize / 5.0f; 3552 3553 graphics.FillRectangle(&iconBrush, 3554 centerX - gap - barWidth, centerY - barHeight / 2.0f, 3555 barWidth, barHeight); 3556 graphics.FillRectangle(&iconBrush, 3557 centerX + gap, centerY - barHeight / 2.0f, 3558 barWidth, barHeight); 3559 } 3560 else 3561 { 3562 // Draw play triangle 3563 float triWidth = iconSize; 3564 float triHeight = iconSize; 3565 Gdiplus::PointF playTri[3] = { 3566 Gdiplus::PointF(centerX - triWidth / 3.0f, centerY - triHeight / 2.0f), 3567 Gdiplus::PointF(centerX + triWidth * 2.0f / 3.0f, centerY), 3568 Gdiplus::PointF(centerX - triWidth / 3.0f, centerY + triHeight / 2.0f) 3569 }; 3570 graphics.FillPolygon(&iconBrush, playTri, 3); 3571 } 3572 } 3573 else if (isRewindControl || isForwardControl) 3574 { 3575 // Draw small play triangle in appropriate direction 3576 float triWidth = iconSize * 3.0f / 5.0f; 3577 float triHeight = iconSize * 3.0f / 5.0f; 3578 3579 if (isRewindControl) 3580 { 3581 // Triangle pointing left 3582 Gdiplus::PointF tri[3] = { 3583 Gdiplus::PointF(centerX + triWidth / 3.0f, centerY - triHeight / 2.0f), 3584 Gdiplus::PointF(centerX - triWidth * 2.0f / 3.0f, centerY), 3585 Gdiplus::PointF(centerX + triWidth / 3.0f, centerY + triHeight / 2.0f) 3586 }; 3587 graphics.FillPolygon(&iconBrush, tri, 3); 3588 } 3589 else 3590 { 3591 // Triangle pointing right 3592 Gdiplus::PointF tri[3] = { 3593 Gdiplus::PointF(centerX - triWidth / 3.0f, centerY - triHeight / 2.0f), 3594 Gdiplus::PointF(centerX + triWidth * 2.0f / 3.0f, centerY), 3595 Gdiplus::PointF(centerX - triWidth / 3.0f, centerY + triHeight / 2.0f) 3596 }; 3597 graphics.FillPolygon(&iconBrush, tri, 3); 3598 } 3599 } 3600 else if (isSkipStartControl || isSkipEndControl) 3601 { 3602 // Draw skip to start/end icon (triangle + bar) 3603 float triWidth = iconSize * 2.0f / 3.0f; 3604 float triHeight = iconSize; 3605 float barWidth = iconSize / 6.0f; 3606 3607 if (isSkipStartControl) 3608 { 3609 // Bar on left, triangle pointing left 3610 graphics.FillRectangle(&iconBrush, 3611 centerX - triWidth / 2.0f - barWidth, centerY - triHeight / 2.0f, 3612 barWidth, triHeight); 3613 3614 Gdiplus::PointF tri[3] = { 3615 Gdiplus::PointF(centerX + triWidth / 2.0f, centerY - triHeight / 2.0f), 3616 Gdiplus::PointF(centerX - triWidth / 2.0f, centerY), 3617 Gdiplus::PointF(centerX + triWidth / 2.0f, centerY + triHeight / 2.0f) 3618 }; 3619 graphics.FillPolygon(&iconBrush, tri, 3); 3620 } 3621 else 3622 { 3623 // Triangle pointing right, bar on right 3624 Gdiplus::PointF tri[3] = { 3625 Gdiplus::PointF(centerX - triWidth / 2.0f, centerY - triHeight / 2.0f), 3626 Gdiplus::PointF(centerX + triWidth / 2.0f, centerY), 3627 Gdiplus::PointF(centerX - triWidth / 2.0f, centerY + triHeight / 2.0f) 3628 }; 3629 graphics.FillPolygon(&iconBrush, tri, 3); 3630 3631 graphics.FillRectangle(&iconBrush, 3632 centerX + triWidth / 2.0f, centerY - triHeight / 2.0f, 3633 barWidth, triHeight); 3634 } 3635 } 3636 } 3637 3638 //---------------------------------------------------------------------------- 3639 // 3640 // Helper: Mouse interaction for volume icon 3641 // 3642 //---------------------------------------------------------------------------- 3643 static LRESULT CALLBACK VolumeIconSubclassProc( 3644 HWND hWnd, 3645 UINT message, 3646 WPARAM wParam, 3647 LPARAM lParam, 3648 UINT_PTR uIdSubclass, 3649 DWORD_PTR dwRefData) 3650 { 3651 auto* pData = reinterpret_cast<VideoRecordingSession::TrimDialogData*>(dwRefData); 3652 if (!pData) 3653 { 3654 return DefSubclassProc(hWnd, message, wParam, lParam); 3655 } 3656 3657 switch (message) 3658 { 3659 case WM_NCDESTROY: 3660 RemoveWindowSubclass(hWnd, VolumeIconSubclassProc, uIdSubclass); 3661 break; 3662 3663 case WM_MOUSEMOVE: 3664 { 3665 TRACKMOUSEEVENT tme{ sizeof(tme), TME_LEAVE, hWnd, 0 }; 3666 TrackMouseEvent(&tme); 3667 3668 if (!pData->hoverVolumeIcon) 3669 { 3670 pData->hoverVolumeIcon = true; 3671 InvalidateRect(hWnd, nullptr, FALSE); 3672 } 3673 return 0; 3674 } 3675 3676 case WM_MOUSELEAVE: 3677 if (pData->hoverVolumeIcon) 3678 { 3679 pData->hoverVolumeIcon = false; 3680 InvalidateRect(hWnd, nullptr, FALSE); 3681 } 3682 return 0; 3683 3684 case WM_SETCURSOR: 3685 SetCursor(LoadCursor(nullptr, IDC_HAND)); 3686 return TRUE; 3687 } 3688 3689 return DefSubclassProc(hWnd, message, wParam, lParam); 3690 } 3691 3692 //---------------------------------------------------------------------------- 3693 // 3694 // Helper: Mouse interaction for playback controls 3695 // 3696 //---------------------------------------------------------------------------- 3697 static LRESULT CALLBACK PlaybackButtonSubclassProc( 3698 HWND hWnd, 3699 UINT message, 3700 WPARAM wParam, 3701 LPARAM lParam, 3702 UINT_PTR uIdSubclass, 3703 DWORD_PTR dwRefData) 3704 { 3705 auto* pData = reinterpret_cast<VideoRecordingSession::TrimDialogData*>(dwRefData); 3706 if (!pData) 3707 { 3708 return DefSubclassProc(hWnd, message, wParam, lParam); 3709 } 3710 3711 switch (message) 3712 { 3713 case WM_NCDESTROY: 3714 RemoveWindowSubclass(hWnd, PlaybackButtonSubclassProc, uIdSubclass); 3715 break; 3716 3717 case WM_LBUTTONDOWN: 3718 SetFocus(hWnd); 3719 SetCapture(hWnd); 3720 return 0; 3721 3722 case WM_LBUTTONUP: 3723 { 3724 if (GetCapture() == hWnd) 3725 { 3726 ReleaseCapture(); 3727 } 3728 3729 POINT pt{ GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam) }; 3730 RECT rc{}; 3731 GetClientRect(hWnd, &rc); 3732 3733 if (PtInRect(&rc, pt)) 3734 { 3735 HandlePlaybackCommand(GetDlgCtrlID(hWnd), pData); 3736 } 3737 return 0; 3738 } 3739 3740 case WM_KEYUP: 3741 if (wParam == VK_SPACE || wParam == VK_RETURN) 3742 { 3743 HandlePlaybackCommand(GetDlgCtrlID(hWnd), pData); 3744 return 0; 3745 } 3746 break; 3747 3748 case WM_MOUSEMOVE: 3749 { 3750 TRACKMOUSEEVENT tme{ sizeof(tme), TME_LEAVE, hWnd, 0 }; 3751 TrackMouseEvent(&tme); 3752 3753 const int controlId = GetDlgCtrlID(hWnd); 3754 const bool isPlayControl = (controlId == IDC_TRIM_PLAY_PAUSE); 3755 const bool isRewindControl = (controlId == IDC_TRIM_REWIND); 3756 const bool isForwardControl = (controlId == IDC_TRIM_FORWARD); 3757 const bool isSkipStartControl = (controlId == IDC_TRIM_SKIP_START); 3758 3759 bool& hoverFlag = isPlayControl ? pData->hoverPlay : 3760 (isRewindControl ? pData->hoverRewind : 3761 (isForwardControl ? pData->hoverForward : 3762 (isSkipStartControl ? pData->hoverSkipStart : pData->hoverSkipEnd))); 3763 if (!hoverFlag) 3764 { 3765 hoverFlag = true; 3766 InvalidateRect(hWnd, nullptr, FALSE); 3767 } 3768 return 0; 3769 } 3770 3771 case WM_MOUSELEAVE: 3772 { 3773 const int controlId = GetDlgCtrlID(hWnd); 3774 const bool isPlayControl = (controlId == IDC_TRIM_PLAY_PAUSE); 3775 const bool isRewindControl = (controlId == IDC_TRIM_REWIND); 3776 const bool isForwardControl = (controlId == IDC_TRIM_FORWARD); 3777 const bool isSkipStartControl = (controlId == IDC_TRIM_SKIP_START); 3778 3779 bool& hoverFlag = isPlayControl ? pData->hoverPlay : 3780 (isRewindControl ? pData->hoverRewind : 3781 (isForwardControl ? pData->hoverForward : 3782 (isSkipStartControl ? pData->hoverSkipStart : pData->hoverSkipEnd))); 3783 if (hoverFlag) 3784 { 3785 hoverFlag = false; 3786 InvalidateRect(hWnd, nullptr, FALSE); 3787 } 3788 return 0; 3789 } 3790 3791 case WM_SETCURSOR: 3792 SetCursor(LoadCursor(nullptr, IDC_HAND)); 3793 return TRUE; 3794 3795 case WM_ERASEBKGND: 3796 return 1; 3797 3798 } 3799 3800 return DefSubclassProc(hWnd, message, wParam, lParam); 3801 } 3802 3803 //---------------------------------------------------------------------------- 3804 // 3805 // TrimDialogSubclassProc 3806 // 3807 // Subclass procedure for the trim dialog to handle resize grip hit testing 3808 // 3809 //---------------------------------------------------------------------------- 3810 static LRESULT CALLBACK TrimDialogSubclassProc( 3811 HWND hWnd, UINT message, WPARAM wParam, LPARAM lParam, 3812 UINT_PTR uIdSubclass, DWORD_PTR /*dwRefData*/) 3813 { 3814 switch (message) 3815 { 3816 case WM_NCDESTROY: 3817 RemoveWindowSubclass(hWnd, TrimDialogSubclassProc, uIdSubclass); 3818 break; 3819 3820 case WM_NCHITTEST: 3821 { 3822 // First let the default handler process it 3823 LRESULT ht = DefSubclassProc(hWnd, message, wParam, lParam); 3824 3825 // If it's in the client area and not maximized, check for resize grip 3826 if (ht == HTCLIENT && !IsZoomed(hWnd)) 3827 { 3828 RECT rcClient; 3829 GetClientRect(hWnd, &rcClient); 3830 3831 POINT pt = { GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam) }; 3832 ScreenToClient(hWnd, &pt); 3833 3834 const int gripWidth = GetSystemMetrics(SM_CXHSCROLL); 3835 const int gripHeight = GetSystemMetrics(SM_CYVSCROLL); 3836 3837 if (pt.x >= rcClient.right - gripWidth && pt.y >= rcClient.bottom - gripHeight) 3838 { 3839 return HTBOTTOMRIGHT; 3840 } 3841 } 3842 return ht; 3843 } 3844 } 3845 3846 return DefSubclassProc(hWnd, message, wParam, lParam); 3847 } 3848 3849 //---------------------------------------------------------------------------- 3850 // 3851 // VideoRecordingSession::TrimDialogProc 3852 // 3853 // Dialog procedure for trim dialog 3854 // 3855 //---------------------------------------------------------------------------- 3856 INT_PTR CALLBACK VideoRecordingSession::TrimDialogProc(HWND hDlg, UINT message, WPARAM wParam, LPARAM lParam) 3857 { 3858 static TrimDialogData* pData = nullptr; 3859 static UINT currentDpi = DPI_BASELINE; 3860 3861 switch (message) 3862 { 3863 case WM_INITDIALOG: 3864 { 3865 pData = reinterpret_cast<TrimDialogData*>(lParam); 3866 if (!pData) 3867 { 3868 EndDialog(hDlg, IDCANCEL); 3869 return FALSE; 3870 } 3871 3872 hDlgTrimDialog = hDlg; 3873 SetWindowLongPtr(hDlg, DWLP_USER, lParam); 3874 3875 pData->hDialog = hDlg; 3876 pData->hoverPlay = false; 3877 pData->hoverRewind = false; 3878 pData->hoverForward = false; 3879 pData->hoverSkipStart = false; 3880 pData->hoverSkipEnd = false; 3881 pData->isPlaying.store(false, std::memory_order_relaxed); 3882 pData->lastRenderedPreview.store(-1, std::memory_order_relaxed); 3883 3884 AcquireHighResTimer(); 3885 3886 // Make OK the default button 3887 SendMessage(hDlg, DM_SETDEFID, IDOK, 0); 3888 3889 // Subclass the dialog to handle resize grip hit testing 3890 SetWindowSubclass(hDlg, TrimDialogSubclassProc, 0, reinterpret_cast<DWORD_PTR>(pData)); 3891 3892 HWND hTimeline = GetDlgItem(hDlg, IDC_TRIM_TIMELINE); 3893 if (hTimeline) 3894 { 3895 // Remove WS_EX_TRANSPARENT to prevent flicker during resize 3896 SetWindowLongPtr(hTimeline, GWL_EXSTYLE, GetWindowLongPtr(hTimeline, GWL_EXSTYLE) & ~WS_EX_TRANSPARENT); 3897 SetWindowSubclass(hTimeline, TimelineSubclassProc, 1, reinterpret_cast<DWORD_PTR>(pData)); 3898 } 3899 HWND hPlayPause = GetDlgItem(hDlg, IDC_TRIM_PLAY_PAUSE); 3900 if (hPlayPause) 3901 { 3902 SetWindowSubclass(hPlayPause, PlaybackButtonSubclassProc, 2, reinterpret_cast<DWORD_PTR>(pData)); 3903 } 3904 HWND hRewind = GetDlgItem(hDlg, IDC_TRIM_REWIND); 3905 if (hRewind) 3906 { 3907 SetWindowSubclass(hRewind, PlaybackButtonSubclassProc, 3, reinterpret_cast<DWORD_PTR>(pData)); 3908 } 3909 HWND hForward = GetDlgItem(hDlg, IDC_TRIM_FORWARD); 3910 if (hForward) 3911 { 3912 SetWindowSubclass(hForward, PlaybackButtonSubclassProc, 4, reinterpret_cast<DWORD_PTR>(pData)); 3913 } 3914 HWND hSkipStart = GetDlgItem(hDlg, IDC_TRIM_SKIP_START); 3915 if (hSkipStart) 3916 { 3917 SetWindowSubclass(hSkipStart, PlaybackButtonSubclassProc, 5, reinterpret_cast<DWORD_PTR>(pData)); 3918 } 3919 HWND hSkipEnd = GetDlgItem(hDlg, IDC_TRIM_SKIP_END); 3920 if (hSkipEnd) 3921 { 3922 SetWindowSubclass(hSkipEnd, PlaybackButtonSubclassProc, 6, reinterpret_cast<DWORD_PTR>(pData)); 3923 } 3924 HWND hVolumeIcon = GetDlgItem(hDlg, IDC_TRIM_VOLUME_ICON); 3925 if (hVolumeIcon) 3926 { 3927 SetWindowSubclass(hVolumeIcon, VolumeIconSubclassProc, 7, reinterpret_cast<DWORD_PTR>(pData)); 3928 } 3929 3930 // Initialize volume from saved setting 3931 pData->volume = std::clamp(static_cast<double>(g_TrimDialogVolume) / 100.0, 0.0, 1.0); 3932 pData->previousVolume = (pData->volume > 0.0) ? pData->volume : 0.70; // Remember initial volume for unmute 3933 3934 // Initialize volume slider 3935 HWND hVolume = GetDlgItem(hDlg, IDC_TRIM_VOLUME); 3936 if (hVolume) 3937 { 3938 SendMessage(hVolume, TBM_SETRANGE, TRUE, MAKELPARAM(0, 100)); 3939 SendMessage(hVolume, TBM_SETPOS, TRUE, static_cast<LPARAM>(pData->volume * 100)); 3940 } 3941 3942 // Hide volume controls for GIF (no audio) 3943 if (pData->isGif) 3944 { 3945 if (hVolumeIcon) 3946 { 3947 ShowWindow(hVolumeIcon, SW_HIDE); 3948 } 3949 if (hVolume) 3950 { 3951 ShowWindow(hVolume, SW_HIDE); 3952 } 3953 } 3954 3955 // Ensure incoming times are sane and within bounds. 3956 if (pData->videoDuration.count() > 0) 3957 { 3958 const int64_t durationTicks = pData->videoDuration.count(); 3959 const int64_t endTicks = (pData->trimEnd.count() > 0) ? pData->trimEnd.count() : durationTicks; 3960 const int64_t clampedEnd = std::clamp<int64_t>(endTicks, 0, durationTicks); 3961 const int64_t clampedStart = std::clamp<int64_t>(pData->trimStart.count(), 0, clampedEnd); 3962 pData->trimStart = winrt::TimeSpan{ clampedStart }; 3963 pData->trimEnd = winrt::TimeSpan{ clampedEnd }; 3964 } 3965 3966 // Keep the playhead at a valid position. 3967 const int64_t upper = (pData->trimEnd.count() > 0) ? pData->trimEnd.count() : pData->videoDuration.count(); 3968 pData->currentPosition = winrt::TimeSpan{ std::clamp<int64_t>(pData->currentPosition.count(), 0, upper) }; 3969 3970 UpdateDurationDisplay(hDlg, pData); 3971 3972 // Update labels and timeline; skip async preview load if we already have a preloaded frame 3973 if (pData->hPreviewBitmap) 3974 { 3975 // Already have a preview from preloading - just update the UI 3976 UpdatePositionUI(hDlg, pData, true); 3977 InvalidateRect(GetDlgItem(hDlg, IDC_TRIM_PREVIEW), nullptr, FALSE); 3978 } 3979 else 3980 { 3981 // No preloaded preview - start async video load 3982 UpdateVideoPreview(hDlg, pData); 3983 } 3984 // Show time relative to left grip (trimStart) 3985 const auto relativePos = winrt::TimeSpan{ (std::max)(pData->currentPosition.count() - pData->trimStart.count(), int64_t{ 0 }) }; 3986 SetTimeText(hDlg, IDC_TRIM_POSITION_LABEL, relativePos, true); 3987 3988 // Initialize currentDpi to actual dialog DPI (for WM_DPICHANGED handling) 3989 currentDpi = GetDpiForWindowHelper(hDlg); 3990 3991 // Create a larger font for the time position label 3992 { 3993 int fontSize = -MulDiv(12, static_cast<int>(currentDpi), USER_DEFAULT_SCREEN_DPI); // 12pt font 3994 pData->hTimeLabelFont = CreateFont(fontSize, 0, 0, 0, FW_NORMAL, FALSE, FALSE, FALSE, DEFAULT_CHARSET, 3995 OUT_DEFAULT_PRECIS, CLIP_DEFAULT_PRECIS, CLEARTYPE_QUALITY, DEFAULT_PITCH | FF_DONTCARE, L"Segoe UI"); 3996 if (pData->hTimeLabelFont) 3997 { 3998 HWND hPosition = GetDlgItem(hDlg, IDC_TRIM_POSITION_LABEL); 3999 if (hPosition) 4000 { 4001 SendMessage(hPosition, WM_SETFONT, reinterpret_cast<WPARAM>(pData->hTimeLabelFont), TRUE); 4002 } 4003 HWND hDuration = GetDlgItem(hDlg, IDC_TRIM_DURATION_LABEL); 4004 if (hDuration) 4005 { 4006 SendMessage(hDuration, WM_SETFONT, reinterpret_cast<WPARAM>(pData->hTimeLabelFont), TRUE); 4007 } 4008 } 4009 } 4010 4011 // Apply dark mode 4012 ApplyDarkModeToDialog( hDlg ); 4013 4014 // Apply saved dialog size if available, then center 4015 if (g_TrimDialogWidth > 0 && g_TrimDialogHeight > 0) 4016 { 4017 // Get current window rect to preserve position initially 4018 RECT rcDlg{}; 4019 GetWindowRect(hDlg, &rcDlg); 4020 4021 // Apply saved size (stored in screen pixels) 4022 SetWindowPos(hDlg, nullptr, 0, 0, 4023 static_cast<int>(g_TrimDialogWidth), 4024 static_cast<int>(g_TrimDialogHeight), 4025 SWP_NOMOVE | SWP_NOZORDER | SWP_NOACTIVATE); 4026 } 4027 4028 // Center dialog on screen 4029 CenterTrimDialog(hDlg); 4030 return TRUE; 4031 } 4032 4033 case WM_CTLCOLORDLG: 4034 case WM_CTLCOLORBTN: 4035 case WM_CTLCOLOREDIT: 4036 case WM_CTLCOLORLISTBOX: 4037 { 4038 HDC hdc = reinterpret_cast<HDC>(wParam); 4039 HWND hCtrl = reinterpret_cast<HWND>(lParam); 4040 HBRUSH hBrush = HandleDarkModeCtlColor(hdc, hCtrl, message); 4041 if (hBrush) 4042 { 4043 return reinterpret_cast<INT_PTR>(hBrush); 4044 } 4045 break; 4046 } 4047 4048 case WM_CTLCOLORSTATIC: 4049 { 4050 HDC hdc = reinterpret_cast<HDC>(wParam); 4051 HWND hCtrl = reinterpret_cast<HWND>(lParam); 4052 // Use timeline marker color for duration and position labels 4053 if (IsDarkModeEnabled()) 4054 { 4055 int ctrlId = GetDlgCtrlID(hCtrl); 4056 if (ctrlId == IDC_TRIM_DURATION_LABEL || ctrlId == IDC_TRIM_POSITION_LABEL) 4057 { 4058 SetBkMode(hdc, TRANSPARENT); 4059 SetTextColor(hdc, RGB(140, 140, 140)); // Match timeline marker color 4060 return reinterpret_cast<INT_PTR>(GetDarkModeBrush()); 4061 } 4062 } 4063 HBRUSH hBrush = HandleDarkModeCtlColor(hdc, hCtrl, message); 4064 if (hBrush) 4065 { 4066 return reinterpret_cast<INT_PTR>(hBrush); 4067 } 4068 break; 4069 } 4070 4071 case WM_ERASEBKGND: 4072 if (IsDarkModeEnabled()) 4073 { 4074 HDC hdc = reinterpret_cast<HDC>(wParam); 4075 RECT rc; 4076 GetClientRect(hDlg, &rc); 4077 FillRect(hdc, &rc, GetDarkModeBrush()); 4078 4079 // Draw the resize grip at the bottom-right corner (dark mode only for now) 4080 if (!IsZoomed(hDlg)) 4081 { 4082 const int gripWidth = GetSystemMetrics(SM_CXHSCROLL); 4083 const int gripHeight = GetSystemMetrics(SM_CYVSCROLL); 4084 RECT rcGrip = { 4085 rc.right - gripWidth, 4086 rc.bottom - gripHeight, 4087 rc.right, 4088 rc.bottom 4089 }; 4090 4091 HTHEME hTheme = OpenThemeData(hDlg, L"STATUS"); 4092 if (hTheme) 4093 { 4094 DrawThemeBackground(hTheme, hdc, SP_GRIPPER, 0, &rcGrip, nullptr); 4095 CloseThemeData(hTheme); 4096 } 4097 else 4098 { 4099 DrawFrameControl(hdc, &rcGrip, DFC_SCROLL, DFCS_SCROLLSIZEGRIP); 4100 } 4101 } 4102 4103 return TRUE; 4104 } 4105 break; 4106 4107 case WM_GETMINMAXINFO: 4108 { 4109 // Set minimum dialog size to prevent controls from overlapping 4110 MINMAXINFO* mmi = reinterpret_cast<MINMAXINFO*>(lParam); 4111 // Use MapDialogRect to convert dialog units to pixels 4112 // Minimum size: 440x300 dialog units (smaller than original 521x380) 4113 RECT rcMin = { 0, 0, 440, 300 }; 4114 MapDialogRect(hDlg, &rcMin); 4115 // Add frame/border size 4116 RECT rcFrame = { 0, 0, 0, 0 }; 4117 AdjustWindowRectEx(&rcFrame, GetWindowLong(hDlg, GWL_STYLE), FALSE, GetWindowLong(hDlg, GWL_EXSTYLE)); 4118 const int frameWidth = (rcFrame.right - rcFrame.left); 4119 const int frameHeight = (rcFrame.bottom - rcFrame.top); 4120 mmi->ptMinTrackSize.x = rcMin.right + frameWidth; 4121 mmi->ptMinTrackSize.y = rcMin.bottom + frameHeight; 4122 return 0; 4123 } 4124 4125 case WM_SIZE: 4126 { 4127 if (wParam == SIZE_MINIMIZED) 4128 { 4129 return 0; 4130 } 4131 4132 pData = reinterpret_cast<TrimDialogData*>(GetWindowLongPtr(hDlg, DWLP_USER)); 4133 if (!pData) 4134 { 4135 return 0; 4136 } 4137 4138 const int clientWidth = LOWORD(lParam); 4139 const int clientHeight = HIWORD(lParam); 4140 4141 // Use MapDialogRect to convert dialog units to pixels properly 4142 // This accounts for font metrics and DPI 4143 auto DluToPixels = [hDlg](int dluX, int dluY, int* pxX, int* pxY) { 4144 RECT rc = { 0, 0, dluX, dluY }; 4145 MapDialogRect(hDlg, &rc); 4146 if (pxX) *pxX = rc.right; 4147 if (pxY) *pxY = rc.bottom; 4148 }; 4149 4150 // Convert dialog unit values to pixels 4151 int marginLeft, marginRight, marginTop; 4152 DluToPixels(12, 12, &marginLeft, &marginTop); 4153 DluToPixels(11, 0, &marginRight, nullptr); 4154 4155 // Suppress redraw on the entire dialog during layout to prevent tearing 4156 SendMessage(hDlg, WM_SETREDRAW, FALSE, 0); 4157 4158 // Fixed heights from RC file (in dialog units) converted to pixels 4159 int labelHeight, timelineHeight, buttonRowHeight, okCancelHeight, bottomMargin; 4160 int spacing4, spacing2, spacing8; 4161 DluToPixels(0, 10, nullptr, &labelHeight); // Label height: 10 DLU (for 8pt font) 4162 DluToPixels(0, 50, nullptr, &timelineHeight); // Timeline height: 50 DLU 4163 DluToPixels(0, 32, nullptr, &buttonRowHeight); // Play button height: 32 DLU 4164 DluToPixels(0, 14, nullptr, &okCancelHeight); // OK/Cancel height: 14 DLU 4165 DluToPixels(0, 8, nullptr, &bottomMargin); // Bottom margin 4166 DluToPixels(0, 4, nullptr, &spacing4); // 4 DLU spacing 4167 DluToPixels(0, 2, nullptr, &spacing2); // 2 DLU spacing 4168 DluToPixels(0, 8, nullptr, &spacing8); // 8 DLU spacing 4169 4170 // Calculate vertical positions from bottom up 4171 const int okCancelY = clientHeight - bottomMargin - okCancelHeight; 4172 const int buttonRowY = okCancelY - spacing4 - buttonRowHeight; 4173 const int timelineY = buttonRowY - spacing4 - timelineHeight; 4174 const int labelY = timelineY - spacing2 - labelHeight; 4175 4176 // Preview fills from top to above labels 4177 const int previewHeight = labelY - spacing8 - marginTop; 4178 const int previewWidth = clientWidth - marginLeft - marginRight; 4179 const int timelineWidth = previewWidth; 4180 4181 // Resize preview 4182 HWND hPreview = GetDlgItem(hDlg, IDC_TRIM_PREVIEW); 4183 if (hPreview) 4184 { 4185 SetWindowPos(hPreview, nullptr, marginLeft, marginTop, previewWidth, previewHeight, 4186 SWP_NOZORDER | SWP_NOACTIVATE); 4187 } 4188 4189 // Position duration label (left-aligned) 4190 HWND hDuration = GetDlgItem(hDlg, IDC_TRIM_DURATION_LABEL); 4191 if (hDuration) 4192 { 4193 int labelWidth; 4194 DluToPixels(160, 0, &labelWidth, nullptr); 4195 SetWindowPos(hDuration, nullptr, marginLeft, labelY, labelWidth, labelHeight, 4196 SWP_NOZORDER | SWP_NOACTIVATE); 4197 } 4198 4199 // Position time label (centered) 4200 HWND hPosition = GetDlgItem(hDlg, IDC_TRIM_POSITION_LABEL); 4201 if (hPosition) 4202 { 4203 int posLabelWidth; 4204 DluToPixels(200, 0, &posLabelWidth, nullptr); 4205 const int posLabelX = (clientWidth - posLabelWidth) / 2; 4206 SetWindowPos(hPosition, nullptr, posLabelX, labelY, posLabelWidth, labelHeight, 4207 SWP_NOZORDER | SWP_NOACTIVATE); 4208 } 4209 4210 // Resize timeline 4211 HWND hTimeline = GetDlgItem(hDlg, IDC_TRIM_TIMELINE); 4212 if (hTimeline) 4213 { 4214 SetWindowPos(hTimeline, nullptr, marginLeft, timelineY, timelineWidth, timelineHeight, 4215 SWP_NOZORDER | SWP_NOACTIVATE); 4216 } 4217 4218 // Position playback buttons (centered horizontally) 4219 // Button sizes: play=44x32, small=30x26 (in dialog units) 4220 int playButtonWidth, playButtonHeight, smallButtonWidth, smallButtonHeight, buttonSpacing; 4221 DluToPixels(44, 32, &playButtonWidth, &playButtonHeight); 4222 DluToPixels(30, 26, &smallButtonWidth, &smallButtonHeight); 4223 DluToPixels(2, 0, &buttonSpacing, nullptr); 4224 4225 // Count actual buttons present to calculate total width 4226 HWND hSkipStart = GetDlgItem(hDlg, IDC_TRIM_SKIP_START); 4227 HWND hRewind = GetDlgItem(hDlg, IDC_TRIM_REWIND); 4228 HWND hPlayPause = GetDlgItem(hDlg, IDC_TRIM_PLAY_PAUSE); 4229 HWND hForward = GetDlgItem(hDlg, IDC_TRIM_FORWARD); 4230 HWND hSkipEnd = GetDlgItem(hDlg, IDC_TRIM_SKIP_END); 4231 4232 int numSmallButtons = 0; 4233 int numPlayButtons = 0; 4234 if (hSkipStart) numSmallButtons++; 4235 if (hRewind) numSmallButtons++; 4236 if (hPlayPause) numPlayButtons++; 4237 if (hForward) numSmallButtons++; 4238 if (hSkipEnd) numSmallButtons++; 4239 4240 const int numButtons = numSmallButtons + numPlayButtons; 4241 const int totalButtonWidth = smallButtonWidth * numSmallButtons + playButtonWidth * numPlayButtons + 4242 buttonSpacing * (numButtons > 0 ? numButtons - 1 : 0); 4243 int buttonX = (clientWidth - totalButtonWidth) / 2; 4244 4245 if (hSkipStart) 4246 { 4247 const int yOffset = (buttonRowHeight - smallButtonHeight) / 2; 4248 SetWindowPos(hSkipStart, nullptr, buttonX, buttonRowY + yOffset, smallButtonWidth, smallButtonHeight, 4249 SWP_NOZORDER | SWP_NOACTIVATE); 4250 buttonX += smallButtonWidth + buttonSpacing; 4251 } 4252 4253 if (hRewind) 4254 { 4255 const int yOffset = (buttonRowHeight - smallButtonHeight) / 2; 4256 SetWindowPos(hRewind, nullptr, buttonX, buttonRowY + yOffset, smallButtonWidth, smallButtonHeight, 4257 SWP_NOZORDER | SWP_NOACTIVATE); 4258 buttonX += smallButtonWidth + buttonSpacing; 4259 } 4260 4261 if (hPlayPause) 4262 { 4263 SetWindowPos(hPlayPause, nullptr, buttonX, buttonRowY, playButtonWidth, playButtonHeight, 4264 SWP_NOZORDER | SWP_NOACTIVATE); 4265 buttonX += playButtonWidth + buttonSpacing; 4266 } 4267 4268 if (hForward) 4269 { 4270 const int yOffset = (buttonRowHeight - smallButtonHeight) / 2; 4271 SetWindowPos(hForward, nullptr, buttonX, buttonRowY + yOffset, smallButtonWidth, smallButtonHeight, 4272 SWP_NOZORDER | SWP_NOACTIVATE); 4273 buttonX += smallButtonWidth + buttonSpacing; 4274 } 4275 4276 if (hSkipEnd) 4277 { 4278 const int yOffset = (buttonRowHeight - smallButtonHeight) / 2; 4279 SetWindowPos(hSkipEnd, nullptr, buttonX, buttonRowY + yOffset, smallButtonWidth, smallButtonHeight, 4280 SWP_NOZORDER | SWP_NOACTIVATE); 4281 buttonX += smallButtonWidth + buttonSpacing; 4282 } 4283 4284 // Position volume icon and slider (to the right of playback buttons) 4285 int volumeIconWidth, volumeIconHeight, volumeSliderWidth, volumeSliderHeight, volumeSpacing; 4286 DluToPixels(14, 12, &volumeIconWidth, &volumeIconHeight); 4287 DluToPixels(70, 14, &volumeSliderWidth, &volumeSliderHeight); 4288 DluToPixels(8, 0, &volumeSpacing, nullptr); 4289 4290 HWND hVolumeIcon = GetDlgItem(hDlg, IDC_TRIM_VOLUME_ICON); 4291 HWND hVolumeSlider = GetDlgItem(hDlg, IDC_TRIM_VOLUME); 4292 4293 if (hVolumeIcon) 4294 { 4295 const int iconX = buttonX + volumeSpacing; 4296 const int iconY = buttonRowY + (buttonRowHeight - volumeIconHeight) / 2; 4297 SetWindowPos(hVolumeIcon, nullptr, iconX, iconY, volumeIconWidth, volumeIconHeight, 4298 SWP_NOZORDER | SWP_NOACTIVATE); 4299 } 4300 4301 if (hVolumeSlider) 4302 { 4303 const int sliderX = buttonX + volumeSpacing + volumeIconWidth + 4; 4304 const int sliderY = buttonRowY + (buttonRowHeight - volumeSliderHeight) / 2; 4305 SetWindowPos(hVolumeSlider, nullptr, sliderX, sliderY, volumeSliderWidth, volumeSliderHeight, 4306 SWP_NOZORDER | SWP_NOACTIVATE); 4307 } 4308 4309 // Position OK/Cancel buttons (right-aligned) 4310 int okCancelWidth, okCancelSpacingH; 4311 DluToPixels(50, 0, &okCancelWidth, nullptr); 4312 DluToPixels(4, 0, &okCancelSpacingH, nullptr); 4313 4314 HWND hCancel = GetDlgItem(hDlg, IDCANCEL); 4315 if (hCancel) 4316 { 4317 const int cancelX = clientWidth - marginRight - okCancelWidth; 4318 SetWindowPos(hCancel, nullptr, cancelX, okCancelY, okCancelWidth, okCancelHeight, 4319 SWP_NOZORDER | SWP_NOACTIVATE); 4320 } 4321 4322 HWND hOK = GetDlgItem(hDlg, IDOK); 4323 if (hOK) 4324 { 4325 const int okX = clientWidth - marginRight - okCancelWidth - okCancelSpacingH - okCancelWidth; 4326 SetWindowPos(hOK, nullptr, okX, okCancelY, okCancelWidth, okCancelHeight, 4327 SWP_NOZORDER | SWP_NOACTIVATE); 4328 } 4329 4330 // Re-enable redraw and repaint the entire dialog 4331 SendMessage(hDlg, WM_SETREDRAW, TRUE, 0); 4332 // Use RDW_ERASE for the dialog, but invalidate timeline separately without erase to prevent flicker 4333 HWND hTimelineCtrl = GetDlgItem(hDlg, IDC_TRIM_TIMELINE); 4334 RedrawWindow(hDlg, nullptr, nullptr, RDW_ERASE | RDW_FRAME | RDW_INVALIDATE | RDW_ALLCHILDREN); 4335 if (hTimelineCtrl) 4336 { 4337 // Redraw timeline without erase - double buffering handles the background 4338 RedrawWindow(hTimelineCtrl, nullptr, nullptr, RDW_INVALIDATE | RDW_UPDATENOW); 4339 } 4340 return 0; 4341 } 4342 4343 case WMU_PREVIEW_READY: 4344 { 4345 // Video preview loaded - refresh preview area 4346 pData = reinterpret_cast<TrimDialogData*>(GetWindowLongPtr(hDlg, DWLP_USER)); 4347 if (pData) 4348 { 4349 KillTimer(hDlg, kPlaybackTimerId); 4350 InvalidateRect(GetDlgItem(hDlg, IDC_TRIM_PREVIEW), nullptr, FALSE); 4351 } 4352 return TRUE; 4353 } 4354 4355 case WMU_PREVIEW_SCHEDULED: 4356 { 4357 pData = reinterpret_cast<TrimDialogData*>(GetWindowLongPtr(hDlg, DWLP_USER)); 4358 if (pData) 4359 { 4360 UpdateVideoPreview(hDlg, pData); 4361 } 4362 return TRUE; 4363 } 4364 4365 case WMU_DURATION_CHANGED: 4366 { 4367 pData = reinterpret_cast<TrimDialogData*>(GetWindowLongPtr(hDlg, DWLP_USER)); 4368 if (pData) 4369 { 4370 // If the user hasn't manually trimmed (selection was at estimated full duration), 4371 // update the selection to the actual full video duration 4372 if (pData->trimEnd.count() >= pData->originalTrimEnd.count()) 4373 { 4374 pData->trimEnd = pData->videoDuration; 4375 pData->originalTrimEnd = pData->videoDuration; 4376 } 4377 // Clamp trimEnd to actual duration if it exceeds 4378 if (pData->trimEnd.count() > pData->videoDuration.count()) 4379 { 4380 pData->trimEnd = pData->videoDuration; 4381 } 4382 4383 if (pData->currentPosition.count() > pData->trimEnd.count()) 4384 { 4385 pData->currentPosition = pData->trimEnd; 4386 } 4387 UpdateDurationDisplay(hDlg, pData); 4388 UpdatePositionUI(hDlg, pData); 4389 } 4390 return TRUE; 4391 } 4392 4393 case WMU_PLAYBACK_POSITION: 4394 { 4395 pData = reinterpret_cast<TrimDialogData*>(GetWindowLongPtr(hDlg, DWLP_USER)); 4396 if (pData) 4397 { 4398 // Always move the playhead smoothly 4399 UpdatePositionUI(hDlg, pData); 4400 4401 // Throttle expensive thumbnail generation while playing 4402 const int64_t currentTicks = pData->currentPosition.count(); 4403 const int64_t lastTicks = pData->lastRenderedPreview.load(std::memory_order_relaxed); 4404 if (!pData->loadingPreview.load(std::memory_order_relaxed)) 4405 { 4406 const int64_t delta = (lastTicks < 0) ? kPreviewMinDeltaTicks : std::llabs(currentTicks - lastTicks); 4407 if (delta >= kPreviewMinDeltaTicks) 4408 { 4409 UpdateVideoPreview(hDlg, pData, false); 4410 } 4411 } 4412 } 4413 return TRUE; 4414 } 4415 4416 case WMU_PLAYBACK_STOP: 4417 { 4418 pData = reinterpret_cast<TrimDialogData*>(GetWindowLongPtr(hDlg, DWLP_USER)); 4419 if (!pData) 4420 { 4421 return TRUE; 4422 } 4423 4424 // Force UI + session back to the left grip (trim start) position. 4425 pData->currentPosition = pData->trimStart; 4426 #if _DEBUG 4427 OutputDebugStringW((L"[Trim] WMU_PLAYBACK_STOP: resetting to trimStart=" + 4428 std::to_wstring(pData->trimStart.count()) + L"\n").c_str()); 4429 #endif 4430 StopPlayback(hDlg, pData, false); 4431 4432 // Fast path: if we have a cached frame at the trim start position, restore it instantly. 4433 bool usedCachedFrame = false; 4434 if (pData->hCachedStartFrame && 4435 pData->cachedStartFramePosition.count() == pData->trimStart.count()) 4436 { 4437 std::lock_guard<std::mutex> lock(pData->previewBitmapMutex); 4438 if (pData->hCachedStartFrame) // Double-check under lock 4439 { 4440 // Swap the cached frame into the preview 4441 if (pData->hPreviewBitmap && pData->previewBitmapOwned) 4442 { 4443 DeleteObject(pData->hPreviewBitmap); 4444 } 4445 pData->hPreviewBitmap = pData->hCachedStartFrame; 4446 pData->previewBitmapOwned = true; 4447 pData->hCachedStartFrame = nullptr; // Transferred ownership 4448 pData->lastRenderedPreview.store(pData->trimStart.count(), std::memory_order_relaxed); 4449 usedCachedFrame = true; 4450 } 4451 } 4452 4453 if (usedCachedFrame) 4454 { 4455 // Just update UI - we already have the correct frame 4456 UpdatePositionUI(hDlg, pData, true); 4457 InvalidateRect(GetDlgItem(hDlg, IDC_TRIM_PREVIEW), nullptr, FALSE); 4458 } 4459 else 4460 { 4461 // Fall back to regenerating the preview 4462 UpdateVideoPreview(hDlg, pData); 4463 } 4464 return TRUE; 4465 } 4466 4467 case WM_DRAWITEM: 4468 { 4469 pData = reinterpret_cast<TrimDialogData*>(GetWindowLongPtr(hDlg, DWLP_USER)); 4470 if (!pData) break; 4471 4472 DRAWITEMSTRUCT* pDIS = reinterpret_cast<DRAWITEMSTRUCT *> (lParam); 4473 4474 if (pDIS->CtlID == IDC_TRIM_TIMELINE) 4475 { 4476 // Draw custom timeline 4477 UINT timelineDpi = GetDpiForWindowHelper(pDIS->hwndItem); 4478 DrawTimeline(pDIS->hDC, pDIS->rcItem, pData, timelineDpi); 4479 return TRUE; 4480 } 4481 else if (pDIS->CtlID == IDC_TRIM_PREVIEW) 4482 { 4483 RECT rcFill = pDIS->rcItem; 4484 const int controlWidth = rcFill.right - rcFill.left; 4485 const int controlHeight = rcFill.bottom - rcFill.top; 4486 4487 std::unique_lock<std::mutex> previewLock(pData->previewBitmapMutex); 4488 4489 // Create memory DC for double buffering to eliminate flicker 4490 HDC hdcMem = CreateCompatibleDC(pDIS->hDC); 4491 HBITMAP hbmMem = CreateCompatibleBitmap(pDIS->hDC, controlWidth, controlHeight); 4492 HBITMAP hbmOld = static_cast<HBITMAP>(SelectObject(hdcMem, hbmMem)); 4493 4494 // Draw to memory DC 4495 RECT rcMem = { 0, 0, controlWidth, controlHeight }; 4496 FillRect(hdcMem, &rcMem, static_cast<HBRUSH>(GetStockObject(BLACK_BRUSH))); 4497 4498 if (pData->hPreviewBitmap) 4499 { 4500 HDC hdcBitmap = CreateCompatibleDC(hdcMem); 4501 HBITMAP hOldBitmap = static_cast<HBITMAP>(SelectObject(hdcBitmap, pData->hPreviewBitmap)); 4502 4503 BITMAP bm{}; 4504 GetObject(pData->hPreviewBitmap, sizeof(bm), &bm); 4505 4506 int destWidth = 0; 4507 int destHeight = 0; 4508 4509 if (bm.bmWidth > 0 && bm.bmHeight > 0) 4510 { 4511 const double scaleX = static_cast<double>(controlWidth) / static_cast<double>(bm.bmWidth); 4512 const double scaleY = static_cast<double>(controlHeight) / static_cast<double>(bm.bmHeight); 4513 // Use min to fit entirely within control (letterbox), not max which crops 4514 const double scale = (std::min)(scaleX, scaleY); 4515 4516 destWidth = (std::max)(1, static_cast<int>(std::lround(static_cast<double>(bm.bmWidth) * scale))); 4517 destHeight = (std::max)(1, static_cast<int>(std::lround(static_cast<double>(bm.bmHeight) * scale))); 4518 } 4519 else 4520 { 4521 destWidth = controlWidth; 4522 destHeight = controlHeight; 4523 } 4524 4525 const int offsetX = (controlWidth - destWidth) / 2; 4526 const int offsetY = (controlHeight - destHeight) / 2; 4527 4528 SetStretchBltMode(hdcMem, HALFTONE); 4529 SetBrushOrgEx(hdcMem, 0, 0, nullptr); 4530 StretchBlt(hdcMem, 4531 offsetX, 4532 offsetY, 4533 destWidth, 4534 destHeight, 4535 hdcBitmap, 4536 0, 4537 0, 4538 bm.bmWidth, 4539 bm.bmHeight, 4540 SRCCOPY); 4541 4542 SelectObject(hdcBitmap, hOldBitmap); 4543 DeleteDC(hdcBitmap); 4544 } 4545 else 4546 { 4547 SetTextColor(hdcMem, RGB(200, 200, 200)); 4548 SetBkMode(hdcMem, TRANSPARENT); 4549 DrawText(hdcMem, L"Preview not available", -1, &rcMem, DT_CENTER | DT_VCENTER | DT_SINGLELINE); 4550 } 4551 4552 // Copy the buffered image to the screen 4553 BitBlt(pDIS->hDC, rcFill.left, rcFill.top, controlWidth, controlHeight, hdcMem, 0, 0, SRCCOPY); 4554 4555 // Clean up 4556 SelectObject(hdcMem, hbmOld); 4557 DeleteObject(hbmMem); 4558 DeleteDC(hdcMem); 4559 4560 return TRUE; 4561 } 4562 else if (pDIS->CtlID == IDC_TRIM_PLAY_PAUSE || pDIS->CtlID == IDC_TRIM_REWIND || 4563 pDIS->CtlID == IDC_TRIM_FORWARD || pDIS->CtlID == IDC_TRIM_SKIP_START || 4564 pDIS->CtlID == IDC_TRIM_SKIP_END) 4565 { 4566 DrawPlaybackButton(pDIS, pData); 4567 return TRUE; 4568 } 4569 else if (pDIS->CtlID == IDC_TRIM_VOLUME_ICON) 4570 { 4571 // Draw speaker icon for volume control 4572 int width = pDIS->rcItem.right - pDIS->rcItem.left; 4573 int height = pDIS->rcItem.bottom - pDIS->rcItem.top; 4574 float centerX = pDIS->rcItem.left + width / 2.0f; 4575 float centerY = pDIS->rcItem.top + height / 2.0f; 4576 4577 Gdiplus::Graphics graphics(pDIS->hDC); 4578 graphics.SetSmoothingMode(Gdiplus::SmoothingModeAntiAlias); 4579 4580 // Dark background 4581 Gdiplus::SolidBrush bgBrush(Gdiplus::Color(255, 35, 35, 40)); 4582 graphics.FillRectangle(&bgBrush, pDIS->rcItem.left, pDIS->rcItem.top, width, height); 4583 4584 // Icon color - brighter on hover 4585 const bool isHover = pData && pData->hoverVolumeIcon; 4586 COLORREF iconColor = isHover ? RGB(255, 255, 255) : RGB(180, 180, 180); 4587 Gdiplus::SolidBrush iconBrush(Gdiplus::Color(255, GetRValue(iconColor), GetGValue(iconColor), GetBValue(iconColor))); 4588 Gdiplus::Pen iconPen(Gdiplus::Color(255, GetRValue(iconColor), GetGValue(iconColor), GetBValue(iconColor)), 1.2f); 4589 4590 // Scale for icon 4591 float scale = min(width, height) / 16.0f; 4592 4593 // Draw speaker body (rectangle + triangle) 4594 float speakerLeft = centerX - 4.0f * scale; 4595 float speakerWidth = 3.0f * scale; 4596 float speakerHeight = 5.0f * scale; 4597 graphics.FillRectangle(&iconBrush, speakerLeft, centerY - speakerHeight / 2.0f, speakerWidth, speakerHeight); 4598 4599 // Speaker cone (triangle) 4600 Gdiplus::PointF cone[3] = { 4601 Gdiplus::PointF(speakerLeft + speakerWidth, centerY - speakerHeight / 2.0f), 4602 Gdiplus::PointF(speakerLeft + speakerWidth + 3.0f * scale, centerY - 4.0f * scale), 4603 Gdiplus::PointF(speakerLeft + speakerWidth + 3.0f * scale, centerY + 4.0f * scale) 4604 }; 4605 Gdiplus::PointF cone2[3] = { 4606 Gdiplus::PointF(speakerLeft + speakerWidth, centerY + speakerHeight / 2.0f), 4607 cone[1], 4608 cone[2] 4609 }; 4610 graphics.FillPolygon(&iconBrush, cone, 3); 4611 graphics.FillPolygon(&iconBrush, cone2, 3); 4612 4613 // Draw sound waves based on volume 4614 if (pData && pData->volume > 0.0) 4615 { 4616 float waveX = speakerLeft + speakerWidth + 4.0f * scale; 4617 4618 // First wave (always visible when volume > 0) 4619 graphics.DrawArc(&iconPen, waveX, centerY - 2.5f * scale, 3.0f * scale, 5.0f * scale, -60.0f, 120.0f); 4620 4621 // Second wave (visible when volume > 33%) 4622 if (pData->volume > 0.33) 4623 { 4624 graphics.DrawArc(&iconPen, waveX + 1.5f * scale, centerY - 4.0f * scale, 4.5f * scale, 8.0f * scale, -60.0f, 120.0f); 4625 } 4626 4627 // Third wave (visible when volume > 66%) 4628 if (pData->volume > 0.66) 4629 { 4630 graphics.DrawArc(&iconPen, waveX + 3.0f * scale, centerY - 5.5f * scale, 6.0f * scale, 11.0f * scale, -60.0f, 120.0f); 4631 } 4632 } 4633 else if (pData && pData->volume == 0.0) 4634 { 4635 // Draw X for muted 4636 float xOffset = speakerLeft + speakerWidth + 5.0f * scale; 4637 graphics.DrawLine(&iconPen, xOffset, centerY - 2.5f * scale, xOffset + 3.5f * scale, centerY + 2.5f * scale); 4638 graphics.DrawLine(&iconPen, xOffset, centerY + 2.5f * scale, xOffset + 3.5f * scale, centerY - 2.5f * scale); 4639 } 4640 return TRUE; 4641 } 4642 break; 4643 } 4644 4645 case WM_DPICHANGED: 4646 { 4647 HandleDialogDpiChange( hDlg, wParam, lParam, currentDpi ); 4648 // Invalidate preview and timeline to redraw at new DPI 4649 pData = reinterpret_cast<TrimDialogData*>(GetWindowLongPtr(hDlg, DWLP_USER)); 4650 if (pData) 4651 { 4652 InvalidateRect(GetDlgItem(hDlg, IDC_TRIM_PREVIEW), nullptr, TRUE); 4653 InvalidateRect(GetDlgItem(hDlg, IDC_TRIM_TIMELINE), nullptr, TRUE); 4654 } 4655 return TRUE; 4656 } 4657 4658 case WM_DESTROY: 4659 { 4660 // Save dialog size before closing 4661 RECT rcDlg{}; 4662 if (GetWindowRect(hDlg, &rcDlg)) 4663 { 4664 g_TrimDialogWidth = static_cast<DWORD>(rcDlg.right - rcDlg.left); 4665 g_TrimDialogHeight = static_cast<DWORD>(rcDlg.bottom - rcDlg.top); 4666 reg.WriteRegSettings(RegSettings); 4667 } 4668 4669 pData = reinterpret_cast<TrimDialogData*>(GetWindowLongPtr(hDlg, DWLP_USER)); 4670 if (pData) 4671 { 4672 StopPlayback(hDlg, pData); 4673 4674 // Ensure MediaPlayer and event handlers are fully released 4675 CleanupMediaPlayer(pData); 4676 4677 HWND hTimeline = GetDlgItem(hDlg, IDC_TRIM_TIMELINE); 4678 if (hTimeline) 4679 { 4680 RemoveWindowSubclass(hTimeline, TimelineSubclassProc, 1); 4681 } 4682 HWND hPlayPause = GetDlgItem(hDlg, IDC_TRIM_PLAY_PAUSE); 4683 if (hPlayPause) 4684 { 4685 RemoveWindowSubclass(hPlayPause, PlaybackButtonSubclassProc, 2); 4686 } 4687 HWND hRewind = GetDlgItem(hDlg, IDC_TRIM_REWIND); 4688 if (hRewind) 4689 { 4690 RemoveWindowSubclass(hRewind, PlaybackButtonSubclassProc, 3); 4691 } 4692 HWND hForward = GetDlgItem(hDlg, IDC_TRIM_FORWARD); 4693 if (hForward) 4694 { 4695 RemoveWindowSubclass(hForward, PlaybackButtonSubclassProc, 4); 4696 } 4697 HWND hVolumeIcon = GetDlgItem(hDlg, IDC_TRIM_VOLUME_ICON); 4698 if (hVolumeIcon) 4699 { 4700 RemoveWindowSubclass(hVolumeIcon, VolumeIconSubclassProc, 7); 4701 } 4702 } 4703 if (pData && pData->hPreviewBitmap) 4704 { 4705 std::lock_guard<std::mutex> lock(pData->previewBitmapMutex); 4706 if (pData->previewBitmapOwned) 4707 { 4708 DeleteObject(pData->hPreviewBitmap); 4709 } 4710 pData->hPreviewBitmap = nullptr; 4711 // Also clean up cached playback start frame 4712 if (pData->hCachedStartFrame) 4713 { 4714 DeleteObject(pData->hCachedStartFrame); 4715 pData->hCachedStartFrame = nullptr; 4716 } 4717 } 4718 if (pData) 4719 { 4720 StopMMTimer(pData); // Stop multimedia timer if running 4721 pData->playbackFile = nullptr; 4722 CleanupGifFrames(pData); 4723 // Clean up time label font 4724 if (pData->hTimeLabelFont) 4725 { 4726 DeleteObject(pData->hTimeLabelFont); 4727 pData->hTimeLabelFont = nullptr; 4728 } 4729 } 4730 hDlgTrimDialog = nullptr; 4731 4732 ReleaseHighResTimer(); 4733 break; 4734 } 4735 4736 // Multimedia timer tick - handles MP4 and GIF playback with high precision 4737 case WMU_MM_TIMER_TICK: 4738 { 4739 pData = reinterpret_cast<TrimDialogData*>(GetWindowLongPtr(hDlg, DWLP_USER)); 4740 if (!pData) 4741 { 4742 return TRUE; 4743 } 4744 4745 if (!pData->isPlaying.load(std::memory_order_relaxed)) 4746 { 4747 StopMMTimer(pData); 4748 RefreshPlaybackButtons(hDlg); 4749 return TRUE; 4750 } 4751 4752 // Handle GIF playback 4753 if (pData->isGif && !pData->gifFrames.empty()) 4754 { 4755 // Allow playing from before trimStart - only clamp to video bounds 4756 const int64_t clampedTicks = std::clamp<int64_t>( 4757 pData->currentPosition.count(), 4758 0, 4759 pData->videoDuration.count()); 4760 const size_t frameIndex = FindGifFrameIndex(pData->gifFrames, clampedTicks); 4761 const auto& frame = pData->gifFrames[frameIndex]; 4762 4763 // Check if enough real time has passed to advance to the next frame 4764 auto now = std::chrono::steady_clock::now(); 4765 auto elapsedMs = std::chrono::duration_cast<std::chrono::milliseconds>(now - pData->gifFrameStartTime).count(); 4766 auto frameDurationMs = frame.duration.count() / 10'000; // Convert 100-ns ticks to ms 4767 4768 // Update playhead position smoothly based on elapsed time within current frame 4769 const int64_t frameElapsedTicks = static_cast<int64_t>(elapsedMs) * 10'000; 4770 const int64_t smoothPosition = frame.start.count() + (std::min)(frameElapsedTicks, frame.duration.count()); 4771 // Allow positions before trimStart - only clamp to trimEnd 4772 const int64_t clampedPosition = (std::min)(smoothPosition, pData->trimEnd.count()); 4773 4774 // Check for end-of-clip BEFORE updating UI to avoid showing the end position 4775 // then immediately jumping back to start 4776 if (clampedPosition >= pData->trimEnd.count()) 4777 { 4778 // Immediately mark as not playing to prevent further position updates 4779 pData->isPlaying.store(false, std::memory_order_release); 4780 PostMessage(hDlg, WMU_PLAYBACK_STOP, 0, 0); 4781 return TRUE; 4782 } 4783 4784 pData->currentPosition = winrt::TimeSpan{ clampedPosition }; 4785 4786 // Update playhead 4787 HWND hTimeline = GetDlgItem(hDlg, IDC_TRIM_TIMELINE); 4788 if (hTimeline) 4789 { 4790 const UINT dpi = GetDpiForWindowHelper(hTimeline); 4791 RECT rc; 4792 GetClientRect(hTimeline, &rc); 4793 const int newX = TimelineTimeToClientX(pData, pData->currentPosition, rc.right - rc.left, dpi); 4794 if (newX != pData->lastPlayheadX) 4795 { 4796 InvalidatePlayheadRegion(hTimeline, rc, pData->lastPlayheadX, newX, dpi); 4797 pData->lastPlayheadX = newX; 4798 UpdateWindow(hTimeline); 4799 } 4800 } 4801 4802 // Show time relative to left grip (trimStart) 4803 { 4804 const auto relativePos = winrt::TimeSpan{ (std::max)(pData->currentPosition.count() - pData->trimStart.count(), int64_t{ 0 }) }; 4805 SetTimeText(hDlg, IDC_TRIM_POSITION_LABEL, relativePos, true); 4806 } 4807 4808 if (elapsedMs >= frameDurationMs) 4809 { 4810 // Time to advance to next frame 4811 const int64_t nextTicks = frame.start.count() + frame.duration.count(); 4812 4813 if (nextTicks >= pData->trimEnd.count()) 4814 { 4815 // Immediately mark as not playing to prevent further position updates 4816 pData->isPlaying.store(false, std::memory_order_release); 4817 PostMessage(hDlg, WMU_PLAYBACK_STOP, 0, 0); 4818 } 4819 else 4820 { 4821 pData->currentPosition = winrt::TimeSpan{ nextTicks }; 4822 pData->gifFrameStartTime = now; // Reset timer for new frame 4823 UpdateVideoPreview(hDlg, pData); 4824 } 4825 } 4826 return TRUE; 4827 } 4828 4829 // Handle MP4 playback 4830 if (pData->mediaPlayer) 4831 { 4832 try 4833 { 4834 auto session = pData->mediaPlayer.PlaybackSession(); 4835 if (!session) 4836 { 4837 StopPlayback(hDlg, pData, false); 4838 UpdateVideoPreview(hDlg, pData); 4839 return TRUE; 4840 } 4841 4842 // Simply use MediaPlayer position directly 4843 auto position = session.Position(); 4844 const int64_t mediaTicks = position.count(); 4845 4846 // Suppress the transient 0-position report before the initial seek takes effect. 4847 if (pData->pendingInitialSeek.load(std::memory_order_relaxed) && 4848 pData->pendingInitialSeekTicks.load(std::memory_order_relaxed) > 0 && 4849 mediaTicks == 0) 4850 { 4851 return TRUE; 4852 } 4853 4854 if (mediaTicks != 0) 4855 { 4856 pData->pendingInitialSeek.store(false, std::memory_order_relaxed); 4857 pData->pendingInitialSeekTicks.store(0, std::memory_order_relaxed); 4858 } 4859 4860 // Allow playing from before trimStart - only clamp to video bounds and trimEnd 4861 const int64_t clampedTicks = std::clamp<int64_t>( 4862 mediaTicks, 4863 0, 4864 pData->trimEnd.count()); 4865 4866 // Check for end-of-clip BEFORE updating UI to avoid showing the end position 4867 // then immediately jumping back to start 4868 if (clampedTicks >= pData->trimEnd.count()) 4869 { 4870 // Immediately mark as not playing to prevent further position updates 4871 pData->isPlaying.store(false, std::memory_order_release); 4872 PostMessage(hDlg, WMU_PLAYBACK_STOP, 0, 0); 4873 } 4874 else 4875 { 4876 pData->currentPosition = winrt::TimeSpan{ clampedTicks }; 4877 4878 // Invalidate only the old and new playhead regions for efficiency 4879 HWND hTimeline = GetDlgItem(hDlg, IDC_TRIM_TIMELINE); 4880 if (hTimeline) 4881 { 4882 const UINT dpi = GetDpiForWindowHelper(hTimeline); 4883 RECT rc; 4884 GetClientRect(hTimeline, &rc); 4885 const int newX = TimelineTimeToClientX(pData, pData->currentPosition, rc.right - rc.left, dpi); 4886 // Only repaint if position actually changed 4887 if (newX != pData->lastPlayheadX) 4888 { 4889 InvalidatePlayheadRegion(hTimeline, rc, pData->lastPlayheadX, newX, dpi); 4890 pData->lastPlayheadX = newX; 4891 UpdateWindow(hTimeline); 4892 } 4893 } 4894 // Show time relative to left grip (trimStart) 4895 { 4896 const auto relativePos = winrt::TimeSpan{ (std::max)(pData->currentPosition.count() - pData->trimStart.count(), int64_t{ 0 }) }; 4897 SetTimeText(hDlg, IDC_TRIM_POSITION_LABEL, relativePos, true); 4898 } 4899 } 4900 } 4901 catch (...) 4902 { 4903 } 4904 } 4905 return TRUE; 4906 } 4907 4908 case WM_TIMER: 4909 // WM_TIMER is no longer used for playback; both MP4 and GIF use multimedia timer (WMU_MM_TIMER_TICK) 4910 // This handler is kept for any other timers that might be added in the future 4911 if (wParam == kPlaybackTimerId) 4912 { 4913 // Legacy timer - should not fire anymore, but clean up if it does 4914 KillTimer(hDlg, kPlaybackTimerId); 4915 return TRUE; 4916 } 4917 break; 4918 4919 case WM_HSCROLL: 4920 { 4921 HWND hVolumeSlider = GetDlgItem(hDlg, IDC_TRIM_VOLUME); 4922 if (reinterpret_cast<HWND>(lParam) == hVolumeSlider) 4923 { 4924 pData = reinterpret_cast<TrimDialogData*>(GetWindowLongPtr(hDlg, DWLP_USER)); 4925 if (pData) 4926 { 4927 int pos = static_cast<int>(SendMessage(hVolumeSlider, TBM_GETPOS, 0, 0)); 4928 pData->volume = pos / 100.0; 4929 4930 // Persist volume setting 4931 g_TrimDialogVolume = static_cast<DWORD>(pos); 4932 reg.WriteRegSettings(RegSettings); 4933 4934 if (pData->mediaPlayer) 4935 { 4936 try 4937 { 4938 pData->mediaPlayer.Volume(pData->volume); 4939 pData->mediaPlayer.IsMuted(pData->volume == 0.0); 4940 } 4941 catch (...) 4942 { 4943 } 4944 } 4945 // Invalidate volume icon to update its appearance 4946 InvalidateRect(GetDlgItem(hDlg, IDC_TRIM_VOLUME_ICON), nullptr, FALSE); 4947 } 4948 return TRUE; 4949 } 4950 break; 4951 } 4952 4953 case WM_COMMAND: 4954 switch (LOWORD(wParam)) 4955 { 4956 case IDC_TRIM_VOLUME_ICON: 4957 { 4958 if (HIWORD(wParam) == STN_CLICKED) 4959 { 4960 pData = reinterpret_cast<TrimDialogData*>(GetWindowLongPtr(hDlg, DWLP_USER)); 4961 if (pData) 4962 { 4963 HWND hVolumeSlider = GetDlgItem(hDlg, IDC_TRIM_VOLUME); 4964 4965 if (pData->volume > 0.0) 4966 { 4967 // Mute: save current volume and set to 0 4968 pData->previousVolume = pData->volume; 4969 pData->volume = 0.0; 4970 } 4971 else 4972 { 4973 // Unmute: restore previous volume (default to 70% if never set) 4974 pData->volume = (pData->previousVolume > 0.0) ? pData->previousVolume : 0.70; 4975 } 4976 4977 // Update slider position 4978 if (hVolumeSlider) 4979 { 4980 SendMessage(hVolumeSlider, TBM_SETPOS, TRUE, static_cast<LPARAM>(pData->volume * 100)); 4981 // Force full redraw to avoid leftover thumb artifacts 4982 InvalidateRect(hVolumeSlider, nullptr, TRUE); 4983 } 4984 4985 // Persist volume setting 4986 g_TrimDialogVolume = static_cast<DWORD>(pData->volume * 100); 4987 reg.WriteRegSettings(RegSettings); 4988 4989 // Apply to media player 4990 if (pData->mediaPlayer) 4991 { 4992 try 4993 { 4994 pData->mediaPlayer.Volume(pData->volume); 4995 pData->mediaPlayer.IsMuted(pData->volume == 0.0); 4996 } 4997 catch (...) 4998 { 4999 } 5000 } 5001 5002 // Update icon appearance 5003 InvalidateRect(GetDlgItem(hDlg, IDC_TRIM_VOLUME_ICON), nullptr, FALSE); 5004 } 5005 return TRUE; 5006 } 5007 break; 5008 } 5009 5010 case IDC_TRIM_REWIND: 5011 case IDC_TRIM_PLAY_PAUSE: 5012 case IDC_TRIM_FORWARD: 5013 case IDC_TRIM_SKIP_START: 5014 case IDC_TRIM_SKIP_END: 5015 { 5016 if (HIWORD(wParam) == BN_CLICKED) 5017 { 5018 pData = reinterpret_cast<TrimDialogData*>(GetWindowLongPtr(hDlg, DWLP_USER)); 5019 HandlePlaybackCommand(static_cast<int>(LOWORD(wParam)), pData); 5020 return TRUE; 5021 } 5022 break; 5023 } 5024 5025 case IDOK: 5026 pData = reinterpret_cast<TrimDialogData*>(GetWindowLongPtr(hDlg, DWLP_USER)); 5027 StopPlayback(hDlg, pData); 5028 // Trim times are already set by mouse dragging 5029 EndDialog(hDlg, IDOK); 5030 return TRUE; 5031 5032 case IDCANCEL: 5033 pData = reinterpret_cast<TrimDialogData*>(GetWindowLongPtr(hDlg, DWLP_USER)); 5034 StopPlayback(hDlg, pData); 5035 EndDialog(hDlg, IDCANCEL); 5036 return TRUE; 5037 } 5038 break; 5039 } 5040 5041 return FALSE; 5042 } 5043 5044 //---------------------------------------------------------------------------- 5045 // 5046 // VideoRecordingSession::TrimVideoAsync 5047 // 5048 // Performs the actual video trimming operation 5049 // 5050 //---------------------------------------------------------------------------- 5051 winrt::IAsyncOperation<winrt::hstring> VideoRecordingSession::TrimVideoAsync( 5052 const std::wstring& sourceVideoPath, 5053 winrt::TimeSpan trimTimeStart, 5054 winrt::TimeSpan trimTimeEnd) 5055 { 5056 try 5057 { 5058 // Load the source video file 5059 auto sourceFile = co_await winrt::StorageFile::GetFileFromPathAsync(sourceVideoPath); 5060 5061 // Create a media composition 5062 winrt::MediaComposition composition; 5063 auto clip = co_await winrt::MediaClip::CreateFromFileAsync(sourceFile); 5064 5065 // Set the trim times 5066 clip.TrimTimeFromStart(trimTimeStart); 5067 clip.TrimTimeFromEnd(clip.OriginalDuration() - trimTimeEnd); 5068 5069 // Add the trimmed clip to the composition 5070 composition.Clips().Append(clip); 5071 5072 // Create output file in temp folder 5073 auto tempFolder = co_await winrt::StorageFolder::GetFolderFromPathAsync( 5074 std::filesystem::temp_directory_path().wstring()); 5075 auto zoomitFolder = co_await tempFolder.CreateFolderAsync( 5076 L"ZoomIt", winrt::CreationCollisionOption::OpenIfExists); 5077 5078 // Generate unique filename 5079 std::wstring filename = L"zoomit_trimmed_" + 5080 std::to_wstring(GetTickCount64()) + L".mp4"; 5081 auto outputFile = co_await zoomitFolder.CreateFileAsync( 5082 filename, winrt::CreationCollisionOption::ReplaceExisting); 5083 5084 // Render the composition to the output file with fast trimming (no re-encode) 5085 auto renderResult = co_await composition.RenderToFileAsync( 5086 outputFile, winrt::MediaTrimmingPreference::Fast); 5087 5088 if (renderResult == winrt::TranscodeFailureReason::None) 5089 { 5090 co_return winrt::hstring(outputFile.Path()); 5091 } 5092 else 5093 { 5094 co_return winrt::hstring(); 5095 } 5096 } 5097 catch (...) 5098 { 5099 co_return winrt::hstring(); 5100 } 5101 } 5102 5103 winrt::IAsyncOperation<winrt::hstring> VideoRecordingSession::TrimGifAsync( 5104 const std::wstring& sourceGifPath, 5105 winrt::TimeSpan trimTimeStart, 5106 winrt::TimeSpan trimTimeEnd) 5107 { 5108 co_await winrt::resume_background(); 5109 5110 try 5111 { 5112 if (trimTimeEnd.count() <= trimTimeStart.count()) 5113 { 5114 co_return winrt::hstring(); 5115 } 5116 5117 winrt::com_ptr<IWICImagingFactory> factory; 5118 winrt::check_hresult(CoCreateInstance(CLSID_WICImagingFactory, nullptr, CLSCTX_INPROC_SERVER, IID_PPV_ARGS(factory.put()))); 5119 5120 auto sourceFile = co_await winrt::StorageFile::GetFileFromPathAsync(sourceGifPath); 5121 auto sourceStream = co_await sourceFile.OpenAsync(winrt::FileAccessMode::Read); 5122 5123 winrt::com_ptr<IStream> sourceIStream; 5124 winrt::check_hresult(CreateStreamOverRandomAccessStream(winrt::get_unknown(sourceStream), IID_PPV_ARGS(sourceIStream.put()))); 5125 5126 winrt::com_ptr<IWICBitmapDecoder> decoder; 5127 winrt::check_hresult(factory->CreateDecoderFromStream(sourceIStream.get(), nullptr, WICDecodeMetadataCacheOnLoad, decoder.put())); 5128 5129 UINT frameCount = 0; 5130 winrt::check_hresult(decoder->GetFrameCount(&frameCount)); 5131 if (frameCount == 0) 5132 { 5133 co_return winrt::hstring(); 5134 } 5135 5136 // Prepare output file 5137 auto tempFolder = co_await winrt::StorageFolder::GetFolderFromPathAsync(std::filesystem::temp_directory_path().wstring()); 5138 auto zoomitFolder = co_await tempFolder.CreateFolderAsync(L"ZoomIt", winrt::CreationCollisionOption::OpenIfExists); 5139 std::wstring filename = L"zoomit_trimmed_" + std::to_wstring(GetTickCount64()) + L".gif"; 5140 auto outputFile = co_await zoomitFolder.CreateFileAsync(filename, winrt::CreationCollisionOption::ReplaceExisting); 5141 auto outputStream = co_await outputFile.OpenAsync(winrt::FileAccessMode::ReadWrite); 5142 5143 winrt::com_ptr<IStream> outputIStream; 5144 winrt::check_hresult(CreateStreamOverRandomAccessStream(winrt::get_unknown(outputStream), IID_PPV_ARGS(outputIStream.put()))); 5145 5146 winrt::com_ptr<IWICBitmapEncoder> encoder; 5147 winrt::check_hresult(factory->CreateEncoder(GUID_ContainerFormatGif, nullptr, encoder.put())); 5148 winrt::check_hresult(encoder->Initialize(outputIStream.get(), WICBitmapEncoderNoCache)); 5149 5150 // Try to set looping metadata 5151 try 5152 { 5153 winrt::com_ptr<IWICMetadataQueryWriter> encoderMetadataWriter; 5154 if (SUCCEEDED(encoder->GetMetadataQueryWriter(encoderMetadataWriter.put())) && encoderMetadataWriter) 5155 { 5156 PROPVARIANT prop{}; 5157 PropVariantInit(&prop); 5158 prop.vt = VT_UI1 | VT_VECTOR; 5159 prop.caub.cElems = 11; 5160 prop.caub.pElems = static_cast<UCHAR*>(CoTaskMemAlloc(11)); 5161 if (prop.caub.pElems) 5162 { 5163 memcpy(prop.caub.pElems, "NETSCAPE2.0", 11); 5164 encoderMetadataWriter->SetMetadataByName(L"/appext/application", &prop); 5165 } 5166 PropVariantClear(&prop); 5167 5168 PropVariantInit(&prop); 5169 prop.vt = VT_UI1 | VT_VECTOR; 5170 prop.caub.cElems = 5; 5171 prop.caub.pElems = static_cast<UCHAR*>(CoTaskMemAlloc(5)); 5172 if (prop.caub.pElems) 5173 { 5174 prop.caub.pElems[0] = 3; 5175 prop.caub.pElems[1] = 1; 5176 prop.caub.pElems[2] = 0; 5177 prop.caub.pElems[3] = 0; 5178 prop.caub.pElems[4] = 0; 5179 encoderMetadataWriter->SetMetadataByName(L"/appext/data", &prop); 5180 } 5181 PropVariantClear(&prop); 5182 } 5183 } 5184 catch (...) 5185 { 5186 // Loop metadata is optional; continue without failing 5187 } 5188 5189 int64_t cumulativeTicks = 0; 5190 bool wroteFrame = false; 5191 5192 for (UINT i = 0; i < frameCount; ++i) 5193 { 5194 winrt::com_ptr<IWICBitmapFrameDecode> frame; 5195 if (FAILED(decoder->GetFrame(i, frame.put()))) 5196 { 5197 continue; 5198 } 5199 5200 UINT delayCs = kGifDefaultDelayCs; 5201 try 5202 { 5203 winrt::com_ptr<IWICMetadataQueryReader> metadata; 5204 if (SUCCEEDED(frame->GetMetadataQueryReader(metadata.put())) && metadata) 5205 { 5206 PROPVARIANT prop{}; 5207 PropVariantInit(&prop); 5208 if (SUCCEEDED(metadata->GetMetadataByName(L"/grctlext/Delay", &prop))) 5209 { 5210 if (prop.vt == VT_UI2) 5211 { 5212 delayCs = prop.uiVal; 5213 } 5214 else if (prop.vt == VT_UI1) 5215 { 5216 delayCs = prop.bVal; 5217 } 5218 } 5219 PropVariantClear(&prop); 5220 } 5221 } 5222 catch (...) 5223 { 5224 } 5225 5226 if (delayCs == 0) 5227 { 5228 delayCs = kGifDefaultDelayCs; 5229 } 5230 5231 const int64_t frameStart = cumulativeTicks; 5232 const int64_t frameEnd = frameStart + static_cast<int64_t>(delayCs) * 100'000; 5233 cumulativeTicks = frameEnd; 5234 5235 if (frameEnd <= trimTimeStart.count() || frameStart >= trimTimeEnd.count()) 5236 { 5237 continue; 5238 } 5239 5240 const int64_t visibleStart = (std::max)(frameStart, trimTimeStart.count()); 5241 const int64_t visibleEnd = (std::min)(frameEnd, trimTimeEnd.count()); 5242 const int64_t visibleTicks = visibleEnd - visibleStart; 5243 if (visibleTicks <= 0) 5244 { 5245 continue; 5246 } 5247 5248 UINT width = 0; 5249 UINT height = 0; 5250 frame->GetSize(&width, &height); 5251 5252 winrt::com_ptr<IWICBitmapFrameEncode> frameEncode; 5253 winrt::com_ptr<IPropertyBag2> propertyBag; 5254 winrt::check_hresult(encoder->CreateNewFrame(frameEncode.put(), propertyBag.put())); 5255 winrt::check_hresult(frameEncode->Initialize(propertyBag.get())); 5256 winrt::check_hresult(frameEncode->SetSize(width, height)); 5257 5258 WICPixelFormatGUID pixelFormat = GUID_WICPixelFormat8bppIndexed; 5259 winrt::check_hresult(frameEncode->SetPixelFormat(&pixelFormat)); 5260 5261 winrt::com_ptr<IWICFormatConverter> converter; 5262 winrt::check_hresult(factory->CreateFormatConverter(converter.put())); 5263 winrt::check_hresult(converter->Initialize(frame.get(), GUID_WICPixelFormat32bppBGRA, WICBitmapDitherTypeNone, nullptr, 0.0, WICBitmapPaletteTypeCustom)); 5264 5265 winrt::check_hresult(frameEncode->WriteSource(converter.get(), nullptr)); 5266 5267 try 5268 { 5269 winrt::com_ptr<IWICMetadataQueryWriter> frameMetadataWriter; 5270 if (SUCCEEDED(frameEncode->GetMetadataQueryWriter(frameMetadataWriter.put())) && frameMetadataWriter) 5271 { 5272 PROPVARIANT prop{}; 5273 PropVariantInit(&prop); 5274 prop.vt = VT_UI2; 5275 // Convert ticks (100ns) to centiseconds with rounding and minimum 1 5276 const int64_t roundedCs = (visibleTicks + 50'000) / 100'000; 5277 prop.uiVal = static_cast<USHORT>((std::max<int64_t>)(1, roundedCs)); 5278 frameMetadataWriter->SetMetadataByName(L"/grctlext/Delay", &prop); 5279 PropVariantClear(&prop); 5280 5281 PropVariantInit(&prop); 5282 prop.vt = VT_UI1; 5283 prop.bVal = 2; // restore to background 5284 frameMetadataWriter->SetMetadataByName(L"/grctlext/Disposal", &prop); 5285 PropVariantClear(&prop); 5286 } 5287 } 5288 catch (...) 5289 { 5290 } 5291 5292 winrt::check_hresult(frameEncode->Commit()); 5293 wroteFrame = true; 5294 } 5295 5296 winrt::check_hresult(encoder->Commit()); 5297 5298 if (!wroteFrame) 5299 { 5300 co_return winrt::hstring(); 5301 } 5302 5303 co_return winrt::hstring(outputFile.Path()); 5304 } 5305 catch (...) 5306 { 5307 co_return winrt::hstring(); 5308 } 5309 }