diff --git a/HANDOFF.md b/HANDOFF.md index 36824f9..220c0e0 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -1,96 +1,91 @@ -# HANDOFF — 2026-09-14 (web capture REDESIGNED: composition-capture, PNG polling + CaptureScheduler deleted — NOT yet verified on device, NOT pushed) +# HANDOFF — 2026-09-14 (composition capture DEVICE-VERIFIED ✓; slice-15 pacing fix committed locally — A/V re-measure next) ## Branch / Commit State -`main` HEAD = **`c01206f`** (web-frame-capture redesign — committed LOCALLY, **NOT pushed**; web work -commit-locally-until-greenlight). Before it: `b22d08e` (signed audio-sync, already on origin/main). +`main` HEAD = **slice-15 commit** (FramePump duplicate-on-lag pacing — committed LOCALLY, **NOT +pushed**; web/A/V work stays commit-local until greenlight). Before it: `c01206f` (composition +capture redesign, also local/unpushed), before that `b22d08e` (signed audio-sync, pushed). Working tree **clean**. ## ⚠️ Branding (2026-09-14, creator-corrected): product = **llamacasty**, internals = ytLive -The product is **llamacasty**; the repo path, csproj `AssemblyName`/`RootNamespace`, DB/log paths +The product is **llamacasty**; repo path, csproj `AssemblyName`/`RootNamespace`, DB/log paths (`%APPDATA%\ytLlive\...`), and most code names are the legacy **ytLive/ytLlive**. User-facing -language must say "llamacasty"; code/assembly/repo names stay ytLive. Full detail in -`ai.md` → Brand → "Product name vs repo/assembly branding". +language says "llamacasty"; code/assembly/repo names stay ytLive. See `ai.md` → Brand. -## 💥 The web-capture take history (why this redesign exists) +## ✅ DEVICE-VERIFIED — composition capture looks good; then the A/V defect surfaced -The recording was 60fps but web widgets ran at ~1/6 speed: capture was a `CapturePreviewAsync` PNG -poll; every full-HD encode+decode cost **35–165ms** so the session budget capped real cadence at -~20Hz — a 60fps widget still juddered. Slice 11's `CaptureScheduler` (30Hz) was the wrong lever: -the fix is frame-driven, not faster polling. +The creator ran real takes off the `c01206f` build and reports the video looks **very good** — the +Windows.Graphics.Capture web path is confirmed on device (open item closed). BUT the same takes +showed the A/V defect we have seen before: **both desktop and webcam playback are accelerated, the +audio "gets speedier", then cuts off at the end.** -## 🔬 Committed locally as `c01206f` — composition capture (NOT pushed) +## 🔬 Sliced and measured (evidence) -**What:** web sources now render through `CoreWebView2CompositionController` into -`Windows.Graphics.Capture` (the Flutter `webview_windows` / `WebView2CompositionControl` -mechanism) — frame-driven at the renderer's pace instead of polling PNGs. +With the A/V recipe (`MyMistakes.md` → "Measuring audio-video A/V sync" + `/tmp/opencode/avsync.py`): -**Pipeline (per session):** `CreateCoreWebView2CompositionControllerAsync(appHwnd)` → -`RootVisualTarget` = a `RelativeSizeAdjustment=1,1` child under a 1920×1080 root `ContainerVisual` -on one `Compositor` (CoreMessaging DQ P/Invoke recipe — see ai.md Slice 14 / MyMistakes) → -`GraphicsCaptureItem.CreateFromVisual(root)` → free-threaded `Direct3D11CaptureFramePool` (2 -buffers) → `SoftwareBitmap.CreateCopyFromSurfaceAsync` (`BitmapAlphaMode.Straight`) → -per-frame `FindContentBounds` bbox → `CropBounds` → epoch'd ring → dispatcher-coalesced crop copy -into ONE shared `WriteableBitmap`. +- `ty-20260914-1723-0000-2.mp4`: 697 video frames @60fps = **11.62s** vs audio **11.84s**. +- `ty-20260914-1726-0000-2.mp4` (clap take): 163 frames = **2.72s** vs **2.93s** audio. Video ends + 0.21–0.24s before audio → the cut-off. Clap: audio env peak 1.655s vs video motion 0.75–1.03s; + cross-correlation lag **+0.667s** (audio late). +- `startup.log`: `FramePump stall: iteration 33-46ms (> 2× the 17ms interval): worst render 33ms … + dropped 0`. -**Files:** NEW `Services/WebCaptureFrameSource.cs`; reworked `Services/WebView2Manager.cs` (internal -seam ctor `(Dispatcher, Func?)` for hermetic tests); DELETED -`Services/CaptureScheduler.cs`; `MainViewModel.Web.cs` (`InitWebView2()` no-arg), -`MainViewModel.Streaming.Operations.cs` (three `SetCaptureInterval` hooks gone — verified zero -remaining), `MainWindow.xaml(.cs)` (`InitWebView2()` moved ctor→**Loaded**; `WebViewHostPanel` -overlay deleted). `TransparentBackgroundScript` unchanged. +**Root cause (slice 15):** slice 10's freshness choice skipped the overrun's missed slots — one +fresh frame per ~35ms stall authored into a 60fps container = **accelerated playback**. OBS's +answer is duplicate-on-lag (`libobs/video-io.c`, docs.obsproject.com/backend-design): fill each +missed slot by repeating the newest frame — duration == wall, judder never fast-forward. -**Tests:** reworked `ytLive.Tests/WebView2ManagerTests.cs` — dropped the 4 control-size tests + -`CaptureScheduler_Drops…`; `FindContentBounds` tests moved to `WebCaptureFrameSource`; the ONE -integration test (`Frames_PublishCroppedPreview_And_CoalesceToLatest_CarryingCropBounds`) drives the -seam with `FakeWebSource` + background-STA `DispatcherPump`: crop-sized preview published once → -`CropBounds` + `IsOpaque=false` → back-to-back frames coalesce to latest. `RoundClipInteractionTests` -comment updated (WebViewHostPanel gone). Suite **293/293 green**, app build **0 warnings**. Docs -slant (ai.md Slice 14, HANDOFF, MyMistakes, TASK 17, Controls/ViewModels/Services indexes) updated in -the same working set. +## 🔬 Committed locally — slice 15: one frame per deadline slot (duplicate-on-lag) -## ⚠️ Open items on this change (before it is PUSHABLE) +**What:** `Services/Encoder/FramePump.cs` submit is now a bounded catch-up — +`while (now >= nextTick) { submit latest composite; nextTick += intervalTicks; }` clamped to a +`deadlineNow` captured once per iteration. First missed slot gets the fresh composite, the rest get +repeats of it (OBS duplication) — recording duration == wall time under any render load, no +acceleration, no audio tail cut. The burned `_outputIndex` moved inside the submit loop (every +emitted slot gets its own +1; also fixes the old unconditional bump that gapped the judge sequence +on non-submitting fast-render iterations). -- **Device verification:** the composition path has never run against a real widget. Verify ~60fps - web animation in a take (also: the OBS de-throttle flags stay; hidden-page JS throttling is fixed - by them, capture pacing is now renderer-driven). -- **No push yet** — web work is commit-local until the user says push. +**Good Dog test:** `ytLive.Tests/FramePumpTests.cs` → `Pump_Overrun_Renders_EmitsEverySlot_NotSkipped` +(60fps, 35ms render cost, asserts ≥0.65 of the wall slots emitted — the old skip-pump wrote ~1/35ms). +**294/294 green, app build 0 warnings.** Files: `Services/Encoder/FramePump.cs`, +`ytLive.Tests/FramePumpTests.cs`. Docs in same commit: ai.md Slice 15, MyMistakes (supersedes the +slice-10 "no burst re-write" clause + latest numbers), this HANDOFF. -## ✅ SHIPPED + PUSHED — signed audio sync, −500..+500 (`b22d08e`) +## ⚠️ Open items (before PUSHABLE) -Positive = delay the mix (audio AHEAD — the `AudioSyncDelay` line, live-reactive). Negative = -**advance** (audio BEHIND): OBS-style "eat the stream head" — `AudioMixer.StartLive` arms -`_advanceSamplesRemaining = |N| ms`, `LiveLoopAsync` skips `min(budget, mixBuffer.Length)` off each -write head while it lasts. Slider relabeled **AUDIO SYNC**, `−500..500`, locked while -live/recording (`IsEditMode`). One regression test: -`StartLive_NegativeOffset_AdvancesAudio_ByDroppingTheStreamHead`. +- **Device re-verify:** a take on the slice-15 build must play at REAL time (no acceleration, no + cut-off audio tail). Confirm via ffprobe: video stream duration ≈ audio ≈ container. +- **Re-measure the true A/V offset** with a clap take now that video pacing is honest + (`/tmp/opencode/avsync.py`). The +0.6s reading on 1726 was confounded by the 1.1x + acceleration. If a real residual remains after the pacing fix, the backlog is the AUDIO pipeline + (mixer ring / advance), not video. +- **No push yet** — commit-locally-until-greenlight for web/A/V work. -Take `ty-20260914-1128-0000-2.mp4`: **offset ≈ +0.54s** (collapsed from +2.11/2.22s; residual was -the baked-in `SyncOffsetMs=300`, now 0 — confirmed via sqlite3). Both streams `start_time=0`. +## Open threads (carried) -## Open threads - -- **Verify signed sync negative direction on device** (clap take with a negative offset). -- **Audio-silence verification** — fixed (`724af14`); creator heard real audio. -- **Webcam MJPG missing / ~10–14Hz**, layer SortOrder, truncation-with-dynamic-scenes — queued. -- **Sync control user-doc tutorial — REQUIRED before 1.0** (creator directive; TASK 22 file). -- **Device verify the composition web capture (this change).** +- Audio-silence verification — fixed (`724af14`); creator heard real audio. +- Webcam MJPG missing / ~10–14Hz, layer SortOrder, truncation-with-dynamic-scenes — queued. +- Sync control user-doc tutorial — REQUIRED before 1.0 (creator directive; TASK 22). +- Signed A/V sync: verify the negative (advance) direction on device. ## Landmines - testhost shares startup.log — filter by time. -- `cmd.exe /c "taskkill /F /IM ytLive.exe"` (WSL double-slashes mangle) before rebuilds. +- `cmd.exe /c "taskkill /F /IM ytLive.exe"` (WSL double-slashes mangle) before rebuilds — a live + app process locks `ytLive.exe` and the apphost copy fails (MSB3021, seen today). - Build/tests: **Windows dotnet host** (`/mnt/c/Program Files/dotnet/dotnet.exe`). 0 warnings — - only `./scripts/verify.sh ""`'s clean build counts. + only `./scripts/verify.sh ""`'s clean build counts. Building `ytLive.csproj` alone does + NOT rebuild `ytLive.Tests.dll` — run the Tests csproj before `vstest`. - ffmpeg/ffprobe: `/mnt/c/Program Files/Krita (x64)/bin/` with Windows paths. -- `MyMistakes.md` has the **audio/video sync measurement recipe** AND the **composition-capture - CoreMessaging DQ recipe (2026-09-14)** — grep before re-deriving. -- sqlite3 lives at `/home/gramps/android-sdk/platform-tools/sqlite3` (WSL) for the DB at +- `MyMistakes.md` has the **A/V sync measurement recipe**, the **deadline-pacing** lessons + (item 1 + slice 15), and the **CoreMessaging DQ recipe** — grep before re-deriving. +- sqlite3 at `/home/gramps/android-sdk/platform-tools/sqlite3` for `/mnt/c/Users/gramp/AppData/Roaming/ytLlive/ytLlive.db`. ## Next step -From this dirty state: run `./scripts/verify.sh ""`, review the diff, commit -locally (web work — NO push, per standing directive). Then a device take to verify ~60fps web -animation, then present for push review. \ No newline at end of file +Have the creator record a clap take on the slice-15 build → ffprobe durations (video == audio == +wall) + `/tmp/opencode/avsync.py` for the honest offset. If durations match, the acceleration +defect is closed; then decide push with the user, and attack any true audio-latency residual as its +own work unit. \ No newline at end of file diff --git a/MyMistakes.md b/MyMistakes.md index 3d534e9..bc3172c 100644 --- a/MyMistakes.md +++ b/MyMistakes.md @@ -307,9 +307,9 @@ Both halves were solved by OBS/libyuv long ago; do not re-derive: OBS's answering machinery is the encoder queue (`libobs/obs-encoder.c`): the encoder thread NEVER couples back into the video thread; overflow = dropped data, NEVER a frozen producer. Fixed as: bounded `Channel` (cap 120) + a dedicated drain task owning stdin, - `SubmitFrameAsync` = copy-to-pool-array + `TryWrite` (drop-newest + count when full), pump - emits ONE fresh composite per iteration (no burst re-write), stop flushes the queue then EOF. - **Rule: verify with a clock-independent judge.** The WSL ticker that "proved" slice 9 has its + `SubmitFrameAsync` = copy-to-pool-array + `TryWrite` (drop-newest + count when full), stop + flushes the queue then EOF. **Rule: verify with a clock-independent judge.** The WSL ticker that + "proved" slice 9 has its own Host-timer jitter under Windows load — so this slice burns a dot-matrix `_outputIndex` into the bottom-right of every composite; decoding the recording reads the honest sequence (+1/frame, jumps = counted drops) with no external clock involved. Take 17 must read @@ -317,6 +317,30 @@ Both halves were solved by OBS/libyuv long ago; do not re-derive: UNRELIABLE here: the scene is always animating — session elapsed timer + REC pulse — so no two frames are ever byte-identical.) + **SUPERSEDED by slice 15 (2026-09-14):** the last clause "pump emits ONE fresh composite per + iteration (no burst re-write)" had it BACKWARDS for the deadline. Slice 10 chose a skip when + the render overruns — an overrun's slots vanish from the file — and that AUTHORS ACCELERATED + playback (my item-1 lesson already said it: "NEVER a skip … a late render shows as REPEATED + footage"). Device takes proved it: one fresh frame per 35ms stall → `ty-20260914-1726` = 163 + video frames (2.72s) against 2.93s of audio, video ending 0.22s early ("audio speeds up then + cuts off"). OBS's answer (docs.obsproject.com/backend-design: "If the video frame queue is + full, it will duplicate the last frame"; `libobs/obs-output.c` counts "lagged frames due to + rendering lag/stalls" — never a time-hole) is to fill each missed slot by DUPLICATING the + newest frame; duration == wall, judder not fast-forward. That's what the pump's submit now + does: a catch-up loop over the missed slots emitting this iteration's composite again, clamped + to a `deadlineNow` captured once (bounded — the smear that justified slice 10 was the + BLOCKING pipe-write re-copying during a long freeze; the queue makes each emit nanoseconds, + so the burst is safe). The burned index moved inside the submit loop: EVERY emitted slot + carries its own +1 (this also fixed the old unconditional `_outputIndex++` that gapped the + sequence on non-submitting fast-render iterations). A/V sync must be re-clap-measured after + this fix — the +0.6s audio-late reading on 1726 was confounded by the 1.1x acceleration. + +**Latest A/V sync numbers (2026-09-14, pre-slice-15 pacing):** keep the recipe below honest on +clap measures. ty-1723: 697 video frames (60fps) = 11.62s vs 11.84s audio. ty-1726 (clap): 163 +frames = 2.72s vs 2.93s audio; clap audio env peak 1.655s vs video motion cluster 0.75–1.03s → +single-peak offset ≈ +0.64–0.89s, cross-correlation lag +0.667s (audio late) — BOTH confounded by +the acceleration; re-measure after slice 15. + **Take-4 follow-ups (2026-09-04) — the symptom needed a second pass, so cite again:** render was still 58.9ms after slice 1. Slice 2 (buffer pool + opaque-row memcpy + diff --git a/Services/Encoder/FramePump.cs b/Services/Encoder/FramePump.cs index 3b85b80..6f4df9b 100644 --- a/Services/Encoder/FramePump.cs +++ b/Services/Encoder/FramePump.cs @@ -394,34 +394,38 @@ public sealed class FramePump : IDisposable lock (_gate) encoder = _encoder; if (encoder == null) break; - // Burned-in frame counter (slice 10 judge): a clock-independent - // pacing witness burned literally into the composite. Decode the - // recording and read the bottom-right strip: the number must advance - // +1 per frame and jump only by counted drops (queue overflow or a - // render-overrun's skipped slots). The WSL ticker replaced as the - // judge because its own timers can smear under Windows host load — - // this can't lie. - _outputIndex++; - BurnFrameIndex(frame.BgraPixels, frame.Width, frame.Height, _outputIndex); - - // Deadline pacing (slice 10 reshape): ONE fresh composite per iteration, submitted - // only when the clock has reached the next deadline. Two changes from - // the take-9 counting loop, both derived from where the stale-content - // bug actually lived: - // 1. SubmitFrameAsync no longer blocks on the pipe — it ENQUEUES into - // the encoder's bounded queue (the OBS video-thread model), so the - // pump can never stall behind ffmpeg, and overflow DROPS the - // newest frame. The queue wasn't here in the take-9 loop — a - // lagging ffmpeg made the pump's submit block, and the burst - // while-loop (below) then re-wrote the SAME stale composite for - // every slot that ticked past, which is why the ticker smeared. - // 2. One submission max per iteration: each catch-up slot now gets a - // FRESH render instead of a repeat of the stale one. Missed slots - // vanish from the file (a count-based gap, like the queue drop) — - // never duplicated frozen frames. - if (!ct.IsCancellationRequested - && System.Diagnostics.Stopwatch.GetTimestamp() >= nextTick) + // Deadline pacing, OBS duplicate-on-lag (slice 15): emit ONE frame per + // deadline slot — a fresh composite when the render kept up, a REPEAT + // of this iteration's latest composite for every slot the render + // overran. The OBS model never leaves a wall-time hole: libobs + // media-io/video-io.c runs on its own clock and duplicates the latest + // frame when the video thread lags, logging "lagged frames due to + // rendering lag/stalls" — never skipped time (docs.obsproject.com/ + // backend-design: "If the video frame queue is full, it will duplicate + // the last frame"). Slice 10 chose the opposite for freshness: an + // overrun SKIPPED the missed slots, so a 60fps-authoring pump emitting + // one frame per 35ms overrun authored ~1.7x playback (ty-1726: 163 + // video frames for 2.93s of audio) and ended the video 0.22s before the + // audio tail. Counting duplicates instead of skips keeps recording + // duration == wall duration (no acceleration, no audio tail cut) at the + // cost of a short judder during a stall — the accepted trade. The burst + // is microseconds (the enqueue never blocks, and the loop is clamped to + // deadlineNow) so it can't smear the way the take-9 blocking burst did. + var deadlineNow = System.Diagnostics.Stopwatch.GetTimestamp(); + while (!ct.IsCancellationRequested && deadlineNow >= nextTick) { + // Burned-in frame counter (slice 10 judge): a clock-independent + // pacing witness burned literally into the composite. Decode the + // recording and read the bottom-right strip: the number must + // advance +1 per authored frame and jump only by counted drops + // (queue overflow). Burned here — on the buffer finally submitted, + // which the enqueue's snapshot copies before the next iteration + // rewrites it — so every file frame carries its own index and a + // fast-render iteration that submitted nothing leaves no phantom + // gap (the slice-10 unconditional bump before the gate could). + _outputIndex++; + BurnFrameIndex(frame.BgraPixels, frame.Width, frame.Height, _outputIndex); + submitSw.Restart(); await encoder.SubmitFrameAsync(frame, ct); submitSw.Stop(); diff --git a/ai.md b/ai.md index b8487e5..66a06a2 100644 --- a/ai.md +++ b/ai.md @@ -996,6 +996,29 @@ Full suite 290/291 passing, the sole failure the pre-existing compositor pixel t - Full suite **293/293 green, 0 warnings**. Audio untouched. **NOT YET VERIFIED ON DEVICE** — the composition path needs a real 60fps-widget run (open item; see HANDOFF). Web work commits stay LOCAL (no push) until the user greenlights. +- **Slice 15 — one frame per deadline SLOT: duplicate-on-lag, never skip (2026-09-14, device takes + ty-1723/1726):** slice 10's freshness choice — a render overrun VANISHES the missed slots from the + file — authored ACCELERATED playback: ~35ms render cost vs the 16.6ms slot, one fresh frame per + 35ms (log wall-1726: `FramePump stall: iteration 33ms (> 2× the 17ms interval): worst render + 33-46ms`), and a 60fps container muxed on arrival → 1723: 697 frames = 11.62s video vs 11.84s + audio; 1726: 163 frames = 2.72s vs 2.93s. The video also ended 0.21–0.24s before the audio ("audio + speeds up, cuts off at the end"). OBS's answer is duplicate-on-lag: `libobs/media-io/video-io.c` + emits one frame per tick and a lagger DUPLICATES the newest frame, counted as "lagged frames due + to rendering lag/stalls" (obs-output.c) — a wall-time hole never exists (docs.obsproject.com/ + backend-design: "If the video frame queue is full, it will duplicate the last frame"). The pump's + submit is now a bounded catch-up: `while (now >= nextTick) { submit; nextTick += intervalTicks; }` + over a `deadlineNow` captured once per iteration — the LATEST composite, fresh on the first missed + slot, repeated (OBS's duplication) for the rest. Duration == wall under any render load, at the + cost of a short judder during a stall. The burst is safe because `Channel.TryWrite` + never blocks (slice 10's queue — the take-9 smear was the BLOCKING pipe-write re-copying a stale buffer + during a long freeze; here each emit is nanoseconds). The burned `_outputIndex` (slice 10 judge) + moved INSIDE the submit loop so every emitted slot carries its own +1 (and the old unconditional + pre-gate bump no longer gaps the sequence on non-submitting fast-render iterations). **Good Dog + test** `Pump_Overrun_Renders_EmitsEverySlot_NotSkipped`: 60fps, resolver sleeps 35ms, asserts ≥0.65 + of the wall slots are emitted (the skip-pump wrote ~1/35ms ≈ 200 in 7s; the slot pump ~415). Full + suite **294/294 green, 0 warnings**. The +0.6s audio-late clap reading on 1726 was confounded by + the 1.1x acceleration — re-measure on device; if a real residual remains it is the audio pipeline. + No push (web/A/V work stays local). - **Stop ordering matters:** `StopAsync` stops the encoder — since slice 10 it FLUSHES the pending queue (`Channel.TryComplete` → drain writes the leftovers, closes stdin → EOF → ffmpeg finalizes+exits; an accepted frame is never lost) — **before** awaiting the loop. The old reverse-order deadlock was diff --git a/ytLive.Tests/FramePumpTests.cs b/ytLive.Tests/FramePumpTests.cs index b25fd37..e9bc6c2 100644 --- a/ytLive.Tests/FramePumpTests.cs +++ b/ytLive.Tests/FramePumpTests.cs @@ -395,6 +395,41 @@ public class FramePumpTests } } + /// The ONE integration test for the slice-15 pacing fix (2026-09-14): + /// a render that overruns the deadline must NOT skip the missed slots — a skip + /// authors fast playback: ty-1726 rendered one frame per 35ms overrun into a + /// 60fps container (163 frames for 2.93s of audio) and cut the audio tail off. + /// OBS fills an overrun's slots by DUPLICATING the newest frame (libobs + /// media-io/video-io.c; docs.obsproject.com/backend-design: "If the video frame + /// queue is full, it will duplicate the last frame") so recording duration stays + /// equal to wall duration. Here a 60fps pump renders every frame at ~35ms cost + /// (a genuine overrun): the slice-10 keep-fresh loop authored ~1 per 35ms; the + /// slot-filling loop must emit a frame for ~every 16.6ms of wall time. + [Fact] + public async Task Pump_Overrun_Renders_EmitsEverySlot_NotSkipped() + { + const int fps = 60; + var encoder = new FakeEncoder(); + using var pump = NewPump(encoder, resolve: _ => + { + System.Threading.Thread.Sleep(35); + return null; + }); + + await pump.StartAsync(); + await Task.Delay(7000); // ~420 slots of wall time + await pump.StopAsync(); + + // Slot-faithful: ~1 frame per 16.6ms -> ~415 in the window. Skipping (the + // slice-10 behavior): ~1 per 35ms -> ~200. 0.65 * fps * (elapsed seconds) + // is a middle line a skipped pump can never cross, a filling one only fails + // on a pathological run. + Assert.True(encoder.Frames.Count >= (int)(7.0 * fps * 0.65), + $"pump emitted {encoder.Frames.Count} frames in ~7s wall at {fps}fps — " + + $"{7.0 / encoder.Frames.Count * fps:F1}x playback — an overrun must " + + "DUPLICATE the newest frame per missed slot (OBS), not skip it"); + } + /// The ONE integration test for the take-4 scratch pool: the pump recycles /// its master buffers across frames (the 8.3MB-per-tick LOH churn that cost GC /// stalls inside "render") while EVERY frame's content stays correct — stale bytes