前言

Apple 推出 实况照片 (Live Photos) 很多年了,它会在手机拍摄一张照片的同时,记录下数秒时间的有声影像,使得定格的瞬间可以有更多的「现场感」和「故事感」。后来 Google 推出了自己的 动态照片 (Motion Photos),尽管他们在实现原理上还存在着一些不同,但基本上都可以将它们理解为一张静态图片 + 一段有声的瞬间 + 一些其他信息。

今年,随着各个社交平台对实况照片的支持,我们可能越来越喜欢这种更生动有趣的记录方式。本文主要介绍在 Hugo 静态博客网站上优雅地展示 Live Photos 或 Motion Photos 的方式。

值得注意的是,目前在 Web 网站上,.JPG.MP4 仍然是兼容性最好的格式。iOS 设备上的 Live Photos 会在备份导出到非 Apple 设备时,自动拆分为 .HEIC 静态图片和一段 .MOV 视频,因此,如果想在网页上展示 Live Photos,可能还需要进行格式转换,可以使用的工具太多了,本文对此不作展开说明。

目前我已经知道的 Samsung、小米/红米、OPPO/OnePlus 等主流 Android 手机基本都遵循了 Google 的 Motion Photos 规范,细节上的差异不在本文讨论范围内。

至此,我们仍需要将 Motion Photos 手动「拆」成静态图片 + 视频,这里推荐使用 GoMoPho (Google motion photos video extractor) 工具进行拆包。

效果

下面演示的是一张 Motion Photos 在本文最终实现的效果,你可以尝试:

  • 点击 LIVE|声音按钮
  • 在触屏设备上长按图片
  • 鼠标指针悬停在图片上
  • 移开悬停在图片上的鼠标指针

实现过程

我们首先要对 Motion Photos 中的静态图片和视频资源进行提取,下面介绍两款工具,一款直接在浏览器中使用,另一款需下载后在命令行中使用。

MotionFlow

MotionFlow 是我用 AI 辅助构建的一款纯静态的在线 Web 应用,支持批量处理 Motion Photos 并打包下载。你可以 在线体验 ,它是开箱即用的,所有处理操作只在浏览器中进行以保证隐私和安全。

motionflow.webp

GoMoPho

GoMoPho 是一款跨平台的 Google Motion Photos 提取工具, 下载 后解压直接在终端运行(需指定参数)

GoMoPhoConsole.exe d <输入路径> s "<输出路径>" p "*.<文件后缀名>"

举个例子

gomopho.webp

解压完成

gomopho-out.webp

一张 Motion Photo 解包后会得到一张照片和一个视频,它们体积较大,建议上传到图床或对象存储上,并确保其可以通过 直链访问

以 Hugo PaperMod 主题为例,新建一个短代码 motionPhotos.html

{{- $videoSrc := .Get "video" -}}
{{- $imageSrc := .Get "image" -}}
{{- $altText := .Get "alt" | default "Motion Photo" -}}
{{- $uniqueId := now.UnixNano | string | md5 -}}
{{- $imageResource := "" -}}
{{- $localResource := "" -}}

{{- if $imageSrc -}}
    {{- $isRemote := or (hasPrefix $imageSrc "http://") (hasPrefix $imageSrc "https://") -}}

    {{- if not $isRemote -}}
        {{- if hugo.IsProduction -}}
            {{- $localResource = resources.Get $imageSrc -}}
        {{- end -}}
        {{- if $localResource -}}
            {{- $imageResource = $localResource -}}
        {{- else -}}
            {{- $imageResource = resources.GetRemote $imageSrc -}}
        {{- end -}}
    {{- else -}}
        {{- $imageResource = resources.GetRemote $imageSrc -}}
    {{- end -}}
{{- end -}}

<div class="live-photo-container" id="mp-{{ $uniqueId }}"
    {{- with $imageResource -}}
        {{- if and .Width .Height -}}
            style="aspect-ratio: {{ .Width }} / {{ .Height }}; max-width: min(100%, {{ .Width }}px);"
        {{- else -}}
            style="aspect-ratio: 4/3; max-width: min(100%, 700px);"
        {{- end -}}
    {{- else -}}
        style="aspect-ratio: 4/3; max-width: min(100%, 700px);"
    {{- end -}}>
    <video class="live-photo-video" data-src="{{ $videoSrc }}" poster="{{ $imageSrc }}" muted playsinline loop>
        您的浏览器不支持 HTML5 视频。
    </video>
    <div class="live-photo-controls-group">
    <button class="live-photo-control-btn live-photo-toggle-btn" data-state="static" aria-label="Toggle Motion Photo">
        <i class="icon-live"></i><span>LIVE</span>
    </button>
    <button class="live-photo-control-btn live-photo-mute-btn" data-muted="true" aria-label="Toggle Mute">
        <i class="icon-muted"></i>
    </button></div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function(){
    var container=document.getElementById('mp-{{ $uniqueId }}');
    if(!container)return;
    var video=container.querySelector('.live-photo-video');
    var toggleBtn=container.querySelector('.live-photo-toggle-btn');
    var muteBtn=container.querySelector('.live-photo-mute-btn');
    var playTimeout;
    const HOVER_DELAY=500;
    var isManuallyControlled=false;
    function updateMuteButtonUI(isMuted){
        muteBtn.setAttribute('data-muted',isMuted.toString());
        var icon=muteBtn.querySelector('i');
        icon.className=isMuted?'icon-muted':'icon-unmuted';
    }
    function startVideo(autoUnmute){
        if(toggleBtn.getAttribute('data-state')==='static'){
            if(!video.src){
                video.src=video.getAttribute('data-src');
                video.load();
            }
            video.style.opacity=1;
            if(autoUnmute){
                video.muted=false;
                updateMuteButtonUI(false);
            }
            video.play().catch(e=>{
                if(e.name!=='AbortError'){console.error("Video play failed:",e);}
            });
            toggleBtn.setAttribute('data-state','playing');
        }
    }
    function stopVideo(){
        if(toggleBtn.getAttribute('data-state')==='playing'){
            video.pause();
            video.currentTime=0;
            toggleBtn.setAttribute('data-state','static');
            if(!video.muted){
                video.muted=true;
                updateMuteButtonUI(true);
            }
        }
    }
    container.addEventListener('mouseenter',function(){
        clearTimeout(playTimeout);
        if(isManuallyControlled)return;
        playTimeout=setTimeout(()=>startVideo(true),HOVER_DELAY);
    });
    container.addEventListener('mouseleave',function(){
        clearTimeout(playTimeout);
        if(!isManuallyControlled)stopVideo();
    });
    toggleBtn.addEventListener('click',function(event){
        event.stopPropagation();
        var state=toggleBtn.getAttribute('data-state');
        if(state==='static'){
            isManuallyControlled=true;
            startVideo(true);
        }else{
            isManuallyControlled=false;
            stopVideo();
        }
    });
    muteBtn.addEventListener('click',function(event){
        event.stopPropagation();
        video.muted=!video.muted;
        updateMuteButtonUI(video.muted);
    });
    video.addEventListener('ended',function(){
        isManuallyControlled=false;
        stopVideo();
    });
});
</script>
<style>
.live-photo-container{position:relative;margin:var(--gap) auto;overflow:hidden;border-radius:var(--radius);cursor:pointer;background-color:var(--code-block-bg);max-height:70vh;}.live-photo-video{position:absolute;top:0;left:0;width:100%;height:100%;display:block;object-fit:cover;}.live-photo-controls-group{position:absolute;bottom:8px;right:8px;display:flex;align-items:center;gap:5px;}.live-photo-control-btn{position:static;background-color:rgba(0,0,0,0.6);border:none;color:white;cursor:pointer;transition:background-color 0.2s,opacity 0.2s;box-shadow:0 1px 3px rgba(0,0,0,0.4);display:flex;align-items:center;justify-content:center;white-space:nowrap;}.live-photo-control-btn:hover{background-color:rgba(0,0,0,0.8);}.live-photo-toggle-btn{border-radius:20px;padding:5px 9px;font-size:0.85em;}.live-photo-toggle-btn i.icon-live{background-image:url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAHgAAAB4CAMAAAAOusbgAAAAM1BMVEX///+OjpOOjpOOjpOOjpOOjpOOjpOOjpOOjpOOjpOOjpOOjpOOjpOOjpOOjpOOjpOOjpMym3PHAAAAEXRSTlMAMEAQsP/AIKDggHDQkPBQYHOKyH4AAAUWSURBVHgBYhg6gJEJQgNoMcPsWEEYCguQCKiB/a/2lecp1hgm4sx8v3La1huSG0CdB0C3x3b+vm4AiKYGCX4INTKxBl9mqXKxRlgjrNFao+VbghhMk4OzMLTIBPxwAhsA0LxXGoDqj3KNco2oRttPMNMefBCopN9nl91RW9h9Vn6zSlD5jvDklmQvRk+Lm94W5o+cm3/spGDhh7VG85LMO7qtiAaB0qSSCNAcDXkOtVkdoVnwOW1QBsF3mx2bp8ZIbZt5iIltPx7C4b6xDuMCrXmXfmjOeR+BvMbgRntkp7exox4B1c0D7oaPCZuSPK7wnxV9yuZjwr5bapMQrpBfTLfUfsRcCTELqluELjEJ15CMmNz0JmUFBZbs2Bz19uQS4Qax9Pbw4PQ5Krrsilv6z4asDquVzxrAB5uzQTigwI1kF09w4N2DrRskGyZo0NYZnRwObeKt9rrwdp0jh4d5yk3zBXedqk27TfqzrqEmq99AsHX6XBjrh2+fpflVkWUmJN6Sp7rp7jxuUGGzoSUbnKw7dLba2FF2AWK+d4IVNiAazI1FP2vbzIi6afw03DHXUd1uCBu9WYo1yFyEk36CzaTp6sqru3HWWn8+wVZdV1f27Ky1k0pS+qsRHuZt4N17l4cKzdMYyHs0jIus2BIbn9XC5oFjk8cIENEn+/Juy+zk8DRRC6+qo1cNtoHgDxTsK5vES+cXVtfOv3ByhAsxvyh2kS8azQd0WbC0kBlBBCUT5Uv+dG67ZXUtLTFOIehApWvRwkpp/3bNF/52ClY0YZ9N9BdbQfHBKjOM/c2/Q+gt2QxtOlnX1ZVLpxYyJFu6gEpRnsUoaXGsMUKWhkBB2iC3S63dkspRwVX4Wway0Q0p5xTYUKPsl8ROvXD8qrDzMHYLza66GV8XO7J8cluW5TnJPolwQMvpKk5wELV389RG1dG5slbMPMMBmRfdz2Kl7Ln25GqYCaiwEwO4S3xXlyl7scnLafOj3H19Ar7fw8EifrdvOPnDrw5K7sx943LLZ/l5Oqi8qfIH88QSb5IuLJeGrYj6u90OijnLnw8x3xcOsrC7Ldw+MyIzsSgsV1LuhSI879lxIVW4yML/qjeLHAliGIqGisL3P+2smtyy5F9J2pqsCx0w/OcufnF8rH21F9PscZmpaT7szysCiyuML669nl5rO+kfIPpH5riTMGInYa9fu8XLGs1AQC30GQz2HBLsPZ9j9cJbIKDfJgf0C1IYaukKJW1uRtLWoKTN0Q25JE3lE/ODeTOcmGcmMedKEefCUgRefLFI8cXxxRe+3MS6lC4uNxmu3PT5nvBdYEvTC2yBPLZQFCAx5wFSUrRkb5OSIjMyXY/w8PlpV42y8QazbZEaCr9fyJe5fYE0sH9KA8GZ34ghlYohIt2pLZF/AMELf3N83ijVnfZvL46vbR+kEt/zKAye8Ub5wMAkVtQUG68840rUzFufJCBnB2i4jJeEpHp3S6q/GKkegROOLCRb+hvFIIMTABzj4nAMWypNNCAcg4nK80ck32nulM7KByM00ACRmypFbq52C7nxgYGMfKxjkFHwK7Eqfg+tBMnKOEgGo3PbPgmdw2DBjsGC+nikPhBKPNhQB4S1BCBSgX7xsY9gzlkf7NZH2XF43++imKLXbfcz4H3jYrzXrhCjm9+gYYEGjSUtKWdZ2JJCm3AK14RzAk04Om1H+o1W+NlQ7TMSPh+F8t0sH9Z52kznnTX/ZvwBSmxeg6qlDAUAAAAASUVORK5CYII");background-size:16px 16px;background-repeat:no-repeat;background-position:center center;display:inline-block;width:16px;height:16px;flex-shrink:0;margin-right:4px;content:none;font-size:0;}.live-photo-mute-btn{border-radius:50%;width:32px;height:32px;padding:0;opacity:0.9;display:flex;}.live-photo-mute-btn i{display:block;width:100%;height:100%;font-size:0;content:none;color:transparent;background-repeat:no-repeat;background-position:center center;background-size:18px 18px;}.live-photo-mute-btn[data-muted="true"] i.icon-muted{background-image:url("data:image/svg+xml,%3Csvg%20xmlns%3D%27http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%27%20viewBox%3D%270%200%2024%2024%27%20width%3D%2724%27%20height%3D%2724%27%3E%3Cpath%20fill%3D%27none%27%20stroke%3D%27white%27%20stroke-linecap%3D%27round%27%20stroke-linejoin%3D%27round%27%20stroke-width%3D%272%27%20d%3D%27M15%208a5%205%200%200%201%201.912%204.934m-1.377%202.602A5%205%200%200%201%2015%2016m2.7-11a9%209%200%200%201%202.362%2011.086m-1.676%202.299A9%209%200%200%201%2017.7%2019M9.069%205.054L9.5%204.5A.8.8%200%200%201%2011%205v2m0%204v8a.8.8%200%200%201-1.5.5L6%2015H4a1%201%200%200%201-1-1v-4a1%201%200%200%201%201-1h2l1.294-1.664M3%203l18%2018%27%2F%3E%3C%2Fsvg%3E");}.live-photo-mute-btn[data-muted="false"] i.icon-unmuted{background-image:url("data:image/svg+xml,%3Csvg%20xmlns%3D%27http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%27%20viewBox%3D%270%200%2024%2024%27%20width%3D%2724%27%20height%3D%2724%27%3E%3Cpath%20fill%3D%27none%27%20stroke%3D%27white%27%20stroke-linecap%3D%27round%27%20stroke-linejoin%3D%27round%27%20stroke-width%3D%272%27%20d%3D%27M15%208a5%205%200%200%201%200%208m2.7-11a9%209%200%200%201%200%2014M6%2015H4a1%201%200%200%201-1-1v-4a1%201%200%200%201%201-1h2l3.5-4.5A.8.8%200%200%201%2011%205v14a.8.8%200%200%201-1.5.5z%27%2F%3E%3C%2Fsvg%3E");}
</style>

使用

下面的 \\ 用于文中转义短代码的渲染,实际使用时请去掉

{{\\< motionPhotos
video="https://s3.dejavu.moe/blog/<Live Photos 视频>.mp4"
image="https://s3.dejavu.moe/blog/<live Photos 照片>.jpg"
alt="我的实况照片" >\\}}

积分

可供参考的资料:

参考资料: