Files
DeeJanuz--frametop/hands/rec/main.qml
T
DeeJanuzandClaude Opus 5.5 97b7fc837c Hands: shorter recording sessions with pose sweeps
The second real session took 16 minutes, half of it 36 still poses. Labels
come from the auto-labeller, so what matters is variety, not clean holds.
A sweep step shows a strip of pose pictures and lights one every 4 s while
the hands move slowly near and far; each cue is a prompt event with
"cue": true. The core session is now 15 steps, about 5 minutes recorded.

The pose groups, the one-hand sweeps' groups and the cue order are shuffled
per session, seeded from its id and saved in session.json. A quick round
(--quick, or the checklist's choice) is about 2 minutes for extra lighting.
Touch the dot has 6 dots, the push sections two heights.

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

1775 lines
86 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()
}
}
// Restart SteamVR (the camera check's fix). What it does to Frametop, read from the code
// (hands/README.md, "Camera check"): ft-screens quits when SteamVR does, which ends the
// desktop; its unit doesn't restart, and stopping it ends what was started in it.
function askRestartSteamVR() {
confirm.ask("Restart SteamVR?",
"SteamVR starts its cameras again, and with them the colour camera module. This closes every "
+ "VR app. It also closes the Frametop desktop with all its windows, this one too: Frametop's "
+ "screens don't come back by themselves. Save your work first; programs started from the "
+ "desktop, such as terminals and what runs in them, stop too.\n\nOnce SteamVR is back, start "
+ "the desktop again (Desktop in the library) and open the Hand recorder: it checks the cameras "
+ "again. If they're still off, restart the headset.",
() => backend.restartSteamVR())
}
// The camera check's problem, with its fix (a header message on the checklist and session pages).
component CameraMessage: Kirigami.InlineMessage {
position: Kirigami.InlineMessage.Position.Header
type: backend.restartingSteamVR ? Kirigami.MessageType.Information : Kirigami.MessageType.Error
text: backend.restartingSteamVR ? "Restarting SteamVR, then checking the cameras again…" : backend.cameraText
actions: [
Kirigami.Action {
text: "Restart SteamVR…"
icon.name: "system-reboot"
enabled: !backend.restartingSteamVR && !backend.sessionActive
onTriggered: root.askRestartSteamVR()
},
Kirigami.Action {
text: "Check again"
icon.name: "view-refresh"
enabled: !backend.cameraBusy
onTriggered: backend.checkCameras()
}
]
}
// 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() })
} else if (backend.uploading && !quitting) {
close.accepted = false
confirm.ask("Cancel the upload?", "An upload is running. Closing cancels it; you can start it again later.",
() => { 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) } ]
}
CameraMessage {
Layout.fillWidth: true
visible: !backend.sessionActive && (backend.cameraText !== "" || backend.restartingSteamVR)
}
}
Component.onCompleted: backend.checkCameras()
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: "Cameras" }
RowLayout {
Kirigami.FormData.label: "Tracking cameras:"
Controls.Label {
Layout.maximumWidth: Kirigami.Units.gridUnit * 20
wrapMode: Text.Wrap
text: backend.camerasIgnored ? "Not checked (--ignore-cameras)"
: backend.cameraState === "ok" ? "All four are running."
: backend.cameraState === "degraded" ? "Not all running: see the message at the top."
: backend.cameraState === "unknown" ? "Couldn't tell (" + backend.cameraSummary.replace(/^unknown: /, "")
+ "). The session checks again when it starts."
: "Checking…"
}
Controls.ToolButton {
visible: backend.cameraEvidence !== ""
icon.name: "documentinfo"
text: "Details"
checkable: true
id: cameraDetails
display: Controls.AbstractButton.IconOnly
Controls.ToolTip.text: "What the check looked at"
Controls.ToolTip.visible: hovered
}
Controls.ToolButton {
icon.name: "view-refresh"
text: "Check again"
display: Controls.AbstractButton.IconOnly
enabled: !backend.cameraBusy
onClicked: backend.checkCameras()
Controls.ToolTip.text: text
Controls.ToolTip.visible: hovered
}
}
Controls.Label {
visible: cameraDetails.checked && backend.cameraEvidence !== ""
Layout.maximumWidth: Kirigami.Units.gridUnit * 34
wrapMode: Text.WrapAnywhere
font: Kirigami.Theme.smallFont
opacity: 0.7
textFormat: Text.PlainText
text: backend.cameraSummary + "\n" + backend.cameraEvidence
}
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 shows each step: a picture "
+ "of the hand pose, where to hold your hands, and what to do. The sections: 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. In the pose steps your "
+ "hands keep moving while a row of pictures lights up one shape after another: follow it.\n\n"
+ "Each step waits until you're ready: press the button on the right side of the headset, "
+ "or Space or Next in this window. A 3-2-1 "
+ "countdown follows, then hold the pose until the bar runs out. Nothing is recorded while "
+ "a step waits. The headset button also pauses and resumes a recording. In this window P "
+ "pauses, R records the last step again, S skips a section and Esc stops.\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."
}
ColumnLayout {
Kirigami.FormData.label: "Round:"
Controls.RadioButton {
id: fullRound
text: "Full session"
checked: !backend.hasFullSession
}
Controls.RadioButton {
id: quickRound
text: "Quick round (about 3 min): hand size, poses, the dot, no hands"
checked: backend.hasFullSession
}
}
Kirigami.InlineMessage {
Layout.maximumWidth: Kirigami.Units.gridUnit * 26
Layout.fillWidth: true
visible: backend.hasFullSession
Layout.preferredHeight: visible ? implicitHeight : 0
type: Kirigami.MessageType.Information
text: "You've recorded a full session already. A quick round in a different light (dim, "
+ "daylight) adds the most now."
}
Controls.CheckBox {
id: autoAdvance
Kirigami.FormData.label: "Pace:"
text: "Advance by itself (no Next between steps)"
}
Controls.Label {
Layout.maximumWidth: Kirigami.Units.gridUnit * 26
wrapMode: Text.Wrap
opacity: 0.7
text: (autoAdvance.checked
? "Each step shows for a few seconds and the next follows by itself. Quicker if you "
+ "know the steps already. "
: "") + "Length: " + backend.planText(checklist.answers(), autoAdvance.checked, quickRound.checked) + "."
}
Controls.Button {
text: "Start"
icon.name: "media-record"
enabled: checklist.ready && !backend.camerasBlockStart && !backend.restartingSteamVR
onClicked: {
if (backend.startSession(checklist.answers(), lighting.currentValue, autoAdvance.checked,
quickRound.checked))
root.show(sessionPage)
}
}
Controls.Label {
visible: (!checklist.ready || backend.camerasBlockStart) && !backend.sessionActive
opacity: 0.7
text: !checklist.privacyOk ? "Tick the three privacy checks to start."
: lighting.currentIndex < 0 ? "Choose the lighting to start."
: backend.camerasBlockStart ? "The headset's cameras aren't all running: see the message at the top." : ""
}
}
}
}
// ---------------------------------------------------------------- Session
Component {
id: sessionPage
Kirigami.Page {
id: sessionView
title: "Session"
readonly property var st: backend.status
readonly property string state: st.state || ""
readonly property bool waiting: !!st.waiting
readonly property bool stepMode: st.mode !== "auto"
readonly property var stateText: ({
starting: "Starting…", intro: "Get ready", ready: "Get ready", countdown: "Starting",
running: "Recording", paused: "Paused", between: "Between sections", done: "Done",
stopped: "Stopped", error: "Error", nohands: "No hands seen"
})
// The keys while the window has focus (the panel in the headset lists them too).
Shortcut {
sequence: "Space"
enabled: backend.sessionActive
onActivated: backend.nextStep()
}
Shortcut {
sequence: "P"
enabled: backend.sessionActive
onActivated: backend.togglePause()
}
Shortcut {
sequence: "R"
enabled: backend.sessionActive
onActivated: backend.redo()
}
Shortcut {
sequence: "S"
enabled: backend.sessionActive
onActivated: backend.skipSection()
}
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
}
}
// The pose picture as the panel shows it: a right hand as drawn, a left hand flipped,
// both hands as a flipped copy beside it (DESIGN.md "The pose pictures").
component PosePicture: Row {
property string path
property string mode
property real side: Kirigami.Units.gridUnit * 9
spacing: Kirigami.Units.smallSpacing
visible: path !== ""
Image {
visible: parent.mode === "both"
width: visible ? parent.side * 0.75 : 0
height: width
source: parent.path ? "file://" + parent.path : ""
fillMode: Image.PreserveAspectFit
mirror: true
smooth: true
mipmap: true
}
Image {
width: parent.mode === "both" ? parent.side * 0.75 : parent.side
height: width
source: parent.path ? "file://" + parent.path : ""
fillMode: Image.PreserveAspectFit
mirror: parent.mode === "mirror"
smooth: true
mipmap: true
}
}
// Where to hold the hands: a front view (left, centre, right, up, down; the push
// sections' chest, desk and eye) and how far out (near, mid, far), as on the panel.
component WhereDiagram: RowLayout {
id: where
property string position
property string distance
readonly property var cell: ({ centre: [1, 1], center: [1, 1], chest: [1, 1], left: [0, 1],
right: [2, 1], up: [1, 0], eye: [1, 0], down: [1, 2],
desk: [1, 2] })[position] || null
readonly property int step: ["near", "mid", "far"].indexOf(distance)
readonly property real unit: Kirigami.Units.gridUnit * 1.1
readonly property color accent: "#4cd9ff"
spacing: Kirigami.Units.gridUnit
visible: cell !== null || step >= 0
ColumnLayout {
visible: where.cell !== null
Grid {
Layout.alignment: Qt.AlignHCenter
columns: 3
spacing: 2
Repeater {
model: 9
Rectangle {
required property int index
width: where.unit * 1.33
height: where.unit
radius: 3
readonly property bool on: where.cell !== null && index === where.cell[1] * 3 + where.cell[0]
color: on ? where.accent
: Qt.rgba(Kirigami.Theme.textColor.r, Kirigami.Theme.textColor.g,
Kirigami.Theme.textColor.b, 0.12)
}
}
}
Controls.Label {
Layout.alignment: Qt.AlignHCenter
font: Kirigami.Theme.smallFont
text: ({ left: "To your left", right: "To your right", up: "Up high", down: "Down low",
chest: "Chest height", desk: "Desk height", eye: "Eye level" })[where.position]
|| "In front"
}
}
ColumnLayout {
visible: where.step >= 0
Row {
Layout.alignment: Qt.AlignHCenter
spacing: where.unit * 0.6
height: where.unit * 1.2
// the head, seen from the side, then the arm's reach
Rectangle {
width: where.unit
height: width
radius: width / 2
anchors.verticalCenter: parent.verticalCenter
color: Qt.rgba(Kirigami.Theme.textColor.r, Kirigami.Theme.textColor.g,
Kirigami.Theme.textColor.b, 0.6)
}
Repeater {
model: 3
Rectangle {
required property int index
readonly property bool on: index === where.step
width: on ? where.unit * 0.8 : where.unit * 0.35
height: width
radius: width / 2
anchors.verticalCenter: parent.verticalCenter
color: on ? where.accent : Qt.rgba(Kirigami.Theme.textColor.r, Kirigami.Theme.textColor.g,
Kirigami.Theme.textColor.b, 0.35)
}
}
}
Controls.Label {
Layout.alignment: Qt.AlignHCenter
font: Kirigami.Theme.smallFont
text: ["Close: a hand's length", "Halfway out", "Arm stretched out"][Math.max(0, where.step)]
}
}
}
header: ColumnLayout {
spacing: 0
Kirigami.InlineMessage {
Layout.fillWidth: true
visible: backend.runnerError !== "" || (sessionView.state === "error" && sessionView.st.error !== backend.cameraText)
position: Kirigami.InlineMessage.Position.Header
type: Kirigami.MessageType.Error
text: backend.runnerError || ("The session stopped with an error: " + (sessionView.st.error || "unknown"))
}
// The camera check stopped the session (at its start, or after a step with no hands).
CameraMessage {
Layout.fillWidth: true
visible: (!backend.sessionActive && sessionView.state !== "" && backend.cameraText !== "")
|| backend.restartingSteamVR
}
}
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
RowLayout {
Layout.fillWidth: true
Kirigami.Heading {
level: 1
text: sessionView.stateText[sessionView.state] || sessionView.state
}
Item { Layout.fillWidth: true }
Controls.Label {
visible: !!sessionView.st.take && sessionView.state !== "paused"
&& (sessionView.state === "countdown" || sessionView.state === "running"
|| !sessionView.stepMode)
text: "● Recording"
color: Kirigami.Theme.negativeTextColor
}
}
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 : "")
+ ((sessionView.st.step_index || 0) > 0 && sessionView.stepMode
? " · step " + sessionView.st.step_index + " of " + sessionView.st.step_count : "")
font.bold: true
}
RowLayout {
Layout.fillWidth: true
spacing: Kirigami.Units.gridUnit * 1.5
ColumnLayout {
visible: !!sessionView.st.image || !!sessionView.st.position || !!sessionView.st.distance
spacing: Kirigami.Units.largeSpacing
PosePicture {
Layout.alignment: Qt.AlignHCenter
path: sessionView.st.image || ""
mode: sessionView.st.image_mode || ""
}
Controls.Label {
Layout.alignment: Qt.AlignHCenter
Layout.maximumWidth: Kirigami.Units.gridUnit * 14
visible: !!sessionView.st.image && !!sessionView.st.caption
wrapMode: Text.Wrap
horizontalAlignment: Text.AlignHCenter
opacity: 0.7
font: Kirigami.Theme.smallFont
text: sessionView.st.caption || ""
}
WhereDiagram {
id: where
Layout.alignment: Qt.AlignHCenter
position: sessionView.st.position || ""
distance: sessionView.st.distance || ""
}
}
ColumnLayout {
Layout.fillWidth: true
spacing: Kirigami.Units.largeSpacing
Controls.Label {
Layout.fillWidth: true
visible: !!sessionView.st.prompt
wrapMode: Text.Wrap
font.pointSize: Kirigami.Theme.defaultFont.pointSize * 1.4
text: sessionView.st.prompt || ""
}
// A sweep's row of pictures, the lit one framed, as on the panel
Row {
visible: (sessionView.st.strip || []).length > 0
spacing: Kirigami.Units.largeSpacing
Repeater {
model: sessionView.st.strip || []
Column {
required property var modelData
required property int index
readonly property bool lit: index === sessionView.st.cue
spacing: Kirigami.Units.smallSpacing
Rectangle {
width: Kirigami.Units.gridUnit * 5.5
height: width
radius: Kirigami.Units.smallSpacing * 2
color: Qt.rgba(Kirigami.Theme.textColor.r, Kirigami.Theme.textColor.g,
Kirigami.Theme.textColor.b, 0.06)
border.width: parent.lit ? 3 : 0
border.color: "#4cd9ff"
Image {
anchors.fill: parent
anchors.margins: 4
source: modelData.image ? "file://" + modelData.image : ""
fillMode: Image.PreserveAspectFit
mirror: modelData.mode === "mirror"
opacity: parent.parent.lit ? 1 : 0.4
smooth: true
mipmap: true
}
}
Controls.Label {
anchors.horizontalCenter: parent.horizontalCenter
text: modelData.label || ""
color: parent.lit ? "#4cd9ff" : Kirigami.Theme.textColor
opacity: parent.lit ? 1 : 0.6
}
}
}
}
// The countdown's 3, 2, 1, then the hold's word, big
Controls.Label {
visible: !!sessionView.st.big && sessionView.state !== "paused"
text: sessionView.st.big || ""
font.pointSize: Kirigami.Theme.defaultFont.pointSize * 4
font.bold: true
color: Kirigami.Theme.highlightColor
}
Controls.Label {
visible: !sessionView.waiting && sessionView.state !== "countdown"
&& sessionView.state !== "ready" && 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
}
Controls.Button {
visible: sessionView.waiting && sessionView.state !== "paused"
focusPolicy: Qt.NoFocus
text: sessionView.state === "nohands" ? "Try again" : "Next"
icon.name: sessionView.state === "nohands" ? "edit-undo" : "go-next"
font.pointSize: Kirigami.Theme.defaultFont.pointSize * 1.5
Layout.preferredWidth: Kirigami.Units.gridUnit * 10
Layout.preferredHeight: Kirigami.Units.gridUnit * 3
onClicked: backend.nextStep()
}
Controls.Label {
visible: sessionView.waiting && sessionView.state !== "paused"
opacity: 0.7
text: sessionView.state === "nohands"
? "Try again starts the same step (Space or the headset button do too). Stop ends the session, "
+ "keeping what's recorded."
: (sessionView.st.ready_text || "Ready? Press Space or click Next")
+ ". A 3-2-1 countdown starts the recording."
wrapMode: Text.Wrap
Layout.fillWidth: true
}
}
}
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: "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. "
+ (sessionView.st.button ? "The button on the right side of the headset: "
+ (sessionView.stepMode ? "next, " : "") + "pause or resume. " : "")
+ "While this window has focus: "
+ (sessionView.stepMode ? "Space: next · " : "")
+ "P: pause or resume · R: record the last step again · S: skip section · Esc: stop. "
+ "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
enabled: !!sessionView.st.can_redo
focusPolicy: Qt.NoFocus
text: "Redo step"
icon.name: "edit-undo"
onClicked: backend.redo()
}
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"
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 until you press Upload on the Upload page."
}
}
}
// ---------------------------------------------------------------- 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 || "" : ""
readonly property var chosen: exportBox.currentIndex >= 0 && exportBox.currentIndex < exported.length
? exported[exportBox.currentIndex] : null
readonly property var up: backend.upload
readonly property var upError: up.error || ({})
readonly property var upResult: up.result || ({})
// The upload shown is this export's (the window keeps the last one's result).
readonly property bool mine: up.session === session
property var info: ({ previous: {}, uploads: 0 })
readonly property bool loggedIn: backend.login.state === "ok"
// Why Upload is off, or "" when it can go.
readonly property string blocked: {
if (session === "") return "Choose an export"
if (chosen && chosen.export_stale) return "Export the session again first"
if (backend.exporting) return "Wait for the export to finish"
if (backend.uploading) return ""
if (backend.hubDryRun) return ""
if (!backend.uploadAllowed) return "Contributions aren't open yet"
if (!loggedIn) return "Log in to Hugging Face first (below)"
return ""
}
function refreshInfo() { info = backend.uploadInfo(session) }
onSessionChanged: refreshInfo()
Connections {
target: backend
function onSessionsChanged() { uploadView.refreshInfo() }
}
Component.onCompleted: {
refreshInfo()
if (backend.login.state === "unknown") backend.checkLogin()
}
function startUpload() {
if (info.previous && info.previous.export_sha)
confirm.ask("Upload this export again?",
"It was uploaded on " + info.previous.uploaded + " (" + (info.previous.pr_url || "no link")
+ "). Uploading it again opens a second pull request.",
() => backend.startUpload(uploadView.session, true))
else
backend.startUpload(session, false)
}
header: ColumnLayout {
spacing: 0
DraftBanner { Layout.fillWidth: true }
Kirigami.InlineMessage {
Layout.fillWidth: true
visible: backend.hubDryRun
position: Kirigami.InlineMessage.Position.Header
type: Kirigami.MessageType.Information
text: "Dry run (--hub-dry-run): Upload checks the export and lists what it would send. "
+ "Nothing goes over the network."
}
Kirigami.InlineMessage {
Layout.fillWidth: true
visible: backend.textsDraft && backend.allowUploadSet && !backend.hubDryRun
position: Kirigami.InlineMessage.Position.Header
type: Kirigami.MessageType.Warning
text: "Rehearsal: FT_HANDREC_ALLOW_UPLOAD=1 allows uploads to " + backend.dataset
+ " while the texts are drafts."
}
}
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. It can be uploaded from here."
helpfulAction: Kirigami.Action {
text: "Export"; icon.name: "document-export"
onTriggered: root.show(exportPage)
}
}
Kirigami.FormLayout {
Layout.fillWidth: true
visible: uploadView.exported.length > 0
Controls.ComboBox {
id: exportBox
Kirigami.FormData.label: "Export:"
model: uploadView.exported
textRole: "label"
valueRole: "id"
enabled: !backend.uploading
Component.onCompleted: currentIndex = Math.max(0, indexOfValue(root.chosenSession))
onActivated: root.chosenSession = currentValue
}
Controls.Label {
Kirigami.FormData.label: "Size:"
text: uploadView.chosen ? uploadView.chosen.exportText : ""
}
Controls.Label {
Kirigami.FormData.label: "Goes to:"
textFormat: Text.StyledText
text: "<a href=\"" + backend.datasetUrl + "\">" + backend.dataset + "</a>, as a pull request"
onLinkActivated: link => Qt.openUrlExternally(link)
}
RowLayout {
Kirigami.FormData.label: "Hugging Face:"
Controls.Label {
Layout.maximumWidth: Kirigami.Units.gridUnit * 22
wrapMode: Text.Wrap
text: backend.login.state === "dry" ? "not checked in a dry run"
: backend.login.state === "unknown" ? "not checked yet"
: backend.login.state === "none" ? "not logged in"
: backend.login.text || ""
}
Controls.Button {
visible: backend.login.state !== "dry"
text: "Check again"
icon.name: "view-refresh"
enabled: backend.login.state !== "checking"
onClicked: backend.checkLogin()
}
}
}
Kirigami.InlineMessage {
Layout.fillWidth: true
visible: uploadView.chosen !== null && uploadView.chosen.export_stale
type: Kirigami.MessageType.Warning
text: "This session changed since it was exported. Export it again so the upload has your latest deletions."
}
// Not logged in: how to log in, in a terminal. The token never goes into this window.
Kirigami.InlineMessage {
Layout.fillWidth: true
visible: uploadView.session !== "" && ["none", "read", "error", "missing"].indexOf(backend.login.state) >= 0
type: backend.login.state === "error" ? Kirigami.MessageType.Error : Kirigami.MessageType.Warning
text: backend.login.state === "none"
? "You're not logged in to Hugging Face. Open a terminal (Konsole) and run the command below. "
+ "Paste a token with the Write role when it asks (steps 1 to 3 below say how to make one). "
+ "The token goes only into that terminal, never into this window. Then press Check again."
: backend.login.text || ""
actions: backend.login.link ? [linkAction] : []
Kirigami.Action {
id: linkAction
text: "Open"
icon.name: "internet-services"
onTriggered: Qt.openUrlExternally(backend.login.link)
}
}
RowLayout {
Layout.fillWidth: true
visible: uploadView.session !== "" && ["none", "read"].indexOf(backend.login.state) >= 0
Controls.TextField {
id: loginCommand
Layout.fillWidth: true
readOnly: true
font.family: "monospace"
text: backend.loginCommand + (backend.login.state === "read" ? " --force" : "")
}
Controls.Button {
text: "Copy command"
icon.name: "edit-copy"
onClicked: backend.copy(loginCommand.text)
}
}
Kirigami.InlineMessage {
Layout.fillWidth: true
visible: uploadView.session !== "" && uploadView.info.previous.export_sha !== undefined
type: Kirigami.MessageType.Information
text: "This export was uploaded on " + (uploadView.info.previous.uploaded || "") + ": "
+ (uploadView.info.previous.pr_url || "") + ". There's no need to upload it again."
}
RowLayout {
visible: uploadView.session !== ""
Controls.Button {
text: backend.hubDryRun ? "Upload (dry run)" : "Upload"
icon.name: "cloud-upload"
enabled: uploadView.blocked === "" && !backend.uploading
onClicked: uploadView.startUpload()
}
Controls.Button {
visible: backend.uploading
text: "Cancel"
icon.name: "dialog-cancel"
onClicked: backend.cancelUpload()
}
Controls.Label {
visible: !backend.uploading && uploadView.blocked !== ""
opacity: 0.7
text: uploadView.blocked
}
}
// Progress: a share while the export is checked, a sweeping bar while it's sent
// (huggingface_hub doesn't report progress). Drawn here, as on the Export page.
Rectangle {
id: track
Layout.preferredWidth: Kirigami.Units.gridUnit * 20
implicitHeight: Kirigami.Units.smallSpacing * 2
visible: backend.uploading
radius: height / 2
clip: true
color: Qt.rgba(Kirigami.Theme.textColor.r, Kirigami.Theme.textColor.g, Kirigami.Theme.textColor.b, 0.15)
readonly property bool unknown: uploadView.up.fraction === undefined || uploadView.up.fraction < 0
Rectangle {
id: fill
height: parent.height
radius: parent.radius
color: Kirigami.Theme.highlightColor
width: track.unknown ? parent.width / 4 : parent.width * Math.max(0, Math.min(1, uploadView.up.fraction))
x: 0
SequentialAnimation on x {
running: track.visible && track.unknown
loops: Animation.Infinite
onRunningChanged: if (!running) fill.x = 0
NumberAnimation { from: -fill.width; to: track.width; duration: 1600 }
}
}
}
Controls.Label {
Layout.fillWidth: true
visible: uploadView.mine && (uploadView.up.text || "") !== ""
&& (backend.uploading || uploadView.up.phase === "failed" && uploadView.upError.kind === "cancelled")
wrapMode: Text.Wrap
text: uploadView.up.text || ""
}
Kirigami.InlineMessage {
Layout.fillWidth: true
visible: !backend.uploading && uploadView.mine && uploadView.up.phase === "done"
type: Kirigami.MessageType.Positive
text: uploadView.upResult.dry_run
? "Dry run: the export passed its checks. It would go to " + uploadView.upResult.repo + "/"
+ uploadView.upResult.path_in_repo + " (" + uploadView.upResult.files + " files). Nothing was sent."
: "Uploaded. Your pull request: " + (uploadView.upResult.pr_url || "")
+ ". The maintainer reviews it before it joins the dataset."
actions: uploadView.upResult.pr_url ? [openPr, copyPr] : []
Kirigami.Action {
id: openPr
text: "Open"
icon.name: "internet-services"
onTriggered: Qt.openUrlExternally(uploadView.upResult.pr_url)
}
Kirigami.Action {
id: copyPr
text: "Copy link"
icon.name: "edit-copy"
onTriggered: backend.copy(uploadView.upResult.pr_url)
}
}
Kirigami.InlineMessage {
Layout.fillWidth: true
visible: !backend.uploading && uploadView.mine && uploadView.up.phase === "failed"
&& uploadView.upError.kind !== "cancelled"
type: Kirigami.MessageType.Error
text: (uploadView.upError.text || "")
+ ((uploadView.upError.errors || []).length ? "\n\n• " + uploadView.upError.errors.join("\n• ") : "")
actions: uploadView.upError.link ? [openErrorLink] : []
Kirigami.Action {
id: openErrorLink
text: "Open"
icon.name: "internet-services"
onTriggered: Qt.openUrlExternally(uploadView.upError.link)
}
}
Controls.TextArea {
Layout.fillWidth: true
visible: uploadView.mine && (uploadView.up.log || "") !== ""
readOnly: true
selectByMouse: true
wrapMode: Text.WrapAnywhere
font.family: "monospace"
text: uploadView.up.log || ""
}
Kirigami.Separator {
Layout.fillWidth: true
visible: uploadView.session !== ""
}
Kirigami.Heading {
level: 3
visible: uploadView.session !== ""
text: "Or upload from a terminal"
}
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)
}
}
}
}
}