Files
DeeJanuz--frametop/hands/rec/main.qml
T
DeeJanuzandClaude Opus 5.5 27b731878c Hands: the hand recorder's window, review and export
hands/rec/ft_handrec.py + main.qml (Kirigami, dev container; host launcher
hands/rec/ft-handrec): consent (CONSENT.md, asked again when its version
changes; profile.json with a random contributor id), the before-you-start
checklist with the lighting and free-space checks, the session controls
(Space pauses, Esc stops), review with a frame-set viewer that deletes
ranges, takes and sessions, export with progress and cancel (warns while
the headset is worn), and the upload page (UPLOAD.md, the
huggingface-cli command; HF_DATASET is a placeholder). --dry-run runs
sessions without processes, for testing.

hands/rec/takes.py (standard library): indexes sets.bin and sets-N.bin
without reading pixels, reads one set's cameras, keeps deleted ranges in
take.json, and exports: deleted sets left out, zstd -10 -T2 at nice 19,
manifest.json and SHA256SUMS, nothing left behind on cancel.

CONSENT.md and UPLOAD.md are drafts pending a legal review; the window
says contributions aren't open yet. The dev container gains zstd.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 10:50:22 -06:00

1129 lines
51 KiB
QML

// Frametop Hand Recorder (Kirigami). Backend: ft_handrec.py ("backend"); frame sets come from
// its image provider (image://frames/...).
import QtQuick
import QtQuick.Controls as Controls
import QtQuick.Layouts
import org.kde.kirigami as Kirigami
Kirigami.ApplicationWindow {
id: root
title: "Frametop Hand Recorder"
width: Kirigami.Units.gridUnit * 50
height: Kirigami.Units.gridUnit * 36
// The session chosen on the Export page, for the Upload page.
property string chosenSession: ""
globalDrawer: Kirigami.GlobalDrawer {
isMenu: false
modal: false
collapsible: true
collapsed: root.width < Kirigami.Units.gridUnit * 34
actions: [
Kirigami.Action { text: "Welcome"; icon.name: "help-about"; onTriggered: root.show(welcomePage) },
Kirigami.Action {
text: "Before you start"; icon.name: "view-task"; enabled: !backend.needsConsent
onTriggered: root.show(checklistPage)
},
Kirigami.Action {
text: "Session"; icon.name: "media-record"; enabled: !backend.needsConsent
onTriggered: root.show(sessionPage)
},
Kirigami.Action { text: "Review"; icon.name: "view-preview"; onTriggered: root.show(reviewPage) },
Kirigami.Action {
text: "Export"; icon.name: "document-export"; enabled: !backend.needsConsent
onTriggered: root.show(exportPage)
},
Kirigami.Action {
text: "Upload"; icon.name: "cloud-upload"; enabled: !backend.needsConsent
onTriggered: root.show(uploadPage)
}
]
}
// The texts wait for a legal review; until then nobody should contribute.
component DraftBanner: Kirigami.InlineMessage {
visible: backend.textsDraft
position: Kirigami.InlineMessage.Position.Header
type: Kirigami.MessageType.Warning
text: "Contributions aren't open yet. These texts are drafts waiting for a legal review: you can "
+ "record and review, but please don't upload anything until this message is gone."
}
// Read-only Markdown (CONSENT.md, UPLOAD.md), selectable for copying.
component MarkdownText: Controls.TextArea {
id: markdownText
property string markdown
// Links get the application palette's colour when the text is parsed, and Kirigami's theme
// doesn't set that palette: set it, then parse again (the theme resolves after creation).
readonly property color linkColor: Kirigami.Theme.linkColor
function parse() {
backend.setLinkColor(linkColor)
text = markdown
}
onLinkColorChanged: parse()
onMarkdownChanged: parse()
Component.onCompleted: parse()
textFormat: TextEdit.MarkdownText
readOnly: true
selectByMouse: true
wrapMode: Text.Wrap
background: null
padding: 0
onLinkActivated: link => Qt.openUrlExternally(link)
}
function plural(n, word) {
return n + " " + word + (n === 1 ? "" : "s")
}
function show(page) {
pageStack.clear()
pageStack.push(page)
}
// --page welcome|checklist|session|review|export|upload opens the app on that page; the
// Welcome page comes first until the consent is agreed to.
pageStack.initialPage: backend.needsConsent && startPage !== "review" ? welcomePage
: (({ welcome: welcomePage, checklist: checklistPage, session: sessionPage, review: reviewPage,
export: exportPage, upload: uploadPage })[startPage] || checklistPage)
Connections {
target: backend
function onMessage(text, isError) {
root.showPassiveNotification(text, isError ? "long" : "short")
}
}
// Asks before something is deleted for good: ask(title, text, function).
Kirigami.PromptDialog {
id: confirm
property var action: null
standardButtons: Controls.Dialog.Ok | Controls.Dialog.Cancel
onAccepted: if (action) action()
function ask(title, text, fn) {
confirm.title = title
confirm.subtitle = text
confirm.action = fn
confirm.open()
}
}
// Closing during a session asks first. Quitting closes the window again (Qt 6), so the
// answer is remembered; the backend stops the session as the app quits.
property bool quitting: false
onClosing: close => {
if (backend.sessionActive && !quitting) {
close.accepted = false
confirm.ask("Stop the session?", "A session is recording. Closing stops it; what's recorded so far is kept.",
() => { root.quitting = true; Qt.quit() })
}
}
// ---------------------------------------------------------------- Welcome
Component {
id: welcomePage
Kirigami.ScrollablePage {
title: "Welcome"
header: DraftBanner { }
ColumnLayout {
spacing: Kirigami.Units.largeSpacing
MarkdownText {
Layout.fillWidth: true
markdown: backend.consentText
}
Kirigami.Separator { Layout.fillWidth: true }
Kirigami.InlineMessage {
Layout.fillWidth: true
visible: !backend.needsConsent
type: Kirigami.MessageType.Positive
text: "You agreed to this text (" + backend.consentAccepted + "). Your contributor id is "
+ backend.contributor + ": keep it if you might want to withdraw later."
}
Controls.Button {
visible: !backend.needsConsent
text: "Copy contributor id"
icon.name: "edit-copy"
onClicked: backend.copy(backend.contributor)
}
Kirigami.InlineMessage {
Layout.fillWidth: true
visible: backend.needsConsent && backend.contributor !== ""
type: Kirigami.MessageType.Information
text: "This text changed since you last agreed to it. Please read it again; your contributor id stays the same."
}
Controls.CheckBox {
id: adult
visible: backend.needsConsent
text: "I'm 18 or older"
}
Controls.CheckBox {
id: agree
visible: backend.needsConsent
text: "I agree to the text above"
}
RowLayout {
visible: backend.needsConsent
Controls.Label { text: "Handedness (optional):" }
Controls.ComboBox {
id: handed
model: backend.handednessChoices
textRole: "text"
valueRole: "value"
Component.onCompleted: currentIndex = Math.max(0, indexOfValue(backend.handedness))
}
}
Controls.Button {
visible: backend.needsConsent
enabled: adult.checked && agree.checked
text: "Agree and continue"
icon.name: "go-next"
onClicked: {
backend.acceptConsent(adult.checked, agree.checked, handed.currentValue)
if (!backend.needsConsent)
root.show(checklistPage)
}
}
Controls.Button {
visible: !backend.needsConsent
text: "Continue"
icon.name: "go-next"
onClicked: root.show(checklistPage)
}
}
}
}
// ---------------------------------------------------------------- Before you start
Component {
id: checklistPage
Kirigami.ScrollablePage {
id: checklist
title: "Before you start"
readonly property bool privacyOk: privacy1.checked && privacy2.checked && privacy3.checked
readonly property bool ready: privacyOk && lighting.currentIndex >= 0 && backend.diskOk
&& backend.runnerError === "" && !backend.sessionActive
function answers() {
const objects = []
for (let i = 0; i < objectBoxes.count; ++i) {
const box = objectBoxes.itemAt(i)
if (box.checked)
objects.push(box.value)
}
return {
objects: objects,
own_objects: ownObjects.text.split(",").map(s => s.trim()).filter(s => s !== ""),
controllers: straps.checked ? "straps" : "none",
sleeves: sleeves.currentValue,
rings: rings.checked,
watch: watch.checked,
notes: notes.text,
privacy: privacyOk
}
}
header: ColumnLayout {
spacing: 0
DraftBanner { Layout.fillWidth: true }
Kirigami.InlineMessage {
Layout.fillWidth: true
visible: backend.runnerError !== ""
position: Kirigami.InlineMessage.Position.Header
type: Kirigami.MessageType.Error
text: backend.runnerError
}
Kirigami.InlineMessage {
Layout.fillWidth: true
visible: backend.sessionActive
position: Kirigami.InlineMessage.Position.Header
type: Kirigami.MessageType.Information
text: "A session is running."
actions: [ Kirigami.Action { text: "Go to it"; onTriggered: root.show(sessionPage) } ]
}
}
Kirigami.FormLayout {
Kirigami.Separator { Kirigami.FormData.isSection: true; Kirigami.FormData.label: "Within reach" }
Controls.Label {
Kirigami.FormData.label: ""
Layout.maximumWidth: Kirigami.Units.gridUnit * 26
wrapMode: Text.Wrap
text: "Put these on the desk or table in front of you. Untick what you don't have; "
+ "the session skips those."
}
Repeater {
id: objectBoxes
model: backend.objects
Controls.CheckBox {
required property var modelData
required property int index
readonly property string value: modelData.value
Kirigami.FormData.label: index === 0 ? "Objects:" : ""
text: modelData.text
checked: true
}
}
Controls.TextField {
id: ownObjects
Kirigami.FormData.label: "Your own:"
Layout.preferredWidth: Kirigami.Units.gridUnit * 20
placeholderText: "other things you use, separated by commas"
}
Kirigami.Separator { Kirigami.FormData.isSection: true; Kirigami.FormData.label: "Controllers" }
ColumnLayout {
Kirigami.FormData.label: "Ergonomic Kit straps:"
Controls.RadioButton { id: straps; text: "Yes, I have the controllers with the straps" }
Controls.RadioButton { text: "No"; checked: true }
}
Controls.Label {
Layout.maximumWidth: Kirigami.Units.gridUnit * 26
wrapMode: Text.Wrap
opacity: 0.7
text: "With the straps, the controllers stay on your hands while your fingers move freely. "
+ "The controllers' tracking then says exactly where your hands are, which teaches the "
+ "model how far away a hand is. Without them, the two controller sections are skipped."
}
Kirigami.Separator { Kirigami.FormData.isSection: true; Kirigami.FormData.label: "Light" }
Controls.ComboBox {
id: lighting
Kirigami.FormData.label: "Lighting this round:"
model: backend.lightingChoices
textRole: "text"
valueRole: "value"
currentIndex: -1
displayText: currentIndex < 0 ? "Choose…" : currentText
onActivated: backend.checkLighting(currentValue)
}
Controls.Label {
Layout.maximumWidth: Kirigami.Units.gridUnit * 26
wrapMode: Text.Wrap
opacity: 0.7
text: "Each round in a different light helps the most: dim, a normal room, daylight."
}
Kirigami.InlineMessage {
Layout.maximumWidth: Kirigami.Units.gridUnit * 26
Layout.fillWidth: true
visible: backend.lightingNote !== "" && lighting.currentIndex >= 0
Layout.preferredHeight: visible ? implicitHeight : 0
type: Kirigami.MessageType.Warning
text: backend.lightingNote
}
Controls.Button {
visible: backend.lightingNote !== "" && lighting.currentIndex >= 0
text: "Check the light again"
icon.name: "view-refresh"
onClicked: backend.checkLighting(lighting.currentValue)
}
Kirigami.Separator { Kirigami.FormData.isSection: true; Kirigami.FormData.label: "You" }
Controls.ComboBox {
id: sleeves
Kirigami.FormData.label: "Sleeves:"
model: backend.sleeveChoices
textRole: "text"
valueRole: "value"
}
Controls.CheckBox { id: rings; Kirigami.FormData.label: "Wearing:"; text: "Rings" }
Controls.CheckBox { id: watch; text: "A watch or bracelet" }
Controls.Label {
Layout.maximumWidth: Kirigami.Units.gridUnit * 26
wrapMode: Text.Wrap
opacity: 0.7
text: "Wear what you normally do: the dataset needs hands with and without them. This only notes it."
}
Controls.TextField {
id: notes
Kirigami.FormData.label: "Notes (optional):"
Layout.preferredWidth: Kirigami.Units.gridUnit * 20
placeholderText: "anything that helps, e.g. a bandage on a finger"
}
Kirigami.Separator { Kirigami.FormData.isSection: true; Kirigami.FormData.label: "Privacy" }
Controls.CheckBox {
id: privacy1
Kirigami.FormData.label: "I've checked:"
text: "I'm facing away from other people, mirrors and windows"
}
Controls.CheckBox {
id: privacy2
text: "No papers, photos, screens or other private things are in view"
}
Controls.CheckBox {
id: privacy3
text: "Nobody else's face or hands will be in view"
}
Kirigami.Separator { Kirigami.FormData.isSection: true; Kirigami.FormData.label: "Space" }
Controls.Label {
Kirigami.FormData.label: "Free space:"
text: backend.freeText + " (a round takes about 10 GB)"
}
Kirigami.InlineMessage {
Layout.maximumWidth: Kirigami.Units.gridUnit * 26
Layout.fillWidth: true
visible: !backend.diskOk
type: Kirigami.MessageType.Error
text: "Not enough free space for a round. Export and delete earlier sessions (Review), "
+ "or free up space, first."
}
Kirigami.Separator { Kirigami.FormData.isSection: true; Kirigami.FormData.label: "What will happen" }
Controls.Label {
Layout.maximumWidth: Kirigami.Units.gridUnit * 26
wrapMode: Text.Wrap
text: "Press Start, then put the headset on. A panel in the headset says what to do, one "
+ "section at a time: hand poses, gestures, typing and the mouse, your objects, touching a "
+ "dot, and moves with the controllers on and off. Each section is recorded as one take.\n\n"
+ "Space in this window pauses and Esc stops. You can also skip a section.\n\n"
+ "Nothing leaves the headset. Afterwards you watch the takes in Review, delete anything "
+ "you don't want to share, and only then export."
}
Controls.Button {
text: "Start"
icon.name: "media-record"
enabled: checklist.ready
onClicked: {
if (backend.startSession(checklist.answers(), lighting.currentValue))
root.show(sessionPage)
}
}
Controls.Label {
visible: !checklist.ready && !backend.sessionActive
opacity: 0.7
text: !checklist.privacyOk ? "Tick the three privacy checks to start."
: lighting.currentIndex < 0 ? "Choose the lighting to start." : ""
}
}
}
}
// ---------------------------------------------------------------- Session
Component {
id: sessionPage
Kirigami.Page {
id: sessionView
title: "Session"
readonly property var st: backend.status
readonly property string state: st.state || ""
readonly property var stateText: ({
starting: "Starting…", intro: "Get ready", running: "Recording", paused: "Paused",
between: "Between sections", done: "Done", stopped: "Stopped", error: "Error"
})
// Space and Esc while the window has focus (the panel in the headset says so too).
Shortcut {
sequence: "Space"
enabled: backend.sessionActive
onActivated: backend.togglePause()
}
Shortcut {
sequence: "Esc"
enabled: backend.sessionActive
onActivated: backend.stopSession()
}
// A hand chip like the panel's: seen (green), lost (orange) or hidden.
component HandChip: Controls.Label {
property var seen
visible: seen === true || seen === false || seen === "seen" || seen === "lost"
readonly property bool ok: seen === true || seen === "seen"
padding: Kirigami.Units.smallSpacing
leftPadding: Kirigami.Units.largeSpacing
rightPadding: Kirigami.Units.largeSpacing
color: "white"
background: Rectangle {
radius: height / 2
color: parent.ok ? Kirigami.Theme.positiveTextColor : Kirigami.Theme.neutralTextColor
}
}
header: Kirigami.InlineMessage {
visible: backend.runnerError !== "" || sessionView.state === "error"
position: Kirigami.InlineMessage.Position.Header
type: Kirigami.MessageType.Error
text: backend.runnerError || ("The session stopped with an error: " + (sessionView.st.error || "unknown"))
}
Kirigami.PlaceholderMessage {
anchors.centerIn: parent
width: parent.width - Kirigami.Units.gridUnit * 4
visible: sessionView.state === ""
icon.name: "media-record"
text: "No session yet"
explanation: "Go through Before you start, then press Start there."
helpfulAction: Kirigami.Action {
text: "Before you start"; icon.name: "view-task"
onTriggered: root.show(checklistPage)
}
}
ColumnLayout {
anchors.fill: parent
anchors.margins: Kirigami.Units.gridUnit
visible: sessionView.state !== ""
spacing: Kirigami.Units.largeSpacing
Kirigami.Heading {
level: 1
text: sessionView.stateText[sessionView.state] || sessionView.state
}
Controls.Label {
visible: (sessionView.st.section_index || 0) > 0
text: "Section " + sessionView.st.section_index + " of " + sessionView.st.section_count
+ (sessionView.st.title ? ": " + sessionView.st.title : "")
font.bold: true
}
Controls.Label {
Layout.fillWidth: true
visible: !!sessionView.st.prompt
wrapMode: Text.Wrap
font.pointSize: Kirigami.Theme.defaultFont.pointSize * 1.4
text: sessionView.st.prompt || ""
}
Controls.Label {
visible: sessionView.st.seconds_left !== undefined && sessionView.st.seconds_left !== null
&& sessionView.st.seconds_left >= 0
text: Math.ceil(sessionView.st.seconds_left || 0) + " s left"
opacity: 0.7
}
Kirigami.InlineMessage {
Layout.fillWidth: true
visible: !!sessionView.st.note
// (A hidden InlineMessage keeps its height in a layout.)
Layout.preferredHeight: visible ? implicitHeight : 0
type: Kirigami.MessageType.Warning
text: sessionView.st.note || ""
}
RowLayout {
spacing: Kirigami.Units.largeSpacing
HandChip { text: "Left hand"; seen: sessionView.st.hands ? sessionView.st.hands.left : undefined }
HandChip { text: "Right hand"; seen: sessionView.st.hands ? sessionView.st.hands.right : undefined }
}
Controls.Label {
visible: !!sessionView.st.take
text: "Recording to take " + (sessionView.st.take || "")
opacity: 0.7
font: Kirigami.Theme.smallFont
}
Item { Layout.fillHeight: true }
Controls.Label {
Layout.fillWidth: true
wrapMode: Text.Wrap
opacity: 0.7
text: "The instructions appear in the headset. While this window has focus, Space pauses "
+ "and resumes, and Esc stops. Stopping keeps what's recorded so far."
}
RowLayout {
// No keyboard focus on the buttons, so Space always reaches the shortcut.
Controls.Button {
visible: !backend.sessionActive
focusPolicy: Qt.NoFocus
text: "New session…"
icon.name: "media-record"
onClicked: root.show(checklistPage)
}
Controls.Button {
visible: backend.sessionActive
focusPolicy: Qt.NoFocus
text: sessionView.state === "paused" ? "Resume" : "Pause"
icon.name: sessionView.state === "paused" ? "media-playback-start" : "media-playback-pause"
onClicked: backend.togglePause()
}
Controls.Button {
visible: backend.sessionActive
focusPolicy: Qt.NoFocus
text: "Skip section"
icon.name: "media-skip-forward"
onClicked: backend.skipSection()
}
Controls.Button {
visible: backend.sessionActive
focusPolicy: Qt.NoFocus
text: "Stop"
icon.name: "media-playback-stop"
onClicked: backend.stopSession()
}
Controls.Button {
visible: !backend.sessionActive && backend.sessionId !== ""
focusPolicy: Qt.NoFocus
text: "Review this session"
icon.name: "view-preview"
onClicked: {
root.show(reviewPage)
pageStack.push(takesPage, { session: backend.sessionId })
}
}
}
}
}
}
// ---------------------------------------------------------------- Review
Component {
id: reviewPage
Kirigami.ScrollablePage {
title: "Review"
actions: [
Kirigami.Action { text: "Refresh"; icon.name: "view-refresh"; onTriggered: backend.refreshSessions() }
]
ListView {
model: backend.sessions
spacing: Kirigami.Units.smallSpacing
Kirigami.PlaceholderMessage {
anchors.centerIn: parent
width: parent.width - Kirigami.Units.gridUnit * 4
visible: parent.count === 0
text: "No sessions yet"
explanation: "Recorded sessions appear here, to watch and trim before you export them."
}
delegate: Controls.ItemDelegate {
id: sessionRow
required property var modelData
width: ListView.view.width
onClicked: pageStack.push(takesPage, { session: modelData.id })
contentItem: RowLayout {
spacing: Kirigami.Units.largeSpacing
Kirigami.Icon {
source: sessionRow.modelData.active ? "media-record" : "folder-videos"
implicitWidth: Kirigami.Units.iconSizes.medium
implicitHeight: implicitWidth
}
ColumnLayout {
Layout.fillWidth: true
spacing: 0
Controls.Label {
text: sessionRow.modelData.label + (sessionRow.modelData.active ? " (recording)" : "")
+ (sessionRow.modelData.dry_run ? " (dry run)" : "")
font.bold: true
}
Controls.Label {
Layout.fillWidth: true
elide: Text.ElideRight
opacity: 0.7
font: Kirigami.Theme.smallFont
text: root.plural(sessionRow.modelData.takes, "take") + " · " + sessionRow.modelData.sizeText
+ (sessionRow.modelData.statusText ? " · " + sessionRow.modelData.statusText : "")
+ (sessionRow.modelData.lightingText ? " · " + sessionRow.modelData.lightingText : "")
+ (sessionRow.modelData.export_stale ? " · changed since its export"
: sessionRow.modelData.exported ? " · exported (" + sessionRow.modelData.exportText + ")" : "")
}
}
Controls.ToolButton {
icon.name: "edit-delete"
text: "Delete session"
display: Controls.AbstractButton.IconOnly
enabled: !sessionRow.modelData.active
Controls.ToolTip.text: text
Controls.ToolTip.visible: hovered
onClicked: {
const sid = sessionRow.modelData.id
confirm.ask("Delete this session?",
"The session of " + sessionRow.modelData.label + " (" + root.plural(sessionRow.modelData.takes, "take")
+ ", " + sessionRow.modelData.sizeText + ") and its export are deleted from the "
+ "headset for good.",
() => { pageStack.pop(pageStack.items[0]); backend.deleteSession(sid) })
}
}
}
}
}
}
}
Component {
id: takesPage
Kirigami.ScrollablePage {
id: takesView
property string session: ""
property var rows: backend.takeList(session)
title: "Takes"
Connections {
target: backend
function onSessionsChanged() { takesView.rows = backend.takeList(takesView.session) }
}
ListView {
model: takesView.rows
spacing: Kirigami.Units.smallSpacing
Kirigami.PlaceholderMessage {
anchors.centerIn: parent
width: parent.width - Kirigami.Units.gridUnit * 4
visible: parent.count === 0
text: "No takes in this session"
}
delegate: Controls.ItemDelegate {
id: takeRow
required property var modelData
width: ListView.view.width
onClicked: pageStack.push(viewerPage, { session: takesView.session, take: modelData.id })
contentItem: RowLayout {
spacing: Kirigami.Units.largeSpacing
Image {
Layout.preferredWidth: Kirigami.Units.gridUnit * 5
Layout.preferredHeight: Kirigami.Units.gridUnit * 4.5
fillMode: Image.PreserveAspectFit
asynchronous: true
source: takeRow.modelData.sets > 0
? "image://frames/thumb/" + takesView.session + "/" + takeRow.modelData.id : ""
}
ColumnLayout {
Layout.fillWidth: true
spacing: 0
Controls.Label { text: takeRow.modelData.title; font.bold: true }
Controls.Label {
Layout.fillWidth: true
elide: Text.ElideRight
opacity: 0.7
font: Kirigami.Theme.smallFont
text: takeRow.modelData.status + " · " + takeRow.modelData.durationText + " · "
+ takeRow.modelData.sets + " sets"
+ (takeRow.modelData.deleted_sets ? " (" + takeRow.modelData.deleted_sets + " deleted)" : "")
+ " · " + takeRow.modelData.sizeText
}
}
Controls.ToolButton {
icon.name: "edit-delete"
text: "Delete take"
display: Controls.AbstractButton.IconOnly
Controls.ToolTip.text: text
Controls.ToolTip.visible: hovered
onClicked: {
const take = takeRow.modelData.id
confirm.ask("Delete this take?",
"\"" + takeRow.modelData.title + "\" (" + takeRow.modelData.durationText
+ ") is deleted from the headset for good.",
() => { pageStack.pop(takesView); backend.deleteTake(takesView.session, take) })
}
}
}
}
}
}
}
Component {
id: viewerPage
Kirigami.Page {
id: viewer
property string session: ""
property string take: ""
property var info: backend.takeInfo(session, take)
property int index: 0
property int markStart: -1
property int markEnd: -1
title: info.title
readonly property bool deletedHere: isDeleted(index)
function step(n) {
index = Math.max(0, Math.min(info.count - 1, index + n))
}
function isDeleted(i) {
for (const r of info.ranges)
if (i >= r[0] && i <= r[1] && r[0] >= 0)
return true
return false
}
function deleteMarked() {
backend.deleteRange(session, take, markStart, markEnd)
markStart = markEnd = -1
info = backend.takeInfo(session, take)
}
Connections {
target: backend
function onSessionsChanged() { viewer.info = backend.takeInfo(viewer.session, viewer.take) }
}
// Keyboard stepping: arrows one set, Page Up/Down ten, Home/End, [ and ] mark a range,
// Delete deletes it.
Item {
id: keys
focus: true
Component.onCompleted: forceActiveFocus()
Keys.onPressed: event => {
const k = event.key
if (k === Qt.Key_Left) viewer.step(-1)
else if (k === Qt.Key_Right) viewer.step(1)
else if (k === Qt.Key_PageUp) viewer.step(-10)
else if (k === Qt.Key_PageDown) viewer.step(10)
else if (k === Qt.Key_Home) viewer.index = 0
else if (k === Qt.Key_End) viewer.index = Math.max(0, viewer.info.count - 1)
else if (k === Qt.Key_BracketLeft) viewer.markStart = viewer.index
else if (k === Qt.Key_BracketRight) viewer.markEnd = viewer.index
else if (k === Qt.Key_Delete && viewer.markStart >= 0 && viewer.markEnd >= 0) viewer.deleteMarked()
else return
event.accepted = true
}
}
Kirigami.PlaceholderMessage {
anchors.centerIn: parent
visible: viewer.info.count === 0
text: "This take has no frame sets"
}
ColumnLayout {
anchors.fill: parent
visible: viewer.info.count > 0
spacing: Kirigami.Units.smallSpacing
Image {
id: frame
Layout.fillWidth: true
Layout.fillHeight: true
fillMode: Image.PreserveAspectFit
asynchronous: true
cache: false
retainWhileLoading: true
source: viewer.info.count > 0
? "image://frames/set/" + viewer.session + "/" + viewer.take + "/" + viewer.index : ""
MouseArea { anchors.fill: parent; onClicked: keys.forceActiveFocus() }
Rectangle {
visible: viewer.deletedHere
x: (frame.width - frame.paintedWidth) / 2
y: (frame.height - frame.paintedHeight) / 2
width: frame.paintedWidth
height: frame.paintedHeight
color: Qt.rgba(0.8, 0, 0, 0.35)
Controls.Label {
anchors.centerIn: parent
text: "Deleted: not exported"
color: "white"
font.bold: true
}
}
}
// The slider, with the deleted ranges (red) and the marked range (blue) under it.
Item {
Layout.fillWidth: true
implicitHeight: slider.implicitHeight
Repeater {
model: viewer.info.ranges
Rectangle {
required property var modelData
visible: modelData[0] >= 0
readonly property real unit: (slider.availableWidth) / Math.max(1, viewer.info.count - 1)
x: slider.leftPadding + modelData[0] * unit - 2
width: Math.max(4, (modelData[1] - modelData[0]) * unit + 4)
y: slider.topPadding + slider.availableHeight / 2 + 4
height: 4
color: Kirigami.Theme.negativeTextColor
}
}
Rectangle {
visible: viewer.markStart >= 0
readonly property int last: viewer.markEnd >= 0 ? viewer.markEnd : viewer.index
readonly property real unit: (slider.availableWidth) / Math.max(1, viewer.info.count - 1)
x: slider.leftPadding + Math.min(viewer.markStart, last) * unit - 2
width: Math.abs(last - viewer.markStart) * unit + 4
y: slider.topPadding + slider.availableHeight / 2 - 8
height: 4
color: Kirigami.Theme.highlightColor
}
Controls.Slider {
id: slider
anchors.left: parent.left
anchors.right: parent.right
focusPolicy: Qt.NoFocus
from: 0
to: Math.max(0, viewer.info.count - 1)
stepSize: 1
snapMode: Controls.Slider.SnapAlways
value: viewer.index
onMoved: viewer.index = Math.round(value)
}
}
RowLayout {
Layout.fillWidth: true
Controls.Label {
text: "Set " + (viewer.index + 1) + " of " + viewer.info.count + " · "
+ backend.setTime(viewer.session, viewer.take, viewer.index)
}
Item { Layout.fillWidth: true }
Controls.Button {
focusPolicy: Qt.NoFocus
text: viewer.markStart >= 0 ? "Start: " + (viewer.markStart + 1) : "Mark start"
icon.name: "go-first"
onClicked: viewer.markStart = viewer.index
}
Controls.Button {
focusPolicy: Qt.NoFocus
text: viewer.markEnd >= 0 ? "End: " + (viewer.markEnd + 1) : "Mark end"
icon.name: "go-last"
onClicked: viewer.markEnd = viewer.index
}
Controls.Button {
focusPolicy: Qt.NoFocus
enabled: viewer.markStart >= 0 && viewer.markEnd >= 0
text: "Delete range"
icon.name: "edit-cut"
onClicked: viewer.deleteMarked()
}
Controls.Button {
focusPolicy: Qt.NoFocus
visible: viewer.markStart >= 0 || viewer.markEnd >= 0
text: "Clear marks"
icon.name: "edit-clear"
onClicked: viewer.markStart = viewer.markEnd = -1
}
}
Flow {
Layout.fillWidth: true
spacing: Kirigami.Units.smallSpacing
visible: viewer.info.ranges.length > 0
Controls.Label { text: "Deleted:"; topPadding: Kirigami.Units.smallSpacing }
Repeater {
model: viewer.info.ranges
Controls.Button {
required property var modelData
required property int index
focusPolicy: Qt.NoFocus
flat: true
text: (modelData[0] < 0 ? "a range with no sets"
: "sets " + (modelData[0] + 1) + " to " + (modelData[1] + 1)) + ": restore"
icon.name: "edit-undo"
onClicked: backend.restoreRange(viewer.session, viewer.take, index)
}
}
}
Controls.Label {
Layout.fillWidth: true
wrapMode: Text.Wrap
opacity: 0.7
font: Kirigami.Theme.smallFont
text: "← → step one set, Page Up/Down ten, Home/End. [ and ] mark a range's start and end, "
+ "Delete deletes it. Deleted sets stay on the headset until export, which leaves them out."
}
}
}
}
// ---------------------------------------------------------------- Export
Component {
id: exportPage
Kirigami.ScrollablePage {
id: exportView
title: "Export"
property bool worn: backend.headsetWorn()
readonly property var chosen: {
const list = backend.sessions
for (const s of list)
if (s.id === sessionBox.currentValue)
return s
return null
}
Timer {
interval: 5000
running: true
repeat: true
onTriggered: exportView.worn = backend.headsetWorn()
}
header: ColumnLayout {
spacing: 0
DraftBanner { Layout.fillWidth: true }
Kirigami.InlineMessage {
Layout.fillWidth: true
visible: exportView.worn
position: Kirigami.InlineMessage.Position.Header
type: Kirigami.MessageType.Warning
text: "Someone seems to be wearing the headset. Exporting keeps the processor busy for a "
+ "while, which can make VR stutter: export when the headset is off, or go ahead anyway."
}
Kirigami.InlineMessage {
Layout.fillWidth: true
visible: !backend.zstdFound
position: Kirigami.InlineMessage.Position.Header
type: Kirigami.MessageType.Error
text: "zstd isn't installed in the dev container, so nothing can be exported. Run setup/dev-container.sh."
}
}
Kirigami.FormLayout {
Controls.ComboBox {
id: sessionBox
Kirigami.FormData.label: "Session:"
model: backend.sessions
textRole: "label"
valueRole: "id"
enabled: !backend.exporting
Component.onCompleted: {
const i = indexOfValue(root.chosenSession)
currentIndex = i >= 0 ? i : (count > 0 ? 0 : -1)
}
onActivated: root.chosenSession = currentValue
}
Controls.Label {
Kirigami.FormData.label: "Recorded:"
visible: exportView.chosen !== null
text: exportView.chosen ? root.plural(exportView.chosen.takes, "take") + ", " + exportView.chosen.sizeText : ""
}
Controls.Label {
Kirigami.FormData.label: "Export:"
visible: exportView.chosen !== null
text: !exportView.chosen ? ""
: exportView.chosen.export_stale ? "changed since it was exported: export it again"
: exportView.chosen.exported ? "ready, " + exportView.chosen.exportText : "not exported yet"
}
RowLayout {
Controls.Button {
text: exportView.chosen && exportView.chosen.exported ? "Export again" : "Export"
icon.name: "document-export"
enabled: exportView.chosen !== null && !backend.exporting && backend.zstdFound
&& !exportView.chosen.active
onClicked: backend.exportSession(exportView.chosen.id, false)
}
Controls.Button {
visible: backend.exporting
text: "Cancel"
icon.name: "dialog-cancel"
onClicked: backend.cancelExport()
}
}
// Drawn here: the desktop style's ProgressBar paints nothing without a QApplication.
Rectangle {
Kirigami.FormData.label: "Progress:"
Layout.preferredWidth: Kirigami.Units.gridUnit * 20
implicitHeight: Kirigami.Units.smallSpacing * 2
visible: backend.exporting
radius: height / 2
color: Qt.rgba(Kirigami.Theme.textColor.r, Kirigami.Theme.textColor.g, Kirigami.Theme.textColor.b, 0.15)
Rectangle {
width: parent.width * Math.max(0, Math.min(1, backend.exportFraction))
height: parent.height
radius: parent.radius
color: Kirigami.Theme.highlightColor
}
}
Controls.Label {
Layout.maximumWidth: Kirigami.Units.gridUnit * 26
visible: backend.exportText !== "" && backend.exportSessionId === sessionBox.currentValue
wrapMode: Text.WrapAnywhere
text: backend.exportText
}
RowLayout {
visible: exportView.chosen !== null && exportView.chosen.exported && !backend.exporting
Controls.Button {
text: "Upload instructions"
icon.name: "go-next"
onClicked: root.show(uploadPage)
}
Controls.Button {
text: "Delete export"
icon.name: "edit-delete"
onClicked: {
const sid = exportView.chosen.id
confirm.ask("Delete this export?", "The export is deleted; the session's recordings stay.",
() => backend.deleteExport(sid))
}
}
}
}
footer: Controls.Label {
padding: Kirigami.Units.largeSpacing
wrapMode: Text.Wrap
opacity: 0.7
text: "Export leaves out the ranges you deleted, compresses the rest and adds a manifest and "
+ "checksums, in " + backend.exportsDir + ". It runs at the lowest priority "
+ "and takes a few minutes per round. Nothing is uploaded: the Upload page explains how."
}
}
}
// ---------------------------------------------------------------- Upload
Component {
id: uploadPage
Kirigami.ScrollablePage {
id: uploadView
title: "Upload"
readonly property var exported: backend.sessions.filter(s => s.exported)
readonly property string session: exportBox.currentIndex >= 0 ? exportBox.currentValue || "" : ""
header: DraftBanner { }
ColumnLayout {
spacing: Kirigami.Units.largeSpacing
Kirigami.PlaceholderMessage {
Layout.fillWidth: true
visible: uploadView.exported.length === 0
icon.name: "document-export"
text: "Nothing exported yet"
explanation: "Review a session, then export it. Its upload instructions appear here."
helpfulAction: Kirigami.Action {
text: "Export"; icon.name: "document-export"
onTriggered: root.show(exportPage)
}
}
RowLayout {
visible: uploadView.exported.length > 0
Controls.Label { text: "Export:" }
Controls.ComboBox {
id: exportBox
model: uploadView.exported
textRole: "label"
valueRole: "id"
Component.onCompleted: currentIndex = Math.max(0, indexOfValue(root.chosenSession))
}
}
Kirigami.InlineMessage {
Layout.fillWidth: true
visible: uploadView.exported.length > 0 && exportBox.currentIndex >= 0
&& uploadView.exported[exportBox.currentIndex].export_stale
type: Kirigami.MessageType.Warning
text: "This session changed since it was exported. Export it again so the upload has your latest deletions."
}
RowLayout {
Layout.fillWidth: true
visible: uploadView.session !== ""
Controls.TextArea {
id: command
Layout.fillWidth: true
readOnly: true
selectByMouse: true
wrapMode: Text.WrapAnywhere
font.family: "monospace"
text: backend.uploadCommand(uploadView.session)
}
Controls.Button {
text: "Copy command"
icon.name: "edit-copy"
onClicked: backend.copy(command.text)
}
}
MarkdownText {
Layout.fillWidth: true
visible: uploadView.session !== ""
markdown: backend.uploadText(uploadView.session)
}
}
}
}
}