Silent Foreign Perspective

アニメ視聴環境整備2025(Magpie, Abema)


少なくとも向こう半年は観続けるものがありそうなのでぼんやり整備した。
前半部分はストリーミング配信の動画の画質をよくする方向、後半は例によってAbemaの動画プレイヤーの挙動を弄る回です。別に技術的にそんな面白くはない(やってることも相まってスクリプトキディレベル)けどメモとして。。。

画質改善

前提として、こういうことするのは大抵BD, DVDから(違法化以前に)リッピングしたデータを持っている人間で、MPC-HCみたいなプレイヤーで再生するときにいじくりまわす部分っぽい感じはある。Anime4Kなんかはそういう流れの中でリアルタイムにアップスケールしたりするやつなので、基本的にそのままだとブラウザ上での再生には使えない。そこをなんとか……ってのがこのセクションの目的。

前使ってたやつ:AnimeSR

Chromeブラウザ拡張でストリーミングサービスの動画にフィルタかけられるのでかなり便利に使ってた。内部的にはいくつかのAnime4Kのフィルタのセットが組まれていて、処理の強度の好みやパソコンのスペックに応じて選んでいく感じ。ただブラウザ内で動く都合上、DRMがかかっているサービスでは利用できない。Abemaは前まで使えてた気がするけど。。。拡張機能ストアからは消えてて、インターネットの海を漁るとまだ転がっていそう。自分が使っているサービスで使えるようなら試してみる価値はありそう。

フレーム生成:Lossless Scaling (optional)

Steamで売ってる有料のやつ。ツクール製RPGをプレイするために入れてたけど、正直拡大するだけなら次のMagpieの方が設定項目細かくてよさそう。あとアニメ用途に使うと困るのは、フルスクリーン表示した際に解像度の変換が起きないからかスケーリングを設定しても動かないっぽい感じがある。そのため暇なときにMagpieと同時起動してフレーム生成だけさせている。30FPS→240FPSとかになるの無意味にヌルヌルしてて楽しい。すぐ飽きてやめたけど。

フィルタ, スケーリング:Magpie

本題。Lossless Scalingと同様、ウィンドウや画面全体をスケーリングしたりエフェクトかけたりするやつ。無料。ピクセルを保ったままスケーリングしたいレトロゲームとか、解像度低い古いノベルゲームとかで使われるようなソフト。同梱されているシェーダーがめちゃくちゃ多くて楽しい。今の設定はこれ↓

  • Anime4K Restore VL
  • Anime4K Upscale Denoise VL
  • Bicubic (x1, b=0.33, c=0.33)
  • Anime4K Restore VL
  • Anime4K Upscale Denoise VL
  • Anime4K Thin HQ (0.1, x2)

それぞれの内容はAnime4Kのドキュメントのこの辺参照。特にスケーリングしたあとThinをちょっとだけかけると線が強調されすぎなくていい感じに見えます。色々インターネットの既存情報を参考にしていて、本当はClamp Highlightsとか使ってみたいんだけどMagpieにはまだ同梱されていない。GLSLからMagpie独自のHLSLに移植する必要があるっぽい? 流石にそこまで手間かけてられんので。。。
あとBicubicは「2段階RestoreとUpscaleかけるときは間で画像を縮小すると負荷が減って良い感じになるで」みたいなのを見たので何となく入れてみたんだけど、倍率をx0.5にするとめちゃくちゃ細部が潰れてしまうのでx1にした。補間アルゴリズムなのにx1って(Losslessと同じで)なにもしてないんじゃないかって感じなんだけど、何か挟んでおいた方が見た目違う感じしたのでかなり謎です。スケーリング設定がFactor(倍率指定)ではなくFit(最適化)だから画面解像度のx1にまで縮小してるってことかな? 日本語訳のせいでよくわからず。
全然関係ないけど前職でImageMagickとか使っていた時に参考にしまくっていた方の記事が今回も参考になったので貼っておきます。おすすめ。
画像リサイズのうんちく (補間フィルタ) https://qiita.com/yoya/items/f167b2598fec98679422

Abemaプレイヤー挙動改善2025

前の記事はここ。ぼっち・ざ・ろっく!もう2年以上前なんか。。。

前回からのAbema側の仕様変更点

  • 次のエピソードへ進む部分のクラス名が変わった
    • 変更前を記録していないが、大体.com-vod-VODNextProgramInfoから.com-vod-VODPlayerNextContentRecommendBaseのような感じ。
  • シリーズの最終話でも、次のエピソードへボタンが表示されるようになり、別作品の1話へ飛ばされるようになった
    • 改悪。ひとつ前の項目から推測されるように、この部分にリコメンド機能が入ったと思われる。
  • エンディングが開始すると、次のエピソードへ進む部分が表示されるがしばらくすると消える(自動遷移しない)
    • 改善。アニメオタクに配慮していて偉い。ただ結構長いこと居座るので消すは消す。
  • エピソードへの遷移時、自動で再生しないことがある
    • 改悪。広告付きAbemaプレミアムなのでその関連の可能性はある

実装:ページを開いたとき、再生位置が0秒で停止しているなら再生する

まず再生時間の取得を共通化する。

const getTimer = () => {
  const time = document.querySelectorAll("span.com-vod-VODTime time");
  if (!time) {
    return {};
  }
  const nowMS = time[0]?.attributes.datetime.value.match(/^PT(\d+H)?(\d+M)?(\d+S)/);
  const now = parseInt(nowMS?.[1]?.slice(0, -1) ?? 0) * 3600 + parseInt(nowMS?.[2]?.slice(0, -1) ?? 0) * 60 + parseInt(nowMS?.[3]?.slice(0, -1) ?? 0);
  const maxMS = time[1]?.attributes.datetime.value.match(/^PT(\d+H)?(\d+M)?(\d+S)/);
  const max = parseInt(maxMS?.[1]?.slice(0, -1) ?? 0) * 3600 + parseInt(maxMS?.[2]?.slice(0, -1) ?? 0) * 60 + parseInt(maxMS?.[3]?.slice(0, -1) ?? 0);
  return {now: now, max: max};
}

ついでに正規表現をちゃんとしたので1時間以上の作品にも対応した。ちゃんと調べたらこのフォーマットはISO 8601の継続時間形式で、JSの標準APIでパースできないかなと思って見てみたけど無かった。Temporal.Durationが対応しているらしい。早く使わせてくれー。逆に言うとAbemaがTemporalを先んじて使っているからこうなっているのかも?
読み込み時の再生はtimer.nowと再生ボタンの状態(svgのファイル名)を確認するだけ。

const clickPlayOnFirst = (timer) => {
  const svg_href = document.querySelector(".com-vod-PlaybackButton__icon use")?.getAttribute('xlink:href');
  if (timer?.now !== undefined && timer?.now == 0 && svg_href.includes('play.svg')) {
    document.querySelector(".com-vod-PlaybackButton")?.click();
  }
}

実装:次のエピソードが別作品の時は同シリーズの1話に戻る

おすすめ作品対策でもあるし、前回記事の課題にも残っていたのを対応。これで無限視聴が可能になった。再生時間取得部分は前の項目参照。

const isSameSeries = (pathname1, pathname2) => pathname1?.split('/')?.at(-1)?.split('_')?.[0] == pathname2?.split('/')?.at(-1)?.split('_')?.[0];

const firstEpisodeHref = (pathname) => `${pathname?.split('/')?.at(-1)?.split('_')?.[0]}_s1_p1`;

const clickNextProgramInfo = (timer) => {
  if (timer?.now && timer?.max && timer?.now >= timer?.max) {
    const n = document.querySelector('div.com-vod-VODPlayerNextContentRecommendBase__next a');
    if (!!n) {
      const nextHref = isSameSeries(location.pathname, n.attributes.href.value) ? n.attributes.href.value : firstEpisodeHref(location.pathname);
      location.href = nextHref;
    }
  }
};

前回未解決:フルスクリーン継続視聴

Chromeでのエラー文は以下。

Failed to execute 'requestFullscreen' on 'Element': API can only be initiated by a user gesture.

コンソールからフルスクリーン切り替えボタンのclick()を呼んだりする分には動くが、setTimeout内で呼ぶとこのエラーが出る。基本的に対応不可能だった。
ただ、プレイヤー要素のrequestFullscreenによるフルスクリーンは次話遷移で維持されないが、事前にユーザーがF11などでフルスクリーンにしておけば次話に遷移してもフルスクリーンが維持されることがわかった。これを活用して無理やりなんとかします。(Google Chrome特有のエラーという可能性はあるけど面倒なので調べませんでした)

実装:疑似フルスクリーン

スタイルを用意する。これが当たると要素が表示領域いっぱいまで拡大される。

const insertStyle = () => {
  const style = document.createElement('style');
  style.textContent = `
    .pseudo_fullscreen {
      position: fixed;
      top: 0;
      left: 0;
      width: 100%;
      height: 100%;
    }
  `;
  document.head.appendChild(style);
}

疑似フルスクリーンのオン・オフ。サイドバーとヘッダーさえ消せばあとはプレイヤーをでっかくするだけだった。

const pseudoFullscreenOn = () => {
  const sideBar = document.querySelector('.c-application-SideNavigation');
  const header = document.querySelector('.c-common-HeaderContainer-header');
  sideBar.style.display = 'none';
  header.style.display = 'none';
  document.body.style.overflow = 'hidden';
  const v = document.querySelector('.c-vod-EpisodePlayerContainer-wrapper');
  v.classList.add('pseudo_fullscreen');
}

const pseudoFullscreenOff = () => {
  const sideBar = document.querySelector('.c-application-SideNavigation');
  const header = document.querySelector('.c-common-HeaderContainer-header');
  sideBar.style.display = '';
  header.style.display = '';
  document.body.style.overflow = '';
  const v = document.querySelector('.c-vod-EpisodePlayerContainer-wrapper');
  v.classList.remove('pseudo_fullscreen');
}

画面内のフルスクリーンボタンの動作を疑似フルスクリーンに切り替える。例によってstopImmidiatePropagationで既存のclickイベントを殺しておく感じ。あとちゃんと処理済みのクラスを付けて複数回実行されないようにしておく。Fキーを押したときの動作やボタンアイコンの切り替わりは対応してない。

const isPseudoFullscreen = () => !!document.querySelector('.pseudo_fullscreen');

const changeFullscreen = () => {
  const button = document.querySelector('.com-vod-FullscreenButton:not(.fullscreen_changed)');
  if (!!button) {
    button.addEventListener('click', (event) => {
      event.stopImmediatePropagation();
      isPseudoFullscreen() ? pseudoFullscreenOff() : pseudoFullscreenOn();
    });
    button.classList.add("fullscreen_changed");
  }
}

最後に、次話に遷移する部分と再生開始時で疑似フルスクリーンだったかどうかの情報を共有して状態が継続されるようにする。

const beforeIsFullscreenKey = "tampermonkey.abema_enhancer.before_is_fullscreen";

const clickNextProgramInfo = (timer) => {
  if (timer?.now && timer?.max && timer?.now >= timer?.max) {
    const n = document.querySelector('div.com-vod-VODPlayerNextContentRecommendBase__next a');
    if (!!n) {
      const nextHref = isSameSeries(location.pathname, n.attributes.href.value) ? n.attributes.href.value : firstEpisodeHref(location.pathname);
      sessionStorage.setItem(beforeIsFullscreenKey, isPseudoFullscreen())
      location.href = nextHref;
    }
  }
};

const clickPlayOnFirst = (timer) => {
  const svg_href = document.querySelector(".com-vod-PlaybackButton__icon use")?.getAttribute('xlink:href');
  if (timer?.now !== undefined && timer?.now == 0 && svg_href.includes('play.svg')) {
    document.querySelector(".com-vod-PlaybackButton")?.click();
    if (sessionStorage.getItem(beforeIsFullscreenKey) == 'true') {
      pseudoFullscreenOn();
    }
    sessionStorage.removeItem(beforeIsFullscreenKey);
  }
}

最初はfullscreen=trueみたいなクエリパラメータでもつけて遷移したろうと思ってたんだけど、サービス運用者に見つかったらマジで変な人間に見えるだろうなと思ってsessionStorageにした。userscriptでも使えるのは知らんかった。フルスクリーンで継続視聴したいときはF11押す→画面内のフルスクリーンボタン押す、とする感じ。そんなに手間でもないしいっかという気持ち。
とくにまとめとかもなく終わる。

よいこの付録:全文

順番とか命名とかぐちゃぐちゃなんでご容赦ください。

// ==UserScript==
// @name         Abema Enhancer
// @version      2
// @description  Fix Abema TV Player's behavior.
// @match        https://abema.tv/video/episode/*
// @grant        none
// ==/UserScript==

(() => {
  'use strict';

  const beforeIsFullscreenKey = "tampermonkey.abema_enhancer.before_is_fullscreen";

  const insertStyle = () => {
    const style = document.createElement('style');
    style.textContent = `
      .pseudo_fullscreen {
        position: fixed;
        top: 0;
        left: 0;
        width: 100%;
        height: 100%;
      }
    `;
    document.head.appendChild(style);
  }

  const getTimer = () => {
    const time = document.querySelectorAll("span.com-vod-VODTime time");
    if (!time) {
      return {};
    }
    const nowMS = time[0]?.attributes.datetime.value.match(/^PT(\d+H)?(\d+M)?(\d+S)/);
    const now = parseInt(nowMS?.[1]?.slice(0, -1) ?? 0) * 3600 + parseInt(nowMS?.[2]?.slice(0, -1) ?? 0) * 60 + parseInt(nowMS?.[3]?.slice(0, -1) ?? 0);
    const maxMS = time[1]?.attributes.datetime.value.match(/^PT(\d+H)?(\d+M)?(\d+S)/);
    const max = parseInt(maxMS?.[1]?.slice(0, -1) ?? 0) * 3600 + parseInt(maxMS?.[2]?.slice(0, -1) ?? 0) * 60 + parseInt(maxMS?.[3]?.slice(0, -1) ?? 0);
    return {now: now, max: max};
  }

  const closeNextProgramInfo = () => {
    const cancelButton = document.querySelector('.com-vod-VODPlayerNextContentRecommendBase__cancel-button');
    const v = cancelButton?.attributes?.tabindex.value
    if (v !== undefined && v !== '-1') {
      cancelButton.click();
    }
  };

  const pseudoFullscreenOn = () => {
    const sideBar = document.querySelector('.c-application-SideNavigation');
    const header = document.querySelector('.c-common-HeaderContainer-header');
    sideBar.style.display = 'none';
    header.style.display = 'none';
    document.body.style.overflow = 'hidden';
    const v = document.querySelector('.c-vod-EpisodePlayerContainer-wrapper');
    v.classList.add('pseudo_fullscreen');
  }

  const pseudoFullscreenOff = () => {
    const sideBar = document.querySelector('.c-application-SideNavigation');
    const header = document.querySelector('.c-common-HeaderContainer-header');
    sideBar.style.display = '';
    header.style.display = '';
    document.body.style.overflow = '';
    const v = document.querySelector('.c-vod-EpisodePlayerContainer-wrapper');
    v.classList.remove('pseudo_fullscreen');
  }

  const isPseudoFullscreen = () => !!document.querySelector('.pseudo_fullscreen');

  const isSameSeries = (pathname1, pathname2) => pathname1?.split('/')?.at(-1)?.split('_')?.[0] == pathname2?.split('/')?.at(-1)?.split('_')?.[0];

  const firstEpisodeHref = (pathname) => `${pathname?.split('/')?.at(-1)?.split('_')?.[0]}_s1_p1`;

  const clickNextProgramInfo = (timer) => {
    if (timer?.now && timer?.max && timer?.now >= timer?.max) {
      const n = document.querySelector('div.com-vod-VODPlayerNextContentRecommendBase__next a');
      if (!!n) {
        const nextHref = isSameSeries(location.pathname, n.attributes.href.value) ? n.attributes.href.value : firstEpisodeHref(location.pathname);
        sessionStorage.setItem(beforeIsFullscreenKey, isPseudoFullscreen())
        location.href = nextHref;
      }
    }
  };

  const clickPlayOnFirst = (timer) => {
    const svg_href = document.querySelector(".com-vod-PlaybackButton__icon use")?.getAttribute('xlink:href');
    if (timer?.now !== undefined && timer?.now == 0 && svg_href.includes('play.svg')) {
      document.querySelector(".com-vod-PlaybackButton")?.click();
      if (sessionStorage.getItem(beforeIsFullscreenKey) == 'true') {
        pseudoFullscreenOn();
      }
      sessionStorage.removeItem(beforeIsFullscreenKey);
    }
  }

  const changeLink = () => {
    const links = document.querySelectorAll('.com-content-list-ContentListEpisodeItem__link:not(.link_changed)');
    links.forEach(function(element) {
      const before = element.getAttribute('href')
      if (!before.includes('?')) {
        element.setAttribute('href', before + '?next=true');
        element.addEventListener('click', (event) => {
          event.stopImmediatePropagation();
        });
        element.classList.add("link_changed");
      }
    })
  }

  const changeFullscreen = () => {
    const button = document.querySelector('.com-vod-FullscreenButton:not(.fullscreen_changed)');
    if (!!button) {
      button.addEventListener('click', (event) => {
        event.stopImmediatePropagation();
        isPseudoFullscreen() ? pseudoFullscreenOff() : pseudoFullscreenOn();
      });
      button.classList.add("fullscreen_changed");
    }
  }

  insertStyle();
  const intervalID = setInterval(() => {
    const timer = getTimer();
    console.log(timer);
    closeNextProgramInfo();
    clickNextProgramInfo(timer);
    clickPlayOnFirst(timer);
    changeLink();
    changeFullscreen();
  }, 1000);
})();