> ## Content Index
> Fetch the complete content index at: https://infer.blog/llms.txt
> Use this file to discover other available public pages before exploring further.

# Bringing iPhone Live Photos to Ghost
- URL: https://infer.blog/test-live-photo-functionality/
- Published: 2026-04-05T23:35:43.000Z
- Updated: 2026-04-24T18:06:56.000Z
- Description: Ghost can't handle Live Photos natively, but its Video card and Code Injection get you there. A conversion script, some CSS, and an IntersectionObserver turn short videos into photos that breathe.
- Author: Daniel Soteldo
- Tags: AI, Design, Ghost Blog, Self-Hosting

**TL;DR:** Ghost can't handle Live Photos natively, but its Video card coupled with Code Injection can accomplish the same thing. The simplist approach is to use macOS's Quick Action to convert an MOV to MP4 and Ghost JS code injection. The result are photos that breathe by preloading, crossfading and respecting ghost's native looping functionality. The Safari browser is especially cantankerous, and more so if you're self hosting with Coolify. More details within. Full source [on GitHub](https://github.com/cassthebandit/infer-ghost-live-photos?ref=infer.blog). See example below.

0:00 

/0:02 

1× 

## The Background

I take a lot of photos with my iPhone, and most of them are Live Photos — Apple [captures 1.5 seconds of video](https://support.apple.com/en-us/104966?ref=infer.blog) on either side of the shutter tap. By long-pressing on any photo taken from your phone, you'll get a quick video that makes the photo come alive. I wanted that feeling on my blog. Not full video. Not GIFs. Just photos that breathe- subtle motion that makes a moment feel real without screaming "look at me, I'm a video."

At the file level, a Live Photo is two files: a still HEIC image and a \~3-second MOV video clip. From an mac PC, you can [Option-drag](https://www.macworld.com/article/231564/how-to-export-an-image-and-a-video-out-of-a-heif-live-photo-via-macos.html?ref=infer.blog) from the Photos app to a folder, and you get both and HEIC image (`IMG_xxxx.HEIC`) and short video (IMG\_xxxx.MOV) side by side. A regular drag gives you only a JPEG. My goal was to figure out the queckest way to get the video component onto the page in a way that felt like a photo.

![](https://infer.blog/content/images/2026/04/SCR-20260420-qpyd.png)

![](https://infer.blog/content/images/2026/04/SCR-20260420-qmgb.png)

![](https://infer.blog/content/images/2026/04/SCR-20260420-qmqw.png)

Here's what it looks like on the published site. The still image softens with a blur dissolve and comes alive as you scroll to it. No play button, no seek bar, no volume control. Hover and a `▶ LIVE` badge fades in. Scroll away and it pauses. Scroll back and it replays. When the video ends, the poster fades back in before the last frame- no hard cuts. By toggling "Loop" on in Ghost's editor, the live photo will cycle with a few seconds of dwell between plays. If a reader has reduced motion enabled, they see a static frame.

0:00 

/0:02 

1× 

## Ghost won't do this for you

Ghost's image uploader [rejects HEIC](https://forum.ghost.org/t/heic-image-support/51994?ref=infer.blog). There's no plugin system for custom card types- people have been [asking for years](https://forum.ghost.org/t/how-to-build-custom-cards/15401?ref=infer.blog). Additionally, Ghost has no [media library](https://forum.ghost.org/t/add-a-media-library/40043?ref=infer.blog) for browsing uploaded files. When Ghost [rebuilt its editor on Lexical](https://ghost.org/changelog/new-editor/?ref=infer.blog) in October 2023, it killed the one historical workaround — a [custom extension cards hack](https://forum.ghost.org/t/a-guide-on-how-to-develop-your-own-custom-card/16071?ref=infer.blog) that only worked with the old Ember editor.

I had Claude do deep research on what was available. A lot of approaches looked solid on first pass:

- **Apple's [LivePhotosKit JS](https://developer.apple.com/documentation/livephotoskitjs?ref=infer.blog)** — [Released in 2017](https://developer.apple.com/news/?id=04202017a&ref=infer.blog) for embedding Live Photos on the web. The [npm package](https://www.npmjs.com/package/livephotoskit?ref=infer.blog) hasn't been updated since 2019\. One developer [called it](https://medium.com/@kielnicholls/embedding-livephotos-on-a-web-page-5dfa9b8b83e3?ref=infer.blog) "abandoned marketing fluff."
- **The [live-photo npm package](https://github.com/IceyWu/live-photo?ref=infer.blog)** — Actively maintained, well-built, zero dependencies. Right tool for a custom front-end. Ghost's editor can't load npm packages. Ultimately incompatible.
- **[n8n](https://n8n.io/?ref=infer.blog) automation pipeline** — iOS Shortcut → ffmpeg → Ghost API upload. Every piece worked except the last mile: Ghost's Admin API has no documented video upload endpoint. More infrastructure...
- **[ConvertX](https://github.com/C4illin/ConvertX?ref=infer.blog)** — Self-hosted file converter. Makes sense for a team, but overkill for a single user blog. Still more infrastructure...
- **Direct MOV upload** — Ghost's Video card [only accepts MP4, WebM, and OGG](https://ghost.org/changelog/video-cards/?ref=infer.blog).  
What Ghost does have is a native [Video card](https://ghost.org/help/cards/?ref=infer.blog) that accepts MP4 with custom thumbnails and a Loop toggle. [Brandur Leach](https://brandur.org/fragments/ios-live-photos-and-ffmpeg?ref=infer.blog) had documented a clean approach: convert the MOV, embed with a `<video>` tag, add autoplay attributes. I adapted that for Ghost's Video card, and the key insight was simple — I'd never upload a video under 5 seconds unless it was a Live Photo.

That meant Code Injection could use duration as the differentiator. Short clips become living photos automatically. Longer videos keep Ghost's normal player with controls. Zero decisions at authoring time.

## The build

Three pieces, no dependencies between them including a conversion script, a header CSS, and a footer JS. Full source is [on GitHub](https://github.com/cassthebandit/infer-ghost-live-photos?ref=infer.blog).

**The conversion.** I set up a macOS Automator Quick Action so I can right-click any `.mov` and convert it in place. Open Automator → New → Quick Action, set "Workflow receives current" to **movie files** in **Finder**, drag in a **Run Shell Script** action, set "Pass input" to **as arguments**, and paste:

```bash
for f in "$@"; do
    dir=$(dirname "$f")
    name=$(basename "$f")
    base="${name%.*}"
    output="${dir}/${base}_ghost.mp4"
    passlog=$(mktemp -d)/ffmpeg2pass
 
    /opt/homebrew/bin/ffmpeg -i "$f" \
        -an \
        -map_metadata -1 \
        -c:v libx264 \
        -b:v 4M \
        -pass 1 \
        -passlogfile "$passlog" \
        -pix_fmt yuv420p \
        -movflags +faststart \
        -f null /dev/null && \
    /opt/homebrew/bin/ffmpeg -i "$f" \
        -an \
        -map_metadata -1 \
        -c:v libx264 \
        -b:v 4M \
        -pass 2 \
        -passlogfile "$passlog" \
        -pix_fmt yuv420p \
        -movflags +faststart \
        "$output"
 
    rm -rf "$(dirname "$passlog")"
done

```

Save as "Convert to Ghost MP4." Option-drag a Live Photo from Photos.app to the desktop, right-click the MOV → Quick Actions → Convert to Ghost MP4\. Two-pass H.264 at 4Mbps, audio stripped, `-map_metadata -1` strips GPS coordinates and Apple device identifiers, `faststart` moves the moov atom for streaming. Output runs 500KB–1.5MB for a 3-second clip and encodes in 2–3 seconds. The full path to [ffmpeg](https://ffmpeg.org/?ref=infer.blog) is required because Automator doesn't inherit your shell PATH.

The day-to-day workflow ends up being five steps: Option-drag from Photos.app, right-click to convert, drag the MP4 into a Ghost Video card, set a custom thumbnail (the JPEG from the same export), publish. Toggle Loop on if you want the clip to repeat. Code Injection handles the rest.

One note on Cloudflare: their [CDN terms](https://www.cloudflare.com/service-specific-terms-application-services/?ref=infer.blog) restrict video served outside their own services. For a personal blog with a few 3-second clips per post at 500KB–1.5MB each, this isn't a concern. For longer video, Ghost [natively embeds YouTube](https://ghost.org/integrations/youtube/?ref=infer.blog).

![](https://infer.blog/content/images/2026/04/SCR-20260420-rhuz.png)

![](https://infer.blog/content/images/2026/04/SCR-20260420-rflx.png)

![](https://infer.blog/content/images/2026/04/SCR-20260420-riup.png)

![](https://infer.blog/content/images/2026/04/SCR-20260420-rjet.png)

![](https://infer.blog/content/images/2026/04/SCR-20260420-rjaj.png)

**The CSS.** Pasted into Ghost's [Site Header](https://ghost.org/tutorials/use-code-injection-in-ghost/?ref=infer.blog) below existing branding CSS. When the JS adds a `.live-photo` class to a video card, this hides Ghost's player chrome:

```css
/* Hide Ghost's custom player overlay and controls */
.kg-video-card.live-photo .kg-video-overlay {
  display: none !important;
}
.kg-video-card.live-photo .kg-video-player-container {
  display: none !important;
}

```

There's also a `▶ LIVE` badge — a `::after` pseudo-element with `backdrop-filter: blur(8px)` and an opacity transition on hover. `prefers-reduced-motion` changes the badge to "PHOTO."

**The JS.** Pasted into Ghost's Site Footer. Two IntersectionObservers split the work:

```javascript
// Preload observer — starts download one viewport height before visible
var preloadObserver = new IntersectionObserver(function (entries) {
  entries.forEach(function (entry) {
    if (entry.isIntersecting) {
      entry.target.setAttribute('preload', 'auto');
      preloadObserver.unobserve(entry.target);
    }
  });
}, { rootMargin: '100% 0px', threshold: 0 });
 
// Play observer — plays when 50% visible, pauses when scrolled away
var playObserver = new IntersectionObserver(function (entries) {
  entries.forEach(function (entry) {
    var video = entry.target;
    if (entry.isIntersecting) {
      video.currentTime = 0;
      video.play();
      fadeOutOverlay(video._livePhotoRefs.overlay);
    } else {
      video.pause();
    }
  });
}, { threshold: 0.5 });

```

Videos start with `preload="none"` — they don't download on page load. As the reader scrolls toward one, the preload observer fires first (its margin is larger) and starts the download. By the time the play observer fires, the video is buffered.

The crossfade uses the [Web Animations API](https://developer.mozilla.org/en-US/docs/Web/API/Web%5FAnimations%5FAPI?ref=infer.blog). A poster overlay image sits on top of the video permanently and animates between `opacity: 1` and `opacity: 0` with a 6px blur dissolve over 350ms. Both transitions are fully inset — the video plays simultaneously while the overlay dissolves, so no time is added. When playback ends, `timeupdate` triggers the fade-in during the final 350ms, so the motion gradually freezes into the still.

For looping, the script detects Ghost's `loop` attribute before stripping it, then manages the cycle: video ends → poster fades in → 4-second dwell on the still → crossfade out → replay. Scroll away and the dwell timeout cancels. Scroll back and a fresh cycle starts. A 3-second clip runs about 7.7 seconds per cycle.

Ghost's `poster` attribute on video elements is a [transparent spacer GIF](https://img.spacergif.org/?ref=infer.blog), not the actual thumbnail. The real URL lives on the `<figure>` element as `data-kg-custom-thumbnail`. The crossfade was working correctly the entire time I was debugging it — I was just animating an invisible image. The fix was one line.

Video doesn't work in email clients. Ghost includes a fallback static frame in email versions of posts. That's an email limitation, not something the implementation can address.

The effect only appears on the published page, not in Ghost's editor — Code Injection runs on the front-end, not in admin. In the editor you'll see Ghost's normal video player with controls.

## Debugging Safari on Coolify

This section is specific to my stack: Ghost on [Coolify](https://coolify.io/?ref=infer.blog) behind [Cloudflare](https://www.cloudflare.com/?ref=infer.blog). If you're not running Coolify, you can skip this. If you are, and Safari won't play your video, this is probably why.

The implementation worked immediately in Chrome and Firefox. Safari played nothing. No error, no fallback — frozen frame.

The iPhone's MOV files use HEVC (H.265), and Safari has a history of being particular about HEVC in MP4 containers. Apple's [codec requirements](https://developer.apple.com/documentation/http-live-streaming/http-live-streaming-hls-authoring-specification-for-apple-devices?ref=infer.blog) specify particular profiles, color spaces, and bitrate limits. The exported MOVs had full-range DCI-P3 color, frame cropping side data, and a 150fps timebase. All of that looked like the problem. I went deep on ffmpeg — explicit color conversion, resolution scaling, CRF 10, `-preset placebo`, manual x264 params. The files played perfectly in localhost. Through the production stack, Safari still refused.

The curl told the story:

```
curl -I -H "Range: bytes=0-1" https://infer.blog/content/media/example.mp4
 
HTTP/2 200          ← should be 206 Partial Content
Content-Encoding: gzip
ETag: W/"7ffeda-..."  ← weak ETag
                    ← no Accept-Ranges, no Content-Range, no Content-Length

```

Safari sends `Range: bytes=0-1` as a probe before playing video. It expects `206 Partial Content` with proper range headers. It got `200` with gzipped content and no range support. Chrome tolerates this. Safari doesn't.

The codec was never the problem. [Coolify's Traefik reverse proxy](https://coolify.io/docs/knowledge-base/proxy/traefik/overview?ref=infer.blog) applies gzip compression to every content type including `video/mp4`. Traefik's compress middleware [strips range response headers](https://github.com/traefik/traefik/issues/6449?ref=infer.blog), breaking byte-range support. This is a [filed Coolify bug](https://github.com/coollabsio/coolify/issues/5222?ref=infer.blog).

The obvious fix — `excludedContentTypes` on the gzip middleware — doesn't work because Coolify auto-generates Traefik labels and silently strips custom ones on every restart. The middleware is per-container via Docker labels (`gzip@docker`), and Traefik's [provider namespacing](https://doc.traefik.io/traefik/middlewares/overview/?ref=infer.blog) means a file-based override is a separate middleware no router references.

The fix: a [Traefik Dynamic Configuration](https://coolify.io/docs/knowledge-base/proxy/traefik/dynamic-config?ref=infer.blog) that creates a higher-priority route for `/content/media/` with no middlewares. Media bypasses gzip. Everything else still gets compressed.

```yaml
# Coolify → Servers → Proxy → Dynamic Configurations → ghost-media-no-compress.yaml
http:
  routers:
    ghost-media:
      rule: "Host(`infer.blog`) && PathPrefix(`/content/media/`)"
      service: ghost-media-service
      entryPoints:
        - http
      priority: 100
  services:
    ghost-media-service:
      loadBalancer:
        servers:
          - url: "http://ghost-yk2azizo7ulg8hr3e05sfl9l:2368"

```

With that in place, the original HEVC remux played perfectly in Safari. Every hour I'd spent on ffmpeg encoding parameters was debugging the wrong layer.

---

Same approach as the [branding CSS](https://infer.blog/branding-ghost-solo-without-touching-a-theme-file/) and the [image optimization Worker](https://infer.blog/bringing-image-optimization-to-ghost-v5-solo/): Ghost stays stock, customization lives in Code Injection, everything is version-controlled independently. One note for anyone doing research with AI — always verify against primary documentation. Several approaches in this project sounded plausible but weren't grounded in how Ghost actually works. The docs catch what intuition misses.

---

*Claude helped build the implementation, co-wrote this post, and co-debugged the Safari and crossfade rendering issues across multiple sessions. I directed the investigation, made the architecture decisions, and did the final edit.*

---

*Daniel Soteldo is COO and Co-Founder of [Revelus Dermatology](https://www.revelusdermatology.com/?ref=infer.blog) in Austin, TX.*