Two well-used laptops connected by a cable in a dim workspace, each displaying a grid of glowing photo-selection tiles.

My wife spent about an hour and a half selecting photos in Google Photos for a Shutterfly project. She had 153 selected. Then the Shutterfly session timed out in the background.

When she clicked Done, Google Photos gave the kind of error that usually triggers tears:

Couldn't add photos. Go back to the partner app, website, kiosk, or other device you started from and connect to Google Photos again.

The obvious answer was to start over.

That would have meant finding and selecting 153 photos again.

The error was real. The selection was still there.

The important detail was that the picker was still open, and it still said that 153 photos were selected.

The Shutterfly handoff had expired. But Google Photos had not yet thrown away the browser state that represented the selection.

So instead of clicking around blindly or trying to reverse engineer Shutterfly's backend, we opened DevTools and looked at the rendered page. Each selected photo tile had a checkbox. Its surrounding DOM contained an opaque Google Photos media identifier.

The identifier was not a friendly filename or an album path. It looked like this:

AF1Qip…redacted-example…

That was enough.

We copied the selected IDs out of the still-open picker before touching the expired session. Then we opened a fresh Shutterfly → Google Photos picker, authorized the connection again, and used the IDs to find the same photo tiles as the picker scrolled through the library.

The recovery ended with 154 selected. Somewhere between the number we had seen and the recovered DOM state, there was one extra photo. I am not going to pretend I know why. But the recovered set was 154 unique Google Photos IDs, and the new picker visibly confirmed all 154.

The smallest useful scraper

The useful observation was not that the page had a secret API. It was that the app had already rendered the state we needed.

The real script had to walk a little farther up the DOM, tolerate virtualized tiles, and save the results before the page changed. The replay script had to do the opposite: watch for tiles as Google Photos loaded them, read each tile's ID, and click only the matching checkbox.

But the central move was simple:

selected UI state → stable media IDs → fresh picker session → same selected UI state

No password scraping. No fake upload endpoint. No attempt to bypass Google Photos or Shutterfly. The actual upload still happened through the normal picker after we had reconnected it.

The state was on a different Mac

The old picker—the one that still remembered the selection—was open on my wife's older Mac. It had a browser and DevTools, which turned out to be enough, but it did not have my newer development setup, Swamp, or Codex installed on it.

That changed the shape of the recovery.

We enabled SSH only so I could put small, disposable JavaScript files in a folder on that Mac. She pasted and ran them herself in the browser Console where the state actually lived. The recorder downloaded a private manifest there, and we copied that one file back to my newer Mac.

Then I opened a new Shutterfly → Google Photos session on the newer Mac and signed in again. Codex used Computer Use to inspect the fresh picker, confirm that it exposed the same kind of tile ID, and drive the browser through the virtualized grid while the restorer selected matching photos. Once the count was visible and verified, the photos went through the normal picker handoff to Shutterfly.

That is not a grand remote-control story. It was a couple of Macs doing the jobs they could still do: one preserved the evidence, and the other had the tools to investigate and replay it.

The two scripts

These are the complete versions worth saving. They are intentionally specific to the Google Photos picker DOM we saw on August 1, 2026, and Google can change that DOM at any time.

1. Capture the selection before closing the broken picker

Open DevTools on the still-open Google Photos picker, paste this script into the Console, then scroll through the entire selected range. It captures each selected visible tile and downloads an ID-only manifest when you run window.exportGooglePhotosSelection().

// Google Photos picker selection recorder.
// Run this in DevTools while the original picker still shows selected photos.
// Scroll through the whole selection, then run:
// window.exportGooglePhotosSelection()
(() => {
  const state = window.__googlePhotosSelectionRecorder =
    window.__googlePhotosSelectionRecorder || { mediaIds: new Set() };

  function mediaIdFor(checkbox) {
    let element = checkbox;
    for (let depth = 0; element && depth < 8; depth += 1, element = element.parentElement) {
      const jslog = element.getAttribute?.('jslog') || '';
      const match = jslog.match(/(?:^|;\s*)8:(AF1Qip[^;\s]+)/);
      if (match) return match[1];
    }
    return null;
  }

  function capture() {
    document
      .querySelectorAll('[role="checkbox"][aria-checked="true"]')
      .forEach((checkbox) => {
        const id = mediaIdFor(checkbox);
        if (id) state.mediaIds.add(id);
      });
    console.log(`Captured ${state.mediaIds.size} unique selected media IDs.`);
  }

  const observer = new MutationObserver(capture);
  observer.observe(document.body, {
    childList: true,
    subtree: true,
    attributes: true,
    attributeFilter: ['aria-checked', 'jslog']
  });

  window.exportGooglePhotosSelection = () => {
    capture();
    const manifest = {
      format: 'google-photos-picker-selection/v1',
      mediaIds: [...state.mediaIds].sort()
    };
    const blob = new Blob([JSON.stringify(manifest, null, 2)], {
      type: 'application/json'
    });
    const url = URL.createObjectURL(blob);
    const link = Object.assign(document.createElement('a'), {
      href: url,
      download: 'google-photos-selection.json'
    });
    link.click();
    setTimeout(() => URL.revokeObjectURL(url), 1000);
    console.log(`Saved ${manifest.mediaIds.length} media IDs.`);
  };

  capture();
  console.log('Recorder armed. Scroll through the selected photos, then run exportGooglePhotosSelection().');
})();

2. Restore the selection in a fresh picker

Start a new Google Photos picker from the partner site and paste this script into DevTools. When it asks, paste the complete contents of the private JSON manifest from step one. It scrolls through the virtualized grid and checks matching photos. It deliberately never clicks Done.

// Google Photos picker selection restorer.
// Run this in a fresh picker. Paste the private JSON manifest when prompted.
// It only checks matching photos; it never clicks Done.
(() => {
  const rawManifest = prompt('Paste the complete google-photos-selection.json contents:');
  if (!rawManifest) throw new Error('No selection manifest was supplied.');

  const parsed = JSON.parse(rawManifest);
  const mediaIds = new Set(Array.isArray(parsed) ? parsed : parsed.mediaIds);
  if (!mediaIds.size) throw new Error('The manifest did not contain any media IDs.');

  const state = window.__googlePhotosSelectionRestore = {
    wanted: mediaIds,
    found: new Set(),
    selected: new Set(),
    ticksAtBottom: 0,
    done: false,
    stop() {
      clearInterval(this.timer);
      this.observer.disconnect();
      this.done = true;
    }
  };

  function mediaIdFor(checkbox) {
    let element = checkbox;
    for (let depth = 0; element && depth < 8; depth += 1, element = element.parentElement) {
      const jslog = element.getAttribute?.('jslog') || '';
      const match = jslog.match(/(?:^|;\s*)8:(AF1Qip[^;\s]+)/);
      if (match) return match[1];
    }
    return null;
  }

  function selectVisibleMatches() {
    document.querySelectorAll('[role="checkbox"]').forEach((checkbox) => {
      const id = mediaIdFor(checkbox);
      if (!id || !state.wanted.has(id)) return;
      state.found.add(id);
      if (checkbox.getAttribute('aria-checked') !== 'true') checkbox.click();
      if (checkbox.getAttribute('aria-checked') === 'true') state.selected.add(id);
    });
  }

  function findScroller() {
    return [...document.querySelectorAll('div')]
      .filter((element) => {
        const style = getComputedStyle(element);
        return /(auto|scroll)/.test(style.overflowY) &&
          element.scrollHeight > element.clientHeight + 200;
      })
      .sort((a, b) => b.clientHeight - a.clientHeight)[0];
  }

  function tick() {
    selectVisibleMatches();
    const scroller = findScroller();
    if (!scroller) return;

    const atBottom = scroller.scrollTop + scroller.clientHeight >= scroller.scrollHeight - 4;
    state.ticksAtBottom = atBottom ? state.ticksAtBottom + 1 : 0;
    if (state.ticksAtBottom >= 5) {
      state.stop();
      console.table({
        requested: state.wanted.size,
        found: state.found.size,
        selected: state.selected.size,
        missing: state.wanted.size - state.found.size
      });
      return;
    }

    scroller.scrollBy({ top: Math.max(500, scroller.clientHeight * 0.8) });
    console.log(`Found ${state.found.size}/${state.wanted.size}; selected ${state.selected.size}.`);
  }

  state.observer = new MutationObserver(selectVisibleMatches);
  state.observer.observe(document.body, { childList: true, subtree: true });
  state.timer = setInterval(tick, 650);
  tick();
  console.log('Restorer started. To stop early, run window.__googlePhotosSelectionRestore.stop().');
})();

We also wrote a broader network-request recorder and a generic DOM snapshotter while we were still looking for the state. They can capture picker URLs, image URLs, and other session-adjacent data, and they were not needed for the successful recovery. We just needed to find the delicate, but still present browser state.

This is a much better use of an agent than pretending it knew the answer

I did not know whether the selected photos were recoverable when we started. Neither did Codex.

Codex initially reached the end of the obvious path when its network observations did not provide a recovery route. That was useful evidence, but it was not the conclusion. Asking it to try the JavaScript Console gave us a different surface to inspect—and that surface still had the selection.

With that guidance, it sought answers to a few small questions:

  • Are selected tiles distinguishable in the DOM?
  • Is there an identifier attached to those tiles?
  • Does the fresh picker render the same identifier?
  • Can it reselect without clicking Done on our behalf?

Each answer narrowed the next move. The agent wrote small console snippets, we ran them in the browser that had the state, copied the captured result across machines, and then let the fresh picker scroll and restore the selection.

That last boundary mattered. The restore script could select photos. It stopped before Done. Sending 154 family photos to Shutterfly was still our decision.

This is probably not a Swamp story yet

While my habit of driving almost everything through Swamp now, this wasn't the time and place. It was time for a valiant, but in all likelihood, doomed front end rescue effort. If I had already built a google photos or shutterfly swamp model or extension, I would have leaned on it. But it was just striaght recover time, while wrangling excited kids, with my wife on the verge of tears, and having just got back from a work trip.

There is a future version of this that might be a Swamp story. A personal photo-handoff tool could record a completed picker manifest, the destination, the expected count, and whether the final export succeeded. That would turn a later failed print handoff into a queryable recovery record instead of a browser archaeology project.

While I prefer to reach Swamp first these days. I did not use Swamp to recover these photos. I used a couple of disposable browser-console scripts, an open DevTools pane, and a little stubbornness.

Next time we hit a problem like this, we will have a clearer question: is the selection manifest worth preserving as a durable artifact? If the answer keeps being yes, that is when a small Swamp model may fit.

Ephemeral Browser State and "Computer Use" Saved the Day

It is easy to treat visible web error as the end of a workflow. Sometimes it is. A server-side session can be gone, and no amount of JavaScript will put it back.

But a web app is also sitting in front of the browser telling it what to render. A selected checkbox, an aria-checked="true" attribute, a virtualized photo tile, and an opaque media ID are all evidence. If the page is still open, that evidence may be enough to preserve the user's work before the next click destroys it.

That does not make every broken picker recoverable. It does make “start over” a hypothesis instead of a conclusion.

In this case, the picker remembered.