Add rich previews for copied files
This commit is contained in:
@@ -947,6 +947,7 @@ Item {
|
|||||||
result: root.currentResult
|
result: root.currentResult
|
||||||
openAction: function() { root.openResult(root.currentResult) }
|
openAction: function() { root.openResult(root.currentResult) }
|
||||||
copyTextAction: function(text) { root.copyText(text) }
|
copyTextAction: function(text) { root.copyText(text) }
|
||||||
|
pluginDir: root.pluginDir
|
||||||
visible: root.currentResult !== null
|
visible: root.currentResult !== null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+199
-46
@@ -1,4 +1,5 @@
|
|||||||
import QtQuick
|
import QtQuick
|
||||||
|
import Quickshell.Io
|
||||||
import qs.Commons
|
import qs.Commons
|
||||||
import qs.Ui
|
import qs.Ui
|
||||||
import "Classify.js" as Classify
|
import "Classify.js" as Classify
|
||||||
@@ -16,6 +17,55 @@ Item {
|
|||||||
property var openAction: function() {}
|
property var openAction: function() {}
|
||||||
// Wired by the picker: copies a string (OCR text, QR payload) to the clipboard.
|
// Wired by the picker: copies a string (OCR text, QR payload) to the clipboard.
|
||||||
property var copyTextAction: function(text) {}
|
property var copyTextAction: function(text) {}
|
||||||
|
property string pluginDir: ""
|
||||||
|
property int fileIndex: 0
|
||||||
|
readonly property var filePaths: entry && entry.type === "files" ? entry.paths || [] : []
|
||||||
|
readonly property string selectedFile: filePaths.length ? filePaths[Math.min(fileIndex, filePaths.length - 1)] : ""
|
||||||
|
property var fileInfo: ({})
|
||||||
|
property int fileRequest: 0
|
||||||
|
onFilePathsChanged: fileIndex = 0
|
||||||
|
onSelectedFileChanged: {
|
||||||
|
fileRequest++
|
||||||
|
fileInfo = ({})
|
||||||
|
fileProbe.running = false
|
||||||
|
fileDelay.restart()
|
||||||
|
}
|
||||||
|
Timer {
|
||||||
|
id: fileDelay
|
||||||
|
interval: 120
|
||||||
|
onTriggered: {
|
||||||
|
if (!root.selectedFile || !root.pluginDir) return
|
||||||
|
fileProbe.command = ["python3", root.pluginDir + "/scripts/file-preview.py", String(root.fileRequest), root.selectedFile]
|
||||||
|
fileProbe.running = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Process {
|
||||||
|
id: fileProbe
|
||||||
|
stdout: StdioCollector {
|
||||||
|
waitForEnd: true
|
||||||
|
onStreamFinished: {
|
||||||
|
try {
|
||||||
|
var data = JSON.parse(text)
|
||||||
|
if (String(data.request) === String(root.fileRequest)) root.fileInfo = data.file
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function fileDetails() {
|
||||||
|
var f = fileInfo
|
||||||
|
var parts = []
|
||||||
|
if (f.kind) parts.push(f.kind)
|
||||||
|
if (f.bytes !== undefined && f.kind !== "Folder") parts.push(Classify.formatBytes(f.bytes))
|
||||||
|
if (f.width && f.height) parts.push(f.width + "×" + f.height)
|
||||||
|
if (f.duration) {
|
||||||
|
var seconds = Math.floor(f.duration)
|
||||||
|
parts.push(Math.floor(seconds / 60) + ":" + (seconds % 60 < 10 ? "0" : "") + seconds % 60)
|
||||||
|
}
|
||||||
|
if (f.audio) parts.push(f.audio)
|
||||||
|
if (f.modified) parts.push("Modified " + new Date(f.modified * 1000).toLocaleString())
|
||||||
|
return parts.join(" · ")
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
readonly property string font_: Style.font.menuFamily
|
readonly property string font_: Style.font.menuFamily
|
||||||
readonly property color fg: Color.menu.text
|
readonly property color fg: Color.menu.text
|
||||||
@@ -79,7 +129,7 @@ Item {
|
|||||||
chips.push(Classify.plural(st.words, "word"))
|
chips.push(Classify.plural(st.words, "word"))
|
||||||
chips.push(Classify.plural(st.lines, "line"))
|
chips.push(Classify.plural(st.lines, "line"))
|
||||||
}
|
}
|
||||||
if (r.bytes > 0) chips.push(Classify.formatBytes(r.bytes))
|
if (r.bytes > 0 && e.type !== "files") chips.push(Classify.formatBytes(r.bytes))
|
||||||
if (e.type === "image" && e.w && e.h) chips.push(e.w + "×" + e.h)
|
if (e.type === "image" && e.w && e.h) chips.push(e.w + "×" + e.h)
|
||||||
if (e.type === "image") chips.push(e.mime || "image")
|
if (e.type === "image") chips.push(e.mime || "image")
|
||||||
if (e.pinned) chips.push("★ pinned")
|
if (e.pinned) chips.push("★ pinned")
|
||||||
@@ -625,8 +675,10 @@ Item {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// files body
|
// File content is bounded by a scrollable viewport, including long paths
|
||||||
Column {
|
// and text snippets. Selecting another file loads its details on demand.
|
||||||
|
Flickable {
|
||||||
|
id: filesBody
|
||||||
anchors.top: divider.bottom
|
anchors.top: divider.bottom
|
||||||
anchors.bottom: metaRow.top
|
anchors.bottom: metaRow.top
|
||||||
anchors.left: parent.left
|
anchors.left: parent.left
|
||||||
@@ -634,57 +686,158 @@ Item {
|
|||||||
anchors.topMargin: Style.space(10)
|
anchors.topMargin: Style.space(10)
|
||||||
anchors.bottomMargin: Style.space(10)
|
anchors.bottomMargin: Style.space(10)
|
||||||
visible: root.derived === "files"
|
visible: root.derived === "files"
|
||||||
spacing: Style.space(6)
|
|
||||||
clip: true
|
clip: true
|
||||||
|
contentWidth: width
|
||||||
Repeater {
|
contentHeight: fileContent.height
|
||||||
model: {
|
boundsBehavior: Flickable.StopAtBounds
|
||||||
if (!root.entry || root.entry.type !== "files") return []
|
Connections {
|
||||||
return (root.entry.paths || []).slice(0, 10)
|
target: root
|
||||||
|
function onSelectedFileChanged() { filesBody.contentY = 0 }
|
||||||
|
}
|
||||||
|
WheelHandler {
|
||||||
|
acceptedDevices: PointerDevice.Mouse | PointerDevice.TouchPad
|
||||||
|
onWheel: function(ev) {
|
||||||
|
filesBody.flick(0, ev.angleDelta.y < 0 ? -240 : 240)
|
||||||
|
ev.accepted = true
|
||||||
}
|
}
|
||||||
|
}
|
||||||
delegate: Row {
|
Column {
|
||||||
required property var modelData
|
id: fileContent
|
||||||
width: parent ? parent.width : 0
|
width: filesBody.width
|
||||||
|
spacing: Style.space(8)
|
||||||
|
Row {
|
||||||
|
width: parent.width
|
||||||
spacing: Style.space(8)
|
spacing: Style.space(8)
|
||||||
|
visible: root.filePaths.length > 1
|
||||||
Text {
|
Repeater {
|
||||||
text: ""
|
model: ["‹ Previous", "Next ›"]
|
||||||
color: Color.accent
|
delegate: Rectangle {
|
||||||
font.family: root.font_
|
required property int index
|
||||||
font.pixelSize: Style.font.body
|
required property string modelData
|
||||||
|
width: navLabel.implicitWidth + Style.space(16)
|
||||||
|
height: Style.space(24)
|
||||||
|
color: root.chipBg
|
||||||
|
radius: Style.cornerRadius
|
||||||
|
Text {
|
||||||
|
id: navLabel
|
||||||
|
anchors.centerIn: parent
|
||||||
|
text: modelData
|
||||||
|
color: root.fg
|
||||||
|
font.family: root.font_
|
||||||
|
font.pixelSize: Style.font.caption
|
||||||
|
}
|
||||||
|
MouseArea {
|
||||||
|
anchors.fill: parent
|
||||||
|
onClicked: root.fileIndex = (root.fileIndex + (index === 0 ? -1 : 1) + root.filePaths.length) % root.filePaths.length
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Text {
|
Text {
|
||||||
text: Classify.fileBase(modelData)
|
text: (root.fileIndex + 1) + " / " + root.filePaths.length
|
||||||
color: root.fg
|
color: root.mutedFg
|
||||||
font.family: root.font_
|
font.family: root.font_
|
||||||
font.pixelSize: Style.font.body
|
font.pixelSize: Style.font.caption
|
||||||
elide: Text.ElideMiddle
|
|
||||||
width: parent.width - Style.space(24)
|
|
||||||
maximumLineCount: 1
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
Text {
|
||||||
|
width: parent.width
|
||||||
Text {
|
text: Classify.fileBase(root.selectedFile)
|
||||||
visible: !!(root.entry && root.entry.paths && root.entry.paths.length > 10)
|
textFormat: Text.PlainText
|
||||||
text: root.entry && root.entry.paths ? "… and " + (root.entry.paths.length - 10) + " more" : ""
|
wrapMode: Text.WrapAnywhere
|
||||||
color: root.mutedFg
|
color: root.fg
|
||||||
font.family: root.font_
|
font.family: root.font_
|
||||||
font.pixelSize: Style.font.caption
|
font.pixelSize: Style.font.body
|
||||||
}
|
font.bold: true
|
||||||
|
}
|
||||||
Text {
|
Rectangle {
|
||||||
text: root.entry && root.entry.paths && root.entry.paths.length > 0
|
id: thumbnailPanel
|
||||||
? "in " + Classify.fileDir(root.entry.paths[0])
|
readonly property bool loading: fileDelay.running || fileProbe.running || fileThumbnail.status === Image.Loading
|
||||||
: ""
|
width: parent.width
|
||||||
color: root.mutedFg
|
height: visible ? Math.min(Style.space(240), filesBody.height * 0.55) : 0
|
||||||
font.family: root.font_
|
visible: loading || !!root.fileInfo.thumbnail
|
||||||
font.pixelSize: Style.font.caption
|
color: root.chipBg
|
||||||
elide: Text.ElideMiddle
|
radius: Style.cornerRadius
|
||||||
width: parent.width
|
clip: true
|
||||||
maximumLineCount: 1
|
Image {
|
||||||
|
id: fileThumbnail
|
||||||
|
anchors.fill: parent
|
||||||
|
visible: status === Image.Ready && !thumbnailPanel.loading
|
||||||
|
source: root.fileInfo.thumbnail || ""
|
||||||
|
fillMode: Image.PreserveAspectFit
|
||||||
|
asynchronous: true
|
||||||
|
sourceSize.width: 640
|
||||||
|
sourceSize.height: 360
|
||||||
|
}
|
||||||
|
Column {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
spacing: Style.space(8)
|
||||||
|
visible: thumbnailPanel.loading
|
||||||
|
Text {
|
||||||
|
anchors.horizontalCenter: parent.horizontalCenter
|
||||||
|
text: "◌"
|
||||||
|
color: Color.accent
|
||||||
|
font.pixelSize: Style.space(28)
|
||||||
|
NumberAnimation on rotation {
|
||||||
|
from: 0
|
||||||
|
to: 360
|
||||||
|
duration: 1000
|
||||||
|
loops: Animation.Infinite
|
||||||
|
running: thumbnailPanel.loading && filesBody.visible
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
text: "Loading preview…"
|
||||||
|
color: root.mutedFg
|
||||||
|
font.family: root.font_
|
||||||
|
font.pixelSize: Style.font.caption
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
visible: !thumbnailPanel.loading && fileThumbnail.status === Image.Error
|
||||||
|
text: "Thumbnail unavailable"
|
||||||
|
color: root.mutedFg
|
||||||
|
font.family: root.font_
|
||||||
|
font.pixelSize: Style.font.caption
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
width: parent.width
|
||||||
|
text: root.fileInfo.error || root.fileDetails() || (thumbnailPanel.loading ? "Loading file information…" : "File information unavailable")
|
||||||
|
textFormat: Text.PlainText
|
||||||
|
wrapMode: Text.WrapAnywhere
|
||||||
|
color: root.mutedFg
|
||||||
|
font.family: root.font_
|
||||||
|
font.pixelSize: Style.font.caption
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
width: parent.width
|
||||||
|
text: root.selectedFile
|
||||||
|
textFormat: Text.PlainText
|
||||||
|
wrapMode: Text.WrapAnywhere
|
||||||
|
color: root.mutedFg
|
||||||
|
font.family: root.font_
|
||||||
|
font.pixelSize: Style.font.caption
|
||||||
|
}
|
||||||
|
TextEdit {
|
||||||
|
width: parent.width
|
||||||
|
visible: text.length > 0
|
||||||
|
text: root.fileInfo.text || root.fileInfo.details || ""
|
||||||
|
textFormat: TextEdit.PlainText
|
||||||
|
readOnly: true
|
||||||
|
activeFocusOnPress: false
|
||||||
|
wrapMode: TextEdit.WrapAnywhere
|
||||||
|
color: root.fg
|
||||||
|
font.family: root.font_
|
||||||
|
font.pixelSize: Style.font.body
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
visible: !!root.fileInfo.truncated
|
||||||
|
text: "Preview limited to the first 8 KiB"
|
||||||
|
color: root.mutedFg
|
||||||
|
font.family: root.font_
|
||||||
|
font.pixelSize: Style.font.caption
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -61,6 +61,20 @@ That's the whole install — `omarchy plugin add` clones the repo, validates the
|
|||||||
manifest, and enables it. It replaces the built-in `omarchy.clipboard`
|
manifest, and enables it. It replaces the built-in `omarchy.clipboard`
|
||||||
(restore it later with `omarchy plugin disable alanfortlink.clipboard`).
|
(restore it later with `omarchy plugin disable alanfortlink.clipboard`).
|
||||||
|
|
||||||
|
### File previews
|
||||||
|
|
||||||
|
Copied files show their name, type, size, modification time, and path. Image
|
||||||
|
and video files show a thumbnail and resolution; videos and audio show duration,
|
||||||
|
and audio also shows codec, sample rate, and channels. PDFs show a first-page
|
||||||
|
thumbnail and page information. Text and code files show the first 8 KiB.
|
||||||
|
Folders and other files retain basic information. Use Previous/Next to inspect
|
||||||
|
multiple copied files. Details load on selection, including for existing history.
|
||||||
|
|
||||||
|
Media previews use optional `ffmpeg` (`ffprobe` included); PDF previews use
|
||||||
|
optional `poppler` (`pdfinfo` and `pdftoppm`). Without them, basic file information
|
||||||
|
still works. Preview commands have time and output limits, and media thumbnails
|
||||||
|
are skipped above 40 megapixels. Missing or moved files show an error.
|
||||||
|
|
||||||
### Dependencies
|
### Dependencies
|
||||||
|
|
||||||
All are regular Arch packages; nothing is downloaded or run at install time.
|
All are regular Arch packages; nothing is downloaded or run at install time.
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Bounded, on-demand metadata for one local copied file; never modifies it."""
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import mimetypes
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import stat
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
from capture import read_bounded
|
||||||
|
|
||||||
|
|
||||||
|
def preview(path):
|
||||||
|
result = {'path': path}
|
||||||
|
deadline = time.monotonic() + 6
|
||||||
|
|
||||||
|
def run(args, limit=128 * 1024):
|
||||||
|
remaining = deadline - time.monotonic()
|
||||||
|
return read_bounded(args, limit, timeout=min(2, remaining)) if remaining > 0 else None
|
||||||
|
|
||||||
|
try:
|
||||||
|
info = os.stat(path)
|
||||||
|
result.update(bytes=info.st_size, modified=int(info.st_mtime))
|
||||||
|
if stat.S_ISDIR(info.st_mode):
|
||||||
|
result['kind'] = 'Folder'
|
||||||
|
return result
|
||||||
|
if not stat.S_ISREG(info.st_mode):
|
||||||
|
result['kind'] = 'Special file'
|
||||||
|
return result
|
||||||
|
mime = mimetypes.guess_type(path)[0] or 'application/octet-stream'
|
||||||
|
result['mime'] = mime
|
||||||
|
result['kind'] = mime
|
||||||
|
# Do not let a media playlist cause ffmpeg to open referenced resources.
|
||||||
|
media = Path(path).suffix.lower() in {'.png', '.jpg', '.jpeg', '.webp', '.gif', '.bmp', '.tiff', '.tif', '.avif', '.heic', '.mp4', '.mkv', '.webm', '.mov', '.avi', '.m4v', '.mp3', '.flac', '.wav', '.ogg', '.opus', '.m4a', '.aac'}
|
||||||
|
if media:
|
||||||
|
options = ['-v', 'error', '-max_alloc', '67108864', '-protocol_whitelist', 'file', '-probesize', '1048576', '-analyzeduration', '1000000']
|
||||||
|
data = run(['ffprobe'] + options + ['-show_entries', 'format=duration:stream=codec_type,codec_name,width,height,sample_rate,channels', '-of', 'json', path])
|
||||||
|
if data:
|
||||||
|
parsed = json.loads(data)
|
||||||
|
duration = float(parsed.get('format', {}).get('duration', 0))
|
||||||
|
if 0 < duration < 1e9:
|
||||||
|
result['duration'] = duration
|
||||||
|
streams = parsed.get('streams', [])
|
||||||
|
visual = next((s for s in streams if s.get('codec_type') == 'video'), {})
|
||||||
|
audio = next((s for s in streams if s.get('codec_type') == 'audio'), {})
|
||||||
|
for key in ('width', 'height'):
|
||||||
|
if visual.get(key):
|
||||||
|
result[key] = int(visual[key])
|
||||||
|
if audio:
|
||||||
|
result['audio'] = ' · '.join(str(audio[k]) + suffix for k, suffix in [('codec_name', ''), ('sample_rate', ' Hz'), ('channels', ' channels')] if audio.get(k))
|
||||||
|
if 0 < result.get('width', 0) * result.get('height', 0) <= 40000000:
|
||||||
|
thumb = run(['ffmpeg'] + options + ['-nostdin', '-threads', '1', '-i', path, '-frames:v', '1', '-vf', 'scale=640:360:force_original_aspect_ratio=decrease', '-threads', '1', '-f', 'image2pipe', '-c:v', 'mjpeg', '-protocol_whitelist', 'pipe', 'pipe:1'], 512 * 1024)
|
||||||
|
if thumb:
|
||||||
|
result['thumbnail'] = 'data:image/jpeg;base64,' + base64.b64encode(thumb).decode('ascii')
|
||||||
|
elif mime == 'application/pdf':
|
||||||
|
thumb = run(['pdftoppm', '-f', '1', '-singlefile', '-scale-to', '640', '-jpeg', path], 512 * 1024)
|
||||||
|
if thumb:
|
||||||
|
result['thumbnail'] = 'data:image/jpeg;base64,' + base64.b64encode(thumb).decode('ascii')
|
||||||
|
data = run(['pdfinfo', path])
|
||||||
|
if data:
|
||||||
|
result['details'] = '\n'.join(line.strip() for line in data.decode('utf-8', 'replace').splitlines() if line.startswith(('Pages:', 'Page size:', 'Title:', 'Author:')))
|
||||||
|
elif mime.startswith('text/') or Path(path).suffix.lower() in {'.json', '.yaml', '.yml', '.toml', '.md', '.py', '.js', '.ts', '.qml', '.sh', '.rs', '.go', '.log', '.csv'}:
|
||||||
|
# Nonblocking open avoids hanging on a file replaced with a FIFO.
|
||||||
|
fd = os.open(path, os.O_RDONLY | os.O_NONBLOCK)
|
||||||
|
with os.fdopen(fd, 'rb') as source:
|
||||||
|
if stat.S_ISREG(os.fstat(source.fileno()).st_mode):
|
||||||
|
data = source.read(8193)
|
||||||
|
if b'\0' not in data:
|
||||||
|
result['text'] = data[:8192].decode('utf-8', 'replace')
|
||||||
|
result['truncated'] = len(data) > 8192
|
||||||
|
except (OSError, ValueError, TypeError) as error:
|
||||||
|
result['error'] = str(error)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
print(json.dumps({'request': sys.argv[1], 'file': preview(os.path.abspath(sys.argv[2]))}))
|
||||||
+1
-1
@@ -2,5 +2,5 @@
|
|||||||
# Run all plugin logic tests. Requires node and python3.
|
# Run all plugin logic tests. Requires node and python3.
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
cd "$(dirname "${BASH_SOURCE[0]}")/.."
|
cd "$(dirname "${BASH_SOURCE[0]}")/.."
|
||||||
python3 -m unittest discover -s tests -p 'test_capture.py'
|
python3 -m unittest discover -s tests -p 'test_*.py'
|
||||||
exec node --test 'tests/*.mjs'
|
exec node --test 'tests/*.mjs'
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import importlib.util
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
spec = importlib.util.spec_from_file_location('file_preview', Path(__file__).resolve().parents[1] / 'scripts/file-preview.py')
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(module)
|
||||||
|
|
||||||
|
|
||||||
|
class FilePreviewTests(unittest.TestCase):
|
||||||
|
def test_text_is_bounded_and_path_preserved(self):
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
path = Path(directory) / 'a " quoted.txt'
|
||||||
|
path.write_text('a' * 10000)
|
||||||
|
data = module.preview(str(path))
|
||||||
|
self.assertEqual(data['bytes'], 10000)
|
||||||
|
self.assertEqual(len(data['text']), 8192)
|
||||||
|
self.assertTrue(data['truncated'])
|
||||||
|
self.assertEqual(data['path'], str(path))
|
||||||
|
|
||||||
|
def test_missing_file_and_folder(self):
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
self.assertEqual(module.preview(directory)['kind'], 'Folder')
|
||||||
|
self.assertIn('error', module.preview(directory + '/missing'))
|
||||||
|
|
||||||
|
def test_special_file_is_not_read(self):
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
path = directory + '/fifo.txt'
|
||||||
|
os.mkfifo(path)
|
||||||
|
self.assertEqual(module.preview(path)['kind'], 'Special file')
|
||||||
|
|
||||||
|
def test_missing_media_tools_preserve_basic_metadata(self):
|
||||||
|
with tempfile.NamedTemporaryFile(suffix='.mp4') as source:
|
||||||
|
with patch.object(module, 'read_bounded', return_value=None):
|
||||||
|
data = module.preview(source.name)
|
||||||
|
self.assertEqual(data['mime'], 'video/mp4')
|
||||||
|
self.assertIn('bytes', data)
|
||||||
|
self.assertNotIn('thumbnail', data)
|
||||||
|
|
||||||
|
@unittest.skipUnless(shutil.which('ffmpeg') and shutil.which('ffprobe'), 'ffmpeg optional')
|
||||||
|
def test_video_resolution_duration_and_thumbnail(self):
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
path = directory + '/clip.mkv'
|
||||||
|
subprocess.run(['ffmpeg', '-v', 'error', '-f', 'lavfi', '-i', 'color=c=red:s=160x90:d=1', '-c:v', 'ffv1', path], check=True, timeout=10)
|
||||||
|
data = module.preview(path)
|
||||||
|
self.assertEqual((data['width'], data['height']), (160, 90))
|
||||||
|
self.assertAlmostEqual(data['duration'], 1, places=1)
|
||||||
|
self.assertTrue(data['thumbnail'].startswith('data:image/jpeg;base64,'))
|
||||||
|
|
||||||
|
@unittest.skipUnless(shutil.which('ffmpeg') and shutil.which('ffprobe'), 'ffmpeg optional')
|
||||||
|
def test_audio_details(self):
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
path = directory + '/audio.wav'
|
||||||
|
subprocess.run(['ffmpeg', '-v', 'error', '-f', 'lavfi', '-i', 'sine=duration=1', path], check=True, timeout=10)
|
||||||
|
data = module.preview(path)
|
||||||
|
self.assertAlmostEqual(data['duration'], 1, places=1)
|
||||||
|
self.assertIn('44100 Hz', data['audio'])
|
||||||
|
self.assertNotIn('thumbnail', data)
|
||||||
|
|
||||||
|
def test_generic_binary_not_shown_as_text(self):
|
||||||
|
with tempfile.NamedTemporaryFile(suffix='.txt') as source:
|
||||||
|
source.write(b'abc\x00def')
|
||||||
|
source.flush()
|
||||||
|
self.assertNotIn('text', module.preview(source.name))
|
||||||
Reference in New Issue
Block a user